yatt-md 0.1.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 +172 -0
- package/SPEC.md +368 -0
- package/dist/cli/serve.js +3187 -0
- package/dist/index.d.ts +13 -0
- package/dist/index.js +21 -0
- package/dist/lexer.d.ts +12 -0
- package/dist/lexer.js +100 -0
- package/dist/parser.d.ts +5 -0
- package/dist/parser.js +473 -0
- package/dist/renderer/gantt-svg.d.ts +10 -0
- package/dist/renderer/gantt-svg.js +489 -0
- package/dist/renderer/list-html.d.ts +7 -0
- package/dist/renderer/list-html.js +204 -0
- package/dist/scheduler.d.ts +2 -0
- package/dist/scheduler.js +284 -0
- package/dist/types.d.ts +92 -0
- package/dist/types.js +1 -0
- package/dist/validator.d.ts +2 -0
- package/dist/validator.js +121 -0
- package/package.json +51 -0
|
@@ -0,0 +1,3187 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// cli/serve.ts
|
|
4
|
+
import * as http from "node:http";
|
|
5
|
+
import * as fs from "node:fs";
|
|
6
|
+
import * as path from "node:path";
|
|
7
|
+
import { execFile } from "node:child_process";
|
|
8
|
+
|
|
9
|
+
// src/lexer.ts
|
|
10
|
+
var HEADER_KEYS = /* @__PURE__ */ new Set([
|
|
11
|
+
"title",
|
|
12
|
+
"owner",
|
|
13
|
+
"start",
|
|
14
|
+
"schedule",
|
|
15
|
+
"timezone",
|
|
16
|
+
"week-start"
|
|
17
|
+
]);
|
|
18
|
+
function tokenize(source) {
|
|
19
|
+
const lines = source.split(/\r?\n/);
|
|
20
|
+
const tokens = [];
|
|
21
|
+
let headerMode = true;
|
|
22
|
+
for (let i = 0; i < lines.length; i++) {
|
|
23
|
+
const lineNum = i + 1;
|
|
24
|
+
const raw = lines[i];
|
|
25
|
+
const trimmed = raw.trim();
|
|
26
|
+
if (trimmed === "") {
|
|
27
|
+
tokens.push({ type: "blank", raw, line: lineNum });
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
if (trimmed.startsWith("//")) {
|
|
31
|
+
tokens.push({ type: "comment", raw, line: lineNum, content: trimmed.slice(2).trim() });
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
if (trimmed.startsWith("##")) {
|
|
35
|
+
headerMode = false;
|
|
36
|
+
tokens.push({ type: "section", raw, line: lineNum, level: 2, content: trimmed.slice(2).trim() });
|
|
37
|
+
continue;
|
|
38
|
+
}
|
|
39
|
+
if (trimmed.startsWith("#")) {
|
|
40
|
+
headerMode = false;
|
|
41
|
+
tokens.push({ type: "section", raw, line: lineNum, level: 1, content: trimmed.slice(1).trim() });
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (/^parallel\s*:/i.test(trimmed)) {
|
|
45
|
+
headerMode = false;
|
|
46
|
+
const rest = trimmed.replace(/^parallel\s*:\s*/i, "");
|
|
47
|
+
tokens.push({ type: "parallel-open", raw, line: lineNum, content: rest });
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (/^end\s*:/i.test(trimmed)) {
|
|
51
|
+
headerMode = false;
|
|
52
|
+
const rest = trimmed.replace(/^end\s*:\s*/i, "");
|
|
53
|
+
tokens.push({ type: "parallel-close", raw, line: lineNum, content: rest });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (trimmed.startsWith(">>")) {
|
|
57
|
+
headerMode = false;
|
|
58
|
+
tokens.push({ type: "milestone", raw, line: lineNum, content: trimmed.slice(2).trim() });
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (trimmed.startsWith("...")) {
|
|
62
|
+
headerMode = false;
|
|
63
|
+
tokens.push({ type: "subtask", raw, line: lineNum, depth: 3, content: trimmed.slice(3).trim() });
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (trimmed.startsWith("..") && !trimmed.startsWith("...")) {
|
|
67
|
+
headerMode = false;
|
|
68
|
+
tokens.push({ type: "subtask", raw, line: lineNum, depth: 2, content: trimmed.slice(2).trim() });
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
if (trimmed.startsWith(".") && !trimmed.startsWith("..")) {
|
|
72
|
+
headerMode = false;
|
|
73
|
+
tokens.push({ type: "subtask", raw, line: lineNum, depth: 1, content: trimmed.slice(1).trim() });
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
if (trimmed.startsWith("[")) {
|
|
77
|
+
headerMode = false;
|
|
78
|
+
tokens.push({ type: "task", raw, line: lineNum, content: trimmed });
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (headerMode) {
|
|
82
|
+
const headerMatch = trimmed.match(/^([\w-]+)\s*:\s*(.*)$/);
|
|
83
|
+
if (headerMatch && HEADER_KEYS.has(headerMatch[1].toLowerCase())) {
|
|
84
|
+
tokens.push({
|
|
85
|
+
type: "header-field",
|
|
86
|
+
raw,
|
|
87
|
+
line: lineNum,
|
|
88
|
+
key: headerMatch[1].toLowerCase(),
|
|
89
|
+
value: headerMatch[2].trim()
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
headerMode = false;
|
|
95
|
+
let plainContent = trimmed;
|
|
96
|
+
const listMarkerMatch = trimmed.match(/^(?:[-*]\s+|\d+[.)]\s+)/);
|
|
97
|
+
if (listMarkerMatch) {
|
|
98
|
+
plainContent = trimmed.slice(listMarkerMatch[0].length);
|
|
99
|
+
}
|
|
100
|
+
tokens.push({ type: "task", raw, line: lineNum, content: plainContent });
|
|
101
|
+
}
|
|
102
|
+
return tokens;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// src/parser.ts
|
|
106
|
+
var SIGIL_TO_STATUS = {
|
|
107
|
+
" ": "new",
|
|
108
|
+
"~": "active",
|
|
109
|
+
"x": "done",
|
|
110
|
+
"!": "blocked",
|
|
111
|
+
"?": "at-risk",
|
|
112
|
+
">": "deferred",
|
|
113
|
+
"_": "cancelled",
|
|
114
|
+
"-": "cancelled",
|
|
115
|
+
"=": "review",
|
|
116
|
+
"o": "paused"
|
|
117
|
+
};
|
|
118
|
+
var WORD_TO_STATUS = {
|
|
119
|
+
new: "new",
|
|
120
|
+
active: "active",
|
|
121
|
+
done: "done",
|
|
122
|
+
blocked: "blocked",
|
|
123
|
+
"at-risk": "at-risk",
|
|
124
|
+
deferred: "deferred",
|
|
125
|
+
cancelled: "cancelled",
|
|
126
|
+
review: "review",
|
|
127
|
+
paused: "paused"
|
|
128
|
+
};
|
|
129
|
+
var RE_DURATION = /^(\d+(?:\.\d+)?)(h|bd|d|w|m|q)$/;
|
|
130
|
+
var RE_PRIORITY = /^!(high|critical|low|normal)$/;
|
|
131
|
+
var RE_PROGRESS = /^%(\d+)$/;
|
|
132
|
+
var RE_ID = /^id:([\w-]+)$/;
|
|
133
|
+
var RE_HASH_ID = /^#([\w-]+)$/;
|
|
134
|
+
var RE_AFTER = /^after:([\w|,-]+)$/;
|
|
135
|
+
var RE_MODIFIER = /^\+[\w-]+(:\S+)?$/;
|
|
136
|
+
var RE_START_DATE = /^>(\d{4}-\d{2}-\d{2})$/;
|
|
137
|
+
var RE_DUE_DATE = /^<(\d{4}-\d{2}-\d{2})$/;
|
|
138
|
+
var RE_RECURRENCE = /^\*(daily|weekday|weekly|biweekly|monthly|quarterly|yearly)$/;
|
|
139
|
+
var RE_EXTERNAL_REF = /^\$[\w-]+$/;
|
|
140
|
+
var MODIFIER_KEYWORDS = /* @__PURE__ */ new Set([
|
|
141
|
+
"deadline",
|
|
142
|
+
"fixed",
|
|
143
|
+
"external",
|
|
144
|
+
"waiting",
|
|
145
|
+
"at-risk",
|
|
146
|
+
"blocked",
|
|
147
|
+
"critical",
|
|
148
|
+
"tentative",
|
|
149
|
+
"recurring",
|
|
150
|
+
"milestone",
|
|
151
|
+
"delayed",
|
|
152
|
+
"hard-block"
|
|
153
|
+
]);
|
|
154
|
+
function parseStatus(content) {
|
|
155
|
+
const wordMatch = content.match(/^\[([\w-]+)\]\s*/);
|
|
156
|
+
if (wordMatch) {
|
|
157
|
+
const word = wordMatch[1].toLowerCase();
|
|
158
|
+
if (WORD_TO_STATUS[word]) {
|
|
159
|
+
return { status: WORD_TO_STATUS[word], rest: content.slice(wordMatch[0].length) };
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
const sigilMatch = content.match(/^\[(.)\]\s*/);
|
|
163
|
+
if (sigilMatch) {
|
|
164
|
+
const sigil = sigilMatch[1];
|
|
165
|
+
if (SIGIL_TO_STATUS[sigil] !== void 0) {
|
|
166
|
+
return { status: SIGIL_TO_STATUS[sigil], rest: content.slice(sigilMatch[0].length) };
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
function parseFields(segments) {
|
|
172
|
+
const result = { assignees: [], tags: [], after: [], modifiers: [] };
|
|
173
|
+
for (const seg of segments) {
|
|
174
|
+
const s = seg.trim();
|
|
175
|
+
if (!s) continue;
|
|
176
|
+
const durMatch = s.match(RE_DURATION);
|
|
177
|
+
if (durMatch && !result.duration) {
|
|
178
|
+
result.duration = { value: parseFloat(durMatch[1]), unit: durMatch[2] };
|
|
179
|
+
continue;
|
|
180
|
+
}
|
|
181
|
+
const startMatch = s.match(RE_START_DATE);
|
|
182
|
+
if (startMatch) {
|
|
183
|
+
result.startDate = startMatch[1];
|
|
184
|
+
continue;
|
|
185
|
+
}
|
|
186
|
+
const dueMatch = s.match(RE_DUE_DATE);
|
|
187
|
+
if (dueMatch) {
|
|
188
|
+
result.dueDate = dueMatch[1];
|
|
189
|
+
continue;
|
|
190
|
+
}
|
|
191
|
+
const idMatch = s.match(RE_ID);
|
|
192
|
+
if (idMatch) {
|
|
193
|
+
result.id = idMatch[1];
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
const afterMatch = s.match(RE_AFTER);
|
|
197
|
+
if (afterMatch) {
|
|
198
|
+
const raw = afterMatch[1];
|
|
199
|
+
if (raw.includes("|")) {
|
|
200
|
+
result.after.push({ ids: raw.split("|").filter(Boolean), logic: "or" });
|
|
201
|
+
} else {
|
|
202
|
+
result.after.push({ ids: raw.split(",").filter(Boolean), logic: "and" });
|
|
203
|
+
}
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
const prioMatch = s.match(RE_PRIORITY);
|
|
207
|
+
if (prioMatch) {
|
|
208
|
+
result.priority = prioMatch[1];
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
const progressMatch = s.match(RE_PROGRESS);
|
|
212
|
+
if (progressMatch) {
|
|
213
|
+
result.progress = Math.min(100, Math.max(0, parseInt(progressMatch[1], 10)));
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const recurMatch = s.match(RE_RECURRENCE);
|
|
217
|
+
if (recurMatch) {
|
|
218
|
+
result.recurrence = recurMatch[1];
|
|
219
|
+
continue;
|
|
220
|
+
}
|
|
221
|
+
const shiftMatch = s.match(/^(delayed|blocked)\s+(\S+)$/);
|
|
222
|
+
if (shiftMatch) {
|
|
223
|
+
result.modifiers.push(shiftMatch[1] + ":" + shiftMatch[2]);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
if (RE_MODIFIER.test(s)) {
|
|
227
|
+
result.modifiers.push(s.slice(1));
|
|
228
|
+
continue;
|
|
229
|
+
}
|
|
230
|
+
const bareModMatch = s.match(/^([\w-]+)(:\S+)?$/);
|
|
231
|
+
if (bareModMatch && MODIFIER_KEYWORDS.has(bareModMatch[1])) {
|
|
232
|
+
result.modifiers.push(s);
|
|
233
|
+
continue;
|
|
234
|
+
}
|
|
235
|
+
if (RE_EXTERNAL_REF.test(s)) {
|
|
236
|
+
result.externalRef = s;
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
const assigneeMatches = s.match(/(@[\w-]+)/g);
|
|
240
|
+
if (assigneeMatches && s.replace(/(@[\w-]+)/g, "").trim() === "") {
|
|
241
|
+
result.assignees.push(...assigneeMatches.map((a) => a.slice(1)));
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
const hashIdMatch = s.match(RE_HASH_ID);
|
|
245
|
+
if (hashIdMatch) {
|
|
246
|
+
result.id = hashIdMatch[1];
|
|
247
|
+
continue;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return result;
|
|
251
|
+
}
|
|
252
|
+
function splitPipeFields(content) {
|
|
253
|
+
const raw = content.split("|");
|
|
254
|
+
const merged = [];
|
|
255
|
+
for (let i = 0; i < raw.length; i++) {
|
|
256
|
+
const seg = raw[i].trim();
|
|
257
|
+
if (merged.length > 0 && /^after:/.test(merged[merged.length - 1].trim()) && /^[\w-]+$/.test(seg)) {
|
|
258
|
+
merged[merged.length - 1] += "|" + seg;
|
|
259
|
+
} else {
|
|
260
|
+
merged.push(raw[i]);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return merged;
|
|
264
|
+
}
|
|
265
|
+
function parseNameAndFields(content) {
|
|
266
|
+
const parts = splitPipeFields(content);
|
|
267
|
+
const name = (parts[0] ?? "").trim();
|
|
268
|
+
const fields = parseFields(parts.slice(1));
|
|
269
|
+
return { name, fields };
|
|
270
|
+
}
|
|
271
|
+
function buildTask(content, line2, depth = 0) {
|
|
272
|
+
const statusResult = parseStatus(content);
|
|
273
|
+
const status = statusResult?.status ?? "new";
|
|
274
|
+
const rest = statusResult?.rest ?? content;
|
|
275
|
+
const { name, fields } = parseNameAndFields(rest);
|
|
276
|
+
const task = {
|
|
277
|
+
type: "task",
|
|
278
|
+
status,
|
|
279
|
+
name,
|
|
280
|
+
assignees: fields.assignees,
|
|
281
|
+
tags: fields.tags,
|
|
282
|
+
after: fields.after,
|
|
283
|
+
modifiers: fields.modifiers,
|
|
284
|
+
subtasks: [],
|
|
285
|
+
line: line2
|
|
286
|
+
};
|
|
287
|
+
if (fields.id) task.id = fields.id;
|
|
288
|
+
if (fields.priority) task.priority = fields.priority;
|
|
289
|
+
if (fields.progress !== void 0) task.progress = fields.progress;
|
|
290
|
+
if (fields.duration) task.duration = fields.duration;
|
|
291
|
+
if (fields.startDate) task.startDate = fields.startDate;
|
|
292
|
+
if (fields.dueDate) task.dueDate = fields.dueDate;
|
|
293
|
+
if (fields.recurrence) task.recurrence = fields.recurrence;
|
|
294
|
+
if (fields.externalRef) task.externalRef = fields.externalRef;
|
|
295
|
+
return task;
|
|
296
|
+
}
|
|
297
|
+
function buildMilestone(content, line2) {
|
|
298
|
+
const { name, fields } = parseNameAndFields(content);
|
|
299
|
+
const ms = {
|
|
300
|
+
type: "milestone",
|
|
301
|
+
name,
|
|
302
|
+
after: fields.after,
|
|
303
|
+
modifiers: fields.modifiers,
|
|
304
|
+
line: line2
|
|
305
|
+
};
|
|
306
|
+
if (fields.id) ms.id = fields.id;
|
|
307
|
+
if (fields.startDate) ms.date = fields.startDate;
|
|
308
|
+
if (fields.dueDate) ms.date = fields.dueDate;
|
|
309
|
+
return ms;
|
|
310
|
+
}
|
|
311
|
+
function parse(source) {
|
|
312
|
+
const tokens = tokenize(source);
|
|
313
|
+
const errors = [];
|
|
314
|
+
const header = {};
|
|
315
|
+
const items = [];
|
|
316
|
+
const idMap = /* @__PURE__ */ new Map();
|
|
317
|
+
function registerId(id, item, line2) {
|
|
318
|
+
if (idMap.has(id)) {
|
|
319
|
+
errors.push({ message: `Duplicate ID: "${id}"`, line: line2, severity: "error" });
|
|
320
|
+
} else {
|
|
321
|
+
idMap.set(id, item);
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
let i = 0;
|
|
325
|
+
while (i < tokens.length && tokens[i].type === "header-field") {
|
|
326
|
+
const tok = tokens[i++];
|
|
327
|
+
switch (tok.key) {
|
|
328
|
+
case "title":
|
|
329
|
+
header.title = tok.value;
|
|
330
|
+
break;
|
|
331
|
+
case "owner":
|
|
332
|
+
header.owner = tok.value;
|
|
333
|
+
break;
|
|
334
|
+
case "start":
|
|
335
|
+
header.start = tok.value;
|
|
336
|
+
break;
|
|
337
|
+
case "schedule":
|
|
338
|
+
if (tok.value === "business-days" || tok.value === "calendar-days") {
|
|
339
|
+
header.schedule = tok.value;
|
|
340
|
+
}
|
|
341
|
+
break;
|
|
342
|
+
case "timezone":
|
|
343
|
+
header.timezone = tok.value;
|
|
344
|
+
break;
|
|
345
|
+
case "week-start":
|
|
346
|
+
if (tok.value === "mon" || tok.value === "sun") header.weekStart = tok.value;
|
|
347
|
+
break;
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
const parallelStack = [];
|
|
351
|
+
function currentContainer() {
|
|
352
|
+
if (parallelStack.length > 0) {
|
|
353
|
+
return parallelStack[parallelStack.length - 1];
|
|
354
|
+
}
|
|
355
|
+
return { items };
|
|
356
|
+
}
|
|
357
|
+
const taskStack = [null, null, null, null];
|
|
358
|
+
function resetTaskStack() {
|
|
359
|
+
taskStack[0] = taskStack[1] = taskStack[2] = taskStack[3] = null;
|
|
360
|
+
}
|
|
361
|
+
while (i < tokens.length) {
|
|
362
|
+
const tok = tokens[i++];
|
|
363
|
+
if (tok.type === "blank" || tok.type === "header-field") continue;
|
|
364
|
+
if (tok.type === "comment") {
|
|
365
|
+
const comment = { type: "comment", text: tok.content ?? "", line: tok.line };
|
|
366
|
+
currentContainer().items.push(comment);
|
|
367
|
+
continue;
|
|
368
|
+
}
|
|
369
|
+
if (tok.type === "section") {
|
|
370
|
+
resetTaskStack();
|
|
371
|
+
const section = {
|
|
372
|
+
type: "section",
|
|
373
|
+
title: tok.content ?? "",
|
|
374
|
+
level: tok.level === 2 ? 2 : 1,
|
|
375
|
+
line: tok.line
|
|
376
|
+
};
|
|
377
|
+
currentContainer().items.push(section);
|
|
378
|
+
continue;
|
|
379
|
+
}
|
|
380
|
+
if (tok.type === "parallel-open") {
|
|
381
|
+
resetTaskStack();
|
|
382
|
+
const content = tok.content ?? "";
|
|
383
|
+
const pipeIdx = content.indexOf("|");
|
|
384
|
+
let blockName;
|
|
385
|
+
let blockFields;
|
|
386
|
+
if (pipeIdx !== -1) {
|
|
387
|
+
blockName = content.slice(0, pipeIdx).trim() || void 0;
|
|
388
|
+
blockFields = parseFields(content.slice(pipeIdx + 1).split("|"));
|
|
389
|
+
} else {
|
|
390
|
+
blockName = content.trim() || void 0;
|
|
391
|
+
blockFields = parseFields([]);
|
|
392
|
+
}
|
|
393
|
+
const block = {
|
|
394
|
+
type: "parallel",
|
|
395
|
+
name: blockName,
|
|
396
|
+
id: blockName ?? void 0,
|
|
397
|
+
after: blockFields.after,
|
|
398
|
+
items: [],
|
|
399
|
+
line: tok.line
|
|
400
|
+
};
|
|
401
|
+
if (block.id) registerId(block.id, block, tok.line);
|
|
402
|
+
currentContainer().items.push(block);
|
|
403
|
+
parallelStack.push(block);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
if (tok.type === "parallel-close") {
|
|
407
|
+
resetTaskStack();
|
|
408
|
+
if (parallelStack.length === 0) {
|
|
409
|
+
errors.push({ message: "Unexpected end: without matching parallel:", line: tok.line, severity: "error" });
|
|
410
|
+
} else {
|
|
411
|
+
parallelStack.pop();
|
|
412
|
+
}
|
|
413
|
+
continue;
|
|
414
|
+
}
|
|
415
|
+
if (tok.type === "milestone") {
|
|
416
|
+
resetTaskStack();
|
|
417
|
+
const ms = buildMilestone(tok.content ?? "", tok.line);
|
|
418
|
+
if (ms.id) registerId(ms.id, ms, tok.line);
|
|
419
|
+
const descLines = [];
|
|
420
|
+
while (i < tokens.length && tokens[i].type === "comment") {
|
|
421
|
+
descLines.push(tokens[i].content ?? "");
|
|
422
|
+
i++;
|
|
423
|
+
}
|
|
424
|
+
if (descLines.length > 0) ms.description = descLines.join("\n");
|
|
425
|
+
currentContainer().items.push(ms);
|
|
426
|
+
continue;
|
|
427
|
+
}
|
|
428
|
+
if (tok.type === "task") {
|
|
429
|
+
const task = buildTask(tok.content ?? "", tok.line, 0);
|
|
430
|
+
if (task.id) registerId(task.id, task, tok.line);
|
|
431
|
+
const descLines = [];
|
|
432
|
+
while (i < tokens.length && tokens[i].type === "comment") {
|
|
433
|
+
descLines.push(tokens[i].content ?? "");
|
|
434
|
+
i++;
|
|
435
|
+
}
|
|
436
|
+
if (descLines.length > 0) task.description = descLines.join("\n");
|
|
437
|
+
currentContainer().items.push(task);
|
|
438
|
+
taskStack[0] = task;
|
|
439
|
+
taskStack[1] = taskStack[2] = taskStack[3] = null;
|
|
440
|
+
continue;
|
|
441
|
+
}
|
|
442
|
+
if (tok.type === "subtask") {
|
|
443
|
+
const depth = tok.depth ?? 1;
|
|
444
|
+
const task = buildTask(tok.content ?? "", tok.line, depth);
|
|
445
|
+
if (task.id) registerId(task.id, task, tok.line);
|
|
446
|
+
const descLines = [];
|
|
447
|
+
while (i < tokens.length && tokens[i].type === "comment") {
|
|
448
|
+
descLines.push(tokens[i].content ?? "");
|
|
449
|
+
i++;
|
|
450
|
+
}
|
|
451
|
+
if (descLines.length > 0) task.description = descLines.join("\n");
|
|
452
|
+
taskStack[depth] = task;
|
|
453
|
+
for (let d = depth + 1; d <= 3; d++) taskStack[d] = null;
|
|
454
|
+
let attached = false;
|
|
455
|
+
for (let pd = depth - 1; pd >= 0; pd--) {
|
|
456
|
+
if (taskStack[pd]) {
|
|
457
|
+
taskStack[pd].subtasks.push(task);
|
|
458
|
+
attached = true;
|
|
459
|
+
break;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
if (!attached) {
|
|
463
|
+
currentContainer().items.push(task);
|
|
464
|
+
}
|
|
465
|
+
continue;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
if (parallelStack.length > 0) {
|
|
469
|
+
errors.push({
|
|
470
|
+
message: `Unclosed parallel block: "${parallelStack[parallelStack.length - 1].name ?? "(anonymous)"}"`,
|
|
471
|
+
line: parallelStack[parallelStack.length - 1].line,
|
|
472
|
+
severity: "error"
|
|
473
|
+
});
|
|
474
|
+
}
|
|
475
|
+
const doc = { header, items, idMap };
|
|
476
|
+
function resolveItem(item) {
|
|
477
|
+
for (const dep of item.after) {
|
|
478
|
+
for (const id of dep.ids) {
|
|
479
|
+
if (!idMap.has(id)) {
|
|
480
|
+
errors.push({
|
|
481
|
+
message: `Unknown dependency ID: "${id}"`,
|
|
482
|
+
line: item.line,
|
|
483
|
+
severity: "error"
|
|
484
|
+
});
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
if (item.type === "task") {
|
|
489
|
+
for (const sub of item.subtasks) resolveItem(sub);
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
function resolveBlock(block) {
|
|
493
|
+
for (const dep of block.after) {
|
|
494
|
+
for (const id of dep.ids) {
|
|
495
|
+
if (!idMap.has(id)) {
|
|
496
|
+
errors.push({
|
|
497
|
+
message: `Unknown dependency ID: "${id}" in parallel block`,
|
|
498
|
+
line: block.line,
|
|
499
|
+
severity: "error"
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
for (const it of block.items) {
|
|
505
|
+
if (it.type === "task" || it.type === "milestone") resolveItem(it);
|
|
506
|
+
else if (it.type === "parallel") resolveBlock(it);
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
for (const item of items) {
|
|
510
|
+
if (item.type === "task" || item.type === "milestone") resolveItem(item);
|
|
511
|
+
else if (item.type === "parallel") resolveBlock(item);
|
|
512
|
+
}
|
|
513
|
+
return { doc, errors };
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// src/validator.ts
|
|
517
|
+
function validate(doc) {
|
|
518
|
+
const errors = [];
|
|
519
|
+
const allSchedulable = [];
|
|
520
|
+
function collectItems(items) {
|
|
521
|
+
for (const item of items) {
|
|
522
|
+
if (item.type === "task") {
|
|
523
|
+
allSchedulable.push(item);
|
|
524
|
+
collectItems(item.subtasks);
|
|
525
|
+
} else if (item.type === "milestone") {
|
|
526
|
+
allSchedulable.push(item);
|
|
527
|
+
} else if (item.type === "parallel") {
|
|
528
|
+
collectItems(item.items);
|
|
529
|
+
}
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
collectItems(doc.items);
|
|
533
|
+
for (const item of allSchedulable) {
|
|
534
|
+
for (const dep of item.after) {
|
|
535
|
+
for (const id of dep.ids) {
|
|
536
|
+
if (!doc.idMap.has(id)) {
|
|
537
|
+
errors.push({
|
|
538
|
+
message: `Reference to unknown ID "${id}"`,
|
|
539
|
+
line: item.line,
|
|
540
|
+
severity: "error"
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
for (const item of allSchedulable) {
|
|
547
|
+
if (item.type === "task") {
|
|
548
|
+
if (item.progress === 100 && item.status !== "done") {
|
|
549
|
+
errors.push({
|
|
550
|
+
message: `Task "${item.name}" has 100% progress but status is not "done"`,
|
|
551
|
+
line: item.line,
|
|
552
|
+
severity: "warning"
|
|
553
|
+
});
|
|
554
|
+
}
|
|
555
|
+
if (item.status === "done" && item.progress !== void 0 && item.progress < 100) {
|
|
556
|
+
errors.push({
|
|
557
|
+
message: `Task "${item.name}" has status "done" but progress is ${item.progress}%`,
|
|
558
|
+
line: item.line,
|
|
559
|
+
severity: "warning"
|
|
560
|
+
});
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
for (const item of allSchedulable) {
|
|
565
|
+
if (item.type === "task") {
|
|
566
|
+
if ((item.status === "deferred" || item.status === "cancelled") && item.after.length > 0) {
|
|
567
|
+
errors.push({
|
|
568
|
+
message: `Task "${item.name}" is ${item.status} but has dependency constraints`,
|
|
569
|
+
line: item.line,
|
|
570
|
+
severity: "warning"
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
const inDegree = /* @__PURE__ */ new Map();
|
|
576
|
+
const dependents = /* @__PURE__ */ new Map();
|
|
577
|
+
for (const [id] of doc.idMap) {
|
|
578
|
+
inDegree.set(id, 0);
|
|
579
|
+
dependents.set(id, []);
|
|
580
|
+
}
|
|
581
|
+
function addDepsForItem(item) {
|
|
582
|
+
if (!item.id) return;
|
|
583
|
+
for (const dep of item.after) {
|
|
584
|
+
for (const depId of dep.ids) {
|
|
585
|
+
if (!doc.idMap.has(depId)) continue;
|
|
586
|
+
dependents.get(depId)?.push(item.id);
|
|
587
|
+
inDegree.set(item.id, (inDegree.get(item.id) ?? 0) + 1);
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
for (const item of allSchedulable) {
|
|
592
|
+
addDepsForItem(item);
|
|
593
|
+
}
|
|
594
|
+
const queue = [];
|
|
595
|
+
for (const [id, deg] of inDegree) {
|
|
596
|
+
if (deg === 0) queue.push(id);
|
|
597
|
+
}
|
|
598
|
+
let processed = 0;
|
|
599
|
+
while (queue.length > 0) {
|
|
600
|
+
const current = queue.shift();
|
|
601
|
+
processed++;
|
|
602
|
+
for (const dependent of dependents.get(current) ?? []) {
|
|
603
|
+
const newDeg = (inDegree.get(dependent) ?? 1) - 1;
|
|
604
|
+
inDegree.set(dependent, newDeg);
|
|
605
|
+
if (newDeg === 0) queue.push(dependent);
|
|
606
|
+
}
|
|
607
|
+
}
|
|
608
|
+
if (processed < inDegree.size) {
|
|
609
|
+
const cycleIds = [...inDegree.entries()].filter(([, deg]) => deg > 0).map(([id]) => id);
|
|
610
|
+
errors.push({
|
|
611
|
+
message: `Dependency cycle detected among: ${cycleIds.join(", ")}`,
|
|
612
|
+
line: 0,
|
|
613
|
+
severity: "error"
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
return errors;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
// src/scheduler.ts
|
|
620
|
+
function parseModifierDuration(modifier) {
|
|
621
|
+
const match = modifier.match(/^[\w-]+:(\d+(?:\.\d+)?)(h|bd|d|w|m|q)$/);
|
|
622
|
+
if (!match) return null;
|
|
623
|
+
return { value: parseFloat(match[1]), unit: match[2] };
|
|
624
|
+
}
|
|
625
|
+
function addDays(date, days) {
|
|
626
|
+
const d = new Date(date);
|
|
627
|
+
d.setUTCDate(d.getUTCDate() + Math.round(days));
|
|
628
|
+
return d;
|
|
629
|
+
}
|
|
630
|
+
function isWeekend(date, weekStart = "mon") {
|
|
631
|
+
const dow = date.getUTCDay();
|
|
632
|
+
if (weekStart === "sun") {
|
|
633
|
+
return dow === 5 || dow === 6;
|
|
634
|
+
}
|
|
635
|
+
return dow === 0 || dow === 6;
|
|
636
|
+
}
|
|
637
|
+
function addBusinessDays(date, days, weekStart = "mon") {
|
|
638
|
+
let d = new Date(date);
|
|
639
|
+
let remaining = Math.round(days);
|
|
640
|
+
const sign = remaining >= 0 ? 1 : -1;
|
|
641
|
+
remaining = Math.abs(remaining);
|
|
642
|
+
while (remaining > 0) {
|
|
643
|
+
d = addDays(d, sign);
|
|
644
|
+
if (!isWeekend(d, weekStart)) {
|
|
645
|
+
remaining--;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return d;
|
|
649
|
+
}
|
|
650
|
+
function addDuration(start, duration, useBusinessDays, weekStart = "mon") {
|
|
651
|
+
const { value, unit } = duration;
|
|
652
|
+
switch (unit) {
|
|
653
|
+
case "h": {
|
|
654
|
+
const ms = value * 60 * 60 * 1e3;
|
|
655
|
+
return new Date(start.getTime() + ms);
|
|
656
|
+
}
|
|
657
|
+
case "d":
|
|
658
|
+
return addDays(start, value);
|
|
659
|
+
case "bd":
|
|
660
|
+
return addBusinessDays(start, value, weekStart);
|
|
661
|
+
case "w":
|
|
662
|
+
return addDays(start, value * 7);
|
|
663
|
+
case "m": {
|
|
664
|
+
const d = new Date(start);
|
|
665
|
+
d.setUTCMonth(d.getUTCMonth() + value);
|
|
666
|
+
return d;
|
|
667
|
+
}
|
|
668
|
+
case "q": {
|
|
669
|
+
const d = new Date(start);
|
|
670
|
+
d.setUTCMonth(d.getUTCMonth() + value * 3);
|
|
671
|
+
return d;
|
|
672
|
+
}
|
|
673
|
+
default:
|
|
674
|
+
return addDays(start, value);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
function parseDate(s) {
|
|
678
|
+
return /* @__PURE__ */ new Date(s + "T00:00:00Z");
|
|
679
|
+
}
|
|
680
|
+
function maxDate(a, b) {
|
|
681
|
+
return a.getTime() >= b.getTime() ? a : b;
|
|
682
|
+
}
|
|
683
|
+
function minDate(a, b) {
|
|
684
|
+
return a.getTime() <= b.getTime() ? a : b;
|
|
685
|
+
}
|
|
686
|
+
function resolvedEnd(id, ctx) {
|
|
687
|
+
const item = ctx.idMap.get(id);
|
|
688
|
+
if (!item) return void 0;
|
|
689
|
+
if (item.type === "task") return item.computedEnd;
|
|
690
|
+
if (item.type === "milestone") return item.computedDate;
|
|
691
|
+
if (item.type === "parallel") return item.computedEnd;
|
|
692
|
+
return void 0;
|
|
693
|
+
}
|
|
694
|
+
function computeStartFromDeps(after, ctx) {
|
|
695
|
+
if (after.length === 0) return void 0;
|
|
696
|
+
let result;
|
|
697
|
+
for (const dep of after) {
|
|
698
|
+
let depDate;
|
|
699
|
+
if (dep.logic === "and") {
|
|
700
|
+
for (const id of dep.ids) {
|
|
701
|
+
const end = resolvedEnd(id, ctx);
|
|
702
|
+
if (end) depDate = depDate ? maxDate(depDate, end) : end;
|
|
703
|
+
}
|
|
704
|
+
} else {
|
|
705
|
+
for (const id of dep.ids) {
|
|
706
|
+
const end = resolvedEnd(id, ctx);
|
|
707
|
+
if (end) depDate = depDate ? minDate(depDate, end) : end;
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
if (depDate) result = result ? maxDate(result, depDate) : depDate;
|
|
711
|
+
}
|
|
712
|
+
return result;
|
|
713
|
+
}
|
|
714
|
+
function scheduleTask(task, sequentialAnchor, ctx) {
|
|
715
|
+
const isFixed = task.modifiers.includes("fixed") && task.startDate;
|
|
716
|
+
let start;
|
|
717
|
+
if (isFixed && task.startDate) {
|
|
718
|
+
start = parseDate(task.startDate);
|
|
719
|
+
} else {
|
|
720
|
+
const depStart = computeStartFromDeps(task.after, ctx);
|
|
721
|
+
if (task.after.length > 0) {
|
|
722
|
+
start = depStart ?? sequentialAnchor;
|
|
723
|
+
} else {
|
|
724
|
+
start = sequentialAnchor;
|
|
725
|
+
}
|
|
726
|
+
if (task.startDate) {
|
|
727
|
+
const explicit = parseDate(task.startDate);
|
|
728
|
+
start = maxDate(start, explicit);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
task.computedStart = start;
|
|
732
|
+
if (task.duration) {
|
|
733
|
+
const bd = task.duration.unit === "bd" || ctx.useBusinessDays;
|
|
734
|
+
task.computedEnd = addDuration(start, task.duration, bd, ctx.weekStart);
|
|
735
|
+
} else {
|
|
736
|
+
task.computedEnd = new Date(start);
|
|
737
|
+
}
|
|
738
|
+
const blockedMod = task.modifiers.find((m) => m.startsWith("blocked:"));
|
|
739
|
+
if (blockedMod) {
|
|
740
|
+
const blockDur = parseModifierDuration(blockedMod);
|
|
741
|
+
if (blockDur) {
|
|
742
|
+
task.plannedStart = new Date(task.computedStart);
|
|
743
|
+
task.plannedEnd = task.computedEnd ? new Date(task.computedEnd) : void 0;
|
|
744
|
+
const bd = blockDur.unit === "bd" || ctx.useBusinessDays;
|
|
745
|
+
task.computedStart = addDuration(task.computedStart, blockDur, bd, ctx.weekStart);
|
|
746
|
+
if (task.duration) {
|
|
747
|
+
const durBd = task.duration.unit === "bd" || ctx.useBusinessDays;
|
|
748
|
+
task.computedEnd = addDuration(task.computedStart, task.duration, durBd, ctx.weekStart);
|
|
749
|
+
} else {
|
|
750
|
+
task.computedEnd = new Date(task.computedStart);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
const delayedMod = task.modifiers.find((m) => m.startsWith("delayed:"));
|
|
755
|
+
if (delayedMod) {
|
|
756
|
+
const delayDur = parseModifierDuration(delayedMod);
|
|
757
|
+
if (delayDur) {
|
|
758
|
+
task.delayStart = task.computedEnd ? new Date(task.computedEnd) : void 0;
|
|
759
|
+
if (!task.plannedStart) {
|
|
760
|
+
task.plannedStart = new Date(task.computedStart);
|
|
761
|
+
task.plannedEnd = task.computedEnd ? new Date(task.computedEnd) : void 0;
|
|
762
|
+
}
|
|
763
|
+
const bd = delayDur.unit === "bd" || ctx.useBusinessDays;
|
|
764
|
+
if (task.computedEnd) {
|
|
765
|
+
task.computedEnd = addDuration(task.computedEnd, delayDur, bd, ctx.weekStart);
|
|
766
|
+
}
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
let subAnchor = start;
|
|
770
|
+
for (const sub of task.subtasks) {
|
|
771
|
+
scheduleTask(sub, subAnchor, ctx);
|
|
772
|
+
if (sub.computedEnd && sub.status !== "deferred" && sub.status !== "cancelled") {
|
|
773
|
+
subAnchor = sub.computedEnd;
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
if (task.subtasks.length > 0) {
|
|
777
|
+
for (const sub of task.subtasks) {
|
|
778
|
+
if (sub.computedEnd) {
|
|
779
|
+
task.computedEnd = task.computedEnd ? maxDate(task.computedEnd, sub.computedEnd) : sub.computedEnd;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function scheduleMilestone(ms, sequentialAnchor, ctx) {
|
|
785
|
+
const depStart = computeStartFromDeps(ms.after, ctx);
|
|
786
|
+
let date;
|
|
787
|
+
if (ms.after.length > 0) {
|
|
788
|
+
date = depStart ?? sequentialAnchor;
|
|
789
|
+
} else {
|
|
790
|
+
date = sequentialAnchor;
|
|
791
|
+
}
|
|
792
|
+
if (ms.date) {
|
|
793
|
+
const explicit = parseDate(ms.date);
|
|
794
|
+
date = maxDate(date, explicit);
|
|
795
|
+
}
|
|
796
|
+
ms.computedDate = date;
|
|
797
|
+
}
|
|
798
|
+
function scheduleBlock(block, sequentialAnchor, ctx) {
|
|
799
|
+
const depStart = computeStartFromDeps(block.after, ctx);
|
|
800
|
+
const blockStart = depStart ? maxDate(depStart, sequentialAnchor) : sequentialAnchor;
|
|
801
|
+
block.computedStart = blockStart;
|
|
802
|
+
let innerAnchor = blockStart;
|
|
803
|
+
for (const item of block.items) {
|
|
804
|
+
if (item.type === "task") {
|
|
805
|
+
scheduleTask(item, innerAnchor, ctx);
|
|
806
|
+
if (item.computedEnd && item.status !== "deferred" && item.status !== "cancelled") {
|
|
807
|
+
innerAnchor = item.computedEnd;
|
|
808
|
+
}
|
|
809
|
+
} else if (item.type === "milestone") {
|
|
810
|
+
scheduleMilestone(item, innerAnchor, ctx);
|
|
811
|
+
if (item.computedDate) innerAnchor = item.computedDate;
|
|
812
|
+
} else if (item.type === "parallel") {
|
|
813
|
+
scheduleBlock(item, innerAnchor, ctx);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
let blockEnd;
|
|
817
|
+
for (const item of block.items) {
|
|
818
|
+
let itemEnd;
|
|
819
|
+
if (item.type === "task") itemEnd = item.computedEnd;
|
|
820
|
+
else if (item.type === "milestone") itemEnd = item.computedDate;
|
|
821
|
+
else if (item.type === "parallel") itemEnd = item.computedEnd;
|
|
822
|
+
if (itemEnd) blockEnd = blockEnd ? maxDate(blockEnd, itemEnd) : itemEnd;
|
|
823
|
+
}
|
|
824
|
+
block.computedEnd = blockEnd ?? blockStart;
|
|
825
|
+
}
|
|
826
|
+
function schedule(doc) {
|
|
827
|
+
const projectStart = doc.header.start ? parseDate(doc.header.start) : new Date(Date.UTC((/* @__PURE__ */ new Date()).getUTCFullYear(), (/* @__PURE__ */ new Date()).getUTCMonth(), (/* @__PURE__ */ new Date()).getUTCDate()));
|
|
828
|
+
const useBusinessDays = doc.header.schedule === "business-days";
|
|
829
|
+
const weekStart = doc.header.weekStart ?? "mon";
|
|
830
|
+
const ctx = {
|
|
831
|
+
projectStart,
|
|
832
|
+
useBusinessDays,
|
|
833
|
+
weekStart,
|
|
834
|
+
idMap: doc.idMap
|
|
835
|
+
};
|
|
836
|
+
let sequentialAnchor = projectStart;
|
|
837
|
+
for (const item of doc.items) {
|
|
838
|
+
if (item.type === "task") {
|
|
839
|
+
scheduleTask(item, sequentialAnchor, ctx);
|
|
840
|
+
if (item.computedEnd && item.status !== "deferred" && item.status !== "cancelled") {
|
|
841
|
+
sequentialAnchor = item.computedEnd;
|
|
842
|
+
}
|
|
843
|
+
} else if (item.type === "milestone") {
|
|
844
|
+
scheduleMilestone(item, sequentialAnchor, ctx);
|
|
845
|
+
if (item.computedDate) sequentialAnchor = item.computedDate;
|
|
846
|
+
} else if (item.type === "parallel") {
|
|
847
|
+
scheduleBlock(item, sequentialAnchor, ctx);
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
return doc;
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
// src/renderer/gantt-svg.ts
|
|
854
|
+
var STATUS_COLORS = {
|
|
855
|
+
new: "#93a8c4",
|
|
856
|
+
active: "#6a9fd8",
|
|
857
|
+
done: "#6aab85",
|
|
858
|
+
blocked: "#c97070",
|
|
859
|
+
"at-risk": "#c9a04a",
|
|
860
|
+
deferred: "#a892cc",
|
|
861
|
+
cancelled: "#8a96a6",
|
|
862
|
+
review: "#8e7ec4",
|
|
863
|
+
paused: "#7a90a6"
|
|
864
|
+
};
|
|
865
|
+
var STATUS_DARK = {
|
|
866
|
+
new: "#7a90a8",
|
|
867
|
+
active: "#5588c0",
|
|
868
|
+
done: "#56986e",
|
|
869
|
+
blocked: "#b85a5a",
|
|
870
|
+
"at-risk": "#b8902a",
|
|
871
|
+
deferred: "#9278b8",
|
|
872
|
+
cancelled: "#606878",
|
|
873
|
+
review: "#7868b0",
|
|
874
|
+
paused: "#607080"
|
|
875
|
+
};
|
|
876
|
+
function escapeXml(s) {
|
|
877
|
+
return s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
878
|
+
}
|
|
879
|
+
function collectRows(items, depth, rows, inBlock, block) {
|
|
880
|
+
for (const item of items) {
|
|
881
|
+
if (item.type === "task") {
|
|
882
|
+
rows.push({ kind: "task", task: item, depth, inBlock, block });
|
|
883
|
+
if (item.subtasks.length > 0) {
|
|
884
|
+
collectRows(item.subtasks, depth + 1, rows, inBlock, block);
|
|
885
|
+
}
|
|
886
|
+
} else if (item.type === "milestone") {
|
|
887
|
+
rows.push({ kind: "milestone", milestone: item, depth, inBlock, block });
|
|
888
|
+
} else if (item.type === "parallel") {
|
|
889
|
+
const blockStartRow = rows.length;
|
|
890
|
+
rows.push({ kind: "parallel-header", block: item, depth, blockRowStart: blockStartRow });
|
|
891
|
+
collectRows(item.items, depth, rows, true, item);
|
|
892
|
+
if (rows.length > blockStartRow) {
|
|
893
|
+
rows[rows.length - 1].isLastInBlock = true;
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
function getDates(rows) {
|
|
899
|
+
let min = Infinity;
|
|
900
|
+
let max = -Infinity;
|
|
901
|
+
for (const row of rows) {
|
|
902
|
+
if (row.kind === "task" && row.task) {
|
|
903
|
+
if (row.task.computedStart) min = Math.min(min, row.task.computedStart.getTime());
|
|
904
|
+
if (row.task.computedEnd) max = Math.max(max, row.task.computedEnd.getTime());
|
|
905
|
+
}
|
|
906
|
+
if (row.kind === "milestone" && row.milestone?.computedDate) {
|
|
907
|
+
const t = row.milestone.computedDate.getTime();
|
|
908
|
+
min = Math.min(min, t);
|
|
909
|
+
max = Math.max(max, t);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
if (!isFinite(min)) min = Date.now();
|
|
913
|
+
if (!isFinite(max)) max = min + 7 * 864e5;
|
|
914
|
+
const span = max - min;
|
|
915
|
+
return {
|
|
916
|
+
minDate: new Date(min - span * 0.02),
|
|
917
|
+
maxDate: new Date(max + span * 0.05)
|
|
918
|
+
};
|
|
919
|
+
}
|
|
920
|
+
function getGranularity(spanDays) {
|
|
921
|
+
if (spanDays < 30) return "day";
|
|
922
|
+
if (spanDays < 180) return "week";
|
|
923
|
+
if (spanDays < 365) return "month";
|
|
924
|
+
return "quarter";
|
|
925
|
+
}
|
|
926
|
+
function generateTicks(minD, maxD, granularity) {
|
|
927
|
+
const ticks = [];
|
|
928
|
+
const cur = new Date(minD);
|
|
929
|
+
if (granularity === "day") {
|
|
930
|
+
cur.setUTCHours(0, 0, 0, 0);
|
|
931
|
+
while (cur <= maxD) {
|
|
932
|
+
ticks.push(new Date(cur));
|
|
933
|
+
cur.setUTCDate(cur.getUTCDate() + 1);
|
|
934
|
+
}
|
|
935
|
+
} else if (granularity === "week") {
|
|
936
|
+
cur.setUTCHours(0, 0, 0, 0);
|
|
937
|
+
const dow = cur.getUTCDay();
|
|
938
|
+
const toMon = dow === 0 ? -6 : 1 - dow;
|
|
939
|
+
cur.setUTCDate(cur.getUTCDate() + toMon);
|
|
940
|
+
while (cur <= maxD) {
|
|
941
|
+
ticks.push(new Date(cur));
|
|
942
|
+
cur.setUTCDate(cur.getUTCDate() + 7);
|
|
943
|
+
}
|
|
944
|
+
} else if (granularity === "month") {
|
|
945
|
+
cur.setUTCDate(1);
|
|
946
|
+
cur.setUTCHours(0, 0, 0, 0);
|
|
947
|
+
while (cur <= maxD) {
|
|
948
|
+
ticks.push(new Date(cur));
|
|
949
|
+
cur.setUTCMonth(cur.getUTCMonth() + 1);
|
|
950
|
+
}
|
|
951
|
+
} else {
|
|
952
|
+
cur.setUTCDate(1);
|
|
953
|
+
cur.setUTCHours(0, 0, 0, 0);
|
|
954
|
+
const q = Math.floor(cur.getUTCMonth() / 3);
|
|
955
|
+
cur.setUTCMonth(q * 3);
|
|
956
|
+
while (cur <= maxD) {
|
|
957
|
+
ticks.push(new Date(cur));
|
|
958
|
+
cur.setUTCMonth(cur.getUTCMonth() + 3);
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
return ticks;
|
|
962
|
+
}
|
|
963
|
+
function formatTick(d, granularity) {
|
|
964
|
+
const months = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
965
|
+
if (granularity === "day") {
|
|
966
|
+
return `${d.getUTCDate()} ${months[d.getUTCMonth()]}`;
|
|
967
|
+
}
|
|
968
|
+
if (granularity === "week") {
|
|
969
|
+
return `${months[d.getUTCMonth()]} ${d.getUTCDate()}`;
|
|
970
|
+
}
|
|
971
|
+
if (granularity === "month") {
|
|
972
|
+
return `${months[d.getUTCMonth()]} ${d.getUTCFullYear()}`;
|
|
973
|
+
}
|
|
974
|
+
const q = Math.floor(d.getUTCMonth() / 3) + 1;
|
|
975
|
+
return `Q${q} ${d.getUTCFullYear()}`;
|
|
976
|
+
}
|
|
977
|
+
function rect(x, y, w, h, attrs = {}) {
|
|
978
|
+
const attrStr = Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(" ");
|
|
979
|
+
return `<rect x="${x}" y="${y}" width="${Math.max(0, w)}" height="${h}" ${attrStr}/>`;
|
|
980
|
+
}
|
|
981
|
+
function text(x, y, content, attrs = {}) {
|
|
982
|
+
const attrStr = Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(" ");
|
|
983
|
+
return `<text x="${x}" y="${y}" ${attrStr}>${escapeXml(content)}</text>`;
|
|
984
|
+
}
|
|
985
|
+
function line(x1, y1, x2, y2, attrs = {}) {
|
|
986
|
+
const attrStr = Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(" ");
|
|
987
|
+
return `<line x1="${x1}" y1="${y1}" x2="${x2}" y2="${y2}" ${attrStr}/>`;
|
|
988
|
+
}
|
|
989
|
+
function circle(cx, cy, r, attrs = {}) {
|
|
990
|
+
const attrStr = Object.entries(attrs).map(([k, v]) => `${k}="${v}"`).join(" ");
|
|
991
|
+
return `<circle cx="${cx}" cy="${cy}" r="${r}" ${attrStr}/>`;
|
|
992
|
+
}
|
|
993
|
+
function initials(name) {
|
|
994
|
+
return name.split(/[\s_-]/).slice(0, 2).map((w) => w[0]?.toUpperCase() ?? "").join("");
|
|
995
|
+
}
|
|
996
|
+
function renderGanttSVG(doc, options) {
|
|
997
|
+
const opts = {
|
|
998
|
+
width: options?.width ?? 1200,
|
|
999
|
+
rowHeight: options?.rowHeight ?? 28,
|
|
1000
|
+
headerHeight: options?.headerHeight ?? 40,
|
|
1001
|
+
padding: options?.padding ?? 16,
|
|
1002
|
+
fontFamily: options?.fontFamily ?? "ui-sans-serif, system-ui, sans-serif",
|
|
1003
|
+
theme: options?.theme ?? "light"
|
|
1004
|
+
};
|
|
1005
|
+
const isDark = opts.theme === "dark";
|
|
1006
|
+
const bgColor = isDark ? "#0f172a" : "#ffffff";
|
|
1007
|
+
const textColor = isDark ? "#e2e8f0" : "#1e293b";
|
|
1008
|
+
const mutedColor = isDark ? "#64748b" : "#94a3b8";
|
|
1009
|
+
const trackColor = isDark ? "#1e293b" : "#f1f5f9";
|
|
1010
|
+
const borderColor = isDark ? "#334155" : "#e2e8f0";
|
|
1011
|
+
const labelWidth = 200;
|
|
1012
|
+
const chartLeft = opts.padding + labelWidth;
|
|
1013
|
+
const chartWidth = opts.width - chartLeft - opts.padding;
|
|
1014
|
+
const rows = [];
|
|
1015
|
+
collectRows(doc.items, 0, rows, false);
|
|
1016
|
+
const { minDate: minDate2, maxDate: maxDate2 } = getDates(rows);
|
|
1017
|
+
const totalMs = maxDate2.getTime() - minDate2.getTime();
|
|
1018
|
+
const spanDays = totalMs / 864e5;
|
|
1019
|
+
function dateToX(d) {
|
|
1020
|
+
const frac = (d.getTime() - minDate2.getTime()) / totalMs;
|
|
1021
|
+
return chartLeft + frac * chartWidth;
|
|
1022
|
+
}
|
|
1023
|
+
const granularity = getGranularity(spanDays);
|
|
1024
|
+
const ticks = generateTicks(minDate2, maxDate2, granularity);
|
|
1025
|
+
const totalHeight = opts.headerHeight + rows.length * opts.rowHeight + opts.padding;
|
|
1026
|
+
const parts = [];
|
|
1027
|
+
parts.push(`<defs>
|
|
1028
|
+
<clipPath id="chart-clip"><rect x="${chartLeft}" y="0" width="${chartWidth}" height="${totalHeight}"/></clipPath>
|
|
1029
|
+
</defs>`);
|
|
1030
|
+
parts.push(rect(0, 0, opts.width, totalHeight, { fill: bgColor }));
|
|
1031
|
+
if (doc.header.title) {
|
|
1032
|
+
parts.push(text(opts.padding, opts.headerHeight / 2 + 5, doc.header.title, {
|
|
1033
|
+
fill: textColor,
|
|
1034
|
+
"font-size": "14",
|
|
1035
|
+
"font-weight": "600",
|
|
1036
|
+
"font-family": opts.fontFamily
|
|
1037
|
+
}));
|
|
1038
|
+
}
|
|
1039
|
+
const axisY = opts.headerHeight - 1;
|
|
1040
|
+
parts.push(line(chartLeft, axisY, chartLeft + chartWidth, axisY, {
|
|
1041
|
+
stroke: borderColor,
|
|
1042
|
+
"stroke-width": "1"
|
|
1043
|
+
}));
|
|
1044
|
+
for (const tick of ticks) {
|
|
1045
|
+
const x = dateToX(tick);
|
|
1046
|
+
if (x < chartLeft || x > chartLeft + chartWidth) continue;
|
|
1047
|
+
parts.push(line(x, opts.headerHeight, x, totalHeight, {
|
|
1048
|
+
stroke: trackColor,
|
|
1049
|
+
"stroke-width": "1",
|
|
1050
|
+
"clip-path": "url(#chart-clip)"
|
|
1051
|
+
}));
|
|
1052
|
+
parts.push(line(x, axisY - 4, x, axisY, { stroke: mutedColor, "stroke-width": "1" }));
|
|
1053
|
+
parts.push(text(x + 3, axisY - 6, formatTick(tick, granularity), {
|
|
1054
|
+
fill: mutedColor,
|
|
1055
|
+
"font-size": "10",
|
|
1056
|
+
"font-family": opts.fontFamily
|
|
1057
|
+
}));
|
|
1058
|
+
}
|
|
1059
|
+
{
|
|
1060
|
+
const blockRanges = [];
|
|
1061
|
+
const blockStack = [];
|
|
1062
|
+
for (let ri = 0; ri < rows.length; ri++) {
|
|
1063
|
+
const row = rows[ri];
|
|
1064
|
+
if (row.kind === "parallel-header" && row.block) blockStack.push({ block: row.block, rowStart: ri });
|
|
1065
|
+
if (row.isLastInBlock && blockStack.length > 0) {
|
|
1066
|
+
const entry = blockStack.pop();
|
|
1067
|
+
blockRanges.push({ block: entry.block, rowStart: entry.rowStart, rowEnd: ri });
|
|
1068
|
+
}
|
|
1069
|
+
}
|
|
1070
|
+
for (const br of blockRanges) {
|
|
1071
|
+
const by = opts.headerHeight + br.rowStart * opts.rowHeight;
|
|
1072
|
+
const bh = (br.rowEnd - br.rowStart + 1) * opts.rowHeight;
|
|
1073
|
+
parts.push(line(opts.padding / 2, by + 4, opts.padding / 2, by + bh - 4, {
|
|
1074
|
+
stroke: "#6366f1",
|
|
1075
|
+
"stroke-width": "2",
|
|
1076
|
+
opacity: "0.35"
|
|
1077
|
+
}));
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1080
|
+
for (let ri = 0; ri < rows.length; ri++) {
|
|
1081
|
+
const row = rows[ri];
|
|
1082
|
+
const rowY = opts.headerHeight + ri * opts.rowHeight;
|
|
1083
|
+
const midY = rowY + opts.rowHeight / 2;
|
|
1084
|
+
if (row.kind === "parallel-header" && row.block) {
|
|
1085
|
+
const indent = row.depth * 14 + opts.padding;
|
|
1086
|
+
const blockName = (row.block.name ?? "(parallel)").toUpperCase();
|
|
1087
|
+
parts.push(text(indent, midY + 4, blockName, {
|
|
1088
|
+
fill: isDark ? "#818cf8" : "#6366f1",
|
|
1089
|
+
"font-size": "9",
|
|
1090
|
+
"font-weight": "700",
|
|
1091
|
+
"letter-spacing": "0.08em",
|
|
1092
|
+
"font-family": opts.fontFamily
|
|
1093
|
+
}));
|
|
1094
|
+
continue;
|
|
1095
|
+
}
|
|
1096
|
+
if (row.kind === "task" && row.task) {
|
|
1097
|
+
const task = row.task;
|
|
1098
|
+
const isSubtask = row.depth > 0;
|
|
1099
|
+
const indent = row.depth * 14 + opts.padding;
|
|
1100
|
+
const color = isDark ? STATUS_DARK[task.status] : STATUS_COLORS[task.status];
|
|
1101
|
+
const isCancelled = task.status === "cancelled";
|
|
1102
|
+
const isPending = task.status === "new" || task.status === "deferred" || task.status === "paused";
|
|
1103
|
+
const lineColor = isCancelled ? mutedColor : color;
|
|
1104
|
+
const mainOpacity = isCancelled ? 0.4 : isPending ? 0.6 : 1;
|
|
1105
|
+
const sw = isSubtask ? 1.5 : 2;
|
|
1106
|
+
const dotR = isSubtask ? 3 : 4;
|
|
1107
|
+
parts.push(circle(indent + 4, midY, 2.5, {
|
|
1108
|
+
fill: lineColor,
|
|
1109
|
+
opacity: mainOpacity
|
|
1110
|
+
}));
|
|
1111
|
+
const labelText = task.name.length > 27 ? task.name.slice(0, 25) + "\u2026" : task.name;
|
|
1112
|
+
parts.push(text(indent + 12, midY + 4, labelText, {
|
|
1113
|
+
fill: isCancelled ? mutedColor : textColor,
|
|
1114
|
+
"font-size": isSubtask ? "11" : "12",
|
|
1115
|
+
"font-family": opts.fontFamily,
|
|
1116
|
+
opacity: isCancelled ? "0.45" : "1"
|
|
1117
|
+
}));
|
|
1118
|
+
if (!task.computedStart || !task.computedEnd) continue;
|
|
1119
|
+
const delayedMod = task.modifiers.find((m) => m.startsWith("delayed:"));
|
|
1120
|
+
const blockedTimeMod = task.modifiers.find((m) => m.startsWith("blocked:"));
|
|
1121
|
+
const taskEndDate = delayedMod && task.delayStart ? task.delayStart : task.computedEnd;
|
|
1122
|
+
const x1 = dateToX(task.computedStart);
|
|
1123
|
+
const x2 = Math.max(x1 + 5, dateToX(taskEndDate));
|
|
1124
|
+
parts.push(line(chartLeft, midY, chartLeft + chartWidth, midY, {
|
|
1125
|
+
stroke: trackColor,
|
|
1126
|
+
"stroke-width": "1",
|
|
1127
|
+
"clip-path": "url(#chart-clip)"
|
|
1128
|
+
}));
|
|
1129
|
+
if (blockedTimeMod && task.plannedStart && task.plannedEnd) {
|
|
1130
|
+
const gx1 = dateToX(task.plannedStart);
|
|
1131
|
+
const gx2 = Math.max(gx1 + 4, dateToX(task.plannedEnd));
|
|
1132
|
+
parts.push(line(gx1, midY, gx2, midY, {
|
|
1133
|
+
stroke: "#ef4444",
|
|
1134
|
+
"stroke-width": String(sw - 0.5),
|
|
1135
|
+
"stroke-dasharray": "3,3",
|
|
1136
|
+
"stroke-linecap": "round",
|
|
1137
|
+
opacity: "0.35",
|
|
1138
|
+
"clip-path": "url(#chart-clip)"
|
|
1139
|
+
}));
|
|
1140
|
+
parts.push(circle(gx1, midY, dotR - 1, {
|
|
1141
|
+
fill: "none",
|
|
1142
|
+
stroke: "#ef4444",
|
|
1143
|
+
"stroke-width": "1.5",
|
|
1144
|
+
opacity: "0.35",
|
|
1145
|
+
"clip-path": "url(#chart-clip)"
|
|
1146
|
+
}));
|
|
1147
|
+
parts.push(circle(gx2, midY, dotR - 1, {
|
|
1148
|
+
fill: "none",
|
|
1149
|
+
stroke: "#ef4444",
|
|
1150
|
+
"stroke-width": "1.5",
|
|
1151
|
+
opacity: "0.35",
|
|
1152
|
+
"clip-path": "url(#chart-clip)"
|
|
1153
|
+
}));
|
|
1154
|
+
if (x1 > gx2 + 6) {
|
|
1155
|
+
parts.push(line(gx2, midY, x1, midY, {
|
|
1156
|
+
stroke: "#ef4444",
|
|
1157
|
+
"stroke-width": "1",
|
|
1158
|
+
"stroke-dasharray": "2,5",
|
|
1159
|
+
opacity: "0.2",
|
|
1160
|
+
"clip-path": "url(#chart-clip)"
|
|
1161
|
+
}));
|
|
1162
|
+
}
|
|
1163
|
+
}
|
|
1164
|
+
const dashArr = isPending ? "5,4" : void 0;
|
|
1165
|
+
const span = x2 - x1;
|
|
1166
|
+
if (task.progress != null && task.progress > 0 && !isCancelled) {
|
|
1167
|
+
const progX = x1 + span * (task.progress / 100);
|
|
1168
|
+
parts.push(line(x1, midY, x2, midY, {
|
|
1169
|
+
stroke: lineColor,
|
|
1170
|
+
"stroke-width": String(sw),
|
|
1171
|
+
opacity: "0.2",
|
|
1172
|
+
"stroke-linecap": "round",
|
|
1173
|
+
"clip-path": "url(#chart-clip)",
|
|
1174
|
+
...dashArr ? { "stroke-dasharray": dashArr } : {}
|
|
1175
|
+
}));
|
|
1176
|
+
parts.push(line(x1, midY, progX, midY, {
|
|
1177
|
+
stroke: lineColor,
|
|
1178
|
+
"stroke-width": String(sw + 0.5),
|
|
1179
|
+
"stroke-linecap": "round",
|
|
1180
|
+
"clip-path": "url(#chart-clip)"
|
|
1181
|
+
}));
|
|
1182
|
+
} else {
|
|
1183
|
+
parts.push(line(x1, midY, x2, midY, {
|
|
1184
|
+
stroke: lineColor,
|
|
1185
|
+
"stroke-width": String(sw),
|
|
1186
|
+
opacity: String(mainOpacity),
|
|
1187
|
+
"stroke-linecap": "round",
|
|
1188
|
+
"clip-path": "url(#chart-clip)",
|
|
1189
|
+
...dashArr ? { "stroke-dasharray": dashArr } : {}
|
|
1190
|
+
}));
|
|
1191
|
+
}
|
|
1192
|
+
parts.push(circle(x1, midY, dotR, {
|
|
1193
|
+
fill: lineColor,
|
|
1194
|
+
opacity: String(mainOpacity),
|
|
1195
|
+
"clip-path": "url(#chart-clip)"
|
|
1196
|
+
}));
|
|
1197
|
+
if (task.status === "done") {
|
|
1198
|
+
parts.push(circle(x2, midY, dotR, { fill: lineColor, "clip-path": "url(#chart-clip)" }));
|
|
1199
|
+
} else {
|
|
1200
|
+
parts.push(circle(x2, midY, dotR, {
|
|
1201
|
+
fill: bgColor,
|
|
1202
|
+
stroke: lineColor,
|
|
1203
|
+
"stroke-width": "1.5",
|
|
1204
|
+
opacity: String(mainOpacity),
|
|
1205
|
+
"clip-path": "url(#chart-clip)"
|
|
1206
|
+
}));
|
|
1207
|
+
}
|
|
1208
|
+
if (delayedMod && task.delayStart && task.computedEnd) {
|
|
1209
|
+
const ox1 = dateToX(task.delayStart);
|
|
1210
|
+
const ox2 = Math.max(ox1 + 5, dateToX(task.computedEnd));
|
|
1211
|
+
parts.push(line(ox1, midY, ox2, midY, {
|
|
1212
|
+
stroke: "#f59e0b",
|
|
1213
|
+
"stroke-width": String(sw),
|
|
1214
|
+
"stroke-dasharray": "4,3",
|
|
1215
|
+
"stroke-linecap": "round",
|
|
1216
|
+
opacity: "0.7",
|
|
1217
|
+
"clip-path": "url(#chart-clip)"
|
|
1218
|
+
}));
|
|
1219
|
+
parts.push(circle(ox2, midY, dotR, {
|
|
1220
|
+
fill: "none",
|
|
1221
|
+
stroke: "#f59e0b",
|
|
1222
|
+
"stroke-width": "1.5",
|
|
1223
|
+
opacity: "0.7",
|
|
1224
|
+
"clip-path": "url(#chart-clip)"
|
|
1225
|
+
}));
|
|
1226
|
+
}
|
|
1227
|
+
if (task.modifiers.includes("deadline") && task.dueDate) {
|
|
1228
|
+
const dlX = dateToX(/* @__PURE__ */ new Date(task.dueDate + "T00:00:00Z"));
|
|
1229
|
+
parts.push(line(dlX, rowY + 3, dlX, rowY + opts.rowHeight - 3, {
|
|
1230
|
+
stroke: "#ef4444",
|
|
1231
|
+
"stroke-width": "1.5",
|
|
1232
|
+
"stroke-dasharray": "2,2",
|
|
1233
|
+
opacity: "0.6",
|
|
1234
|
+
"clip-path": "url(#chart-clip)"
|
|
1235
|
+
}));
|
|
1236
|
+
}
|
|
1237
|
+
const circleR = 8;
|
|
1238
|
+
const visualEnd = delayedMod && task.delayStart && task.computedEnd ? dateToX(task.computedEnd) : x2;
|
|
1239
|
+
if (task.assignees.length > 0) {
|
|
1240
|
+
let cx = visualEnd + circleR + 5;
|
|
1241
|
+
for (const assignee of task.assignees.slice(0, 3)) {
|
|
1242
|
+
parts.push(circle(cx, midY, circleR, {
|
|
1243
|
+
fill: bgColor,
|
|
1244
|
+
stroke: lineColor,
|
|
1245
|
+
"stroke-width": "1.5",
|
|
1246
|
+
"clip-path": "url(#chart-clip)"
|
|
1247
|
+
}));
|
|
1248
|
+
parts.push(text(cx, midY + 3.5, initials(assignee), {
|
|
1249
|
+
fill: textColor,
|
|
1250
|
+
"font-size": "7",
|
|
1251
|
+
"font-weight": "600",
|
|
1252
|
+
"text-anchor": "middle",
|
|
1253
|
+
"font-family": opts.fontFamily,
|
|
1254
|
+
"clip-path": "url(#chart-clip)"
|
|
1255
|
+
}));
|
|
1256
|
+
cx += circleR * 1.9;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
{
|
|
1260
|
+
const nameX = x1 + dotR + 4;
|
|
1261
|
+
const availW = chartLeft + chartWidth - nameX - 8;
|
|
1262
|
+
if (availW > 30) {
|
|
1263
|
+
const maxChars = Math.max(4, Math.floor(availW / 6.5));
|
|
1264
|
+
const nameLabel = task.name.length > maxChars ? task.name.slice(0, maxChars - 1) + "\u2026" : task.name;
|
|
1265
|
+
parts.push(text(nameX, midY - (isSubtask ? 5 : 6), nameLabel, {
|
|
1266
|
+
fill: mutedColor,
|
|
1267
|
+
"font-size": isSubtask ? "9" : "10",
|
|
1268
|
+
"font-family": opts.fontFamily,
|
|
1269
|
+
opacity: isCancelled ? "0.4" : "0.75",
|
|
1270
|
+
"clip-path": "url(#chart-clip)"
|
|
1271
|
+
}));
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
if (task.description) {
|
|
1275
|
+
parts.push(`<rect x="0" y="${rowY}" width="${opts.width}" height="${opts.rowHeight}" fill="transparent" data-line="${task.line}" style="cursor:pointer"><title>${escapeXml(task.description)}</title></rect>`);
|
|
1276
|
+
} else {
|
|
1277
|
+
parts.push(rect(0, rowY, opts.width, opts.rowHeight, {
|
|
1278
|
+
fill: "transparent",
|
|
1279
|
+
"data-line": task.line,
|
|
1280
|
+
style: "cursor:pointer"
|
|
1281
|
+
}));
|
|
1282
|
+
}
|
|
1283
|
+
}
|
|
1284
|
+
if (row.kind === "milestone" && row.milestone) {
|
|
1285
|
+
const ms = row.milestone;
|
|
1286
|
+
const indent = row.depth * 14 + opts.padding;
|
|
1287
|
+
const labelText = ms.name.length > 27 ? ms.name.slice(0, 25) + "\u2026" : ms.name;
|
|
1288
|
+
parts.push(text(indent + 12, midY + 4, labelText, {
|
|
1289
|
+
fill: isDark ? "#fbbf24" : "#92400e",
|
|
1290
|
+
"font-size": "11",
|
|
1291
|
+
"font-style": "italic",
|
|
1292
|
+
"font-family": opts.fontFamily
|
|
1293
|
+
}));
|
|
1294
|
+
if (!ms.computedDate) continue;
|
|
1295
|
+
const dmx = dateToX(ms.computedDate);
|
|
1296
|
+
const msR = row.depth > 0 ? 4 : 5;
|
|
1297
|
+
if (ms.modifiers.includes("deadline")) {
|
|
1298
|
+
parts.push(line(dmx, opts.headerHeight, dmx, totalHeight, {
|
|
1299
|
+
stroke: "#ef4444",
|
|
1300
|
+
"stroke-width": "1",
|
|
1301
|
+
opacity: "0.2",
|
|
1302
|
+
"clip-path": "url(#chart-clip)"
|
|
1303
|
+
}));
|
|
1304
|
+
}
|
|
1305
|
+
parts.push(circle(dmx, midY, msR, {
|
|
1306
|
+
fill: isDark ? "#fbbf24" : "#d97706",
|
|
1307
|
+
"clip-path": "url(#chart-clip)"
|
|
1308
|
+
}));
|
|
1309
|
+
parts.push(circle(dmx, midY, msR - 2.5, {
|
|
1310
|
+
fill: bgColor,
|
|
1311
|
+
"clip-path": "url(#chart-clip)"
|
|
1312
|
+
}));
|
|
1313
|
+
parts.push(text(dmx + msR + 4, midY + 3.5, ms.name.slice(0, 18), {
|
|
1314
|
+
fill: isDark ? "#fbbf24" : "#92400e",
|
|
1315
|
+
"font-size": "9",
|
|
1316
|
+
"font-weight": "600",
|
|
1317
|
+
"font-family": opts.fontFamily,
|
|
1318
|
+
"clip-path": "url(#chart-clip)"
|
|
1319
|
+
}));
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
const today = /* @__PURE__ */ new Date();
|
|
1323
|
+
today.setUTCHours(0, 0, 0, 0);
|
|
1324
|
+
if (today >= minDate2 && today <= maxDate2) {
|
|
1325
|
+
const tx = dateToX(today);
|
|
1326
|
+
parts.push(line(tx, opts.headerHeight, tx, totalHeight, {
|
|
1327
|
+
stroke: "#ef4444",
|
|
1328
|
+
"stroke-width": "1",
|
|
1329
|
+
opacity: "0.5"
|
|
1330
|
+
}));
|
|
1331
|
+
parts.push(circle(tx, opts.headerHeight, 3, { fill: "#ef4444", opacity: "0.7" }));
|
|
1332
|
+
}
|
|
1333
|
+
parts.push(line(chartLeft, 0, chartLeft, totalHeight, {
|
|
1334
|
+
stroke: borderColor,
|
|
1335
|
+
"stroke-width": "1",
|
|
1336
|
+
opacity: "0.6"
|
|
1337
|
+
}));
|
|
1338
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${opts.width}" height="${totalHeight}" viewBox="0 0 ${opts.width} ${totalHeight}" font-family="${opts.fontFamily}">
|
|
1339
|
+
${parts.join("\n")}
|
|
1340
|
+
</svg>`;
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// cli/serve.ts
|
|
1344
|
+
function parseArgs(argv) {
|
|
1345
|
+
let folder2 = process.cwd();
|
|
1346
|
+
let port = 3e3;
|
|
1347
|
+
let noOpen2 = false;
|
|
1348
|
+
for (let i = 0; i < argv.length; i++) {
|
|
1349
|
+
if ((argv[i] === "--port" || argv[i] === "-p") && argv[i + 1]) {
|
|
1350
|
+
port = parseInt(argv[++i], 10);
|
|
1351
|
+
} else if (argv[i] === "--no-open") {
|
|
1352
|
+
noOpen2 = true;
|
|
1353
|
+
} else if (argv[i] === "--help" || argv[i] === "-h") {
|
|
1354
|
+
process.stdout.write([
|
|
1355
|
+
"",
|
|
1356
|
+
"yatt [folder] [options]",
|
|
1357
|
+
"",
|
|
1358
|
+
" folder Directory to watch (default: current directory)",
|
|
1359
|
+
" --port, -p N Port to listen on (default: 3000, auto-increments if busy)",
|
|
1360
|
+
" --no-open Do not open the browser automatically",
|
|
1361
|
+
" --help, -h Show this help",
|
|
1362
|
+
""
|
|
1363
|
+
].join("\n"));
|
|
1364
|
+
process.exit(0);
|
|
1365
|
+
} else if (!argv[i].startsWith("-")) {
|
|
1366
|
+
folder2 = path.resolve(argv[i]);
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
return { folder: folder2, port, noOpen: noOpen2 };
|
|
1370
|
+
}
|
|
1371
|
+
var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".svn", "dist", ".next", ".nuxt"]);
|
|
1372
|
+
function walkMdFiles(rootDir) {
|
|
1373
|
+
const results = [];
|
|
1374
|
+
function walk(dir) {
|
|
1375
|
+
let entries;
|
|
1376
|
+
try {
|
|
1377
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
1378
|
+
} catch {
|
|
1379
|
+
return;
|
|
1380
|
+
}
|
|
1381
|
+
for (const entry of entries) {
|
|
1382
|
+
if (entry.name.startsWith(".")) continue;
|
|
1383
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
1384
|
+
const full = path.join(dir, entry.name);
|
|
1385
|
+
if (entry.isDirectory()) walk(full);
|
|
1386
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) results.push(full);
|
|
1387
|
+
}
|
|
1388
|
+
}
|
|
1389
|
+
walk(rootDir);
|
|
1390
|
+
return results.sort();
|
|
1391
|
+
}
|
|
1392
|
+
function parseMdBlocks(source) {
|
|
1393
|
+
const blocks = [];
|
|
1394
|
+
const lines = source.split(/\r?\n/);
|
|
1395
|
+
let state = "normal";
|
|
1396
|
+
let fenceClose = "```";
|
|
1397
|
+
let buf = [];
|
|
1398
|
+
function flushProse() {
|
|
1399
|
+
const t = buf.join("\n").trim();
|
|
1400
|
+
if (t) blocks.push({ kind: "prose", text: t });
|
|
1401
|
+
buf = [];
|
|
1402
|
+
}
|
|
1403
|
+
for (const line2 of lines) {
|
|
1404
|
+
if (state === "yatt") {
|
|
1405
|
+
if (line2.trimEnd() === fenceClose) {
|
|
1406
|
+
blocks.push({ kind: "yatt", source: buf.join("\n") });
|
|
1407
|
+
buf = [];
|
|
1408
|
+
state = "normal";
|
|
1409
|
+
} else {
|
|
1410
|
+
buf.push(line2);
|
|
1411
|
+
}
|
|
1412
|
+
continue;
|
|
1413
|
+
}
|
|
1414
|
+
if (state === "fence") {
|
|
1415
|
+
buf.push(line2);
|
|
1416
|
+
if (line2.trimEnd() === fenceClose) state = "normal";
|
|
1417
|
+
continue;
|
|
1418
|
+
}
|
|
1419
|
+
if (/^```yatt\s*$/.test(line2)) {
|
|
1420
|
+
flushProse();
|
|
1421
|
+
fenceClose = "```";
|
|
1422
|
+
state = "yatt";
|
|
1423
|
+
continue;
|
|
1424
|
+
}
|
|
1425
|
+
if (/^~~~yatt\s*$/.test(line2)) {
|
|
1426
|
+
flushProse();
|
|
1427
|
+
fenceClose = "~~~";
|
|
1428
|
+
state = "yatt";
|
|
1429
|
+
continue;
|
|
1430
|
+
}
|
|
1431
|
+
if (/^```/.test(line2)) {
|
|
1432
|
+
buf.push(line2);
|
|
1433
|
+
fenceClose = "```";
|
|
1434
|
+
state = "fence";
|
|
1435
|
+
continue;
|
|
1436
|
+
}
|
|
1437
|
+
if (/^~~~/.test(line2)) {
|
|
1438
|
+
buf.push(line2);
|
|
1439
|
+
fenceClose = "~~~";
|
|
1440
|
+
state = "fence";
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1443
|
+
const hm = line2.match(/^(#{1,6})\s+(.+)$/);
|
|
1444
|
+
if (hm) {
|
|
1445
|
+
flushProse();
|
|
1446
|
+
blocks.push({ kind: "heading", level: hm[1].length, text: hm[2] });
|
|
1447
|
+
continue;
|
|
1448
|
+
}
|
|
1449
|
+
buf.push(line2);
|
|
1450
|
+
}
|
|
1451
|
+
flushProse();
|
|
1452
|
+
return blocks;
|
|
1453
|
+
}
|
|
1454
|
+
function extractTasksFromItems(items) {
|
|
1455
|
+
const tasks = [];
|
|
1456
|
+
function fmt(d) {
|
|
1457
|
+
return d ? d.toISOString().slice(0, 10) : void 0;
|
|
1458
|
+
}
|
|
1459
|
+
function walkTask(t, depth) {
|
|
1460
|
+
const dotPrefix = ".".repeat(depth);
|
|
1461
|
+
const info = {
|
|
1462
|
+
name: t.name,
|
|
1463
|
+
status: t.status,
|
|
1464
|
+
assignees: t.assignees,
|
|
1465
|
+
tags: t.tags,
|
|
1466
|
+
depth,
|
|
1467
|
+
dotPrefix,
|
|
1468
|
+
line: t.line
|
|
1469
|
+
};
|
|
1470
|
+
if (t.priority) info.priority = t.priority;
|
|
1471
|
+
if (t.progress !== void 0) info.progress = t.progress;
|
|
1472
|
+
if (t.id) info.id = t.id;
|
|
1473
|
+
if (t.duration) info.duration = `${t.duration.value}${t.duration.unit}`;
|
|
1474
|
+
if (t.startDate) info.startDate = t.startDate;
|
|
1475
|
+
if (t.dueDate) info.dueDate = t.dueDate;
|
|
1476
|
+
if (t.after.length) info.after = t.after.map((d) => d.ids.join(d.logic === "or" ? "|" : ",")).join(",");
|
|
1477
|
+
if (t.modifiers.length) info.modifiers = [...t.modifiers];
|
|
1478
|
+
if (t.description) info.description = t.description;
|
|
1479
|
+
const s = fmt(t.computedStart), e = fmt(t.computedEnd);
|
|
1480
|
+
if (s) info.start = s;
|
|
1481
|
+
if (e) info.end = e;
|
|
1482
|
+
tasks.push(info);
|
|
1483
|
+
for (const sub of t.subtasks) walkTask(sub, depth + 1);
|
|
1484
|
+
}
|
|
1485
|
+
function walk(its) {
|
|
1486
|
+
for (const item of its) {
|
|
1487
|
+
if (item.type === "task") walkTask(item, 0);
|
|
1488
|
+
else if (item.type === "parallel") walk(item.items);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
walk(items);
|
|
1492
|
+
return tasks;
|
|
1493
|
+
}
|
|
1494
|
+
function renderYattBlock(source) {
|
|
1495
|
+
const { doc, errors: parseErrors } = parse(source);
|
|
1496
|
+
const validationErrors = validate(doc);
|
|
1497
|
+
const scheduled = schedule(doc);
|
|
1498
|
+
const html = renderGanttSVG(scheduled, { theme: "dark", width: 1100 });
|
|
1499
|
+
const errors = [...parseErrors, ...validationErrors].map((e) => `Line ${e.line}: ${e.message}`);
|
|
1500
|
+
const tasks = extractTasksFromItems(doc.items);
|
|
1501
|
+
return { html, errors, tasks };
|
|
1502
|
+
}
|
|
1503
|
+
function renderFile(absPath) {
|
|
1504
|
+
const source = fs.readFileSync(absPath, "utf8");
|
|
1505
|
+
const blocks = parseMdBlocks(source);
|
|
1506
|
+
return blocks.map((b) => {
|
|
1507
|
+
if (b.kind === "yatt") {
|
|
1508
|
+
const { html, errors, tasks } = renderYattBlock(b.source);
|
|
1509
|
+
return { kind: "yatt", html, errors, tasks, source: b.source };
|
|
1510
|
+
}
|
|
1511
|
+
if (b.kind === "heading") return { kind: "heading", level: b.level, text: b.text };
|
|
1512
|
+
return { kind: "prose", text: b.text };
|
|
1513
|
+
});
|
|
1514
|
+
}
|
|
1515
|
+
function replaceYattBlock(fileSource, blockIdx, newSource) {
|
|
1516
|
+
const lines = fileSource.split(/\r?\n/);
|
|
1517
|
+
let idx = 0;
|
|
1518
|
+
let scanState = "normal";
|
|
1519
|
+
let fenceClose = "```";
|
|
1520
|
+
let contentStart = -1;
|
|
1521
|
+
let contentEnd = -1;
|
|
1522
|
+
for (let i = 0; i < lines.length; i++) {
|
|
1523
|
+
const ln = lines[i];
|
|
1524
|
+
if (scanState === "yatt") {
|
|
1525
|
+
if (ln.trimEnd() === fenceClose) {
|
|
1526
|
+
if (idx === blockIdx) {
|
|
1527
|
+
contentEnd = i;
|
|
1528
|
+
break;
|
|
1529
|
+
}
|
|
1530
|
+
idx++;
|
|
1531
|
+
scanState = "normal";
|
|
1532
|
+
}
|
|
1533
|
+
continue;
|
|
1534
|
+
}
|
|
1535
|
+
if (scanState === "fence") {
|
|
1536
|
+
if (ln.trimEnd() === fenceClose) scanState = "normal";
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
if (/^```yatt\s*$/.test(ln)) {
|
|
1540
|
+
if (idx === blockIdx) contentStart = i + 1;
|
|
1541
|
+
fenceClose = "```";
|
|
1542
|
+
scanState = "yatt";
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1545
|
+
if (/^~~~yatt\s*$/.test(ln)) {
|
|
1546
|
+
if (idx === blockIdx) contentStart = i + 1;
|
|
1547
|
+
fenceClose = "~~~";
|
|
1548
|
+
scanState = "yatt";
|
|
1549
|
+
continue;
|
|
1550
|
+
}
|
|
1551
|
+
if (/^```/.test(ln)) {
|
|
1552
|
+
fenceClose = "```";
|
|
1553
|
+
scanState = "fence";
|
|
1554
|
+
continue;
|
|
1555
|
+
}
|
|
1556
|
+
if (/^~~~/.test(ln)) {
|
|
1557
|
+
fenceClose = "~~~";
|
|
1558
|
+
scanState = "fence";
|
|
1559
|
+
continue;
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
if (contentStart < 0 || contentEnd < 0) throw new Error(`YATT block ${blockIdx} not found`);
|
|
1563
|
+
const newLines = newSource.split(/\r?\n/);
|
|
1564
|
+
lines.splice(contentStart, contentEnd - contentStart, ...newLines);
|
|
1565
|
+
return lines.join("\n");
|
|
1566
|
+
}
|
|
1567
|
+
var SseManager = class {
|
|
1568
|
+
constructor() {
|
|
1569
|
+
this.clients = /* @__PURE__ */ new Set();
|
|
1570
|
+
}
|
|
1571
|
+
add(res) {
|
|
1572
|
+
res.writeHead(200, { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive" });
|
|
1573
|
+
res.write(":ok\n\n");
|
|
1574
|
+
this.clients.add(res);
|
|
1575
|
+
res.on("close", () => this.clients.delete(res));
|
|
1576
|
+
}
|
|
1577
|
+
broadcast(event) {
|
|
1578
|
+
const msg = `data: ${event}
|
|
1579
|
+
|
|
1580
|
+
`;
|
|
1581
|
+
for (const res of this.clients) res.write(msg);
|
|
1582
|
+
}
|
|
1583
|
+
};
|
|
1584
|
+
function watchFolder(rootDir, sse) {
|
|
1585
|
+
let debounce = null;
|
|
1586
|
+
const fire = () => {
|
|
1587
|
+
if (debounce) clearTimeout(debounce);
|
|
1588
|
+
debounce = setTimeout(() => sse.broadcast("reload"), 150);
|
|
1589
|
+
};
|
|
1590
|
+
try {
|
|
1591
|
+
fs.watch(rootDir, { recursive: true }, (_e, f) => {
|
|
1592
|
+
if (f && f.endsWith(".md")) fire();
|
|
1593
|
+
});
|
|
1594
|
+
} catch {
|
|
1595
|
+
fs.watch(rootDir, {}, (_e, f) => {
|
|
1596
|
+
if (f && f.endsWith(".md")) fire();
|
|
1597
|
+
});
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
function openBrowser(url) {
|
|
1601
|
+
try {
|
|
1602
|
+
if (process.platform === "win32") execFile("explorer", [url], () => {
|
|
1603
|
+
});
|
|
1604
|
+
else if (process.platform === "darwin") execFile("open", [url], () => {
|
|
1605
|
+
});
|
|
1606
|
+
else execFile("xdg-open", [url], () => {
|
|
1607
|
+
});
|
|
1608
|
+
} catch {
|
|
1609
|
+
}
|
|
1610
|
+
}
|
|
1611
|
+
function guardPath(rootDir, relPath) {
|
|
1612
|
+
if (!relPath) return null;
|
|
1613
|
+
const absPath = path.resolve(rootDir, relPath);
|
|
1614
|
+
const safeRoot = rootDir.endsWith(path.sep) ? rootDir : rootDir + path.sep;
|
|
1615
|
+
if (!absPath.startsWith(safeRoot) && absPath !== rootDir) return null;
|
|
1616
|
+
return absPath;
|
|
1617
|
+
}
|
|
1618
|
+
var CSS = `
|
|
1619
|
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
|
1620
|
+
:root {
|
|
1621
|
+
--bg: #0d1117;
|
|
1622
|
+
--panel: #161b22;
|
|
1623
|
+
--panel2: #1c2128;
|
|
1624
|
+
--border: #30363d;
|
|
1625
|
+
--text: #e6edf3;
|
|
1626
|
+
--muted: #7d8590;
|
|
1627
|
+
--accent: #388bfd;
|
|
1628
|
+
--accent-hi: #79c0ff;
|
|
1629
|
+
--green: #3fb950;
|
|
1630
|
+
--red: #f85149;
|
|
1631
|
+
--orange: #f0883e;
|
|
1632
|
+
--yellow: #d29922;
|
|
1633
|
+
--purple: #bc8cff;
|
|
1634
|
+
--sidebar-w: 240px;
|
|
1635
|
+
--topbar-h: 46px;
|
|
1636
|
+
}
|
|
1637
|
+
html, body { height: 100%; background: var(--bg); color: var(--text);
|
|
1638
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', system-ui, sans-serif;
|
|
1639
|
+
font-size: 14px; overflow: hidden; }
|
|
1640
|
+
#app { display: flex; flex-direction: column; height: 100vh; }
|
|
1641
|
+
|
|
1642
|
+
/* \u2500\u2500 topbar \u2500\u2500 */
|
|
1643
|
+
#topbar { flex: 0 0 var(--topbar-h); display: flex; align-items: center; gap: 10px;
|
|
1644
|
+
padding: 0 14px; background: var(--panel); border-bottom: 1px solid var(--border); z-index: 10; }
|
|
1645
|
+
.logo { font-size: 13px; font-weight: 700; letter-spacing: 0.12em; color: var(--accent-hi);
|
|
1646
|
+
text-transform: uppercase; flex-shrink: 0; }
|
|
1647
|
+
#folder-path { font-size: 11px; color: var(--muted); overflow: hidden; text-overflow: ellipsis;
|
|
1648
|
+
white-space: nowrap; flex: 1; min-width: 0; }
|
|
1649
|
+
#view-tabs { display: flex; gap: 2px; flex-shrink: 0; }
|
|
1650
|
+
.tab { background: none; border: none; cursor: pointer; padding: 5px 12px;
|
|
1651
|
+
font-size: 12px; color: var(--muted); border-radius: 4px;
|
|
1652
|
+
transition: color 0.1s, background 0.1s; font-family: inherit; }
|
|
1653
|
+
.tab:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
|
1654
|
+
.tab.active { color: var(--accent-hi); background: rgba(56,139,253,0.12); }
|
|
1655
|
+
#save-status { font-size: 11px; color: var(--muted); flex-shrink: 0; min-width: 60px; text-align: right; }
|
|
1656
|
+
#save-status.unsaved { color: var(--orange); }
|
|
1657
|
+
#save-status.saved { color: var(--green); }
|
|
1658
|
+
#save-status.error { color: var(--red); }
|
|
1659
|
+
|
|
1660
|
+
/* \u2500\u2500 git widget \u2500\u2500 */
|
|
1661
|
+
#git-widget { display: flex; align-items: center; gap: 6px; flex-shrink: 0; margin-left: auto; }
|
|
1662
|
+
#git-widget.hidden { display: none; }
|
|
1663
|
+
.git-branch { display: flex; align-items: center; gap: 4px; font-size: 11px; color: var(--muted);
|
|
1664
|
+
background: var(--panel2); border: 1px solid var(--border); border-radius: 4px; padding: 2px 7px; }
|
|
1665
|
+
.git-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--green); flex-shrink: 0; }
|
|
1666
|
+
.git-dot.dirty { background: var(--orange); }
|
|
1667
|
+
.git-count { font-size: 10px; color: var(--muted); display: flex; align-items: center; gap: 2px; }
|
|
1668
|
+
.git-count.ahead { color: #6a9fd8; }
|
|
1669
|
+
.git-count.behind { color: var(--orange); }
|
|
1670
|
+
.git-btn { background: var(--panel2); border: 1px solid var(--border); color: var(--text);
|
|
1671
|
+
border-radius: 4px; padding: 2px 9px; font-size: 11px; cursor: pointer; white-space: nowrap; }
|
|
1672
|
+
.git-btn:hover { border-color: var(--accent); color: var(--accent-hi); }
|
|
1673
|
+
.git-btn:disabled { opacity: 0.4; cursor: default; }
|
|
1674
|
+
.git-btn.primary { border-color: var(--accent); color: var(--accent-hi); }
|
|
1675
|
+
#git-msg { font-size: 11px; max-width: 200px; overflow: hidden; text-overflow: ellipsis;
|
|
1676
|
+
white-space: nowrap; flex-shrink: 0; }
|
|
1677
|
+
#git-msg.ok { color: var(--green); }
|
|
1678
|
+
#git-msg.err { color: var(--red); }
|
|
1679
|
+
/* commit modal */
|
|
1680
|
+
#git-commit-modal { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1001;
|
|
1681
|
+
display: flex; align-items: center; justify-content: center; }
|
|
1682
|
+
#git-commit-modal.hidden { display: none; }
|
|
1683
|
+
#git-commit-box { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
|
1684
|
+
padding: 20px; width: 360px; display: flex; flex-direction: column; gap: 12px; }
|
|
1685
|
+
#git-commit-box h3 { margin: 0; font-size: 14px; color: var(--text); }
|
|
1686
|
+
#git-commit-msg { width: 100%; box-sizing: border-box; background: var(--panel2); border: 1px solid var(--border);
|
|
1687
|
+
color: var(--text); border-radius: 5px; padding: 8px 10px; font-size: 12px; font-family: inherit;
|
|
1688
|
+
resize: vertical; min-height: 60px; }
|
|
1689
|
+
#git-commit-msg:focus { outline: none; border-color: var(--accent); }
|
|
1690
|
+
.git-commit-actions { display: flex; justify-content: flex-end; gap: 8px; }
|
|
1691
|
+
|
|
1692
|
+
/* \u2500\u2500 workspace \u2500\u2500 */
|
|
1693
|
+
#workspace { flex: 1; display: flex; min-height: 0; }
|
|
1694
|
+
|
|
1695
|
+
/* \u2500\u2500 sidebar \u2500\u2500 */
|
|
1696
|
+
#sidebar { flex: 0 0 var(--sidebar-w); overflow-y: auto; border-right: 1px solid var(--border);
|
|
1697
|
+
background: var(--panel); scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
|
1698
|
+
#sidebar::-webkit-scrollbar { width: 4px; }
|
|
1699
|
+
#sidebar::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
1700
|
+
#sidebar-inner { padding: 6px 0; }
|
|
1701
|
+
.group-label { font-size: 10px; font-weight: 600; text-transform: uppercase;
|
|
1702
|
+
letter-spacing: 0.08em; color: var(--muted); padding: 10px 14px 3px; }
|
|
1703
|
+
.file-item { display: block; padding: 5px 14px; font-size: 12px; color: var(--text);
|
|
1704
|
+
cursor: pointer; text-decoration: none; white-space: nowrap; overflow: hidden;
|
|
1705
|
+
text-overflow: ellipsis; border-left: 2px solid transparent; transition: background 0.1s; }
|
|
1706
|
+
.file-item:hover { background: rgba(56,139,253,0.08); }
|
|
1707
|
+
.file-item.active { background: rgba(56,139,253,0.14); color: var(--accent-hi); border-left-color: var(--accent); }
|
|
1708
|
+
|
|
1709
|
+
/* \u2500\u2500 main \u2500\u2500 */
|
|
1710
|
+
#main { flex: 1; display: flex; flex-direction: column; min-width: 0; overflow: hidden; }
|
|
1711
|
+
.view-panel { flex: 1; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
|
1712
|
+
.view-panel::-webkit-scrollbar { width: 6px; }
|
|
1713
|
+
.view-panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
|
|
1714
|
+
|
|
1715
|
+
/* \u2500\u2500 document view \u2500\u2500 */
|
|
1716
|
+
#doc-content { max-width: 1200px; margin: 0 auto; padding: 28px 36px 80px; }
|
|
1717
|
+
.block-h1 { font-size: 24px; font-weight: 700; margin: 32px 0 12px;
|
|
1718
|
+
padding-bottom: 8px; border-bottom: 1px solid var(--border); }
|
|
1719
|
+
.block-h2 { font-size: 18px; font-weight: 600; margin: 24px 0 8px; }
|
|
1720
|
+
.block-h3, .block-h4, .block-h5, .block-h6 { font-size: 14px; font-weight: 600; margin: 18px 0 6px; color: var(--muted); }
|
|
1721
|
+
.prose { line-height: 1.65; color: var(--text); margin: 12px 0; }
|
|
1722
|
+
.prose p { margin: 6px 0; }
|
|
1723
|
+
.prose ul, .prose ol { padding-left: 20px; margin: 6px 0; }
|
|
1724
|
+
.prose li { margin: 3px 0; }
|
|
1725
|
+
.prose strong { font-weight: 600; }
|
|
1726
|
+
.prose em { font-style: italic; }
|
|
1727
|
+
.prose a { color: var(--accent-hi); }
|
|
1728
|
+
.prose h1,.prose h2,.prose h3,.prose h4 { font-weight: 600; margin: 14px 0 6px; }
|
|
1729
|
+
.prose blockquote { border-left: 2px solid var(--border); padding-left: 12px; color: var(--muted); margin: 8px 0; }
|
|
1730
|
+
.prose hr { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
|
|
1731
|
+
.prose code { font-family: ui-monospace, monospace; font-size: 12px;
|
|
1732
|
+
background: var(--panel2); padding: 1px 5px; border-radius: 3px; color: var(--accent-hi); }
|
|
1733
|
+
.prose pre { background: var(--panel2); border: 1px solid var(--border); border-radius: 6px;
|
|
1734
|
+
padding: 14px 16px; overflow-x: auto; margin: 10px 0; }
|
|
1735
|
+
.prose pre code { background: none; padding: 0; font-size: 12px; color: var(--text); }
|
|
1736
|
+
.prose table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 13px; }
|
|
1737
|
+
.prose th { text-align: left; padding: 6px 12px; background: var(--panel2);
|
|
1738
|
+
border: 1px solid var(--border); font-weight: 600; }
|
|
1739
|
+
.prose td { padding: 6px 12px; border: 1px solid var(--border); }
|
|
1740
|
+
.prose tr:nth-child(even) td { background: rgba(255,255,255,0.02); }
|
|
1741
|
+
|
|
1742
|
+
/* \u2500\u2500 yatt control (embedded per-block) \u2500\u2500 */
|
|
1743
|
+
.yatt-ctrl { margin: 16px 0; border: 1px solid var(--border); border-radius: 8px; overflow: auto;
|
|
1744
|
+
display: flex; flex-direction: column; resize: vertical; min-height: 400px; }
|
|
1745
|
+
.yatt-ctrl-bar { display: flex; gap: 2px; padding: 4px 8px; flex: 0 0 auto;
|
|
1746
|
+
border-bottom: 1px solid var(--border); background: var(--panel2); }
|
|
1747
|
+
.yatt-ctrl-tab { background: none; border: none; cursor: pointer; padding: 3px 10px;
|
|
1748
|
+
font-size: 11px; color: var(--muted); border-radius: 3px;
|
|
1749
|
+
transition: color 0.1s, background 0.1s; font-family: inherit; }
|
|
1750
|
+
.yatt-ctrl-tab:hover { color: var(--text); background: rgba(255,255,255,0.06); }
|
|
1751
|
+
.yatt-ctrl-tab.active { color: var(--accent-hi); background: rgba(56,139,253,0.12); }
|
|
1752
|
+
.yatt-ctrl-body { flex: 1; min-height: 0; display: flex; flex-direction: column; overflow: auto; }
|
|
1753
|
+
.yatt-ctrl-panel { display: none; }
|
|
1754
|
+
.yatt-ctrl-panel.active { display: flex; flex-direction: column; flex: 1; min-height: 0; overflow: auto; }
|
|
1755
|
+
.yatt-ctrl-panel[data-panel="timeline"] svg { max-width: 100%; height: auto; display: block; }
|
|
1756
|
+
.yatt-errors { background: rgba(248,81,73,0.08); border-bottom: 1px solid rgba(248,81,73,0.25);
|
|
1757
|
+
padding: 6px 12px; font-size: 11px; color: var(--red); font-family: ui-monospace, monospace; }
|
|
1758
|
+
|
|
1759
|
+
/* \u2500\u2500 kanban (inside control and shared atoms) \u2500\u2500 */
|
|
1760
|
+
.ctrl-kanban { display: flex; gap: 10px; padding: 12px; overflow-x: auto; align-items: flex-start; }
|
|
1761
|
+
.k-col { flex: 0 0 200px; background: var(--bg); border-radius: 6px;
|
|
1762
|
+
border: 1px solid var(--border); display: flex; flex-direction: column; max-height: 360px; }
|
|
1763
|
+
.k-col.empty { flex: 0 0 32px; cursor: default; }
|
|
1764
|
+
.k-col.empty .k-col-header { display: none; }
|
|
1765
|
+
.k-col.empty .k-cards { display: none; }
|
|
1766
|
+
.k-col-empty-label { display: none; }
|
|
1767
|
+
.k-col.empty .k-col-empty-label { display: flex; flex-direction: column; align-items: center;
|
|
1768
|
+
justify-content: center; height: 100%; gap: 8px; padding: 10px 0; }
|
|
1769
|
+
.k-col-empty-line { width: 3px; border-radius: 2px; flex-shrink: 0; min-height: 24px; flex: 1; max-height: 60px; }
|
|
1770
|
+
.k-col-empty-title { writing-mode: vertical-rl; text-orientation: mixed; transform: rotate(180deg);
|
|
1771
|
+
font-size: 9px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
|
|
1772
|
+
.k-col-header { padding: 8px 12px; border-bottom: 1px solid var(--border);
|
|
1773
|
+
display: flex; align-items: center; gap: 7px; flex-shrink: 0; }
|
|
1774
|
+
.k-col-line { width: 3px; height: 12px; border-radius: 2px; flex-shrink: 0; }
|
|
1775
|
+
.k-col-title { font-size: 10px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.07em; flex: 1; }
|
|
1776
|
+
.k-col-count { font-size: 11px; color: var(--muted); }
|
|
1777
|
+
.k-cards { overflow-y: auto; padding: 6px; display: flex; flex-direction: column;
|
|
1778
|
+
gap: 5px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
|
1779
|
+
.k-cards::-webkit-scrollbar { width: 3px; }
|
|
1780
|
+
.k-cards::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
|
1781
|
+
.k-card { background: var(--panel); border: 1px solid var(--border); border-radius: 5px;
|
|
1782
|
+
padding: 8px 10px; transition: border-color 0.12s; }
|
|
1783
|
+
.k-card:hover { border-color: var(--accent); }
|
|
1784
|
+
.k-card-name { font-size: 11px; color: var(--text); line-height: 1.4; margin-bottom: 3px; }
|
|
1785
|
+
.k-card-desc { font-size: 10px; color: var(--muted); line-height: 1.4; margin-bottom: 5px; font-style: italic; }
|
|
1786
|
+
.k-shift-badge { display: inline-flex; align-items: center; gap: 3px; font-size: 10px; font-weight: 600;
|
|
1787
|
+
border-radius: 3px; padding: 1px 5px; margin-right: 3px; }
|
|
1788
|
+
.k-shift-badge.delayed { background: rgba(245,158,11,0.15); color: #f59e0b; }
|
|
1789
|
+
.k-shift-badge.blocked { background: rgba(239,68,68,0.15); color: #ef4444; }
|
|
1790
|
+
.k-card-shifts { display: flex; gap: 4px; flex-wrap: wrap; margin-bottom: 5px; }
|
|
1791
|
+
.k-card-meta { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; }
|
|
1792
|
+
.k-progress { height: 2px; background: var(--border); border-radius: 1px; margin-top: 6px; overflow: hidden; }
|
|
1793
|
+
.k-progress-fill { height: 100%; border-radius: 1px; background: var(--accent); }
|
|
1794
|
+
.k-priority { font-size: 10px; padding: 1px 5px; border-radius: 3px;
|
|
1795
|
+
background: rgba(255,255,255,0.06); color: var(--muted); }
|
|
1796
|
+
.k-priority[data-p="critical"] { color: var(--red); }
|
|
1797
|
+
.k-priority[data-p="high"] { color: var(--orange); }
|
|
1798
|
+
|
|
1799
|
+
/* \u2500\u2500 people (inside control) \u2500\u2500 */
|
|
1800
|
+
.ctrl-people { padding: 12px 16px; display: flex; flex-direction: column; gap: 12px; }
|
|
1801
|
+
.person-card { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
|
1802
|
+
.person-header { display: flex; align-items: center; gap: 10px; padding: 10px 14px;
|
|
1803
|
+
border-bottom: 1px solid var(--border); }
|
|
1804
|
+
.person-name { font-size: 12px; font-weight: 600; }
|
|
1805
|
+
.person-count { font-size: 11px; color: var(--muted); margin-top: 1px; }
|
|
1806
|
+
.ptask-row { display: flex; align-items: center; gap: 8px; padding: 6px 14px;
|
|
1807
|
+
border-bottom: 1px solid var(--border); }
|
|
1808
|
+
.ptask-row:last-child { border-bottom: none; }
|
|
1809
|
+
.ptask-name { font-size: 12px; flex: 1; }
|
|
1810
|
+
.ptask-priority { font-size: 10px; color: var(--muted); }
|
|
1811
|
+
.ptask-priority[data-p="critical"] { color: var(--red); }
|
|
1812
|
+
.ptask-priority[data-p="high"] { color: var(--orange); }
|
|
1813
|
+
.ptask-prog { font-size: 10px; color: var(--muted); font-family: ui-monospace, monospace; }
|
|
1814
|
+
|
|
1815
|
+
/* \u2500\u2500 markdown/edit view \u2500\u2500 */
|
|
1816
|
+
#view-markdown { display: flex; flex-direction: column; overflow: hidden; }
|
|
1817
|
+
#view-markdown.view-panel { overflow: hidden; }
|
|
1818
|
+
.view-panel[hidden] { display: none !important; }
|
|
1819
|
+
#editor { display: block; flex: 1 1 0; min-height: 0; width: 100%; resize: none;
|
|
1820
|
+
background: var(--bg); color: var(--text); border: none; outline: none;
|
|
1821
|
+
padding: 24px 32px; font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
|
|
1822
|
+
font-size: 13px; line-height: 1.65; tab-size: 2; }
|
|
1823
|
+
|
|
1824
|
+
/* \u2500\u2500 yatt source panel (read-only) \u2500\u2500 */
|
|
1825
|
+
.yatt-src { padding: 16px; font-family: ui-monospace, 'Cascadia Code', monospace;
|
|
1826
|
+
font-size: 12px; line-height: 1.6; overflow-x: auto;
|
|
1827
|
+
background: var(--bg); color: var(--text); white-space: pre; margin: 0; }
|
|
1828
|
+
|
|
1829
|
+
/* \u2500\u2500 yatt inline block editor \u2500\u2500 */
|
|
1830
|
+
.yatt-block-editor { display: block; width: 100%; flex: 1; min-height: 0; padding: 14px 16px;
|
|
1831
|
+
font-family: ui-monospace, 'Cascadia Code', 'Fira Code', monospace;
|
|
1832
|
+
font-size: 12px; line-height: 1.6; background: var(--bg); color: var(--text);
|
|
1833
|
+
border: none; outline: none; resize: none; tab-size: 2; }
|
|
1834
|
+
.yatt-block-bar { display: flex; align-items: center; padding: 4px 12px;
|
|
1835
|
+
border-top: 1px solid var(--border); background: var(--panel2); min-height: 26px; }
|
|
1836
|
+
.yatt-block-status { font-size: 10px; }
|
|
1837
|
+
|
|
1838
|
+
/* \u2500\u2500 task edit modal \u2500\u2500 */
|
|
1839
|
+
#task-edit-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: 1000;
|
|
1840
|
+
display: flex; align-items: center; justify-content: center; }
|
|
1841
|
+
#task-edit-overlay.hidden { display: none; }
|
|
1842
|
+
#task-edit-modal { background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
|
|
1843
|
+
padding: 22px 24px; width: 500px; max-height: 88vh; overflow-y: auto;
|
|
1844
|
+
scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
|
1845
|
+
.te-title { font-size: 15px; font-weight: 700; margin-bottom: 16px; color: var(--text); }
|
|
1846
|
+
.te-row { display: grid; gap: 10px; margin-bottom: 10px; }
|
|
1847
|
+
.te-row.cols-2 { grid-template-columns: 1fr 1fr; }
|
|
1848
|
+
.te-row.cols-3 { grid-template-columns: 1fr 1fr 1fr; }
|
|
1849
|
+
.te-field label { display: block; font-size: 10px; font-weight: 600; text-transform: uppercase;
|
|
1850
|
+
letter-spacing: 0.06em; color: var(--muted); margin-bottom: 4px; }
|
|
1851
|
+
.te-field input, .te-field select { width: 100%; background: var(--bg); border: 1px solid var(--border);
|
|
1852
|
+
border-radius: 5px; color: var(--text); padding: 5px 8px; font-size: 12px; font-family: inherit;
|
|
1853
|
+
outline: none; transition: border-color 0.1s; }
|
|
1854
|
+
.te-field input:focus, .te-field select:focus { border-color: var(--accent); }
|
|
1855
|
+
.te-field textarea { width: 100%; background: var(--bg); border: 1px solid var(--border);
|
|
1856
|
+
border-radius: 5px; color: var(--text); padding: 5px 8px; font-size: 12px; font-family: inherit;
|
|
1857
|
+
outline: none; transition: border-color 0.1s; resize: vertical; min-height: 56px; box-sizing: border-box; }
|
|
1858
|
+
.te-field textarea:focus { border-color: var(--accent); }
|
|
1859
|
+
.te-field select option { background: var(--panel); }
|
|
1860
|
+
.te-actions { display: flex; align-items: center; gap: 8px; margin-top: 18px; }
|
|
1861
|
+
.te-save-msg { font-size: 11px; color: var(--muted); flex: 1; }
|
|
1862
|
+
.te-btn { padding: 5px 16px; border-radius: 5px; border: none; cursor: pointer;
|
|
1863
|
+
font-size: 12px; font-family: inherit; font-weight: 500; transition: opacity 0.1s; }
|
|
1864
|
+
.te-btn-primary { background: var(--accent); color: #fff; }
|
|
1865
|
+
.te-btn-primary:hover { opacity: 0.85; }
|
|
1866
|
+
.te-btn-ghost { background: rgba(255,255,255,0.06); color: var(--text); border: 1px solid var(--border); }
|
|
1867
|
+
.te-btn-ghost:hover { background: rgba(255,255,255,0.1); }
|
|
1868
|
+
|
|
1869
|
+
/* \u2500\u2500 kanban drag \u2500\u2500 */
|
|
1870
|
+
.k-col.drag-over .k-cards { background: rgba(56,139,253,0.08); border-radius: 0 0 6px 6px; }
|
|
1871
|
+
.k-card.dragging { opacity: 0.35; }
|
|
1872
|
+
.k-card[data-line] { cursor: pointer; }
|
|
1873
|
+
.k-card[data-line]:hover { border-color: var(--accent-hi); }
|
|
1874
|
+
.ptask-row[data-line] { cursor: pointer; }
|
|
1875
|
+
.ptask-row[data-line]:hover { background: rgba(56,139,253,0.06); }
|
|
1876
|
+
|
|
1877
|
+
/* \u2500\u2500 gantt hover card \u2500\u2500 */
|
|
1878
|
+
#gantt-hover-card { display:none; position:fixed; z-index:9999; pointer-events:none;
|
|
1879
|
+
background: var(--panel); border: 1px solid var(--border); border-radius: 8px;
|
|
1880
|
+
padding: 10px 12px; min-width: 180px; max-width: 260px;
|
|
1881
|
+
box-shadow: 0 8px 24px rgba(0,0,0,0.25); font-size: 12px; }
|
|
1882
|
+
.ghc-name { font-size: 13px; font-weight: 600; color: var(--text); margin-bottom: 6px; line-height: 1.3; }
|
|
1883
|
+
.ghc-row { display: flex; align-items: center; gap: 6px; color: var(--muted); margin-bottom: 3px; font-size: 11px; }
|
|
1884
|
+
.ghc-status { display: inline-flex; align-items: center; gap: 4px; }
|
|
1885
|
+
.ghc-dot { width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
|
1886
|
+
.ghc-desc { color: var(--muted); font-style: italic; font-size: 11px; margin-top: 6px;
|
|
1887
|
+
padding-top: 6px; border-top: 1px solid var(--border); line-height: 1.4; }
|
|
1888
|
+
.ghc-tags { display: flex; flex-wrap: wrap; gap: 3px; margin-top: 5px; }
|
|
1889
|
+
.ghc-tag { background: var(--border); color: var(--muted); border-radius: 3px; padding: 1px 5px; font-size: 10px; }
|
|
1890
|
+
|
|
1891
|
+
/* \u2500\u2500 shared atoms \u2500\u2500 */
|
|
1892
|
+
.sdot { display: inline-block; width: 7px; height: 7px; border-radius: 50%; flex-shrink: 0; }
|
|
1893
|
+
.avatar { display: inline-flex; align-items: center; justify-content: center;
|
|
1894
|
+
width: 20px; height: 20px; border-radius: 50%; background: var(--border);
|
|
1895
|
+
font-size: 9px; font-weight: 700; color: var(--text); flex-shrink: 0; }
|
|
1896
|
+
.avatar-lg { width: 32px; height: 32px; font-size: 11px; }
|
|
1897
|
+
.loading { color: var(--muted); padding: 40px; text-align: center; font-size: 13px; }
|
|
1898
|
+
.err { color: var(--red) !important; }
|
|
1899
|
+
`;
|
|
1900
|
+
var JS = `
|
|
1901
|
+
var state = {
|
|
1902
|
+
files: [], currentFile: null, view: 'view',
|
|
1903
|
+
blocks: null, source: null, saveStatus: '', saveTimer: null,
|
|
1904
|
+
editTask: null, editCtrlId: null, editBidx: null
|
|
1905
|
+
};
|
|
1906
|
+
|
|
1907
|
+
// Persists the active panel for each yatt ctrl across SSE reloads
|
|
1908
|
+
var ctrlViews = {};
|
|
1909
|
+
|
|
1910
|
+
var STATUS_COLOR = {
|
|
1911
|
+
'new':'#7d8590','active':'#388bfd','done':'#3fb950','blocked':'#f85149',
|
|
1912
|
+
'at-risk':'#f0883e','deferred':'#bc8cff','cancelled':'#30363d',
|
|
1913
|
+
'review':'#d29922','paused':'#484f58'
|
|
1914
|
+
};
|
|
1915
|
+
var STATUS_LABEL = {
|
|
1916
|
+
'new':'New','active':'Active','done':'Done','blocked':'Blocked',
|
|
1917
|
+
'at-risk':'At Risk','deferred':'Deferred','cancelled':'Cancelled',
|
|
1918
|
+
'review':'Review','paused':'Paused'
|
|
1919
|
+
};
|
|
1920
|
+
var KANBAN_COLS = ['new','active','review','blocked','paused','done','cancelled'];
|
|
1921
|
+
|
|
1922
|
+
var STATUS_SIGIL = {
|
|
1923
|
+
'new':' ','active':'~','done':'x','blocked':'!',
|
|
1924
|
+
'at-risk':'?','deferred':'>','cancelled':'_','review':'=','paused':'o'
|
|
1925
|
+
};
|
|
1926
|
+
|
|
1927
|
+
// \u2500\u2500 Task serializer \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1928
|
+
function serializeTaskLine(t) {
|
|
1929
|
+
var sig = STATUS_SIGIL[t.status] || ' ';
|
|
1930
|
+
var dp = t.dotPrefix || '';
|
|
1931
|
+
var head = (dp ? dp + ' ' : '') + '[' + sig + '] ' + (t.name || '');
|
|
1932
|
+
var fields = [];
|
|
1933
|
+
if (t.id) fields.push('id:' + t.id);
|
|
1934
|
+
if (t.duration) fields.push(t.duration);
|
|
1935
|
+
if (t.assignees && t.assignees.length) fields.push(t.assignees.map(function(a){return '@'+a;}).join(' '));
|
|
1936
|
+
if (t.tags && t.tags.length) fields.push(t.tags.map(function(a){return '#'+a;}).join(' '));
|
|
1937
|
+
if (t.priority && t.priority !== 'normal') fields.push('!' + t.priority);
|
|
1938
|
+
if (t.progress != null && t.progress !== '') fields.push('%' + t.progress);
|
|
1939
|
+
if (t.startDate) fields.push('>' + t.startDate);
|
|
1940
|
+
if (t.dueDate) fields.push('<' + t.dueDate);
|
|
1941
|
+
if (t.after) fields.push('after:' + t.after);
|
|
1942
|
+
if (t.modifiers && t.modifiers.length) t.modifiers.forEach(function(m) {
|
|
1943
|
+
var shiftMatch = m.match(/^(delayed|blocked):(.+)$/);
|
|
1944
|
+
if (shiftMatch) { fields.push(shiftMatch[1] + ' ' + shiftMatch[2]); }
|
|
1945
|
+
else { fields.push('+' + m); }
|
|
1946
|
+
});
|
|
1947
|
+
return head + (fields.length ? ' | ' + fields.join(' | ') : '');
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1950
|
+
function patchBlockSource(source, lineNum, newLine) {
|
|
1951
|
+
var lines = source.split('\\n');
|
|
1952
|
+
if (lineNum >= 1 && lineNum <= lines.length) lines[lineNum - 1] = newLine;
|
|
1953
|
+
return lines.join('\\n');
|
|
1954
|
+
}
|
|
1955
|
+
|
|
1956
|
+
function patchBlockSourceWithDescription(source, lineNum, newLine, descText) {
|
|
1957
|
+
var lines = source.split('\\n');
|
|
1958
|
+
if (lineNum < 1 || lineNum > lines.length) return source;
|
|
1959
|
+
// Count existing description comment lines immediately after the task line
|
|
1960
|
+
var oldDescCount = 0;
|
|
1961
|
+
var idx = lineNum; // 0-based index of the line after the task
|
|
1962
|
+
while (idx < lines.length && lines[idx].trimStart().startsWith('//')) {
|
|
1963
|
+
oldDescCount++;
|
|
1964
|
+
idx++;
|
|
1965
|
+
}
|
|
1966
|
+
// Build replacement: task line + optional description comment lines
|
|
1967
|
+
var newLines = [newLine];
|
|
1968
|
+
if (descText && descText.trim()) {
|
|
1969
|
+
descText.trim().split('\\n').forEach(function(dl) {
|
|
1970
|
+
newLines.push('// ' + dl.trim());
|
|
1971
|
+
});
|
|
1972
|
+
}
|
|
1973
|
+
Array.prototype.splice.apply(lines, [lineNum - 1, 1 + oldDescCount].concat(newLines));
|
|
1974
|
+
return lines.join('\\n');
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
function saveBlock(bidx, newSource, onDone) {
|
|
1978
|
+
if (!state.currentFile) return;
|
|
1979
|
+
// Update local cache so in-flight UI doesn't see stale source
|
|
1980
|
+
var yblocks = (state.blocks || []).filter(function(b){ return b.kind === 'yatt'; });
|
|
1981
|
+
if (yblocks[bidx]) yblocks[bidx].source = newSource;
|
|
1982
|
+
fetch('/api/save-block?p=' + encodeURIComponent(state.currentFile) + '&idx=' + bidx, {
|
|
1983
|
+
method: 'POST', headers: {'Content-Type':'text/plain;charset=utf-8'}, body: newSource
|
|
1984
|
+
}).then(function(r) {
|
|
1985
|
+
if (r.ok) { if (onDone) onDone(null); refreshGitStatus(); }
|
|
1986
|
+
else r.json().then(function(d){ if (onDone) onDone(d.error || 'Error'); });
|
|
1987
|
+
}).catch(function(e){ if (onDone) onDone(e.message); });
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1990
|
+
// \u2500\u2500 Task edit popup \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
1991
|
+
function openTaskEdit(task) {
|
|
1992
|
+
if (!task) return;
|
|
1993
|
+
state.editTask = task;
|
|
1994
|
+
document.getElementById('te-name').value = task.name || '';
|
|
1995
|
+
document.getElementById('te-status').value = task.status || 'new';
|
|
1996
|
+
document.getElementById('te-assignees').value = (task.assignees||[]).map(function(a){return '@'+a;}).join(' ');
|
|
1997
|
+
document.getElementById('te-tags').value = (task.tags||[]).map(function(a){return '#'+a;}).join(' ');
|
|
1998
|
+
document.getElementById('te-priority').value = task.priority || 'normal';
|
|
1999
|
+
document.getElementById('te-progress').value = task.progress != null ? task.progress : '';
|
|
2000
|
+
document.getElementById('te-duration').value = task.duration || '';
|
|
2001
|
+
document.getElementById('te-startdate').value = task.startDate || '';
|
|
2002
|
+
document.getElementById('te-duedate').value = task.dueDate || '';
|
|
2003
|
+
document.getElementById('te-id').value = task.id || '';
|
|
2004
|
+
document.getElementById('te-after').value = task.after || '';
|
|
2005
|
+
document.getElementById('te-description').value = task.description || '';
|
|
2006
|
+
var mods = task.modifiers || [];
|
|
2007
|
+
var delayedMod = mods.find(function(m){ return m.match(/^delayed:/); });
|
|
2008
|
+
var blockedMod = mods.find(function(m){ return m.match(/^blocked:/); });
|
|
2009
|
+
document.getElementById('te-delayed').value = delayedMod ? delayedMod.split(':')[1] : '';
|
|
2010
|
+
document.getElementById('te-blocked').value = blockedMod ? blockedMod.split(':')[1] : '';
|
|
2011
|
+
document.getElementById('te-save-msg').textContent = '';
|
|
2012
|
+
document.getElementById('task-edit-overlay').classList.remove('hidden');
|
|
2013
|
+
setTimeout(function(){ document.getElementById('te-name').focus(); }, 50);
|
|
2014
|
+
}
|
|
2015
|
+
|
|
2016
|
+
function closeTaskEdit() {
|
|
2017
|
+
document.getElementById('task-edit-overlay').classList.add('hidden');
|
|
2018
|
+
state.editTask = null;
|
|
2019
|
+
}
|
|
2020
|
+
|
|
2021
|
+
function saveTaskEdit() {
|
|
2022
|
+
var task = state.editTask;
|
|
2023
|
+
if (!task) return;
|
|
2024
|
+
var updated = {};
|
|
2025
|
+
for (var k in task) updated[k] = task[k];
|
|
2026
|
+
updated.name = document.getElementById('te-name').value.trim();
|
|
2027
|
+
updated.status = document.getElementById('te-status').value;
|
|
2028
|
+
var ar = document.getElementById('te-assignees').value.trim();
|
|
2029
|
+
updated.assignees = ar ? ar.split(/\\s+/).map(function(a){return a.replace(/^@/,'');}).filter(Boolean) : [];
|
|
2030
|
+
var tr = document.getElementById('te-tags').value.trim();
|
|
2031
|
+
updated.tags = tr ? tr.split(/\\s+/).map(function(a){return a.replace(/^#/,'');}).filter(Boolean) : [];
|
|
2032
|
+
updated.priority = document.getElementById('te-priority').value || 'normal';
|
|
2033
|
+
var pg = document.getElementById('te-progress').value;
|
|
2034
|
+
updated.progress = pg !== '' ? parseInt(pg, 10) : null;
|
|
2035
|
+
updated.duration = document.getElementById('te-duration').value.trim() || null;
|
|
2036
|
+
updated.startDate = document.getElementById('te-startdate').value.trim() || null;
|
|
2037
|
+
updated.dueDate = document.getElementById('te-duedate').value.trim() || null;
|
|
2038
|
+
updated.id = document.getElementById('te-id').value.trim() || null;
|
|
2039
|
+
updated.after = document.getElementById('te-after').value.trim() || null;
|
|
2040
|
+
updated.description = document.getElementById('te-description').value.trim() || null;
|
|
2041
|
+
// Rebuild modifiers: keep non-shift ones, then append updated delayed/blocked
|
|
2042
|
+
var baseMods = (updated.modifiers || []).filter(function(m){
|
|
2043
|
+
return !m.match(/^(delayed|blocked):/);
|
|
2044
|
+
});
|
|
2045
|
+
var delayedVal = document.getElementById('te-delayed').value.trim();
|
|
2046
|
+
var blockedVal = document.getElementById('te-blocked').value.trim();
|
|
2047
|
+
if (delayedVal) baseMods.push('delayed:' + delayedVal);
|
|
2048
|
+
if (blockedVal) baseMods.push('blocked:' + blockedVal);
|
|
2049
|
+
updated.modifiers = baseMods;
|
|
2050
|
+
|
|
2051
|
+
var bidx = state.editTask.bidx;
|
|
2052
|
+
var yblocks = (state.blocks || []).filter(function(b){ return b.kind === 'yatt'; });
|
|
2053
|
+
var block = yblocks[bidx];
|
|
2054
|
+
if (!block || !block.source) {
|
|
2055
|
+
document.getElementById('te-save-msg').style.color = 'var(--red)';
|
|
2056
|
+
document.getElementById('te-save-msg').textContent = 'Error: block source not found';
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
var newLine = serializeTaskLine(updated);
|
|
2060
|
+
var newSource = patchBlockSourceWithDescription(block.source, task.line, newLine, updated.description);
|
|
2061
|
+
var msgEl = document.getElementById('te-save-msg');
|
|
2062
|
+
msgEl.style.color = 'var(--muted)'; msgEl.textContent = 'Saving...';
|
|
2063
|
+
saveBlock(bidx, newSource, function(err) {
|
|
2064
|
+
if (err) { msgEl.style.color='var(--red)'; msgEl.textContent='Error: '+err; }
|
|
2065
|
+
else { closeTaskEdit(); }
|
|
2066
|
+
});
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
// \u2500\u2500 Yatt ctrl init (called after each block renders) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2070
|
+
function findTask(bidx, line) {
|
|
2071
|
+
var yblocks = (state.blocks || []).filter(function(b){ return b.kind === 'yatt'; });
|
|
2072
|
+
var block = yblocks[bidx];
|
|
2073
|
+
if (!block) return null;
|
|
2074
|
+
return (block.tasks || []).find(function(t){ return t.line === line; }) || null;
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
function initYattCtrl(ctrlId, bidx) {
|
|
2078
|
+
var ctrl = document.getElementById(ctrlId);
|
|
2079
|
+
if (!ctrl) return;
|
|
2080
|
+
ctrl.setAttribute('data-bidx', bidx);
|
|
2081
|
+
|
|
2082
|
+
// Timeline: click + hover on task rows
|
|
2083
|
+
var svgEl = ctrl.querySelector('[data-panel="timeline"] svg');
|
|
2084
|
+
if (svgEl) {
|
|
2085
|
+
svgEl.addEventListener('click', function(e) {
|
|
2086
|
+
var el = e.target;
|
|
2087
|
+
while (el && el !== svgEl) {
|
|
2088
|
+
var dl = el.getAttribute ? el.getAttribute('data-line') : null;
|
|
2089
|
+
if (dl) {
|
|
2090
|
+
var task = findTask(bidx, parseInt(dl));
|
|
2091
|
+
if (task) { task.bidx = bidx; openTaskEdit(task); }
|
|
2092
|
+
return;
|
|
2093
|
+
}
|
|
2094
|
+
el = el.parentElement;
|
|
2095
|
+
}
|
|
2096
|
+
});
|
|
2097
|
+
svgEl.addEventListener('mousemove', function(e) {
|
|
2098
|
+
var el = e.target;
|
|
2099
|
+
while (el && el !== svgEl) {
|
|
2100
|
+
var dl = el.getAttribute ? el.getAttribute('data-line') : null;
|
|
2101
|
+
if (dl) {
|
|
2102
|
+
var task = findTask(bidx, parseInt(dl));
|
|
2103
|
+
if (task) { showGanttHoverCard(task, e.clientX, e.clientY); return; }
|
|
2104
|
+
}
|
|
2105
|
+
el = el.parentElement;
|
|
2106
|
+
}
|
|
2107
|
+
hideGanttHoverCard();
|
|
2108
|
+
});
|
|
2109
|
+
svgEl.addEventListener('mouseleave', hideGanttHoverCard);
|
|
2110
|
+
}
|
|
2111
|
+
|
|
2112
|
+
// Kanban: click-to-edit + drag-drop
|
|
2113
|
+
var kbEl = ctrl.querySelector('[data-panel="kanban"] .ctrl-kanban');
|
|
2114
|
+
if (kbEl) initKanban(kbEl, bidx);
|
|
2115
|
+
|
|
2116
|
+
// People: click-to-edit
|
|
2117
|
+
var peEl = ctrl.querySelector('[data-panel="people"]');
|
|
2118
|
+
if (peEl) {
|
|
2119
|
+
peEl.querySelectorAll('.ptask-row[data-line]').forEach(function(row) {
|
|
2120
|
+
row.addEventListener('click', function() {
|
|
2121
|
+
var task = findTask(bidx, parseInt(row.getAttribute('data-line')));
|
|
2122
|
+
if (task) { task.bidx = bidx; openTaskEdit(task); }
|
|
2123
|
+
});
|
|
2124
|
+
});
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
// Markdown tab: auto-save textarea
|
|
2128
|
+
var mdPanel = ctrl.querySelector('[data-panel="markdown"]');
|
|
2129
|
+
if (mdPanel) {
|
|
2130
|
+
var ta = mdPanel.querySelector('.yatt-block-editor');
|
|
2131
|
+
var statusEl = mdPanel.querySelector('.yatt-block-status');
|
|
2132
|
+
if (ta) {
|
|
2133
|
+
var bsTimer = null;
|
|
2134
|
+
ta.addEventListener('input', function() {
|
|
2135
|
+
if (bsTimer) clearTimeout(bsTimer);
|
|
2136
|
+
if (statusEl) { statusEl.style.color = 'var(--orange)'; statusEl.textContent = 'Unsaved'; }
|
|
2137
|
+
bsTimer = setTimeout(function() {
|
|
2138
|
+
bsTimer = null;
|
|
2139
|
+
saveBlock(bidx, ta.value, function(err) {
|
|
2140
|
+
if (!statusEl) return;
|
|
2141
|
+
statusEl.textContent = err ? 'Error: '+err : 'Saved';
|
|
2142
|
+
statusEl.style.color = err ? 'var(--red)' : 'var(--green)';
|
|
2143
|
+
if (!err) setTimeout(function(){ statusEl.textContent=''; }, 2000);
|
|
2144
|
+
});
|
|
2145
|
+
}, 1200);
|
|
2146
|
+
});
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
}
|
|
2150
|
+
|
|
2151
|
+
function initKanban(kbEl, bidx) {
|
|
2152
|
+
var dragging = null;
|
|
2153
|
+
kbEl.querySelectorAll('.k-card[data-line]').forEach(function(card) {
|
|
2154
|
+
card.setAttribute('draggable', 'true');
|
|
2155
|
+
card.addEventListener('click', function() {
|
|
2156
|
+
if (card.classList.contains('dragging')) return;
|
|
2157
|
+
var task = findTask(bidx, parseInt(card.getAttribute('data-line')));
|
|
2158
|
+
if (task) { task.bidx = bidx; openTaskEdit(task); }
|
|
2159
|
+
});
|
|
2160
|
+
card.addEventListener('dragstart', function(e) {
|
|
2161
|
+
dragging = card; card.classList.add('dragging');
|
|
2162
|
+
e.dataTransfer.effectAllowed = 'move';
|
|
2163
|
+
e.dataTransfer.setData('text/plain', card.getAttribute('data-line'));
|
|
2164
|
+
});
|
|
2165
|
+
card.addEventListener('dragend', function() {
|
|
2166
|
+
card.classList.remove('dragging'); dragging = null;
|
|
2167
|
+
});
|
|
2168
|
+
});
|
|
2169
|
+
kbEl.querySelectorAll('.k-col[data-status]').forEach(function(col) {
|
|
2170
|
+
var newStatus = col.getAttribute('data-status');
|
|
2171
|
+
col.addEventListener('dragover', function(e) { e.preventDefault(); col.classList.add('drag-over'); });
|
|
2172
|
+
col.addEventListener('dragleave', function() { col.classList.remove('drag-over'); });
|
|
2173
|
+
col.addEventListener('drop', function(e) {
|
|
2174
|
+
e.preventDefault(); col.classList.remove('drag-over');
|
|
2175
|
+
var tline = parseInt(e.dataTransfer.getData('text/plain') || '0');
|
|
2176
|
+
var task = findTask(bidx, tline);
|
|
2177
|
+
if (!task || task.status === newStatus) return;
|
|
2178
|
+
var yblocks = (state.blocks||[]).filter(function(b){return b.kind==='yatt';});
|
|
2179
|
+
var block = yblocks[bidx];
|
|
2180
|
+
if (!block||!block.source) return;
|
|
2181
|
+
// Update in memory immediately
|
|
2182
|
+
task.status = newStatus;
|
|
2183
|
+
var updated = {}; for (var k in task) updated[k]=task[k];
|
|
2184
|
+
var newSource = patchBlockSource(block.source, task.line, serializeTaskLine(updated));
|
|
2185
|
+
block.source = newSource;
|
|
2186
|
+
// Rebuild kanban in-place so we stay on the kanban tab
|
|
2187
|
+
kbEl.innerHTML = buildKanbanHtml(block.tasks || []);
|
|
2188
|
+
initKanban(kbEl, bidx);
|
|
2189
|
+
saveBlock(bidx, newSource, null);
|
|
2190
|
+
});
|
|
2191
|
+
});
|
|
2192
|
+
}
|
|
2193
|
+
|
|
2194
|
+
function esc(s) {
|
|
2195
|
+
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
|
2196
|
+
}
|
|
2197
|
+
function sdot(status) {
|
|
2198
|
+
var c = STATUS_COLOR[status] || '#7d8590';
|
|
2199
|
+
return '<span class="sdot" style="background:' + c + '" title="' + esc(status) + '"></span>';
|
|
2200
|
+
}
|
|
2201
|
+
function shiftBadges(task) {
|
|
2202
|
+
var html = '';
|
|
2203
|
+
var mods = task.modifiers || [];
|
|
2204
|
+
mods.forEach(function(m) {
|
|
2205
|
+
var dm = m.match(/^(delayed|blocked):(.+)$/);
|
|
2206
|
+
if (dm) html += '<span class="k-shift-badge ' + dm[1] + '" title="' + dm[1] + ' ' + dm[2] + '">' +
|
|
2207
|
+
(dm[1] === 'delayed' ? '\u23F1' : '\u{1F6AB}') + ' ' + dm[2] + '</span>';
|
|
2208
|
+
});
|
|
2209
|
+
return html;
|
|
2210
|
+
}
|
|
2211
|
+
function avatarEl(name, large) {
|
|
2212
|
+
var clean = name.replace(/[^a-zA-Z]/g,'');
|
|
2213
|
+
var init = (clean.slice(0,2) || name.slice(0,2)).toUpperCase();
|
|
2214
|
+
return '<span class="' + (large ? 'avatar avatar-lg' : 'avatar') + '" title="@' + esc(name) + '">' + esc(init) + '</span>';
|
|
2215
|
+
}
|
|
2216
|
+
|
|
2217
|
+
var _ghcTask = null;
|
|
2218
|
+
function showGanttHoverCard(task, mx, my) {
|
|
2219
|
+
var card = document.getElementById('gantt-hover-card');
|
|
2220
|
+
if (!card) return;
|
|
2221
|
+
if (_ghcTask === task) {
|
|
2222
|
+
// Just reposition
|
|
2223
|
+
positionGhc(card, mx, my);
|
|
2224
|
+
return;
|
|
2225
|
+
}
|
|
2226
|
+
_ghcTask = task;
|
|
2227
|
+
var STATUS_COLOR_MAP = {
|
|
2228
|
+
new:'#93a8c4', active:'#6a9fd8', done:'#6aab85', blocked:'#c97070',
|
|
2229
|
+
'at-risk':'#c9a04a', deferred:'#a892cc', cancelled:'#8a96a6',
|
|
2230
|
+
review:'#8e7ec4', paused:'#7a90a6'
|
|
2231
|
+
};
|
|
2232
|
+
var dotColor = STATUS_COLOR_MAP[task.status] || '#94a3b8';
|
|
2233
|
+
var html = '<div class="ghc-name">' + esc(task.name) + '</div>';
|
|
2234
|
+
html += '<div class="ghc-row ghc-status"><span class="ghc-dot" style="background:' + dotColor + '"></span>' + esc(task.status) + '</div>';
|
|
2235
|
+
if (task.assignees && task.assignees.length) {
|
|
2236
|
+
html += '<div class="ghc-row">\u{1F464} ' + task.assignees.map(function(a){ return '@'+esc(a); }).join(', ') + '</div>';
|
|
2237
|
+
}
|
|
2238
|
+
if (task.progress != null) {
|
|
2239
|
+
html += '<div class="ghc-row">\u25D0 ' + task.progress + '%</div>';
|
|
2240
|
+
}
|
|
2241
|
+
if (task.startDate || task.dueDate) {
|
|
2242
|
+
html += '<div class="ghc-row">\u{1F4C5} ' + esc(task.startDate || '?') + (task.dueDate ? ' \u2192 ' + esc(task.dueDate) : '') + '</div>';
|
|
2243
|
+
}
|
|
2244
|
+
if (task.duration) {
|
|
2245
|
+
html += '<div class="ghc-row">\u23F3 ' + esc(task.duration.value + task.duration.unit) + '</div>';
|
|
2246
|
+
}
|
|
2247
|
+
var mods = (task.modifiers || []);
|
|
2248
|
+
var delayedM = mods.find(function(m){ return m.match(/^delayed:/); });
|
|
2249
|
+
var blockedM = mods.find(function(m){ return m.match(/^blocked:/); });
|
|
2250
|
+
if (delayedM) html += '<div class="ghc-row" style="color:#c9a04a">\u23F1 delayed ' + esc(delayedM.split(':')[1]) + '</div>';
|
|
2251
|
+
if (blockedM) html += '<div class="ghc-row" style="color:#c97070">\u{1F6AB} blocked ' + esc(blockedM.split(':')[1]) + '</div>';
|
|
2252
|
+
if (task.tags && task.tags.length) {
|
|
2253
|
+
html += '<div class="ghc-tags">' + task.tags.map(function(t){ return '<span class="ghc-tag">#'+esc(t)+'</span>'; }).join('') + '</div>';
|
|
2254
|
+
}
|
|
2255
|
+
if (task.description) {
|
|
2256
|
+
html += '<div class="ghc-desc">' + esc(task.description) + '</div>';
|
|
2257
|
+
}
|
|
2258
|
+
card.innerHTML = html;
|
|
2259
|
+
card.style.display = 'block';
|
|
2260
|
+
positionGhc(card, mx, my);
|
|
2261
|
+
}
|
|
2262
|
+
function positionGhc(card, mx, my) {
|
|
2263
|
+
var pad = 14;
|
|
2264
|
+
var w = card.offsetWidth || 220;
|
|
2265
|
+
var h = card.offsetHeight || 120;
|
|
2266
|
+
var left = mx + pad;
|
|
2267
|
+
var top = my - h / 2;
|
|
2268
|
+
if (left + w > window.innerWidth - 8) left = mx - w - pad;
|
|
2269
|
+
if (top < 8) top = 8;
|
|
2270
|
+
if (top + h > window.innerHeight - 8) top = window.innerHeight - h - 8;
|
|
2271
|
+
card.style.left = left + 'px';
|
|
2272
|
+
card.style.top = top + 'px';
|
|
2273
|
+
}
|
|
2274
|
+
function hideGanttHoverCard() {
|
|
2275
|
+
_ghcTask = null;
|
|
2276
|
+
var card = document.getElementById('gantt-hover-card');
|
|
2277
|
+
if (card) card.style.display = 'none';
|
|
2278
|
+
}
|
|
2279
|
+
function inlineMd(s) {
|
|
2280
|
+
var r = esc(s).replace(/\`([^\`]+)\`/g,'<code>$1</code>');
|
|
2281
|
+
return r
|
|
2282
|
+
.replace(/\\*\\*(.+?)\\*\\*/g,'<strong>$1</strong>')
|
|
2283
|
+
.replace(/__(.+?)__/g,'<strong>$1</strong>')
|
|
2284
|
+
.replace(/\\*(.+?)\\*/g,'<em>$1</em>')
|
|
2285
|
+
.replace(/\\[([^\\]]+)\\]\\(([^)]+)\\)/g,'<a href="$2" target="_blank">$1</a>');
|
|
2286
|
+
}
|
|
2287
|
+
function isTableRow(s) { return s.trim().startsWith('|') && s.trim().endsWith('|'); }
|
|
2288
|
+
function isSepRow(s) { return /^\\|[-|: ]+\\|$/.test(s.trim()); }
|
|
2289
|
+
function renderTblRow(s, tag) {
|
|
2290
|
+
return '<tr>' + s.trim().slice(1,-1).split('|').map(function(c) {
|
|
2291
|
+
return '<' + tag + '>' + inlineMd(c.trim()) + '</' + tag + '>';
|
|
2292
|
+
}).join('') + '</tr>';
|
|
2293
|
+
}
|
|
2294
|
+
function simpleMarkdown(md) {
|
|
2295
|
+
var lines = md.split('\\n'), out = [], inList = false, inTable = false, tblHead = false;
|
|
2296
|
+
var inCode = false, codeLang = '', codeBuf = [];
|
|
2297
|
+
for (var i = 0; i < lines.length; i++) {
|
|
2298
|
+
var raw = lines[i];
|
|
2299
|
+
// fenced code blocks
|
|
2300
|
+
if (inCode) {
|
|
2301
|
+
if (raw.trimEnd() === '\`\`\`' || raw.trimEnd() === '~~~') {
|
|
2302
|
+
out.push('<pre><code' + (codeLang ? ' class="lang-' + esc(codeLang) + '"' : '') + '>' +
|
|
2303
|
+
codeBuf.map(function(l) { return esc(l); }).join('\\n') + '</code></pre>');
|
|
2304
|
+
codeBuf = []; inCode = false; codeLang = '';
|
|
2305
|
+
} else { codeBuf.push(raw); }
|
|
2306
|
+
continue;
|
|
2307
|
+
}
|
|
2308
|
+
var fence = raw.match(/^(\`\`\`|~~~)(\\w*)\\s*$/);
|
|
2309
|
+
if (fence) {
|
|
2310
|
+
if (inList) { out.push('</ul>'); inList = false; }
|
|
2311
|
+
if (inTable) { out.push('</tbody></table>'); inTable = false; }
|
|
2312
|
+
inCode = true; codeLang = fence[2] || ''; continue;
|
|
2313
|
+
}
|
|
2314
|
+
// tables
|
|
2315
|
+
if (isTableRow(raw)) {
|
|
2316
|
+
if (!inTable) {
|
|
2317
|
+
if (inList) { out.push('</ul>'); inList = false; }
|
|
2318
|
+
out.push('<table>'); inTable = true; tblHead = true;
|
|
2319
|
+
}
|
|
2320
|
+
if (isSepRow(raw)) { tblHead = false; out.push('<tbody>'); continue; }
|
|
2321
|
+
if (tblHead) out.push('<thead>' + renderTblRow(raw,'th') + '</thead>');
|
|
2322
|
+
else out.push(renderTblRow(raw,'td'));
|
|
2323
|
+
continue;
|
|
2324
|
+
}
|
|
2325
|
+
if (inTable) { out.push('</tbody></table>'); inTable = false; }
|
|
2326
|
+
if (raw.trim() === '') { if (inList) { out.push('</ul>'); inList = false; } continue; }
|
|
2327
|
+
var hm = raw.match(/^(#{1,6})\\s+(.+)$/);
|
|
2328
|
+
if (hm) { if (inList) { out.push('</ul>'); inList = false; }
|
|
2329
|
+
out.push('<h'+hm[1].length+'>'+inlineMd(hm[2])+'</h'+hm[1].length+'>'); continue; }
|
|
2330
|
+
var bq = raw.match(/^>\\s*(.*)/);
|
|
2331
|
+
if (bq) { if (inList) { out.push('</ul>'); inList = false; }
|
|
2332
|
+
out.push('<blockquote>'+inlineMd(bq[1])+'</blockquote>'); continue; }
|
|
2333
|
+
var li = raw.match(/^[-*+]\\s+(.+)$/);
|
|
2334
|
+
if (li) { if (!inList) { out.push('<ul>'); inList = true; }
|
|
2335
|
+
out.push('<li>'+inlineMd(li[1])+'</li>'); continue; }
|
|
2336
|
+
if (inList) { out.push('</ul>'); inList = false; }
|
|
2337
|
+
if (raw.trim() === '---' || raw.trim() === '***') { out.push('<hr>'); continue; }
|
|
2338
|
+
out.push('<p>'+inlineMd(raw)+'</p>');
|
|
2339
|
+
}
|
|
2340
|
+
if (inList) out.push('</ul>');
|
|
2341
|
+
if (inTable) out.push('</tbody></table>');
|
|
2342
|
+
if (inCode) out.push('<pre><code>' + codeBuf.map(function(l){return esc(l);}).join('\\n') + '</code></pre>');
|
|
2343
|
+
return out.join('\\n');
|
|
2344
|
+
}
|
|
2345
|
+
|
|
2346
|
+
// \u2500\u2500 Yatt control (per-block independent widget) \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2347
|
+
|
|
2348
|
+
// Called from onclick attributes injected into innerHTML \u2014 must be top-level
|
|
2349
|
+
function yattCtrlSetView(ctrlId, panel) {
|
|
2350
|
+
ctrlViews[ctrlId] = panel;
|
|
2351
|
+
var ctrl = document.getElementById(ctrlId);
|
|
2352
|
+
if (!ctrl) return;
|
|
2353
|
+
ctrl.querySelectorAll('.yatt-ctrl-tab').forEach(function(btn) {
|
|
2354
|
+
btn.classList.toggle('active', btn.getAttribute('data-panel') === panel);
|
|
2355
|
+
});
|
|
2356
|
+
ctrl.querySelectorAll('.yatt-ctrl-panel').forEach(function(p) {
|
|
2357
|
+
p.classList.toggle('active', p.getAttribute('data-panel') === panel);
|
|
2358
|
+
});
|
|
2359
|
+
}
|
|
2360
|
+
|
|
2361
|
+
function buildKanbanHtml(tasks) {
|
|
2362
|
+
var byStatus = {};
|
|
2363
|
+
KANBAN_COLS.forEach(function(s) { byStatus[s] = []; });
|
|
2364
|
+
(tasks || []).forEach(function(t) {
|
|
2365
|
+
var s = t.status || 'new';
|
|
2366
|
+
if (!byStatus[s]) byStatus[s] = [];
|
|
2367
|
+
byStatus[s].push(t);
|
|
2368
|
+
});
|
|
2369
|
+
var html = '';
|
|
2370
|
+
KANBAN_COLS.forEach(function(status) {
|
|
2371
|
+
var cards = byStatus[status] || [];
|
|
2372
|
+
var color = STATUS_COLOR[status] || '#7d8590';
|
|
2373
|
+
var label = STATUS_LABEL[status] || status;
|
|
2374
|
+
var isEmpty = cards.length === 0;
|
|
2375
|
+
html += '<div class="k-col' + (isEmpty ? ' empty' : '') + '" data-status="' + esc(status) + '">';
|
|
2376
|
+
if (isEmpty) {
|
|
2377
|
+
html += '<div class="k-col-empty-label">';
|
|
2378
|
+
html += '<span class="k-col-empty-line" style="background:' + color + '"></span>';
|
|
2379
|
+
html += '<span class="k-col-empty-title" style="color:' + color + '">' + esc(label) + '</span>';
|
|
2380
|
+
html += '</div>';
|
|
2381
|
+
} else {
|
|
2382
|
+
html += '<div class="k-col-header">';
|
|
2383
|
+
html += '<span class="k-col-line" style="background:' + color + '"></span>';
|
|
2384
|
+
html += '<span class="k-col-title">' + esc(label) + '</span>';
|
|
2385
|
+
html += '<span class="k-col-count">' + cards.length + '</span>';
|
|
2386
|
+
html += '</div>';
|
|
2387
|
+
}
|
|
2388
|
+
html += '<div class="k-cards">';
|
|
2389
|
+
cards.forEach(function(t) {
|
|
2390
|
+
var indent = t.depth > 0 ? 'padding-left:' + (8 + t.depth * 10) + 'px;' : '';
|
|
2391
|
+
var lineAttr = t.line ? ' data-line="' + t.line + '"' : '';
|
|
2392
|
+
html += '<div class="k-card" style="' + indent + '"' + lineAttr + '>';
|
|
2393
|
+
html += '<div class="k-card-name">' + esc(t.name) + '</div>';
|
|
2394
|
+
if (t.description) {
|
|
2395
|
+
html += '<div class="k-card-desc">' + esc(t.description) + '</div>';
|
|
2396
|
+
}
|
|
2397
|
+
var sb = shiftBadges(t);
|
|
2398
|
+
if (sb) html += '<div class="k-card-shifts">' + sb + '</div>';
|
|
2399
|
+
html += '<div class="k-card-meta">';
|
|
2400
|
+
if (t.assignees && t.assignees.length) {
|
|
2401
|
+
t.assignees.slice(0,3).forEach(function(a) { html += avatarEl(a, false); });
|
|
2402
|
+
}
|
|
2403
|
+
if (t.priority && t.priority !== 'normal') {
|
|
2404
|
+
html += '<span class="k-priority" data-p="' + esc(t.priority) + '">' + esc(t.priority) + '</span>';
|
|
2405
|
+
}
|
|
2406
|
+
html += '</div>';
|
|
2407
|
+
if (t.progress != null) {
|
|
2408
|
+
html += '<div class="k-progress"><div class="k-progress-fill" style="width:' + t.progress + '%"></div></div>';
|
|
2409
|
+
}
|
|
2410
|
+
html += '</div>';
|
|
2411
|
+
});
|
|
2412
|
+
html += '</div></div>';
|
|
2413
|
+
});
|
|
2414
|
+
if (!html) html = '<div class="loading" style="padding:20px">No tasks.</div>';
|
|
2415
|
+
return html;
|
|
2416
|
+
}
|
|
2417
|
+
|
|
2418
|
+
function buildPeopleHtml(tasks) {
|
|
2419
|
+
var byPerson = {};
|
|
2420
|
+
(tasks || []).forEach(function(t) {
|
|
2421
|
+
var people = t.assignees && t.assignees.length ? t.assignees : ['(unassigned)'];
|
|
2422
|
+
people.forEach(function(p) {
|
|
2423
|
+
if (!byPerson[p]) byPerson[p] = [];
|
|
2424
|
+
byPerson[p].push(t);
|
|
2425
|
+
});
|
|
2426
|
+
});
|
|
2427
|
+
var names = Object.keys(byPerson).sort(function(a,b) {
|
|
2428
|
+
if (a==='(unassigned)') return 1; if (b==='(unassigned)') return -1;
|
|
2429
|
+
return a.localeCompare(b);
|
|
2430
|
+
});
|
|
2431
|
+
if (!names.length) return '<div class="loading" style="padding:20px">No tasks.</div>';
|
|
2432
|
+
var html = '';
|
|
2433
|
+
names.forEach(function(name) {
|
|
2434
|
+
var list = byPerson[name];
|
|
2435
|
+
var isU = name === '(unassigned)';
|
|
2436
|
+
html += '<div class="person-card"><div class="person-header">';
|
|
2437
|
+
if (!isU) html += avatarEl(name, true);
|
|
2438
|
+
html += '<div><div class="person-name">' + esc(isU ? 'Unassigned' : '@' + name) + '</div>';
|
|
2439
|
+
html += '<div class="person-count">' + list.length + ' task' + (list.length===1?'':'s') + '</div></div></div>';
|
|
2440
|
+
list.forEach(function(t) {
|
|
2441
|
+
var lineAttr = t.line ? ' data-line="' + t.line + '"' : '';
|
|
2442
|
+
html += '<div class="ptask-row"' + lineAttr + '>' + sdot(t.status);
|
|
2443
|
+
html += '<span class="ptask-name">' + esc(t.name) + '</span>';
|
|
2444
|
+
var sb = shiftBadges(t);
|
|
2445
|
+
if (sb) html += sb;
|
|
2446
|
+
if (t.priority && t.priority !== 'normal')
|
|
2447
|
+
html += '<span class="ptask-priority" data-p="' + esc(t.priority) + '">' + esc(t.priority) + '</span>';
|
|
2448
|
+
if (t.progress != null) html += '<span class="ptask-prog">' + t.progress + '%</span>';
|
|
2449
|
+
html += '</div>';
|
|
2450
|
+
});
|
|
2451
|
+
html += '</div>';
|
|
2452
|
+
});
|
|
2453
|
+
return html;
|
|
2454
|
+
}
|
|
2455
|
+
|
|
2456
|
+
function buildYattCtrlHtml(block, ctrlId, bidx) {
|
|
2457
|
+
var errHtml = block.errors && block.errors.length
|
|
2458
|
+
? '<div class="yatt-errors">' + block.errors.map(esc).join('<br>') + '</div>' : '';
|
|
2459
|
+
var tl = '<div class="yatt-ctrl-panel active" data-panel="timeline">' + (block.html || '') + '</div>';
|
|
2460
|
+
var kb = '<div class="yatt-ctrl-panel" data-panel="kanban"><div class="ctrl-kanban">' +
|
|
2461
|
+
buildKanbanHtml(block.tasks || []) + '</div></div>';
|
|
2462
|
+
var pe = '<div class="yatt-ctrl-panel" data-panel="people"><div class="ctrl-people">' +
|
|
2463
|
+
buildPeopleHtml(block.tasks || []) + '</div></div>';
|
|
2464
|
+
var md = '<div class="yatt-ctrl-panel" data-panel="markdown">' +
|
|
2465
|
+
'<textarea class="yatt-block-editor" spellcheck="false">' + esc(block.source || '') + '</textarea>' +
|
|
2466
|
+
'<div class="yatt-block-bar"><span class="yatt-block-status"></span></div>' +
|
|
2467
|
+
'</div>';
|
|
2468
|
+
var tabs =
|
|
2469
|
+
'<button class="yatt-ctrl-tab active" data-panel="timeline" onclick="yattCtrlSetView(\\'' + ctrlId + '\\',\\'timeline\\')">Timeline</button>' +
|
|
2470
|
+
'<button class="yatt-ctrl-tab" data-panel="kanban" onclick="yattCtrlSetView(\\'' + ctrlId + '\\',\\'kanban\\')">Kanban</button>' +
|
|
2471
|
+
'<button class="yatt-ctrl-tab" data-panel="people" onclick="yattCtrlSetView(\\'' + ctrlId + '\\',\\'people\\')">People</button>' +
|
|
2472
|
+
'<button class="yatt-ctrl-tab" data-panel="markdown" onclick="yattCtrlSetView(\\'' + ctrlId + '\\',\\'markdown\\')">Edit</button>';
|
|
2473
|
+
return '<div class="yatt-ctrl" id="' + ctrlId + '" data-bidx="' + bidx + '">' +
|
|
2474
|
+
errHtml +
|
|
2475
|
+
'<div class="yatt-ctrl-bar">' + tabs + '</div>' +
|
|
2476
|
+
'<div class="yatt-ctrl-body">' + tl + kb + pe + md + '</div>' +
|
|
2477
|
+
'</div>';
|
|
2478
|
+
}
|
|
2479
|
+
|
|
2480
|
+
// \u2500\u2500 Document view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2481
|
+
|
|
2482
|
+
function renderViewPanel() {
|
|
2483
|
+
var el = document.getElementById('doc-content');
|
|
2484
|
+
if (!el) return;
|
|
2485
|
+
if (state.blocks) { buildDocument(state.blocks, el); return; }
|
|
2486
|
+
el.innerHTML = '<div class="loading">Loading\u2026</div>';
|
|
2487
|
+
fetch('/api/render?p=' + encodeURIComponent(state.currentFile))
|
|
2488
|
+
.then(function(r) { return r.json(); })
|
|
2489
|
+
.then(function(d) { state.blocks = d.blocks || []; buildDocument(state.blocks, el); })
|
|
2490
|
+
.catch(function(e) { el.innerHTML = '<div class="loading err">' + esc(e.message) + '</div>'; });
|
|
2491
|
+
}
|
|
2492
|
+
|
|
2493
|
+
function buildDocument(blocks, container) {
|
|
2494
|
+
if (!blocks || !blocks.length) {
|
|
2495
|
+
container.innerHTML = '<div class="loading">No content.</div>'; return;
|
|
2496
|
+
}
|
|
2497
|
+
var html = '', ctrlIdx = 0, ctrlList = [];
|
|
2498
|
+
blocks.forEach(function(b) {
|
|
2499
|
+
if (b.kind === 'heading') {
|
|
2500
|
+
html += '<div class="block-h' + b.level + '">' + inlineMd(b.text) + '</div>';
|
|
2501
|
+
} else if (b.kind === 'prose') {
|
|
2502
|
+
html += '<div class="prose">' + simpleMarkdown(b.text) + '</div>';
|
|
2503
|
+
} else if (b.kind === 'yatt') {
|
|
2504
|
+
var bidx = ctrlIdx++;
|
|
2505
|
+
html += buildYattCtrlHtml(b, 'yatt-' + bidx, bidx);
|
|
2506
|
+
ctrlList.push(bidx);
|
|
2507
|
+
}
|
|
2508
|
+
});
|
|
2509
|
+
container.innerHTML = html;
|
|
2510
|
+
ctrlList.forEach(function(bidx) {
|
|
2511
|
+
var ctrlId = 'yatt-' + bidx;
|
|
2512
|
+
initYattCtrl(ctrlId, bidx);
|
|
2513
|
+
var saved = ctrlViews[ctrlId];
|
|
2514
|
+
if (saved && saved !== 'timeline') yattCtrlSetView(ctrlId, saved);
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
// \u2500\u2500 Markdown/edit view \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2519
|
+
|
|
2520
|
+
function renderMarkdownView() {
|
|
2521
|
+
var ta = document.getElementById('editor');
|
|
2522
|
+
if (!ta) return;
|
|
2523
|
+
if (state.source !== null) { ta.value = state.source; return; }
|
|
2524
|
+
fetch('/api/source?p=' + encodeURIComponent(state.currentFile))
|
|
2525
|
+
.then(function(r) { return r.text(); })
|
|
2526
|
+
.then(function(src) { state.source = src; ta.value = src; })
|
|
2527
|
+
.catch(function(e) { ta.value = '// Error: ' + e.message; });
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
function doSave() {
|
|
2531
|
+
if (!state.currentFile) return;
|
|
2532
|
+
var ta = document.getElementById('editor');
|
|
2533
|
+
if (!ta) return;
|
|
2534
|
+
var src = ta.value; state.source = src;
|
|
2535
|
+
setSaveStatus('unsaved', 'Saving\u2026');
|
|
2536
|
+
fetch('/api/save?p=' + encodeURIComponent(state.currentFile), {
|
|
2537
|
+
method: 'POST', headers: { 'Content-Type': 'text/plain; charset=utf-8' }, body: src
|
|
2538
|
+
})
|
|
2539
|
+
.then(function(r) {
|
|
2540
|
+
if (r.ok) setSaveStatus('saved', 'Saved');
|
|
2541
|
+
else r.json().then(function(d) { setSaveStatus('error', d.error || ('HTTP ' + r.status)); });
|
|
2542
|
+
})
|
|
2543
|
+
.catch(function(e) { setSaveStatus('error', e.message); });
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
function setSaveStatus(cls, text) {
|
|
2547
|
+
var el = document.getElementById('save-status');
|
|
2548
|
+
if (!el) return;
|
|
2549
|
+
el.className = cls; el.textContent = text;
|
|
2550
|
+
if (cls === 'saved') setTimeout(function() {
|
|
2551
|
+
if (el.textContent === 'Saved') { el.textContent = ''; el.className = ''; }
|
|
2552
|
+
}, 2500);
|
|
2553
|
+
}
|
|
2554
|
+
|
|
2555
|
+
// \u2500\u2500 Page-level view switching \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2556
|
+
|
|
2557
|
+
function setView(v) {
|
|
2558
|
+
if (state.saveTimer && v !== 'markdown') {
|
|
2559
|
+
clearTimeout(state.saveTimer); state.saveTimer = null; doSave();
|
|
2560
|
+
}
|
|
2561
|
+
state.view = v;
|
|
2562
|
+
var url = new URL(window.location.href);
|
|
2563
|
+
url.searchParams.set('v', v);
|
|
2564
|
+
history.replaceState(history.state, '', url.toString());
|
|
2565
|
+
document.querySelectorAll('.tab').forEach(function(btn) {
|
|
2566
|
+
btn.classList.toggle('active', btn.getAttribute('data-view') === v);
|
|
2567
|
+
});
|
|
2568
|
+
['view','markdown'].forEach(function(n) {
|
|
2569
|
+
var el = document.getElementById('view-' + n);
|
|
2570
|
+
if (el) el.hidden = (n !== v);
|
|
2571
|
+
});
|
|
2572
|
+
renderCurrentView();
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
function renderCurrentView() {
|
|
2576
|
+
if (!state.currentFile) return;
|
|
2577
|
+
if (state.view === 'view') renderViewPanel();
|
|
2578
|
+
else if (state.view === 'markdown') renderMarkdownView();
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2581
|
+
// \u2500\u2500 Sidebar \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2582
|
+
|
|
2583
|
+
function buildSidebar(files) {
|
|
2584
|
+
state.files = files;
|
|
2585
|
+
var groups = {};
|
|
2586
|
+
files.forEach(function(f) {
|
|
2587
|
+
var parts = f.split('/');
|
|
2588
|
+
var dir = parts.length > 1 ? parts.slice(0,-1).join('/') : '';
|
|
2589
|
+
if (!groups[dir]) groups[dir] = [];
|
|
2590
|
+
groups[dir].push(f);
|
|
2591
|
+
});
|
|
2592
|
+
var keys = Object.keys(groups).sort(function(a,b) {
|
|
2593
|
+
if (a==='') return -1; if (b==='') return 1; return a.localeCompare(b);
|
|
2594
|
+
});
|
|
2595
|
+
var html = '';
|
|
2596
|
+
keys.forEach(function(dir) {
|
|
2597
|
+
html += '<div class="group-label">' + esc(dir===''?'(root)':dir+'/') + '</div>';
|
|
2598
|
+
groups[dir].forEach(function(f) {
|
|
2599
|
+
var name = f.split('/').pop();
|
|
2600
|
+
html += '<a class="file-item' + (f===state.currentFile?' active':'') + '" data-p="' + esc(f) + '" title="' + esc(f) + '">' + esc(name) + '</a>';
|
|
2601
|
+
});
|
|
2602
|
+
});
|
|
2603
|
+
document.getElementById('sidebar-inner').innerHTML = html;
|
|
2604
|
+
var fc = document.getElementById('file-count');
|
|
2605
|
+
if (fc) fc.textContent = files.length + ' file' + (files.length===1?'':'s');
|
|
2606
|
+
document.querySelectorAll('.file-item').forEach(function(el) {
|
|
2607
|
+
el.addEventListener('click', function() { navigateTo(el.getAttribute('data-p')); });
|
|
2608
|
+
});
|
|
2609
|
+
}
|
|
2610
|
+
|
|
2611
|
+
// \u2500\u2500 Navigation \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2612
|
+
|
|
2613
|
+
function loadFile(p) {
|
|
2614
|
+
state.currentFile = p; state.blocks = null; state.source = null;
|
|
2615
|
+
document.querySelectorAll('.file-item').forEach(function(el) {
|
|
2616
|
+
el.classList.toggle('active', el.getAttribute('data-p') === p);
|
|
2617
|
+
});
|
|
2618
|
+
renderCurrentView();
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
function loadFileList() {
|
|
2622
|
+
return fetch('/api/files')
|
|
2623
|
+
.then(function(r) { return r.json(); })
|
|
2624
|
+
.then(function(files) { buildSidebar(files); return files; });
|
|
2625
|
+
}
|
|
2626
|
+
|
|
2627
|
+
function navigateTo(p) {
|
|
2628
|
+
var u = new URL(window.location.href);
|
|
2629
|
+
u.searchParams.set('p', p);
|
|
2630
|
+
history.pushState({ p: p }, '', u.toString());
|
|
2631
|
+
loadFile(p);
|
|
2632
|
+
}
|
|
2633
|
+
|
|
2634
|
+
// \u2500\u2500 SSE live reload \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2635
|
+
|
|
2636
|
+
function connectSse() {
|
|
2637
|
+
var es = new EventSource('/events');
|
|
2638
|
+
var debounce = null;
|
|
2639
|
+
es.onmessage = function(e) {
|
|
2640
|
+
if (e.data === 'reload') {
|
|
2641
|
+
clearTimeout(debounce);
|
|
2642
|
+
debounce = setTimeout(function() {
|
|
2643
|
+
loadFileList().then(function() {
|
|
2644
|
+
if (state.view === 'markdown') return;
|
|
2645
|
+
if (state.currentFile) { state.blocks = null; renderCurrentView(); }
|
|
2646
|
+
});
|
|
2647
|
+
}, 250);
|
|
2648
|
+
}
|
|
2649
|
+
};
|
|
2650
|
+
es.onerror = function() { es.close(); setTimeout(connectSse, 2000); };
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
// \u2500\u2500 Init \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2654
|
+
|
|
2655
|
+
window.addEventListener('DOMContentLoaded', function() {
|
|
2656
|
+
var fp = document.getElementById('folder-path');
|
|
2657
|
+
if (fp && typeof ROOT_FOLDER !== 'undefined') fp.textContent = ROOT_FOLDER;
|
|
2658
|
+
|
|
2659
|
+
document.querySelectorAll('.tab').forEach(function(btn) {
|
|
2660
|
+
btn.addEventListener('click', function() { setView(btn.getAttribute('data-view')); });
|
|
2661
|
+
});
|
|
2662
|
+
|
|
2663
|
+
var editor = document.getElementById('editor');
|
|
2664
|
+
if (editor) {
|
|
2665
|
+
editor.addEventListener('input', function() {
|
|
2666
|
+
state.source = editor.value;
|
|
2667
|
+
if (state.saveTimer) clearTimeout(state.saveTimer);
|
|
2668
|
+
setSaveStatus('unsaved', 'Unsaved');
|
|
2669
|
+
state.saveTimer = setTimeout(function() { state.saveTimer = null; doSave(); }, 1200);
|
|
2670
|
+
});
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
var params = new URLSearchParams(window.location.search);
|
|
2674
|
+
var initial = params.get('p');
|
|
2675
|
+
var initialView = params.get('v') || 'view';
|
|
2676
|
+
|
|
2677
|
+
state.view = initialView;
|
|
2678
|
+
document.querySelectorAll('.tab').forEach(function(btn) {
|
|
2679
|
+
btn.classList.toggle('active', btn.getAttribute('data-view') === initialView);
|
|
2680
|
+
});
|
|
2681
|
+
['view','markdown'].forEach(function(n) {
|
|
2682
|
+
var el = document.getElementById('view-' + n);
|
|
2683
|
+
if (el) el.hidden = (n !== initialView);
|
|
2684
|
+
});
|
|
2685
|
+
|
|
2686
|
+
// Task edit overlay: close on backdrop click or Escape
|
|
2687
|
+
var overlay = document.getElementById('task-edit-overlay');
|
|
2688
|
+
if (overlay) {
|
|
2689
|
+
overlay.addEventListener('click', function(e) {
|
|
2690
|
+
if (e.target === overlay) closeTaskEdit();
|
|
2691
|
+
});
|
|
2692
|
+
}
|
|
2693
|
+
document.addEventListener('keydown', function(e) {
|
|
2694
|
+
if (e.key === 'Escape') closeTaskEdit();
|
|
2695
|
+
});
|
|
2696
|
+
|
|
2697
|
+
// Popup form: submit on Enter in inputs (not textarea)
|
|
2698
|
+
document.addEventListener('keydown', function(e) {
|
|
2699
|
+
if (e.key === 'Enter' && state.editTask) {
|
|
2700
|
+
var tag = (e.target && e.target.tagName) ? e.target.tagName.toLowerCase() : '';
|
|
2701
|
+
if (tag === 'input' || tag === 'select') { e.preventDefault(); saveTaskEdit(); }
|
|
2702
|
+
}
|
|
2703
|
+
});
|
|
2704
|
+
|
|
2705
|
+
loadFileList().then(function(files) {
|
|
2706
|
+
if (initial && files.includes(initial)) loadFile(initial);
|
|
2707
|
+
else if (files.length > 0) navigateTo(files[0]);
|
|
2708
|
+
else {
|
|
2709
|
+
var c = document.getElementById('doc-content');
|
|
2710
|
+
if (c) c.innerHTML = '<div class="loading">No .md files found.</div>';
|
|
2711
|
+
}
|
|
2712
|
+
});
|
|
2713
|
+
|
|
2714
|
+
connectSse();
|
|
2715
|
+
});
|
|
2716
|
+
|
|
2717
|
+
window.addEventListener('popstate', function(e) {
|
|
2718
|
+
if (e.state && e.state.p) loadFile(e.state.p);
|
|
2719
|
+
});
|
|
2720
|
+
|
|
2721
|
+
// \u2500\u2500 Git integration \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500
|
|
2722
|
+
|
|
2723
|
+
var gitState = { available: false, dirty: false, ahead: 0, behind: 0, branch: '', hasRemote: false };
|
|
2724
|
+
var gitBusy = false;
|
|
2725
|
+
|
|
2726
|
+
function refreshGitStatus() {
|
|
2727
|
+
fetch('/api/git/status').then(function(r){ return r.json(); }).then(function(s) {
|
|
2728
|
+
gitState = s;
|
|
2729
|
+
renderGitWidget();
|
|
2730
|
+
}).catch(function(){});
|
|
2731
|
+
}
|
|
2732
|
+
|
|
2733
|
+
function renderGitWidget() {
|
|
2734
|
+
var w = document.getElementById('git-widget');
|
|
2735
|
+
if (!w) return;
|
|
2736
|
+
if (!gitState.available) { w.classList.add('hidden'); return; }
|
|
2737
|
+
w.classList.remove('hidden');
|
|
2738
|
+
|
|
2739
|
+
document.getElementById('git-dot').className = 'git-dot' + (gitState.dirty ? ' dirty' : '');
|
|
2740
|
+
document.getElementById('git-branch-name').textContent = gitState.branch;
|
|
2741
|
+
|
|
2742
|
+
var behindEl = document.getElementById('git-behind');
|
|
2743
|
+
var aheadEl = document.getElementById('git-ahead');
|
|
2744
|
+
if (gitState.behind > 0) {
|
|
2745
|
+
document.getElementById('git-behind-n').textContent = gitState.behind;
|
|
2746
|
+
behindEl.classList.remove('hidden');
|
|
2747
|
+
} else { behindEl.classList.add('hidden'); }
|
|
2748
|
+
if (gitState.ahead > 0) {
|
|
2749
|
+
document.getElementById('git-ahead-n').textContent = gitState.ahead;
|
|
2750
|
+
aheadEl.classList.remove('hidden');
|
|
2751
|
+
} else { aheadEl.classList.add('hidden'); }
|
|
2752
|
+
|
|
2753
|
+
document.getElementById('git-pull-btn').style.display = gitState.hasRemote ? '' : 'none';
|
|
2754
|
+
document.getElementById('git-push-btn').style.display = gitState.hasRemote ? '' : 'none';
|
|
2755
|
+
document.getElementById('git-commit-btn').disabled = gitBusy;
|
|
2756
|
+
document.getElementById('git-pull-btn').disabled = gitBusy;
|
|
2757
|
+
document.getElementById('git-push-btn').disabled = gitBusy;
|
|
2758
|
+
}
|
|
2759
|
+
|
|
2760
|
+
function setGitMsg(msg, type) {
|
|
2761
|
+
var el = document.getElementById('git-msg');
|
|
2762
|
+
el.textContent = msg;
|
|
2763
|
+
el.className = type === 'ok' ? 'ok' : type === 'err' ? 'err' : '';
|
|
2764
|
+
if (msg && type !== 'err') setTimeout(function(){ if (el.textContent === msg) el.textContent = ''; }, 4000);
|
|
2765
|
+
}
|
|
2766
|
+
|
|
2767
|
+
function gitDoPull() {
|
|
2768
|
+
if (gitBusy) return;
|
|
2769
|
+
gitBusy = true; renderGitWidget(); setGitMsg('Pulling\u2026', '');
|
|
2770
|
+
fetch('/api/git/pull', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(r) {
|
|
2771
|
+
gitBusy = false;
|
|
2772
|
+
if (r.conflict) { setGitMsg('Merge conflict \u2014 resolve from CLI', 'err'); }
|
|
2773
|
+
else if (!r.ok) { setGitMsg('Pull failed \u2014 ' + (r.output || r.error || '').slice(0, 60), 'err'); }
|
|
2774
|
+
else { setGitMsg('Pulled \u2713', 'ok'); }
|
|
2775
|
+
refreshGitStatus();
|
|
2776
|
+
}).catch(function(e){ gitBusy = false; setGitMsg('Pull error', 'err'); refreshGitStatus(); });
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
function gitDoPush() {
|
|
2780
|
+
if (gitBusy) return;
|
|
2781
|
+
gitBusy = true; renderGitWidget(); setGitMsg('Pushing\u2026', '');
|
|
2782
|
+
fetch('/api/git/push', { method: 'POST' }).then(function(r){ return r.json(); }).then(function(r) {
|
|
2783
|
+
gitBusy = false;
|
|
2784
|
+
if (r.rejected) { setGitMsg('Push rejected \u2014 pull first or use CLI', 'err'); }
|
|
2785
|
+
else if (!r.ok) { setGitMsg('Push failed \u2014 ' + (r.output || r.error || '').slice(0, 60), 'err'); }
|
|
2786
|
+
else { setGitMsg('Pushed \u2713', 'ok'); }
|
|
2787
|
+
refreshGitStatus();
|
|
2788
|
+
}).catch(function(e){ gitBusy = false; setGitMsg('Push error', 'err'); refreshGitStatus(); });
|
|
2789
|
+
}
|
|
2790
|
+
|
|
2791
|
+
function openCommitModal() {
|
|
2792
|
+
var now = new Date();
|
|
2793
|
+
var ts = now.getFullYear() + '-' +
|
|
2794
|
+
String(now.getMonth()+1).padStart(2,'0') + '-' +
|
|
2795
|
+
String(now.getDate()).padStart(2,'0') + ' ' +
|
|
2796
|
+
String(now.getHours()).padStart(2,'0') + ':' +
|
|
2797
|
+
String(now.getMinutes()).padStart(2,'0');
|
|
2798
|
+
document.getElementById('git-commit-msg').value = 'Update tasks ' + ts;
|
|
2799
|
+
document.getElementById('git-commit-modal').classList.remove('hidden');
|
|
2800
|
+
setTimeout(function(){ document.getElementById('git-commit-msg').focus(); }, 50);
|
|
2801
|
+
}
|
|
2802
|
+
|
|
2803
|
+
function closeCommitModal() {
|
|
2804
|
+
document.getElementById('git-commit-modal').classList.add('hidden');
|
|
2805
|
+
}
|
|
2806
|
+
|
|
2807
|
+
function gitDoCommit() {
|
|
2808
|
+
var msg = document.getElementById('git-commit-msg').value.trim();
|
|
2809
|
+
if (!msg) return;
|
|
2810
|
+
closeCommitModal();
|
|
2811
|
+
gitBusy = true; renderGitWidget(); setGitMsg('Committing\u2026', '');
|
|
2812
|
+
fetch('/api/git/commit', {
|
|
2813
|
+
method: 'POST',
|
|
2814
|
+
headers: { 'Content-Type': 'application/json' },
|
|
2815
|
+
body: JSON.stringify({ message: msg })
|
|
2816
|
+
}).then(function(r){ return r.json(); }).then(function(r) {
|
|
2817
|
+
gitBusy = false;
|
|
2818
|
+
if (r.noop) { setGitMsg('Nothing to commit', ''); }
|
|
2819
|
+
else if (!r.ok) { setGitMsg('Commit failed \u2014 ' + (r.error || r.output || '').slice(0, 60), 'err'); }
|
|
2820
|
+
else { setGitMsg('Committed \u2713', 'ok'); }
|
|
2821
|
+
refreshGitStatus();
|
|
2822
|
+
}).catch(function(e){ gitBusy = false; setGitMsg('Commit error', 'err'); refreshGitStatus(); });
|
|
2823
|
+
}
|
|
2824
|
+
|
|
2825
|
+
// Initial status fetch + poll every 30s
|
|
2826
|
+
refreshGitStatus();
|
|
2827
|
+
setInterval(refreshGitStatus, 30000);
|
|
2828
|
+
`;
|
|
2829
|
+
function buildShellHtml(rootDir) {
|
|
2830
|
+
return `<!DOCTYPE html>
|
|
2831
|
+
<html lang="en">
|
|
2832
|
+
<head>
|
|
2833
|
+
<meta charset="UTF-8">
|
|
2834
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
2835
|
+
<title>YATT Viewer</title>
|
|
2836
|
+
<style>${CSS}</style>
|
|
2837
|
+
</head>
|
|
2838
|
+
<body>
|
|
2839
|
+
<div id="app">
|
|
2840
|
+
<header id="topbar">
|
|
2841
|
+
<span class="logo">YATT</span>
|
|
2842
|
+
<span id="folder-path"></span>
|
|
2843
|
+
<div id="view-tabs">
|
|
2844
|
+
<button class="tab active" data-view="view">View</button>
|
|
2845
|
+
<button class="tab" data-view="markdown">Markdown</button>
|
|
2846
|
+
</div>
|
|
2847
|
+
<span id="save-status"></span>
|
|
2848
|
+
<span id="file-count" style="font-size:11px;color:var(--muted);flex-shrink:0"></span>
|
|
2849
|
+
<div id="git-widget" class="hidden">
|
|
2850
|
+
<span id="git-msg"></span>
|
|
2851
|
+
<span class="git-count behind hidden" id="git-behind" title="commits behind remote">\u2193<span id="git-behind-n"></span></span>
|
|
2852
|
+
<span class="git-branch"><span class="git-dot" id="git-dot"></span><span id="git-branch-name"></span></span>
|
|
2853
|
+
<span class="git-count ahead hidden" id="git-ahead" title="commits ahead of remote">\u2191<span id="git-ahead-n"></span></span>
|
|
2854
|
+
<button class="git-btn" id="git-pull-btn" onclick="gitDoPull()" title="Pull latest from remote">Pull</button>
|
|
2855
|
+
<button class="git-btn primary" id="git-commit-btn" onclick="openCommitModal()" title="Commit all changes">Commit</button>
|
|
2856
|
+
<button class="git-btn" id="git-push-btn" onclick="gitDoPush()" title="Push to remote">Push</button>
|
|
2857
|
+
</div>
|
|
2858
|
+
</header>
|
|
2859
|
+
<div id="workspace">
|
|
2860
|
+
<nav id="sidebar"><div id="sidebar-inner"></div></nav>
|
|
2861
|
+
<main id="main">
|
|
2862
|
+
<div id="view-view" class="view-panel"><div id="doc-content"></div></div>
|
|
2863
|
+
<div id="view-markdown" class="view-panel" hidden><textarea id="editor" spellcheck="false"></textarea></div>
|
|
2864
|
+
</main>
|
|
2865
|
+
</div>
|
|
2866
|
+
</div>
|
|
2867
|
+
|
|
2868
|
+
<div id="git-commit-modal" class="hidden">
|
|
2869
|
+
<div id="git-commit-box">
|
|
2870
|
+
<h3>Commit changes</h3>
|
|
2871
|
+
<textarea id="git-commit-msg" placeholder="Commit message"></textarea>
|
|
2872
|
+
<div class="git-commit-actions">
|
|
2873
|
+
<button class="te-btn te-btn-ghost" onclick="closeCommitModal()">Cancel</button>
|
|
2874
|
+
<button class="te-btn te-btn-primary" onclick="gitDoCommit()">Commit</button>
|
|
2875
|
+
</div>
|
|
2876
|
+
</div>
|
|
2877
|
+
</div>
|
|
2878
|
+
<div id="gantt-hover-card"></div>
|
|
2879
|
+
<div id="task-edit-overlay" class="hidden">
|
|
2880
|
+
<div id="task-edit-modal">
|
|
2881
|
+
<div class="te-title">Edit Task</div>
|
|
2882
|
+
<div class="te-row">
|
|
2883
|
+
<div class="te-field"><label>Name</label><input type="text" id="te-name" placeholder="Task name"></div>
|
|
2884
|
+
</div>
|
|
2885
|
+
<div class="te-row cols-2">
|
|
2886
|
+
<div class="te-field"><label>Status</label>
|
|
2887
|
+
<select id="te-status">
|
|
2888
|
+
<option value="new">New</option>
|
|
2889
|
+
<option value="active">Active</option>
|
|
2890
|
+
<option value="review">Review</option>
|
|
2891
|
+
<option value="blocked">Blocked</option>
|
|
2892
|
+
<option value="paused">Paused</option>
|
|
2893
|
+
<option value="done">Done</option>
|
|
2894
|
+
<option value="cancelled">Cancelled</option>
|
|
2895
|
+
</select>
|
|
2896
|
+
</div>
|
|
2897
|
+
<div class="te-field"><label>Priority</label>
|
|
2898
|
+
<select id="te-priority">
|
|
2899
|
+
<option value="low">Low</option>
|
|
2900
|
+
<option value="normal">Normal</option>
|
|
2901
|
+
<option value="high">High</option>
|
|
2902
|
+
<option value="critical">Critical</option>
|
|
2903
|
+
</select>
|
|
2904
|
+
</div>
|
|
2905
|
+
</div>
|
|
2906
|
+
<div class="te-row cols-2">
|
|
2907
|
+
<div class="te-field"><label>Assignees (space-separated)</label><input type="text" id="te-assignees" placeholder="@alice @bob"></div>
|
|
2908
|
+
<div class="te-field"><label>Tags (space-separated)</label><input type="text" id="te-tags" placeholder="#backend #api"></div>
|
|
2909
|
+
</div>
|
|
2910
|
+
<div class="te-row cols-3">
|
|
2911
|
+
<div class="te-field"><label>Duration</label><input type="text" id="te-duration" placeholder="5d, 2bd, 1w"></div>
|
|
2912
|
+
<div class="te-field"><label>Start Date</label><input type="text" id="te-startdate" placeholder="YYYY-MM-DD"></div>
|
|
2913
|
+
<div class="te-field"><label>Due Date</label><input type="text" id="te-duedate" placeholder="YYYY-MM-DD"></div>
|
|
2914
|
+
</div>
|
|
2915
|
+
<div class="te-row cols-3">
|
|
2916
|
+
<div class="te-field"><label>Progress (%)</label><input type="number" id="te-progress" min="0" max="100" placeholder="0"></div>
|
|
2917
|
+
<div class="te-field"><label>ID</label><input type="text" id="te-id" placeholder="task-slug"></div>
|
|
2918
|
+
<div class="te-field"><label>After (deps)</label><input type="text" id="te-after" placeholder="id1,id2"></div>
|
|
2919
|
+
</div>
|
|
2920
|
+
<div class="te-row cols-2">
|
|
2921
|
+
<div class="te-field"><label>Delayed by</label><input type="text" id="te-delayed" placeholder="e.g. 3d, 1w"></div>
|
|
2922
|
+
<div class="te-field"><label>Blocked for</label><input type="text" id="te-blocked" placeholder="e.g. 2w, 5d"></div>
|
|
2923
|
+
</div>
|
|
2924
|
+
<div class="te-row">
|
|
2925
|
+
<div class="te-field"><label>Description</label><textarea id="te-description" placeholder="Optional description (saved as // comment lines below the task)"></textarea></div>
|
|
2926
|
+
</div>
|
|
2927
|
+
<div class="te-actions">
|
|
2928
|
+
<span class="te-save-msg" id="te-save-msg"></span>
|
|
2929
|
+
<button class="te-btn te-btn-ghost" onclick="closeTaskEdit()">Cancel</button>
|
|
2930
|
+
<button class="te-btn te-btn-primary" onclick="saveTaskEdit()">Save</button>
|
|
2931
|
+
</div>
|
|
2932
|
+
</div>
|
|
2933
|
+
</div>
|
|
2934
|
+
<script>var ROOT_FOLDER = ${JSON.stringify(rootDir)};
|
|
2935
|
+
${JS}</script>
|
|
2936
|
+
</body>
|
|
2937
|
+
</html>`;
|
|
2938
|
+
}
|
|
2939
|
+
function createServer2(rootDir, port, sse) {
|
|
2940
|
+
const shellHtml = buildShellHtml(rootDir);
|
|
2941
|
+
return http.createServer((req, res) => {
|
|
2942
|
+
const parsed = new URL(req.url ?? "/", `http://localhost:${port}`);
|
|
2943
|
+
const pathname = parsed.pathname;
|
|
2944
|
+
if (pathname === "/") {
|
|
2945
|
+
res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
|
|
2946
|
+
res.end(shellHtml);
|
|
2947
|
+
return;
|
|
2948
|
+
}
|
|
2949
|
+
if (pathname === "/api/files") {
|
|
2950
|
+
const files = walkMdFiles(rootDir).map((f) => path.relative(rootDir, f).split(path.sep).join("/"));
|
|
2951
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2952
|
+
res.end(JSON.stringify(files));
|
|
2953
|
+
return;
|
|
2954
|
+
}
|
|
2955
|
+
if (pathname === "/api/render") {
|
|
2956
|
+
const absPath = guardPath(rootDir, parsed.searchParams.get("p"));
|
|
2957
|
+
if (!absPath) {
|
|
2958
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2959
|
+
res.end('"Bad request"');
|
|
2960
|
+
return;
|
|
2961
|
+
}
|
|
2962
|
+
try {
|
|
2963
|
+
const blocks = renderFile(absPath);
|
|
2964
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
2965
|
+
res.end(JSON.stringify({ blocks }));
|
|
2966
|
+
} catch (e) {
|
|
2967
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
2968
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
2969
|
+
}
|
|
2970
|
+
return;
|
|
2971
|
+
}
|
|
2972
|
+
if (pathname === "/api/source") {
|
|
2973
|
+
const absPath = guardPath(rootDir, parsed.searchParams.get("p"));
|
|
2974
|
+
if (!absPath) {
|
|
2975
|
+
res.writeHead(400);
|
|
2976
|
+
res.end("Bad request");
|
|
2977
|
+
return;
|
|
2978
|
+
}
|
|
2979
|
+
try {
|
|
2980
|
+
res.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
|
|
2981
|
+
res.end(fs.readFileSync(absPath, "utf8"));
|
|
2982
|
+
} catch (e) {
|
|
2983
|
+
res.writeHead(500);
|
|
2984
|
+
res.end(e.message);
|
|
2985
|
+
}
|
|
2986
|
+
return;
|
|
2987
|
+
}
|
|
2988
|
+
if (pathname === "/api/save" && req.method === "POST") {
|
|
2989
|
+
const absPath = guardPath(rootDir, parsed.searchParams.get("p"));
|
|
2990
|
+
if (!absPath) {
|
|
2991
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2992
|
+
res.end('"Bad request"');
|
|
2993
|
+
return;
|
|
2994
|
+
}
|
|
2995
|
+
const chunks = [];
|
|
2996
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
2997
|
+
req.on("end", () => {
|
|
2998
|
+
try {
|
|
2999
|
+
fs.writeFileSync(absPath, Buffer.concat(chunks).toString("utf8"), "utf8");
|
|
3000
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3001
|
+
res.end('"ok"');
|
|
3002
|
+
sse.broadcast("reload");
|
|
3003
|
+
} catch (e) {
|
|
3004
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3005
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
3006
|
+
}
|
|
3007
|
+
});
|
|
3008
|
+
req.on("error", (e) => {
|
|
3009
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3010
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
3011
|
+
});
|
|
3012
|
+
return;
|
|
3013
|
+
}
|
|
3014
|
+
if (pathname === "/api/save-block" && req.method === "POST") {
|
|
3015
|
+
const absPath = guardPath(rootDir, parsed.searchParams.get("p"));
|
|
3016
|
+
const blockIdx = parseInt(parsed.searchParams.get("idx") ?? "-1", 10);
|
|
3017
|
+
if (!absPath || blockIdx < 0) {
|
|
3018
|
+
res.writeHead(400, { "Content-Type": "application/json" });
|
|
3019
|
+
res.end(JSON.stringify({ error: "Bad request" }));
|
|
3020
|
+
return;
|
|
3021
|
+
}
|
|
3022
|
+
const chunks = [];
|
|
3023
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
3024
|
+
req.on("end", () => {
|
|
3025
|
+
try {
|
|
3026
|
+
const newBlockSource = Buffer.concat(chunks).toString("utf8");
|
|
3027
|
+
const fileSource = fs.readFileSync(absPath, "utf8");
|
|
3028
|
+
const updated = replaceYattBlock(fileSource, blockIdx, newBlockSource);
|
|
3029
|
+
fs.writeFileSync(absPath, updated, "utf8");
|
|
3030
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3031
|
+
res.end('"ok"');
|
|
3032
|
+
sse.broadcast("reload");
|
|
3033
|
+
} catch (e) {
|
|
3034
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3035
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
3036
|
+
}
|
|
3037
|
+
});
|
|
3038
|
+
req.on("error", (e) => {
|
|
3039
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3040
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
3041
|
+
});
|
|
3042
|
+
return;
|
|
3043
|
+
}
|
|
3044
|
+
if (pathname === "/events") {
|
|
3045
|
+
sse.add(res);
|
|
3046
|
+
return;
|
|
3047
|
+
}
|
|
3048
|
+
if (pathname === "/api/git/status" && req.method === "GET") {
|
|
3049
|
+
gitStatus(rootDir).then((status) => {
|
|
3050
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3051
|
+
res.end(JSON.stringify(status));
|
|
3052
|
+
}).catch((e) => {
|
|
3053
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3054
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
3055
|
+
});
|
|
3056
|
+
return;
|
|
3057
|
+
}
|
|
3058
|
+
if (pathname === "/api/git/pull" && req.method === "POST") {
|
|
3059
|
+
runGit(["pull"], rootDir).then((r) => {
|
|
3060
|
+
const conflict = r.stdout.includes("CONFLICT") || r.stderr.includes("CONFLICT");
|
|
3061
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3062
|
+
res.end(JSON.stringify({ ok: r.ok && !conflict, conflict, output: r.stdout || r.stderr }));
|
|
3063
|
+
if (r.ok && !conflict) sse.broadcast("reload");
|
|
3064
|
+
}).catch((e) => {
|
|
3065
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3066
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
3067
|
+
});
|
|
3068
|
+
return;
|
|
3069
|
+
}
|
|
3070
|
+
if (pathname === "/api/git/commit" && req.method === "POST") {
|
|
3071
|
+
const chunks = [];
|
|
3072
|
+
req.on("data", (c) => chunks.push(c));
|
|
3073
|
+
req.on("end", () => {
|
|
3074
|
+
let msg = "Update tasks";
|
|
3075
|
+
try {
|
|
3076
|
+
msg = JSON.parse(Buffer.concat(chunks).toString()).message || msg;
|
|
3077
|
+
} catch {
|
|
3078
|
+
}
|
|
3079
|
+
runGit(["add", "-A"], rootDir).then((a) => {
|
|
3080
|
+
if (!a.ok) throw new Error(a.stderr || "git add failed");
|
|
3081
|
+
return runGit(["commit", "-m", msg], rootDir);
|
|
3082
|
+
}).then((r) => {
|
|
3083
|
+
const noop = r.stdout.includes("nothing to commit");
|
|
3084
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3085
|
+
res.end(JSON.stringify({ ok: r.ok || noop, noop, output: r.stdout || r.stderr }));
|
|
3086
|
+
}).catch((e) => {
|
|
3087
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3088
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
3089
|
+
});
|
|
3090
|
+
});
|
|
3091
|
+
return;
|
|
3092
|
+
}
|
|
3093
|
+
if (pathname === "/api/git/push" && req.method === "POST") {
|
|
3094
|
+
runGit(["push"], rootDir).then((r) => {
|
|
3095
|
+
const rejected = r.stderr.includes("rejected") || r.stderr.includes("[rejected]");
|
|
3096
|
+
res.writeHead(200, { "Content-Type": "application/json" });
|
|
3097
|
+
res.end(JSON.stringify({ ok: r.ok && !rejected, rejected, output: r.stdout || r.stderr }));
|
|
3098
|
+
}).catch((e) => {
|
|
3099
|
+
res.writeHead(500, { "Content-Type": "application/json" });
|
|
3100
|
+
res.end(JSON.stringify({ error: e.message }));
|
|
3101
|
+
});
|
|
3102
|
+
return;
|
|
3103
|
+
}
|
|
3104
|
+
res.writeHead(404, { "Content-Type": "text/plain" });
|
|
3105
|
+
res.end("Not found");
|
|
3106
|
+
});
|
|
3107
|
+
}
|
|
3108
|
+
function runGit(args, cwd) {
|
|
3109
|
+
return new Promise((resolve2) => {
|
|
3110
|
+
execFile("git", args, { cwd, timeout: 15e3 }, (err, stdout, stderr) => {
|
|
3111
|
+
resolve2({ ok: !err, stdout: stdout.trim(), stderr: stderr.trim() });
|
|
3112
|
+
});
|
|
3113
|
+
});
|
|
3114
|
+
}
|
|
3115
|
+
async function gitStatus(cwd) {
|
|
3116
|
+
const fallback = { available: false, dirty: false, ahead: 0, behind: 0, branch: "", hasRemote: false };
|
|
3117
|
+
const branchR = await runGit(["rev-parse", "--abbrev-ref", "HEAD"], cwd);
|
|
3118
|
+
if (!branchR.ok) return fallback;
|
|
3119
|
+
const branch = branchR.stdout || "HEAD";
|
|
3120
|
+
const porcelain = await runGit(["status", "--porcelain"], cwd);
|
|
3121
|
+
if (!porcelain.ok) return fallback;
|
|
3122
|
+
const dirty = porcelain.stdout.length > 0;
|
|
3123
|
+
const remoteR = await runGit(["rev-parse", "--abbrev-ref", "@{u}"], cwd);
|
|
3124
|
+
const hasRemote = remoteR.ok;
|
|
3125
|
+
let ahead = 0, behind = 0;
|
|
3126
|
+
if (hasRemote) {
|
|
3127
|
+
const countR = await runGit(["rev-list", "--left-right", "--count", "HEAD...@{u}"], cwd);
|
|
3128
|
+
if (countR.ok) {
|
|
3129
|
+
const parts = countR.stdout.split(/\s+/);
|
|
3130
|
+
ahead = parseInt(parts[0] ?? "0", 10) || 0;
|
|
3131
|
+
behind = parseInt(parts[1] ?? "0", 10) || 0;
|
|
3132
|
+
}
|
|
3133
|
+
}
|
|
3134
|
+
return { available: true, dirty, ahead, behind, branch, hasRemote };
|
|
3135
|
+
}
|
|
3136
|
+
function isPortFree(port) {
|
|
3137
|
+
return new Promise((resolve2) => {
|
|
3138
|
+
const srv = http.createServer();
|
|
3139
|
+
srv.once("error", () => resolve2(false));
|
|
3140
|
+
srv.once("listening", () => {
|
|
3141
|
+
srv.close();
|
|
3142
|
+
resolve2(true);
|
|
3143
|
+
});
|
|
3144
|
+
srv.listen(port, "127.0.0.1");
|
|
3145
|
+
});
|
|
3146
|
+
}
|
|
3147
|
+
async function findFreePort(start) {
|
|
3148
|
+
for (let p = start; p < start + 20; p++) {
|
|
3149
|
+
if (await isPortFree(p)) return p;
|
|
3150
|
+
}
|
|
3151
|
+
return start;
|
|
3152
|
+
}
|
|
3153
|
+
var { folder, port: preferredPort, noOpen } = parseArgs(process.argv.slice(2));
|
|
3154
|
+
if (!fs.existsSync(folder)) {
|
|
3155
|
+
process.stderr.write(`Error: folder not found: ${folder}
|
|
3156
|
+
`);
|
|
3157
|
+
process.exit(1);
|
|
3158
|
+
}
|
|
3159
|
+
(async () => {
|
|
3160
|
+
const port = await findFreePort(preferredPort);
|
|
3161
|
+
const addr = `http://localhost:${port}`;
|
|
3162
|
+
if (port !== preferredPort) {
|
|
3163
|
+
process.stdout.write(`Port ${preferredPort} in use, using ${port} instead.
|
|
3164
|
+
`);
|
|
3165
|
+
}
|
|
3166
|
+
const sse = new SseManager();
|
|
3167
|
+
watchFolder(folder, sse);
|
|
3168
|
+
const server = createServer2(folder, port, sse);
|
|
3169
|
+
server.listen(port, "127.0.0.1", () => {
|
|
3170
|
+
process.stdout.write(`
|
|
3171
|
+
YATT \xB7 ${folder}
|
|
3172
|
+
`);
|
|
3173
|
+
process.stdout.write(`
|
|
3174
|
+
\u2192 ${addr}
|
|
3175
|
+
|
|
3176
|
+
`);
|
|
3177
|
+
process.stdout.write(`Ctrl+C to stop.
|
|
3178
|
+
|
|
3179
|
+
`);
|
|
3180
|
+
if (!noOpen) openBrowser(addr);
|
|
3181
|
+
});
|
|
3182
|
+
server.on("error", (err) => {
|
|
3183
|
+
process.stderr.write(`Server error: ${err.message}
|
|
3184
|
+
`);
|
|
3185
|
+
process.exit(1);
|
|
3186
|
+
});
|
|
3187
|
+
})();
|