@md2do/core 0.2.3 → 0.4.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/coverage/coverage-final.json +6 -5
  3. package/coverage/index.html +52 -37
  4. package/coverage/lcov-report/index.html +52 -37
  5. package/coverage/lcov-report/src/filters/index.html +1 -1
  6. package/coverage/lcov-report/src/filters/index.ts.html +1 -1
  7. package/coverage/lcov-report/src/index.html +5 -5
  8. package/coverage/lcov-report/src/index.ts.html +13 -4
  9. package/coverage/lcov-report/src/parser/index.html +17 -17
  10. package/coverage/lcov-report/src/parser/index.ts.html +181 -13
  11. package/coverage/lcov-report/src/parser/patterns.ts.html +18 -9
  12. package/coverage/lcov-report/src/scanner/index.html +15 -15
  13. package/coverage/lcov-report/src/scanner/index.ts.html +83 -8
  14. package/coverage/lcov-report/src/sorting/index.html +1 -1
  15. package/coverage/lcov-report/src/sorting/index.ts.html +1 -1
  16. package/coverage/lcov-report/src/utils/dates.ts.html +169 -25
  17. package/coverage/lcov-report/src/utils/id.ts.html +1 -1
  18. package/coverage/lcov-report/src/utils/index.html +19 -19
  19. package/coverage/lcov-report/src/warnings/filter.ts.html +364 -0
  20. package/coverage/lcov-report/src/warnings/index.html +116 -0
  21. package/coverage/lcov-report/src/writer/index.html +1 -1
  22. package/coverage/lcov-report/src/writer/index.ts.html +1 -1
  23. package/coverage/lcov.info +760 -512
  24. package/coverage/src/filters/index.html +1 -1
  25. package/coverage/src/filters/index.ts.html +1 -1
  26. package/coverage/src/index.html +5 -5
  27. package/coverage/src/index.ts.html +13 -4
  28. package/coverage/src/parser/index.html +17 -17
  29. package/coverage/src/parser/index.ts.html +181 -13
  30. package/coverage/src/parser/patterns.ts.html +18 -9
  31. package/coverage/src/scanner/index.html +15 -15
  32. package/coverage/src/scanner/index.ts.html +83 -8
  33. package/coverage/src/sorting/index.html +1 -1
  34. package/coverage/src/sorting/index.ts.html +1 -1
  35. package/coverage/src/utils/dates.ts.html +169 -25
  36. package/coverage/src/utils/id.ts.html +1 -1
  37. package/coverage/src/utils/index.html +19 -19
  38. package/coverage/src/warnings/filter.ts.html +364 -0
  39. package/coverage/src/warnings/index.html +116 -0
  40. package/coverage/src/writer/index.html +1 -1
  41. package/coverage/src/writer/index.ts.html +1 -1
  42. package/dist/index.d.mts +103 -12
  43. package/dist/index.d.ts +103 -12
  44. package/dist/index.js +125 -11
  45. package/dist/index.mjs +125 -12
  46. package/package.json +1 -1
  47. package/src/index.ts +3 -0
  48. package/src/parser/index.ts +61 -5
  49. package/src/parser/patterns.ts +8 -5
  50. package/src/scanner/index.ts +25 -0
  51. package/src/types/index.ts +35 -2
  52. package/src/utils/dates.ts +58 -10
  53. package/src/warnings/filter.ts +93 -0
package/dist/index.mjs CHANGED
@@ -10,7 +10,7 @@ var ASSIGNEE = /@([\w-]+)/;
10
10
  var PRIORITY_URGENT = /!!!/;
11
11
  var PRIORITY_HIGH = /!!/;
12
12
  var PRIORITY_NORMAL = /(?<!!)!(?!!)/;
13
- var DUE_DATE_ABSOLUTE = /\[due:\s*(\d{4}-\d{2}-\d{2})\s*\]/i;
13
+ var DUE_DATE_ABSOLUTE = /\[due:\s*(\d{4}-\d{2}-\d{2})(?:\s+(\d{1,2}:\d{2}))?\s*\]/i;
14
14
  var DUE_DATE_RELATIVE = /\[due:\s*(tomorrow|today|next\s+week|next\s+month)\]/i;
15
15
  var DUE_DATE_SHORT = /\[due:\s*(\d{1,2}\/\d{1,2}(?:\/\d{2,4})?)\]/i;
16
16
  var TAG = /#([\w-]+)/g;
@@ -43,17 +43,38 @@ import {
43
43
  addDays,
44
44
  addWeeks,
45
45
  addMonths,
46
- startOfWeek
46
+ startOfWeek,
47
+ setHours,
48
+ setMinutes
47
49
  } from "date-fns";
48
- function parseAbsoluteDate(dateStr) {
50
+ function parseTime(timeStr) {
51
+ const match = timeStr.match(/^(\d{1,2}):(\d{2})$/);
52
+ if (!match || !match[1] || !match[2]) return null;
53
+ const hours = parseInt(match[1], 10);
54
+ const minutes = parseInt(match[2], 10);
55
+ if (hours < 0 || hours > 23 || minutes < 0 || minutes > 59) {
56
+ return null;
57
+ }
58
+ return { hours, minutes };
59
+ }
60
+ function parseAbsoluteDate(dateStr, timeStr) {
49
61
  const referenceDate = /* @__PURE__ */ new Date();
50
62
  let date = parse(dateStr, "yyyy-MM-dd", referenceDate);
51
- if (isValid(date)) return date;
52
- date = parse(dateStr, "M/d/yy", referenceDate);
53
- if (isValid(date)) return date;
54
- date = parse(dateStr, "M/d/yyyy", referenceDate);
55
- if (isValid(date)) return date;
56
- return null;
63
+ if (!isValid(date)) {
64
+ date = parse(dateStr, "M/d/yy", referenceDate);
65
+ }
66
+ if (!isValid(date)) {
67
+ date = parse(dateStr, "M/d/yyyy", referenceDate);
68
+ }
69
+ if (!isValid(date)) return null;
70
+ if (timeStr) {
71
+ const time = parseTime(timeStr);
72
+ if (time) {
73
+ date = setHours(date, time.hours);
74
+ date = setMinutes(date, time.minutes);
75
+ }
76
+ }
77
+ return date;
57
78
  }
58
79
  function resolveRelativeDate(relative, baseDate) {
59
80
  const normalized = relative.toLowerCase().trim();
@@ -145,12 +166,29 @@ function extractCompletedDate(text) {
145
166
  function extractDueDate(text, context) {
146
167
  const absoluteMatch = text.match(PATTERNS.DUE_DATE_ABSOLUTE);
147
168
  if (absoluteMatch?.[1]) {
148
- const date = parseAbsoluteDate(absoluteMatch[1]);
169
+ const dateStr = absoluteMatch[1];
170
+ let timeStr = absoluteMatch[2];
171
+ if (!timeStr && context.defaultDueTime) {
172
+ if (context.defaultDueTime === "start" && context.workdayStartTime) {
173
+ timeStr = context.workdayStartTime;
174
+ } else if (context.defaultDueTime === "end" && context.workdayEndTime) {
175
+ timeStr = context.workdayEndTime;
176
+ }
177
+ }
178
+ const date = parseAbsoluteDate(dateStr, timeStr);
149
179
  return { date: date ?? void 0 };
150
180
  }
151
181
  const shortMatch = text.match(PATTERNS.DUE_DATE_SHORT);
152
182
  if (shortMatch?.[1]) {
153
- const date = parseAbsoluteDate(shortMatch[1]);
183
+ let timeStr;
184
+ if (context.defaultDueTime) {
185
+ if (context.defaultDueTime === "start" && context.workdayStartTime) {
186
+ timeStr = context.workdayStartTime;
187
+ } else if (context.defaultDueTime === "end" && context.workdayEndTime) {
188
+ timeStr = context.workdayEndTime;
189
+ }
190
+ }
191
+ const date = parseAbsoluteDate(shortMatch[1], timeStr);
154
192
  return { date: date ?? void 0 };
155
193
  }
156
194
  const relativeMatch = text.match(PATTERNS.DUE_DATE_RELATIVE);
@@ -159,11 +197,15 @@ function extractDueDate(text, context) {
159
197
  return {
160
198
  date: void 0,
161
199
  warning: {
200
+ severity: "warning",
201
+ source: "md2do",
202
+ ruleId: "relative-date-no-context",
162
203
  file: "",
163
204
  // Will be filled in by caller
164
205
  line: 0,
165
206
  // Will be filled in by caller
166
207
  text: text.trim(),
208
+ message: "Relative due date without context date from heading. Add a heading with a date above this task.",
167
209
  reason: "Relative due date without context date from heading. Add a heading with a date above this task."
168
210
  }
169
211
  };
@@ -180,36 +222,52 @@ function parseTask(line, lineNumber, file, context) {
180
222
  const warnings = [];
181
223
  if (/^\s*[*+]\s+\[[ xX]\]/.test(line)) {
182
224
  warnings.push({
225
+ severity: "warning",
226
+ source: "md2do",
227
+ ruleId: "unsupported-bullet",
183
228
  file,
184
229
  line: lineNumber,
185
230
  text: line.trim(),
231
+ message: "Unsupported bullet marker (* or +). Use dash (-) for task lists.",
186
232
  reason: "Unsupported bullet marker (* or +). Use dash (-) for task lists."
187
233
  });
188
234
  return { task: null, warnings };
189
235
  }
190
236
  if (/^\s*-\s+\[[xX]\s+\]/.test(line) || /^\s*-\s+\[\s+[xX]\]/.test(line)) {
191
237
  warnings.push({
238
+ severity: "warning",
239
+ source: "md2do",
240
+ ruleId: "malformed-checkbox",
192
241
  file,
193
242
  line: lineNumber,
194
243
  text: line.trim(),
244
+ message: "Malformed checkbox with extra spaces. Use [x] or [ ] without extra spaces.",
195
245
  reason: "Malformed checkbox with extra spaces. Use [x] or [ ] without extra spaces."
196
246
  });
197
247
  return { task: null, warnings };
198
248
  }
199
249
  if (/^\s*-\s+\[[ xX]\][^\s]/.test(line)) {
200
250
  warnings.push({
251
+ severity: "warning",
252
+ source: "md2do",
253
+ ruleId: "missing-space-after",
201
254
  file,
202
255
  line: lineNumber,
203
256
  text: line.trim(),
257
+ message: 'Missing space after checkbox. Use "- [x] Task" format.',
204
258
  reason: 'Missing space after checkbox. Use "- [x] Task" format.'
205
259
  });
206
260
  return { task: null, warnings };
207
261
  }
208
262
  if (/^\s*-\[[ xX]\]/.test(line)) {
209
263
  warnings.push({
264
+ severity: "warning",
265
+ source: "md2do",
266
+ ruleId: "missing-space-before",
210
267
  file,
211
268
  line: lineNumber,
212
269
  text: line.trim(),
270
+ message: 'Missing space before checkbox. Use "- [x] Task" format.',
213
271
  reason: 'Missing space before checkbox. Use "- [x] Task" format.'
214
272
  });
215
273
  return { task: null, warnings };
@@ -235,17 +293,25 @@ function parseTask(line, lineNumber, file, context) {
235
293
  }
236
294
  if (!completed && !dueDateResult.date && !context.currentDate) {
237
295
  warnings.push({
296
+ severity: "info",
297
+ source: "md2do",
298
+ ruleId: "missing-due-date",
238
299
  file,
239
300
  line: lineNumber,
240
301
  text: fullText.trim(),
302
+ message: "Task has no due date. Add [due: YYYY-MM-DD] or place under a heading with a date.",
241
303
  reason: "Task has no due date. Add [due: YYYY-MM-DD] or place under a heading with a date."
242
304
  });
243
305
  }
244
306
  if (completed && !completedDate) {
245
307
  warnings.push({
308
+ severity: "info",
309
+ source: "md2do",
310
+ ruleId: "missing-completed-date",
246
311
  file,
247
312
  line: lineNumber,
248
313
  text: fullText.trim(),
314
+ message: "Completed task missing completion date. Add [completed: YYYY-MM-DD].",
249
315
  reason: "Completed task missing completion date. Add [completed: YYYY-MM-DD]."
250
316
  });
251
317
  }
@@ -289,9 +355,10 @@ var MarkdownScanner = class {
289
355
  *
290
356
  * @param filePath - Relative file path (for context extraction)
291
357
  * @param content - File content as string
358
+ * @param options - Optional scanner options including workday config
292
359
  * @returns Object containing tasks and warnings
293
360
  */
294
- scanFile(filePath, content) {
361
+ scanFile(filePath, content, options) {
295
362
  const tasks = [];
296
363
  const warnings = [];
297
364
  const todoistIds = /* @__PURE__ */ new Map();
@@ -300,6 +367,15 @@ var MarkdownScanner = class {
300
367
  if (project !== void 0) context.project = project;
301
368
  const person = extractPersonFromFilename(filePath);
302
369
  if (person !== void 0) context.person = person;
370
+ if (options?.workdayStartTime) {
371
+ context.workdayStartTime = options.workdayStartTime;
372
+ }
373
+ if (options?.workdayEndTime) {
374
+ context.workdayEndTime = options.workdayEndTime;
375
+ }
376
+ if (options?.defaultDueTime) {
377
+ context.defaultDueTime = options.defaultDueTime;
378
+ }
303
379
  const lines = content.split("\n");
304
380
  for (let i = 0; i < lines.length; i++) {
305
381
  const line = lines[i];
@@ -318,9 +394,13 @@ var MarkdownScanner = class {
318
394
  const existing = todoistIds.get(result.task.todoistId);
319
395
  if (existing) {
320
396
  warnings.push({
397
+ severity: "error",
398
+ source: "md2do",
399
+ ruleId: "duplicate-todoist-id",
321
400
  file: filePath,
322
401
  line: lineNumber,
323
402
  text: result.task.text,
403
+ message: `Duplicate Todoist ID [todoist:${result.task.todoistId}]. Also found at ${existing.file}:${existing.line}.`,
324
404
  reason: `Duplicate Todoist ID [todoist:${result.task.todoistId}]. Also found at ${existing.file}:${existing.line}.`
325
405
  });
326
406
  } else {
@@ -359,9 +439,13 @@ var MarkdownScanner = class {
359
439
  const existing = todoistIds.get(task.todoistId);
360
440
  if (existing && existing.file !== task.file) {
361
441
  allWarnings.push({
442
+ severity: "error",
443
+ source: "md2do",
444
+ ruleId: "duplicate-todoist-id",
362
445
  file: task.file,
363
446
  line: task.line,
364
447
  text: task.text,
448
+ message: `Duplicate Todoist ID [todoist:${task.todoistId}] across files. Also found at ${existing.file}:${existing.line}.`,
365
449
  reason: `Duplicate Todoist ID [todoist:${task.todoistId}] across files. Also found at ${existing.file}:${existing.line}.`
366
450
  });
367
451
  } else if (!existing) {
@@ -810,6 +894,32 @@ function combineComparators(comparators) {
810
894
  function reverse(comparator) {
811
895
  return (a, b) => -comparator(a, b);
812
896
  }
897
+
898
+ // src/warnings/filter.ts
899
+ function filterWarnings(warnings, config = {}) {
900
+ if (config.enabled === false) {
901
+ return [];
902
+ }
903
+ if (!config.rules) {
904
+ return warnings;
905
+ }
906
+ return warnings.filter((warning) => {
907
+ const level = config.rules?.[warning.ruleId];
908
+ if (level === void 0) {
909
+ return true;
910
+ }
911
+ return level !== "off";
912
+ });
913
+ }
914
+ function groupWarningsBySeverity(warnings) {
915
+ return warnings.reduce(
916
+ (acc, warning) => {
917
+ acc[warning.severity].push(warning);
918
+ return acc;
919
+ },
920
+ { error: [], warning: [], info: [] }
921
+ );
922
+ }
813
923
  export {
814
924
  ASSIGNEE,
815
925
  COMPLETED_DATE,
@@ -838,10 +948,13 @@ export {
838
948
  extractProjectFromPath,
839
949
  extractTags,
840
950
  extractTodoistId,
951
+ filterWarnings,
841
952
  filters_exports as filters,
842
953
  generateTaskId,
954
+ groupWarningsBySeverity,
843
955
  parseAbsoluteDate,
844
956
  parseTask,
957
+ parseTime,
845
958
  resolveRelativeDate,
846
959
  sorting_exports as sorting,
847
960
  updateTask,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@md2do/core",
3
- "version": "0.2.3",
3
+ "version": "0.4.0",
4
4
  "description": "Core parsing, filtering, scanning, and file writing for md2do",
5
5
  "keywords": [
6
6
  "markdown",
package/src/index.ts CHANGED
@@ -19,6 +19,9 @@ export * as filters from './filters/index.js';
19
19
  // Sorting (export as namespace to avoid conflicts)
20
20
  export * as sorting from './sorting/index.js';
21
21
 
22
+ // Warnings
23
+ export * from './warnings/filter.js';
24
+
22
25
  // Utilities
23
26
  export * from './utils/dates.js';
24
27
  export * from './utils/id.js';
@@ -94,27 +94,50 @@ export function extractCompletedDate(text: string): Date | undefined {
94
94
  * Extract due date from task text with context awareness
95
95
  *
96
96
  * Handles both absolute dates ([due: 2026-01-25]) and relative dates
97
- * ([due: tomorrow]) which require context.
97
+ * ([due: tomorrow]) which require context. Supports optional time ([due: 2026-01-25 17:00]).
98
98
  *
99
99
  * @param text - Task text
100
- * @param context - Parsing context (for relative dates)
100
+ * @param context - Parsing context (for relative dates and workday config)
101
101
  * @returns Object with parsed date and optional warning
102
102
  */
103
103
  export function extractDueDate(
104
104
  text: string,
105
105
  context: ParsingContext,
106
106
  ): { date: Date | undefined; warning?: Warning } {
107
- // Try absolute date first
107
+ // Try absolute date first (with optional time)
108
108
  const absoluteMatch = text.match(PATTERNS.DUE_DATE_ABSOLUTE);
109
109
  if (absoluteMatch?.[1]) {
110
- const date = parseAbsoluteDate(absoluteMatch[1]);
110
+ const dateStr = absoluteMatch[1];
111
+ let timeStr = absoluteMatch[2]; // May be undefined
112
+
113
+ // If no time specified, apply default from workday config
114
+ if (!timeStr && context.defaultDueTime) {
115
+ if (context.defaultDueTime === 'start' && context.workdayStartTime) {
116
+ timeStr = context.workdayStartTime;
117
+ } else if (context.defaultDueTime === 'end' && context.workdayEndTime) {
118
+ timeStr = context.workdayEndTime;
119
+ }
120
+ }
121
+
122
+ const date = parseAbsoluteDate(dateStr, timeStr);
111
123
  return { date: date ?? undefined };
112
124
  }
113
125
 
114
126
  // Try short format (M/D or M/D/YY)
115
127
  const shortMatch = text.match(PATTERNS.DUE_DATE_SHORT);
116
128
  if (shortMatch?.[1]) {
117
- const date = parseAbsoluteDate(shortMatch[1]);
129
+ let timeStr: string | undefined;
130
+
131
+ // Apply default time from workday config
132
+ if (context.defaultDueTime) {
133
+ if (context.defaultDueTime === 'start' && context.workdayStartTime) {
134
+ timeStr = context.workdayStartTime;
135
+ } else if (context.defaultDueTime === 'end' && context.workdayEndTime) {
136
+ timeStr = context.workdayEndTime;
137
+ }
138
+ }
139
+
140
+ const date = parseAbsoluteDate(shortMatch[1], timeStr);
118
141
  return { date: date ?? undefined };
119
142
  }
120
143
 
@@ -126,9 +149,14 @@ export function extractDueDate(
126
149
  return {
127
150
  date: undefined,
128
151
  warning: {
152
+ severity: 'warning',
153
+ source: 'md2do',
154
+ ruleId: 'relative-date-no-context',
129
155
  file: '', // Will be filled in by caller
130
156
  line: 0, // Will be filled in by caller
131
157
  text: text.trim(),
158
+ message:
159
+ 'Relative due date without context date from heading. Add a heading with a date above this task.',
132
160
  reason:
133
161
  'Relative due date without context date from heading. Add a heading with a date above this task.',
134
162
  },
@@ -212,9 +240,14 @@ export function parseTask(
212
240
  // Check for asterisk/plus bullet markers (not supported)
213
241
  if (/^\s*[*+]\s+\[[ xX]\]/.test(line)) {
214
242
  warnings.push({
243
+ severity: 'warning',
244
+ source: 'md2do',
245
+ ruleId: 'unsupported-bullet',
215
246
  file,
216
247
  line: lineNumber,
217
248
  text: line.trim(),
249
+ message:
250
+ 'Unsupported bullet marker (* or +). Use dash (-) for task lists.',
218
251
  reason:
219
252
  'Unsupported bullet marker (* or +). Use dash (-) for task lists.',
220
253
  });
@@ -224,9 +257,14 @@ export function parseTask(
224
257
  // Check for extra spaces inside checkbox: [x ] or [ x]
225
258
  if (/^\s*-\s+\[[xX]\s+\]/.test(line) || /^\s*-\s+\[\s+[xX]\]/.test(line)) {
226
259
  warnings.push({
260
+ severity: 'warning',
261
+ source: 'md2do',
262
+ ruleId: 'malformed-checkbox',
227
263
  file,
228
264
  line: lineNumber,
229
265
  text: line.trim(),
266
+ message:
267
+ 'Malformed checkbox with extra spaces. Use [x] or [ ] without extra spaces.',
230
268
  reason:
231
269
  'Malformed checkbox with extra spaces. Use [x] or [ ] without extra spaces.',
232
270
  });
@@ -236,9 +274,13 @@ export function parseTask(
236
274
  // Check for missing space after checkbox: [x]Task
237
275
  if (/^\s*-\s+\[[ xX]\][^\s]/.test(line)) {
238
276
  warnings.push({
277
+ severity: 'warning',
278
+ source: 'md2do',
279
+ ruleId: 'missing-space-after',
239
280
  file,
240
281
  line: lineNumber,
241
282
  text: line.trim(),
283
+ message: 'Missing space after checkbox. Use "- [x] Task" format.',
242
284
  reason: 'Missing space after checkbox. Use "- [x] Task" format.',
243
285
  });
244
286
  return { task: null, warnings };
@@ -247,9 +289,13 @@ export function parseTask(
247
289
  // Check for missing space before checkbox: -[x]
248
290
  if (/^\s*-\[[ xX]\]/.test(line)) {
249
291
  warnings.push({
292
+ severity: 'warning',
293
+ source: 'md2do',
294
+ ruleId: 'missing-space-before',
250
295
  file,
251
296
  line: lineNumber,
252
297
  text: line.trim(),
298
+ message: 'Missing space before checkbox. Use "- [x] Task" format.',
253
299
  reason: 'Missing space before checkbox. Use "- [x] Task" format.',
254
300
  });
255
301
  return { task: null, warnings };
@@ -288,9 +334,14 @@ export function parseTask(
288
334
  // Incomplete tasks without any date (no explicit due date and no context date)
289
335
  if (!completed && !dueDateResult.date && !context.currentDate) {
290
336
  warnings.push({
337
+ severity: 'info',
338
+ source: 'md2do',
339
+ ruleId: 'missing-due-date',
291
340
  file,
292
341
  line: lineNumber,
293
342
  text: fullText.trim(),
343
+ message:
344
+ 'Task has no due date. Add [due: YYYY-MM-DD] or place under a heading with a date.',
294
345
  reason:
295
346
  'Task has no due date. Add [due: YYYY-MM-DD] or place under a heading with a date.',
296
347
  });
@@ -299,9 +350,14 @@ export function parseTask(
299
350
  // Completed tasks should have completion dates
300
351
  if (completed && !completedDate) {
301
352
  warnings.push({
353
+ severity: 'info',
354
+ source: 'md2do',
355
+ ruleId: 'missing-completed-date',
302
356
  file,
303
357
  line: lineNumber,
304
358
  text: fullText.trim(),
359
+ message:
360
+ 'Completed task missing completion date. Add [completed: YYYY-MM-DD].',
305
361
  reason:
306
362
  'Completed task missing completion date. Add [completed: YYYY-MM-DD].',
307
363
  });
@@ -63,17 +63,20 @@ export const PRIORITY_HIGH = /!!/;
63
63
  export const PRIORITY_NORMAL = /(?<!!)!(?!!)/;
64
64
 
65
65
  /**
66
- * Matches absolute due date in ISO format [due: YYYY-MM-DD]
66
+ * Matches absolute due date in ISO format [due: YYYY-MM-DD] with optional time [due: YYYY-MM-DD HH:MM]
67
67
  *
68
68
  * Examples:
69
- * "[due: 2026-01-25]" → "2026-01-25"
70
- * "[due:2026-01-25]" → "2026-01-25" (spaces optional)
71
- * "[due: 2026-01-25 ]" → "2026-01-25" (whitespace allowed)
69
+ * "[due: 2026-01-25]" → "2026-01-25", undefined
70
+ * "[due: 2026-01-25 17:00]" → "2026-01-25", "17:00"
71
+ * "[due: 2026-01-25 9:00]" → "2026-01-25", "9:00"
72
+ * "[due:2026-01-25]" → "2026-01-25", undefined (spaces optional)
72
73
  *
73
74
  * Groups:
74
75
  * [1] - Date string in YYYY-MM-DD format
76
+ * [2] - Optional time string in H:MM or HH:MM format (24-hour)
75
77
  */
76
- export const DUE_DATE_ABSOLUTE = /\[due:\s*(\d{4}-\d{2}-\d{2})\s*\]/i;
78
+ export const DUE_DATE_ABSOLUTE =
79
+ /\[due:\s*(\d{4}-\d{2}-\d{2})(?:\s+(\d{1,2}:\d{2}))?\s*\]/i;
77
80
 
78
81
  /**
79
82
  * Matches relative due date keywords
@@ -62,11 +62,17 @@ export class MarkdownScanner {
62
62
  *
63
63
  * @param filePath - Relative file path (for context extraction)
64
64
  * @param content - File content as string
65
+ * @param options - Optional scanner options including workday config
65
66
  * @returns Object containing tasks and warnings
66
67
  */
67
68
  scanFile(
68
69
  filePath: string,
69
70
  content: string,
71
+ options?: {
72
+ workdayStartTime?: string;
73
+ workdayEndTime?: string;
74
+ defaultDueTime?: 'start' | 'end';
75
+ },
70
76
  ): {
71
77
  tasks: Task[];
72
78
  warnings: Warning[];
@@ -86,6 +92,17 @@ export class MarkdownScanner {
86
92
  const person = extractPersonFromFilename(filePath);
87
93
  if (person !== undefined) context.person = person;
88
94
 
95
+ // Add workday config to context if provided
96
+ if (options?.workdayStartTime) {
97
+ context.workdayStartTime = options.workdayStartTime;
98
+ }
99
+ if (options?.workdayEndTime) {
100
+ context.workdayEndTime = options.workdayEndTime;
101
+ }
102
+ if (options?.defaultDueTime) {
103
+ context.defaultDueTime = options.defaultDueTime;
104
+ }
105
+
89
106
  // Process file line by line
90
107
  const lines = content.split('\n');
91
108
 
@@ -114,9 +131,13 @@ export class MarkdownScanner {
114
131
  const existing = todoistIds.get(result.task.todoistId);
115
132
  if (existing) {
116
133
  warnings.push({
134
+ severity: 'error',
135
+ source: 'md2do',
136
+ ruleId: 'duplicate-todoist-id',
117
137
  file: filePath,
118
138
  line: lineNumber,
119
139
  text: result.task.text,
140
+ message: `Duplicate Todoist ID [todoist:${result.task.todoistId}]. Also found at ${existing.file}:${existing.line}.`,
120
141
  reason: `Duplicate Todoist ID [todoist:${result.task.todoistId}]. Also found at ${existing.file}:${existing.line}.`,
121
142
  });
122
143
  } else {
@@ -168,9 +189,13 @@ export class MarkdownScanner {
168
189
  // Only add warning if duplicate is in a different file
169
190
  // (same-file duplicates are already caught by scanFile)
170
191
  allWarnings.push({
192
+ severity: 'error',
193
+ source: 'md2do',
194
+ ruleId: 'duplicate-todoist-id',
171
195
  file: task.file,
172
196
  line: task.line,
173
197
  text: task.text,
198
+ message: `Duplicate Todoist ID [todoist:${task.todoistId}] across files. Also found at ${existing.file}:${existing.line}.`,
174
199
  reason: `Duplicate Todoist ID [todoist:${task.todoistId}] across files. Also found at ${existing.file}:${existing.line}.`,
175
200
  });
176
201
  } else if (!existing) {
@@ -36,6 +36,10 @@ export interface ParsingContext {
36
36
  person?: string;
37
37
  currentDate?: Date;
38
38
  currentHeading?: string;
39
+ // Workday configuration for time handling
40
+ workdayStartTime?: string; // e.g., "08:00"
41
+ workdayEndTime?: string; // e.g., "17:00"
42
+ defaultDueTime?: 'start' | 'end'; // Which time to use when no time specified
39
43
  }
40
44
 
41
45
  export interface TaskFilterCriteria {
@@ -65,9 +69,38 @@ export interface ScanResult {
65
69
  };
66
70
  }
67
71
 
72
+ export type WarningSeverity = 'info' | 'warning' | 'error';
73
+
74
+ export type WarningCode =
75
+ | 'unsupported-bullet' // * or + instead of -
76
+ | 'malformed-checkbox' // [x ] or [ x]
77
+ | 'missing-space-after' // -[x]Task
78
+ | 'missing-space-before' // -[x] Task
79
+ | 'relative-date-no-context' // [due: tomorrow] without heading date
80
+ | 'missing-due-date' // Incomplete task with no due date
81
+ | 'missing-completed-date' // [x] without [completed: date]
82
+ | 'duplicate-todoist-id' // Same Todoist ID in multiple tasks
83
+ | 'file-read-error'; // Failed to read file
84
+
68
85
  export interface Warning {
86
+ // Position
69
87
  file: string;
70
88
  line: number;
71
- text: string;
72
- reason: string;
89
+ column?: number;
90
+
91
+ // Classification
92
+ severity: WarningSeverity;
93
+ source: 'md2do';
94
+ ruleId: WarningCode;
95
+
96
+ // Content
97
+ message: string; // User-facing message
98
+ text?: string; // The actual text that triggered it
99
+
100
+ // Documentation (optional - for future use)
101
+ url?: string;
102
+
103
+ // Legacy field for backward compatibility (deprecated)
104
+ /** @deprecated Use message instead */
105
+ reason?: string;
73
106
  }