@beauraines/rtm-cli 1.9.3 → 1.11.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 +14 -0
- package/README.md +27 -3
- package/package.json +2 -2
- package/src/cmd/obsidian.js +253 -0
- package/src/cmd/task.js +20 -2
- package/src/tests/obsidian.test.js +74 -0
- package/src/tests/sanitizeTag.test.js +24 -0
- package/src/utils/sanitizeTag.js +14 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,20 @@
|
|
|
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.11.0](https://github.com/beauraines/rtm-cli/compare/v1.10.0...v1.11.0) (2025-12-17)
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
### Features
|
|
9
|
+
|
|
10
|
+
* **tasks:** adds list name to command output ([#161](https://github.com/beauraines/rtm-cli/issues/161)) ([174c669](https://github.com/beauraines/rtm-cli/commit/174c669e3c667278ffe3a295331d1e9041feae35))
|
|
11
|
+
|
|
12
|
+
## [1.10.0](https://github.com/beauraines/rtm-cli/compare/v1.9.3...v1.10.0) (2025-12-17)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
### Features
|
|
16
|
+
|
|
17
|
+
* 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))
|
|
18
|
+
|
|
5
19
|
### [1.9.3](https://github.com/beauraines/rtm-cli/compare/v1.9.2...v1.9.3) (2025-10-05)
|
|
6
20
|
|
|
7
21
|
|
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
|
-
|
|
136
|
-
|
|
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.
|
|
3
|
+
"version": "1.11.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.
|
|
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
|
+
};
|
package/src/cmd/task.js
CHANGED
|
@@ -9,6 +9,7 @@ const debug = require('debug')('rtm-cli-task');
|
|
|
9
9
|
|
|
10
10
|
|
|
11
11
|
let TASKS = [];
|
|
12
|
+
let LIST_MAP = new Map();
|
|
12
13
|
|
|
13
14
|
// Get Display Styles
|
|
14
15
|
let styles = config.get().styles;
|
|
@@ -34,6 +35,18 @@ async function action(args, env) {
|
|
|
34
35
|
}
|
|
35
36
|
|
|
36
37
|
|
|
38
|
+
// Fetch all RTM lists to map IDs to names
|
|
39
|
+
try {
|
|
40
|
+
log.spinner.start('Fetching Lists');
|
|
41
|
+
const lists = await new Promise((res, rej) => user.lists.get((err, lists) => err ? rej(err) : res(lists)));
|
|
42
|
+
LIST_MAP = new Map(lists.map(l => [l.id, l.name]));
|
|
43
|
+
} catch (e) {
|
|
44
|
+
log.spinner.warn(`Could not fetch lists: ${e.message || e}`);
|
|
45
|
+
} finally {
|
|
46
|
+
log.spinner.stop();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
|
|
37
50
|
log.spinner.start("Getting Task(s)");
|
|
38
51
|
// Use provided args
|
|
39
52
|
for (const arg in args[0]) {
|
|
@@ -80,9 +93,14 @@ function displayTask(taskDetails) {
|
|
|
80
93
|
let index = taskDetails.index;
|
|
81
94
|
// eslint-disable-next-line no-unused-vars
|
|
82
95
|
const { _list, list_id, taskseries_id, task_id, _index, name, priority, start, due, completed, isRecurring, isSubtask, estimate, url, tags, notes ,...otherAttributes } = taskDetails.task;
|
|
96
|
+
|
|
97
|
+
const listName = LIST_MAP.get(list_id) || "Not found";
|
|
98
|
+
|
|
83
99
|
log.style(index + " " + name,styles.list,true);
|
|
84
|
-
log.style(`List: `,styles.index)
|
|
85
|
-
log(`${
|
|
100
|
+
log.style(`List Name: `,styles.index)
|
|
101
|
+
log(`${listName}`)
|
|
102
|
+
log.style(`List Id: `,styles.index)
|
|
103
|
+
log(`${list_id}`)
|
|
86
104
|
log.style(`Priority: `,styles.index)
|
|
87
105
|
log.style(`${priority}`,styles.priority[priority],true)
|
|
88
106
|
log.style(`Start: `,styles.index)
|
|
@@ -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;
|