Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
sagemathinc
GitHub Repository: sagemathinc/cocalc
Path: blob/master/src/packages/frontend/editors/task-editor/actions.ts
1691 views
1
/*
2
* This file is part of CoCalc: Copyright © 2020 Sagemath, Inc.
3
* License: MS-RSL – see LICENSE.md for details
4
*/
5
6
/*
7
Task Actions
8
*/
9
10
import { fromJS, Map } from "immutable";
11
import { throttle } from "lodash";
12
import {
13
close,
14
copy_with,
15
cmp,
16
uuid,
17
history_path,
18
search_split,
19
} from "@cocalc/util/misc";
20
import { update_visible } from "./update-visible";
21
import { create_key_handler } from "./keyboard";
22
import { toggle_checkbox } from "./desc-rendering";
23
import { Actions } from "../../app-framework";
24
import {
25
Align,
26
HashtagState,
27
Headings,
28
HeadingsDir,
29
LocalViewStateMap,
30
SelectedHashtags,
31
Sort,
32
Task,
33
TaskMap,
34
TaskState,
35
} from "./types";
36
import { SyncDB } from "@cocalc/sync/editor/db";
37
import { webapp_client } from "../../webapp-client";
38
import type {
39
Actions as TaskFrameActions,
40
Store as TaskStore,
41
} from "@cocalc/frontend/frame-editors/task-editor/actions";
42
import Fragment from "@cocalc/frontend/misc/fragment-id";
43
44
const LAST_EDITED_THRESH_S = 30;
45
const TASKS_HELP_URL = "https://doc.cocalc.com/tasks.html";
46
47
export class TaskActions extends Actions<TaskState> {
48
public syncdb: SyncDB;
49
private project_id: string;
50
private path: string;
51
private truePath: string;
52
public store: TaskStore;
53
_update_visible: Function;
54
private is_closed: boolean = false;
55
private key_handler?: (any) => void;
56
private set_save_status?: () => void;
57
private frameId: string;
58
private frameActions: TaskFrameActions;
59
private virtuosoRef?;
60
61
public _init(
62
project_id: string,
63
path: string,
64
syncdb: SyncDB,
65
store: TaskStore,
66
truePath: string, // because above path is auxpath for each frame.
67
): void {
68
this._update_visible = throttle(this.__update_visible, 500);
69
this.project_id = project_id;
70
this.path = path;
71
this.truePath = truePath;
72
this.syncdb = syncdb;
73
this.store = store;
74
}
75
76
public _init_frame(frameId: string, frameActions) {
77
this.frameId = frameId;
78
this.frameActions = frameActions;
79
// Ensure that the list of visible tasks is updated soon.
80
// Can't do without waiting a moment, do this being called
81
// during a react render loop and also triggering one.
82
// This is triggered if you close all of the frames and
83
// then the default frame tree comes back, and it would
84
// otherwise just sit there waiting on a syncdoc change.
85
setTimeout(() => {
86
if (this.is_closed) return;
87
this._update_visible();
88
}, 1);
89
}
90
91
public setFrameData(obj): void {
92
this.frameActions.set_frame_data({ ...obj, id: this.frameId });
93
}
94
95
public getFrameData(key: string) {
96
return this.frameActions._get_frame_data(this.frameId, key);
97
}
98
99
public close(): void {
100
if (this.is_closed) {
101
return;
102
}
103
this.is_closed = true;
104
if (this.key_handler != null) {
105
this.frameActions.erase_active_key_handler(this.key_handler);
106
}
107
close(this);
108
this.is_closed = true;
109
}
110
111
public enable_key_handler(): void {
112
if (this.is_closed) {
113
return;
114
}
115
if (this.key_handler == null) {
116
this.key_handler = create_key_handler(this);
117
}
118
this.frameActions.set_active_key_handler(this.key_handler);
119
}
120
121
public disable_key_handler(): void {
122
if (this.key_handler == null || this.redux == null) {
123
return;
124
}
125
this.frameActions.erase_active_key_handler(this.key_handler);
126
delete this.key_handler;
127
}
128
129
private __update_visible(): void {
130
if (this.store == null) return;
131
const tasks = this.store.get("tasks");
132
if (tasks == null) return;
133
const view: LocalViewStateMap =
134
this.getFrameData("local_view_state") ?? fromJS({});
135
const local_task_state =
136
this.getFrameData("local_task_state") ?? fromJS({});
137
const current_task_id = this.getFrameData("current_task_id");
138
const counts = this.getFrameData("counts") ?? fromJS({});
139
140
let obj: any = update_visible(
141
tasks,
142
local_task_state,
143
view,
144
counts,
145
current_task_id,
146
);
147
148
if (obj.visible.size == 0 && view.get("search")?.trim().length == 0) {
149
// Deal with a weird edge case: https://github.com/sagemathinc/cocalc/issues/4763
150
// If nothing is visible and the search is blank, clear any selected hashtags.
151
this.clear_all_hashtags();
152
obj = update_visible(
153
tasks,
154
local_task_state,
155
view,
156
counts,
157
current_task_id,
158
);
159
}
160
161
// We make obj explicit to avoid giving update_visible power to
162
// change anything about state...
163
// This is just "explicit is better than implicit".
164
obj = copy_with(obj, [
165
"visible",
166
"current_task_id",
167
"counts",
168
"hashtags",
169
"search_desc",
170
"search_terms",
171
]);
172
this.setFrameData(obj);
173
if (obj.redoSoonMs > 0) {
174
// do it again a few times, so the recently marked done task disappears.
175
setTimeout(() => this.__update_visible(), obj.redoSoonMs);
176
}
177
}
178
179
public set_local_task_state(task_id: string | undefined, obj: object): void {
180
if (this.is_closed) {
181
return;
182
}
183
if (task_id == null) {
184
task_id = this.getFrameData("current_task_id");
185
}
186
if (task_id == null) {
187
return;
188
}
189
// Set local state related to a specific task -- this is NOT sync'd between clients
190
const local = this.getFrameData("local_task_state") ?? fromJS({});
191
obj["task_id"] = task_id;
192
let x = local.get(obj["task_id"]);
193
if (x == null) {
194
x = fromJS(obj);
195
} else {
196
for (let k in obj) {
197
const v = obj[k];
198
x = x.set(k, fromJS(v));
199
}
200
}
201
this.setFrameData({
202
local_task_state: local.set(obj["task_id"], x),
203
});
204
}
205
206
public set_local_view_state(obj, update_visible = true): void {
207
if (this.is_closed) {
208
return;
209
}
210
// Set local state related to what we see/search for/etc.
211
let local: LocalViewStateMap =
212
this.getFrameData("local_view_state") ?? fromJS({});
213
for (let key in obj) {
214
const value = obj[key];
215
if (
216
key == "show_deleted" ||
217
key == "show_done" ||
218
key == "show_max" ||
219
key == "font_size" ||
220
key == "sort" ||
221
key == "selected_hashtags" ||
222
key == "search" ||
223
key == "scrollState"
224
) {
225
local = local.set(key as any, fromJS(value));
226
} else {
227
throw Error(`bug setting local_view_state -- invalid field "${key}"`);
228
}
229
}
230
this.setFrameData({
231
local_view_state: local,
232
});
233
if (update_visible) {
234
this._update_visible();
235
}
236
}
237
238
clearAllFilters = (obj?) => {
239
this.set_local_view_state(
240
{
241
show_deleted: false,
242
show_done: false,
243
show_max: false,
244
selected_hashtags: {},
245
search: "",
246
...obj,
247
},
248
false,
249
);
250
this.__update_visible();
251
};
252
253
public async save(): Promise<void> {
254
if (this.is_closed) {
255
return;
256
}
257
try {
258
await this.syncdb.save_to_disk();
259
} catch (err) {
260
if (this.is_closed) {
261
// expected to fail when closing
262
return;
263
}
264
// somehow report that save to disk failed.
265
console.warn("Tasks save to disk failed ", err);
266
}
267
this.set_save_status?.();
268
}
269
270
public new_task(): void {
271
// create new task positioned before the current task
272
const cur_pos = this.store.getIn([
273
"tasks",
274
this.getFrameData("current_task_id") ?? "",
275
"position",
276
]);
277
278
const positions = getPositions(this.store.get("tasks"));
279
let position: number | undefined = undefined;
280
if (cur_pos != null && positions.length > 0) {
281
for (
282
let i = 1, end = positions.length, asc = 1 <= end;
283
asc ? i < end : i > end;
284
asc ? i++ : i--
285
) {
286
if (cur_pos === positions[i]) {
287
position = (positions[i - 1] + positions[i]) / 2;
288
break;
289
}
290
}
291
if (position == null) {
292
position = positions[0] - 1;
293
}
294
} else {
295
// There is no current visible task, so just put new task at the very beginning.
296
if (positions.length > 0) {
297
position = positions[0] - 1;
298
} else {
299
position = 0;
300
}
301
}
302
303
// Default new task is search description, but
304
// do not include any negations. This is handy and also otherwise
305
// you wouldn't see the new task!
306
const search = this.getFrameData("search_desc");
307
const desc = search_split(search)
308
.filter((x) => x[0] !== "-")
309
.join(" ");
310
311
const task_id = uuid();
312
this.set_task(task_id, { desc, position });
313
this.set_current_task(task_id);
314
this.edit_desc(task_id);
315
}
316
317
public set_task(
318
task_id?: string,
319
obj?: object,
320
setState: boolean = false,
321
save: boolean = true, // make new commit to syncdb state
322
): void {
323
if (obj == null || this.is_closed) {
324
return;
325
}
326
if (task_id == null) {
327
task_id = this.getFrameData("current_task_id");
328
}
329
if (task_id == null) {
330
return;
331
}
332
let task = this.store.getIn(["tasks", task_id]) as any;
333
// Update last_edited if desc or due date changes
334
if (
335
task == null ||
336
(obj["desc"] != null && obj["desc"] !== task.get("desc")) ||
337
(obj["due_date"] != null && obj["due_date"] !== task.get("due_date")) ||
338
(obj["done"] != null && obj["done"] !== task.get("done"))
339
) {
340
const last_edited =
341
this.store.getIn(["tasks", task_id, "last_edited"]) ?? 0;
342
const now = Date.now();
343
if (now - last_edited >= LAST_EDITED_THRESH_S * 1000) {
344
obj["last_edited"] = now;
345
}
346
}
347
348
obj["task_id"] = task_id;
349
this.syncdb.set(obj);
350
if (save) {
351
this.commit();
352
}
353
if (setState) {
354
// also set state directly in the tasks object locally
355
// **immediately**; this would happen
356
// eventually as a result of the syncdb set above.
357
let tasks = this.store.get("tasks") ?? fromJS({});
358
task = tasks.get(task_id) ?? (fromJS({ task_id }) as any);
359
if (task == null) throw Error("bug");
360
for (let k in obj) {
361
const v = obj[k];
362
if (
363
k == "desc" ||
364
k == "done" ||
365
k == "deleted" ||
366
k == "task_id" ||
367
k == "position" ||
368
k == "due_date" ||
369
k == "last_edited"
370
) {
371
task = task.set(k as keyof Task, fromJS(v));
372
} else {
373
throw Error(`bug setting task -- invalid field "${k}"`);
374
}
375
}
376
tasks = tasks.set(task_id, task);
377
this.setState({ tasks });
378
}
379
}
380
381
public delete_task(task_id: string): void {
382
this.set_task(task_id, { deleted: true });
383
}
384
385
public undelete_task(task_id: string): void {
386
this.set_task(task_id, { deleted: false });
387
}
388
389
public delete_current_task(): void {
390
const task_id = this.getFrameData("current_task_id");
391
if (task_id == null) return;
392
this.delete_task(task_id);
393
}
394
395
public undelete_current_task(): void {
396
const task_id = this.getFrameData("current_task_id");
397
if (task_id == null) return;
398
this.undelete_task(task_id);
399
}
400
401
// only delta = 1 or -1 is supported!
402
public move_task_delta(delta: -1 | 1): void {
403
if (delta !== 1 && delta !== -1) {
404
return;
405
}
406
const task_id = this.getFrameData("current_task_id");
407
if (task_id == null) {
408
return;
409
}
410
const visible = this.getFrameData("visible");
411
if (visible == null) {
412
return;
413
}
414
const i = visible.indexOf(task_id);
415
if (i === -1) {
416
return;
417
}
418
const j = i + delta;
419
if (j < 0 || j >= visible.size) {
420
return;
421
}
422
// swap positions for i and j
423
const tasks = this.store.get("tasks");
424
if (tasks == null) return;
425
const pos_i = tasks.getIn([task_id, "position"]);
426
const pos_j = tasks.getIn([visible.get(j), "position"]);
427
this.set_task(task_id, { position: pos_j }, true);
428
this.set_task(visible.get(j), { position: pos_i }, true);
429
this.scrollIntoView();
430
}
431
432
public time_travel(): void {
433
this.redux.getProjectActions(this.project_id).open_file({
434
path: history_path(this.path),
435
foreground: true,
436
});
437
}
438
439
public help(): void {
440
window.open(TASKS_HELP_URL, "_blank")?.focus();
441
}
442
443
set_current_task = (task_id: string): void => {
444
if (this.getFrameData("current_task_id") == task_id) {
445
return;
446
}
447
this.setFrameData({ current_task_id: task_id });
448
this.scrollIntoView();
449
this.setFragment(task_id);
450
};
451
452
public set_current_task_delta(delta: number): void {
453
const task_id = this.getFrameData("current_task_id");
454
if (task_id == null) {
455
return;
456
}
457
const visible = this.getFrameData("visible");
458
if (visible == null) {
459
return;
460
}
461
let i = visible.indexOf(task_id);
462
if (i === -1) {
463
return;
464
}
465
i += delta;
466
if (i < 0) {
467
i = 0;
468
} else if (i >= visible.size) {
469
i = visible.size - 1;
470
}
471
const new_task_id = visible.get(i);
472
if (new_task_id != null) {
473
this.set_current_task(new_task_id);
474
}
475
}
476
477
public undo(): void {
478
if (this.syncdb == null) {
479
return;
480
}
481
this.syncdb.undo();
482
this.commit();
483
}
484
485
public redo(): void {
486
if (this.syncdb == null) {
487
return;
488
}
489
this.syncdb.redo();
490
this.commit();
491
}
492
493
public commit(): void {
494
this.syncdb.commit();
495
}
496
497
public set_task_not_done(task_id: string | undefined): void {
498
if (task_id == null) {
499
task_id = this.getFrameData("current_task_id");
500
}
501
this.set_task(task_id, { done: false });
502
}
503
504
public set_task_done(task_id: string | undefined): void {
505
if (task_id == null) {
506
task_id = this.getFrameData("current_task_id");
507
}
508
this.set_task(task_id, { done: true });
509
}
510
511
public toggle_task_done(task_id: string | undefined): void {
512
if (task_id == null) {
513
task_id = this.getFrameData("current_task_id");
514
}
515
if (task_id != null) {
516
this.set_task(
517
task_id,
518
{ done: !this.store.getIn(["tasks", task_id, "done"]) },
519
true,
520
);
521
}
522
}
523
524
public stop_editing_due_date(task_id: string | undefined): void {
525
this.set_local_task_state(task_id, { editing_due_date: false });
526
}
527
528
public edit_due_date(task_id: string | undefined): void {
529
this.set_local_task_state(task_id, { editing_due_date: true });
530
}
531
532
public stop_editing_desc(task_id: string | undefined): void {
533
this.set_local_task_state(task_id, { editing_desc: false });
534
}
535
536
isEditing = () => {
537
const task_id = this.getFrameData("current_task_id");
538
return !!this.getFrameData("local_task_state")?.getIn([
539
task_id,
540
"editing_desc",
541
]);
542
};
543
544
// null=unselect all.
545
public edit_desc(task_id: string | undefined | null): void {
546
// close any that were currently in edit state before opening new one
547
const local = this.getFrameData("local_task_state") ?? fromJS({});
548
for (const [id, state] of local) {
549
if (state.get("editing_desc")) {
550
this.stop_editing_desc(id);
551
}
552
}
553
if (task_id !== null) {
554
this.set_local_task_state(task_id, { editing_desc: true });
555
}
556
this.disable_key_handler();
557
setTimeout(() => {
558
this.disable_key_handler();
559
}, 1);
560
}
561
562
public set_due_date(
563
task_id: string | undefined,
564
date: number | undefined,
565
): void {
566
this.set_task(task_id, { due_date: date });
567
}
568
569
public set_desc(
570
task_id: string | undefined,
571
desc: string,
572
save: boolean = true,
573
): void {
574
this.set_task(task_id, { desc }, false, save);
575
}
576
577
public set_color(task_id: string, color: string, save: boolean = true): void {
578
this.set_task(task_id, { color }, false, save);
579
}
580
581
public toggleHideBody(task_id: string | undefined): void {
582
if (task_id == null) {
583
task_id = this.getFrameData("current_task_id");
584
}
585
if (task_id == null) {
586
return;
587
}
588
const hideBody = !this.store.getIn(["tasks", task_id, "hideBody"]);
589
this.set_task(task_id, { hideBody });
590
}
591
592
public show_deleted(): void {
593
this.set_local_view_state({ show_deleted: true });
594
}
595
596
public stop_showing_deleted(): void {
597
this.set_local_view_state({ show_deleted: false });
598
}
599
600
public show_done(): void {
601
this.set_local_view_state({ show_done: true });
602
}
603
604
public stop_showing_done(): void {
605
this.set_local_view_state({ show_done: false });
606
}
607
608
public empty_trash(): void {
609
this.store.get("tasks")?.forEach((task: TaskMap, task_id: string) => {
610
if (task.get("deleted")) {
611
this.syncdb.delete({ task_id });
612
}
613
});
614
}
615
616
public set_hashtag_state(tag: string, state?: HashtagState): void {
617
let selected_hashtags: SelectedHashtags =
618
this.getFrameData("local_view_state")?.get("selected_hashtags") ??
619
Map<string, HashtagState>();
620
if (state == null) {
621
selected_hashtags = selected_hashtags.delete(tag);
622
} else {
623
selected_hashtags = selected_hashtags.set(tag, state);
624
}
625
this.set_local_view_state({ selected_hashtags });
626
}
627
628
public clear_all_hashtags(): void {
629
this.set_local_view_state({
630
selected_hashtags: Map<string, HashtagState>(),
631
});
632
}
633
634
public set_sort_column(column: Headings, dir: HeadingsDir): void {
635
let view = this.getFrameData("local_view_state") ?? fromJS({});
636
let sort = view.get("sort") ?? (fromJS({}) as unknown as Sort);
637
sort = sort.set("column", column);
638
sort = sort.set("dir", dir);
639
view = view.set("sort", sort);
640
this.setFrameData({ local_view_state: view });
641
this._update_visible();
642
}
643
644
// Move task that was at position old_index to now be at
645
// position new_index. NOTE: This is NOT a swap.
646
public reorder_tasks(old_index: number, new_index: number): void {
647
if (old_index === new_index) {
648
return;
649
}
650
const visible = this.getFrameData("visible");
651
const old_id = visible.get(old_index);
652
const new_id = visible.get(new_index);
653
if (new_id == null) return;
654
const new_pos = this.store.getIn(["tasks", new_id, "position"]);
655
if (new_pos == null) {
656
return;
657
}
658
let position;
659
if (new_index === 0) {
660
// moving to very beginning
661
position = new_pos - 1;
662
} else if (new_index < old_index) {
663
const before_id = visible.get(new_index - 1);
664
const before_pos =
665
this.store.getIn(["tasks", before_id ?? "", "position"]) ?? new_pos - 1;
666
position = (new_pos + before_pos) / 2;
667
} else if (new_index > old_index) {
668
const after_id = visible.get(new_index + 1);
669
const after_pos =
670
this.store.getIn(["tasks", after_id ?? "", "position"]) ?? new_pos + 1;
671
position = (new_pos + after_pos) / 2;
672
}
673
this.set_task(old_id, { position }, true);
674
this.__update_visible();
675
}
676
677
public focus_find_box(): void {
678
this.disable_key_handler();
679
this.setFrameData({ focus_find_box: true });
680
}
681
682
public blur_find_box(): void {
683
this.setFrameData({ focus_find_box: false });
684
}
685
686
setVirtuosoRef = (virtuosoRef) => {
687
this.virtuosoRef = virtuosoRef;
688
};
689
690
// scroll the current_task_id into view, possibly changing filters
691
// in order to make it visibile, if necessary.
692
scrollIntoView = async (align: Align = "view") => {
693
if (this.virtuosoRef?.current == null) {
694
return;
695
}
696
const current_task_id = this.getFrameData("current_task_id");
697
if (current_task_id == null) {
698
return;
699
}
700
let visible = this.getFrameData("visible");
701
if (visible == null) {
702
return;
703
}
704
// Figure out the index of current_task_id.
705
let index = visible.indexOf(current_task_id);
706
if (index === -1) {
707
const task = this.store.getIn(["tasks", current_task_id]);
708
if (task == null) {
709
// no such task anywhere, not even in trash, etc
710
return;
711
}
712
if (
713
this.getFrameData("search_desc")?.trim() ||
714
task.get("deleted") ||
715
task.get("done")
716
) {
717
// active search -- try clearing it.
718
this.clearAllFilters({
719
show_deleted: !!task.get("deleted"),
720
show_done: !!task.get("done"),
721
});
722
visible = this.getFrameData("visible");
723
index = visible.indexOf(current_task_id);
724
if (index == -1) {
725
return;
726
}
727
} else {
728
return;
729
}
730
}
731
if (align == "start" || align == "center" || align == "end") {
732
this.virtuosoRef.current.scrollToIndex({ index, align });
733
} else {
734
this.virtuosoRef.current.scrollIntoView({ index });
735
}
736
};
737
738
public set_show_max(show_max: number): void {
739
this.set_local_view_state({ show_max }, false);
740
}
741
742
// TODO: implement
743
/*
744
public start_timer(task_id: string): void {}
745
public stop_timer(task_id: string): void {}
746
public delete_timer(task_id: string): void {}
747
*/
748
749
public toggle_desc_checkbox(
750
task_id: string,
751
index: number,
752
checked: boolean,
753
): void {
754
let desc = this.store.getIn(["tasks", task_id, "desc"]);
755
if (desc == null) {
756
return;
757
}
758
desc = toggle_checkbox(desc, index, checked);
759
this.set_desc(task_id, desc);
760
}
761
762
public hide(): void {
763
this.disable_key_handler();
764
}
765
766
public async show(): Promise<void> {}
767
768
chatgptGetText(scope: "cell" | "all", current_id?): string {
769
if (scope == "all") {
770
// TODO: it would be better to uniformly shorten long tasks, rather than just truncating at the end...
771
return this.toMarkdown();
772
} else if (scope == "cell") {
773
if (current_id == null) return "";
774
return this.store.getIn(["tasks", current_id, "desc"]) ?? "";
775
} else {
776
return "";
777
}
778
}
779
780
toMarkdown(): string {
781
const visible = this.getFrameData("visible");
782
if (visible == null) return "";
783
const tasks = this.store.get("tasks");
784
if (tasks == null) return "";
785
const v: string[] = [];
786
visible.forEach((task_id) => {
787
const task = tasks.get(task_id);
788
if (task == null) return;
789
let s = "";
790
if (task.get("deleted")) {
791
s += "**Deleted**\n\n";
792
}
793
if (task.get("done")) {
794
s += "**Done**\n\n";
795
}
796
const due = task.get("due_date");
797
if (due) {
798
s += `Due: ${new Date(due).toLocaleString()}\n\n`;
799
}
800
s += task.get("desc") ?? "";
801
v.push(s);
802
});
803
return v.join("\n\n---\n\n");
804
}
805
// Exports the currently visible tasks to a markdown file and opens it.
806
public async export_to_markdown(): Promise<void> {
807
const content = this.toMarkdown();
808
const path = this.truePath + ".md";
809
await webapp_client.project_client.write_text_file({
810
project_id: this.project_id,
811
path,
812
content,
813
});
814
this.redux
815
.getProjectActions(this.project_id)
816
.open_file({ path, foreground: true });
817
}
818
819
setFragment = (id?) => {
820
if (!id) {
821
Fragment.clear();
822
} else {
823
Fragment.set({ id });
824
}
825
};
826
}
827
828
function getPositions(tasks): number[] {
829
const v: number[] = [];
830
tasks?.forEach((task: TaskMap) => {
831
const position = task.get("position");
832
if (position != null) {
833
v.push(position);
834
}
835
});
836
return v.sort(cmp); // cmp by <, > instead of string!
837
}
838
839