timeline-truth 0.1.0 → 0.1.1
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 +44 -9
- package/docs/MCP-SETUP.md +27 -3
- package/docs/RELEASE.md +10 -3
- package/package.json +2 -2
- package/src/mcp-tools.js +19 -1
- package/src/timeline.js +235 -5
package/README.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# Timeline Truth
|
|
2
2
|
|
|
3
|
-
Status: v0.
|
|
4
|
-
22 or newer.
|
|
3
|
+
Status: v0.1.0 public release. MIT licensed. Requires Node.js 22 or newer.
|
|
5
4
|
|
|
6
5
|
Timeline Truth is a local MCP server for AI-agent TPM workflows: paste PRD/Jira/status notes,
|
|
7
6
|
CSV exports, launch checklists, or rough planning text; get timeline JSON,
|
|
@@ -34,6 +33,32 @@ the mermaid_gantt output. Do not infer missing dates or owners.
|
|
|
34
33
|
The server returns normalized items, gaps such as missing start/end dates, the
|
|
35
34
|
default assumption that dates were not inferred, and portable Mermaid output.
|
|
36
35
|
|
|
36
|
+
For larger Markdown notes, `create_timeline` can parse only selected headings
|
|
37
|
+
and ignore the rest:
|
|
38
|
+
|
|
39
|
+
```json
|
|
40
|
+
{
|
|
41
|
+
"sources": [
|
|
42
|
+
{
|
|
43
|
+
"id": "program-note",
|
|
44
|
+
"type": "markdown",
|
|
45
|
+
"path": "docs/program.md",
|
|
46
|
+
"content": "..."
|
|
47
|
+
}
|
|
48
|
+
],
|
|
49
|
+
"markdown": {
|
|
50
|
+
"sections": ["Timeline", "Follow-Ups"],
|
|
51
|
+
"ignoreFrontmatter": true
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
Markdown tables under those headings are parsed into items. Fuzzy targets such
|
|
57
|
+
as `W3-W4 May 2026` are preserved as `time_window`/`date_text` and flagged with
|
|
58
|
+
an `exact_date` gap instead of being converted into invented dates. The response
|
|
59
|
+
also includes `noise_report.ignored` counts for skipped frontmatter, prose, and
|
|
60
|
+
table rows without target dates.
|
|
61
|
+
|
|
37
62
|
## Why This Exists
|
|
38
63
|
|
|
39
64
|
Most timeline tools assume the plan is already structured. Real planning inputs
|
|
@@ -66,7 +91,7 @@ npm install
|
|
|
66
91
|
node src/mcp-server.js
|
|
67
92
|
```
|
|
68
93
|
|
|
69
|
-
Npm package config
|
|
94
|
+
Npm package config:
|
|
70
95
|
|
|
71
96
|
```json
|
|
72
97
|
{
|
|
@@ -79,9 +104,19 @@ Npm package config, after the npm package is published:
|
|
|
79
104
|
}
|
|
80
105
|
```
|
|
81
106
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
107
|
+
Optional global install:
|
|
108
|
+
|
|
109
|
+
```bash
|
|
110
|
+
npm install -g timeline-truth
|
|
111
|
+
timeline-truth-mcp
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
If your global npm bin directory is on `PATH`, you can also configure the MCP
|
|
115
|
+
server with `"command": "timeline-truth-mcp"`. The `npx --package` config above
|
|
116
|
+
is the most portable option because it does not depend on global shell setup.
|
|
117
|
+
|
|
118
|
+
For local development, use the checkout config in
|
|
119
|
+
[docs/MCP-SETUP.md](docs/MCP-SETUP.md).
|
|
85
120
|
|
|
86
121
|
## MCP Tools
|
|
87
122
|
|
|
@@ -107,9 +142,9 @@ Each example has a compact expected-output JSON file and is covered by tests.
|
|
|
107
142
|
|
|
108
143
|
## Current limitations
|
|
109
144
|
|
|
110
|
-
- Text
|
|
111
|
-
|
|
112
|
-
- Markdown
|
|
145
|
+
- Text parsing is heuristic. It works best when each planning item is on its own
|
|
146
|
+
line.
|
|
147
|
+
- Markdown parsing supports heading filters and simple pipe tables, but rich
|
|
113
148
|
nested documents are not fully parsed.
|
|
114
149
|
- CSV and JSON are more reliable than free-form notes when exact fields matter.
|
|
115
150
|
- There are no Jira, Confluence, Slack, or hosted imports in this release.
|
package/docs/MCP-SETUP.md
CHANGED
|
@@ -26,7 +26,7 @@ npm install
|
|
|
26
26
|
|
|
27
27
|
## Npm Package
|
|
28
28
|
|
|
29
|
-
Use
|
|
29
|
+
Use the published `timeline-truth` package:
|
|
30
30
|
|
|
31
31
|
```json
|
|
32
32
|
{
|
|
@@ -39,8 +39,32 @@ Use this after the `timeline-truth` npm package is published:
|
|
|
39
39
|
}
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
42
|
+
This is the recommended package config because it does not require a global npm
|
|
43
|
+
install or depend on the user's global npm bin directory being on `PATH`.
|
|
44
|
+
|
|
45
|
+
Optional global install:
|
|
46
|
+
|
|
47
|
+
```bash
|
|
48
|
+
npm install -g timeline-truth
|
|
49
|
+
timeline-truth-mcp
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
With the global package installed and available on `PATH`, the MCP config can
|
|
53
|
+
call the package binary directly:
|
|
54
|
+
|
|
55
|
+
```json
|
|
56
|
+
{
|
|
57
|
+
"mcpServers": {
|
|
58
|
+
"timeline-truth": {
|
|
59
|
+
"command": "timeline-truth-mcp",
|
|
60
|
+
"args": []
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
If you need to test local changes before publishing a new version, use the local
|
|
67
|
+
checkout config above.
|
|
44
68
|
|
|
45
69
|
## Agent Prompt
|
|
46
70
|
|
package/docs/RELEASE.md
CHANGED
|
@@ -2,15 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
Use this checklist before publishing a public version of `timeline-truth`.
|
|
4
4
|
|
|
5
|
+
## Current Release Notes
|
|
6
|
+
|
|
7
|
+
- Markdown ingestion now ignores frontmatter by default, parses configured
|
|
8
|
+
headings, supports simple pipe tables, preserves fuzzy time windows, enriches
|
|
9
|
+
source refs, and reports ignored content through `noise_report`.
|
|
10
|
+
- This release intentionally does not include CLI file mode, TPM validation
|
|
11
|
+
profiles, or dependency matching by external IDs.
|
|
12
|
+
|
|
5
13
|
## Verify Package Name
|
|
6
14
|
|
|
7
15
|
```bash
|
|
8
16
|
npm view timeline-truth version
|
|
9
17
|
```
|
|
10
18
|
|
|
11
|
-
Expected
|
|
12
|
-
|
|
13
|
-
Expected after publish: npm returns the latest published version.
|
|
19
|
+
Expected result: npm returns the latest published version. Before publishing a
|
|
20
|
+
new release, confirm whether the version in `package.json` needs to be bumped.
|
|
14
21
|
|
|
15
22
|
## Verify Local Quality
|
|
16
23
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "timeline-truth",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Open-source MCP server for compiling messy project planning inputs into evidence-preserving timelines.",
|
|
5
5
|
"author": "hilmimuktitama",
|
|
6
6
|
"license": "MIT",
|
|
@@ -18,7 +18,7 @@
|
|
|
18
18
|
},
|
|
19
19
|
"files": [
|
|
20
20
|
"src",
|
|
21
|
-
"docs",
|
|
21
|
+
"docs/*.md",
|
|
22
22
|
"examples",
|
|
23
23
|
"README.md",
|
|
24
24
|
"LICENSE"
|
package/src/mcp-tools.js
CHANGED
|
@@ -6,6 +6,7 @@ const SOURCE_SCHEMA = {
|
|
|
6
6
|
additionalProperties: true,
|
|
7
7
|
properties: {
|
|
8
8
|
id: { type: "string", description: "Stable source identifier used in source_refs." },
|
|
9
|
+
path: { type: "string", description: "Optional file path to preserve in source_refs." },
|
|
9
10
|
type: { type: "string", enum: ["text", "markdown", "csv", "json"], default: "text" },
|
|
10
11
|
content: {
|
|
11
12
|
description: "Pasted text/file content. JSON sources may pass a JSON string or object.",
|
|
@@ -42,6 +43,23 @@ export function listTimelineTools() {
|
|
|
42
43
|
type: "array",
|
|
43
44
|
minItems: 1,
|
|
44
45
|
items: SOURCE_SCHEMA
|
|
46
|
+
},
|
|
47
|
+
markdown: {
|
|
48
|
+
type: "object",
|
|
49
|
+
additionalProperties: false,
|
|
50
|
+
properties: {
|
|
51
|
+
sections: {
|
|
52
|
+
type: "array",
|
|
53
|
+
items: { type: "string" },
|
|
54
|
+
description:
|
|
55
|
+
"Markdown headings to parse. Defaults to Timeline, Milestones, Next, Risks And Blockers, and Follow-Ups."
|
|
56
|
+
},
|
|
57
|
+
ignoreFrontmatter: {
|
|
58
|
+
type: "boolean",
|
|
59
|
+
default: true,
|
|
60
|
+
description: "Ignore YAML frontmatter before parsing Markdown content."
|
|
61
|
+
}
|
|
62
|
+
}
|
|
45
63
|
}
|
|
46
64
|
}
|
|
47
65
|
}
|
|
@@ -108,7 +126,7 @@ export function listTimelineTools() {
|
|
|
108
126
|
export function callTimelineTool(name, args = {}) {
|
|
109
127
|
switch (name) {
|
|
110
128
|
case "create_timeline":
|
|
111
|
-
return jsonContent(createTimeline({ sources: args.sources }));
|
|
129
|
+
return jsonContent(createTimeline({ sources: args.sources, markdown: args.markdown }));
|
|
112
130
|
case "validate_timeline":
|
|
113
131
|
return jsonContent(validateTimeline(args.timeline));
|
|
114
132
|
case "render_timeline":
|
package/src/timeline.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
const DATE_PATTERN = /\b\d{4}-\d{2}-\d{2}\b/g;
|
|
2
|
+
const DEFAULT_MARKDOWN_SECTIONS = ["Timeline", "Milestones", "Next", "Risks And Blockers", "Follow-Ups"];
|
|
2
3
|
|
|
3
4
|
export function createTimeline(input = {}) {
|
|
4
5
|
const sources = Array.isArray(input.sources) ? input.sources : [];
|
|
5
6
|
const importedAssumptions = [];
|
|
6
|
-
const
|
|
7
|
+
const noiseReport = createNoiseReport();
|
|
8
|
+
const items = sources.flatMap((source, index) =>
|
|
9
|
+
parseSource(source, index, importedAssumptions, input, noiseReport)
|
|
10
|
+
);
|
|
7
11
|
const timeline = normalizeTimeline({
|
|
8
12
|
items,
|
|
9
13
|
assumptions: [
|
|
@@ -28,6 +32,7 @@ export function createTimeline(input = {}) {
|
|
|
28
32
|
assumptions: validatedTimeline.assumptions,
|
|
29
33
|
gaps: validatedTimeline.gaps,
|
|
30
34
|
issues: validation.issues,
|
|
35
|
+
noise_report: noiseReport,
|
|
31
36
|
renders: {
|
|
32
37
|
mermaid_gantt: renderTimeline(validatedTimeline, { format: "mermaid_gantt" }),
|
|
33
38
|
mermaid_timeline: renderTimeline(validatedTimeline, { format: "mermaid_timeline" }),
|
|
@@ -42,11 +47,15 @@ export function validateTimeline(timeline = {}) {
|
|
|
42
47
|
const issues = [];
|
|
43
48
|
|
|
44
49
|
for (const item of normalized.items) {
|
|
45
|
-
if (
|
|
50
|
+
if (item.exact_date_needed) {
|
|
51
|
+
gaps.push(makeGap(item, "exact_date", "Exact date needed before rendering this fuzzy time window."));
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
if (!item.start && !item.time_window) {
|
|
46
55
|
gaps.push(makeGap(item, "start", "Missing start date. Ask for the planned start date instead of inferring it."));
|
|
47
56
|
}
|
|
48
57
|
|
|
49
|
-
if (!item.end && !item.duration && item.type !== "milestone") {
|
|
58
|
+
if (!item.end && !item.duration && !item.time_window && item.type !== "milestone") {
|
|
50
59
|
gaps.push(makeGap(item, "end", "Missing end date or duration for a non-milestone item."));
|
|
51
60
|
}
|
|
52
61
|
|
|
@@ -142,11 +151,12 @@ export function refineTimeline(timeline = {}, refinement = {}) {
|
|
|
142
151
|
};
|
|
143
152
|
}
|
|
144
153
|
|
|
145
|
-
function parseSource(source, index, importedAssumptions) {
|
|
154
|
+
function parseSource(source, index, importedAssumptions, input, noiseReport) {
|
|
146
155
|
const normalizedSource = {
|
|
147
156
|
id: source?.id || `source-${index + 1}`,
|
|
148
157
|
type: source?.type || "text",
|
|
149
|
-
content: source?.content ?? ""
|
|
158
|
+
content: source?.content ?? "",
|
|
159
|
+
path: source?.path || source?.file_path || source?.filePath
|
|
150
160
|
};
|
|
151
161
|
|
|
152
162
|
if (normalizedSource.type === "json") {
|
|
@@ -157,6 +167,10 @@ function parseSource(source, index, importedAssumptions) {
|
|
|
157
167
|
return parseCsvSource(normalizedSource);
|
|
158
168
|
}
|
|
159
169
|
|
|
170
|
+
if (normalizedSource.type === "markdown") {
|
|
171
|
+
return parseMarkdownSource(normalizedSource, input?.markdown, noiseReport);
|
|
172
|
+
}
|
|
173
|
+
|
|
160
174
|
return parseTextSource(normalizedSource);
|
|
161
175
|
}
|
|
162
176
|
|
|
@@ -197,6 +211,85 @@ function parseTextSource(source) {
|
|
|
197
211
|
.filter(Boolean);
|
|
198
212
|
}
|
|
199
213
|
|
|
214
|
+
function parseMarkdownSource(source, markdownOptions = {}, noiseReport = createNoiseReport()) {
|
|
215
|
+
const lines = String(source.content).split(/\r?\n/);
|
|
216
|
+
const allowedHeadings = getAllowedMarkdownHeadings(markdownOptions);
|
|
217
|
+
const hasAllowedHeadings = markdownHasAllowedHeadings(lines, allowedHeadings);
|
|
218
|
+
const items = [];
|
|
219
|
+
let currentHeading;
|
|
220
|
+
let inFrontmatter = markdownOptions?.ignoreFrontmatter === false ? false : lines[0]?.trim() === "---";
|
|
221
|
+
|
|
222
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
223
|
+
const line = lines[index];
|
|
224
|
+
const trimmed = line.trim();
|
|
225
|
+
|
|
226
|
+
if (inFrontmatter) {
|
|
227
|
+
noiseReport.ignored.frontmatter_lines += 1;
|
|
228
|
+
if (index > 0 && trimmed === "---") inFrontmatter = false;
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const heading = parseMarkdownHeading(trimmed);
|
|
233
|
+
if (heading) {
|
|
234
|
+
currentHeading = heading;
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!trimmed) continue;
|
|
239
|
+
|
|
240
|
+
const inAllowedSection = !hasAllowedHeadings || allowedHeadings.has(normalizeHeading(currentHeading));
|
|
241
|
+
if (!inAllowedSection) {
|
|
242
|
+
noiseReport.ignored.prose_lines += 1;
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (isMarkdownTableLine(trimmed)) {
|
|
247
|
+
const table = parseMarkdownTable(lines, index);
|
|
248
|
+
if (!table) {
|
|
249
|
+
noiseReport.ignored.prose_lines += 1;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
for (const row of table.rows) {
|
|
254
|
+
const item = markdownRecordToItem(row.record, currentHeading);
|
|
255
|
+
if (!item) {
|
|
256
|
+
noiseReport.ignored.table_rows_without_dates += row.hasDate ? 0 : 1;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (!row.hasDate) noiseReport.ignored.table_rows_without_dates += 1;
|
|
261
|
+
items.push(
|
|
262
|
+
normalizeItem(item, [
|
|
263
|
+
compactObject({
|
|
264
|
+
sourceId: source.id,
|
|
265
|
+
path: source.path,
|
|
266
|
+
heading: currentHeading,
|
|
267
|
+
tableRow: row.tableRow,
|
|
268
|
+
line: row.line,
|
|
269
|
+
text: row.text
|
|
270
|
+
})
|
|
271
|
+
])
|
|
272
|
+
);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
index = table.endIndex;
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
const parsedLine = parseTextLine(line, source.id, index + 1);
|
|
280
|
+
if (parsedLine) {
|
|
281
|
+
parsedLine.source_refs = parsedLine.source_refs.map((sourceRef) =>
|
|
282
|
+
compactObject({ ...sourceRef, path: source.path, heading: currentHeading })
|
|
283
|
+
);
|
|
284
|
+
items.push(parsedLine);
|
|
285
|
+
} else {
|
|
286
|
+
noiseReport.ignored.prose_lines += 1;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
return items;
|
|
291
|
+
}
|
|
292
|
+
|
|
200
293
|
function parseTextLine(line, sourceId, lineNumber) {
|
|
201
294
|
const trimmed = normalizePlanningLine(line);
|
|
202
295
|
if (!trimmed) return null;
|
|
@@ -292,6 +385,31 @@ function csvRecordToItem(record) {
|
|
|
292
385
|
};
|
|
293
386
|
}
|
|
294
387
|
|
|
388
|
+
function markdownRecordToItem(record, heading) {
|
|
389
|
+
const title =
|
|
390
|
+
valueFromRecord(record, ["item", "follow_up", "follow-up", "followup", "task", "milestone", "risk", "blocker"]) ||
|
|
391
|
+
valueFromRecord(record, ["title", "name"]);
|
|
392
|
+
if (!title) return null;
|
|
393
|
+
|
|
394
|
+
const target = valueFromRecord(record, ["target", "date", "when", "time_window", "window"]);
|
|
395
|
+
const dates = extractExactDates(target);
|
|
396
|
+
const fuzzyTarget = target && dates.length === 0 ? target : undefined;
|
|
397
|
+
|
|
398
|
+
return {
|
|
399
|
+
title,
|
|
400
|
+
type: normalizeHeading(heading) === "milestones" || record.type === "milestone" ? "milestone" : "task",
|
|
401
|
+
start: dates[0],
|
|
402
|
+
end: dates[1],
|
|
403
|
+
owner: valueFromRecord(record, ["owner", "assignee"]),
|
|
404
|
+
status: valueFromRecord(record, ["status"]) || "planned",
|
|
405
|
+
dependencies: valueFromRecord(record, ["dependencies", "depends_on", "depends on"]),
|
|
406
|
+
time_window: fuzzyTarget,
|
|
407
|
+
date_text: fuzzyTarget,
|
|
408
|
+
exact_date_needed: Boolean(fuzzyTarget),
|
|
409
|
+
confidence: target ? 0.7 : 0.55
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
|
|
295
413
|
function normalizeTimeline(timeline = {}) {
|
|
296
414
|
const items = Array.isArray(timeline.items)
|
|
297
415
|
? timeline.items.map((item) => normalizeItem(item, item.source_refs))
|
|
@@ -321,6 +439,9 @@ function normalizeItem(item = {}, sourceRefs = []) {
|
|
|
321
439
|
start: blankToUndefined(item.start),
|
|
322
440
|
end: blankToUndefined(item.end),
|
|
323
441
|
duration: blankToUndefined(item.duration),
|
|
442
|
+
time_window: blankToUndefined(item.time_window),
|
|
443
|
+
date_text: blankToUndefined(item.date_text),
|
|
444
|
+
exact_date_needed: Boolean(item.exact_date_needed),
|
|
324
445
|
owner: blankToUndefined(item.owner),
|
|
325
446
|
status: blankToUndefined(item.status) || "unknown",
|
|
326
447
|
dependencies: dependencies.map((dependency) => String(dependency).trim()).filter(Boolean),
|
|
@@ -503,6 +624,115 @@ function blankToUndefined(value) {
|
|
|
503
624
|
return normalized === "" ? undefined : normalized;
|
|
504
625
|
}
|
|
505
626
|
|
|
627
|
+
function createNoiseReport() {
|
|
628
|
+
return {
|
|
629
|
+
ignored: {
|
|
630
|
+
frontmatter_lines: 0,
|
|
631
|
+
prose_lines: 0,
|
|
632
|
+
table_rows_without_dates: 0
|
|
633
|
+
}
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function getAllowedMarkdownHeadings(options = {}) {
|
|
638
|
+
const sections = Array.isArray(options?.sections) && options.sections.length > 0
|
|
639
|
+
? options.sections
|
|
640
|
+
: DEFAULT_MARKDOWN_SECTIONS;
|
|
641
|
+
return new Set(sections.map((section) => normalizeHeading(section)));
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
function markdownHasAllowedHeadings(lines, allowedHeadings) {
|
|
645
|
+
return lines.some((line) => {
|
|
646
|
+
const heading = parseMarkdownHeading(line.trim());
|
|
647
|
+
return heading && allowedHeadings.has(normalizeHeading(heading));
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function parseMarkdownHeading(line) {
|
|
652
|
+
const match = line.match(/^#{1,6}\s+(.+?)\s*#*$/);
|
|
653
|
+
return match ? match[1].trim() : undefined;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
function normalizeHeading(value) {
|
|
657
|
+
return String(value || "")
|
|
658
|
+
.trim()
|
|
659
|
+
.toLowerCase()
|
|
660
|
+
.replace(/&/g, "and")
|
|
661
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
662
|
+
.trim();
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function isMarkdownTableLine(line) {
|
|
666
|
+
return /^\|.*\|\s*$/.test(line);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
function parseMarkdownTable(lines, startIndex) {
|
|
670
|
+
const header = parseMarkdownTableCells(lines[startIndex]);
|
|
671
|
+
const separator = parseMarkdownTableCells(lines[startIndex + 1]);
|
|
672
|
+
if (header.length === 0 || !isMarkdownSeparatorRow(separator)) return null;
|
|
673
|
+
|
|
674
|
+
const headers = header.map((cell) => normalizeHeader(cell));
|
|
675
|
+
const rows = [];
|
|
676
|
+
let index = startIndex + 2;
|
|
677
|
+
let tableRow = 1;
|
|
678
|
+
|
|
679
|
+
while (index < lines.length && isMarkdownTableLine(lines[index].trim())) {
|
|
680
|
+
const cells = parseMarkdownTableCells(lines[index]);
|
|
681
|
+
const record = {};
|
|
682
|
+
headers.forEach((column, columnIndex) => {
|
|
683
|
+
record[column] = cells[columnIndex] ?? "";
|
|
684
|
+
});
|
|
685
|
+
|
|
686
|
+
const target = valueFromRecord(record, ["target", "date", "when", "time_window", "window"]);
|
|
687
|
+
rows.push({
|
|
688
|
+
record,
|
|
689
|
+
tableRow,
|
|
690
|
+
line: index + 1,
|
|
691
|
+
text: lines[index].trim(),
|
|
692
|
+
hasDate: extractExactDates(target).length > 0 || Boolean(blankToUndefined(target))
|
|
693
|
+
});
|
|
694
|
+
tableRow += 1;
|
|
695
|
+
index += 1;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
return {
|
|
699
|
+
rows,
|
|
700
|
+
endIndex: index - 1
|
|
701
|
+
};
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function parseMarkdownTableCells(line = "") {
|
|
705
|
+
const trimmed = String(line).trim();
|
|
706
|
+
if (!isMarkdownTableLine(trimmed)) return [];
|
|
707
|
+
return trimmed
|
|
708
|
+
.replace(/^\|/, "")
|
|
709
|
+
.replace(/\|$/, "")
|
|
710
|
+
.split("|")
|
|
711
|
+
.map((cell) => cell.trim());
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
function isMarkdownSeparatorRow(cells) {
|
|
715
|
+
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
function valueFromRecord(record, names) {
|
|
719
|
+
for (const name of names) {
|
|
720
|
+
const normalized = normalizeHeader(name);
|
|
721
|
+
const value = blankToUndefined(record[normalized]);
|
|
722
|
+
if (value) return value;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
return undefined;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function extractExactDates(value) {
|
|
729
|
+
return [...String(value || "").matchAll(DATE_PATTERN)].map((match) => match[0]);
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
function compactObject(value) {
|
|
733
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
734
|
+
}
|
|
735
|
+
|
|
506
736
|
function escapeMermaidText(value) {
|
|
507
737
|
return String(value).replace(/[:#;]/g, "-").trim();
|
|
508
738
|
}
|