stratanodex 0.1.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/README.md +251 -0
- package/dist/api/ApiError.js +10 -0
- package/dist/api/client.js +96 -0
- package/dist/commands/add.js +45 -0
- package/dist/commands/config.js +41 -0
- package/dist/commands/done.js +39 -0
- package/dist/commands/executor.js +457 -0
- package/dist/commands/list.js +86 -0
- package/dist/commands/login.js +1 -0
- package/dist/commands/loginFlow.js +86 -0
- package/dist/commands/logout.js +11 -0
- package/dist/commands/registry.js +351 -0
- package/dist/commands/resolver.js +184 -0
- package/dist/config.js +16 -0
- package/dist/index.js +77 -0
- package/dist/tui/App.js +141 -0
- package/dist/tui/components/AutocompleteOverlay.js +22 -0
- package/dist/tui/components/BottomBar.js +8 -0
- package/dist/tui/components/Breadcrumb.js +6 -0
- package/dist/tui/components/CommandInput.js +111 -0
- package/dist/tui/components/CommandPalette.js +1 -0
- package/dist/tui/components/ErrorBoundary.js +26 -0
- package/dist/tui/components/FocusMode.js +1 -0
- package/dist/tui/components/FolderItem.js +5 -0
- package/dist/tui/components/Header.js +5 -0
- package/dist/tui/components/Keybindings.js +5 -0
- package/dist/tui/components/ListItem.js +5 -0
- package/dist/tui/components/NodeRow.js +14 -0
- package/dist/tui/components/PriorityBadge.js +9 -0
- package/dist/tui/components/SearchOverlay.js +1 -0
- package/dist/tui/components/Spinner.js +23 -0
- package/dist/tui/components/StatusBadge.js +9 -0
- package/dist/tui/components/SuggestionItem.js +8 -0
- package/dist/tui/components/TopBar.js +13 -0
- package/dist/tui/components/TreeConnector.js +17 -0
- package/dist/tui/hooks/useAuth.js +37 -0
- package/dist/tui/hooks/useCommandInput.js +35 -0
- package/dist/tui/hooks/useFolders.js +16 -0
- package/dist/tui/hooks/useKeymap.js +30 -0
- package/dist/tui/hooks/useLists.js +16 -0
- package/dist/tui/hooks/useNavigation.js +15 -0
- package/dist/tui/hooks/useTree.js +93 -0
- package/dist/tui/screens/DailyScreen.js +77 -0
- package/dist/tui/screens/DashboardScreen.js +262 -0
- package/dist/tui/screens/HomeScreen.js +75 -0
- package/dist/tui/screens/ListsScreen.js +73 -0
- package/dist/tui/screens/LoginScreen.js +115 -0
- package/dist/tui/screens/NodeScreen.js +48 -0
- package/dist/tui/screens/TreeScreen.js +182 -0
- package/dist/tui/screens/WelcomeScreen.js +83 -0
- package/dist/tui/types.js +1 -0
- package/dist/types/index.js +1 -0
- package/dist/utils/auth.js +11 -0
- package/dist/utils/logger.js +32 -0
- package/dist/utils/numbering.js +38 -0
- package/dist/utils/recents.js +24 -0
- package/dist/utils/scoring.js +29 -0
- package/dist/utils/tree.js +125 -0
- package/package.json +74 -0
|
@@ -0,0 +1,457 @@
|
|
|
1
|
+
// executor.ts — Takes a fully-formed command string, parses it, and calls the API.
|
|
2
|
+
import { createFolder, updateFolder, deleteFolder, createList, updateList, deleteList, createRootNode, createChildNode, updateNode, deleteNode, moveNode, getMe, getStreak, getFolders, getLists, } from '../api/client.js';
|
|
3
|
+
import { flattenTree, assignNumbers } from '../utils/numbering.js';
|
|
4
|
+
import { clearToken } from '../utils/auth.js';
|
|
5
|
+
/** Resolve index-or-title to a node ID from the flat node list.
|
|
6
|
+
* Index can be hierarchical: "1", "1.2", "1.2.1" etc. */
|
|
7
|
+
function resolveNode(ref, nodes) {
|
|
8
|
+
const flat = flattenTree(nodes);
|
|
9
|
+
const numberMap = assignNumbers(nodes);
|
|
10
|
+
// Try matching by hierarchical number (e.g. "1", "1.2", "1.2.1")
|
|
11
|
+
for (const node of flat) {
|
|
12
|
+
if (numberMap.get(node.id) === ref)
|
|
13
|
+
return node;
|
|
14
|
+
}
|
|
15
|
+
// Fall back to title match
|
|
16
|
+
return flat.find((n) => n.title.toLowerCase() === ref.toLowerCase());
|
|
17
|
+
}
|
|
18
|
+
/** Parse a DD-MM-YYYY date string into ISO format. */
|
|
19
|
+
function parseDate(raw) {
|
|
20
|
+
const [dd, mm, yyyy] = raw.split('-');
|
|
21
|
+
if (!dd || !mm || !yyyy)
|
|
22
|
+
return null;
|
|
23
|
+
const iso = `${yyyy}-${mm}-${dd}`;
|
|
24
|
+
const d = new Date(iso);
|
|
25
|
+
return isNaN(d.getTime()) ? null : d.toISOString();
|
|
26
|
+
}
|
|
27
|
+
/** Parse HH:MM AM/PM into an ISO-compatible time offset (applied to today). */
|
|
28
|
+
function parseTime(raw, baseIso) {
|
|
29
|
+
// e.g. "10:30 AM" or "2:15 PM"
|
|
30
|
+
const match = raw.match(/^(\d{1,2}):(\d{2})\s*(AM|PM)$/i);
|
|
31
|
+
if (!match)
|
|
32
|
+
return null;
|
|
33
|
+
let hours = parseInt(match[1], 10);
|
|
34
|
+
const minutes = parseInt(match[2], 10);
|
|
35
|
+
const meridiem = match[3].toUpperCase();
|
|
36
|
+
if (meridiem === 'PM' && hours !== 12)
|
|
37
|
+
hours += 12;
|
|
38
|
+
if (meridiem === 'AM' && hours === 12)
|
|
39
|
+
hours = 0;
|
|
40
|
+
const base = baseIso ? new Date(baseIso) : new Date();
|
|
41
|
+
base.setHours(hours, minutes, 0, 0);
|
|
42
|
+
return base.toISOString();
|
|
43
|
+
}
|
|
44
|
+
export async function executeCommand(input, _screen, ctx) {
|
|
45
|
+
const trimmed = input.trim();
|
|
46
|
+
if (!trimmed.startsWith('/'))
|
|
47
|
+
return { ok: false, message: 'Commands must start with /' };
|
|
48
|
+
// ─── GLOBAL commands ────────────────────────────────────────────────────
|
|
49
|
+
if (trimmed === '/back') {
|
|
50
|
+
ctx.navigate?.('__pop__');
|
|
51
|
+
return { ok: true, message: '' };
|
|
52
|
+
}
|
|
53
|
+
if (trimmed === '/home' || trimmed === '/folders') {
|
|
54
|
+
ctx.navigate?.('home');
|
|
55
|
+
return { ok: true, message: '' };
|
|
56
|
+
}
|
|
57
|
+
if (trimmed === '/dashboard') {
|
|
58
|
+
ctx.navigate?.('dashboard');
|
|
59
|
+
return { ok: true, message: '' };
|
|
60
|
+
}
|
|
61
|
+
if (trimmed === '/logout') {
|
|
62
|
+
clearToken();
|
|
63
|
+
ctx.navigate?.('login');
|
|
64
|
+
return { ok: true, message: 'Logged out.' };
|
|
65
|
+
}
|
|
66
|
+
if (trimmed === '/help') {
|
|
67
|
+
ctx.navigate?.('help');
|
|
68
|
+
return { ok: true, message: '' };
|
|
69
|
+
}
|
|
70
|
+
if (trimmed === '/whoami') {
|
|
71
|
+
try {
|
|
72
|
+
const [user, streakData] = await Promise.all([getMe(), getStreak()]);
|
|
73
|
+
return { ok: true, message: `${user.name ?? user.email} · Streak: ${streakData.streak} days` };
|
|
74
|
+
}
|
|
75
|
+
catch (e) {
|
|
76
|
+
return { ok: false, message: e.message };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (trimmed === '/tags') {
|
|
80
|
+
ctx.navigate?.('tags');
|
|
81
|
+
return { ok: true, message: '' };
|
|
82
|
+
}
|
|
83
|
+
// ─── FOLDERS commands ───────────────────────────────────────────────────
|
|
84
|
+
if (trimmed.startsWith('/new folder ')) {
|
|
85
|
+
const name = trimmed.slice('/new folder '.length).trim();
|
|
86
|
+
if (!name)
|
|
87
|
+
return { ok: false, message: 'Folder name required.' };
|
|
88
|
+
try {
|
|
89
|
+
const f = await createFolder(name);
|
|
90
|
+
ctx.refetch?.();
|
|
91
|
+
return { ok: true, message: `✓ Created folder "${f.name}"` };
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
return { ok: false, message: e.message };
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (trimmed.startsWith('/delete folder ')) {
|
|
98
|
+
const name = trimmed.slice('/delete folder '.length).trim();
|
|
99
|
+
try {
|
|
100
|
+
const folders = await getFolders();
|
|
101
|
+
const target = folders.find((f) => f.name.toLowerCase() === name.toLowerCase());
|
|
102
|
+
if (!target)
|
|
103
|
+
return { ok: false, message: `Folder "${name}" not found.` };
|
|
104
|
+
await deleteFolder(target.id);
|
|
105
|
+
ctx.refetch?.();
|
|
106
|
+
return { ok: true, message: `✓ Deleted folder "${name}"` };
|
|
107
|
+
}
|
|
108
|
+
catch (e) {
|
|
109
|
+
return { ok: false, message: e.message };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (trimmed.startsWith('/edit folder ')) {
|
|
113
|
+
const rest = trimmed.slice('/edit folder '.length).trim();
|
|
114
|
+
const spaceIdx = rest.indexOf(' ');
|
|
115
|
+
if (spaceIdx === -1)
|
|
116
|
+
return { ok: false, message: 'Usage: /edit folder <name> <new-name>' };
|
|
117
|
+
const oldName = rest.slice(0, spaceIdx).trim();
|
|
118
|
+
const newName = rest.slice(spaceIdx + 1).trim();
|
|
119
|
+
try {
|
|
120
|
+
const folders = await getFolders();
|
|
121
|
+
const target = folders.find((f) => f.name.toLowerCase() === oldName.toLowerCase());
|
|
122
|
+
if (!target)
|
|
123
|
+
return { ok: false, message: `Folder "${oldName}" not found.` };
|
|
124
|
+
await updateFolder(target.id, { name: newName });
|
|
125
|
+
ctx.refetch?.();
|
|
126
|
+
return { ok: true, message: `✓ Renamed folder to "${newName}"` };
|
|
127
|
+
}
|
|
128
|
+
catch (e) {
|
|
129
|
+
return { ok: false, message: e.message };
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
// ─── LISTS commands ──────────────────────────────────────────────────────
|
|
133
|
+
if (trimmed.startsWith('/new list ')) {
|
|
134
|
+
const name = trimmed.slice('/new list '.length).trim();
|
|
135
|
+
if (!name)
|
|
136
|
+
return { ok: false, message: 'List name required.' };
|
|
137
|
+
if (!ctx.folderId)
|
|
138
|
+
return { ok: false, message: 'Not inside a folder.' };
|
|
139
|
+
try {
|
|
140
|
+
const l = await createList(name, ctx.folderId);
|
|
141
|
+
ctx.refetch?.();
|
|
142
|
+
return { ok: true, message: `✓ Created list "${l.name}"` };
|
|
143
|
+
}
|
|
144
|
+
catch (e) {
|
|
145
|
+
return { ok: false, message: e.message };
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (trimmed.startsWith('/delete list ')) {
|
|
149
|
+
const name = trimmed.slice('/delete list '.length).trim();
|
|
150
|
+
if (!ctx.folderId)
|
|
151
|
+
return { ok: false, message: 'Not inside a folder.' };
|
|
152
|
+
try {
|
|
153
|
+
const lists = await getLists(ctx.folderId);
|
|
154
|
+
const target = lists.find((l) => l.name.toLowerCase() === name.toLowerCase());
|
|
155
|
+
if (!target)
|
|
156
|
+
return { ok: false, message: `List "${name}" not found.` };
|
|
157
|
+
await deleteList(target.id);
|
|
158
|
+
ctx.refetch?.();
|
|
159
|
+
return { ok: true, message: `✓ Deleted list "${name}"` };
|
|
160
|
+
}
|
|
161
|
+
catch (e) {
|
|
162
|
+
return { ok: false, message: e.message };
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (trimmed.startsWith('/edit list ')) {
|
|
166
|
+
const rest = trimmed.slice('/edit list '.length).trim();
|
|
167
|
+
const spaceIdx = rest.indexOf(' ');
|
|
168
|
+
if (spaceIdx === -1)
|
|
169
|
+
return { ok: false, message: 'Usage: /edit list <name> <new-name>' };
|
|
170
|
+
const oldName = rest.slice(0, spaceIdx).trim();
|
|
171
|
+
const newName = rest.slice(spaceIdx + 1).trim();
|
|
172
|
+
if (!ctx.folderId)
|
|
173
|
+
return { ok: false, message: 'Not inside a folder.' };
|
|
174
|
+
try {
|
|
175
|
+
const lists = await getLists(ctx.folderId);
|
|
176
|
+
const target = lists.find((l) => l.name.toLowerCase() === oldName.toLowerCase());
|
|
177
|
+
if (!target)
|
|
178
|
+
return { ok: false, message: `List "${oldName}" not found.` };
|
|
179
|
+
await updateList(target.id, { name: newName });
|
|
180
|
+
ctx.refetch?.();
|
|
181
|
+
return { ok: true, message: `✓ Renamed list to "${newName}"` };
|
|
182
|
+
}
|
|
183
|
+
catch (e) {
|
|
184
|
+
return { ok: false, message: e.message };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
// ─── NODES commands ──────────────────────────────────────────────────────
|
|
188
|
+
const nodes = ctx.currentNodes ?? [];
|
|
189
|
+
const listId = ctx.listId;
|
|
190
|
+
if (trimmed.startsWith('/add node ') && !trimmed.includes(' ... ')) {
|
|
191
|
+
const title = trimmed.slice('/add node '.length).trim();
|
|
192
|
+
if (!title)
|
|
193
|
+
return { ok: false, message: 'Node title required.' };
|
|
194
|
+
if (!listId)
|
|
195
|
+
return { ok: false, message: 'Not inside a list.' };
|
|
196
|
+
try {
|
|
197
|
+
const n = await createRootNode(listId, { title });
|
|
198
|
+
ctx.refetch?.();
|
|
199
|
+
return { ok: true, message: `✓ Added "${n.title}"` };
|
|
200
|
+
}
|
|
201
|
+
catch (e) {
|
|
202
|
+
return { ok: false, message: e.message };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
if (trimmed.startsWith('/add sub-node ')) {
|
|
206
|
+
const rest = trimmed.slice('/add sub-node '.length).trim();
|
|
207
|
+
if (!rest)
|
|
208
|
+
return { ok: false, message: 'Sub-node title required.' };
|
|
209
|
+
if (!listId)
|
|
210
|
+
return { ok: false, message: 'Not inside a list.' };
|
|
211
|
+
// Check if first word is a numeric index like "1", "1.2", "1.2.1"
|
|
212
|
+
const parts = rest.split(/\s+/);
|
|
213
|
+
const indexPattern = /^\d+(\.\d+)*$/;
|
|
214
|
+
let parentId;
|
|
215
|
+
let title;
|
|
216
|
+
if (indexPattern.test(parts[0])) {
|
|
217
|
+
// /add sub-node <index> <title>
|
|
218
|
+
const idx = parts[0];
|
|
219
|
+
title = parts.slice(1).join(' ');
|
|
220
|
+
if (!title)
|
|
221
|
+
return { ok: false, message: 'Sub-node title required after index.' };
|
|
222
|
+
const flat = flattenTree(nodes);
|
|
223
|
+
const numberMap = new Map();
|
|
224
|
+
function buildNumbers(children, prefix) {
|
|
225
|
+
const sorted = [...children].sort((a, b) => a.position - b.position);
|
|
226
|
+
sorted.forEach((node, i) => {
|
|
227
|
+
const num = prefix ? `${prefix}.${i + 1}` : `${i + 1}`;
|
|
228
|
+
numberMap.set(num, node.id);
|
|
229
|
+
if ((node.children ?? []).length > 0)
|
|
230
|
+
buildNumbers(node.children, num);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
buildNumbers(nodes, '');
|
|
234
|
+
parentId = numberMap.get(idx);
|
|
235
|
+
if (!parentId)
|
|
236
|
+
return { ok: false, message: `Node "${idx}" not found.` };
|
|
237
|
+
}
|
|
238
|
+
else {
|
|
239
|
+
// /add sub-node <title> — use currently selected node
|
|
240
|
+
title = rest;
|
|
241
|
+
parentId = ctx.selectedNodeId;
|
|
242
|
+
if (!parentId)
|
|
243
|
+
return {
|
|
244
|
+
ok: false,
|
|
245
|
+
message: 'No node selected. Navigate to a node first, or specify an index.',
|
|
246
|
+
};
|
|
247
|
+
}
|
|
248
|
+
try {
|
|
249
|
+
const n = await createChildNode(parentId, { title });
|
|
250
|
+
ctx.refetch?.();
|
|
251
|
+
return { ok: true, message: `✓ Added sub-node "${n.title}"` };
|
|
252
|
+
}
|
|
253
|
+
catch (e) {
|
|
254
|
+
return { ok: false, message: e.message };
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
if (trimmed.startsWith('/done ')) {
|
|
258
|
+
const ref = trimmed.slice('/done '.length).trim();
|
|
259
|
+
const node = resolveNode(ref, nodes);
|
|
260
|
+
if (!node)
|
|
261
|
+
return { ok: false, message: `Node "${ref}" not found.` };
|
|
262
|
+
try {
|
|
263
|
+
await updateNode(node.id, { status: 'DONE' });
|
|
264
|
+
ctx.refetch?.();
|
|
265
|
+
return { ok: true, message: `✓ Done: "${node.title}"` };
|
|
266
|
+
}
|
|
267
|
+
catch (e) {
|
|
268
|
+
return { ok: false, message: e.message };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
if (trimmed.startsWith('/delete node ') && !trimmed.includes(' ... ')) {
|
|
272
|
+
const ref = trimmed.slice('/delete node '.length).trim();
|
|
273
|
+
const node = resolveNode(ref, nodes);
|
|
274
|
+
if (!node)
|
|
275
|
+
return { ok: false, message: `Node "${ref}" not found.` };
|
|
276
|
+
try {
|
|
277
|
+
await deleteNode(node.id);
|
|
278
|
+
ctx.refetch?.();
|
|
279
|
+
return { ok: true, message: `✓ Deleted "${node.title}"` };
|
|
280
|
+
}
|
|
281
|
+
catch (e) {
|
|
282
|
+
return { ok: false, message: e.message };
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
if (trimmed.startsWith('/move node ')) {
|
|
286
|
+
const rest = trimmed.slice('/move node '.length).trim();
|
|
287
|
+
const spaceIdx = rest.indexOf(' ');
|
|
288
|
+
if (spaceIdx === -1)
|
|
289
|
+
return { ok: false, message: 'Usage: /move node <ref> <list-name>' };
|
|
290
|
+
const ref = rest.slice(0, spaceIdx).trim();
|
|
291
|
+
const destListName = rest.slice(spaceIdx + 1).trim();
|
|
292
|
+
const node = resolveNode(ref, nodes);
|
|
293
|
+
if (!node)
|
|
294
|
+
return { ok: false, message: `Node "${ref}" not found.` };
|
|
295
|
+
try {
|
|
296
|
+
// Find destination list across all folders
|
|
297
|
+
const folders = await getFolders();
|
|
298
|
+
let destListId;
|
|
299
|
+
for (const folder of folders) {
|
|
300
|
+
const lsts = await getLists(folder.id);
|
|
301
|
+
const found = lsts.find((l) => l.name.toLowerCase() === destListName.toLowerCase());
|
|
302
|
+
if (found) {
|
|
303
|
+
destListId = found.id;
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
if (!destListId)
|
|
308
|
+
return { ok: false, message: `List "${destListName}" not found.` };
|
|
309
|
+
await moveNode(node.id, null, 0);
|
|
310
|
+
ctx.refetch?.();
|
|
311
|
+
return { ok: true, message: `✓ Moved "${node.title}" to "${destListName}"` };
|
|
312
|
+
}
|
|
313
|
+
catch (e) {
|
|
314
|
+
return { ok: false, message: e.message };
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// ── /edit node ... <property> and /add node ... <property> ───────────────
|
|
318
|
+
const editNodeMatch = trimmed.match(/^\/(edit|add|delete) node (.+?) \.\.\. (\S+)(?:\s+(.*))?$/);
|
|
319
|
+
if (editNodeMatch) {
|
|
320
|
+
const verb = editNodeMatch[1];
|
|
321
|
+
const ref = editNodeMatch[2].trim();
|
|
322
|
+
const property = editNodeMatch[3].trim();
|
|
323
|
+
const value = (editNodeMatch[4] ?? '').trim();
|
|
324
|
+
const node = resolveNode(ref, nodes);
|
|
325
|
+
if (!node)
|
|
326
|
+
return { ok: false, message: `Node "${ref}" not found.` };
|
|
327
|
+
try {
|
|
328
|
+
switch (property) {
|
|
329
|
+
case 'title':
|
|
330
|
+
if (!value)
|
|
331
|
+
return { ok: false, message: 'New title required.' };
|
|
332
|
+
await updateNode(node.id, { title: value });
|
|
333
|
+
ctx.refetch?.();
|
|
334
|
+
return { ok: true, message: `✓ Renamed to "${value}"` };
|
|
335
|
+
case 'status': {
|
|
336
|
+
const statusMap = {
|
|
337
|
+
'NOT-DONE': 'TODO',
|
|
338
|
+
'IN-PROGRESS': 'IN_PROGRESS',
|
|
339
|
+
DONE: 'DONE',
|
|
340
|
+
};
|
|
341
|
+
if (verb === 'delete') {
|
|
342
|
+
await updateNode(node.id, { status: 'TODO' });
|
|
343
|
+
}
|
|
344
|
+
else {
|
|
345
|
+
const mapped = statusMap[value.toUpperCase()];
|
|
346
|
+
if (!mapped)
|
|
347
|
+
return { ok: false, message: `Invalid status. Use NOT-DONE, IN-PROGRESS, or DONE.` };
|
|
348
|
+
await updateNode(node.id, { status: mapped });
|
|
349
|
+
}
|
|
350
|
+
ctx.refetch?.();
|
|
351
|
+
return { ok: true, message: `✓ Status updated` };
|
|
352
|
+
}
|
|
353
|
+
case 'priority': {
|
|
354
|
+
const priorityMap = { LOW: 'LOW', MEDIUM: 'MEDIUM', HIGH: 'HIGH' };
|
|
355
|
+
if (verb === 'delete') {
|
|
356
|
+
await updateNode(node.id, { priority: null });
|
|
357
|
+
}
|
|
358
|
+
else {
|
|
359
|
+
const mapped = priorityMap[value.toUpperCase()];
|
|
360
|
+
if (!mapped)
|
|
361
|
+
return { ok: false, message: `Invalid priority. Use LOW, MEDIUM, or HIGH.` };
|
|
362
|
+
await updateNode(node.id, { priority: mapped });
|
|
363
|
+
}
|
|
364
|
+
ctx.refetch?.();
|
|
365
|
+
return { ok: true, message: `✓ Priority updated` };
|
|
366
|
+
}
|
|
367
|
+
case 'start-date': {
|
|
368
|
+
if (verb === 'delete') {
|
|
369
|
+
await updateNode(node.id, { startAt: null });
|
|
370
|
+
}
|
|
371
|
+
else {
|
|
372
|
+
const iso = parseDate(value);
|
|
373
|
+
if (!iso)
|
|
374
|
+
return { ok: false, message: 'Invalid date. Use DD-MM-YYYY.' };
|
|
375
|
+
await updateNode(node.id, { startAt: iso });
|
|
376
|
+
}
|
|
377
|
+
ctx.refetch?.();
|
|
378
|
+
return { ok: true, message: `✓ Start date updated` };
|
|
379
|
+
}
|
|
380
|
+
case 'end-date': {
|
|
381
|
+
if (verb === 'delete') {
|
|
382
|
+
await updateNode(node.id, { endAt: null });
|
|
383
|
+
}
|
|
384
|
+
else {
|
|
385
|
+
const iso = parseDate(value);
|
|
386
|
+
if (!iso)
|
|
387
|
+
return { ok: false, message: 'Invalid date. Use DD-MM-YYYY.' };
|
|
388
|
+
await updateNode(node.id, { endAt: iso });
|
|
389
|
+
}
|
|
390
|
+
ctx.refetch?.();
|
|
391
|
+
return { ok: true, message: `✓ End date updated` };
|
|
392
|
+
}
|
|
393
|
+
case 'start-time': {
|
|
394
|
+
if (verb === 'delete') {
|
|
395
|
+
await updateNode(node.id, { startAt: null });
|
|
396
|
+
}
|
|
397
|
+
else {
|
|
398
|
+
const iso = parseTime(value, node.startAt ?? undefined);
|
|
399
|
+
if (!iso)
|
|
400
|
+
return { ok: false, message: 'Invalid time. Use HH:MM AM/PM.' };
|
|
401
|
+
await updateNode(node.id, { startAt: iso });
|
|
402
|
+
}
|
|
403
|
+
ctx.refetch?.();
|
|
404
|
+
return { ok: true, message: `✓ Start time updated` };
|
|
405
|
+
}
|
|
406
|
+
case 'end-time': {
|
|
407
|
+
if (verb === 'delete') {
|
|
408
|
+
await updateNode(node.id, { endAt: null });
|
|
409
|
+
}
|
|
410
|
+
else {
|
|
411
|
+
const iso = parseTime(value, node.endAt ?? undefined);
|
|
412
|
+
if (!iso)
|
|
413
|
+
return { ok: false, message: 'Invalid time. Use HH:MM AM/PM.' };
|
|
414
|
+
await updateNode(node.id, { endAt: iso });
|
|
415
|
+
}
|
|
416
|
+
ctx.refetch?.();
|
|
417
|
+
return { ok: true, message: `✓ End time updated` };
|
|
418
|
+
}
|
|
419
|
+
case 'tag': {
|
|
420
|
+
if (!value && verb !== 'delete')
|
|
421
|
+
return { ok: false, message: 'Tag name required.' };
|
|
422
|
+
// Tags by name need a tag ID. For now, signal the screen to handle.
|
|
423
|
+
return {
|
|
424
|
+
ok: false,
|
|
425
|
+
message: 'Tag operations require a tag ID. Use /tags to list available tags.',
|
|
426
|
+
};
|
|
427
|
+
}
|
|
428
|
+
case 'note': {
|
|
429
|
+
if (verb === 'delete') {
|
|
430
|
+
await updateNode(node.id, { notes: null });
|
|
431
|
+
}
|
|
432
|
+
else {
|
|
433
|
+
if (!value)
|
|
434
|
+
return { ok: false, message: 'Note text required.' };
|
|
435
|
+
await updateNode(node.id, { notes: value });
|
|
436
|
+
}
|
|
437
|
+
ctx.refetch?.();
|
|
438
|
+
return { ok: true, message: `✓ Note updated` };
|
|
439
|
+
}
|
|
440
|
+
case 'position': {
|
|
441
|
+
const pos = parseInt(value, 10);
|
|
442
|
+
if (isNaN(pos))
|
|
443
|
+
return { ok: false, message: 'Position must be a number.' };
|
|
444
|
+
await moveNode(node.id, node.parentId ?? null, pos - 1);
|
|
445
|
+
ctx.refetch?.();
|
|
446
|
+
return { ok: true, message: `✓ Node moved to position ${pos}` };
|
|
447
|
+
}
|
|
448
|
+
default:
|
|
449
|
+
return { ok: false, message: `Unknown property "${property}".` };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
catch (e) {
|
|
453
|
+
return { ok: false, message: e.message };
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
return { ok: false, message: `Unknown command: ${trimmed}` };
|
|
457
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import Conf from 'conf';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import { getFolders, getLists, getNodes } from '../api/client.js';
|
|
4
|
+
import { getToken } from '../utils/auth.js';
|
|
5
|
+
import { assignNumbers } from '../utils/numbering.js';
|
|
6
|
+
import { ApiError } from '../api/ApiError.js';
|
|
7
|
+
const store = new Conf({ projectName: 'stratanodex' });
|
|
8
|
+
function statusBadge(status) {
|
|
9
|
+
if (status === 'DONE')
|
|
10
|
+
return chalk.green('[DONE]');
|
|
11
|
+
if (status === 'IN_PROGRESS')
|
|
12
|
+
return chalk.blue('[IN PROGRESS]');
|
|
13
|
+
return chalk.gray('[TODO]');
|
|
14
|
+
}
|
|
15
|
+
function priorityBadge(priority) {
|
|
16
|
+
if (priority === 'HIGH')
|
|
17
|
+
return chalk.red('[HIGH]');
|
|
18
|
+
if (priority === 'MEDIUM')
|
|
19
|
+
return chalk.yellow('[MED]');
|
|
20
|
+
if (priority === 'LOW')
|
|
21
|
+
return chalk.dim('[LOW]');
|
|
22
|
+
return '';
|
|
23
|
+
}
|
|
24
|
+
function printNode(node, numberMap, indentLevel, currentDepth, maxDepth) {
|
|
25
|
+
const num = chalk.dim(numberMap.get(node.id) ?? '');
|
|
26
|
+
const indent = ' '.repeat(indentLevel);
|
|
27
|
+
const connector = indentLevel > 0 ? chalk.dim('└─ ') : '';
|
|
28
|
+
const status = statusBadge(node.status);
|
|
29
|
+
const priority = priorityBadge(node.priority);
|
|
30
|
+
const title = chalk.white(node.title);
|
|
31
|
+
console.log(`${indent}${connector}${num.padEnd(6)} ${title.padEnd(40)} ${status} ${priority}`.trimEnd());
|
|
32
|
+
const children = node.children ?? [];
|
|
33
|
+
if (currentDepth < maxDepth && children.length > 0) {
|
|
34
|
+
const sorted = [...children].sort((a, b) => a.position - b.position);
|
|
35
|
+
for (const child of sorted) {
|
|
36
|
+
printNode(child, numberMap, indentLevel + 1, currentDepth + 1, maxDepth);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export async function runList(options) {
|
|
41
|
+
if (!getToken()) {
|
|
42
|
+
console.log(chalk.red('✗ Not logged in. Run: stratanodex login'));
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const maxDepth = options.depth ?? 0;
|
|
46
|
+
try {
|
|
47
|
+
const folders = await getFolders();
|
|
48
|
+
const listsPerFolder = await Promise.all(folders.map((f) => getLists(f.id).then((lists) => ({ folder: f, lists }))));
|
|
49
|
+
let nodesPerList = new Map();
|
|
50
|
+
if (maxDepth >= 1) {
|
|
51
|
+
const allLists = listsPerFolder.flatMap(({ lists }) => lists);
|
|
52
|
+
const results = await Promise.all(allLists.map((l) => getNodes(l.id).then((nodes) => ({ listId: l.id, nodes }))));
|
|
53
|
+
nodesPerList = new Map(results.map((r) => [r.listId, r.nodes]));
|
|
54
|
+
if (allLists.length > 0) {
|
|
55
|
+
store.set('lastListId', allLists[0].id);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (listsPerFolder.length === 0) {
|
|
59
|
+
console.log(chalk.dim('No folders found. Create one first.'));
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
for (const { folder, lists } of listsPerFolder) {
|
|
63
|
+
console.log(chalk.bold.blue(`\u2601 ${folder.name}`));
|
|
64
|
+
for (const list of lists) {
|
|
65
|
+
console.log(` ${chalk.bold.cyan(`📋 ${list.name}`)}`);
|
|
66
|
+
if (maxDepth >= 1) {
|
|
67
|
+
const nodes = nodesPerList.get(list.id) ?? [];
|
|
68
|
+
const numberMap = assignNumbers(nodes);
|
|
69
|
+
const sorted = [...nodes].sort((a, b) => a.position - b.position);
|
|
70
|
+
for (const node of sorted) {
|
|
71
|
+
printNode(node, numberMap, 2, 1, maxDepth);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
console.log('');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (err instanceof ApiError) {
|
|
80
|
+
console.log(chalk.red(`✗ ${err.message}`));
|
|
81
|
+
}
|
|
82
|
+
else {
|
|
83
|
+
console.log(chalk.red('✗ Unexpected error.'));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { runLogin } from './loginFlow.js';
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
import { useState } from 'react';
|
|
3
|
+
import { render, Box, Text, useApp } from 'ink';
|
|
4
|
+
import TextInput from 'ink-text-input';
|
|
5
|
+
import { login, verify2FA } from '../api/client.js';
|
|
6
|
+
import { saveToken } from '../utils/auth.js';
|
|
7
|
+
import { ApiError } from '../api/ApiError.js';
|
|
8
|
+
import chalk from 'chalk';
|
|
9
|
+
const LoginFlow = () => {
|
|
10
|
+
const { exit } = useApp();
|
|
11
|
+
const [step, setStep] = useState('email');
|
|
12
|
+
const [email, setEmail] = useState('');
|
|
13
|
+
const [password, setPassword] = useState('');
|
|
14
|
+
const [twoFaUserId, setTwoFaUserId] = useState('');
|
|
15
|
+
const [otpCode, setOtpCode] = useState('');
|
|
16
|
+
const [errorMsg, setErrorMsg] = useState('');
|
|
17
|
+
const handleEmailSubmit = (value) => {
|
|
18
|
+
if (!value.trim()) {
|
|
19
|
+
setErrorMsg('✗ Email cannot be empty.');
|
|
20
|
+
return;
|
|
21
|
+
}
|
|
22
|
+
setErrorMsg('');
|
|
23
|
+
setEmail(value);
|
|
24
|
+
setStep('password');
|
|
25
|
+
};
|
|
26
|
+
const handlePasswordSubmit = async (value) => {
|
|
27
|
+
if (!value) {
|
|
28
|
+
setErrorMsg('✗ Password cannot be empty.');
|
|
29
|
+
return;
|
|
30
|
+
}
|
|
31
|
+
setPassword(value);
|
|
32
|
+
setErrorMsg('');
|
|
33
|
+
try {
|
|
34
|
+
const response = await login(email, value);
|
|
35
|
+
if ('requiresTwoFactor' in response && response.requiresTwoFactor) {
|
|
36
|
+
setTwoFaUserId(response.userId);
|
|
37
|
+
setStep('2fa');
|
|
38
|
+
}
|
|
39
|
+
else if ('token' in response) {
|
|
40
|
+
saveToken(response.token);
|
|
41
|
+
console.log(chalk.green(`✓ Logged in as ${response.user.email}`));
|
|
42
|
+
setStep('done');
|
|
43
|
+
exit();
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
catch (err) {
|
|
47
|
+
if (err instanceof ApiError) {
|
|
48
|
+
if (err.statusCode === 401)
|
|
49
|
+
setErrorMsg('✗ Invalid email or password.');
|
|
50
|
+
else if (err.statusCode === 429)
|
|
51
|
+
setErrorMsg('✗ Too many login attempts. Wait a moment.');
|
|
52
|
+
else
|
|
53
|
+
setErrorMsg(`✗ ${err.message}`);
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
setErrorMsg('✗ Unexpected error.');
|
|
57
|
+
}
|
|
58
|
+
setStep('email');
|
|
59
|
+
setEmail('');
|
|
60
|
+
setPassword('');
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
const handleOtpSubmit = async (value) => {
|
|
64
|
+
setOtpCode(value);
|
|
65
|
+
setErrorMsg('');
|
|
66
|
+
try {
|
|
67
|
+
const result = await verify2FA(twoFaUserId, value);
|
|
68
|
+
saveToken(result.token);
|
|
69
|
+
console.log(chalk.green(`✓ Logged in as ${result.user.email}`));
|
|
70
|
+
setStep('done');
|
|
71
|
+
exit();
|
|
72
|
+
}
|
|
73
|
+
catch (err) {
|
|
74
|
+
if (err instanceof ApiError)
|
|
75
|
+
setErrorMsg(`✗ ${err.message}`);
|
|
76
|
+
else
|
|
77
|
+
setErrorMsg('✗ Unexpected error.');
|
|
78
|
+
setOtpCode('');
|
|
79
|
+
}
|
|
80
|
+
};
|
|
81
|
+
return (_jsxs(Box, { flexDirection: "column", children: [step === 'email' && (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Email: " }), _jsx(TextInput, { value: email, onChange: setEmail, onSubmit: handleEmailSubmit })] })), step === 'password' && (_jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Password: " }), _jsx(TextInput, { value: password, onChange: setPassword, onSubmit: handlePasswordSubmit, mask: "*" })] })), step === '2fa' && (_jsxs(Box, { flexDirection: "column", children: [_jsx(Text, { children: "Two-factor authentication required." }), _jsxs(Box, { children: [_jsx(Text, { bold: true, children: "Enter your 6-digit code: " }), _jsx(TextInput, { value: otpCode, onChange: setOtpCode, onSubmit: handleOtpSubmit })] })] })), errorMsg !== '' && _jsx(Text, { color: "red", children: errorMsg })] }));
|
|
82
|
+
};
|
|
83
|
+
export async function runLogin() {
|
|
84
|
+
const { waitUntilExit } = render(_jsx(LoginFlow, {}));
|
|
85
|
+
await waitUntilExit();
|
|
86
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { clearToken, getToken } from '../utils/auth.js';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
export function runLogout() {
|
|
4
|
+
const token = getToken();
|
|
5
|
+
if (!token) {
|
|
6
|
+
console.log(chalk.yellow('You are not logged in.'));
|
|
7
|
+
return;
|
|
8
|
+
}
|
|
9
|
+
clearToken();
|
|
10
|
+
console.log(chalk.green('✓ Logged out.'));
|
|
11
|
+
}
|