imdone-core 1.54.11 → 1.54.13
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/.claude/settings.local.json +8 -0
- package/CHANGELOG.md +9 -0
- package/lib/adapters/parsers/task/CardContentParser.js +41 -15
- package/lib/constants.js +2 -1
- package/lib/file.js +40 -6
- package/lib/mixins/repo-watched-fs-store.js +72 -7
- package/lib/repository.js +30 -1
- package/lib/task.js +34 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,12 @@
|
|
|
1
|
+
## 1.54.13
|
|
2
|
+
|
|
3
|
+
- **Enhancement**: `customCardTerminator` now accepts numeric values to specify the number of consecutive blank lines needed to terminate a card (e.g., `customCardTerminator: 3` requires 3 blank lines)
|
|
4
|
+
- **Enhancement**: `repo.resume()` now automatically calls `refresh()` to pick up file changes that occurred while the repository was paused
|
|
5
|
+
- **Enhancement**: `File.trimBlankLines()` now respects the `customCardTerminator` config setting, preserving the appropriate number of blank lines in card content
|
|
6
|
+
- **Fix**: `Task.updateFromContent()` now respects the `customCardTerminator` config setting when collapsing consecutive blank lines, ensuring consistency across all content operations
|
|
7
|
+
- **Tests**: Added comprehensive test coverage for numeric `customCardTerminator` values, `trimBlankLines` behavior, `updateFromContent` behavior, and `resume()` refresh functionality
|
|
8
|
+
- **Refactor**: Improved code readability by replacing comments with self-documenting function names and descriptive constants
|
|
9
|
+
|
|
1
10
|
## 1.28.8
|
|
2
11
|
- Allow Card and Board actions to return a value
|
|
3
12
|
|
|
@@ -55,10 +55,28 @@ function getContentLines(content) {
|
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
function isCustomCardTerminator(config, line) {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
&&
|
|
58
|
+
if (!config.customCardTerminator) return false
|
|
59
|
+
|
|
60
|
+
const isStringTerminator = typeof config.customCardTerminator === 'string'
|
|
61
|
+
&& config.customCardTerminator.trim()
|
|
62
|
+
|
|
63
|
+
if (isStringTerminator) {
|
|
64
|
+
const terminatorMatches = line && line.trim() === config.customCardTerminator.trim()
|
|
65
|
+
return terminatorMatches
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return false
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const DEFAULT_BLANK_LINE_TERMINATOR_COUNT = 2
|
|
72
|
+
|
|
73
|
+
function getBlankLineTerminatorCount(config) {
|
|
74
|
+
const hasNumericTerminator = typeof config.customCardTerminator === 'number'
|
|
75
|
+
&& config.customCardTerminator > 0
|
|
76
|
+
|
|
77
|
+
return hasNumericTerminator
|
|
78
|
+
? config.customCardTerminator
|
|
79
|
+
: DEFAULT_BLANK_LINE_TERMINATOR_COUNT
|
|
62
80
|
}
|
|
63
81
|
|
|
64
82
|
function isCheckBoxTask (config, line, beforeText) {
|
|
@@ -256,8 +274,8 @@ function getTextFileTaskContent ({
|
|
|
256
274
|
const isTaskInList = isMarkDownFile && isBeforeTextMarkdownList(beforeText)
|
|
257
275
|
let openBlockCommentTokens = 0
|
|
258
276
|
let closeBlockCommentTokens = 0
|
|
259
|
-
|
|
260
|
-
let
|
|
277
|
+
const blankLineTerminatorCount = getBlankLineTerminatorCount(config)
|
|
278
|
+
let consecutiveBlankLines = 0
|
|
261
279
|
let trailingBlankLines = 0
|
|
262
280
|
const rawTaskContentLines = []
|
|
263
281
|
const taskContentLines = []
|
|
@@ -277,7 +295,7 @@ function getTextFileTaskContent ({
|
|
|
277
295
|
if (isCheckBoxTask(config, line, beforeText)) break
|
|
278
296
|
if (isTaskInList && isLineOutsideMarkdownList(line, beforeText)) break
|
|
279
297
|
if (isCustomCardTerminator(config, line)) break
|
|
280
|
-
|
|
298
|
+
|
|
281
299
|
rawTaskContentLines.push(line)
|
|
282
300
|
taskContentLines.push(`${line}`)
|
|
283
301
|
|
|
@@ -285,20 +303,28 @@ function getTextFileTaskContent ({
|
|
|
285
303
|
if (isMarkDownFile && line.trim().indexOf('<!--') > -1)
|
|
286
304
|
openBlockCommentTokens++
|
|
287
305
|
if (isMarkDownFile && line.trim().indexOf('-->') > -1)
|
|
288
|
-
closeBlockCommentTokens++
|
|
306
|
+
closeBlockCommentTokens++
|
|
289
307
|
|
|
290
308
|
const thisLineBlank = line.trim() === ''
|
|
291
309
|
|
|
292
|
-
if (thisLineBlank
|
|
293
|
-
|
|
294
|
-
|
|
310
|
+
if (thisLineBlank) {
|
|
311
|
+
consecutiveBlankLines++
|
|
312
|
+
if (consecutiveBlankLines >= blankLineTerminatorCount) {
|
|
313
|
+
trailingBlankLines = blankLineTerminatorCount
|
|
314
|
+
break
|
|
315
|
+
}
|
|
316
|
+
} else {
|
|
317
|
+
consecutiveBlankLines = 0
|
|
295
318
|
}
|
|
296
|
-
|
|
297
|
-
lastLineBlank = thisLineBlank
|
|
298
319
|
}
|
|
299
320
|
|
|
300
|
-
|
|
301
|
-
|
|
321
|
+
const reachedEndNaturally = trailingBlankLines === 0
|
|
322
|
+
const hasTrailingBlankLines = consecutiveBlankLines > 0
|
|
323
|
+
const isUsingDefaultTerminator = blankLineTerminatorCount === DEFAULT_BLANK_LINE_TERMINATOR_COUNT
|
|
324
|
+
|
|
325
|
+
if (reachedEndNaturally && hasTrailingBlankLines && isUsingDefaultTerminator) {
|
|
326
|
+
trailingBlankLines = consecutiveBlankLines
|
|
327
|
+
}
|
|
302
328
|
|
|
303
329
|
// Remove extra block comment tokens
|
|
304
330
|
if (
|
package/lib/constants.js
CHANGED
|
@@ -50,13 +50,14 @@ module.exports = {
|
|
|
50
50
|
SORT_FILE: path.join(CONFIG_DIR, 'sort.json'),
|
|
51
51
|
TEMPLATES_DIR: path.join(CONFIG_DIR, 'templates'),
|
|
52
52
|
IGNORE_FILE: '.imdoneignore',
|
|
53
|
+
PAUSE_FILE: '.imdonepause',
|
|
53
54
|
DEFAULT_FILE_PATTERN: '^(readme\\.md|home\\.md|readme\\.w+|home\\.w+)$',
|
|
54
55
|
DEFAULT_IGNORE,
|
|
55
56
|
DEFAULT_IGNORE_DIRS,
|
|
56
57
|
DEFAULT_IGNORE_EXTS,
|
|
57
58
|
ERRORS: {
|
|
58
59
|
NOT_A_FILE: 'file must be of type File',
|
|
59
|
-
CALLBACK_REQUIRED: 'Last
|
|
60
|
+
CALLBACK_REQUIRED: 'Last parameter must be a callback function',
|
|
60
61
|
NO_CONTENT: 'File has no content',
|
|
61
62
|
LIST_NOT_FOUND: 'List not found',
|
|
62
63
|
TASK_NOT_FOUND: 'Task not found',
|
package/lib/file.js
CHANGED
|
@@ -820,17 +820,51 @@ File.prototype.modifyTaskFromHtml = function (task, html) {
|
|
|
820
820
|
}
|
|
821
821
|
|
|
822
822
|
File.prototype.modifyTaskFromContent = function (task, content, config) {
|
|
823
|
-
content = trimBlankLines(content, this.isCodeFile())
|
|
823
|
+
content = trimBlankLines(content, this.isCodeFile(), config)
|
|
824
824
|
task.updateFromContent(content)
|
|
825
825
|
this.modifyTask(task, config)
|
|
826
826
|
}
|
|
827
827
|
|
|
828
|
-
const
|
|
828
|
+
const DEFAULT_BLANK_LINE_TERMINATOR_COUNT = 2
|
|
829
|
+
const CODE_FILE_BLANK_LINE_PATTERN = /\n\s*\n/g
|
|
830
|
+
const CODE_FILE_BLANK_LINE_REPLACEMENT = '\n'
|
|
831
|
+
|
|
832
|
+
function getBlankLineTerminatorCountFromConfig(config) {
|
|
833
|
+
const hasNumericTerminator = config
|
|
834
|
+
&& typeof config.customCardTerminator === 'number'
|
|
835
|
+
&& config.customCardTerminator > 0
|
|
836
|
+
|
|
837
|
+
return hasNumericTerminator
|
|
838
|
+
? config.customCardTerminator
|
|
839
|
+
: DEFAULT_BLANK_LINE_TERMINATOR_COUNT
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
function buildExcessiveBlankLinePattern(terminatorCount) {
|
|
843
|
+
const minNewlinesToTrim = terminatorCount + 1
|
|
844
|
+
const patternParts = Array(minNewlinesToTrim).fill('\\n\\s*')
|
|
845
|
+
return new RegExp(patternParts.join(''), 'g')
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
function calculateMaxPreservedBlankLines(terminatorCount) {
|
|
849
|
+
return terminatorCount - 1
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
function buildBlankLineReplacement(maxBlankLines) {
|
|
853
|
+
const newlineCount = maxBlankLines + 1
|
|
854
|
+
return '\n'.repeat(newlineCount)
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
const trimBlankLines = File.trimBlankLines = function (content, isCodeFile, config) {
|
|
829
858
|
if (isCodeFile) {
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
859
|
+
return content.replace(CODE_FILE_BLANK_LINE_PATTERN, CODE_FILE_BLANK_LINE_REPLACEMENT)
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
const terminatorCount = getBlankLineTerminatorCountFromConfig(config)
|
|
863
|
+
const excessiveBlankLinePattern = buildExcessiveBlankLinePattern(terminatorCount)
|
|
864
|
+
const maxBlankLines = calculateMaxPreservedBlankLines(terminatorCount)
|
|
865
|
+
const replacement = buildBlankLineReplacement(maxBlankLines)
|
|
866
|
+
|
|
867
|
+
return content.replace(excessiveBlankLinePattern, replacement)
|
|
834
868
|
}
|
|
835
869
|
|
|
836
870
|
File.prototype.modifyDescription = function (task, config) {
|
|
@@ -4,7 +4,8 @@ var fsStore = require('./repo-fs-store'),
|
|
|
4
4
|
log = require('debug')('imdone-mixins:repo-watched-fs-store'),
|
|
5
5
|
File = require('../file'),
|
|
6
6
|
constants = require('../constants'),
|
|
7
|
-
sane = require('sane')
|
|
7
|
+
sane = require('sane'),
|
|
8
|
+
path = require('path')
|
|
8
9
|
|
|
9
10
|
module.exports = mixin
|
|
10
11
|
|
|
@@ -18,6 +19,7 @@ function mixin(repo, fs = require('fs')) {
|
|
|
18
19
|
var _destroy = repo.destroy
|
|
19
20
|
var _init = repo.init
|
|
20
21
|
var initializing = false
|
|
22
|
+
var paused = false
|
|
21
23
|
|
|
22
24
|
repo.init = function (cb) {
|
|
23
25
|
if (initializing) {
|
|
@@ -33,9 +35,17 @@ function mixin(repo, fs = require('fs')) {
|
|
|
33
35
|
initializing = false
|
|
34
36
|
return cb ? cb(err) : null
|
|
35
37
|
}
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
38
|
+
// Check if pause file exists on init
|
|
39
|
+
const pauseFilePath = path.join(repo.path, constants.PAUSE_FILE)
|
|
40
|
+
fs.access(pauseFilePath, fs.constants.F_OK, (accessErr) => {
|
|
41
|
+
if (!accessErr) {
|
|
42
|
+
log('Pause file detected on init. Pausing repository.')
|
|
43
|
+
paused = true
|
|
44
|
+
}
|
|
45
|
+
repo.initWatcher(() => {
|
|
46
|
+
initializing = false
|
|
47
|
+
if (cb) cb(err, files)
|
|
48
|
+
})
|
|
39
49
|
})
|
|
40
50
|
})
|
|
41
51
|
}
|
|
@@ -57,9 +67,19 @@ function mixin(repo, fs = require('fs')) {
|
|
|
57
67
|
initializing = false
|
|
58
68
|
return cb ? cb(err) : null
|
|
59
69
|
}
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
70
|
+
// Check if pause file exists on refresh
|
|
71
|
+
const pauseFilePath = path.join(repo.path, constants.PAUSE_FILE)
|
|
72
|
+
fs.access(pauseFilePath, fs.constants.F_OK, (accessErr) => {
|
|
73
|
+
if (!accessErr) {
|
|
74
|
+
log('Pause file detected on refresh. Pausing repository.')
|
|
75
|
+
paused = true
|
|
76
|
+
} else {
|
|
77
|
+
paused = false
|
|
78
|
+
}
|
|
79
|
+
repo.initWatcher(() => {
|
|
80
|
+
initializing = false
|
|
81
|
+
return cb ? cb(err, files) : null
|
|
82
|
+
})
|
|
63
83
|
})
|
|
64
84
|
})
|
|
65
85
|
}
|
|
@@ -72,6 +92,10 @@ function mixin(repo, fs = require('fs')) {
|
|
|
72
92
|
return path === constants.IGNORE_FILE
|
|
73
93
|
}
|
|
74
94
|
|
|
95
|
+
var _isImdonePause = function (path) {
|
|
96
|
+
return path === constants.PAUSE_FILE
|
|
97
|
+
}
|
|
98
|
+
|
|
75
99
|
const onConfigChange = function (file) {
|
|
76
100
|
console.info('Loading config. Observed a change to:', file)
|
|
77
101
|
const label = `Refresh repo: ${repo.path}`
|
|
@@ -85,6 +109,30 @@ function mixin(repo, fs = require('fs')) {
|
|
|
85
109
|
|
|
86
110
|
const onIgnoreChange = onConfigChange
|
|
87
111
|
|
|
112
|
+
repo.pause = async function () {
|
|
113
|
+
log('Pausing watcher for:', repo.path)
|
|
114
|
+
paused = true
|
|
115
|
+
return Promise.resolve()
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
repo.resume = async function () {
|
|
119
|
+
log('Resuming watcher for:', repo.path)
|
|
120
|
+
paused = false
|
|
121
|
+
return new Promise((resolve, reject) => {
|
|
122
|
+
repo.refresh((err, files) => {
|
|
123
|
+
if (err) {
|
|
124
|
+
console.error('Error refreshing repo on resume', err)
|
|
125
|
+
return reject(err)
|
|
126
|
+
}
|
|
127
|
+
resolve(files)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
repo.isPaused = function () {
|
|
133
|
+
return paused
|
|
134
|
+
}
|
|
135
|
+
|
|
88
136
|
repo.initWatcher = function (cb) {
|
|
89
137
|
console.log('initializing watcher for:', repo.path)
|
|
90
138
|
repo.watcher = sane(repo.path, {
|
|
@@ -98,6 +146,11 @@ function mixin(repo, fs = require('fs')) {
|
|
|
98
146
|
onIgnoreChange(file)
|
|
99
147
|
}
|
|
100
148
|
|
|
149
|
+
// Don't ignore the pause file so we can detect it
|
|
150
|
+
if (_isImdonePause(file)) {
|
|
151
|
+
return false
|
|
152
|
+
}
|
|
153
|
+
|
|
101
154
|
const ignore = !repo.shouldInclude(file)
|
|
102
155
|
if (!ignore || /^.*\.(jpg|gif|pdf|png)$/i.test(file)) {
|
|
103
156
|
repo.addFilePath(file)
|
|
@@ -119,6 +172,11 @@ function mixin(repo, fs = require('fs')) {
|
|
|
119
172
|
})
|
|
120
173
|
.on('add', function (path, root, stat) {
|
|
121
174
|
console.log('Watcher received add event for file: ' + path)
|
|
175
|
+
if (_isImdonePause(path)) {
|
|
176
|
+
console.log('Pause file detected. Pausing repository.')
|
|
177
|
+
return repo.pause()
|
|
178
|
+
}
|
|
179
|
+
if (paused) return log('Watcher is paused. Ignoring add event for: ' + path)
|
|
122
180
|
if (stat.isDirectory()) return
|
|
123
181
|
var file = repo.getFile(path)
|
|
124
182
|
if (file === undefined) {
|
|
@@ -148,6 +206,8 @@ function mixin(repo, fs = require('fs')) {
|
|
|
148
206
|
`Watcher received change event for file: ${path} repoPath: ${repo.path}`,
|
|
149
207
|
stat.mtime
|
|
150
208
|
)
|
|
209
|
+
if (_isImdonePause(path)) return log('Ignoring change to pause file')
|
|
210
|
+
if (paused) return log('Watcher is paused. Ignoring change event for: ' + path)
|
|
151
211
|
if (!path || stat.isDirectory()) return
|
|
152
212
|
var file = repo.getFile(path) || path
|
|
153
213
|
const isFile = File.isFile(file)
|
|
@@ -178,6 +238,11 @@ function mixin(repo, fs = require('fs')) {
|
|
|
178
238
|
})
|
|
179
239
|
.on('delete', function (path) {
|
|
180
240
|
log('Watcher received unlink event for file: ' + path)
|
|
241
|
+
if (_isImdonePause(path)) {
|
|
242
|
+
console.log('Pause file deleted. Resuming repository.')
|
|
243
|
+
return repo.resume()
|
|
244
|
+
}
|
|
245
|
+
if (paused) return log('Watcher is paused. Ignoring delete event for: ' + path)
|
|
181
246
|
repo.removeFilePath(path)
|
|
182
247
|
var file = new File({
|
|
183
248
|
repoId: repo.getId(),
|
package/lib/repository.js
CHANGED
|
@@ -624,6 +624,33 @@ Repository.prototype.readFileContent = function (file, cb) {
|
|
|
624
624
|
inMixinsNoop(cb)
|
|
625
625
|
}
|
|
626
626
|
|
|
627
|
+
/**
|
|
628
|
+
* Pause file watching (Implemented in mixins)
|
|
629
|
+
* @method pause
|
|
630
|
+
* @return Promise
|
|
631
|
+
*/
|
|
632
|
+
Repository.prototype.pause = async function () {
|
|
633
|
+
return Promise.resolve()
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/**
|
|
637
|
+
* Resume file watching (Implemented in mixins)
|
|
638
|
+
* @method resume
|
|
639
|
+
* @return Promise
|
|
640
|
+
*/
|
|
641
|
+
Repository.prototype.resume = async function () {
|
|
642
|
+
return Promise.resolve()
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* Check if file watching is paused (Implemented in mixins)
|
|
647
|
+
* @method isPaused
|
|
648
|
+
* @return Boolean
|
|
649
|
+
*/
|
|
650
|
+
Repository.prototype.isPaused = function () {
|
|
651
|
+
return false
|
|
652
|
+
}
|
|
653
|
+
|
|
627
654
|
/**
|
|
628
655
|
* Description
|
|
629
656
|
* @method readFile
|
|
@@ -1129,7 +1156,9 @@ Repository.prototype.appendTask = function({file, content, list}, cb) {
|
|
|
1129
1156
|
: description.join(eol.lf)
|
|
1130
1157
|
|
|
1131
1158
|
let appendContent = File.trimBlankLines(
|
|
1132
|
-
`${taskPrefix}${rawTask}${eol.lf}${taskContent}
|
|
1159
|
+
`${taskPrefix}${rawTask}${eol.lf}${taskContent}`,
|
|
1160
|
+
file.isCodeFile(),
|
|
1161
|
+
config
|
|
1133
1162
|
)
|
|
1134
1163
|
|
|
1135
1164
|
fileContent = `${fileContent}${sep}`
|
package/lib/task.js
CHANGED
|
@@ -14,7 +14,7 @@ var _clone = require('lodash.clone'),
|
|
|
14
14
|
{ escapeRegExp } = require('./tools'),
|
|
15
15
|
{ extractWikilinkTopics } = require('./adapters/markdown');
|
|
16
16
|
|
|
17
|
-
const {
|
|
17
|
+
const {
|
|
18
18
|
CHECK_REGEX,
|
|
19
19
|
TASK_TYPES,
|
|
20
20
|
getCheckedData,
|
|
@@ -22,6 +22,33 @@ const {
|
|
|
22
22
|
isNumber
|
|
23
23
|
} = require('./adapters/parsers/task/CardContentParser.js')
|
|
24
24
|
|
|
25
|
+
const DEFAULT_BLANK_LINE_TERMINATOR_COUNT = 2
|
|
26
|
+
|
|
27
|
+
function getBlankLineTerminatorCountFromConfig(config) {
|
|
28
|
+
const hasNumericTerminator = config
|
|
29
|
+
&& typeof config.customCardTerminator === 'number'
|
|
30
|
+
&& config.customCardTerminator > 0
|
|
31
|
+
|
|
32
|
+
return hasNumericTerminator
|
|
33
|
+
? config.customCardTerminator
|
|
34
|
+
: DEFAULT_BLANK_LINE_TERMINATOR_COUNT
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildExcessiveBlankLinePattern(terminatorCount) {
|
|
38
|
+
const minNewlinesToTrim = terminatorCount + 1
|
|
39
|
+
const patternParts = Array(minNewlinesToTrim).fill('\\n\\s*')
|
|
40
|
+
return new RegExp(patternParts.join(''), 'g')
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function calculateMaxPreservedBlankLines(terminatorCount) {
|
|
44
|
+
return terminatorCount - 1
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function buildBlankLineReplacement(maxBlankLines) {
|
|
48
|
+
const newlineCount = maxBlankLines + 1
|
|
49
|
+
return '\n'.repeat(newlineCount)
|
|
50
|
+
}
|
|
51
|
+
|
|
25
52
|
/**
|
|
26
53
|
* Description
|
|
27
54
|
* @method Task
|
|
@@ -177,8 +204,12 @@ class Task {
|
|
|
177
204
|
}
|
|
178
205
|
|
|
179
206
|
updateFromContent(content) {
|
|
180
|
-
|
|
181
|
-
|
|
207
|
+
const terminatorCount = getBlankLineTerminatorCountFromConfig(this.config)
|
|
208
|
+
const excessiveBlankLinePattern = buildExcessiveBlankLinePattern(terminatorCount)
|
|
209
|
+
const maxBlankLines = calculateMaxPreservedBlankLines(terminatorCount)
|
|
210
|
+
const replacement = buildBlankLineReplacement(maxBlankLines)
|
|
211
|
+
|
|
212
|
+
content = content.replace(excessiveBlankLinePattern, replacement)
|
|
182
213
|
let lines = eol.split(content)
|
|
183
214
|
this.text = lines.shift().trimStart()
|
|
184
215
|
if (lines && lines.length > 1 && lines[lines.length - 1].trim() === '') lines.pop()
|