@beauraines/rtm-cli 1.9.3 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. See [standard-version](https://github.com/conventional-changelog/standard-version) for commit guidelines.
4
4
 
5
+ ## [1.10.0](https://github.com/beauraines/rtm-cli/compare/v1.9.3...v1.10.0) (2025-12-17)
6
+
7
+
8
+ ### Features
9
+
10
+ * new command to convert tasks to Obsidian Task format ([#157](https://github.com/beauraines/rtm-cli/issues/157)) ([23c2b39](https://github.com/beauraines/rtm-cli/commit/23c2b391c4a507927cac2aeaed8f5de8557c6b8a))
11
+
5
12
  ### [1.9.3](https://github.com/beauraines/rtm-cli/compare/v1.9.2...v1.9.3) (2025-10-05)
6
13
 
7
14
 
package/README.md CHANGED
@@ -7,6 +7,8 @@ Remember The Milk Command Line Interface
7
7
 
8
8
  ❗❗ **Resolved login issue blocking new users** ❗❗
9
9
 
10
+ ❗❗ **Adds an output format to convert tasks to [Obsidian Tasks](https://publish.obsidian.md/tasks/Getting+Started/Getting+Started) format** ❗❗
11
+
10
12
  ---
11
13
 
12
14
  This Node module provides a command line interface, written in JavaScript,
@@ -101,6 +103,7 @@ The main usage of the program:
101
103
  url [options] [indices...] Display the associated URL of a Task
102
104
  whoami Display RTM user information
103
105
  overdue Display incomplete tasks that are overdue
106
+ obsidian [indices...] Output tasks in Obsidian Task syntax. Export URLs and notes to configured directory (defaults to system temp dir)
104
107
  ```
105
108
 
106
109
 
@@ -131,9 +134,9 @@ Currently, the configuration can customize:
131
134
  - the display of completed tasks
132
135
  - the display of tasks with due dates in the future
133
136
  - **custom aliases** for existing commands
134
- - these are useful for applying commonly used [RTM advanced search](https://www.rememberthemilk.com/help/answer/basics-search-advanced)
135
- filters to display commands
136
- - ex: `overdue` = `ls dueBefore:today AND status:incomplete`
137
+ - these are useful for applying commonly used [RTM advanced search](https://www.rememberthemilk.com/help/answer/basics-search-advanced) filters to display commands
138
+
139
+ - obsidianTaskDir: path to a directory where the `obsidian` command writes URLs and notes (defaults to the system temporary directory)
137
140
 
138
141
 
139
142
  For full documentation on the configuration properties, see the
@@ -154,3 +157,24 @@ For information on installing plugins, see the
154
157
 
155
158
  For information on creating commands, see the **Creating Commands** section
156
159
  in the [Project Wiki](https://github.com/dwaring87/rtm-cli/wiki#creating-commands).
160
+
161
+ ### Obsidian Usage Example
162
+
163
+ Will create output in the [Obsidian Tasks](https://publish.obsidian.md/tasks/Getting+Started/Getting+Started) format. Currently, this only works for incomplete tasks.
164
+
165
+ For example, `rtm ls icemaker` would output
166
+
167
+ ```
168
+ Personal
169
+ 4330 (1) descale icemaker 🔁 | Tue Dec-16
170
+ ```
171
+
172
+ and `rtm obsidian 4330` would output
173
+
174
+ `- [ ] descale icemaker ⌛30m ➕ 2025-09-28 📅 2025-12-16 🔁 every 3 months 🔺 #Personal 🆔 4330`
175
+
176
+ which could be written to a file in your Obsidian Vault.
177
+
178
+ ```shell
179
+ rtm ls due:today | cut -wf1 | sort | xargs ./src/cli.js obsidian >> ~/LocalDocs/Test/Tasks/rtm.md
180
+ ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@beauraines/rtm-cli",
3
- "version": "1.9.3",
3
+ "version": "1.10.0",
4
4
  "description": "RTM CLI",
5
5
  "keywords": [
6
6
  "rtm",
@@ -36,7 +36,7 @@
36
36
  },
37
37
  "homepage": "https://github.com/beauraines/rtm-cli#readme",
38
38
  "dependencies": {
39
- "@beauraines/rtm-api": "^1.6.0",
39
+ "@beauraines/rtm-api": "^1.13.1",
40
40
  "chalk": "^4.0.0",
41
41
  "cli-table3": "^0.6.3",
42
42
  "commander": "^2.11.0",
@@ -0,0 +1,253 @@
1
+ 'use strict';
2
+
3
+ const df = require('dateformat');
4
+ const log = require('../utils/log.js');
5
+ const config = require('../utils/config.js');
6
+ const finish = require('../utils/finish.js');
7
+ const filter = require('../utils/filter');
8
+ const sanitizeTag = require('../utils/sanitizeTag');
9
+ const { indexPrompt } = require('../utils/prompt');
10
+ const debug = require('debug')('rtm-cli-obsidian');
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const os = require('os');
14
+
15
+ let TASKS = [];
16
+ // Map of RTM list IDs to names
17
+ let LIST_MAP = new Map();
18
+
19
+ /**
20
+ * This command outputs tasks in Obsidian Tasks markdown syntax
21
+ * @param args indices
22
+ * @param env
23
+ */
24
+ async function action(args, env) {
25
+ TASKS = [];
26
+ const user = config.user(user => user);
27
+
28
+ let indices;
29
+ if (args.length < 1) {
30
+ indices = indexPrompt('Task:');
31
+ } else {
32
+ // Support multiple indices array
33
+ indices = Array.isArray(args[0]) ? args[0] : [args[0]];
34
+ }
35
+
36
+ // Fetch all RTM lists to map IDs to names
37
+ try {
38
+ log.spinner.start('Fetching Lists');
39
+ const lists = await new Promise((res, rej) => user.lists.get((err, lists) => err ? rej(err) : res(lists)));
40
+ LIST_MAP = new Map(lists.map(l => [l.id, l.name]));
41
+ } catch (e) {
42
+ log.spinner.warn(`Could not fetch lists: ${e.message || e}`);
43
+ } finally {
44
+ log.spinner.stop();
45
+ }
46
+
47
+ log.spinner.start('Getting Task(s)');
48
+ for (const idx of indices) {
49
+ const filterString = filter();
50
+ let response = await user.tasks.rtmIndexFetchTask(idx, filterString);
51
+ if (response.err) {
52
+ log.spinner.warn(`Task #${idx} not found`);
53
+ continue;
54
+ }
55
+ TASKS.push({ idx, task: response.task });
56
+ }
57
+ log.spinner.stop();
58
+
59
+ for (const { idx, task } of TASKS) {
60
+ displayObsidianTask(idx, task);
61
+ }
62
+
63
+ finish();
64
+ }
65
+
66
+ /**
67
+ * Format and log a single task in Obsidian Tasks syntax
68
+ */
69
+ function displayObsidianTask(idx, task) {
70
+ debug(task);
71
+ const { name, priority, start, due, completed, tags = [], added, url, list_id, notes = [], estimate, isRecurring, recurrenceRuleRaw } = task;
72
+
73
+ const listName = LIST_MAP.get(list_id) || list_id;
74
+ // Slugify list name for Obsidian tag
75
+ const listTag = listName.replace(/\s+/g, '-');
76
+ const checkbox = completed ? 'x' : ' ';
77
+ let line = `- [${checkbox}] ${name}`;
78
+
79
+ if (estimate) {
80
+ const dur = formatDuration(estimate);
81
+ line += ` ⌛${dur}`;
82
+ }
83
+
84
+ if (notes.length) {
85
+ line += ` 📓`;
86
+ }
87
+
88
+ if (url) {
89
+ line += ` 🔗`;
90
+ }
91
+
92
+ // Add Obsidian wiki link to the exported detail file
93
+ if (url || notes.length) {
94
+ line += ` [[${idx}]]`;
95
+ }
96
+
97
+ if (added) {
98
+ let createdISO = df(added,"isoDate");
99
+ line += ` ➕ ${createdISO}`;
100
+ }
101
+ if (start) {
102
+ let startISO = df(start,"isoDate");
103
+ line += ` 🛫 ${startISO}`;
104
+ }
105
+ if (due) {
106
+ let dueISO = df(due,"isoDate");
107
+ line += ` 📅 ${dueISO}`;
108
+
109
+ // Recurrence indicator
110
+ if (isRecurring) {
111
+ if (recurrenceRuleRaw) {
112
+ const rec = formatRecurrence(recurrenceRuleRaw);
113
+ line += ` 🔁 ${rec}`;
114
+ } else {
115
+ line += ` 🔁`;
116
+ }
117
+ }
118
+ }
119
+
120
+
121
+
122
+ const priorityMap = { '1': '🔺', '2': '🔼', '3': '🔽' };
123
+ if (priority && priorityMap[priority]) {
124
+ line += ` ${priorityMap[priority]}`;
125
+ }
126
+
127
+ // TODO add location as tags https://github.com/beauraines/rtm-cli/issues/159
128
+ // Add list tag first, then other tags
129
+ const allTags = [`#${listTag}`, ...tags.map(t => `#${sanitizeTag(t)}`)];
130
+ const tagStr = allTags.map(t => ` ${t}`).join('');
131
+ line += `${tagStr}`;
132
+
133
+ line += ` 🆔 ${idx}`;
134
+
135
+ if (url || notes.length) {
136
+ exportDetails(idx, url, notes);
137
+ }
138
+
139
+ log(line);
140
+ }
141
+
142
+ // Helper: format ISO8601 durations (e.g. PT1H30M) to human label
143
+ function formatDuration(iso) {
144
+ const match = iso.match(/^P(?:T(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?)$/);
145
+ if (!match) return iso;
146
+ const [, H, M, S] = match;
147
+ const parts = [];
148
+ if (H) parts.push(`${H}h`);
149
+ if (M) parts.push(`${M}m`);
150
+ if (S) parts.push(`${S}s`);
151
+ return parts.join('') || iso;
152
+ }
153
+
154
+ // Helper: format RFC5545 recurrence to Obsidian Tasks syntax
155
+ function formatRecurrence(raw) {
156
+ let rule = raw;
157
+ if (typeof raw === 'string') {
158
+ try {
159
+ rule = JSON.parse(raw);
160
+ } catch (e) {
161
+ return raw;
162
+ }
163
+ }
164
+ if (rule.$t) {
165
+ const parts = rule.$t.split(';');
166
+ const map = {};
167
+ parts.forEach(p => {
168
+ const [k, v] = p.split('=');
169
+ map[k] = v;
170
+ });
171
+ const FREQ = map.FREQ;
172
+ const INTERVAL = parseInt(map.INTERVAL) || 1;
173
+ const BYDAY = map.BYDAY;
174
+ const BYMONTH = map.BYMONTH;
175
+ const BYMONTHDAY = map.BYMONTHDAY;
176
+ const getOrdinal = n => {
177
+ const s = ['th','st','nd','rd'];
178
+ const v = n % 100;
179
+ return s[(v-20)%10] || s[v] || s[0];
180
+ };
181
+ const weekdayNames = { MO:'Monday', TU:'Tuesday', WE:'Wednesday', TH:'Thursday', FR:'Friday', SA:'Saturday', SU:'Sunday' };
182
+ 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' };
183
+ switch (FREQ) {
184
+ case 'DAILY':
185
+ return INTERVAL === 1 ? 'every day' : `every ${INTERVAL} days`;
186
+ case 'WEEKLY': {
187
+ const days = BYDAY ? BYDAY.split(',').map(d => weekdayNames[d] || d).join(', ') : '';
188
+ if (INTERVAL > 1) {
189
+ return days ? `every ${INTERVAL} weeks on ${days}` : `every ${INTERVAL} weeks`;
190
+ }
191
+ return days ? `every ${days}` : 'every week';
192
+ }
193
+ case 'MONTHLY':
194
+ if (BYMONTHDAY) {
195
+ const day = parseInt(BYMONTHDAY);
196
+ const ord = getOrdinal(day);
197
+ return INTERVAL > 1 ? `every ${INTERVAL} months on the ${day}${ord}` : `every month on the ${day}${ord}`;
198
+ }
199
+ return INTERVAL > 1 ? `every ${INTERVAL} months` : 'every month';
200
+ case 'YEARLY':
201
+ if (BYMONTH && BYMONTHDAY) {
202
+ const month = monthNames[BYMONTH] || BYMONTH;
203
+ const day = parseInt(BYMONTHDAY);
204
+ const ord = getOrdinal(day);
205
+ return INTERVAL > 1 ? `every ${INTERVAL} years on ${month} ${day}${ord}` : `every year on ${month} ${day}${ord}`;
206
+ }
207
+ return INTERVAL > 1 ? `every ${INTERVAL} years` : 'every year';
208
+ }
209
+ }
210
+ if (rule.every) {
211
+ return `every ${rule.every}`;
212
+ }
213
+ return '';
214
+ }
215
+
216
+ // Helper: export URL and notes to a file in /tmp
217
+ function exportDetails(idx, url, notes) {
218
+ const fileName = `${idx}.md`;
219
+ const exportDir = (process.env.NODE_ENV === 'test' ? os.tmpdir() : (config.config.obsidianTaskDir || os.tmpdir()));
220
+ const filePath = path.join(exportDir, 'rtm', fileName);
221
+ let content = '';
222
+ if (url) {
223
+ content += `🔗 [${url}](${url})\n\n---\n\n`;
224
+ }
225
+ if (notes && notes.length) {
226
+ notes.forEach((n, i) => {
227
+ const title = n.title || '';
228
+ const body = n.content || n.body || n.text || '';
229
+ if (title) content += `${title}\n`;
230
+ if (body) content += `${body}\n`;
231
+ content += `\n---\n\n`;
232
+ });
233
+ }
234
+ // Trim trailing newline for combined URL and notes case
235
+ if (url && notes && notes.length) {
236
+ content = content.replace(/\n$/, '');
237
+ }
238
+ // Ensure the export directory exists
239
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
240
+ try {
241
+ fs.writeFileSync(filePath, content);
242
+ } catch (e) {
243
+ console.error(`Failed to write details file for task ${idx}: ${e}`);
244
+ }
245
+ }
246
+
247
+ module.exports = {
248
+ command: 'obsidian [indices...]',
249
+ options: [],
250
+ 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',
251
+ action: action,
252
+ exportDetails
253
+ };
@@ -0,0 +1,74 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+ const os = require('os');
4
+ const { exportDetails } = require('../cmd/obsidian');
5
+
6
+ describe('exportDetails', () => {
7
+ const tmpDir = os.tmpdir();
8
+ const idx = 'test123';
9
+ const filePath = path.join(tmpDir, 'rtm', `${idx}.md`);
10
+
11
+ afterEach(() => {
12
+ if (fs.existsSync(filePath)) {
13
+ fs.unlinkSync(filePath);
14
+ }
15
+ });
16
+
17
+ test('exports URL only', () => {
18
+ exportDetails(idx, 'http://example.com', []);
19
+ const content = fs.readFileSync(filePath, 'utf-8');
20
+ expect(content).toBe('🔗 [http://example.com](http://example.com)\n\n---\n\n');
21
+ });
22
+
23
+ test('exports notes only', () => {
24
+ const notes = [
25
+ {
26
+ id: 114947974,
27
+ created: "2025-12-15T15:51:05.000Z",
28
+ modified: "2025-12-15T15:51:05.000Z",
29
+ title: undefined,
30
+ body: "Duplicate model names from different connections don't display in the drop down"
31
+ }
32
+ ];
33
+ exportDetails(idx, null, notes);
34
+ const expected = `Duplicate model names from different connections don't display in the drop down\n\n---\n\n`;
35
+ const content = fs.readFileSync(filePath, 'utf-8');
36
+ expect(content).toBe(expected);
37
+ });
38
+
39
+ test('exports URL and notes', () => {
40
+ const notes = [
41
+ {
42
+ id: 114947974,
43
+ created: "2025-12-15T15:51:05.000Z",
44
+ modified: "2025-12-15T15:51:05.000Z",
45
+ title: undefined,
46
+ body: "Duplicate model names from different connections don't display in the drop down"
47
+ },
48
+ {
49
+ id: 114947974,
50
+ created: "2025-12-15T15:51:05.000Z",
51
+ modified: "2025-12-15T15:51:05.000Z",
52
+ title: "Note 2",
53
+ body: "note 2 body"
54
+ }
55
+ ];
56
+ exportDetails(idx, 'http://ex.com', notes);
57
+ const content = fs.readFileSync(filePath, 'utf-8');
58
+ const expected = [];
59
+ expected.push('🔗 [http://ex.com](http://ex.com)');
60
+ expected.push('');
61
+ expected.push('---');
62
+ expected.push('');
63
+ expected.push(`Duplicate model names from different connections don't display in the drop down`);
64
+ expected.push('');
65
+ expected.push('---');
66
+ expected.push('');
67
+ expected.push('Note 2');
68
+ expected.push('note 2 body');
69
+ expected.push('');
70
+ expected.push('---');
71
+ expected.push('');
72
+ expect(content.split('\n')).toEqual(expected);
73
+ });
74
+ });
@@ -0,0 +1,24 @@
1
+ const sanitizeTag = require('../utils/sanitizeTag');
2
+
3
+ describe('sanitizeTag', () => {
4
+ test('replaces leading @ with context/', () => {
5
+ expect(sanitizeTag('@home')).toBe('context/home');
6
+ });
7
+
8
+ test('leaves tags without @ unchanged', () => {
9
+ expect(sanitizeTag('work')).toBe('work');
10
+ });
11
+
12
+ test('returns context/ for standalone @', () => {
13
+ expect(sanitizeTag('@')).toBe('context/');
14
+ });
15
+
16
+ test('handles empty string', () => {
17
+ expect(sanitizeTag('')).toBe('');
18
+ });
19
+
20
+ test('non-string inputs are returned as-is', () => {
21
+ expect(sanitizeTag(123)).toBe(123);
22
+ expect(sanitizeTag(null)).toBe(null);
23
+ });
24
+ });
@@ -0,0 +1,14 @@
1
+ 'use strict';
2
+
3
+ // This is used for the Obsidian reformatting
4
+
5
+
6
+ function sanitizeTag(tag) {
7
+ if (typeof tag !== 'string') return tag;
8
+ if (tag.startsWith('@')) {
9
+ return `context/${tag.slice(1)}`;
10
+ }
11
+ return tag;
12
+ }
13
+
14
+ module.exports = sanitizeTag;