mdbase-tasknotes
Advanced tools
| // src/collection.ts | ||
| import { Collection } from "@callumalpass/mdbase"; | ||
| import { basename as basename2 } from "path"; | ||
| // src/config.ts | ||
| import * as fs from "fs"; | ||
| import * as path from "path"; | ||
| import * as os from "os"; | ||
| var CONFIG_DIR = path.join( | ||
| os.homedir(), | ||
| ".config", | ||
| "mdbase-tasknotes" | ||
| ); | ||
| var CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); | ||
| var DEFAULT_CONFIG = { | ||
| collectionPath: null, | ||
| language: "en" | ||
| }; | ||
| function load() { | ||
| try { | ||
| const raw = fs.readFileSync(CONFIG_FILE, "utf-8"); | ||
| return { ...DEFAULT_CONFIG, ...JSON.parse(raw) }; | ||
| } catch { | ||
| return { ...DEFAULT_CONFIG }; | ||
| } | ||
| } | ||
| function resolveUserPath(userPath) { | ||
| return path.resolve(expandHomeDirectory(userPath)); | ||
| } | ||
| function resolveCollectionPath(flagPath) { | ||
| if (flagPath) return resolveUserPath(flagPath); | ||
| const envPath = process.env.MDBASE_TASKNOTES_PATH; | ||
| if (envPath) return resolveUserPath(envPath); | ||
| const config = load(); | ||
| if (config.collectionPath) return resolveUserPath(config.collectionPath); | ||
| return process.cwd(); | ||
| } | ||
| function expandHomeDirectory(userPath) { | ||
| if (userPath === "~") { | ||
| return os.homedir(); | ||
| } | ||
| if (userPath.startsWith("~/") || userPath.startsWith("~\\")) { | ||
| return path.join(os.homedir(), userPath.slice(2)); | ||
| } | ||
| return userPath; | ||
| } | ||
| // src/field-mapping.ts | ||
| import { loadConfig, getType } from "@callumalpass/mdbase"; | ||
| import { basename } from "path"; | ||
| var ALL_ROLES = [ | ||
| "title", | ||
| "status", | ||
| "priority", | ||
| "due", | ||
| "scheduled", | ||
| "completedDate", | ||
| "tags", | ||
| "contexts", | ||
| "projects", | ||
| "timeEstimate", | ||
| "dateCreated", | ||
| "dateModified", | ||
| "recurrence", | ||
| "recurrenceAnchor", | ||
| "completeInstances", | ||
| "skippedInstances", | ||
| "timeEntries" | ||
| ]; | ||
| function defaultFieldMapping() { | ||
| const roleToField = {}; | ||
| const fieldToRole = {}; | ||
| for (const role of ALL_ROLES) { | ||
| roleToField[role] = role; | ||
| fieldToRole[role] = role; | ||
| } | ||
| return { | ||
| roleToField, | ||
| fieldToRole, | ||
| displayNameKey: "title", | ||
| completedStatuses: ["done", "cancelled"] | ||
| }; | ||
| } | ||
| function buildFieldMapping(fields, displayNameKey) { | ||
| const roleToField = {}; | ||
| const fieldToRole = {}; | ||
| const rolesSet = new Set(ALL_ROLES); | ||
| for (const [fieldName, def] of Object.entries(fields)) { | ||
| if (def && typeof def === "object" && typeof def.tn_role === "string") { | ||
| const role = def.tn_role; | ||
| if (!rolesSet.has(role)) continue; | ||
| if (roleToField[role] !== void 0) { | ||
| console.warn(`[mtn] Duplicate tn_role "${role}" on field "${fieldName}", ignoring.`); | ||
| continue; | ||
| } | ||
| roleToField[role] = fieldName; | ||
| fieldToRole[fieldName] = role; | ||
| } | ||
| } | ||
| for (const role of ALL_ROLES) { | ||
| if (roleToField[role] === void 0) { | ||
| if (fields[role] !== void 0) { | ||
| roleToField[role] = role; | ||
| if (fieldToRole[role] === void 0) { | ||
| fieldToRole[role] = role; | ||
| } | ||
| } else { | ||
| roleToField[role] = role; | ||
| } | ||
| } | ||
| } | ||
| const completedStatuses = inferCompletedStatuses(fields, roleToField.status); | ||
| return { | ||
| roleToField, | ||
| fieldToRole, | ||
| displayNameKey: displayNameKey && typeof displayNameKey === "string" && displayNameKey.trim().length > 0 ? displayNameKey : roleToField.title, | ||
| completedStatuses | ||
| }; | ||
| } | ||
| function inferCompletedStatuses(fields, statusFieldName) { | ||
| const statusDef = fields[statusFieldName]; | ||
| if (!statusDef || typeof statusDef !== "object") { | ||
| return ["done", "cancelled"]; | ||
| } | ||
| if (Array.isArray(statusDef.tn_completed_values)) { | ||
| const explicit = statusDef.tn_completed_values.filter((v) => typeof v === "string").map((v) => v.trim()).filter((v) => v.length > 0); | ||
| if (explicit.length > 0) return explicit; | ||
| } | ||
| if (Array.isArray(statusDef.values)) { | ||
| const inferred = statusDef.values.filter((v) => typeof v === "string").filter((v) => { | ||
| const lower = v.toLowerCase(); | ||
| return lower.includes("done") || lower.includes("complete") || lower.includes("cancel") || lower.includes("finish"); | ||
| }); | ||
| if (inferred.length > 0) return inferred; | ||
| } | ||
| return ["done", "cancelled"]; | ||
| } | ||
| async function loadFieldMapping(flagPath) { | ||
| try { | ||
| const collectionPath = resolveCollectionPath(flagPath); | ||
| const configResult = await loadConfig(collectionPath); | ||
| if (!configResult.valid || !configResult.config) { | ||
| return defaultFieldMapping(); | ||
| } | ||
| const typeResult = await getType(collectionPath, configResult.config, "task"); | ||
| if (!typeResult.valid || !typeResult.type) { | ||
| return defaultFieldMapping(); | ||
| } | ||
| const displayNameKey = typeof typeResult.type.display_name_key === "string" ? typeResult.type.display_name_key : typeof typeResult.type.displayNameKey === "string" ? typeResult.type.displayNameKey : void 0; | ||
| return buildFieldMapping(typeResult.type.fields || {}, displayNameKey); | ||
| } catch { | ||
| return defaultFieldMapping(); | ||
| } | ||
| } | ||
| function resolveField(mapping, role) { | ||
| return mapping.roleToField[role]; | ||
| } | ||
| // src/collection.ts | ||
| async function openCollection(flagPath) { | ||
| const collectionPath = resolveCollectionPath(flagPath); | ||
| const { collection, error } = await Collection.open(collectionPath); | ||
| if (error) { | ||
| throw new Error(`Failed to open collection at ${collectionPath}: ${error.message}`); | ||
| } | ||
| return collection; | ||
| } | ||
| async function withCollection(fn, flagPath) { | ||
| const collection = await openCollection(flagPath); | ||
| const mapping = await loadFieldMapping(flagPath); | ||
| try { | ||
| return await fn(collection, mapping); | ||
| } finally { | ||
| await collection.close(); | ||
| } | ||
| } | ||
| async function resolveTaskPath(collection, pathOrTitle, mapping) { | ||
| if (pathOrTitle.includes("/") || pathOrTitle.endsWith(".md")) { | ||
| return pathOrTitle; | ||
| } | ||
| const titleField = resolveField(mapping, "title"); | ||
| const query = pathOrTitle.trim(); | ||
| const escaped = query.replace(/"/g, '\\"'); | ||
| const exact = await queryTasks(collection, `${titleField} == "${escaped}"`, 20); | ||
| if (exact.length === 1) { | ||
| return exact[0].path; | ||
| } | ||
| if (exact.length > 1) { | ||
| throw new Error(formatAmbiguousTaskError(query, exact, titleField)); | ||
| } | ||
| const exactBasename = await queryTasks(collection, `file.basename == "${escaped}"`, 20); | ||
| if (exactBasename.length === 1) { | ||
| return exactBasename[0].path; | ||
| } | ||
| if (exactBasename.length > 1) { | ||
| throw new Error(formatAmbiguousTaskError(query, exactBasename, titleField)); | ||
| } | ||
| const fuzzyTitle = await queryTasks(collection, `${titleField}.contains("${escaped}")`, 20); | ||
| const fuzzyBasename = await queryTasks(collection, `file.basename.contains("${escaped}")`, 20); | ||
| const fuzzy = dedupeByPath([...fuzzyTitle, ...fuzzyBasename]); | ||
| if (fuzzy.length === 1) { | ||
| return fuzzy[0].path; | ||
| } | ||
| if (fuzzy.length > 1) { | ||
| throw new Error( | ||
| formatAmbiguousTaskError( | ||
| query, | ||
| rankCandidates(query, fuzzy, titleField), | ||
| titleField | ||
| ) | ||
| ); | ||
| } | ||
| throw new Error(`No task found matching "${query}"`); | ||
| } | ||
| async function queryTasks(collection, where, limit) { | ||
| try { | ||
| const result = await collection.query({ | ||
| types: ["task"], | ||
| where, | ||
| limit | ||
| }); | ||
| return result.results || []; | ||
| } catch { | ||
| return []; | ||
| } | ||
| } | ||
| function dedupeByPath(candidates) { | ||
| const seen = /* @__PURE__ */ new Set(); | ||
| const deduped = []; | ||
| for (const candidate of candidates) { | ||
| if (seen.has(candidate.path)) continue; | ||
| seen.add(candidate.path); | ||
| deduped.push(candidate); | ||
| } | ||
| return deduped; | ||
| } | ||
| function rankCandidates(query, candidates, titleField) { | ||
| const q = query.toLowerCase(); | ||
| return [...candidates].sort((a, b) => { | ||
| const scoreA = scoreCandidate(q, a, titleField); | ||
| const scoreB = scoreCandidate(q, b, titleField); | ||
| if (scoreA !== scoreB) return scoreB - scoreA; | ||
| const titleA = getTaskTitle(a, titleField).toLowerCase(); | ||
| const titleB = getTaskTitle(b, titleField).toLowerCase(); | ||
| if (titleA !== titleB) return titleA.localeCompare(titleB); | ||
| return a.path.localeCompare(b.path); | ||
| }); | ||
| } | ||
| function scoreCandidate(query, candidate, titleField) { | ||
| const title = getTaskTitle(candidate, titleField).toLowerCase(); | ||
| const path2 = candidate.path.toLowerCase(); | ||
| let score = 0; | ||
| if (title === query) score += 100; | ||
| if (title.startsWith(query)) score += 50; | ||
| if (title.includes(query)) score += 25; | ||
| if (path2.includes(query)) score += 10; | ||
| score += Math.max(0, 10 - Math.abs(title.length - query.length)); | ||
| return score; | ||
| } | ||
| function formatAmbiguousTaskError(query, candidates, titleField) { | ||
| const preview = candidates.slice(0, 5).map((candidate, index) => { | ||
| const title = getTaskTitle(candidate, titleField); | ||
| return ` ${index + 1}. ${title} (${candidate.path})`; | ||
| }).join("\n"); | ||
| const more = candidates.length > 5 ? ` | ||
| ...and ${candidates.length - 5} more` : ""; | ||
| const examplePath = candidates[0]?.path || "tasks/<task>.md"; | ||
| return [ | ||
| `Ambiguous task reference "${query}".`, | ||
| "Matches (best first):", | ||
| `${preview}${more}`, | ||
| `Use a full path to disambiguate (for example: ${examplePath}).` | ||
| ].join("\n"); | ||
| } | ||
| function getTaskTitle(candidate, titleField) { | ||
| if (candidate.frontmatter && titleField) { | ||
| const raw = candidate.frontmatter[titleField]; | ||
| if (typeof raw === "string" && raw.trim().length > 0) { | ||
| return raw; | ||
| } | ||
| } | ||
| const fromPath = basename2(candidate.path, ".md").trim(); | ||
| return fromPath.length > 0 ? fromPath : candidate.path; | ||
| } | ||
| export { | ||
| openCollection, | ||
| resolveTaskPath, | ||
| withCollection | ||
| }; |
| // src/config.ts | ||
| import * as fs from "fs"; | ||
| import * as path from "path"; | ||
| import * as os from "os"; | ||
| var CONFIG_DIR = path.join( | ||
| os.homedir(), | ||
| ".config", | ||
| "mdbase-tasknotes" | ||
| ); | ||
| var CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); | ||
| var DEFAULT_CONFIG = { | ||
| collectionPath: null, | ||
| language: "en" | ||
| }; | ||
| function load() { | ||
| try { | ||
| const raw = fs.readFileSync(CONFIG_FILE, "utf-8"); | ||
| return { ...DEFAULT_CONFIG, ...JSON.parse(raw) }; | ||
| } catch { | ||
| return { ...DEFAULT_CONFIG }; | ||
| } | ||
| } | ||
| function save(config) { | ||
| fs.mkdirSync(CONFIG_DIR, { recursive: true }); | ||
| fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + "\n"); | ||
| } | ||
| function getConfig() { | ||
| return load(); | ||
| } | ||
| function setConfig(key, value) { | ||
| const config = load(); | ||
| if (key === "collectionPath") { | ||
| config.collectionPath = value; | ||
| } else if (key === "language") { | ||
| config.language = value ?? "en"; | ||
| } | ||
| save(config); | ||
| } | ||
| function getConfigPath() { | ||
| return CONFIG_FILE; | ||
| } | ||
| function resolveUserPath(userPath) { | ||
| return path.resolve(expandHomeDirectory(userPath)); | ||
| } | ||
| function resolveCollectionPath(flagPath) { | ||
| if (flagPath) return resolveUserPath(flagPath); | ||
| const envPath = process.env.MDBASE_TASKNOTES_PATH; | ||
| if (envPath) return resolveUserPath(envPath); | ||
| const config = load(); | ||
| if (config.collectionPath) return resolveUserPath(config.collectionPath); | ||
| return process.cwd(); | ||
| } | ||
| function expandHomeDirectory(userPath) { | ||
| if (userPath === "~") { | ||
| return os.homedir(); | ||
| } | ||
| if (userPath.startsWith("~/") || userPath.startsWith("~\\")) { | ||
| return path.join(os.homedir(), userPath.slice(2)); | ||
| } | ||
| return userPath; | ||
| } | ||
| export { | ||
| getConfig, | ||
| getConfigPath, | ||
| resolveCollectionPath, | ||
| resolveUserPath, | ||
| setConfig | ||
| }; |
Sorry, the diff of this file is too big to display
| // src/create-compat.ts | ||
| import { format } from "date-fns"; | ||
| // src/field-mapping.ts | ||
| import { loadConfig, getType } from "@callumalpass/mdbase"; | ||
| import { basename } from "path"; | ||
| // src/config.ts | ||
| import * as fs from "fs"; | ||
| import * as path from "path"; | ||
| import * as os from "os"; | ||
| var CONFIG_DIR = path.join( | ||
| os.homedir(), | ||
| ".config", | ||
| "mdbase-tasknotes" | ||
| ); | ||
| var CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); | ||
| // src/field-mapping.ts | ||
| var ALL_ROLES = [ | ||
| "title", | ||
| "status", | ||
| "priority", | ||
| "due", | ||
| "scheduled", | ||
| "completedDate", | ||
| "tags", | ||
| "contexts", | ||
| "projects", | ||
| "timeEstimate", | ||
| "dateCreated", | ||
| "dateModified", | ||
| "recurrence", | ||
| "recurrenceAnchor", | ||
| "completeInstances", | ||
| "skippedInstances", | ||
| "timeEntries" | ||
| ]; | ||
| function denormalizeFrontmatter(roleData, mapping) { | ||
| const result = {}; | ||
| const rolesSet = new Set(ALL_ROLES); | ||
| for (const [key, value] of Object.entries(roleData)) { | ||
| if (rolesSet.has(key)) { | ||
| result[mapping.roleToField[key]] = value; | ||
| } else { | ||
| result[key] = value; | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
| function resolveField(mapping, role) { | ||
| return mapping.roleToField[role]; | ||
| } | ||
| // src/create-compat.ts | ||
| async function createTaskWithCompat(collection, mapping, roleFrontmatter, body) { | ||
| const taskType = getTaskTypeDef(collection); | ||
| const denormalized = denormalizeFrontmatter(roleFrontmatter, mapping); | ||
| applyFieldDefaults(denormalized, taskType); | ||
| applyTimestampDefaults(denormalized, mapping, taskType); | ||
| applyMatchDefaults(denormalized, taskType); | ||
| const input = { | ||
| type: "task", | ||
| frontmatter: denormalized, | ||
| body | ||
| }; | ||
| const firstAttempt = await collection.create(input); | ||
| if (!firstAttempt.error || firstAttempt.error.code !== "path_required") { | ||
| return firstAttempt; | ||
| } | ||
| const pathResolution = derivePathFromType( | ||
| taskType, | ||
| denormalized, | ||
| mapping, | ||
| /* @__PURE__ */ new Date() | ||
| ); | ||
| if (!pathResolution.path) { | ||
| if (pathResolution.errorMessage) { | ||
| return { | ||
| ...firstAttempt, | ||
| error: { | ||
| ...firstAttempt.error, | ||
| message: pathResolution.errorMessage | ||
| } | ||
| }; | ||
| } | ||
| if (pathResolution.missingKeys && pathResolution.missingKeys.length > 0) { | ||
| const missing = pathResolution.missingKeys.join(", "); | ||
| return { | ||
| ...firstAttempt, | ||
| warnings: [ | ||
| `Cannot resolve path_pattern "${pathResolution.template}": missing template values for ${missing}.` | ||
| ] | ||
| }; | ||
| } | ||
| return firstAttempt; | ||
| } | ||
| return await collection.create({ | ||
| ...input, | ||
| path: pathResolution.path | ||
| }); | ||
| } | ||
| function getTaskTypeDef(collection) { | ||
| const maybeCollection = collection; | ||
| if (!maybeCollection.typeDefs || typeof maybeCollection.typeDefs.get !== "function") { | ||
| return void 0; | ||
| } | ||
| return maybeCollection.typeDefs.get("task"); | ||
| } | ||
| function applyTimestampDefaults(frontmatter, mapping, taskType) { | ||
| const fields = taskType?.fields; | ||
| if (!fields) return; | ||
| const nowIso = (/* @__PURE__ */ new Date()).toISOString(); | ||
| const createdField = resolveField(mapping, "dateCreated"); | ||
| if (fields[createdField] && !hasValue(frontmatter[createdField])) { | ||
| frontmatter[createdField] = nowIso; | ||
| } | ||
| const modifiedField = resolveField(mapping, "dateModified"); | ||
| if (fields[modifiedField] && !hasValue(frontmatter[modifiedField])) { | ||
| frontmatter[modifiedField] = nowIso; | ||
| } | ||
| } | ||
| function applyFieldDefaults(frontmatter, taskType) { | ||
| const fields = taskType?.fields; | ||
| if (!fields) return; | ||
| for (const [fieldName, fieldDef] of Object.entries(fields)) { | ||
| if (fieldDef.default !== void 0 && !hasValue(frontmatter[fieldName])) { | ||
| frontmatter[fieldName] = fieldDef.default; | ||
| } | ||
| } | ||
| } | ||
| function applyMatchDefaults(frontmatter, taskType) { | ||
| const where = taskType?.match?.where; | ||
| if (!where || typeof where !== "object") return; | ||
| for (const [field, condition] of Object.entries(where)) { | ||
| if (condition === null || condition === void 0) continue; | ||
| if (typeof condition !== "object" || Array.isArray(condition)) { | ||
| if (!hasValue(frontmatter[field])) { | ||
| frontmatter[field] = condition; | ||
| } | ||
| continue; | ||
| } | ||
| const ops = condition; | ||
| if ("eq" in ops && !hasValue(frontmatter[field])) { | ||
| frontmatter[field] = ops.eq; | ||
| continue; | ||
| } | ||
| if ("contains" in ops) { | ||
| const expected = ops.contains; | ||
| const current = frontmatter[field]; | ||
| if (Array.isArray(current)) { | ||
| if (!current.some((v) => String(v) === String(expected))) { | ||
| current.push(expected); | ||
| frontmatter[field] = current; | ||
| } | ||
| continue; | ||
| } | ||
| if (typeof current === "string") { | ||
| if (!current.includes(String(expected))) { | ||
| frontmatter[field] = `${current} ${String(expected)}`.trim(); | ||
| } | ||
| continue; | ||
| } | ||
| if (!hasValue(current)) { | ||
| frontmatter[field] = [expected]; | ||
| } | ||
| continue; | ||
| } | ||
| if ("exists" in ops && ops.exists === true && !hasValue(frontmatter[field])) { | ||
| frontmatter[field] = true; | ||
| } | ||
| } | ||
| } | ||
| function derivePathFromType(taskType, frontmatter, mapping, now) { | ||
| if (!taskType || typeof taskType.path_pattern !== "string" || taskType.path_pattern.trim().length === 0) { | ||
| return { | ||
| errorMessage: buildMissingPathPatternMessage(taskType) | ||
| }; | ||
| } | ||
| const values = buildTemplateValues(frontmatter, mapping, now); | ||
| const renderedPattern = renderTemplate(taskType.path_pattern, values); | ||
| if (renderedPattern.path) { | ||
| return { path: ensureMarkdownExt(renderedPattern.path), template: taskType.path_pattern }; | ||
| } | ||
| return { | ||
| template: taskType.path_pattern, | ||
| missingKeys: renderedPattern.missingKeys | ||
| }; | ||
| } | ||
| function buildMissingPathPatternMessage(taskType) { | ||
| const pathGlob = readString(taskType?.match?.path_glob); | ||
| if (!pathGlob) { | ||
| return [ | ||
| "Cannot create task because the task type does not define path_pattern.", | ||
| "Add path_pattern to _types/task.md to tell mtn where new task files should be written." | ||
| ].join(" "); | ||
| } | ||
| const suggestion = suggestPathPatternFromGlob(pathGlob); | ||
| return [ | ||
| `Cannot create task because _types/task.md defines match.path_glob "${pathGlob}" but no path_pattern.`, | ||
| "match.path_glob only identifies existing files; it is not a template for creating new files.", | ||
| `Add path_pattern to tell mtn where to write new tasks, for example: ${suggestion}.` | ||
| ].join(" "); | ||
| } | ||
| function suggestPathPatternFromGlob(pathGlob) { | ||
| const normalized = normalizeRelativePath(pathGlob); | ||
| const withoutGlob = normalized.replace(/\*\*\/\*\.md$/u, "{{titleKebab}}.md").replace(/\*\.md$/u, "{{titleKebab}}.md").replace(/\*\*$/u, "{{titleKebab}}.md").replace(/\*$/u, "{{titleKebab}}.md"); | ||
| const suggestion = withoutGlob === normalized || withoutGlob.length === 0 ? "tasks/{{titleKebab}}.md" : withoutGlob; | ||
| return `path_pattern: "${suggestion}"`; | ||
| } | ||
| function renderTemplate(template, values) { | ||
| const missingKeys = /* @__PURE__ */ new Set(); | ||
| const rendered = template.replace(/\{\{(\w+)\}\}|\{(\w+)\}/g, (_, a, b) => { | ||
| const key = a ?? b; | ||
| const value = values[key]; | ||
| if (value === void 0 || value === null || String(value).trim().length === 0) { | ||
| missingKeys.add(key); | ||
| return ""; | ||
| } | ||
| return String(value); | ||
| }); | ||
| if (missingKeys.size > 0) { | ||
| return { missingKeys: Array.from(missingKeys).sort() }; | ||
| } | ||
| const normalized = normalizeRelativePath(rendered); | ||
| if (!normalized || normalized.includes("..") || normalized.includes("\0")) { | ||
| return { missingKeys: [] }; | ||
| } | ||
| return { path: normalized, missingKeys: [] }; | ||
| } | ||
| function buildTemplateValues(frontmatter, mapping, now) { | ||
| const values = {}; | ||
| const titleField = resolveField(mapping, "title"); | ||
| const priorityField = resolveField(mapping, "priority"); | ||
| const statusField = resolveField(mapping, "status"); | ||
| const dueField = resolveField(mapping, "due"); | ||
| const scheduledField = resolveField(mapping, "scheduled"); | ||
| const contextsField = resolveField(mapping, "contexts"); | ||
| const projectsField = resolveField(mapping, "projects"); | ||
| const tagsField = resolveField(mapping, "tags"); | ||
| const estimateField = resolveField(mapping, "timeEstimate"); | ||
| const rawTitle = readString(frontmatter[titleField]) || readString(frontmatter.title) || "task"; | ||
| const title = sanitizeForPathSegment(rawTitle); | ||
| const priority = sanitizeForPathSegment( | ||
| readString(frontmatter[priorityField]) || readString(frontmatter.priority) || "normal" | ||
| ); | ||
| const status = sanitizeForPathSegment( | ||
| readString(frontmatter[statusField]) || readString(frontmatter.status) || "open" | ||
| ); | ||
| const dueDateRaw = readString(frontmatter[dueField]) || readString(frontmatter.due) || ""; | ||
| const scheduledDateRaw = readString(frontmatter[scheduledField]) || readString(frontmatter.scheduled) || ""; | ||
| const todayDate = format(now, "yyyy-MM-dd"); | ||
| const dueDate = dueDateRaw || scheduledDateRaw || todayDate; | ||
| const scheduledDate = scheduledDateRaw || dueDateRaw || todayDate; | ||
| const contexts = readStringList(frontmatter[contextsField] ?? frontmatter.contexts).map((v) => sanitizeForPathSegment(v)).filter(Boolean); | ||
| const projects = readStringList(frontmatter[projectsField] ?? frontmatter.projects).map(extractProjectName).map((v) => sanitizeForPathSegment(v)).filter(Boolean); | ||
| const tags = readStringList(frontmatter[tagsField] ?? frontmatter.tags).map((v) => sanitizeForPathSegment(v)).filter(Boolean); | ||
| const timeEstimate = frontmatter[estimateField] ?? frontmatter.timeEstimate; | ||
| const zettel = generateZettel(now); | ||
| const titleLower = title.toLowerCase(); | ||
| const titleUpper = title.toUpperCase(); | ||
| const titleSnake = titleLower.replace(/\s+/g, "_"); | ||
| const titleKebab = titleLower.replace(/\s+/g, "-"); | ||
| const titleCamel = toCamelCase(title, false); | ||
| const titlePascal = toCamelCase(title, true); | ||
| const base = { | ||
| title, | ||
| priority, | ||
| status, | ||
| dueDate, | ||
| scheduledDate, | ||
| context: contexts[0] ?? "", | ||
| contexts: contexts.join("/"), | ||
| project: projects[0] ?? "", | ||
| projects: projects.join("/"), | ||
| tags: tags.join(", "), | ||
| hashtags: tags.map((t) => `#${t}`).join(" "), | ||
| timeEstimate: timeEstimate != null ? String(timeEstimate) : "", | ||
| details: "", | ||
| parentNote: "", | ||
| date: format(now, "yyyy-MM-dd"), | ||
| time: format(now, "HHmmss"), | ||
| timestamp: format(now, "yyyy-MM-dd-HHmmss"), | ||
| dateTime: format(now, "yyyy-MM-dd-HHmm"), | ||
| year: format(now, "yyyy"), | ||
| month: format(now, "MM"), | ||
| day: format(now, "dd"), | ||
| hour: format(now, "HH"), | ||
| minute: format(now, "mm"), | ||
| second: format(now, "ss"), | ||
| shortDate: format(now, "yyMMdd"), | ||
| shortYear: format(now, "yy"), | ||
| monthName: format(now, "MMMM"), | ||
| monthNameShort: format(now, "MMM"), | ||
| dayName: format(now, "EEEE"), | ||
| dayNameShort: format(now, "EEE"), | ||
| week: format(now, "ww"), | ||
| quarter: format(now, "q"), | ||
| time12: sanitizeForPathSegment(format(now, "hh:mm a")), | ||
| time24: sanitizeForPathSegment(format(now, "HH:mm")), | ||
| hourPadded: format(now, "HH"), | ||
| hour12: format(now, "hh"), | ||
| ampm: format(now, "a"), | ||
| unix: String(Math.floor(now.getTime() / 1e3)), | ||
| unixMs: String(now.getTime()), | ||
| milliseconds: format(now, "SSS"), | ||
| ms: format(now, "SSS"), | ||
| timezone: sanitizeForPathSegment(format(now, "xxx")), | ||
| timezoneShort: sanitizeForPathSegment(format(now, "xx")), | ||
| utcOffset: sanitizeForPathSegment(format(now, "xxx")), | ||
| utcOffsetShort: sanitizeForPathSegment(format(now, "xx")), | ||
| utcZ: "Z", | ||
| priorityShort: priority ? priority.substring(0, 1).toUpperCase() : "", | ||
| statusShort: status ? status.substring(0, 1).toUpperCase() : "", | ||
| titleLower, | ||
| titleUpper, | ||
| titleSnake, | ||
| titleKebab, | ||
| titleCamel, | ||
| titlePascal, | ||
| zettel, | ||
| nano: `${Date.now()}${Math.random().toString(36).slice(2, 7)}` | ||
| }; | ||
| Object.assign(values, base); | ||
| values[titleField] = title; | ||
| values[priorityField] = priority; | ||
| values[statusField] = status; | ||
| values[dueField] = dueDate; | ||
| values[scheduledField] = scheduledDate; | ||
| for (const [key, value] of Object.entries(frontmatter)) { | ||
| if (values[key] !== void 0) continue; | ||
| if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { | ||
| values[key] = sanitizeForPathSegment(String(value)); | ||
| } | ||
| } | ||
| return values; | ||
| } | ||
| function readString(value) { | ||
| if (typeof value !== "string") return void 0; | ||
| const trimmed = value.trim(); | ||
| return trimmed.length > 0 ? trimmed : void 0; | ||
| } | ||
| function readStringList(value) { | ||
| if (!Array.isArray(value)) return []; | ||
| return value.filter((v) => typeof v === "string").map((v) => v.trim()).filter(Boolean); | ||
| } | ||
| function extractProjectName(project) { | ||
| const wiki = project.match(/\[\[(?:.*\/)?([^\]|]+)(?:\|[^\]]+)?\]\]/); | ||
| if (wiki) return wiki[1]; | ||
| return project; | ||
| } | ||
| function toCamelCase(value, pascal) { | ||
| const words = value.replace(/[^a-zA-Z0-9\s]/g, " ").trim().split(/\s+/).filter(Boolean); | ||
| if (words.length === 0) return ""; | ||
| return words.map((word, index) => { | ||
| const lower = word.toLowerCase(); | ||
| if (index === 0 && !pascal) return lower; | ||
| return lower.charAt(0).toUpperCase() + lower.slice(1); | ||
| }).join(""); | ||
| } | ||
| function generateZettel(now) { | ||
| const datePart = format(now, "yyMMdd"); | ||
| const midnight = new Date(now); | ||
| midnight.setHours(0, 0, 0, 0); | ||
| const secondsSinceMidnight = Math.floor((now.getTime() - midnight.getTime()) / 1e3); | ||
| return `${datePart}${secondsSinceMidnight.toString(36)}`; | ||
| } | ||
| function sanitizeForPathSegment(value) { | ||
| return value.trim().replace(/\s+/g, " ").replace(/[<>:"/\\|?*#[\]]/g, "").replace(/[\u0000-\u001f\u007f-\u009f]/g, "").replace(/^\.+|\.+$/g, "").trim(); | ||
| } | ||
| function normalizeRelativePath(value) { | ||
| return value.replace(/\\/g, "/").replace(/\/+/g, "/").replace(/^\/+|\/+$/g, "").trim(); | ||
| } | ||
| function ensureMarkdownExt(pathValue) { | ||
| const normalized = normalizeRelativePath(pathValue); | ||
| if (!normalized) return normalized; | ||
| if (normalized.toLowerCase().endsWith(".md")) return normalized; | ||
| return `${normalized}.md`; | ||
| } | ||
| function hasValue(value) { | ||
| return value !== null && value !== void 0; | ||
| } | ||
| export { | ||
| createTaskWithCompat | ||
| }; |
+228
| // src/date.ts | ||
| import { isValid, parseISO } from "date-fns"; | ||
| var DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})$/; | ||
| var DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,3})?(?:Z|([+-])(\d{2}):(\d{2}))?$/; | ||
| var RELAXED_DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})(?:T| )(\d{2}):(\d{2})(?::(\d{2})(\.\d{1,3})?)?(Z|([+-])(\d{2}):(\d{2}))?$/; | ||
| function parseDateToUTC(dateString) { | ||
| if (!dateString || dateString.trim().length === 0) { | ||
| throw new Error("Date string cannot be empty"); | ||
| } | ||
| const trimmed = dateString.trim(); | ||
| const dateOnlyMatch = trimmed.match(DATE_ONLY_RE); | ||
| if (dateOnlyMatch) { | ||
| const [, year, month, day] = dateOnlyMatch; | ||
| const y = Number(year); | ||
| const m = Number(month); | ||
| const d = Number(day); | ||
| const parsed2 = new Date(Date.UTC(y, m - 1, d, 0, 0, 0, 0)); | ||
| if (parsed2.getUTCFullYear() !== y || parsed2.getUTCMonth() !== m - 1 || parsed2.getUTCDate() !== d) { | ||
| throw new Error(`Invalid date "${dateString}".`); | ||
| } | ||
| return parsed2; | ||
| } | ||
| if (!isStrictDateTime(trimmed)) { | ||
| throw new Error(`Invalid date "${dateString}".`); | ||
| } | ||
| const parsed = parseISO(trimmed); | ||
| if (!isValid(parsed)) { | ||
| throw new Error(`Invalid date "${dateString}".`); | ||
| } | ||
| return parsed; | ||
| } | ||
| function parseDateToLocal(dateString) { | ||
| if (!dateString || dateString.trim().length === 0) { | ||
| throw new Error("Date string cannot be empty"); | ||
| } | ||
| const trimmed = dateString.trim(); | ||
| const dateOnlyMatch = trimmed.match(DATE_ONLY_RE); | ||
| if (dateOnlyMatch) { | ||
| const [, year, month, day] = dateOnlyMatch; | ||
| const y = Number(year); | ||
| const m = Number(month); | ||
| const d = Number(day); | ||
| const parsed2 = new Date(y, m - 1, d, 0, 0, 0, 0); | ||
| if (parsed2.getFullYear() !== y || parsed2.getMonth() !== m - 1 || parsed2.getDate() !== d) { | ||
| throw new Error(`Invalid date "${dateString}".`); | ||
| } | ||
| return parsed2; | ||
| } | ||
| if (!isStrictDateTime(trimmed)) { | ||
| throw new Error(`Invalid date "${dateString}".`); | ||
| } | ||
| const parsed = parseISO(trimmed); | ||
| if (!isValid(parsed)) { | ||
| throw new Error(`Invalid date "${dateString}".`); | ||
| } | ||
| return parsed; | ||
| } | ||
| function formatDateForStorage(date) { | ||
| if (!date || Number.isNaN(date.getTime())) { | ||
| return ""; | ||
| } | ||
| const y = date.getUTCFullYear(); | ||
| const m = String(date.getUTCMonth() + 1).padStart(2, "0"); | ||
| const d = String(date.getUTCDate()).padStart(2, "0"); | ||
| return `${y}-${m}-${d}`; | ||
| } | ||
| function getCurrentDateString() { | ||
| const now = /* @__PURE__ */ new Date(); | ||
| const y = now.getFullYear(); | ||
| const m = String(now.getMonth() + 1).padStart(2, "0"); | ||
| const d = String(now.getDate()).padStart(2, "0"); | ||
| return `${y}-${m}-${d}`; | ||
| } | ||
| function resolveDateOrToday(date) { | ||
| if (!date) { | ||
| return getCurrentDateString(); | ||
| } | ||
| return validateDateString(date); | ||
| } | ||
| function resolveOperationTargetDate(explicitDate, scheduled, due) { | ||
| if (explicitDate) { | ||
| return validateDateString(explicitDate); | ||
| } | ||
| const scheduledDatePart = extractValidDatePartOrUndefined(scheduled); | ||
| if (scheduledDatePart) { | ||
| return scheduledDatePart; | ||
| } | ||
| const dueDatePart = extractValidDatePartOrUndefined(due); | ||
| if (dueDatePart) { | ||
| return dueDatePart; | ||
| } | ||
| return getCurrentDateString(); | ||
| } | ||
| function validateDateString(date) { | ||
| if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) { | ||
| throw new Error(`Invalid date "${date}". Expected YYYY-MM-DD.`); | ||
| } | ||
| parseDateToUTC(date); | ||
| return date; | ||
| } | ||
| function resolveDateTimeRangeBound(value, bound) { | ||
| if (!value || value.trim().length === 0) { | ||
| throw new Error("Datetime cannot be empty."); | ||
| } | ||
| const trimmed = value.trim(); | ||
| const dateOnlyMatch = trimmed.match(DATE_ONLY_RE); | ||
| if (dateOnlyMatch) { | ||
| const [, year2, month2, day2] = dateOnlyMatch; | ||
| const y2 = Number(year2); | ||
| const m2 = Number(month2); | ||
| const d2 = Number(day2); | ||
| if (!isValidCalendarDate(y2, m2, d2)) { | ||
| throw new Error(`Invalid datetime "${value}".`); | ||
| } | ||
| return bound === "from" ? new Date(y2, m2 - 1, d2, 0, 0, 0, 0) : new Date(y2, m2 - 1, d2, 23, 59, 59, 999); | ||
| } | ||
| const match = trimmed.match(RELAXED_DATE_TIME_RE); | ||
| if (!match) { | ||
| throw new Error( | ||
| `Invalid datetime "${value}". Expected YYYY-MM-DD, YYYY-MM-DD HH:mm, or YYYY-MM-DDTHH:mm.` | ||
| ); | ||
| } | ||
| const [, year, month, day, hours, minutes, seconds, fraction, tz, tzSign, tzHours, tzMinutes] = match; | ||
| const y = Number(year); | ||
| const m = Number(month); | ||
| const d = Number(day); | ||
| const hh = Number(hours); | ||
| const mm = Number(minutes); | ||
| const ss = seconds === void 0 ? bound === "to" ? 59 : 0 : Number(seconds); | ||
| const ms = fraction ? Number(fraction.slice(1).padEnd(3, "0")) : bound === "to" ? 999 : 0; | ||
| if (!isValidCalendarDate(y, m, d) || !isValidClockTime(hh, mm, ss) || !isValidOffset(tzSign, tzHours, tzMinutes)) { | ||
| throw new Error(`Invalid datetime "${value}".`); | ||
| } | ||
| const normalized = `${year}-${month}-${day}T${hours}:${minutes}:${String(ss).padStart(2, "0")}.${String(ms).padStart(3, "0")}${tz || ""}`; | ||
| const parsed = parseISO(normalized); | ||
| if (!isValid(parsed)) { | ||
| throw new Error(`Invalid datetime "${value}".`); | ||
| } | ||
| return parsed; | ||
| } | ||
| function hasTimeComponent(dateString) { | ||
| if (!dateString) return false; | ||
| return /T\d{2}:\d{2}/.test(dateString); | ||
| } | ||
| function getDatePart(dateString) { | ||
| if (!dateString) return ""; | ||
| if (/^\d{4}-\d{2}-\d{2}$/.test(dateString)) { | ||
| return dateString; | ||
| } | ||
| const tIndex = dateString.indexOf("T"); | ||
| if (tIndex > -1) { | ||
| return dateString.slice(0, tIndex); | ||
| } | ||
| return formatDateForStorage(parseDateToUTC(dateString)); | ||
| } | ||
| function extractValidDatePartOrUndefined(dateString) { | ||
| if (!dateString || dateString.trim().length === 0) { | ||
| return void 0; | ||
| } | ||
| try { | ||
| const datePart = getDatePart(dateString.trim()); | ||
| return validateDateString(datePart); | ||
| } catch { | ||
| return void 0; | ||
| } | ||
| } | ||
| function isSameDateSafe(date1, date2) { | ||
| try { | ||
| const d1 = parseDateToUTC(getDatePart(date1)); | ||
| const d2 = parseDateToUTC(getDatePart(date2)); | ||
| return d1.getTime() === d2.getTime(); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function isBeforeDateSafe(date1, date2) { | ||
| try { | ||
| const d1 = parseDateToUTC(getDatePart(date1)); | ||
| const d2 = parseDateToUTC(getDatePart(date2)); | ||
| return d1.getTime() < d2.getTime(); | ||
| } catch { | ||
| return false; | ||
| } | ||
| } | ||
| function isStrictDateTime(value) { | ||
| const match = value.match(DATE_TIME_RE); | ||
| if (!match) return false; | ||
| const [, year, month, day, hours, minutes, seconds, , tzSign, tzHours, tzMinutes] = match; | ||
| const y = Number(year); | ||
| const m = Number(month); | ||
| const d = Number(day); | ||
| const hh = Number(hours); | ||
| const mm = Number(minutes); | ||
| const ss = Number(seconds); | ||
| if (!isValidClockTime(hh, mm, ss) || !isValidCalendarDate(y, m, d)) { | ||
| return false; | ||
| } | ||
| return isValidOffset(tzSign, tzHours, tzMinutes); | ||
| } | ||
| function isValidCalendarDate(year, month, day) { | ||
| const date = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0)); | ||
| return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; | ||
| } | ||
| function isValidClockTime(hours, minutes, seconds) { | ||
| return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59 && seconds >= 0 && seconds <= 59; | ||
| } | ||
| function isValidOffset(tzSign, tzHours, tzMinutes) { | ||
| if (!tzSign) return true; | ||
| const offsetHours = Number(tzHours); | ||
| const offsetMinutes = Number(tzMinutes); | ||
| if (offsetHours > 14 || offsetMinutes > 59) return false; | ||
| if (offsetHours === 14 && offsetMinutes !== 0) return false; | ||
| return true; | ||
| } | ||
| export { | ||
| formatDateForStorage, | ||
| getCurrentDateString, | ||
| getDatePart, | ||
| hasTimeComponent, | ||
| isBeforeDateSafe, | ||
| isSameDateSafe, | ||
| parseDateToLocal, | ||
| parseDateToUTC, | ||
| resolveDateOrToday, | ||
| resolveDateTimeRangeBound, | ||
| resolveOperationTargetDate, | ||
| validateDateString | ||
| }; |
| // src/field-mapping.ts | ||
| import { loadConfig, getType } from "@callumalpass/mdbase"; | ||
| import { basename } from "path"; | ||
| // src/config.ts | ||
| import * as fs from "fs"; | ||
| import * as path from "path"; | ||
| import * as os from "os"; | ||
| var CONFIG_DIR = path.join( | ||
| os.homedir(), | ||
| ".config", | ||
| "mdbase-tasknotes" | ||
| ); | ||
| var CONFIG_FILE = path.join(CONFIG_DIR, "config.json"); | ||
| var DEFAULT_CONFIG = { | ||
| collectionPath: null, | ||
| language: "en" | ||
| }; | ||
| function load() { | ||
| try { | ||
| const raw = fs.readFileSync(CONFIG_FILE, "utf-8"); | ||
| return { ...DEFAULT_CONFIG, ...JSON.parse(raw) }; | ||
| } catch { | ||
| return { ...DEFAULT_CONFIG }; | ||
| } | ||
| } | ||
| function resolveUserPath(userPath) { | ||
| return path.resolve(expandHomeDirectory(userPath)); | ||
| } | ||
| function resolveCollectionPath(flagPath) { | ||
| if (flagPath) return resolveUserPath(flagPath); | ||
| const envPath = process.env.MDBASE_TASKNOTES_PATH; | ||
| if (envPath) return resolveUserPath(envPath); | ||
| const config = load(); | ||
| if (config.collectionPath) return resolveUserPath(config.collectionPath); | ||
| return process.cwd(); | ||
| } | ||
| function expandHomeDirectory(userPath) { | ||
| if (userPath === "~") { | ||
| return os.homedir(); | ||
| } | ||
| if (userPath.startsWith("~/") || userPath.startsWith("~\\")) { | ||
| return path.join(os.homedir(), userPath.slice(2)); | ||
| } | ||
| return userPath; | ||
| } | ||
| // src/field-mapping.ts | ||
| var ALL_ROLES = [ | ||
| "title", | ||
| "status", | ||
| "priority", | ||
| "due", | ||
| "scheduled", | ||
| "completedDate", | ||
| "tags", | ||
| "contexts", | ||
| "projects", | ||
| "timeEstimate", | ||
| "dateCreated", | ||
| "dateModified", | ||
| "recurrence", | ||
| "recurrenceAnchor", | ||
| "completeInstances", | ||
| "skippedInstances", | ||
| "timeEntries" | ||
| ]; | ||
| function defaultFieldMapping() { | ||
| const roleToField = {}; | ||
| const fieldToRole = {}; | ||
| for (const role of ALL_ROLES) { | ||
| roleToField[role] = role; | ||
| fieldToRole[role] = role; | ||
| } | ||
| return { | ||
| roleToField, | ||
| fieldToRole, | ||
| displayNameKey: "title", | ||
| completedStatuses: ["done", "cancelled"] | ||
| }; | ||
| } | ||
| function buildFieldMapping(fields, displayNameKey) { | ||
| const roleToField = {}; | ||
| const fieldToRole = {}; | ||
| const rolesSet = new Set(ALL_ROLES); | ||
| for (const [fieldName, def] of Object.entries(fields)) { | ||
| if (def && typeof def === "object" && typeof def.tn_role === "string") { | ||
| const role = def.tn_role; | ||
| if (!rolesSet.has(role)) continue; | ||
| if (roleToField[role] !== void 0) { | ||
| console.warn(`[mtn] Duplicate tn_role "${role}" on field "${fieldName}", ignoring.`); | ||
| continue; | ||
| } | ||
| roleToField[role] = fieldName; | ||
| fieldToRole[fieldName] = role; | ||
| } | ||
| } | ||
| for (const role of ALL_ROLES) { | ||
| if (roleToField[role] === void 0) { | ||
| if (fields[role] !== void 0) { | ||
| roleToField[role] = role; | ||
| if (fieldToRole[role] === void 0) { | ||
| fieldToRole[role] = role; | ||
| } | ||
| } else { | ||
| roleToField[role] = role; | ||
| } | ||
| } | ||
| } | ||
| const completedStatuses = inferCompletedStatuses(fields, roleToField.status); | ||
| return { | ||
| roleToField, | ||
| fieldToRole, | ||
| displayNameKey: displayNameKey && typeof displayNameKey === "string" && displayNameKey.trim().length > 0 ? displayNameKey : roleToField.title, | ||
| completedStatuses | ||
| }; | ||
| } | ||
| function inferCompletedStatuses(fields, statusFieldName) { | ||
| const statusDef = fields[statusFieldName]; | ||
| if (!statusDef || typeof statusDef !== "object") { | ||
| return ["done", "cancelled"]; | ||
| } | ||
| if (Array.isArray(statusDef.tn_completed_values)) { | ||
| const explicit = statusDef.tn_completed_values.filter((v) => typeof v === "string").map((v) => v.trim()).filter((v) => v.length > 0); | ||
| if (explicit.length > 0) return explicit; | ||
| } | ||
| if (Array.isArray(statusDef.values)) { | ||
| const inferred = statusDef.values.filter((v) => typeof v === "string").filter((v) => { | ||
| const lower = v.toLowerCase(); | ||
| return lower.includes("done") || lower.includes("complete") || lower.includes("cancel") || lower.includes("finish"); | ||
| }); | ||
| if (inferred.length > 0) return inferred; | ||
| } | ||
| return ["done", "cancelled"]; | ||
| } | ||
| function isCompletedStatus(mapping, status) { | ||
| if (!status) return false; | ||
| return mapping.completedStatuses.includes(status); | ||
| } | ||
| function getDefaultCompletedStatus(mapping) { | ||
| return mapping.completedStatuses[0] || "done"; | ||
| } | ||
| async function loadFieldMapping(flagPath) { | ||
| try { | ||
| const collectionPath = resolveCollectionPath(flagPath); | ||
| const configResult = await loadConfig(collectionPath); | ||
| if (!configResult.valid || !configResult.config) { | ||
| return defaultFieldMapping(); | ||
| } | ||
| const typeResult = await getType(collectionPath, configResult.config, "task"); | ||
| if (!typeResult.valid || !typeResult.type) { | ||
| return defaultFieldMapping(); | ||
| } | ||
| const displayNameKey = typeof typeResult.type.display_name_key === "string" ? typeResult.type.display_name_key : typeof typeResult.type.displayNameKey === "string" ? typeResult.type.displayNameKey : void 0; | ||
| return buildFieldMapping(typeResult.type.fields || {}, displayNameKey); | ||
| } catch { | ||
| return defaultFieldMapping(); | ||
| } | ||
| } | ||
| function normalizeFrontmatter(raw, mapping) { | ||
| const result = {}; | ||
| for (const [key, value] of Object.entries(raw)) { | ||
| const role = mapping.fieldToRole[key]; | ||
| result[role ?? key] = value; | ||
| } | ||
| return result; | ||
| } | ||
| function denormalizeFrontmatter(roleData, mapping) { | ||
| const result = {}; | ||
| const rolesSet = new Set(ALL_ROLES); | ||
| for (const [key, value] of Object.entries(roleData)) { | ||
| if (rolesSet.has(key)) { | ||
| result[mapping.roleToField[key]] = value; | ||
| } else { | ||
| result[key] = value; | ||
| } | ||
| } | ||
| return result; | ||
| } | ||
| function resolveField(mapping, role) { | ||
| return mapping.roleToField[role]; | ||
| } | ||
| function resolveDisplayTitle(frontmatter, mapping, taskPath) { | ||
| const candidates = [mapping.displayNameKey, "title"]; | ||
| const seen = /* @__PURE__ */ new Set(); | ||
| for (const key of candidates) { | ||
| if (seen.has(key)) continue; | ||
| seen.add(key); | ||
| const value = frontmatter[key]; | ||
| if (typeof value === "string" && value.trim().length > 0) { | ||
| return value; | ||
| } | ||
| } | ||
| if (typeof taskPath === "string" && taskPath.trim().length > 0) { | ||
| const fromPath = basename(taskPath, ".md").trim(); | ||
| if (fromPath.length > 0) { | ||
| return fromPath; | ||
| } | ||
| } | ||
| return void 0; | ||
| } | ||
| export { | ||
| buildFieldMapping, | ||
| defaultFieldMapping, | ||
| denormalizeFrontmatter, | ||
| getDefaultCompletedStatus, | ||
| isCompletedStatus, | ||
| loadFieldMapping, | ||
| normalizeFrontmatter, | ||
| resolveDisplayTitle, | ||
| resolveField | ||
| }; |
| // src/mapper.ts | ||
| function mapToFrontmatter(parsed) { | ||
| const fm = {}; | ||
| fm.title = parsed.title; | ||
| if (parsed.dueDate) fm.due = parsed.dueDate; | ||
| if (parsed.scheduledDate) fm.scheduled = parsed.scheduledDate; | ||
| if (parsed.priority) fm.priority = parsed.priority; | ||
| if (parsed.status) fm.status = parsed.status; | ||
| if (parsed.tags && parsed.tags.length > 0) fm.tags = parsed.tags; | ||
| if (parsed.contexts && parsed.contexts.length > 0) fm.contexts = parsed.contexts; | ||
| if (parsed.projects && parsed.projects.length > 0) { | ||
| fm.projects = parsed.projects.map(toProjectWikilink); | ||
| } | ||
| if (parsed.recurrence) fm.recurrence = parsed.recurrence; | ||
| if (parsed.estimate) fm.timeEstimate = parsed.estimate; | ||
| const body = parsed.details || void 0; | ||
| return { frontmatter: fm, body }; | ||
| } | ||
| function toProjectWikilink(project) { | ||
| const trimmed = project.trim(); | ||
| return isWikilink(trimmed) ? trimmed : `[[projects/${trimmed}]]`; | ||
| } | ||
| function isWikilink(value) { | ||
| return /^\[\[[^\]]+\]\]$/.test(value); | ||
| } | ||
| function extractProjectNames(projects) { | ||
| if (!projects) return []; | ||
| return projects.filter(Boolean).map((p) => { | ||
| const match = p.match(/\[\[(?:.*\/)?([^\]]+)\]\]/); | ||
| return match ? match[1] : p; | ||
| }); | ||
| } | ||
| export { | ||
| extractProjectNames, | ||
| mapToFrontmatter | ||
| }; |
| // src/recurrence.ts | ||
| import { createRequire } from "module"; | ||
| // src/date.ts | ||
| import { isValid, parseISO } from "date-fns"; | ||
| var DATE_ONLY_RE = /^(\d{4})-(\d{2})-(\d{2})$/; | ||
| var DATE_TIME_RE = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(\.\d{1,3})?(?:Z|([+-])(\d{2}):(\d{2}))?$/; | ||
| function parseDateToUTC(dateString) { | ||
| if (!dateString || dateString.trim().length === 0) { | ||
| throw new Error("Date string cannot be empty"); | ||
| } | ||
| const trimmed = dateString.trim(); | ||
| const dateOnlyMatch = trimmed.match(DATE_ONLY_RE); | ||
| if (dateOnlyMatch) { | ||
| const [, year, month, day] = dateOnlyMatch; | ||
| const y = Number(year); | ||
| const m = Number(month); | ||
| const d = Number(day); | ||
| const parsed2 = new Date(Date.UTC(y, m - 1, d, 0, 0, 0, 0)); | ||
| if (parsed2.getUTCFullYear() !== y || parsed2.getUTCMonth() !== m - 1 || parsed2.getUTCDate() !== d) { | ||
| throw new Error(`Invalid date "${dateString}".`); | ||
| } | ||
| return parsed2; | ||
| } | ||
| if (!isStrictDateTime(trimmed)) { | ||
| throw new Error(`Invalid date "${dateString}".`); | ||
| } | ||
| const parsed = parseISO(trimmed); | ||
| if (!isValid(parsed)) { | ||
| throw new Error(`Invalid date "${dateString}".`); | ||
| } | ||
| return parsed; | ||
| } | ||
| function isStrictDateTime(value) { | ||
| const match = value.match(DATE_TIME_RE); | ||
| if (!match) return false; | ||
| const [, year, month, day, hours, minutes, seconds, , tzSign, tzHours, tzMinutes] = match; | ||
| const y = Number(year); | ||
| const m = Number(month); | ||
| const d = Number(day); | ||
| const hh = Number(hours); | ||
| const mm = Number(minutes); | ||
| const ss = Number(seconds); | ||
| if (!isValidClockTime(hh, mm, ss) || !isValidCalendarDate(y, m, d)) { | ||
| return false; | ||
| } | ||
| return isValidOffset(tzSign, tzHours, tzMinutes); | ||
| } | ||
| function isValidCalendarDate(year, month, day) { | ||
| const date = new Date(Date.UTC(year, month - 1, day, 0, 0, 0, 0)); | ||
| return date.getUTCFullYear() === year && date.getUTCMonth() === month - 1 && date.getUTCDate() === day; | ||
| } | ||
| function isValidClockTime(hours, minutes, seconds) { | ||
| return hours >= 0 && hours <= 23 && minutes >= 0 && minutes <= 59 && seconds >= 0 && seconds <= 59; | ||
| } | ||
| function isValidOffset(tzSign, tzHours, tzMinutes) { | ||
| if (!tzSign) return true; | ||
| const offsetHours = Number(tzHours); | ||
| const offsetMinutes = Number(tzMinutes); | ||
| if (offsetHours > 14 || offsetMinutes > 59) return false; | ||
| if (offsetHours === 14 && offsetMinutes !== 0) return false; | ||
| return true; | ||
| } | ||
| // src/recurrence.ts | ||
| var require2 = createRequire(import.meta.url); | ||
| var { RRule } = require2("rrule"); | ||
| var DTSTART_RE = /DTSTART:(\d{8}(?:T\d{6}Z?)?);?/; | ||
| function completeRecurringTask(input) { | ||
| const completionDate = input.completionDate; | ||
| const completeInstances = Array.isArray(input.completeInstances) ? [...input.completeInstances] : []; | ||
| const skippedInstances = Array.isArray(input.skippedInstances) ? [...input.skippedInstances] : []; | ||
| if (!completeInstances.includes(completionDate)) { | ||
| completeInstances.push(completionDate); | ||
| } | ||
| const nextSkippedInstances = skippedInstances.filter((d) => d !== completionDate); | ||
| const schedule = recalculateRecurringScheduleInternal({ | ||
| recurrence: input.recurrence, | ||
| recurrenceAnchor: input.recurrenceAnchor, | ||
| scheduled: input.scheduled, | ||
| due: input.due, | ||
| dateCreated: input.dateCreated, | ||
| completeInstances, | ||
| skippedInstances: nextSkippedInstances, | ||
| referenceDate: completionDate, | ||
| completionDateForAnchor: completionDate | ||
| }); | ||
| return { | ||
| updatedRecurrence: schedule.updatedRecurrence, | ||
| nextScheduled: schedule.nextScheduled, | ||
| nextDue: schedule.nextDue, | ||
| completeInstances, | ||
| skippedInstances: nextSkippedInstances | ||
| }; | ||
| } | ||
| function recalculateRecurringSchedule(input) { | ||
| return recalculateRecurringScheduleInternal({ | ||
| ...input | ||
| }); | ||
| } | ||
| function recalculateRecurringScheduleInternal(input) { | ||
| const anchor = input.recurrenceAnchor === "completion" ? "completion" : "scheduled"; | ||
| const sourceDate = input.scheduled || input.dateCreated || input.referenceDate; | ||
| let updatedRecurrence = input.recurrence; | ||
| if (anchor === "completion") { | ||
| const anchorDate = input.completionDateForAnchor || input.referenceDate || sourceDate; | ||
| updatedRecurrence = updateDTSTARTInRecurrenceRule(updatedRecurrence, anchorDate) || updatedRecurrence; | ||
| } else { | ||
| updatedRecurrence = addDTSTARTToRecurrenceRule(updatedRecurrence, sourceDate) || updatedRecurrence; | ||
| } | ||
| const referenceDate = parseDateString(input.referenceDate) || parseDateString(input.scheduled); | ||
| if (!referenceDate) { | ||
| return { updatedRecurrence, nextScheduled: null, nextDue: null }; | ||
| } | ||
| const completionDay = parseDateString(input.referenceDate); | ||
| const completeInstances = Array.isArray(input.completeInstances) ? input.completeInstances : []; | ||
| const skippedInstances = Array.isArray(input.skippedInstances) ? input.skippedInstances : []; | ||
| const processedDates = /* @__PURE__ */ new Set([ | ||
| ...completeInstances, | ||
| ...skippedInstances, | ||
| formatDateUTC(referenceDate) | ||
| ]); | ||
| let nextOccurrence = getNextOccurrenceDate(updatedRecurrence, sourceDate, referenceDate, true); | ||
| if (completionDay) { | ||
| let guard = 0; | ||
| while (nextOccurrence && nextOccurrence.getTime() < completionDay.getTime() && guard < 1e3) { | ||
| nextOccurrence = getNextOccurrenceDate( | ||
| updatedRecurrence, | ||
| sourceDate, | ||
| nextOccurrence, | ||
| false | ||
| ); | ||
| guard++; | ||
| } | ||
| } | ||
| let processedGuard = 0; | ||
| while (nextOccurrence && processedGuard < 1e3) { | ||
| const dateStr = formatDateUTC(nextOccurrence); | ||
| if (!processedDates.has(dateStr)) break; | ||
| nextOccurrence = getNextOccurrenceDate(updatedRecurrence, sourceDate, nextOccurrence, false); | ||
| processedGuard++; | ||
| } | ||
| if (!nextOccurrence) { | ||
| return { updatedRecurrence, nextScheduled: null, nextDue: null }; | ||
| } | ||
| const nextScheduled = formatLikeExisting(input.scheduled, nextOccurrence); | ||
| const nextDue = computeNextDue(input, nextOccurrence); | ||
| return { updatedRecurrence, nextScheduled, nextDue }; | ||
| } | ||
| function computeNextDue(input, nextScheduledDate) { | ||
| if (!input.due || !input.scheduled) { | ||
| return null; | ||
| } | ||
| const originalDue = parseDateString(input.due); | ||
| const originalScheduled = parseDateString(input.scheduled); | ||
| if (!originalDue || !originalScheduled) { | ||
| return null; | ||
| } | ||
| const offsetMs = originalDue.getTime() - originalScheduled.getTime(); | ||
| const nextDueDate = new Date(nextScheduledDate.getTime() + offsetMs); | ||
| return formatLikeExisting(input.due, nextDueDate); | ||
| } | ||
| function getNextOccurrenceDate(recurrence, sourceDate, afterDate, inclusive) { | ||
| const rule = buildRRule(recurrence, sourceDate); | ||
| if (!rule) return null; | ||
| return rule.after(afterDate, inclusive); | ||
| } | ||
| function buildRRule(recurrence, sourceDate) { | ||
| try { | ||
| const dtstartMatch = recurrence.match(DTSTART_RE); | ||
| const rruleString = recurrence.replace(DTSTART_RE, "").replace(/^;/, "").trim(); | ||
| if (!rruleString.includes("FREQ=")) { | ||
| return null; | ||
| } | ||
| const options = RRule.parseString(rruleString); | ||
| const dtstart = parseDTSTARTValue(dtstartMatch?.[1]) || parseDateString(sourceDate); | ||
| if (dtstart) { | ||
| options.dtstart = dtstart; | ||
| } | ||
| return new RRule(options); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function addDTSTARTToRecurrenceRule(recurrence, sourceDate) { | ||
| if (!recurrence || recurrence.includes("DTSTART:")) { | ||
| return recurrence; | ||
| } | ||
| const dtstart = formatDTSTARTValue(sourceDate); | ||
| if (!dtstart) return null; | ||
| return `DTSTART:${dtstart};${recurrence}`; | ||
| } | ||
| function updateDTSTARTInRecurrenceRule(recurrence, dateStr) { | ||
| if (!recurrence) return null; | ||
| const dtstart = formatDTSTARTValue(dateStr); | ||
| if (!dtstart) return null; | ||
| if (recurrence.includes("DTSTART:")) { | ||
| return recurrence.replace(DTSTART_RE, `DTSTART:${dtstart};`); | ||
| } | ||
| return `DTSTART:${dtstart};${recurrence}`; | ||
| } | ||
| function formatDTSTARTValue(dateStr) { | ||
| if (!dateStr) return null; | ||
| if (dateStr.includes("T")) { | ||
| const parsed2 = parseDateString(dateStr); | ||
| if (!parsed2) return null; | ||
| const year2 = parsed2.getUTCFullYear(); | ||
| const month2 = String(parsed2.getUTCMonth() + 1).padStart(2, "0"); | ||
| const day2 = String(parsed2.getUTCDate()).padStart(2, "0"); | ||
| const hours = String(parsed2.getUTCHours()).padStart(2, "0"); | ||
| const minutes = String(parsed2.getUTCMinutes()).padStart(2, "0"); | ||
| const seconds = String(parsed2.getUTCSeconds()).padStart(2, "0"); | ||
| return `${year2}${month2}${day2}T${hours}${minutes}${seconds}Z`; | ||
| } | ||
| const parsed = parseDateString(dateStr); | ||
| if (!parsed) return null; | ||
| const year = parsed.getUTCFullYear(); | ||
| const month = String(parsed.getUTCMonth() + 1).padStart(2, "0"); | ||
| const day = String(parsed.getUTCDate()).padStart(2, "0"); | ||
| return `${year}${month}${day}`; | ||
| } | ||
| function parseDTSTARTValue(value) { | ||
| if (!value) return null; | ||
| if (value.length === 8) { | ||
| const year = Number(value.slice(0, 4)); | ||
| const month = Number(value.slice(4, 6)) - 1; | ||
| const day = Number(value.slice(6, 8)); | ||
| return new Date(Date.UTC(year, month, day, 0, 0, 0, 0)); | ||
| } | ||
| const dtMatch = value.match( | ||
| /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/ | ||
| ); | ||
| if (!dtMatch) return null; | ||
| const [, y, m, d, hh, mm, ss] = dtMatch; | ||
| return new Date( | ||
| Date.UTC(Number(y), Number(m) - 1, Number(d), Number(hh), Number(mm), Number(ss), 0) | ||
| ); | ||
| } | ||
| function parseDateString(dateStr) { | ||
| if (!dateStr) return null; | ||
| try { | ||
| return parseDateToUTC(dateStr); | ||
| } catch { | ||
| return null; | ||
| } | ||
| } | ||
| function formatLikeExisting(existingValue, date) { | ||
| const datePart = formatDateUTC(date); | ||
| if (existingValue && existingValue.includes("T")) { | ||
| return `${datePart}T${existingValue.split("T")[1]}`; | ||
| } | ||
| return datePart; | ||
| } | ||
| function formatDateUTC(date) { | ||
| const y = date.getUTCFullYear(); | ||
| const m = String(date.getUTCMonth() + 1).padStart(2, "0"); | ||
| const d = String(date.getUTCDate()).padStart(2, "0"); | ||
| return `${y}-${m}-${d}`; | ||
| } | ||
| export { | ||
| completeRecurringTask, | ||
| recalculateRecurringSchedule | ||
| }; |
+3
-3
| { | ||
| "name": "mdbase-tasknotes", | ||
| "version": "0.1.2", | ||
| "version": "0.1.3", | ||
| "description": "Standalone CLI for managing markdown tasks via mdbase", | ||
@@ -35,3 +35,3 @@ "type": "module", | ||
| "dependencies": { | ||
| "@callumalpass/mdbase": "^0.2.1", | ||
| "@callumalpass/mdbase": "^0.2.2", | ||
| "chalk": "^4.1.2", | ||
@@ -45,3 +45,3 @@ "commander": "^12.1.0", | ||
| "@types/node": "^20.0.0", | ||
| "tasknotes-nlp-core": "^0.1.0", | ||
| "tasknotes-nlp-core": "^0.1.2", | ||
| "tsup": "^8.0.0", | ||
@@ -48,0 +48,0 @@ "typescript": "^5.5.0" |
+16
-0
@@ -94,4 +94,20 @@ # mdbase-tasknotes | ||
| ## Creating Tasks With Custom Paths | ||
| `match.path_glob` and `path_pattern` do different jobs in `_types/task.md`: | ||
| - `match.path_glob` tells mdbase which existing files should be treated as tasks. | ||
| - `path_pattern` tells `mtn create` where to write a new task file. | ||
| If your task type only has `match.path_glob`, listing existing tasks can work, but creating a new task without an explicit path cannot choose a filename. Add `path_pattern` for creation: | ||
| ```yaml | ||
| path_pattern: "calendar/{{year}}/{{month}}-{{monthNameShort}}/{{titleKebab}}.md" | ||
| match: | ||
| path_glob: "calendar/**/*.md" | ||
| ``` | ||
| ## License | ||
| MIT |
Sorry, the diff of this file is too big to display
Debug access
Supply chain riskUses debug, reflection and dynamic code execution features.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
Debug access
Supply chain riskUses debug, reflection and dynamic code execution features.
Found 1 instance in 1 package
Environment variable access
Supply chain riskPackage accesses environment variables, which may be a sign of credential stuffing or data theft.
Found 1 instance in 1 package
Filesystem access
Supply chain riskAccesses the file system, and could potentially read sensitive data.
Found 1 instance in 1 package
Long strings
Supply chain riskContains long string literals, which may be a sign of obfuscated or packed code.
Found 1 instance in 1 package
690329
27.37%11
266.67%18319
27.83%113
16.49%60
27.66%Updated