imdone-core
Version:
892 lines (770 loc) • 24.8 kB
JavaScript
import { Project } from 'imdone-api/lib/project.js';
import { PluginManager } from './plugins/plugin-manager.js';
import { Repository } from './repository.js';
import { newCard } from './card.js';
import { constants } from './constants.js';
const { JOURNAL_TYPE, CONFIG_DIR } = constants;
import _path from 'node:path';
import moment from 'moment';
import { getIsoDateWithOffset } from './adapters/date-time.js';
import * as fileGateway from './adapters/file-gateway.js';
import matter from 'gray-matter';
import _isObject from 'lodash.isobject';
import _isString from 'lodash.isstring';
import _isFunction from 'lodash.isfunction';
import { exec } from 'node:child_process';
import { sort } from 'fast-sort';
import { cloneDeep } from './clone.js'
import { Task } from './task.js';
import eol from 'eol';
import { renderMarkdown, extractWikilinkTopics } from './adapters/markdown.js';
import { getFunctionSignature } from './adapters/parsers/function-parser.js';
import { format } from './adapters/parsers/content-transformer.js';
import { logger } from './adapters/logger.js';
function calculateTotals(lists = []) {
const totals = {}
lists.forEach((list) => {
try {
totals[list.name] = list.tasks.length
} catch (e) {
logger.warn('Error calculating list totals:', e)
}
})
return totals
}
function onChange(project, event, data) {
project.emit(event, data)
}
const EVENTS = [
'file.processed',
'files.found',
'file.update',
'file.saved',
'list.modified',
'config.update',
]
export class WorkerProject extends Project {
constructor(repo) {
super()
this.repo = repo
this.innerFilter = ''
this.fileGateway = fileGateway
this._updatedAt = undefined
this.data = {}
this.dataKeys = []
this.repo.project = this
this.pluginManager = new PluginManager(this)
}
get configDir() {
return _path.join(this.path, CONFIG_DIR)
}
get allTopics() {
return [...this.repo.allTopics]
}
get allTags() {
return [...this.repo.allTags]
}
get allContexts() {
return [...this.repo.allContexts]
}
get allMeta() {
return this.repo.allMeta
}
get lists() {
return this.getLists({
tasks: this.filteredCards,
})
}
get allLists() {
return this.getLists()
}
get filteredCards() {
return this.getCards(this.filter)
}
get isoDateWithOffset() {
return getIsoDateWithOffset()
}
get updatedAt() {
return this._updatedAt
}
get files() {
return this.repo.getFilePaths()
}
get config() {
return this.repo.config
}
get defaultFilter() {
return this.config && this.config.defaultFilter
}
set defaultFilter(filter) {
this.config.defaultFilter = filter
}
get totals() {
return calculateTotals(this.lists)
}
get path() {
return this.repo.path
}
get name() {
return this.repo.getDisplayName()
}
get doneList() {
return this.config.getDoneList()
}
get boardActions() {
return [
...this.pluginManager.getBoardActions(),
]
}
get filter() {
return this.innerFilter
}
set filter(filter) {
this.innerFilter = filter
}
async init() {
this.pluginManager.on('plugin-installed', () => this.emitUpdate())
this.pluginManager.on('plugin-uninstalled', () => this.emitUpdate())
this.pluginManager.on('plugins-reloaded', () => this.emitUpdate())
await this.pluginManager.loadPlugins()
this.data = await this.pluginManager.getBoardProperties()
this.dataKeys = this.getDataKeys(this.data)
logger.log('data', this.data)
logger.log('dataKeys', this.dataKeys)
EVENTS.forEach((event) => {
this.repo.on(event, (data) => onChange(this, event, data))
})
this.repo.on('task.found', (task) => this.pluginManager.onTaskFound(task))
const files = await this.repo.init()
try {
await this.pluginManager.startDevMode()
} catch (err) {
logger.log('Error on starting dev mode', err)
throw new Error('Error on starting dev mode', { cause: err })
}
await this.toImdoneJSON()
return files
}
initIndexes(allLists) {
allLists.forEach((list) => {
list.tasks.filter(list => !list.filter).forEach((task, index) => {
task.index = index
})
})
}
// HACK To handle circular dependency issue with file
// <!--
// order:0
// -->
toJSON() {
return { path: this.path }
}
getListsForImdoneJSON() {
this.initIndexes(this.repo.getTasksByList())
let allLists = this.getLists({
populateFiltered: true,
})
const totals = calculateTotals(allLists)
allLists = allLists.map((list) => {
list = { ...list }
list.tasks = list.tasks.map((card, index) => {
card.interpretedContent = ''
card.totals = totals
return card
})
return list
})
return { allLists, totals }
}
getInitializedCards(allLists, totals) {
return allLists
.map((list) => list.tasks)
.flat()
.map(
(card) => card.init(totals)
)
}
async toImdoneJSON() {
this.pluginManager.initDevMode()
logger.time('toJSON time')
this._updatedAt = new Date()
const { allLists , totals } = this.getListsForImdoneJSON()
logger.time('plugin onBeforeBoardUpdate time')
await this.pluginManager.onBeforeBoardUpdate()
logger.timeEnd('plugin onBeforeBoardUpdate time')
logger.time('getBoardProperties time')
const data = this.data = await this.pluginManager.getBoardProperties()
logger.timeEnd('getBoardProperties time')
logger.time('getDataKeys time')
const dataKeys = this.dataKays = this.getDataKeys(data)
logger.timeEnd('getDataKeys time')
logger.time('card init time')
const cards = this.getInitializedCards(allLists, totals)
logger.timeEnd('card init time')
logger.time('plugin onBoardUpdate time')
await this.pluginManager.onBoardUpdate(allLists)
logger.timeEnd('plugin onBoardUpdate time')
logger.time('getTags time')
const tags = this.getTags(cards)
logger.timeEnd('getTags time')
logger.time('getLists time')
const lists = this.filter
? this.getRequestedLists(cards)
: allLists
logger.timeEnd('getLists time')
logger.timeEnd('toJSON time')
return {
path: this.path,
config: this.config,
lists: lists,
files: this.files,
totals,
totalCards: this.repo.getTasks().length,
tags,
allMeta: this.allMeta,
allContexts: this.allContexts,
allTags: this.allTags,
filter: this.filter,
defaultFilter: this.defaultFilter,
actions: this.boardActions,
plugins: this.pluginManager.getPlugins(),
data,
dataKeys,
queryProps: this.getQueryProps(cards),
}
}
getRequestedLists(cards) {
return this.getLists({
tasks: this.getCards(this.filter, cards),
populateFiltered: true,
}).map(list => {
const configList = this.config.lists.find(l => l.name === list.name)
list.hidden = configList && configList.hidden
if (this.hideLists.includes(list.name)) {
list.hidden = true
}
return list
})
}
emit() {} // This is implemented in imdone UI worker
emitUpdate() {
this.emit('file.update')
}
async destroy() {
await this.repo.destroy()
if (this.pluginManager) this.pluginManager.destroyPlugins()
}
getDataKeys(data) {
const keys = Object.keys(data).map((key) => {
const value = data[key]
if (_isFunction(value)) {
key = getFunctionSignature(value)
}
return key
})
keys.forEach((key) => {
const value = data[key]
if (_isObject(value) && !Array.isArray(value)) {
keys.push(...Object.keys(value).map((k) => `${key}.${k}`))
}
})
return keys
}
removeList(list) {
this.repo.removeList(list)
}
getLists(opts) {
const {tasks = this.getDefaultFilteredCards(), populateFiltered = false} = opts || {}
return Repository.getTasksByList(this.repo, tasks, true, populateFiltered)
}
getTaskQueryProps(props) {
const queryProps = []
if (props) {
// add all properties of the task to the queryProps array
Object.keys(props).forEach((key) => {
if (key === 'data') return
if (queryProps.includes(key)) return
queryProps.push(key)
if (_isObject(props[key])) {
Object.keys(props[key]).forEach((subKey) => {
if (!queryProps.includes(`${key}.${subKey}`))
queryProps.push(`${key}.${subKey}`)
})
}
})
}
return queryProps
}
getQueryProps(cards) {
if (!cards) return []
const queryProps = this.getTaskQueryProps(cards[0])
return [
...new Set(queryProps),
...[...this.repo.metaKeys].map(key => `allMeta.${key}`),
...[...this.repo.metaKeys].map(key => `meta.${key}`),
...this.allTopics,
...this.allTags,
...this.allContexts,
]
}
getTags(cards = this.getDefaultFilteredCards()) {
const tags = []
cards.forEach((card) => {
card.allTags.forEach((tag) => {
let foundTag = tags.find(({name}) => tag === name)
if (!foundTag) {
foundTag = {name: tag, count: 1}
tags.push(foundTag)
}
foundTag.count++
})
})
return sort(tags).by([
{desc: (tag) => tag.count},
{asc: (tag) => tag.name},
])
}
getDefaultFilteredCards() {
return this.filterCards(this.repo.getTasks(), this.defaultFilter)
}
getAllCards(filter) {
const allTasks = this.repo.getTasks()
return this.getCards(filter, allTasks)
}
getCards(filter, cards = this.getDefaultFilteredCards()) {
cards = filter ? this.filterCards(cards, filter) : cards
return cards
}
filterCards(cards, filter) {
this.hideLists = []
const {
result,
hideLists
} = Repository.filterCards(cards, filter)
this.hideLists = hideLists
return result
}
async addMetadata(task, key, value) {
if (task.hasMetaData(key, value)) return
if (!task.allMeta[key]) task.allMeta[key] = []
task.allMeta[key].push(value)
if (!/^['"]/.test(value) && /\s/.test(value)) value = `"${value}"`
const metaData = `${key}${this.config.getMetaSep()}${value}`
const content = task.addToLastCommentInContent(
task.content,
metaData,
this.config.isMetaNewLine()
)
return await this.updateCardContent(task, content)
}
async removeMetadata(task, key, value) {
if (!task.meta[key]) return
const file = this.getFileForTask(task)
const content = file.removeMetaData(task.content, key, value)
return await this.updateCardContent(task,content)
}
async addTag(task, tag) {
if (task.tags.includes(tag)) return
task.allTags.push(tag)
const tagContent = `${this.config.getTagPrefix()}${tag}`
const content = task.addToLastCommentInContent(
task.content,
tagContent,
this.config.isMetaNewLine()
)
return await this.updateCardContent(task, content)
}
async removeTag(task, tag) {
if (!task.tags.includes(tag)) return
const tagContent = new RegExp(
`\\${this.config.getTagPrefix()}${tag}\\s`,
'g'
)
logger.log('removeTag regex:', tagContent)
const content = task.content.replace(tagContent, '')
return await this.updateCardContent(task, content)
}
async moveTask(task, newList, newPos) {
return await this.repo.moveTask({task, newList, newPos})
}
getFile(filePath) {
return this.repo.getFile(filePath)
}
getFileForTask(task) {
return this.repo.getFileForTask(task)
}
rollBackFileForTask(task) {
return this.getFileForTask(task).rollback().extractTasks(this.config)
}
async updateCardContent(task, content) {
const file = await this.repo.modifyTaskFromContent(task, content)
this.emitUpdate()
return file
}
async snackBar({ message, type, duration }) {
this.emit('project.snackBar', { message, type, duration })
}
async toast({ message, type, duration }) {
this.emit('project.toast', { message, type, duration })
}
filterLists(filter, lists = this.lists) {
return lists.map((list) => {
let newList = { ...list, tasks: [] }
newList = cloneDeep(newList)
newList.tasks = Repository.query(list.tasks, filter)
return newList
})
}
async copyToClipboard(text, message) {this.files
this.emit('project.copyToClipboard', { text, message })
}
async openUrl(url) {
this.emit('project.openUrl', url)
}
// TODO For methods that emit, they should still do the thing without imdone UI
// <!--
// #urgent
// #important
// order:-195
// -->
async openPath(path) {
this.emit('project.openPath', path)
}
saveFile(content, file) {
const filePath = this.getFullPath(file)
this.emit('project.saveFile', { file: filePath, content })
}
/*
* @param {Object} opts - The options object
* @param {String} opts.list - The list name to add the card to
* @param {String} opts.path - The path to the file to add the card to
* @param {String} opts.template - The template to use for the new card
* @param {String} opts.title - The title of the new card
* @param {String} opts.comments - The comments to add to the new card
* @param {Boolean} opts.emit - Whether to emit the new card event
*/
async newCard({ list, path, template, title, comments, emit = true }) {
if (!path || !_path.parse(path).ext) path = this.getNewCardsFile({title})
path = this.getFullPath(path)
const { isFile, isDirectory } = await fileGateway.preparePathForWriting(path)
if (!template) template = await this.getNewCardTemplate(path, isFile)
const boardData = await this.pluginManager.getBoardProperties();
template = format(template, boardData)
if (comments) template = Task.addToLastCommentInContent(template, comments, this.config.isMetaNewLine())
let relativePath = _path.relative(this.path, path)
if (isDirectory) relativePath += _path.sep
const data = {
list,
path,
relativePath,
template,
isDirectory,
}
if (emit) this.emit('project.newCard', data)
return data
}
async addCardToFile(opts) {
return await this.addTaskToFile(opts)
}
async addTaskToFile({path, list = this.config?.getDefaultList(), content, tags = [], contexts = [], meta = [], useCardTemplate = false}) {
const pluginMods = await this.pluginManager.onBeforeAddTask({path, list, meta, tags, contexts, content, useCardTemplate})
path = pluginMods.path
content = pluginMods.content
meta = pluginMods.meta
tags = pluginMods.tags
contexts = pluginMods.contexts
const cardData = await this.newCard({list, path, title: eol.split(content)[0], emit: false})
const filePath = cardData.path
if (useCardTemplate) content += cardData.template
const boardData = await this.pluginManager.getBoardProperties();
const data = { ...boardData, ...cardData}
content = format(content, data)
content = this.addTagsToContent(tags, content)
content = this.addContextsToContent(contexts, content)
content = this.addMetaToContent(meta, content)
return await this.repo.addTaskToFile(filePath, list, content)
}
addMetaToContent(meta, content) {
if (!meta) return content;
// Handle both array format and object format
let metaArray = [];
if (Array.isArray(meta)) {
// Current format: [{key, value}, ...]
metaArray = meta;
} else if (typeof meta === 'object') {
// New format: {key1: [values], key2: [values], ...}
metaArray = [];
Object.entries(meta).forEach(([key, values]) => {
if (Array.isArray(values)) {
values.forEach(value => {
metaArray.push({ key, value });
});
} else {
// Single value case
metaArray.push({ key, value: values });
}
});
}
if (metaArray.length === 0) return content;
let metaContent = '';
metaArray.forEach(({ key, value }) => {
if (!/^['"]/.test(value) && /\s/.test(value)) value = `"${value}"`;
const metaData = `${key}${this.config.getMetaSep()}${value}`;
if (content.includes(metaData)) return;
const existingMetadata = Task.parseMetaData(this.config, content)[key];
if (existingMetadata) {
content = Task.removeMetaData({
config: this.config,
content,
key,
value: existingMetadata[0],
});
}
const spaceOrNewLine = this.config.isMetaNewLine() ? '\n' : ' ';
metaContent = `${metaContent}${spaceOrNewLine}${metaData}`;
});
content = Task.addToLastCommentInContent(content, metaContent.trim(), this.config.isMetaNewLine())
return content
}
addContextsToContent(contexts, content) {
if (contexts && contexts.length > 0) {
let contextContent = ''
contexts.forEach(context => {
const contextWithPrefix = `@${context}`
if (content.includes(contextWithPrefix)) return
contextContent = `${contextContent} @${context}`
})
content = Task.addToLastCommentInContent(content, contextContent.trim(), this.config.isMetaNewLine())
}
return content
}
addTagsToContent(tags, content) {
if (tags && tags.length > 0) {
let tagContent = ''
tags.forEach(tag => {
const tagWithPrefix = `${this.config.getTagPrefix()}${tag}`
if (content.includes(tagWithPrefix)) return
tagContent = `${tagContent} ${tagWithPrefix}`
})
content = Task.addToLastCommentInContent(content, tagContent.trim(), this.config.isMetaNewLine())
}
return content
}
async deleteTask(task) {
await this.repo.deleteTask(task)
await this.pluginManager.onAfterDeleteTask(task)
}
async deleteTasks(tasks) {
await this.repo.deleteTasks(tasks)
}
setFilter(filter) {
this.emit('project.filter', { filter })
}
async getNewCardTemplate(file, isFile) {
const frontMatter = await this.getNewCardFileFrontMatter(file, isFile)
const card = newCard(
{
frontMatter,
repoId: this.path,
text: '',
source: { path: this.getNewCardsFile() },
},
this,
true
)
card.init(this.totals)
return card.formatContent(frontMatter.template).content
}
async getNewCardFileFrontMatter(file, isFile) {
let fileContent = ''
if (file && isFile) {
fileContent = await fileGateway.readFile(file, 'utf8')
} else if (JOURNAL_TYPE.NEW_FILE === this.config.journlType) {
fileContent = this.config.journalTemplate
}
let { props, computed, template } = this.config.settings.cards
const frontMatter = matter(fileContent).data || {}
if (!_isObject(props)) props = {}
if (!_isObject(computed)) computed = {}
if (!_isString(template)) template = ''
if (!_isObject(frontMatter.props)) frontMatter.props = {}
if (!_isObject(frontMatter.computed)) frontMatter.computed = {}
if (!_isString(frontMatter.template)) frontMatter.template = template
props = {
...props,
...frontMatter.props,
now: new Date().toDateString(),
totals: this.totals,
}
computed = { ...computed, ...frontMatter.computed }
return {
...frontMatter,
props,
computed,
template: frontMatter.template,
}
}
getNewCardsFile(opts = { relPath: false }) {
const { relPath, title } = opts
if (!this.config) return ''
const filePath = this.appendNewCardsTo(title)
if (!filePath) return ''
return filePath
}
appendNewCardsTo(title) {
const journalType = this.config.journalType
if (journalType === JOURNAL_TYPE.SINGLE_FILE)
return this.getFullPath(this.config.appendNewCardsTo)
if (journalType === JOURNAL_TYPE.FOLDER)
return this.getJournalFile().fullFilePath
if (journalType === JOURNAL_TYPE.NEW_FILE) {
if (!title) return this.getFullPath(this.config.journalPath)
const fileName = `${this.sanitizeFileName(title)}.md`
const fileFolder = this.getFullPath(this.config.journalPath)
const filePath = _path.join(fileFolder, fileName)
return filePath
}
}
sanitizeFileName(fileName) {
return fileGateway.sanitizeFileName(fileName, this.config.replaceSpacesWith)
}
getJournalFile() {
const month = moment().format('YYYY-MM')
const today = moment().format('YYYY-MM-DD')
const journalPath = this.config.journalPath
const folderPath = _path.join(journalPath, month)
const journalFilePrefix = this.config.journalFilePrefix
const journalFileSuffix = this.config.journalFileSuffix
const filePath = _path.join(
folderPath,
`${journalFilePrefix}${today}${journalFileSuffix}.md`
)
const fullFilePath = this.getFullPath(filePath)
return { filePath, fullFilePath }
}
getFullPath(...path) {
if (_path.join(...path).startsWith(this.path)) {
return _path.join(...path)
}
return _path.join.apply({}, [this.path, ...path])
}
performCardAction(action, task) {
task = this.repo.getTask(task.id)
try {
action = JSON.parse(action)
} catch (e) {
//
}
if (action.plugin) return this.pluginManager.performCardAction(action, task)
const actionFunction = task.getCardActions()[action.index].action
// TODO These card actions should be removed
// #imdone-1.54.0
// <!--
// order:-175
// -->
const actions = {
filter: (filter) => {
this.setFilter(filter)
},
newCard: async (list, path) => {
if (task.source.lang !== 'text')
return this.alert('Unable to append cards in code files.')
if (path) path = this.getFullPath(path)
await this.newCard({ list, path })
},
alert: (msg) => {
this.toast({ message: msg })
},
openUrl: (url) => {
this.openUrl(url)
},
execCommand: (cmd) => {
return this.exec(cmd)
},
copy: (content, msg) => {
this.copyToClipboard(content, msg)
},
}
const actionThis = {
...task.data,
...task.desc,
actions,
}
try {
logger.log('actionFunction:', actionFunction)
const func = new Function(`return ${actionFunction}`)()
func.apply(actionThis)
} catch (e) {
logger.warn(e)
logger.log('action:', actionFunction)
logger.log('this:', actionThis)
}
}
async performBoardAction(action, task) {
if (task) task = this.repo.getTask(task.id)
if (action && action.plugin)
return await this.pluginManager.performBoardAction(action, task)
const actions = {
filter: (filter) => {
this.setFilter(filter)
},
alert: (msg) => {
this.toast({ message: msg })
},
saveFile: ({ file, content }) => {
this.saveFile(content, file)
},
mailto: ({ subject, body, to, cc, bcc }) => {
const params = []
if (subject) params.push(`subject=${encodeURIComponent(subject)}`)
if (body) params.push(`body=${encodeURIComponent(body)}`)
if (cc) params.push(`cc=${encodeURIComponent(cc)}`)
if (bcc) params.push(`bcc=${encodeURIComponent(bcc)}`)
const url = `mailto:${to}?${params.join('&')}`
logger.log('opening email with:', url)
this.openUrl(url)
},
copy: (content, message) => {
this.copyToClipboard(content, message || 'Your content has been copied')
},
updateCard: (task, content) => {
this.updateCardContent(task, content)
},
}
const actionFunction = this.boardActions[action.index].action
const actionThis = { cards: this.lists, ...actions }
try {
actionFunction.apply(actionThis)
} catch (err) {
logger.warn(err)
logger.log('action:', actionFunction)
logger.log('this:', actionThis)
}
}
exec(cmd) {
return new Promise((resolve, reject) => {
exec(cmd, (error, stdout, stderr) => {
if (stderr) logger.warn('cmd stderr:', stderr)
if (error) return reject(error)
resolve(stdout)
})
})
}
installPlugin({ name, version }) {
return this.pluginManager.installPlugin({ name, version })
}
uninstallPlugin(name) {
return this.pluginManager.uninstallPlugin(name)
}
async refresh() {
await this.repo.refresh()
await this.pluginManager.reloadPlugins()
return await this.toImdoneJSON()
}
renderMarkdown(content, filePath) {
return renderMarkdown(content, filePath || this.path)
}
extractWikilinkTopics(markdown) {
return extractWikilinkTopics(markdown)
}
}