timeline-truth 0.1.0 → 0.2.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 +58 -12
- package/docs/MCP-SETUP.md +30 -3
- package/docs/RELEASE.md +10 -3
- package/package.json +3 -2
- package/src/cli.js +124 -0
- package/src/mcp-server.js +1 -1
- package/src/mcp-tools.js +21 -3
- package/src/timeline.js +385 -9
package/README.md
CHANGED
|
@@ -1,7 +1,6 @@
|
|
|
1
1
|
# Timeline Truth
|
|
2
2
|
|
|
3
|
-
Status: v0.2
|
|
4
|
-
22 or newer.
|
|
3
|
+
Status: v0.2.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,
|
|
@@ -31,8 +30,44 @@ single source. Then summarize the timeline, list gaps and assumptions, and show
|
|
|
31
30
|
the mermaid_gantt output. Do not infer missing dates or owners.
|
|
32
31
|
```
|
|
33
32
|
|
|
34
|
-
The server returns normalized items,
|
|
35
|
-
|
|
33
|
+
The server returns normalized items, confidence reasons, grouped follow-up
|
|
34
|
+
questions, gaps such as missing start/end dates, the default assumption that
|
|
35
|
+
dates were not inferred, and portable Mermaid output.
|
|
36
|
+
|
|
37
|
+
For a quick local smoke test without configuring an MCP client, run the CLI:
|
|
38
|
+
|
|
39
|
+
```bash
|
|
40
|
+
timeline-truth examples/launch-checklist.md --format review
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
The CLI reads from stdin when no file is provided, and can print `json`,
|
|
44
|
+
`markdown`, `mermaid_gantt`, `mermaid_timeline`, or `review` output.
|
|
45
|
+
|
|
46
|
+
For larger Markdown notes, `create_timeline` can parse only selected headings
|
|
47
|
+
and ignore the rest:
|
|
48
|
+
|
|
49
|
+
```json
|
|
50
|
+
{
|
|
51
|
+
"sources": [
|
|
52
|
+
{
|
|
53
|
+
"id": "program-note",
|
|
54
|
+
"type": "markdown",
|
|
55
|
+
"path": "docs/program.md",
|
|
56
|
+
"content": "..."
|
|
57
|
+
}
|
|
58
|
+
],
|
|
59
|
+
"markdown": {
|
|
60
|
+
"sections": ["Timeline", "Follow-Ups"],
|
|
61
|
+
"ignoreFrontmatter": true
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Markdown tables under those headings are parsed into items. Fuzzy targets such
|
|
67
|
+
as `W3-W4 May 2026` are preserved as `time_window`/`date_text` and flagged with
|
|
68
|
+
an `exact_date` gap instead of being converted into invented dates. The response
|
|
69
|
+
also includes `noise_report.ignored` counts for skipped frontmatter, prose, and
|
|
70
|
+
table rows without target dates.
|
|
36
71
|
|
|
37
72
|
## Why This Exists
|
|
38
73
|
|
|
@@ -66,7 +101,7 @@ npm install
|
|
|
66
101
|
node src/mcp-server.js
|
|
67
102
|
```
|
|
68
103
|
|
|
69
|
-
Npm package config
|
|
104
|
+
Npm package config:
|
|
70
105
|
|
|
71
106
|
```json
|
|
72
107
|
{
|
|
@@ -79,9 +114,20 @@ Npm package config, after the npm package is published:
|
|
|
79
114
|
}
|
|
80
115
|
```
|
|
81
116
|
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
117
|
+
Optional global install:
|
|
118
|
+
|
|
119
|
+
```bash
|
|
120
|
+
npm install -g timeline-truth
|
|
121
|
+
timeline-truth examples/launch-checklist.md --format review
|
|
122
|
+
timeline-truth-mcp
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
If your global npm bin directory is on `PATH`, you can also configure the MCP
|
|
126
|
+
server with `"command": "timeline-truth-mcp"`. The `npx --package` config above
|
|
127
|
+
is the most portable option because it does not depend on global shell setup.
|
|
128
|
+
|
|
129
|
+
For local development, use the checkout config in
|
|
130
|
+
[docs/MCP-SETUP.md](docs/MCP-SETUP.md).
|
|
85
131
|
|
|
86
132
|
## MCP Tools
|
|
87
133
|
|
|
@@ -90,7 +136,7 @@ config in [docs/MCP-SETUP.md](docs/MCP-SETUP.md).
|
|
|
90
136
|
- `validate_timeline`: report missing dates, owners, unknown dependencies,
|
|
91
137
|
circular dependencies, and impossible sequencing.
|
|
92
138
|
- `render_timeline`: render a normalized timeline as `mermaid_gantt`,
|
|
93
|
-
`mermaid_timeline`, or `
|
|
139
|
+
`mermaid_timeline`, `markdown`, or `review_report`.
|
|
94
140
|
- `refine_timeline`: apply edits while preserving evidence (`source_refs`) and
|
|
95
141
|
assumptions.
|
|
96
142
|
|
|
@@ -107,9 +153,9 @@ Each example has a compact expected-output JSON file and is covered by tests.
|
|
|
107
153
|
|
|
108
154
|
## Current limitations
|
|
109
155
|
|
|
110
|
-
- Text
|
|
111
|
-
|
|
112
|
-
- Markdown
|
|
156
|
+
- Text parsing is heuristic. It works best when each planning item is on its own
|
|
157
|
+
line.
|
|
158
|
+
- Markdown parsing supports heading filters and simple pipe tables, but rich
|
|
113
159
|
nested documents are not fully parsed.
|
|
114
160
|
- CSV and JSON are more reliable than free-form notes when exact fields matter.
|
|
115
161
|
- 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,33 @@ 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 examples/launch-checklist.md --format review
|
|
50
|
+
timeline-truth-mcp
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
With the global package installed and available on `PATH`, the MCP config can
|
|
54
|
+
call the package binary directly:
|
|
55
|
+
|
|
56
|
+
```json
|
|
57
|
+
{
|
|
58
|
+
"mcpServers": {
|
|
59
|
+
"timeline-truth": {
|
|
60
|
+
"command": "timeline-truth-mcp",
|
|
61
|
+
"args": []
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
If you need to test local changes before publishing a new version, use the local
|
|
68
|
+
checkout config above.
|
|
44
69
|
|
|
45
70
|
## Agent Prompt
|
|
46
71
|
|
|
@@ -57,5 +82,7 @@ Useful follow-up calls:
|
|
|
57
82
|
|
|
58
83
|
- `validate_timeline` after manual edits or agent refinements.
|
|
59
84
|
- `render_timeline` when you need only Mermaid or Markdown output.
|
|
85
|
+
- `render_timeline` with `review_report` when you need a paste-ready review
|
|
86
|
+
summary with confidence reasons and grouped follow-up questions.
|
|
60
87
|
- `refine_timeline` when a human answers a gap and you need to preserve
|
|
61
88
|
existing `source_refs`.
|
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.
|
|
3
|
+
"version": "0.2.0",
|
|
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",
|
|
@@ -14,11 +14,12 @@
|
|
|
14
14
|
"homepage": "https://github.com/hilmimuktitama/timeline-truth#readme",
|
|
15
15
|
"type": "module",
|
|
16
16
|
"bin": {
|
|
17
|
+
"timeline-truth": "src/cli.js",
|
|
17
18
|
"timeline-truth-mcp": "src/mcp-server.js"
|
|
18
19
|
},
|
|
19
20
|
"files": [
|
|
20
21
|
"src",
|
|
21
|
-
"docs",
|
|
22
|
+
"docs/*.md",
|
|
22
23
|
"examples",
|
|
23
24
|
"README.md",
|
|
24
25
|
"LICENSE"
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
3
|
+
import { basename, extname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
|
|
6
|
+
import { createTimeline } from "./timeline.js";
|
|
7
|
+
|
|
8
|
+
const FORMAT_ALIASES = {
|
|
9
|
+
json: "json",
|
|
10
|
+
markdown: "markdown",
|
|
11
|
+
md: "markdown",
|
|
12
|
+
mermaid: "mermaid_gantt",
|
|
13
|
+
mermaid_gantt: "mermaid_gantt",
|
|
14
|
+
gantt: "mermaid_gantt",
|
|
15
|
+
mermaid_timeline: "mermaid_timeline",
|
|
16
|
+
timeline: "mermaid_timeline",
|
|
17
|
+
review: "review_report",
|
|
18
|
+
review_report: "review_report"
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export function parseCliArgs(argv = []) {
|
|
22
|
+
const options = {
|
|
23
|
+
inputPath: "-",
|
|
24
|
+
sourceType: "text",
|
|
25
|
+
format: "json"
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
29
|
+
const arg = argv[index];
|
|
30
|
+
if (arg === "--type" || arg === "--source-type") {
|
|
31
|
+
options.sourceType = requireValue(argv, index, arg);
|
|
32
|
+
index += 1;
|
|
33
|
+
} else if (arg === "--format") {
|
|
34
|
+
options.format = requireValue(argv, index, arg);
|
|
35
|
+
index += 1;
|
|
36
|
+
} else if (arg === "--help" || arg === "-h") {
|
|
37
|
+
options.help = true;
|
|
38
|
+
} else if (arg.startsWith("--")) {
|
|
39
|
+
throw new Error(`Unknown option: ${arg}`);
|
|
40
|
+
} else {
|
|
41
|
+
options.inputPath = arg;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
if (options.inputPath !== "-" && options.sourceType === "text") {
|
|
46
|
+
options.sourceType = inferSourceType(options.inputPath);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
return options;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function runTimelineCli({ argv = process.argv.slice(2), stdin, cwd = process.cwd() } = {}) {
|
|
53
|
+
const options = parseCliArgs(argv);
|
|
54
|
+
if (options.help) return usage();
|
|
55
|
+
|
|
56
|
+
const content = options.inputPath === "-"
|
|
57
|
+
? stdin ?? readFileSync(0, "utf8")
|
|
58
|
+
: readFileSync(resolve(cwd, options.inputPath), "utf8");
|
|
59
|
+
const result = createTimeline({
|
|
60
|
+
sources: [
|
|
61
|
+
{
|
|
62
|
+
id: options.inputPath === "-" ? "stdin" : basename(options.inputPath),
|
|
63
|
+
type: options.sourceType,
|
|
64
|
+
path: options.inputPath === "-" ? undefined : options.inputPath,
|
|
65
|
+
content
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
return formatCliResult(result, options.format);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function formatCliResult(result, format = "json") {
|
|
74
|
+
const normalizedFormat = normalizeFormat(format);
|
|
75
|
+
if (normalizedFormat === "json") return JSON.stringify(result, null, 2);
|
|
76
|
+
if (normalizedFormat === "markdown") return result.renders.markdown.trimEnd();
|
|
77
|
+
if (normalizedFormat === "review_report") return result.renders.review_report.trimEnd();
|
|
78
|
+
if (normalizedFormat === "mermaid_timeline") return result.renders.mermaid_timeline.trimEnd();
|
|
79
|
+
return result.renders.mermaid_gantt.trimEnd();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function normalizeFormat(format) {
|
|
83
|
+
const normalized = String(format || "json").toLowerCase();
|
|
84
|
+
const mapped = FORMAT_ALIASES[normalized];
|
|
85
|
+
if (!mapped) {
|
|
86
|
+
throw new Error(`Unsupported format "${format}". Use json, markdown, mermaid_gantt, mermaid_timeline, or review.`);
|
|
87
|
+
}
|
|
88
|
+
return mapped;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function inferSourceType(inputPath) {
|
|
92
|
+
const extension = extname(inputPath).toLowerCase();
|
|
93
|
+
if (extension === ".md" || extension === ".markdown") return "markdown";
|
|
94
|
+
if (extension === ".csv") return "csv";
|
|
95
|
+
if (extension === ".json") return "json";
|
|
96
|
+
return "text";
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function requireValue(argv, index, option) {
|
|
100
|
+
const value = argv[index + 1];
|
|
101
|
+
if (!value || value.startsWith("--")) {
|
|
102
|
+
throw new Error(`Missing value for ${option}.`);
|
|
103
|
+
}
|
|
104
|
+
return value;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function usage() {
|
|
108
|
+
return [
|
|
109
|
+
"Usage: timeline-truth [file] [--type text|markdown|csv|json] [--format json|markdown|mermaid_gantt|mermaid_timeline|review]",
|
|
110
|
+
"",
|
|
111
|
+
"Reads stdin when no file is provided."
|
|
112
|
+
].join("\n");
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const isDirectRun = process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
|
116
|
+
|
|
117
|
+
if (isDirectRun) {
|
|
118
|
+
try {
|
|
119
|
+
process.stdout.write(`${runTimelineCli()}\n`);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
|
122
|
+
process.exitCode = 1;
|
|
123
|
+
}
|
|
124
|
+
}
|
package/src/mcp-server.js
CHANGED
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
|
}
|
|
@@ -61,7 +79,7 @@ export function listTimelineTools() {
|
|
|
61
79
|
},
|
|
62
80
|
{
|
|
63
81
|
name: "render_timeline",
|
|
64
|
-
description: "Render a normalized timeline as Mermaid Gantt, Mermaid timeline, or
|
|
82
|
+
description: "Render a normalized timeline as Mermaid Gantt, Mermaid timeline, compact Markdown, or a review report.",
|
|
65
83
|
inputSchema: {
|
|
66
84
|
type: "object",
|
|
67
85
|
required: ["timeline"],
|
|
@@ -70,7 +88,7 @@ export function listTimelineTools() {
|
|
|
70
88
|
timeline: TIMELINE_SCHEMA,
|
|
71
89
|
format: {
|
|
72
90
|
type: "string",
|
|
73
|
-
enum: ["mermaid_gantt", "mermaid_timeline", "markdown"],
|
|
91
|
+
enum: ["mermaid_gantt", "mermaid_timeline", "markdown", "review_report"],
|
|
74
92
|
default: "mermaid_gantt"
|
|
75
93
|
}
|
|
76
94
|
}
|
|
@@ -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: [
|
|
@@ -13,7 +17,7 @@ export function createTimeline(input = {}) {
|
|
|
13
17
|
gaps: [],
|
|
14
18
|
render: {
|
|
15
19
|
audience: "TPM/PM",
|
|
16
|
-
defaultFormats: ["mermaid_gantt", "mermaid_timeline", "markdown"]
|
|
20
|
+
defaultFormats: ["mermaid_gantt", "mermaid_timeline", "markdown", "review_report"]
|
|
17
21
|
}
|
|
18
22
|
});
|
|
19
23
|
const validation = validateTimeline(timeline);
|
|
@@ -22,16 +26,20 @@ export function createTimeline(input = {}) {
|
|
|
22
26
|
gaps: validation.gaps,
|
|
23
27
|
issues: validation.issues
|
|
24
28
|
};
|
|
29
|
+
const followups = buildFollowups(validatedTimeline);
|
|
25
30
|
|
|
26
31
|
return {
|
|
27
32
|
timeline: validatedTimeline,
|
|
28
33
|
assumptions: validatedTimeline.assumptions,
|
|
29
34
|
gaps: validatedTimeline.gaps,
|
|
30
35
|
issues: validation.issues,
|
|
36
|
+
followups,
|
|
37
|
+
noise_report: noiseReport,
|
|
31
38
|
renders: {
|
|
32
39
|
mermaid_gantt: renderTimeline(validatedTimeline, { format: "mermaid_gantt" }),
|
|
33
40
|
mermaid_timeline: renderTimeline(validatedTimeline, { format: "mermaid_timeline" }),
|
|
34
|
-
markdown: renderTimeline(validatedTimeline, { format: "markdown" })
|
|
41
|
+
markdown: renderTimeline(validatedTimeline, { format: "markdown" }),
|
|
42
|
+
review_report: renderTimeline(validatedTimeline, { format: "review_report" })
|
|
35
43
|
}
|
|
36
44
|
};
|
|
37
45
|
}
|
|
@@ -42,11 +50,15 @@ export function validateTimeline(timeline = {}) {
|
|
|
42
50
|
const issues = [];
|
|
43
51
|
|
|
44
52
|
for (const item of normalized.items) {
|
|
45
|
-
if (
|
|
53
|
+
if (item.exact_date_needed) {
|
|
54
|
+
gaps.push(makeGap(item, "exact_date", "Exact date needed before rendering this fuzzy time window."));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (!item.start && !item.time_window) {
|
|
46
58
|
gaps.push(makeGap(item, "start", "Missing start date. Ask for the planned start date instead of inferring it."));
|
|
47
59
|
}
|
|
48
60
|
|
|
49
|
-
if (!item.end && !item.duration && item.type !== "milestone") {
|
|
61
|
+
if (!item.end && !item.duration && !item.time_window && item.type !== "milestone") {
|
|
50
62
|
gaps.push(makeGap(item, "end", "Missing end date or duration for a non-milestone item."));
|
|
51
63
|
}
|
|
52
64
|
|
|
@@ -62,11 +74,13 @@ export function validateTimeline(timeline = {}) {
|
|
|
62
74
|
for (const item of normalized.items) {
|
|
63
75
|
for (const dependency of item.dependencies) {
|
|
64
76
|
if (!normalized.items.some((candidate) => candidate.title === dependency)) {
|
|
77
|
+
const suggestions = suggestDependencyTitles(normalized.items, dependency);
|
|
65
78
|
issues.push({
|
|
66
79
|
type: "unknown_dependency",
|
|
67
80
|
severity: "warning",
|
|
68
81
|
itemTitle: item.title,
|
|
69
82
|
dependency,
|
|
83
|
+
suggestions,
|
|
70
84
|
message: `Dependency "${dependency}" was not found in the timeline.`
|
|
71
85
|
});
|
|
72
86
|
}
|
|
@@ -113,6 +127,10 @@ export function renderTimeline(timeline = {}, options = {}) {
|
|
|
113
127
|
return renderMarkdown(normalized);
|
|
114
128
|
}
|
|
115
129
|
|
|
130
|
+
if (format === "review_report") {
|
|
131
|
+
return renderReviewReport(normalized);
|
|
132
|
+
}
|
|
133
|
+
|
|
116
134
|
return renderMermaidGantt(normalized);
|
|
117
135
|
}
|
|
118
136
|
|
|
@@ -142,11 +160,12 @@ export function refineTimeline(timeline = {}, refinement = {}) {
|
|
|
142
160
|
};
|
|
143
161
|
}
|
|
144
162
|
|
|
145
|
-
function parseSource(source, index, importedAssumptions) {
|
|
163
|
+
function parseSource(source, index, importedAssumptions, input, noiseReport) {
|
|
146
164
|
const normalizedSource = {
|
|
147
165
|
id: source?.id || `source-${index + 1}`,
|
|
148
166
|
type: source?.type || "text",
|
|
149
|
-
content: source?.content ?? ""
|
|
167
|
+
content: source?.content ?? "",
|
|
168
|
+
path: source?.path || source?.file_path || source?.filePath
|
|
150
169
|
};
|
|
151
170
|
|
|
152
171
|
if (normalizedSource.type === "json") {
|
|
@@ -157,11 +176,21 @@ function parseSource(source, index, importedAssumptions) {
|
|
|
157
176
|
return parseCsvSource(normalizedSource);
|
|
158
177
|
}
|
|
159
178
|
|
|
179
|
+
if (normalizedSource.type === "markdown") {
|
|
180
|
+
return parseMarkdownSource(normalizedSource, input?.markdown, noiseReport);
|
|
181
|
+
}
|
|
182
|
+
|
|
160
183
|
return parseTextSource(normalizedSource);
|
|
161
184
|
}
|
|
162
185
|
|
|
163
186
|
function parseJsonSource(source, importedAssumptions) {
|
|
164
|
-
|
|
187
|
+
let parsed;
|
|
188
|
+
try {
|
|
189
|
+
parsed = typeof source.content === "string" ? JSON.parse(source.content) : source.content;
|
|
190
|
+
} catch (error) {
|
|
191
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
192
|
+
throw new Error(`Unable to parse JSON source "${source.id}": ${detail}`);
|
|
193
|
+
}
|
|
165
194
|
const rawItems = Array.isArray(parsed) ? parsed : parsed.items ?? [];
|
|
166
195
|
|
|
167
196
|
if (Array.isArray(parsed.assumptions)) {
|
|
@@ -197,6 +226,85 @@ function parseTextSource(source) {
|
|
|
197
226
|
.filter(Boolean);
|
|
198
227
|
}
|
|
199
228
|
|
|
229
|
+
function parseMarkdownSource(source, markdownOptions = {}, noiseReport = createNoiseReport()) {
|
|
230
|
+
const lines = String(source.content).split(/\r?\n/);
|
|
231
|
+
const allowedHeadings = getAllowedMarkdownHeadings(markdownOptions);
|
|
232
|
+
const hasAllowedHeadings = markdownHasAllowedHeadings(lines, allowedHeadings);
|
|
233
|
+
const items = [];
|
|
234
|
+
let currentHeading;
|
|
235
|
+
let inFrontmatter = markdownOptions?.ignoreFrontmatter === false ? false : lines[0]?.trim() === "---";
|
|
236
|
+
|
|
237
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
238
|
+
const line = lines[index];
|
|
239
|
+
const trimmed = line.trim();
|
|
240
|
+
|
|
241
|
+
if (inFrontmatter) {
|
|
242
|
+
noiseReport.ignored.frontmatter_lines += 1;
|
|
243
|
+
if (index > 0 && trimmed === "---") inFrontmatter = false;
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
const heading = parseMarkdownHeading(trimmed);
|
|
248
|
+
if (heading) {
|
|
249
|
+
currentHeading = heading;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (!trimmed) continue;
|
|
254
|
+
|
|
255
|
+
const inAllowedSection = !hasAllowedHeadings || allowedHeadings.has(normalizeHeading(currentHeading));
|
|
256
|
+
if (!inAllowedSection) {
|
|
257
|
+
noiseReport.ignored.prose_lines += 1;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (isMarkdownTableLine(trimmed)) {
|
|
262
|
+
const table = parseMarkdownTable(lines, index);
|
|
263
|
+
if (!table) {
|
|
264
|
+
noiseReport.ignored.prose_lines += 1;
|
|
265
|
+
continue;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
for (const row of table.rows) {
|
|
269
|
+
const item = markdownRecordToItem(row.record, currentHeading);
|
|
270
|
+
if (!item) {
|
|
271
|
+
noiseReport.ignored.table_rows_without_dates += row.hasDate ? 0 : 1;
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
if (!row.hasDate) noiseReport.ignored.table_rows_without_dates += 1;
|
|
276
|
+
items.push(
|
|
277
|
+
normalizeItem(item, [
|
|
278
|
+
compactObject({
|
|
279
|
+
sourceId: source.id,
|
|
280
|
+
path: source.path,
|
|
281
|
+
heading: currentHeading,
|
|
282
|
+
tableRow: row.tableRow,
|
|
283
|
+
line: row.line,
|
|
284
|
+
text: row.text
|
|
285
|
+
})
|
|
286
|
+
])
|
|
287
|
+
);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
index = table.endIndex;
|
|
291
|
+
continue;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const parsedLine = parseTextLine(line, source.id, index + 1);
|
|
295
|
+
if (parsedLine) {
|
|
296
|
+
parsedLine.source_refs = parsedLine.source_refs.map((sourceRef) =>
|
|
297
|
+
compactObject({ ...sourceRef, path: source.path, heading: currentHeading })
|
|
298
|
+
);
|
|
299
|
+
items.push(parsedLine);
|
|
300
|
+
} else {
|
|
301
|
+
noiseReport.ignored.prose_lines += 1;
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return items;
|
|
306
|
+
}
|
|
307
|
+
|
|
200
308
|
function parseTextLine(line, sourceId, lineNumber) {
|
|
201
309
|
const trimmed = normalizePlanningLine(line);
|
|
202
310
|
if (!trimmed) return null;
|
|
@@ -218,7 +326,10 @@ function parseTextLine(line, sourceId, lineNumber) {
|
|
|
218
326
|
owner,
|
|
219
327
|
status,
|
|
220
328
|
dependencies,
|
|
221
|
-
confidence: dates.length > 0 ? 0.75 : 0.45
|
|
329
|
+
confidence: dates.length > 0 ? 0.75 : 0.45,
|
|
330
|
+
confidence_reason: dates.length > 0
|
|
331
|
+
? "Exact date evidence found in source text."
|
|
332
|
+
: "No exact dates found; timeline placement needs human follow-up."
|
|
222
333
|
};
|
|
223
334
|
|
|
224
335
|
return normalizeItem(item, [{ sourceId, line: lineNumber, text: trimmed }]);
|
|
@@ -292,6 +403,36 @@ function csvRecordToItem(record) {
|
|
|
292
403
|
};
|
|
293
404
|
}
|
|
294
405
|
|
|
406
|
+
function markdownRecordToItem(record, heading) {
|
|
407
|
+
const title =
|
|
408
|
+
valueFromRecord(record, ["item", "follow_up", "follow-up", "followup", "task", "milestone", "risk", "blocker"]) ||
|
|
409
|
+
valueFromRecord(record, ["title", "name"]);
|
|
410
|
+
if (!title) return null;
|
|
411
|
+
|
|
412
|
+
const target = valueFromRecord(record, ["target", "date", "when", "time_window", "window"]);
|
|
413
|
+
const dates = extractExactDates(target);
|
|
414
|
+
const fuzzyTarget = target && dates.length === 0 ? target : undefined;
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
title,
|
|
418
|
+
type: normalizeHeading(heading) === "milestones" || record.type === "milestone" ? "milestone" : "task",
|
|
419
|
+
start: dates[0],
|
|
420
|
+
end: dates[1],
|
|
421
|
+
owner: valueFromRecord(record, ["owner", "assignee"]),
|
|
422
|
+
status: valueFromRecord(record, ["status"]) || "planned",
|
|
423
|
+
dependencies: valueFromRecord(record, ["dependencies", "depends_on", "depends on"]),
|
|
424
|
+
time_window: fuzzyTarget,
|
|
425
|
+
date_text: fuzzyTarget,
|
|
426
|
+
exact_date_needed: Boolean(fuzzyTarget),
|
|
427
|
+
confidence: target ? 0.7 : 0.55,
|
|
428
|
+
confidence_reason: fuzzyTarget
|
|
429
|
+
? "Fuzzy date text was preserved for human review."
|
|
430
|
+
: target
|
|
431
|
+
? "Exact target date evidence found in Markdown table."
|
|
432
|
+
: "No target date found in Markdown table row."
|
|
433
|
+
};
|
|
434
|
+
}
|
|
435
|
+
|
|
295
436
|
function normalizeTimeline(timeline = {}) {
|
|
296
437
|
const items = Array.isArray(timeline.items)
|
|
297
438
|
? timeline.items.map((item) => normalizeItem(item, item.source_refs))
|
|
@@ -303,6 +444,7 @@ function normalizeTimeline(timeline = {}) {
|
|
|
303
444
|
milestones,
|
|
304
445
|
assumptions: Array.isArray(timeline.assumptions) ? [...timeline.assumptions] : [],
|
|
305
446
|
gaps: Array.isArray(timeline.gaps) ? [...timeline.gaps] : [],
|
|
447
|
+
issues: Array.isArray(timeline.issues) ? [...timeline.issues] : [],
|
|
306
448
|
render: timeline.render && typeof timeline.render === "object" ? { ...timeline.render } : {}
|
|
307
449
|
};
|
|
308
450
|
}
|
|
@@ -321,10 +463,16 @@ function normalizeItem(item = {}, sourceRefs = []) {
|
|
|
321
463
|
start: blankToUndefined(item.start),
|
|
322
464
|
end: blankToUndefined(item.end),
|
|
323
465
|
duration: blankToUndefined(item.duration),
|
|
466
|
+
time_window: blankToUndefined(item.time_window),
|
|
467
|
+
date_text: blankToUndefined(item.date_text),
|
|
468
|
+
exact_date_needed: Boolean(item.exact_date_needed),
|
|
324
469
|
owner: blankToUndefined(item.owner),
|
|
325
470
|
status: blankToUndefined(item.status) || "unknown",
|
|
326
471
|
dependencies: dependencies.map((dependency) => String(dependency).trim()).filter(Boolean),
|
|
327
472
|
confidence: typeof item.confidence === "number" ? item.confidence : 0.6,
|
|
473
|
+
confidence_reason: typeof item.confidence_reason === "string"
|
|
474
|
+
? item.confidence_reason
|
|
475
|
+
: deriveConfidenceReason(item),
|
|
328
476
|
source_refs: normalizeSourceRefs(sourceRefs)
|
|
329
477
|
};
|
|
330
478
|
}
|
|
@@ -392,6 +540,24 @@ function dedupeCycles(cycles) {
|
|
|
392
540
|
});
|
|
393
541
|
}
|
|
394
542
|
|
|
543
|
+
function suggestDependencyTitles(items, dependency) {
|
|
544
|
+
const dependencyKey = normalizeDependencyKey(dependency);
|
|
545
|
+
if (!dependencyKey) return [];
|
|
546
|
+
|
|
547
|
+
return items
|
|
548
|
+
.filter((item) => {
|
|
549
|
+
const titleKey = normalizeDependencyKey(item.title);
|
|
550
|
+
const idKey = normalizeDependencyKey(item.id);
|
|
551
|
+
return titleKey === dependencyKey || idKey === dependencyKey;
|
|
552
|
+
})
|
|
553
|
+
.map((item) => item.title)
|
|
554
|
+
.slice(0, 3);
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function normalizeDependencyKey(value) {
|
|
558
|
+
return String(value || "").toLowerCase().replace(/[^a-z0-9]+/g, "");
|
|
559
|
+
}
|
|
560
|
+
|
|
395
561
|
function renderMermaidGantt(timeline) {
|
|
396
562
|
const lines = ["gantt", " title Project Timeline", " dateFormat YYYY-MM-DD", " axisFormat %b %d", " section Plan"];
|
|
397
563
|
|
|
@@ -451,6 +617,107 @@ function renderMarkdown(timeline) {
|
|
|
451
617
|
return `${lines.join("\n")}\n`;
|
|
452
618
|
}
|
|
453
619
|
|
|
620
|
+
function renderReviewReport(timeline) {
|
|
621
|
+
const followups = buildFollowups(timeline);
|
|
622
|
+
const lines = ["## Timeline Review", "", "### Items"];
|
|
623
|
+
|
|
624
|
+
for (const item of timeline.items) {
|
|
625
|
+
const window = item.start ? `${item.start}${item.end ? ` to ${item.end}` : item.duration ? ` for ${item.duration}` : ""}` : item.time_window || "date needed";
|
|
626
|
+
lines.push(`- **${item.title}** (${item.type}, ${item.status}) - ${window}${item.owner ? ` - owner: ${item.owner}` : ""}`);
|
|
627
|
+
lines.push(` - Confidence: ${item.confidence} - ${item.confidence_reason}`);
|
|
628
|
+
if (item.source_refs.length > 0) {
|
|
629
|
+
lines.push(` - Source: ${formatSourceRef(item.source_refs[0])}`);
|
|
630
|
+
}
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
if (timeline.gaps.length > 0) {
|
|
634
|
+
lines.push("", "### Follow-Up Questions");
|
|
635
|
+
for (const followup of followups.all) {
|
|
636
|
+
lines.push(`- ${followup.itemTitle}${followup.owner ? ` (${followup.owner})` : ""}: ${followup.question}`);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
if (timeline.issues.length > 0) {
|
|
641
|
+
lines.push("", "### Issues");
|
|
642
|
+
for (const issue of timeline.issues) {
|
|
643
|
+
const suggestions = issue.suggestions?.length ? ` Suggestions: ${issue.suggestions.join(", ")}.` : "";
|
|
644
|
+
lines.push(`- ${issue.severity}: ${issue.message}${suggestions}`);
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
if (timeline.assumptions.length > 0) {
|
|
649
|
+
lines.push("", "### Assumptions");
|
|
650
|
+
for (const assumption of timeline.assumptions) {
|
|
651
|
+
lines.push(`- ${assumption}`);
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
|
|
655
|
+
return `${lines.join("\n")}\n`;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function buildFollowups(timeline) {
|
|
659
|
+
const byTitle = new Map(timeline.items.map((item) => [item.title, item]));
|
|
660
|
+
const gapFollowups = timeline.gaps.map((gap) => {
|
|
661
|
+
const item = byTitle.get(gap.itemTitle);
|
|
662
|
+
return {
|
|
663
|
+
itemTitle: gap.itemTitle,
|
|
664
|
+
field: gap.field,
|
|
665
|
+
owner: item?.owner,
|
|
666
|
+
question: gap.question,
|
|
667
|
+
source_refs: gap.source_refs
|
|
668
|
+
};
|
|
669
|
+
});
|
|
670
|
+
const dependencyFollowups = timeline.issues
|
|
671
|
+
.filter((issue) => issue.type === "unknown_dependency")
|
|
672
|
+
.map((issue) => {
|
|
673
|
+
const item = byTitle.get(issue.itemTitle);
|
|
674
|
+
const suggestions = issue.suggestions?.length
|
|
675
|
+
? ` Did you mean ${issue.suggestions.join(", ")}?`
|
|
676
|
+
: "";
|
|
677
|
+
return {
|
|
678
|
+
itemTitle: issue.itemTitle,
|
|
679
|
+
field: "dependency",
|
|
680
|
+
dependency: issue.dependency,
|
|
681
|
+
owner: item?.owner,
|
|
682
|
+
question: `Confirm the dependency "${issue.dependency}" or add it to the timeline.${suggestions}`,
|
|
683
|
+
source_refs: item?.source_refs ?? []
|
|
684
|
+
};
|
|
685
|
+
});
|
|
686
|
+
const all = [...gapFollowups, ...dependencyFollowups];
|
|
687
|
+
const dateFollowups = all.filter((followup) => ["start", "end", "exact_date"].includes(followup.field));
|
|
688
|
+
|
|
689
|
+
return {
|
|
690
|
+
all,
|
|
691
|
+
by_field: groupBy(all, (followup) => followup.field),
|
|
692
|
+
by_owner: groupBy(all, (followup) => followup.owner || "Unassigned"),
|
|
693
|
+
by_date: groupBy(dateFollowups, (followup) => followup.field),
|
|
694
|
+
by_dependency: groupBy(dependencyFollowups, (followup) => followup.dependency)
|
|
695
|
+
};
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function groupBy(values, keyFn) {
|
|
699
|
+
return values.reduce((groups, value) => {
|
|
700
|
+
const key = keyFn(value);
|
|
701
|
+
groups[key] = groups[key] || [];
|
|
702
|
+
groups[key].push(value);
|
|
703
|
+
return groups;
|
|
704
|
+
}, {});
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
function deriveConfidenceReason(item) {
|
|
708
|
+
if (item.time_window || item.date_text) return "Fuzzy date text was preserved for human review.";
|
|
709
|
+
if (item.start || item.end || item.duration) return "Structured date evidence was supplied.";
|
|
710
|
+
return "No date evidence was supplied.";
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function formatSourceRef(sourceRef) {
|
|
714
|
+
const parts = [sourceRef.sourceId];
|
|
715
|
+
if (sourceRef.path) parts.push(sourceRef.path);
|
|
716
|
+
if (sourceRef.heading) parts.push(`heading "${sourceRef.heading}"`);
|
|
717
|
+
if (sourceRef.line) parts.push(`line ${sourceRef.line}`);
|
|
718
|
+
return parts.filter(Boolean).join(", ");
|
|
719
|
+
}
|
|
720
|
+
|
|
454
721
|
function parseCsv(content) {
|
|
455
722
|
const rows = [];
|
|
456
723
|
let row = [];
|
|
@@ -503,6 +770,115 @@ function blankToUndefined(value) {
|
|
|
503
770
|
return normalized === "" ? undefined : normalized;
|
|
504
771
|
}
|
|
505
772
|
|
|
773
|
+
function createNoiseReport() {
|
|
774
|
+
return {
|
|
775
|
+
ignored: {
|
|
776
|
+
frontmatter_lines: 0,
|
|
777
|
+
prose_lines: 0,
|
|
778
|
+
table_rows_without_dates: 0
|
|
779
|
+
}
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
function getAllowedMarkdownHeadings(options = {}) {
|
|
784
|
+
const sections = Array.isArray(options?.sections) && options.sections.length > 0
|
|
785
|
+
? options.sections
|
|
786
|
+
: DEFAULT_MARKDOWN_SECTIONS;
|
|
787
|
+
return new Set(sections.map((section) => normalizeHeading(section)));
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
function markdownHasAllowedHeadings(lines, allowedHeadings) {
|
|
791
|
+
return lines.some((line) => {
|
|
792
|
+
const heading = parseMarkdownHeading(line.trim());
|
|
793
|
+
return heading && allowedHeadings.has(normalizeHeading(heading));
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
function parseMarkdownHeading(line) {
|
|
798
|
+
const match = line.match(/^#{1,6}\s+(.+?)\s*#*$/);
|
|
799
|
+
return match ? match[1].trim() : undefined;
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function normalizeHeading(value) {
|
|
803
|
+
return String(value || "")
|
|
804
|
+
.trim()
|
|
805
|
+
.toLowerCase()
|
|
806
|
+
.replace(/&/g, "and")
|
|
807
|
+
.replace(/[^a-z0-9]+/g, " ")
|
|
808
|
+
.trim();
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function isMarkdownTableLine(line) {
|
|
812
|
+
return /^\|.*\|\s*$/.test(line);
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
function parseMarkdownTable(lines, startIndex) {
|
|
816
|
+
const header = parseMarkdownTableCells(lines[startIndex]);
|
|
817
|
+
const separator = parseMarkdownTableCells(lines[startIndex + 1]);
|
|
818
|
+
if (header.length === 0 || !isMarkdownSeparatorRow(separator)) return null;
|
|
819
|
+
|
|
820
|
+
const headers = header.map((cell) => normalizeHeader(cell));
|
|
821
|
+
const rows = [];
|
|
822
|
+
let index = startIndex + 2;
|
|
823
|
+
let tableRow = 1;
|
|
824
|
+
|
|
825
|
+
while (index < lines.length && isMarkdownTableLine(lines[index].trim())) {
|
|
826
|
+
const cells = parseMarkdownTableCells(lines[index]);
|
|
827
|
+
const record = {};
|
|
828
|
+
headers.forEach((column, columnIndex) => {
|
|
829
|
+
record[column] = cells[columnIndex] ?? "";
|
|
830
|
+
});
|
|
831
|
+
|
|
832
|
+
const target = valueFromRecord(record, ["target", "date", "when", "time_window", "window"]);
|
|
833
|
+
rows.push({
|
|
834
|
+
record,
|
|
835
|
+
tableRow,
|
|
836
|
+
line: index + 1,
|
|
837
|
+
text: lines[index].trim(),
|
|
838
|
+
hasDate: extractExactDates(target).length > 0 || Boolean(blankToUndefined(target))
|
|
839
|
+
});
|
|
840
|
+
tableRow += 1;
|
|
841
|
+
index += 1;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
return {
|
|
845
|
+
rows,
|
|
846
|
+
endIndex: index - 1
|
|
847
|
+
};
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function parseMarkdownTableCells(line = "") {
|
|
851
|
+
const trimmed = String(line).trim();
|
|
852
|
+
if (!isMarkdownTableLine(trimmed)) return [];
|
|
853
|
+
return trimmed
|
|
854
|
+
.replace(/^\|/, "")
|
|
855
|
+
.replace(/\|$/, "")
|
|
856
|
+
.split("|")
|
|
857
|
+
.map((cell) => cell.trim());
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function isMarkdownSeparatorRow(cells) {
|
|
861
|
+
return cells.length > 0 && cells.every((cell) => /^:?-{3,}:?$/.test(cell.trim()));
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function valueFromRecord(record, names) {
|
|
865
|
+
for (const name of names) {
|
|
866
|
+
const normalized = normalizeHeader(name);
|
|
867
|
+
const value = blankToUndefined(record[normalized]);
|
|
868
|
+
if (value) return value;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
return undefined;
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
function extractExactDates(value) {
|
|
875
|
+
return [...String(value || "").matchAll(DATE_PATTERN)].map((match) => match[0]);
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
function compactObject(value) {
|
|
879
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined));
|
|
880
|
+
}
|
|
881
|
+
|
|
506
882
|
function escapeMermaidText(value) {
|
|
507
883
|
return String(value).replace(/[:#;]/g, "-").trim();
|
|
508
884
|
}
|