imdone-core
Version:
1,390 lines (1,246 loc) • 41.2 kB
JavaScript
import _isObject from 'lodash.isobject'
import _assign from 'lodash.assign'
import { omit } from './omit.js'
import crypto from 'crypto'
import Emitter from 'events'
import path from 'path'
import matter from 'gray-matter'
import eol from 'eol'
import { tools } from './tools.js'
import debug from 'debug'
import * as chrono from 'chrono-node'
import { Task } from './task.js'
import { newCard } from './card.js'
import { sort } from 'fast-sort'
import XRegExp from 'xregexp'
import { getIsoDateWithOffset } from './adapters/date-time.js'
import {
FILE_TYPES,
getTaskContent,
getRawTask,
getHashStyleRegex,
hasTaskInText,
LIST_NAME_PATTERN,
LINK_STYLE_REGEX,
} from './adapters/parsers/task/CardContentParser.js'
import { appContext } from './context/ApplicationContext.js'
import { languages } from './languages.js'
import { logger } from './adapters/logger.js'
const log = debug('imdone-core:File')
const lf = String(eol.lf)
const lineEnd = String(eol.auto)
const { hasBlankLines, escapeRegExp } = tools
const ERRORS = {
NOT_A_TASK: 'task must be of type Task',
}
const CODE_STYLE_END = '((:)(-?[\\d.]+(?:e-?\\d+)?)?)?[ \\t]+(.+)$'
const CODE_STYLE_PATTERN = '([A-Z]+[A-Z-_]+?)' + CODE_STYLE_END
const CHECK_STYLE_PATTERN = /^(\s*- \[([x ])\]\s)(.+$)/gm
function getTaskId (path, line, text) {
var shasum = crypto.createHash('sha1')
const taskElements = { path, line, text: text.trim() }
shasum.update(JSON.stringify(taskElements))
return shasum.digest('hex')
}
function getDoneList(config) {
return config.getDoneList()
}
function getDoingList(config) {
return config.getDoingList()
}
function getFrontMeta(meta) {
const metadata = {}
Object.entries(meta).forEach(([prop, val]) => {
if (Array.isArray(val)) {
val.forEach((item) => {
if (_isObject(item) || Array.isArray(item)) return
if (!metadata[prop]) metadata[prop] = []
metadata[prop].push(`${item}`)
})
}
})
return metadata
}
function trackMarkdownChecklistChanges({task, config, modify}) {
const doneList = config.getDoneList()
let beforeTextModified = false
if (task.beforeText.trim().startsWith('- [ ]')) {
if (task.list === doneList) {
if (modify) {
task.beforeText = task.beforeText.replace('[ ]', '[x]')
beforeTextModified = true
} else {
task.list = config.getDefaultList()
}
}
} else if (task.beforeText.trim().startsWith('- [x]')) {
if (task.list !== doneList) {
if (modify) {
task.beforeText = task.beforeText.replace('[x]', '[ ]')
beforeTextModified = true
} else {
task.list = doneList
}
}
}
return beforeTextModified
}
function trimBlankLines (content, isCodeFile) {
if (isCodeFile) {
// replace all occurrences of two empty lines in a row with a single empty line
return content.replace(/\n\s*\n/g, '\n')
}
return content.replace(/\n\s*\n\s*\n/g, '\n\n')
}
function padDescription (description = [], beforeText = '') {
return Task.padDescription(description, beforeText)
}
function bothAreUndefNull(a, b) {
return isUndefNull(a) && isUndefNull(b)
}
function isUndefNull(val) {
return val === null || val === undefined
}
export class File extends Emitter {
constructor(opts) {
super()
this.project = opts.project
this.projectContext = appContext().projectContext
if (_isObject(opts.file) && _isObject(opts.config)) {
_assign(this, opts.file)
this.tasks.forEach((task) => {
task = newCard(task, this.project)
})
} else {
this.repoId = opts.repoId
this.path = opts.filePath
this.content = opts.content
this.modifiedTime = opts.modifiedTime
this.createdTime = opts.createdTime
this.languages = opts.languages || languages
this.modified = false
this.frontMatter = {
tags: [],
context: [],
meta: {},
}
this.tasks = []
this.isDir = false
this.lineCount = 0
this.deleted = false
}
}
static CODE_STYLE_PATTERN = CODE_STYLE_PATTERN
static CHECK_STYLE_PATTERN = CHECK_STYLE_PATTERN
static getTaskId = getTaskId
static trimBlankLines = trimBlankLines
static padDescription = padDescription
static isListNameValid(listName) {
return new XRegExp(LIST_NAME_PATTERN).test(listName)
}
static getUnixContent (content) {
return content.replace(/\r\n|\r/g, '\n')
}
static isMetaNewLine (config) {
return config.isMetaNewLine()
}
static isAddCompletedMeta (config) {
return config.isAddCompletedMeta()
}
static isFile (file) {
return file instanceof File
}
static isMarkdown (relPath) {
return /\.md$/i.test(relPath) || path.extname(relPath) === ''
}
static addStarted ({ task, content, config }) {
if (!task || !config || !config.settings) return content
if (!config.settings.cards.addStartedMeta) return content
if (task.meta.started) return content
if (task.list !== getDoingList(config)) return content
const now = getIsoDateWithOffset()
content = task.addToLastCommentInContent(
content,
`started${config.getMetaSep()}${now}`,
File.isMetaNewLine(config)
)
const lines = eol.split(content)
return lines.join(lineEnd)
}
static addCompleted ({ task, content, config }) {
const doneList = getDoneList(config)
if (
!task ||
!config ||
!config.settings ||
!doneList ||
!this.isAddCompletedMeta(config)
)
return content
if (task.list !== doneList) return content
if (task.completed || task.meta.completed) return content
if (content.includes(`completed${config.getMetaSep()}`)) return content
const now = getIsoDateWithOffset()
content = task.addToLastCommentInContent(
content,
`completed${config.getMetaSep()}${now}`,
File.isMetaNewLine(config)
)
const lines = eol.split(content)
return lines.join(lineEnd)
}
static parseDueDate (config, text) {
return File.parseDate(config, text, 'due')
}
static parseRemindDate (config, text) {
return File.parseDate(config, text, 'remind')
}
static parseDeferDate (config, text) {
return File.parseDate(config, text, 'defer')
}
static parseDate (config, text, dateType) {
// TODO Add the ability to set multiple reminders
// #imdone-1.54.0
// <!--
// order:-150
// -->
if (text.includes(`${dateType}${config.getMetaSep()}`)) return text
let dateString
return text.replace(new RegExp(`(\\s${dateType})\\s.*?\\.`,'gi'), (match, p1) => {
if (dateString) return match
try {
const date = chrono.parseDate(match, new Date(), {forwardDate: true})
if (!date) return match
dateString = getIsoDateWithOffset(date)
return `${p1.toLowerCase()}${config.getMetaSep()}${dateString}`
} catch (e) {
logger.log(`Unable to parse ${dateType} date for ${text}`)
return match
}
})
}
removeCompleted ({ task, content, config }) {
const doneList = getDoneList(config)
if (doneList === task.list) return content
if (
task.meta.completed &&
task.meta.completed.length > 0
) {
content = this.removeMetaData(
content,
'completed',
task.meta.completed[0]
)
}
return content
}
trackListChanges ({ task, content, config, modify }) {
const lists = config.lists
.filter((list) => !list.filter)
.map((list) => list.name)
let beforeTextModified = false
if (this.isMarkDownFile()) {
beforeTextModified = trackMarkdownChecklistChanges({task, config, modify})
if (
config?.settings?.cards?.trackChanges &&
task.hasListChanged(lists)
) {
const list = task.list
const now = new Date().toISOString()
content = task.addToLastCommentInContent(
content,
`${list}${config.getMetaSep()}${now}`,
File.isMetaNewLine(config)
)
if (!task.meta[list]) task.meta[list] = []
task.meta[list].push(now)
}
}
return { content, beforeTextModified }
}
removeMetaData (content, key, value) {
return Task.removeMetaData({
config: this.projectContext.config,
content,
key,
value,
})
}
transformTask ({config, modify, task}) {
let content = task.content
const trackListChanges = this.trackListChanges({
task,
content,
config,
modify,
})
content = trackListChanges.content
content = File.parseDueDate(config, content)
content = File.parseRemindDate(config, content)
content = File.parseDeferDate(config, content)
content = File.addStarted({ task, content, config })
content = File.addCompleted({ task, content, config })
content = this.removeCompleted({ task, content, config })
content = task.format(content)
if (
content.trim() !== task.content.trim()
|| trackListChanges.beforeTextModified
|| task.orderModified
) {
this.modifyTaskFromContent(task, content, config)
}
}
transformTasks (config, modify) {
sort(this.getTasks())
.desc(u => u.line)
.forEach(task => this.transformTask({config, modify, task}))
}
// TODO Blank lines should be allowed in code tasks
// <!--
// #imdone-1.54.0
// #feature
// order:-105
// -->
extractCodeStyleTasks (config, pos, content) {
var self = this
var codeStyleRegex = new RegExp(CODE_STYLE_PATTERN, 'gm')
var result
const inBlockComment = this.isInBlockComment(content)
const singleLineBlockComment =
inBlockComment && this.isSingleLineBlockComment(content)
while ((result = codeStyleRegex.exec(content)) !== null) {
var posInContent = pos + result.index
var line = this.getLineNumber(posInContent)
if (self.getTaskAtLine(line)) continue
var charBeforeList = this.getContent().substring(
posInContent - 1,
posInContent
)
if (this.startsWithCommentOrSpace(posInContent) && charBeforeList !== '#') {
var list = result[1]
if (!config.includeList(list)) continue
var rawTask = result[0]
var text = result[5]
const linePos = self.getLinePos(line)
const matchCharsInFront = this.getContent()
.substring(linePos, posInContent)
.match(/\S+\s*/)
const charsInFront = matchCharsInFront ? matchCharsInFront[0].length : 0
var hasColon =
result[3] !== undefined
const taskStartOnLine = inBlockComment
? posInContent - charsInFront - linePos
: pos - linePos
var task = self.extractTaskWithDescription({
taskStartOnLine,
rawTask,
config,
list,
text,
line,
type: Task.Types.CODE,
hasColon,
content: self.getContent().substring(linePos),
inBlockComment,
singleLineBlockComment,
})
self.addTask(task)
self.emit('task.found', task)
}
}
}
extractHashStyleTasks (config, pos, content) {
var hashStyleRegex = getHashStyleRegex(config.tokenPrefix)
var lang = this.getLang()
const codePositions = this.getMarkdownCodePositions(content);
var result
while ((result = hashStyleRegex.exec(content)) !== null) {
if (this.isResultInMarkdownCode(codePositions, result.index)) continue;
var [rawTask, list, orderGroup, order, text] = result
if (!config.listExists(list)) continue
const posInContent = result.index + pos
if (this.isWithinCodeSpanOrBlock(result.index, content)) continue
const line = this.getLineNumber(posInContent)
if (this.getTaskAtLine(line)) continue
if (lang.block) {
const blockEndPos = text.indexOf(lang.block.end)
if (blockEndPos > -1) text = text.substring(0, blockEndPos)
}
var task = this.extractTaskWithDescription({
config,
list,
text,
order,
line,
type: Task.Types.HASHTAG,
hasColon: orderGroup && orderGroup.startsWith(':'),
content: content.substring(result.index),
pos: posInContent,
})
task.taskStartOnLine = pos - this.getLinePos(line)
task.rawTask = rawTask
this.addTask(task)
this.emit('task.found', task)
}
}
extractLinkStyleTasks (config, pos, content) {
var self = this
var linkStyleRegex = new XRegExp(LINK_STYLE_REGEX)
const log = debug('extractLinkStyleTasks')
const codePositions = this.getMarkdownCodePositions(content);
var result
while ((result = linkStyleRegex.exec(content)) !== null) {
if (this.isResultInMarkdownCode(codePositions, result.index)) continue;
let [match, before, rawTask, text, list, colon, order] = result
const beforeLength = before ? before.length : 0
const posInContent = result.index + pos + beforeLength
if (this.isWithinCodeSpanOrBlock(result.index, content)) continue
var line = this.getLineNumber(posInContent)
if (self.getTaskAtLine(line)) continue
if (!config.listExists(list)) continue
log('*********************************************************')
log(result)
log('list:%s text:%s order:%d line:%d', list, text, order, line)
var task = self.extractTaskWithDescription({
config,
list,
text,
order,
line,
type: Task.Types.MARKDOWN,
hasColon: !!colon,
content: content.substring(result.index),
pos: posInContent,
})
task.rawTask = rawTask
self.addTask(task)
self.emit('task.found', task)
}
}
extractCheckboxTasks (config, pos, content) {
if (!this.isMarkDownFile()) return
if (!config.isAddCheckBoxTasks()) return
const codePositions = this.getMarkdownCodePositions(content);
const type = Task.Types[config.getNewCardSyntax()]
const checkStyleRegex = new RegExp(CHECK_STYLE_PATTERN)
let result
let tasks = []
const hasTaskAtLine = (line) => {
return (
this.getTaskAtLine(line) ||
tasks.find((task) => {
return line >= task.line && line <= task.lastLine
})
)
}
while ((result = checkStyleRegex.exec(content)) !== null) {
if (this.isResultInMarkdownCode(codePositions, result.index)) continue;
let [match, before, checked, text] = result
const list =
checked.trim() === 'x' ? config.getDoneList() : config.getDefaultList()
const beforeLength = before.length
const posInContent = result.index + pos + beforeLength
var line = this.getLineNumber(posInContent)
if (hasTaskAtLine(line)) continue
var task = this.extractTaskWithDescription({
config,
list,
text,
line,
type,
hasColon: true,
content: content.substring(result.index),
pos: posInContent,
})
task.rawTask = getRawTask({tokenPrefix: config.tokenPrefix, orderMeta: config.orderMeta, list, text, type})
tasks.push(task)
}
if (tasks) {
const hasTaskAtLine = (line) => {
return (
this.getTaskAtLine(line) ||
tasks.find((task) => {
return line > task.line && line <= task.lastLine
})
)
}
tasks = tasks.filter((task) => {
return !hasTaskAtLine(task.line)
})
if (!tasks) return
let newContent = content
tasks.reverse().forEach((task) => {
this.addTask(task)
const beforeTaskContent = content.substring(0, task.pos)
const afterTaskContent = newContent.substring(task.pos + task.text.length)
newContent = beforeTaskContent + task.rawTask + afterTaskContent
this.emit('task.found', task)
})
if (this.content === newContent || !newContent) return
this.content = newContent
this.setModified(true)
logger.log('extractCheckboxTasks setModified true for file:', this.path)
}
}
extractTasks (config) {
this.tasks = []
if (this.isMarkDownFile()) {
const data = this.parseFrontMatter(config)
if (data.imdone_ignore || data['kanban-plugin']) return this
}
if (this.isCodeFile()) {
this.extractTasksInCodeFile(config)
} else {
this.extractTasksInNonCodeFile(config)
}
return this
}
extractAndTransformTasks (config) {
this.extractTasks(config)
this.transformTasks(config)
}
// TODO Extract functions should be moved to another file
// Only pass the needed config values to the functions
// #imdone-1.54.0
// <!--
// order:40
// -->
extractTasksInCodeFile (config) {
var self = this
var commentRegex = this.getCodeCommentRegex()
var result
while ((result = commentRegex.exec(self.getContent())) !== null) {
var comment = result[0]
var commentStart = result.index
self.extractCodeStyleTasks(config, commentStart, comment)
}
}
extractTasksInNonCodeFile (config) {
this.extractHashStyleTasks(config, 0, this.getContent())
this.extractLinkStyleTasks(config, 0, this.getContent())
this.extractCheckboxTasks(config, 0, this.getContent())
}
deleteBlankLine (lineNo) {
var startOfLine = this.getLinePos(lineNo)
if (this.isMarkDownFile()) startOfLine = startOfLine - 1
var startOfNextLine = this.getLinePos(lineNo + 1)
if (startOfNextLine < 0) return
var content = this.content
var lineContent = content.substring(startOfLine, startOfNextLine)
const trimmedLine = lineContent.trim()
if (
trimmedLine === '' ||
(this.isMarkDownFile() && /^(-|\*+|\#+)$/.test(trimmedLine))
) {
var start = content.substring(0, startOfLine)
var end = content.substring(startOfNextLine)
this.setContent(start + end)
}
}
deleteTaskContent (beforeContent, afterContent) {
const lang = this.getLang()
if (this.isCodeFile() && lang.block) {
const beforeContentTrim = beforeContent.trim()
const blockStartTrimPos = beforeContentTrim.lastIndexOf(lang.block.start)
if (
blockStartTrimPos > -1 &&
blockStartTrimPos === beforeContentTrim.length - lang.block.start.length
) {
const blockStartPos = beforeContent.lastIndexOf(lang.block.start)
const blockEndPos =
afterContent.indexOf(lang.block.end) + lang.block.end.length
beforeContent = beforeContent.substring(0, blockStartPos)
afterContent = afterContent.substring(blockEndPos)
}
}
this.setContent(beforeContent + afterContent)
}
deleteDescription (task, config) {
if (task.singleLineBlockComment) return
task.description = []
this.modifyDescription(task, config)
}
// TODO This isn't deleting all the newlines at the end of each line in code files
// <!--
// #bug
// #imdone-1.54.0
// order:-165
// -->
deleteCodeOrHashTask (re, task, config) {
var log = debug('delete-task:deleteCodeOrHashTask')
var self = this
var line = task.getLine()
re = new XRegExp(re)
re.lastIndex = this.getLinePos(line)
var result = re.exec(this.getContent())
if (result) {
log('Got result: %j', result)
var lang = self.getLang()
var text = result[task.type === Task.Types.HASHTAG ? 4 : 5]
var taskText = this.trimCommentBlockEnd(text)
if (this.tasksMatch(config, task, line, taskText)) {
var index = result.index
var afterStart = re.lastIndex
if (index < 0) index = 0
if (self.isCodeFile()) {
var commentStart = this.getLinePos(line) + task.taskStartOnLine
var commentPrefix = this.getContent().substring(commentStart, index)
var symbolRe = new RegExp(escapeRegExp(lang.symbol) + '\\s*')
if (symbolRe.test(commentPrefix)) index = commentStart
else if (lang && lang.block && lang.block.end) {
var blockEndRe = new RegExp(escapeRegExp(lang.block.end) + '\\s*$')
var match = blockEndRe.exec(task.rawTask)
if (match) afterStart -= match[0].length
}
}
var beforeContent = this.getContent().substring(0, index)
var afterContent = this.getContent().substring(afterStart)
this.deleteTaskContent(beforeContent, afterContent)
this.deleteBlankLine(line)
this.setModified(true)
// task.type = Task.Types.HASHTAG;
this.emit('task.deleted', task)
this.emit('file.modified', self)
}
}
return this
}
deleteTask (task, config) {
var self = this
if (this.isCodeFile()) {
this.deleteDescription(task, config)
if (task.type === Task.Types.CODE) {
this.deleteCodeOrHashTask(
new RegExp(CODE_STYLE_PATTERN, 'gm'),
task,
config
)
} else if (task.type === Task.Types.HASHTAG) {
this.deleteCodeOrHashTask(getHashStyleRegex(config.tokenPrefix), task, config)
}
} else {
const fileContent = this.getContentLines()
const newContent = [
...fileContent.slice(0, task.line - 1),
...fileContent.slice(task.lastLine),
]
this.setContent(newContent.join(lineEnd))
this.setModified(true)
this.emit('task.deleted', task)
this.emit('file.modified', self)
}
}
modifyTask (task, config, noEmit) {
task.orderModified = true
task.updateOrderMeta(config)
if (task.type === Task.Types.CODE) {
this.modifyCodeOrHashTask(
new RegExp(CODE_STYLE_PATTERN, 'gm'),
task,
config,
noEmit
)
} else {
task.updateContent()
const fileContent = this.getContentLines()
let text = getRawTask({
tokenPrefix: config.tokenPrefix,
orderMeta: config.orderMeta,
beforeText: task.beforeText,
hasColon: task.hasColon,
list: task.list,
order: task.order,
text: task.text,
type: task.type,
})
const linesBefore = fileContent.slice(0, task.line - 1)
if (this.isCodeFile()) {
const linesBeforeLength = linesBefore.join(lineEnd).length
const charsBefore = task.pos - (linesBeforeLength + 1)
if (charsBefore > 0) {
text = fileContent[task.line -1].substring(0,charsBefore) + text
}
}
const linesAfter = fileContent.slice(task.line)
const newContent = [
...linesBefore,
text,
...linesAfter,
].join(lineEnd)
const contentBeforeUpdate = this.getContent()
this.setContent(newContent)
this.modifyDescription(task, config)
if (contentBeforeUpdate.trim() == this.getContent().trim()) return
this.setModified(true)
if (!noEmit) {
this.emit('task.modified', task)
this.emit('file.modified', this)
}
}
}
modifyCodeOrHashTask (re, task, config, noEmit) {
log('In modifyCodeOrHashTask:%j', task)
log('--------------------------------')
log(`modifying task: ${task.rawTask}`)
log(`line: ${task.line}`)
var self = this
var line = task.getLine()
re = new RegExp(re)
var lang = this.getLang()
var linePos = (re.lastIndex = this.getLinePos(line))
var nextLinePos = this.getLinePos(line + 1)
var lineContent = this.getContent().substring(linePos, nextLinePos)
if (lineContent.indexOf(lang.symbol) > -1) {
re.lastIndex = this.getContent().indexOf(lang.symbol, linePos)
}
if (lang.block && lineContent.indexOf(lang.block.start) > -1) {
re.lastIndex = this.getContent().indexOf(lang.block.start, linePos)
}
var result
while ((result = re.exec(this.getContent())) !== null) {
log('Got result: %j', result)
var text = result[task.type === Task.Types.HASHTAG ? 3 : 5]
var taskText = this.trimCommentBlockEnd(text)
if (this.tasksMatch(config, task, line, taskText)) {
if (task.updatedText) task.text = task.updatedText
var index = result.index
const pos = this.isCodeFile() ? index : this.getLinePos(line)
if (index < 0) index = 0
var beforeContent = this.getContent().substring(0, pos)
var afterContent = this.getContent().substring(re.lastIndex)
if (task.inBlockComment) {
var blockEnd = text.indexOf(lang.block.end)
if (blockEnd > -1) {
const desc =
task.description.length > 0
? `${lineEnd}${task.description.join(lineEnd)}`
: ''
text = task.text + desc + text.substring(blockEnd)
} else text = task.text
} else text = task.text
if (/[a-z]/.test(task.list)) task.type = Task.Types.HASHTAG
const tokenPrefix = task.type === Task.Types.HASHTAG ? config.tokenPrefix : ''
var taskContent = getRawTask({tokenPrefix, orderMeta: config.orderMeta, list: task.list, order: task.order, text, type: task.type})
task.line = this.getLineNumber(index)
task.id = getTaskId(self.getPath(), task.line, task.text)
this.setContent(beforeContent + taskContent + afterContent)
if (!task.singleLineBlockComment) {
this.modifyDescription(task, config)
}
this.setModified(true)
// logger.log('modifyCodeOrHashTask setModified true for file:', this.path)
if (!noEmit) {
this.emit('task.modified', task)
this.emit('file.modified', self)
}
return this
}
}
return this
}
modifyTaskFromHtml (task, html) {
const checks = task.getChecksFromHtml(html)
const re = /- \[[\sx]{1}\]/g
const descStart = this.getLinePos(task.line + 1)
const descEnd = this.getLinePos(task.line + 1 + task.description.length)
let descContent =
descEnd > 0
? this.getContent().substring(descStart, descEnd)
: this.getContent().substring(descStart)
const beforeDescContent = this.getContent().substring(0, descStart)
const afterDescContent =
descEnd > 0 ? this.getContent().substring(descEnd) : ''
let i = 0
descContent = descContent.replace(re, (match) => {
const check = checks[i]
i++
if (check === undefined) return match
let char = check ? 'x' : ' '
return `- [${char}]`
})
this.setContent(beforeDescContent + descContent + afterDescContent)
this.setModified(true)
logger.log('modifyTaskFromHtml setModified true for file:', this.path)
}
modifyTaskFromContent (task, content, config) {
content = trimBlankLines(content, this.isCodeFile())
task.updateFromContent(content)
this.modifyTask(task, config)
}
modifyDescription (task, config) {
// TODO This should be split out by type (code, markdown, etc)
// #urgent
// <!-- order:-70 -->
const taskStart = this.getLinePos(task.line)
const descStart = this.getLinePos(task.line + 1)
const contentWithTask = this.getContent().substring(taskStart)
let {
rawTaskContentLines,
taskContentLines,
isWrappedWithCardTag,
trailingBlankLines
} =
this.getTaskContent({
config,
content: contentWithTask,
inBlockComment: task.inBlockComment,
beforeText: task.beforeText,
})
let beforeDescContent = this.getContent().substring(0, descStart)
if (descStart === this.getContent().length && !beforeDescContent.endsWith(lf))
beforeDescContent += lineEnd
let content = this.getContent().substring(descStart)
let spaces = ''
if (this.isCodeFile() && !this.isMarkDownFile()) {
spaces = contentWithTask.substring(0, task.commentStartOnLine)
spaces = spaces.replace(/\S/g, ' ')
}
const descriptionStartsWith = task.descriptionStartsWith
? `${task.descriptionStartsWith} `
: ''
let description = task.description.map(
(desc) => `${spaces}${descriptionStartsWith}${desc}`
)
// BACKLOG Allow this to pad for code files as well
// <!--
// order:-620
// -->
description = File.padDescription(description, task.beforeText).join(lineEnd)
if (taskContentLines.length === 0 && description.length > 0) {
beforeDescContent += description + lineEnd
} else {
let rawTaskContent = rawTaskContentLines.join(lineEnd)
// Add card terminator to description
if (
this.isMarkDownFile() &&
description.length > 0 &&
(hasBlankLines(description) || isWrappedWithCardTag || trailingBlankLines)
) {
task.isWrappedWithCardTag = isWrappedWithCardTag // FIXME Find out where this is used and stop using it
// <!-- order:-30 -->
const blankLinesToAdd = isWrappedWithCardTag ? 2 : trailingBlankLines
description = `${description}${lineEnd.repeat(blankLinesToAdd)}`
}
// Handle code file tasks
if (
task.singleLineBlockComment &&
rawTaskContent.length > 0 &&
description.length === 0
) {
description = lineEnd + description
}
content = content.replace(rawTaskContent, description)
}
task.lastLine = task.line + description.split(lineEnd).length
this.setContent(beforeDescContent + content)
}
extractTaskWithDescription ({
taskStartOnLine,
rawTask,
config,
list,
text,
order,
line,
type,
hasColon,
content,
inBlockComment,
singleLineBlockComment,
pos,
}) {
var self = this
const lang = this.getLang()
let description = []
// BACKLOG ## add beforeText for code files
// <!--
// order:-630
// -->
const beforeText = this.getBeforeText(line, pos)
let { rawTaskContentLines, taskContentLines, isWrappedWithCardTag } = this.getTaskContent({
config,
content,
inBlockComment,
beforeText,
})
if (!singleLineBlockComment) {
description = this.isCodeFile()
? taskContentLines.map((line) => this.trimCommentChars(line))
: structuredClone(taskContentLines)
}
description = Task.trimDescription(description, beforeText)
const descriptionStartsWith = this.getDescriptionChars(inBlockComment)
const frontMatter = this.frontMatter
text = this.trimCommentBlockEnd(text)
let commentStartOnLine =
content.search(/\w/) - (descriptionStartsWith.length + 1)
if (descriptionStartsWith === lang.symbol)
commentStartOnLine = taskStartOnLine
var task = newCard(
{
pos,
frontMatter,
inBlockComment,
singleLineBlockComment,
rawTask,
text,
list,
rawTaskContentLines,
description,
descriptionStartsWith,
taskStartOnLine,
commentStartOnLine,
hasColon,
order: self.projectContext.getOrder(list, order),
line,
id: getTaskId(self.getPath(), line, text),
repoId: self.getRepoId(),
source: self.getSource(),
type,
beforeText,
isWrappedWithCardTag,
},
this.project
)
task.init()
if (!config.keepEmptyPriority) task.updateOrderMeta(config, task.format(task.descriptionString))
task.orderModified = task.order + "" !== order + "" && !bothAreUndefNull(task.order, order)
return task
}
tasksMatch (config, task, line, taskText) {
return (
task.id == getTaskId(this.getPath(), line, taskText) ||
(task.meta.id &&
Task.getMetaData(config, taskText).id &&
task.meta.id[0] === Task.getMetaData(config, taskText).id[0])
)
}
getTaskAtLine (line) {
return this.getTasks().find((task) => {
return line >= task.line && line <= task.lastLine
})
}
getLinePos (lineNo) {
return Task.getLinePos(this.content, lineNo)
}
getLineNumber (pos, content = this.content) {
return Task.getLineNumber(content, pos)
}
toJSON () {
return omit(this, ['domain', '_events', '_maxListeners'])
}
getRepoId () {
return this.repoId
}
getPath () {
return this.path
}
getFullPath () {
return path.join(this.repoId, this.path)
}
getId () {
return this.getPath()
}
reset () {
this.previousContent = this.content
this.content = null
this.modified = false
return this
}
rollback () {
this.content = this.previousContent
return this
}
setContent (content) {
this.content = content
return this
}
setContentFromFile (content) {
this.content = eol.auto(content || '')
return this
}
getContent () {
return this.content
}
getContentForFile () {
return eol.auto(this.content || '')
}
getContentLines () {
return eol.split(this.getContentForFile())
}
setModifiedTime (modifiedTime) {
this.modifiedTime = modifiedTime
return this
}
setCreatedTime (createdTime) {
this.createdTime = createdTime
return this
}
getCreatedTime () {
return this.createdTime
}
getModifiedTime () {
return this.modifiedTime
}
setModified (modified) {
this.modified = modified
return this
}
isModified () {
return this.modified
}
getType () {
return this.constructor.name
}
getTasks () {
return this.tasks
}
getTask (id) {
return (
this.getTasks().find((task) => id === task.id) ||
this.getTasks().find(
(task) => task.meta && task.meta.id && task.meta.id[0] === id.toString()
)
)
}
addTask (task) {
if (!(task instanceof Task)) throw new Error(ERRORS.NOT_A_TASK)
if (!Array.isArray(this.tasks)) this.tasks = []
var index = this.tasks.findIndex(({ id }) => task.id === id)
log(
'Adding task with text:%s in list:%s with order:%d at index %d',
task.text,
task.list,
task.order,
index
)
if (index > -1) {
this.tasks[index] = task
} else {
this.tasks.push(task)
}
return this
}
removeTask (task) {
if (!(task instanceof Task)) throw new Error(ERRORS.NOT_A_TASK)
if (!Array.isArray(this.tasks)) this.tasks = []
var index = this.tasks.findIndex(({ id }) => task.id === id)
if (index > -1) {
this.tasks.splice(index, 1)
}
}
isMarkDownFile () {
return /^md|markdown$/i.test(this.getExt())
}
getLang () {
var lang = this.languages[path.extname(this.path)]
return lang || { name: 'text', symbol: '' }
}
getExt () {
return path.extname(this.path).substring(1).toLowerCase()
}
isCodeFile () {
var symbol = this.getLang().symbol
return symbol && symbol !== ''
}
getBeforeText (line, pos) {
return this.isCodeFile()
? null
: this.content.substring(this.getLinePos(line), pos)
}
getDescriptionChars (inBlockComment) {
if (!this.isCodeFile()) return ''
const lang = this.getLang()
if (inBlockComment && lang.block && lang.block.ignore) return lang.block.ignore
return lang.symbol
}
getTaskContent ({
config,
content,
inBlockComment,
beforeText,
}) {
const isMarkDownFile = this.isMarkDownFile()
const isCodeFile = this.isCodeFile()
const lang = this.getLang()
const fileType = isMarkDownFile ? FILE_TYPES.MARKDOWN : isCodeFile ? FILE_TYPES.CODE : undefined
return getTaskContent({
config,
content,
inBlockComment,
beforeText,
lang,
fileType,
})
}
isCheckBoxTask (config, line, beforeText) {
if (!config.isAddCheckBoxTasks()) return
const beforeTextCheckData = Task.getCheckedData(beforeText)
if (!beforeTextCheckData) return
const lineCheckData = Task.getCheckedData(line)
if (!lineCheckData) return
return lineCheckData.pad <= beforeTextCheckData.pad
}
getCodeCommentRegex () {
// #BACKLOG:-460 Allow languages to have multiple block comment styles, like html gh:13 id:5
var lang = this.getLang()
var symbol = lang.symbol
var reString = '(?<!["\'`])' + escapeRegExp(symbol) + '[^{].*$'
if (lang.block) {
var start = escapeRegExp(lang.block.start)
var end = escapeRegExp(lang.block.end)
//
reString = reString + '|(?<!["\'`])' + start + '(.|[\\r\\n])*?' + end
}
return new RegExp(reString, 'gmi')
}
trimCommentStart (text) {
if (this.isCodeFile() && this.getLang().symbol) {
var start = escapeRegExp(this.getLang().symbol)
var startPattern = `^\\s*${start}\\s?`
return text.replace(new RegExp(startPattern), '')
}
return text
}
trimCommentBlockEnd (text) {
if (this.isCodeFile() && this.getLang().block) {
var blockEnd = escapeRegExp(this.getLang().block.end)
var endPattern = `\\s?${blockEnd}.*$`
return text.replace(new RegExp(endPattern), '')
}
return text
}
trimCommentBlockStart (text) {
if (this.isCodeFile() && this.getLang().block) {
var blockStart = escapeRegExp(this.getLang().block.start)
var startPattern = `^\\s*${blockStart}\\s?`
return text.replace(new RegExp(startPattern), '')
}
return text
}
trimCommentBlockIgnore (text) {
if (
this.isCodeFile() &&
this.getLang().block &&
this.getLang().block.ignore
) {
var blockIgnore = escapeRegExp(this.getLang().block.ignore)
var ignorePattern = `^\\s*${blockIgnore}\\s?`
return text.replace(new RegExp(ignorePattern), '')
}
return text
}
trimCommentChars (text) {
let newText = this.trimCommentStart(text)
if (text === newText) newText = this.trimCommentBlockEnd(text)
if (text === newText) newText = this.trimCommentBlockStart(text)
if (text === newText) newText = this.trimCommentBlockIgnore(text)
return newText
}
hasCodeStyleTask (config, text) {
if (!this.isCodeFile()) return false
let result = new RegExp(CODE_STYLE_PATTERN).exec(text)
if (!result) return false
return config.includeList(result[1])
}
hasTaskInText (config, text) {
return hasTaskInText(config, text, this.isCodeFile())
}
isInBlockComment (content) {
if (!this.isCodeFile()) return false
const lang = this.getLang()
return (
lang.block &&
lang.block.start &&
content.trim().startsWith(lang.block.start)
)
}
startsWithCommentOrSpace (pos) {
var lang = this.getLang()
var symbol = lang.symbol
var blockStart = lang.block && lang.block.start
if (symbol === this.getContent().substring(pos - symbol.length, pos))
return true
if (
blockStart &&
blockStart === this.getContent().substring(pos - blockStart.length, pos)
)
return true
if (this.getContent().substring(pos - 1, pos) === ' ') return true
return false
}
isSingleLineBlockComment (content) {
return eol.split(content).length === 1
}
parseFrontMatter (config) {
this.frontMatter = {
tags: [],
context: [],
meta: {},
}
if (config.ignoreFrontMatter) return {}
try {
const { data, isEmpty } = matter(this.getContent())
if (!isEmpty) {
this.frontMatter = { props: {}, ...data, ...this.frontMatter }
if (data.meta) this.frontMatter.meta = getFrontMeta(data.meta)
if (data.context) {
this.frontMatter.context = Array.isArray(data.context)
? data.context.map((context) => context.trim())
: data.context
.toString()
.split(',')
.map((context) => context.trim())
}
if (data.tags && !config.ignoreFrontMatterTags) {
this.frontMatter.tags = Array.isArray(data.tags)
? data.tags.map((tag) => tag.trim())
: data.tags
.toString()
.split(',')
.map((tag) => tag.trim())
}
return data
}
} catch (err) {
logger.error(`Error processing front-matter in:${this.path}`, err)
}
return {}
}
getSource () {
var self = this
return {
path: self.getPath(),
id: self.getId(),
repoId: self.getRepoId(),
type: self.getType(),
ext: self.getExt(),
lang: self.getLang().name,
modified: self.isModified(),
modifiedTime: self.getModifiedTime(),
createdTime: self.getCreatedTime(),
}
}
isWithinCodeSpanOrBlock(pos, content) {
const positions = Task.getMarkdownCodePositions(content)
return Task.isResultInMarkdownCode(positions, pos)
}
getMarkdownCodePositions (text) {
const positions = [];
const codeBlockRe = /```[\s\S]*?```/g;
const inlineCodeRe = /`[^`]*`/g;
let match;
// Find code blocks
while ((match = codeBlockRe.exec(text)) !== null) {
positions.push({ start: match.index, end: match.index + match[0].length });
}
// Find inline code spans
while ((match = inlineCodeRe.exec(text)) !== null) {
positions.push({ start: match.index, end: match.index + match[0].length });
}
return positions;
};
isResultInMarkdownCode (positions, index) {
return positions.some(pos => index >= pos.start && index < pos.end);
};
}