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