imdone-core 2.2.0 → 2.3.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 CHANGED
@@ -2,9 +2,9 @@
2
2
  [![Downloads](https://img.shields.io/npm/dm/imdone-core.svg)](https://npmjs.org/package/imdone-core)
3
3
  ![Build Status](https://github.com/imdone/imdone-core/actions/workflows/ci.yml/badge.svg)
4
4
 
5
- Imdone is text based kanban processor with a simple syntax that uses [comment tags like TODO and FIXME](https://en.wikipedia.org/wiki/Comment_%28computer_programming%29#Tags) and [todo.txt format](https://github.com/todotxt/todo.txt#todotxt-format). This model allows the user to create and modify tasks using the keyboard and automatically establishes a link between their tasks and work. Get [imdone](https://imdone.io) or use the cli to see your projects board and this library in action.
5
+ Imdone is text based kanban processor with a simple syntax that uses [comment tags like TODO and FIXME](https://en.wikipedia.org/wiki/Comment_%28computer_programming%29#Tags) and [todo.txt format](https://github.com/todotxt/todo.txt#todotxt-format). This model allows the user to create and modify tasks using the keyboard and automatically establishes a link between their tasks and work. Get [imdone kanban](https://imdone.io/docs/imdone-kanban/) or use the [cli](https://imdone.io/docs/imdone-cli/) to see your projects board and this library in action.
6
6
 
7
- ![imdone-screenshot.png (5120×2838)](https://imdone.io/docs/images/card-anatomy.png)
7
+ ![imdone-screenshot.png (5120×2838)](https://imdone.io/docs/imdone-kanban/images/card-anatomy.png)
8
8
 
9
9
  <!-- START doctoc generated TOC please keep comment here to allow auto update -->
10
10
  <!-- DON'T EDIT THIS SECTION, INSTEAD RE-RUN doctoc TO UPDATE -->
@@ -28,7 +28,7 @@ export async function getFileParser (filePath, config, readStream) {
28
28
  readStream,
29
29
  filePath,
30
30
  lang,
31
- config.lists
31
+ config.code?.include_lists || ['TODO', 'DOING', 'DONE', 'NOTE']
32
32
  )
33
33
  await parser.init()
34
34
  return parser
@@ -1,10 +1,7 @@
1
1
  import path from 'node:path'
2
2
  import { ReadStreamTaskParser } from './ReadStreamTaskParser.js'
3
- import { CodeStyleTaskParser } from '../task/CodeStyleTaskParser.js'
4
3
  import { languages } from '../../../languages.js'
5
- import extractComments from 'multilang-extract-comments'
6
- import eol from 'eol'
7
- import { Readable } from 'node:stream'
4
+ import { CodeStyleTaskParser } from '../task/CodeStyleTaskParser.js'
8
5
 
9
6
  // TODO Move to readstream parsing for code to improve performance
10
7
  // <!--
@@ -18,109 +15,217 @@ export class ReadStreamCodeParser extends ReadStreamTaskParser {
18
15
  this.lists = lists
19
16
  this.filePath = filePath
20
17
  this.lang = lang
21
- this.taskParsers = [new CodeStyleTaskParser()]
22
18
  }
23
19
 
24
20
  async init() {
25
- const code = await streamToString(this.readStream)
26
21
  // TODO Getting tokens from lists should be done higher up in the controller
27
22
  // <!--
28
23
  // order:-10
29
24
  // -->
30
- const tokens = this.lists.filter(list => !list.filter).map(list => list.name)
31
- this.cards = this.parseCards(code, tokens)
25
+ const tokens = this.lists
26
+ .map((list) => {
27
+ if (typeof list === 'string') return list
28
+ if (!list || typeof list !== 'object') return undefined
29
+ return list.name
30
+ })
31
+ .filter(Boolean)
32
+ this.cards = await this.parseCards(tokens)
32
33
  }
33
34
 
34
35
  async readTask() {
35
36
  return this.cards.shift()
36
37
  }
37
38
 
38
- parseCards(code, tokens = ['TODO']) {
39
- const comments = this.parseComments(code);
40
- const cards = [];
41
-
39
+ async parseCards(tokens = ['TODO']) {
40
+ const comments = await this.parseComments()
41
+ const cards = []
42
+ const listNames = tokens.map((token) =>
43
+ typeof token === 'string' ? token : token?.name
44
+ ).filter(Boolean)
45
+ const parser = new CodeStyleTaskParser({
46
+ includeList: (list) => listNames.includes(list),
47
+ listExists: (list) => listNames.includes(list),
48
+ lists: listNames.map((name) => ({ name })),
49
+ })
50
+
42
51
  Object.keys(comments).forEach((comment) => {
43
- const { begin, end, content } = comments[comment];
44
- const line = begin;
45
- const lastLine = end; // TODO lastLine should not be - 1
46
- // <!--
47
- // order:-140
48
- // -->
49
- const lines = content.split('\n');
50
-
51
- // Check if the comment contains "TODO"
52
- lines.forEach(codeLine => {
53
- if (tokens.find((token) => codeLine.includes(token))) {
54
- // Get all lines from start to end
55
-
56
- cards.push({
57
- content,
58
- line,
59
- lastLine,
60
- });
52
+ const {
53
+ begin,
54
+ end,
55
+ content,
56
+ inBlockComment,
57
+ singleLineBlockComment,
58
+ } = comments[comment]
59
+
60
+ const lines = content.split('\n')
61
+ let activeTask = null
62
+ let taskLines = []
63
+
64
+ const flushActiveTask = (lineNo) => {
65
+ if (!activeTask) return
66
+ cards.push({
67
+ ...activeTask,
68
+ content: `${taskLines.join('\n')}\n`,
69
+ line: activeTask.line,
70
+ lastLine: lineNo,
71
+ inBlockComment,
72
+ singleLineBlockComment,
73
+ })
74
+ activeTask = null
75
+ taskLines = []
76
+ }
77
+
78
+ lines.forEach((line, index) => {
79
+ const lineNo = begin + index
80
+ const parsedTask = parser.parseLine(line, lineNo)
81
+
82
+ if (parsedTask) {
83
+ flushActiveTask(lineNo - 1)
84
+ activeTask = parsedTask
85
+ taskLines = [line]
86
+ return
87
+ }
88
+
89
+ if (activeTask) {
90
+ taskLines.push(line)
61
91
  }
62
- });
63
- });
64
- return cards;
92
+ })
93
+
94
+ flushActiveTask(end)
95
+ })
96
+ return cards
65
97
  }
66
98
 
67
- parseComments(code) {
68
- const options = getLanguageOptions(this.filePath);
69
- const comments = extractComments(code, options);
70
- return comments;
99
+ async parseComments() {
100
+ const options = getLanguageOptions(this.filePath, this.lang);
101
+ const comments = [];
102
+ let lineNo = 0;
103
+ let singleLineComment;
104
+ let blockComment;
105
+
106
+ const finishSingleLineComment = () => {
107
+ if (!singleLineComment) return
108
+ comments.push(singleLineComment)
109
+ singleLineComment = undefined
110
+ }
111
+
112
+ for await (const line of this.readInterface) {
113
+ lineNo++
114
+
115
+ if (blockComment) {
116
+ appendBlockCommentLine(blockComment, line, options)
117
+ if (line.includes(options.block.end)) {
118
+ blockComment.end = lineNo
119
+ comments.push(blockComment)
120
+ blockComment = undefined
121
+ }
122
+ continue
123
+ }
124
+
125
+ const blockStartIndex = getBlockStartIndex(line, options)
126
+ const singleLineIndex = getSingleLineCommentIndex(line, options)
127
+ const blockStartsFirst = blockStartIndex > -1 && (singleLineIndex === -1 || blockStartIndex < singleLineIndex)
128
+
129
+ if (blockStartsFirst) {
130
+ finishSingleLineComment()
131
+ blockComment = {
132
+ begin: lineNo,
133
+ end: lineNo,
134
+ contentLines: [],
135
+ inBlockComment: true,
136
+ }
137
+ appendBlockCommentLine(blockComment, line, options)
138
+ if (line.includes(options.block.end)) {
139
+ comments.push(blockComment)
140
+ blockComment = undefined
141
+ }
142
+ } else if (singleLineIndex > -1) {
143
+ const content = line.slice(singleLineIndex + options.singleLineComment.length)
144
+ if (!singleLineComment || singleLineComment.end !== lineNo - 1) {
145
+ finishSingleLineComment()
146
+ singleLineComment = {
147
+ begin: lineNo,
148
+ end: lineNo,
149
+ contentLines: [],
150
+ inBlockComment: false,
151
+ }
152
+ }
153
+ singleLineComment.end = lineNo
154
+ singleLineComment.contentLines.push(trimCommentLine(content))
155
+ } else {
156
+ finishSingleLineComment()
157
+ }
158
+ }
159
+
160
+ finishSingleLineComment()
161
+ if (blockComment) comments.push(blockComment)
162
+
163
+ return comments.reduce((acc, comment, index) => {
164
+ acc[index] = {
165
+ begin: comment.begin,
166
+ end: comment.end,
167
+ content: formatCommentContent(comment.contentLines),
168
+ inBlockComment: comment.inBlockComment,
169
+ singleLineBlockComment: comment.inBlockComment && comment.begin === comment.end,
170
+ }
171
+ return acc
172
+ }, {})
71
173
  }
72
174
 
73
175
  }
74
176
 
177
+ function getBlockStartIndex(line, options) {
178
+ if (!options.block.start || !options.block.end) return -1
179
+ return line.indexOf(options.block.start)
180
+ }
75
181
 
76
- function getLanguageOptions(filePath) {
77
- const extension = path.extname(filePath)
78
- const language = languages[extension]
79
-
80
-
81
- const {
82
- name,
83
- symbol,
84
- block,
85
- } = language ||
86
- {
87
- name: 'text',
88
- symbol: '',
89
- block: {
90
- start: undefined,
91
- end: undefined,
92
- ignore: undefined,
93
- }
94
- };
95
- // Use lang to get comment patterns
96
-
97
- const {
98
- start,
99
- ignore: middle,
100
- end
101
- } = block || {};
102
-
103
- return language && {
104
- pattern: {
105
- name,
106
- nameMatchers: extension && [extension] || [],
107
- singleLineComment: symbol && [{ start: symbol }] || [],
108
- multiLineComment: start && end && [{ start, middle, end }] || [],
109
- },
110
- };
182
+ function getSingleLineCommentIndex(line, options) {
183
+ if (!options.singleLineComment) return -1
184
+ return line.indexOf(options.singleLineComment)
111
185
  }
112
186
 
113
- async function streamToString(stream) {
114
- const chunks = []
187
+ function appendBlockCommentLine(comment, line, options) {
188
+ const startIndex = line.indexOf(options.block.start)
189
+ const contentStart = startIndex > -1 ? startIndex + options.block.start.length : 0
190
+ const endIndex = line.indexOf(options.block.end, contentStart)
191
+ const contentEnd = endIndex > -1 ? endIndex : line.length
192
+ const content = line.slice(contentStart, contentEnd)
193
+
194
+ comment.contentLines.push(trimCommentLine(content, options.block.ignore))
195
+ }
115
196
 
116
- for await (const chunk of stream) {
117
- chunks.push(Buffer.from(chunk))
197
+ function trimCommentLine(line, ignoreToken) {
198
+ let trimmed = line.trimStart()
199
+ if (ignoreToken && trimmed.startsWith(ignoreToken)) {
200
+ trimmed = trimmed.slice(ignoreToken.length).trimStart()
118
201
  }
202
+ return trimmed.trimEnd()
203
+ }
204
+
205
+ function formatCommentContent(lines) {
206
+ const trimmedLines = trimBlankCommentLines(lines)
207
+ if (!trimmedLines.length) return ''
208
+ return `${trimmedLines.join('\n')}\n`
209
+ }
119
210
 
120
- return Buffer.concat(chunks).toString('utf-8')
211
+ function trimBlankCommentLines(lines) {
212
+ const trimmedLines = [...lines]
213
+ while (trimmedLines[0] === '') trimmedLines.shift()
214
+ while (trimmedLines[trimmedLines.length - 1] === '') trimmedLines.pop()
215
+ return trimmedLines
121
216
  }
122
217
 
123
- function stringToStream(string) {
124
- const lines = eol.split(string).map((line) => line + String(eol.auto))
125
- return Readable.from(lines)
218
+ function getLanguageOptions(filePath, lang) {
219
+ const extension = path.extname(filePath)
220
+ const language = lang || languages[extension]
221
+ const block = language?.block || {}
222
+
223
+ return {
224
+ singleLineComment: language?.symbol || '',
225
+ block: {
226
+ start: block.start,
227
+ ignore: block.ignore,
228
+ end: block.end,
229
+ },
230
+ }
126
231
  }
@@ -1,17 +1,92 @@
1
1
  import { ReadStreamTaskParser } from './ReadStreamTaskParser.js'
2
2
  import { HashStyleTaskParser } from '../task/HashStyleTaskParser.js'
3
3
  import { MarkdownStyleTaskParser } from '../task/MarkdownStyleTaskParser.js'
4
- // const CheckStyleTaskParser = require('../task/CheckStyleTaskParser')
4
+ import { CheckStyleTaskParser } from '../task/CheckStyleTaskParser.js'
5
+ import { loadYAML } from '../../yaml.js'
6
+
7
+ const FRONTMATTER_DELIMITER = '---'
8
+ const CODE_FENCE_PATTERN = /^\s*(```|~~~)/
9
+ const INLINE_CODE_SPAN_PATTERN = /`[^`]*`/g
5
10
 
6
11
  export class ReadStreamMarkdownParser extends ReadStreamTaskParser {
7
12
  constructor(readStream, config) {
8
13
  super(readStream, config)
14
+ this.frontmatterLines = []
15
+ this.collectingFrontmatter = false
16
+ this.frontmatterClosed = false
17
+ this.ignoredByFrontmatter = false
18
+ this.currentLineIsFrontmatter = false
19
+ this.currentLineIsCodeFence = false
20
+ this.inCodeFence = false
9
21
  this.taskParsers = [
10
22
  new HashStyleTaskParser(config),
11
23
  new MarkdownStyleTaskParser(config),
12
24
  ]
13
- // if (config.isAddCheckBoxTasks()) {
14
- // this.taskParsers.push(new CheckStyleTaskParser())
15
- // }
25
+ if (config.isAddCheckBoxTasks()) {
26
+ this.taskParsers.push(new CheckStyleTaskParser(config))
27
+ }
28
+ }
29
+
30
+ onLineRead(line) {
31
+ this.updateFrontmatterState(line)
32
+ this.updateCodeFenceState(line)
33
+ }
34
+
35
+ shouldParseTaskLine() {
36
+ return !this.ignoredByFrontmatter &&
37
+ !this.currentLineIsFrontmatter &&
38
+ !this.currentLineIsCodeFence
39
+ }
40
+
41
+ getLineForTaskParsing(line) {
42
+ return line.replace(INLINE_CODE_SPAN_PATTERN, (match) => ' '.repeat(match.length))
43
+ }
44
+
45
+ updateFrontmatterState(line) {
46
+ const trimmed = line.trim()
47
+ this.currentLineIsFrontmatter = false
48
+
49
+ if (this.lineNo === 1 && isFrontmatterDelimiter(trimmed)) {
50
+ this.collectingFrontmatter = true
51
+ this.currentLineIsFrontmatter = true
52
+ return
53
+ }
54
+
55
+ if (!this.collectingFrontmatter) return
56
+
57
+ this.currentLineIsFrontmatter = true
58
+
59
+ if (isFrontmatterDelimiter(trimmed)) {
60
+ this.collectingFrontmatter = false
61
+ this.frontmatterClosed = true
62
+ this.updateIgnoredByFrontmatter()
63
+ return
64
+ }
65
+
66
+ this.frontmatterLines.push(line)
67
+ }
68
+
69
+ updateIgnoredByFrontmatter() {
70
+ if (!this.frontmatterClosed) return
71
+ const data = parseFrontmatter(this.frontmatterLines)
72
+ this.ignoredByFrontmatter = !!(data.imdone_ignore || data['kanban-plugin'])
73
+ }
74
+
75
+ updateCodeFenceState(line) {
76
+ const isFence = CODE_FENCE_PATTERN.test(line)
77
+ this.currentLineIsCodeFence = this.inCodeFence || isFence
78
+ if (isFence) this.inCodeFence = !this.inCodeFence
79
+ }
80
+ }
81
+
82
+ function isFrontmatterDelimiter(trimmedLine) {
83
+ return trimmedLine === FRONTMATTER_DELIMITER
84
+ }
85
+
86
+ function parseFrontmatter(lines) {
87
+ try {
88
+ return loadYAML(lines.join('\n')) || {}
89
+ } catch (error) {
90
+ return {}
16
91
  }
17
92
  }
@@ -16,11 +16,13 @@ export class ReadStreamTaskParser {
16
16
  crlfDelay: Infinity,
17
17
  console: false,
18
18
  })
19
+ this.lineIterator = this.readInterface[Symbol.asyncIterator]()
19
20
  }
20
21
 
21
22
  async readTask() {
22
23
  let taskOrStreamDone = false
23
24
  this.task = this.nextTask || null
25
+ this.nextTask = null
24
26
  this.blankLines = 0
25
27
 
26
28
  try {
@@ -29,6 +31,7 @@ export class ReadStreamTaskParser {
29
31
  let { value, done } = this.line
30
32
  if (this.line && !done) {
31
33
  this.lineNo++
34
+ this.onLineRead(value)
32
35
  const endOfTask = this.isEndOfTask(value)
33
36
  if (this.task && endOfTask) {
34
37
  taskOrStreamDone = true
@@ -51,7 +54,19 @@ export class ReadStreamTaskParser {
51
54
  }
52
55
 
53
56
  async readLine() {
54
- return this.readInterface[Symbol.asyncIterator]().next()
57
+ return this.lineIterator.next()
58
+ }
59
+
60
+ onLineRead() {
61
+ // Hook for parsers that need line-aware state.
62
+ }
63
+
64
+ shouldParseTaskLine() {
65
+ return true
66
+ }
67
+
68
+ getLineForTaskParsing(line) {
69
+ return line
55
70
  }
56
71
 
57
72
  isEndOfTask(line) {
@@ -76,9 +91,12 @@ export class ReadStreamTaskParser {
76
91
  }
77
92
 
78
93
  getTaskFromLine(line) {
94
+ if (!this.shouldParseTaskLine(line)) return
95
+ const originalLine = line
96
+ line = this.getLineForTaskParsing(line)
79
97
  let task
80
98
  this.taskParsers.find((taskParser) => {
81
- task = taskParser.parseLine(line, this.lineNo, this.task)
99
+ task = taskParser.parseLine(line, this.lineNo, this.task, originalLine)
82
100
  return task
83
101
  })
84
102
  return task
@@ -1,8 +1,8 @@
1
1
  import { TaskParser } from "./TaskParser.js"
2
2
 
3
3
  export class CheckStyleTaskParser extends TaskParser {
4
- constructor() {
5
- super()
4
+ constructor(config) {
5
+ super('CHECKBOX', config)
6
6
  }
7
7
 
8
8
  get pattern() {
@@ -20,6 +20,7 @@ export class CheckStyleTaskParser extends TaskParser {
20
20
  return {
21
21
  beforeText,
22
22
  text,
23
+ list: checked ? this.config.getDoneList() : this.config.getDefaultList(),
23
24
  line,
24
25
  checked,
25
26
  type: this.config.getNewCardSyntax(),
@@ -6,7 +6,7 @@ export class CodeStyleTaskParser extends TaskParser {
6
6
  }
7
7
 
8
8
  get pattern() {
9
- return /^(.*?[ #]?)([a-zA-Z-_]+?)(:)(-?[\d.]+(?:e-?\d+)?)?[ \t]+(.+)$/gm
9
+ return /^(.*?[ #]?)([A-Z]+[A-Z-_]+?)(:)?(-?[\d.]+(?:e-?\d+)?)?[ \t]+(.+)$/gm
10
10
  }
11
11
 
12
12
  parse(lineContent, line, matchResult) {
@@ -8,7 +8,7 @@ export class HashStyleTaskParser extends TaskParser {
8
8
 
9
9
  get pattern() {
10
10
  const config = this.config
11
- return getHashStyleRegex(config.tokenPrefix, config.orderMeta)
11
+ return getHashStyleRegex(config.tokenPrefix)
12
12
  }
13
13
 
14
14
  parse(lineContent, line, matchResult) {
@@ -24,10 +24,15 @@ export class TaskParser {
24
24
  throw new Error('Unimplemented')
25
25
  }
26
26
 
27
- parseLine(lineContent, line, task) {
27
+ parseLine(lineContent, line, task, originalLineContent = lineContent) {
28
28
  const re = new RegExp(this.pattern)
29
29
  const result = re.exec(lineContent)
30
- const parseResult = result && this.parse(lineContent, line, result, task)
30
+ if (!result) return
31
+
32
+ const originalResult = originalLineContent === lineContent
33
+ ? result
34
+ : new RegExp(this.pattern).exec(originalLineContent.slice(result.index)) || result
35
+ const parseResult = this.parse(originalLineContent, line, originalResult, task)
31
36
 
32
37
  if (parseResult && this.config.lists && !this.config.listExists(parseResult.list)) {
33
38
  return
package/lib/file.js CHANGED
@@ -852,7 +852,7 @@ export class File extends Emitter {
852
852
  } else text = task.text
853
853
  if (/[a-z]/.test(task.list)) task.type = Task.Types.HASHTAG
854
854
  const tokenPrefix = task.type === Task.Types.HASHTAG ? config.tokenPrefix : ''
855
- var taskContent = getRawTask({tokenPrefix, orderMeta: config.orderMeta, list: task.list, order: task.order, text, type: task.type})
855
+ var taskContent = getRawTask({tokenPrefix, orderMeta: config.orderMeta, hasColon: task.hasColon, list: task.list, order: task.order, text, type: task.type})
856
856
  task.line = this.getLineNumber(index)
857
857
  task.id = getTaskId(self.getPath(), task.line, task.text)
858
858
  this.setContent(beforeContent + taskContent + afterContent)
@@ -1508,4 +1508,4 @@ export class File extends Emitter {
1508
1508
  isResultInMarkdownCode (positions, index) {
1509
1509
  return positions.some(pos => index >= pos.start && index < pos.end);
1510
1510
  };
1511
- }
1511
+ }
@@ -149,13 +149,14 @@ export function buildMigrateConfig (repo, defaultSettings = _defaultSettings) {
149
149
  config.settings.kudosProbability = 0.33
150
150
  }
151
151
 
152
- const defaultCardsSettings = cloneDeep(defaultSettings.cards)
152
+ const defaultSettingsCopy = cloneDeep(defaultSettings)
153
+ const defaultCardsSettings = cloneDeep(defaultSettingsCopy.cards)
153
154
  const cardsSettings = cloneDeep(config.settings.cards)
154
155
 
155
- delete defaultSettings.cards
156
+ delete defaultSettingsCopy.cards
156
157
  delete config.settings.cards
157
158
 
158
- config.settings = { ...defaultSettings, ...config.settings }
159
+ config.settings = { ...defaultSettingsCopy, ...config.settings }
159
160
  config.settings.cards = {
160
161
  ...defaultCardsSettings,
161
162
  ...cardsSettings,
package/lib/repository.js CHANGED
@@ -37,8 +37,140 @@ import { appContext } from './context/ApplicationContext.js'
37
37
  import { computeChecksum } from './checksum.js'
38
38
  import { getTasksToModify } from './get-tasks-to-modify.js'
39
39
  import { logger } from './adapters/logger.js'
40
+ import { getFileParser } from './adapters/parsers/file/LocalFileParserFactory.js'
41
+ import { CodeStyleTaskParser } from './adapters/parsers/task/CodeStyleTaskParser.js'
40
42
  const { ERRORS, ASYNC_LIMIT, DEFAULT_FILE_PATTERN} = constants
41
43
  const DEFAULT_SORT = [{ asc: u => isNumber(u.order) ? u.order : Infinity }, { asc: u => u.text }]
44
+ const STREAM_FALLBACK_REASONS = {
45
+ BLOCK_COMMENT_WRITE_BACK_CONTEXT_UNSUPPORTED: 'block-comment-write-back-context-unsupported',
46
+ }
47
+
48
+ async function readStreamTasks(filePath, config) {
49
+ const parser = await getFileParser(filePath, config)
50
+ const tasks = []
51
+ let task
52
+ try {
53
+ while ((task = await parser.readTask())) {
54
+ tasks.push(task)
55
+ }
56
+ } finally {
57
+ parser.close()
58
+ }
59
+ return tasks
60
+ }
61
+
62
+ function parseCodeStreamTask(streamTask, config) {
63
+ if (!streamTask.content) return
64
+ const firstTaskLine = streamTask.content
65
+ .split(/\r?\n/)
66
+ .find((line) => line.trim())
67
+ if (!firstTaskLine) return
68
+
69
+ const parser = new CodeStyleTaskParser({
70
+ ...config,
71
+ includeList: (list) => config.includeList(list),
72
+ listExists: (list) => config.includeList(list),
73
+ })
74
+ const parsed = parser.parseLine(firstTaskLine, streamTask.line)
75
+ if (!parsed) return
76
+
77
+ parsed.lastLine = streamTask.lastLine
78
+ return parsed
79
+ }
80
+
81
+ function toFileTask({ file, streamTask, config }) {
82
+ const parsedTask = streamTask.text
83
+ ? streamTask
84
+ : parseCodeStreamTask(streamTask, config)
85
+ if (!parsedTask) return
86
+
87
+ const lineContent = file.getContentLines()[parsedTask.line - 1] || ''
88
+ const type = parsedTask.type || Task.Types.CODE
89
+ const taskStartOnLine = getTaskStartOnLine({ lineContent, parsedTask, type, config })
90
+ const linePos = file.getLinePos(parsedTask.line)
91
+ const tokenPrefix = type === Task.Types.HASHTAG ? config.tokenPrefix : ''
92
+ const rawTask = parsedTask.rawTask || getOriginalRawTask({
93
+ lineContent,
94
+ parsedTask,
95
+ taskStartOnLine,
96
+ type,
97
+ tokenPrefix,
98
+ config,
99
+ })
100
+
101
+ return file.extractTaskWithDescription({
102
+ taskStartOnLine,
103
+ rawTask,
104
+ config,
105
+ list: parsedTask.list,
106
+ text: parsedTask.text,
107
+ order: parsedTask.order,
108
+ line: parsedTask.line,
109
+ type,
110
+ hasColon: !!parsedTask.colon,
111
+ content: file.getContent().substring(linePos),
112
+ pos: linePos + taskStartOnLine,
113
+ })
114
+ }
115
+
116
+ function getTaskStartOnLine({ lineContent, parsedTask, type, config }) {
117
+ if (parsedTask.beforeText) return parsedTask.beforeText.length
118
+ if (type === Task.Types.CODE) {
119
+ return Math.max(0, lineContent.indexOf(parsedTask.list))
120
+ }
121
+ if (type === Task.Types.HASHTAG) {
122
+ const taskTokenIndex = lineContent.indexOf(`${config.tokenPrefix}${parsedTask.list}`)
123
+ if (taskTokenIndex > -1) return taskTokenIndex
124
+ }
125
+ return 0
126
+ }
127
+
128
+ function getOriginalRawTask({
129
+ lineContent,
130
+ parsedTask,
131
+ taskStartOnLine,
132
+ type,
133
+ tokenPrefix,
134
+ config,
135
+ }) {
136
+ if (type !== Task.Types.CODE) {
137
+ return lineContent.slice(taskStartOnLine)
138
+ }
139
+
140
+ return getRawTask({
141
+ tokenPrefix,
142
+ orderMeta: config.orderMeta,
143
+ beforeText: parsedTask.beforeText,
144
+ hasColon: !!parsedTask.colon,
145
+ list: parsedTask.list,
146
+ order: parsedTask.order,
147
+ text: parsedTask.text,
148
+ type,
149
+ })
150
+ }
151
+
152
+ function attachStreamTasks({ file, streamTasks, config }) {
153
+ file.tasks = []
154
+ streamTasks.forEach((streamTask) => {
155
+ const task = toFileTask({ file, streamTask, config })
156
+ if (!task) return
157
+ file.addTask(task)
158
+ file.emit('task.found', task)
159
+ })
160
+ }
161
+
162
+ function getStreamWriteBackFallbackReason(streamTasks) {
163
+ if (streamTasks.some((streamTask) => streamTask.inBlockComment)) {
164
+ return STREAM_FALLBACK_REASONS.BLOCK_COMMENT_WRITE_BACK_CONTEXT_UNSUPPORTED
165
+ }
166
+ }
167
+
168
+ function emitParserDiagnostic(repo, file, diagnostic) {
169
+ repo.emit('file.parser', {
170
+ path: file.path,
171
+ ...diagnostic,
172
+ })
173
+ }
42
174
 
43
175
  function getTasksByList (
44
176
  repo,
@@ -768,41 +900,38 @@ export class Repository extends Emitter {
768
900
 
769
901
  file.on('task.found', this.taskFoundListener)
770
902
  file.on('task.modified', this.taskModifiedListener)
771
- const fileContent = file.content
772
-
773
- logger.debug(`[INSTRUMENT] About to call extractAndTransformTasks, content length: ${fileContent?.length || 0}`)
774
- file.extractAndTransformTasks(this.getConfig())
775
903
 
776
- const transformTime = Date.now()
777
- logger.debug(`[INSTRUMENT] extractAndTransformTasks took ${transformTime - readTime}ms`)
904
+ const streamTasks = await readStreamTasks(this.getFullPath(file), this.getConfig())
905
+ const fallbackReason = getStreamWriteBackFallbackReason(streamTasks)
906
+ if (fallbackReason) {
907
+ file.extractAndTransformTasks(this.getConfig())
908
+ emitParserDiagnostic(this, file, {
909
+ mode: 'legacy-fallback',
910
+ reason: fallbackReason,
911
+ })
778
912
 
779
- if (!file.isModified() || fileContent === file.content) {
780
913
  this.resetFile(file)
781
- const totalTime = Date.now() - extractStartTime
782
- logger.debug(`[INSTRUMENT] extractTasks END (no changes) - total ${totalTime}ms`)
914
+ const fallbackTotalTime = Date.now() - extractStartTime
915
+ logger.debug(`[INSTRUMENT] extractTasks END (legacy-fallback) - total ${fallbackTotalTime}ms`)
783
916
  return file
784
917
  }
785
918
 
786
- logger.debug(`[INSTRUMENT] About to call file.extractTasks`)
787
- file.extractTasks(this.getConfig())
788
-
789
- const extractTime = Date.now()
790
- logger.debug(`[INSTRUMENT] file.extractTasks took ${extractTime - transformTime}ms`)
919
+ const fileContent = file.content
920
+ attachStreamTasks({ file, streamTasks, config: this.getConfig() })
921
+ file.transformTasks(this.getConfig())
922
+ emitParserDiagnostic(this, file, {
923
+ mode: 'stream',
924
+ })
791
925
 
792
- if (file.modified) {
793
- logger.debug(`[INSTRUMENT] About to writeFile`)
926
+ if (file.isModified() && fileContent !== file.content) {
927
+ logger.debug('[INSTRUMENT] About to writeFile (stream re-extract flow)')
794
928
  await this.writeFile(file)
795
- logger.debug(`[INSTRUMENT] writeFile took ${Date.now() - extractTime}ms`)
929
+ file.extractTasks(this.getConfig())
796
930
  }
797
931
 
798
932
  this.resetFile(file)
799
-
800
- const totalTime = Date.now() - extractStartTime
801
- if (totalTime > 1000) {
802
- logger.warn(`[INSTRUMENT] ⚠️ SLOW extractTasks: ${file.path} took ${totalTime}ms`)
803
- }
804
- logger.debug(`[INSTRUMENT] extractTasks END - total ${totalTime}ms`)
805
-
933
+ const streamTotalTime = Date.now() - extractStartTime
934
+ logger.debug(`[INSTRUMENT] extractTasks END (stream) - total ${streamTotalTime}ms`)
806
935
  return file
807
936
  }
808
937
 
@@ -1,5 +1,31 @@
1
1
  import { pathToFileURL } from 'node:url'
2
2
 
3
+ const WINDOWS_DRIVE_PATH = /^[a-zA-Z]:[\\/]/
4
+ const WINDOWS_UNC_PATH = /^\\\\[^\\]+\\[^\\]+/
5
+
3
6
  export function toFileImportSpecifier(filePath) {
7
+ const value = String(filePath)
8
+ if (/^(file|data|node):/i.test(value)) return value
9
+ if (WINDOWS_DRIVE_PATH.test(value)) return windowsDrivePathToFileURL(value)
10
+ if (WINDOWS_UNC_PATH.test(value)) return windowsUncPathToFileURL(value)
11
+
4
12
  return pathToFileURL(filePath).href
5
13
  }
14
+
15
+ function windowsDrivePathToFileURL(filePath) {
16
+ const normalizedPath = filePath.replace(/\\/g, '/')
17
+ return `file:///${encodePathSegments(normalizedPath)}`
18
+ }
19
+
20
+ function windowsUncPathToFileURL(filePath) {
21
+ const normalizedPath = filePath.replace(/\\/g, '/').replace(/^\/+/, '')
22
+ const [host, ...pathSegments] = normalizedPath.split('/')
23
+ return `file://${host}/${pathSegments.map(encodeURIComponent).join('/')}`
24
+ }
25
+
26
+ function encodePathSegments(filePath) {
27
+ return filePath
28
+ .split('/')
29
+ .map(segment => /^[a-zA-Z]:$/.test(segment) ? segment : encodeURIComponent(segment))
30
+ .join('/')
31
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "imdone-core",
3
- "version": "2.2.0",
3
+ "version": "2.3.1",
4
4
  "description": "imdone-core",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,10 +10,10 @@
10
10
  "types": "types/index.d.ts",
11
11
  "type": "module",
12
12
  "bin": {
13
- "imdone-cli": "lib/cli.js"
13
+ "imdone-core": "lib/cli.js"
14
14
  },
15
15
  "scripts": {
16
- "prepublishOnly": "npm run build",
16
+ "prepublishOnly": "node scripts/prepublish-only.js",
17
17
  "build": "npm run pre-commit && npm run coverage",
18
18
  "build:docs": "doctoc --github --notitle README.md",
19
19
  "build:docs:index": "node scripts/index-markdown.js",
@@ -84,7 +84,7 @@
84
84
  "markdown-it-emoji": "^2.0.0",
85
85
  "markdown-it-mark": "^3.0.1",
86
86
  "moment": "^2.29.1",
87
- "monquery": "github:imdone/node-monquery#0.2.2",
87
+ "monquery": "https://github.com/imdone/node-monquery/archive/refs/tags/0.2.2.tar.gz",
88
88
  "multilang-extract-comments": "^0.4.0",
89
89
  "open": "^9.1.0",
90
90
  "ora": "^8.2.0",
@@ -100,8 +100,8 @@
100
100
  },
101
101
  "devDependencies": {
102
102
  "@types/node": "^22.13.13",
103
- "@vitest/coverage-istanbul": "^3.1.1",
104
- "@vitest/coverage-v8": "^3.1.1",
103
+ "@vitest/coverage-istanbul": "^3.2.6",
104
+ "@vitest/coverage-v8": "^3.2.6",
105
105
  "chai": "^5.2.0",
106
106
  "colors": "^1.4.0",
107
107
  "dependency-cruiser": "^16.10.0",
@@ -113,6 +113,6 @@
113
113
  "json-stringify-safe": "~5.0.0",
114
114
  "prettier": "^2.5.1",
115
115
  "typescript": "^5.8.2",
116
- "vitest": "^3.1.1"
116
+ "vitest": "^3.2.6"
117
117
  }
118
118
  }
@@ -0,0 +1,30 @@
1
+ import { spawn } from 'node:child_process'
2
+
3
+ const TRUE_VALUES = new Set(['1', 'true', 'yes', 'on'])
4
+
5
+ function isTruthy(value) {
6
+ return TRUE_VALUES.has(String(value || '').toLowerCase())
7
+ }
8
+
9
+ function shouldSkipPrepublishOnly() {
10
+ return isTruthy(process.env.NO_PREPUBLISH)
11
+ }
12
+
13
+ if (shouldSkipPrepublishOnly()) {
14
+ console.log('Skipping prepublishOnly because skip prepublish override is enabled.')
15
+ process.exit(0)
16
+ }
17
+
18
+ const build = spawn('npm', ['run', 'build'], {
19
+ stdio: 'inherit',
20
+ shell: process.platform === 'win32',
21
+ })
22
+
23
+ build.on('exit', (code) => {
24
+ process.exit(code ?? 1)
25
+ })
26
+
27
+ build.on('error', (error) => {
28
+ console.error(`Unable to run prepublishOnly build: ${error.message}`)
29
+ process.exit(1)
30
+ })
@@ -3,11 +3,9 @@ export class ReadStreamCodeParser extends ReadStreamTaskParser {
3
3
  lists: any;
4
4
  filePath: any;
5
5
  lang: any;
6
- taskParsers: CodeStyleTaskParser[];
7
6
  init(): Promise<void>;
8
7
  cards: any[] | undefined;
9
- parseCards(code: any, tokens?: string[]): any[];
10
- parseComments(code: any): any;
8
+ parseCards(tokens?: string[]): Promise<any[]>;
9
+ parseComments(): Promise<{}>;
11
10
  }
12
11
  import { ReadStreamTaskParser } from './ReadStreamTaskParser.js';
13
- import { CodeStyleTaskParser } from '../task/CodeStyleTaskParser.js';
@@ -1,6 +1,17 @@
1
1
  export class ReadStreamMarkdownParser extends ReadStreamTaskParser {
2
2
  constructor(readStream: any, config: any);
3
+ frontmatterLines: any[];
4
+ collectingFrontmatter: boolean;
5
+ frontmatterClosed: boolean;
6
+ ignoredByFrontmatter: boolean;
7
+ currentLineIsFrontmatter: boolean;
8
+ currentLineIsCodeFence: boolean;
9
+ inCodeFence: boolean;
3
10
  taskParsers: (HashStyleTaskParser | MarkdownStyleTaskParser)[];
11
+ onLineRead(line: any): void;
12
+ updateFrontmatterState(line: any): void;
13
+ updateIgnoredByFrontmatter(): void;
14
+ updateCodeFenceState(line: any): void;
4
15
  }
5
16
  import { ReadStreamTaskParser } from './ReadStreamTaskParser.js';
6
17
  import { HashStyleTaskParser } from '../task/HashStyleTaskParser.js';
@@ -7,10 +7,14 @@ export class ReadStreamTaskParser {
7
7
  lineNo: number;
8
8
  blankLines: number;
9
9
  readInterface: ReadLine.Interface;
10
+ lineIterator: NodeJS.AsyncIterator<string, any, any>;
10
11
  readTask(): Promise<any>;
12
+ nextTask: any;
11
13
  readLine(): Promise<IteratorResult<string, any>>;
14
+ onLineRead(): void;
15
+ shouldParseTaskLine(): boolean;
16
+ getLineForTaskParsing(line: any): any;
12
17
  isEndOfTask(line: any): boolean;
13
- nextTask: any;
14
18
  getTaskFromLine(line: any): undefined;
15
19
  addLineToTask(line: any): void;
16
20
  trimLinesToEndTask(): void;
@@ -1,9 +1,10 @@
1
1
  export class CheckStyleTaskParser extends TaskParser {
2
- constructor();
2
+ constructor(config: any);
3
3
  get pattern(): RegExp;
4
4
  parse(lineContent: any, line: any, matchResult: any, task: any): {
5
5
  beforeText: any;
6
6
  text: any;
7
+ list: any;
7
8
  line: any;
8
9
  checked: any;
9
10
  type: any;
@@ -3,5 +3,5 @@ export class TaskParser {
3
3
  config: any;
4
4
  type: any;
5
5
  get pattern(): void;
6
- parseLine(lineContent: any, line: any, task: any): any;
6
+ parseLine(lineContent: any, line: any, task: any, originalLineContent?: any): any;
7
7
  }