@beauraines/rtm-cli
Version:
283 lines (247 loc) • 8.5 kB
JavaScript
;
const df = require('dateformat');
const log = require('../utils/log.js');
const config = require('../utils/config.js');
const finish = require('../utils/finish.js');
const filter = require('../utils/filter');
const sanitizeTag = require('../utils/sanitizeTag');
const { indexPrompt } = require('../utils/prompt');
const debug = require('debug')('rtm-cli-obsidian');
const fs = require('fs');
const path = require('path');
const os = require('os');
let TASKS = [];
// Map of RTM list IDs to names
let LIST_MAP = new Map();
let LOCATION_MAP = new Map();
/**
* This command outputs tasks in Obsidian Tasks markdown syntax
* @param args indices
* @param env
*/
async function action(args, env) {
TASKS = [];
const user = config.user(user => user);
let indices;
if (args.length < 1) {
indices = indexPrompt('Task:');
} else {
// Support multiple indices array
indices = Array.isArray(args[0]) ? args[0] : [args[0]];
}
// Fetch all RTM lists to map IDs to names
try {
log.spinner.start('Fetching Lists');
const lists = await new Promise((res, rej) => user.lists.get((err, lists) => err ? rej(err) : res(lists)));
LIST_MAP = new Map(lists.map(l => [l.id, l.name]));
} catch (e) {
log.spinner.warn(`Could not fetch lists: ${e.message || e}`);
} finally {
log.spinner.stop();
}
// Fetch all RTM Locations to map IDs to names
try {
log.spinner.start('Fetching Locations');
const locations = await new Promise((res, rej) => user.locations.get((err, locations) => err ? rej(err) : res(locations)));
LOCATION_MAP = new Map(locations.map(l => [l.id, l]));
} catch (e) {
log.spinner.warn(`Could not fetch locations: ${e.message || e}`);
} finally {
log.spinner.stop();
}
log.spinner.start('Getting Task(s)');
for (const idx of indices) {
const filterString = filter();
let response = await user.tasks.rtmIndexFetchTask(idx, filterString);
if (response.err) {
log.spinner.warn(`Task #${idx} not found`);
continue;
}
TASKS.push({ idx, task: response.task });
}
log.spinner.stop();
for (const { idx, task } of TASKS) {
displayObsidianTask(idx, task);
}
finish();
}
/**
* Format and log a single task in Obsidian Tasks syntax
*/
function displayObsidianTask(idx, task) {
debug(task);
const { name, priority, start, due, completed, tags = [], added, url, list_id, notes = [], estimate, isRecurring, recurrenceRuleRaw, location_id } = task;
const listName = LIST_MAP.get(list_id) || list_id;
// Slugify list name for Obsidian tag
const listTag = listName.replace(/\s+/g, '-');
task.list_name = listName;
const location = LOCATION_MAP.get(location_id)
const locationName = location?.name;
let locationTag ='';
locationName ? locationTag = '#location/'+locationName.replace(/\s+/g, '-') : null ;
task.location = location;
const checkbox = completed ? 'x' : ' ';
let line = `- [${checkbox}] ${name}`;
if (estimate) {
const dur = formatDuration(estimate);
line += ` ⌛${dur}`;
}
if (notes.length) {
line += ` 📓`;
}
if (url) {
line += ` 🔗`;
}
// Add Obsidian wiki link to the exported detail file
if (url || notes.length) {
line += ` [[${idx}]]`;
}
if (added) {
let createdISO = df(added,"isoDate");
line += ` ➕ ${createdISO}`;
}
if (start) {
let startISO = df(start,"isoDate");
line += ` 🛫 ${startISO}`;
}
if (due) {
let dueISO = df(due,"isoDate");
line += ` 📅 ${dueISO}`;
// Recurrence indicator
if (isRecurring) {
if (recurrenceRuleRaw) {
const rec = formatRecurrence(recurrenceRuleRaw);
line += ` 🔁 ${rec}`;
} else {
line += ` 🔁`;
}
}
}
const priorityMap = { '1': '🔺', '2': '🔼', '3': '🔽' };
if (priority && priorityMap[priority]) {
line += ` ${priorityMap[priority]}`;
}
// Add list tag first, then other tags
const allTags = [`#${listTag}`, `${locationTag}`, ...tags.map(t => `#${sanitizeTag(t)}`)];
const tagStr = allTags.map(t => ` ${t}`).join('');
line += `${tagStr}`;
line += ` 🆔 ${idx}`;
if (url || notes.length) {
exportDetails(idx, task);
}
log(line);
}
// Helper: format ISO8601 durations (e.g. PT1H30M) to human label
function formatDuration(iso) {
const match = iso.match(/^P(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)$/);
if (!match) return iso;
const [, H, M, S] = match;
const parts = [];
if (H) parts.push(`${H}h`);
if (M) parts.push(`${M}m`);
if (S) parts.push(`${S}s`);
return parts.join('') || iso;
}
// Helper: format RFC5545 recurrence to Obsidian Tasks syntax
function formatRecurrence(raw) {
let rule = raw;
if (typeof raw === 'string') {
try {
rule = JSON.parse(raw);
} catch (e) {
return raw;
}
}
if (rule.$t) {
const parts = rule.$t.split(';');
const map = {};
parts.forEach(p => {
const [k, v] = p.split('=');
map[k] = v;
});
const FREQ = map.FREQ;
const INTERVAL = parseInt(map.INTERVAL) || 1;
const BYDAY = map.BYDAY;
const BYMONTH = map.BYMONTH;
const BYMONTHDAY = map.BYMONTHDAY;
const getOrdinal = n => {
const s = ['th','st','nd','rd'];
const v = n % 100;
return s[(v-20)%10] || s[v] || s[0];
};
const weekdayNames = { MO:'Monday', TU:'Tuesday', WE:'Wednesday', TH:'Thursday', FR:'Friday', SA:'Saturday', SU:'Sunday' };
const monthNames = { '1':'January','2':'February','3':'March','4':'April','5':'May','6':'June','7':'July','8':'August','9':'September','10':'October','11':'November','12':'December' };
switch (FREQ) {
case 'DAILY':
return INTERVAL === 1 ? 'every day' : `every ${INTERVAL} days`;
case 'WEEKLY': {
const days = BYDAY ? BYDAY.split(',').map(d => weekdayNames[d] || d).join(', ') : '';
if (INTERVAL > 1) {
return days ? `every ${INTERVAL} weeks on ${days}` : `every ${INTERVAL} weeks`;
}
return days ? `every ${days}` : 'every week';
}
case 'MONTHLY':
if (BYMONTHDAY) {
const day = parseInt(BYMONTHDAY);
const ord = getOrdinal(day);
return INTERVAL > 1 ? `every ${INTERVAL} months on the ${day}${ord}` : `every month on the ${day}${ord}`;
}
return INTERVAL > 1 ? `every ${INTERVAL} months` : 'every month';
case 'YEARLY':
if (BYMONTH && BYMONTHDAY) {
const month = monthNames[BYMONTH] || BYMONTH;
const day = parseInt(BYMONTHDAY);
const ord = getOrdinal(day);
return INTERVAL > 1 ? `every ${INTERVAL} years on ${month} ${day}${ord}` : `every year on ${month} ${day}${ord}`;
}
return INTERVAL > 1 ? `every ${INTERVAL} years` : 'every year';
}
}
if (rule.every) {
return `every ${rule.every}`;
}
return '';
}
// Helper: export URL and notes to a file in /tmp
function exportDetails(idx, task) {
const fileName = `${idx}.md`;
const exportDir = (process.env.NODE_ENV === 'test' ? os.tmpdir() : (config.config.obsidianTaskDir || os.tmpdir()));
const filePath = path.join(exportDir, 'rtm', fileName);
let content = '';
const {url,notes} = task;
if (url) {
content += `🔗 [${url}](${url})\n\n---\n\n`;
}
if (notes && notes.length) {
notes.forEach((n, i) => {
const title = n.title || '';
const body = n.content || n.body || n.text || '';
if (title) content += `${title}\n`;
if (body) content += `${body}\n`;
content += `\n---\n\n`;
});
}
content += '```json\n';
content += JSON.stringify(task,2,4);
content += '\n```\n';
// Trim trailing newline for combined URL and notes case
if (url && notes && notes.length) {
content = content.replace(/\n$/, '');
}
// Ensure the export directory exists
fs.mkdirSync(path.dirname(filePath), { recursive: true });
try {
fs.writeFileSync(filePath, content);
console.error(`Task detail files written to ${filePath}`)
} catch (e) {
console.error(`Failed to write details file for task ${idx}: ${e}`);
}
}
module.exports = {
command: 'obsidian [indices...]',
options: [],
description: 'Output tasks in Obsidian Task syntax. Export URLs and notes to configured directory (defaults to system temp dir)\n\nusage: rtm -x true ls due:today | cut -wf1 | sort | xargs ./src/cli.js -x true obsidian >> ~/LocalDocs/Test/Tasks/rtm.md',
action: action,
exportDetails
};