const St = imports.gi.St; const Gio = imports.gi.Gio const Gtk = imports.gi.Gtk; const GLib = imports.gi.GLib; const Clutter = imports.gi.Clutter; const GnomeDesktop = imports.gi.GnomeDesktop; const Main = imports.ui.main; const ByteArray = imports.byteArray; const Signals = imports.signals; const Mainloop = imports.mainloop; const ME = imports.misc.extensionUtils.getCurrentExtension(); const Gettext = imports.gettext.domain(ME.metadata['gettext-domain']); const _ = Gettext.gettext; const ngettext = Gettext.ngettext; const SIG_MANAGER = ME.imports.lib.signal_manager; const KEY_MANAGER = ME.imports.lib.keybinding_manager; const MISC_UTILS = ME.imports.lib.misc_utils; const G = ME.imports.sections.todo.GLOBAL; const TASK = ME.imports.sections.todo.task_item; const VIEW_MANAGER = ME.imports.sections.todo.view_manager; const TIME_TRACKER = ME.imports.sections.todo.time_tracker; const VIEW_STATS = ME.imports.sections.todo.view__stats; const VIEW_CLEAR = ME.imports.sections.todo.view__clear_tasks; const VIEW_SORT = ME.imports.sections.todo.view__sort; const VIEW_DEFAULT = ME.imports.sections.todo.view__default; const VIEW_SEARCH = ME.imports.sections.todo.view__search; const VIEW_LOADING = ME.imports.sections.todo.view__loading; const VIEW_FILTERS = ME.imports.sections.todo.view__filters; const VIEW_TASK_EDITOR = ME.imports.sections.todo.view__task_editor; const VIEW_FILE_SWITCHER = ME.imports.sections.todo.view__file_switcher; const VIEW_KANBAN_SWITCHER = ME.imports.sections.todo.view__kanban_switcher; const CACHE_FILE = '~/.cache/timepp_gnome_shell_extension/timepp_todo.json'; // ===================================================================== // @@@ Main // // @ext : obj (main extension object) // @settings : obj (extension settings) // // @signals: // - 'new-day' (new day started) (returns string in yyyy-mm-dd iso format) // - 'tasks-changed' // ===================================================================== var SectionMain = class SectionMain extends ME.imports.sections.section_base.SectionBase { constructor (section_name, ext, settings) { super(section_name, ext, settings); this.actor.add_style_class_name('todo-section'); this.separate_menu = this.settings.get_boolean('todo-separate-menu'); this.cache_file = null; this.cache = null; this.sigm = new SIG_MANAGER.SignalManager(); this.keym = new KEY_MANAGER.KeybindingManager(this.settings); this.time_tracker = null; this.view_manager = new VIEW_MANAGER.ViewManager(this.ext, this); // The view manager only allows one view to be visible at a time; however, // since the stats view uses the fullscreen iface, it is orthogonal to // the other views, so we don't use the view manager for it. this.stats_view = new VIEW_STATS.StatsView(this.ext, this, 0); // // init cache file // try { this.cache_file = MISC_UTILS.file_new_for_path(CACHE_FILE); let cache_format_version = ME.metadata['cache-file-format-version'].todo; if (this.cache_file.query_exists(null)) { let [, contents] = this.cache_file.load_contents(null); this.cache = JSON.parse(ByteArray.toString(contents)); } if (!this.cache || !this.cache.format_version || this.cache.format_version !== cache_format_version) { this.cache = { format_version: cache_format_version, // array [of G.TODO_RECORD] todo_files: [], }; } } catch (e) { logError(e); return; } this.create_tasks_mainloop_id = null; // We use this for tracking when a new day begins. this.wallclock = new GnomeDesktop.WallClock(); // Track how many tasks have a particular proj/context/prio, a this.stats = null; this._reset_stats_obj(); // ref to current todo record in cache file this.current_todo_file = null; // A GFile to the todo.txt file, GMonitor. this.todo_txt_file = null; this.todo_file_monitor = null; // All task objects. this.tasks = []; // // keybindings // this.keym.add('todo-keybinding-open', () => { this.ext.open_menu(this.section_name); this.show_view__default(); }); this.keym.add('todo-keybinding-open-to-add', () => { this.ext.open_menu(this.section_name); this.show_view__task_editor(); }); this.keym.add('todo-keybinding-open-to-search', () => { this.ext.open_menu(this.section_name); this.show_view__search(); }); this.keym.add('todo-keybinding-open-to-stats', () => { this.show_view__time_tracker_stats(); }); this.keym.add('todo-keybinding-open-to-switch-files', () => { this.ext.open_menu(this.section_name); this.show_view__file_switcher(); }); this.keym.add('todo-keybinding-open-todotxt-file', () => { if (! this.todo_txt_file) return; let path = this.todo_txt_file.get_path(); if (path) MISC_UTILS.open_file_path(path); }); // // panel item // this.panel_item.actor.add_style_class_name('todo-panel-item'); this.panel_item.icon.gicon = MISC_UTILS.getIcon('timepp-todo-symbolic'); this._toggle_panel_item_mode(); // // listen // this.sigm.connect(this.settings, 'changed::todo-separate-menu', () => { this.separate_menu = this.settings.get_boolean('todo-separate-menu'); this.ext.update_panel_items(); }); this.sigm.connect(this.settings, 'changed::todo-task-width', () => { let width = this.settings.get_int('todo-task-width'); for (let task of this.tasks) task.actor.width = width; }); this.sigm.connect(this.wallclock, 'notify::clock', () => { let t = GLib.DateTime.new_now(this.wallclock.timezone); t = t.format('%H:%M'); if (t === '00:00') this._on_new_day_started(); }); this.sigm.connect(this.settings, 'changed::todo-panel-mode', () => this._toggle_panel_item_mode()); this.sigm.connect(this.ext, 'custom-css-changed', () => this._on_custom_css_changed()); // // finally // this._init_todo_file(); } disable_section () { if (this.create_tasks_mainloop_id) { Mainloop.source_remove(this.create_tasks_mainloop_id); this.create_tasks_mainloop_id = null; } if (this.time_tracker) { this.time_tracker.close(); this.time_tracker = null; } if (this.stats_view) { this.stats_view.destroy(); this.stats_view = null; } this._disable_todo_file_monitor(); this.sigm.clear(); this.keym.clear(); this.view_manager.close_current_view(); this.view_manager = null; this.tasks = []; super.disable_section(); } _init_todo_file () { this.show_view__loading(true); this.view_manager.lock = true; // reset { if (this.create_tasks_mainloop_id) { Mainloop.source_remove(this.create_tasks_mainloop_id); this.create_tasks_mainloop_id = null; } if (this.time_tracker) { this.time_tracker.close(); this.time_tracker = null; } if (this.todo_file_monitor) { this.todo_file_monitor.cancel(); this.todo_file_monitor = null; } this.stats.priorities.clear(); this.stats.contexts.clear(); this.stats.projects.clear(); } try { if (this.cache.todo_files.length === 0) { this.show_view__file_switcher(true); this.view_manager.lock = true; return; } this.current_todo_file = null; let current = this.get_current_todo_file(); if (!current) { this.show_view__file_switcher(true); this.view_manager.lock = true; return; } this.todo_txt_file = MISC_UTILS.file_new_for_path(current.todo_file); if (! this.todo_txt_file.query_exists(null)) this.todo_txt_file.create(Gio.FileCreateFlags.NONE, null); this._enable_todo_file_monitor(); } catch (e) { this.show_view__file_switcher(true); this.view_manager.lock = true; logError(e); Main.notify(_('Unable to load todo file')); return; } let [, lines] = this.todo_txt_file.load_contents(null); lines = ByteArray.toString(lines).split(/\r?\n/).filter((l) => /\S/.test(l)); this.create_tasks(lines, () => { let needs_write = this._check_dates(); this.on_tasks_changed(needs_write); this.time_tracker = new TIME_TRACKER.TimeTracker(this.ext, this); }); } _disable_todo_file_monitor () { if (this.todo_file_monitor) { this.todo_file_monitor.cancel(); this.todo_file_monitor = null; } } _enable_todo_file_monitor () { [this.todo_file_monitor,] = MISC_UTILS.file_monitor(this.todo_txt_file, () => this._on_todo_file_changed()); } store_cache () { if (! this.cache_file) return; if(! this.cache_file.query_exists(null)) this.cache_file.create(Gio.FileCreateFlags.NONE, null); this.cache_file.replace_contents(JSON.stringify(this.cache, null, 2), null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null); } get_current_todo_file () { if (this.current_todo_file) return this.current_todo_file; for (let it of this.cache.todo_files) { if (it.active) { this.current_todo_file = it; break; } } return this.current_todo_file; } write_tasks_to_file () { this._disable_todo_file_monitor(); let content = ''; for (let it of this.tasks) content += it.task_str + '\n'; this.todo_txt_file.replace_contents(content, null, false, Gio.FileCreateFlags.REPLACE_DESTINATION, null); this._enable_todo_file_monitor(); } _on_todo_file_changed (event_type) { this._init_todo_file(); } _on_new_day_started () { this.emit('new-day', MISC_UTILS.date_yyyymmdd()); if (this._check_dates()) this.on_tasks_changed(true, true); } _check_dates () { let today = MISC_UTILS.date_yyyymmdd(); let tasks_updated = false; let recurred_tasks = 0; let deferred_tasks = 0; for (let task of this.tasks) { if (task.check_recurrence()) { tasks_updated = true; recurred_tasks++; } if (task.check_deferred_tasks(today)) { tasks_updated = true; deferred_tasks++; } task.update_dates_markup(); } if (tasks_updated) { if (recurred_tasks > 0) { Main.notify(ngettext('%d task has recurred', '%d tasks have recurred', recurred_tasks).format(recurred_tasks)); } if (deferred_tasks > 0) { Main.notify(ngettext('%d deferred task has been opened', '%d deferred tasks have been opened', deferred_tasks).format(deferred_tasks)); } } return tasks_updated; } _on_custom_css_changed () { for (let task of this.tasks) { task.update_body_markup(); task.update_dates_markup(); } } // The maps have the structure: // @key : string (a context/project/priority) // @val : natural (number of tasks that have that @key) _reset_stats_obj () { this.stats = { deferred_tasks : 0, recurring_completed : 0, recurring_incompleted : 0, hidden : 0, completed : 0, no_priority : 0, priorities : new Map(), contexts : new Map(), projects : new Map(), }; } _toggle_panel_item_mode () { if (this.settings.get_enum('todo-panel-mode') === 0) this.panel_item.set_mode('icon'); else if (this.settings.get_enum('todo-panel-mode') === 1) this.panel_item.set_mode('text'); else this.panel_item.set_mode('icon_text'); } // Create task objects from the given task strings and add them to the // this.tasks array. // // Make sure to call this.on_tasks_changed() soon after calling this func. // // @todo_strings : array (of strings; each string is a line in todo.txt file) // @callback : func create_tasks (todo_strings, callback) { if (this.create_tasks_mainloop_id) { Mainloop.source_remove(this.create_tasks_mainloop_id); this.create_tasks_mainloop_id = null; } // Since we are reusing already instantiated objects, get rid of any // excess task object. // // @NOTE Reusing old objects can be the source of evil... { let len = todo_strings.length; while (this.tasks.length > len) this.tasks.pop().actor.destroy(); } this.create_tasks_mainloop_id = Mainloop.idle_add(() => { this._create_tasks__finish(0, todo_strings, callback); }); } _create_tasks__finish (i, todo_strings, callback) { if (i === todo_strings.length) { if (typeof(callback) === 'function') callback(); this.create_tasks_mainloop_id = null; return; } let str = todo_strings[i]; if (this.tasks[i]) this.tasks[i].reset(false, str); else this.tasks.push(new TASK.TaskItem(this.ext, this, str, false)); this.create_tasks_mainloop_id = Mainloop.idle_add(() => { this._create_tasks__finish(++i, todo_strings, callback); }); } on_tasks_changed (write_to_file = true, refresh_default_view = false) { // // Update stats obj // { this._reset_stats_obj(); let n, proj, context; for (let task of this.tasks) { if (task.is_deferred) { this.stats.deferred_tasks++; continue; } if (task.completed) { if (task.rec_str) this.stats.recurring_completed++ else this.stats.completed++; continue; } for (proj of task.projects) { n = this.stats.projects.get(proj); this.stats.projects.set(proj, n ? ++n : 1); } for (context of task.contexts) { n = this.stats.contexts.get(context); this.stats.contexts.set(context, n ? ++n : 1); } if (task.hidden) { this.stats.hidden++; continue; } if (task.priority === '(_)') { this.stats.no_priority++; } else { n = this.stats.priorities.get(task.priority); this.stats.priorities.set(task.priority, n ? ++n : 1); } if (task.rec_str) this.stats.recurring_incompleted++; } } // // update panel label // { let n_incompleted = this.tasks.length - this.stats.completed - this.stats.hidden - this.stats.recurring_completed - this.stats.deferred_tasks; this.panel_item.set_label('' + n_incompleted); if (n_incompleted) this.panel_item.actor.remove_style_class_name('done'); else this.panel_item.actor.add_style_class_name('done'); } // // Since contexts/projects/priorities are filters, it can happen that we // have redundant filters in case tasks were deleted. Clean 'em up. // { let current = this.get_current_todo_file(); let i, arr, len; arr = current.filters.priorities; for (i = 0, len = arr.length; i < len; i++) { if (! this.stats.priorities.has(arr[i])) { arr.splice(i, 1); len--; i--; } } arr = current.filters.contexts; for (i = 0, len = arr.length; i < len; i++) { if (! this.stats.contexts.has(arr[i])) { arr.splice(i, 1); len--; i--; } } arr = current.filters.projects; for (i = 0, len = arr.length; i < len; i++) { if (! this.stats.projects.has(arr[i])) { arr.splice(i, 1); len--; i--; } } } this.sort_tasks(); this.show_view__default(true, refresh_default_view); if (write_to_file) this.write_tasks_to_file(); this.emit('tasks-changed'); } sort_tasks () { if (! this.get_current_todo_file().automatic_sort) return; let property_map = { [G.SortType.PIN] : 'pinned', [G.SortType.CONTEXT] : 'first_context', [G.SortType.PROJECT] : 'first_project', [G.SortType.PRIORITY] : 'priority', [G.SortType.COMPLETED] : 'completed', [G.SortType.DUE_DATE] : 'due_date', [G.SortType.ALPHABET] : 'msg_text', [G.SortType.RECURRENCE] : 'rec_next', [G.SortType.CREATION_DATE] : 'creation_date', [G.SortType.COMPLETION_DATE] : 'completion_date', }; let sort = this.get_current_todo_file().sorts; let i = 0; let len = sort.length; let props = Array(len); for (; i < len; i++) { props[i] = property_map[ sort[i][0] ]; } this.tasks.sort((a, b) => { let x, y; for (i = 0; (i < len) && (x = a[props[i]]) === (y = b[props[i]]); i++); if (i === len) return 0; switch (sort[i][0]) { case G.SortType.PRIORITY: if (sort[i][1] === G.SortOrder.DESCENDING) return +(x > y) || +(x === y) - 1; else return +(x < y) || +(x === y) - 1; default: if (sort[i][1] === G.SortOrder.DESCENDING) return +(x < y) || +(x === y) - 1; else return +(x > y) || +(x === y) - 1; } }); } // Append the task strings of each given task to the current done.txt file. // // If a given task is not completed, it's task string will be updated to // show that it's completed prior to been appended to the done.txt file. // // The task objects will not be changed. // // @tasks: array (of task objects) archive_tasks (tasks) { let content = ''; let today = MISC_UTILS.date_yyyymmdd(); for (let task of tasks) { if (task.completed) { content += task.task_str + '\n'; } else if (task.priority === '(_)') { content += `x ${today} ${task.task_str}\n`; } else { content += `x ${today} ${task.task_str.slice(3)} pri:${task.priority[1]}\n`; } } try { let current = this.get_current_todo_file(); if (!current || !current.done_file) return; let done_file = MISC_UTILS.file_new_for_path(current.done_file); let append_stream = done_file.append_to(Gio.FileCreateFlags.NONE, null); append_stream.write_all(content, null); } catch (e) { logError(e); } } show_view__default (unlock = false, force_refresh = false) { if (unlock) this.view_manager.lock = false; else if (this.view_manager.lock) return; if (!force_refresh && this.view_manager.current_view_name === G.View.DEFAULT) { Mainloop.idle_add(() => this.view_manager.current_view.dummy_focus_actor.grab_key_focus()); return; } this.view_manager.close_current_view(); let view = new VIEW_DEFAULT.ViewDefault(this.ext, this); this.view_manager.show_view({ view : view, view_name : G.View.DEFAULT, actors : [view.actor], focused_actor : view.dummy_focus_actor, close_callback : () => view.close(), }); } show_view__time_tracker_stats (task) { if (! this.time_tracker) return; this.ext.menu.close(); this.stats_view.open(); if (this.time_tracker.stats_data.size === 0) this.stats_view.show_mode__banner(_('Loading...')); Mainloop.idle_add(() => { let stats = this.time_tracker.get_stats(); if (!stats) { this.stats_view.show_mode__banner(_('Nothing found.')); } else if (!task) { this.stats_view.set_stats(...stats); this.stats_view.show_mode__global(MISC_UTILS.date_yyyymmdd()); } else { this.stats_view.set_stats(...stats); let d = new Date(); this.stats_view.show_mode__single(d.getFullYear(), d.getMonth(), task.task_str, '()'); } }); } show_view__loading (unlock = false) { if (unlock) this.view_manager.lock = false; else if (this.view_manager.lock) return; this.panel_item.set_mode('icon'); this.panel_item.actor.remove_style_class_name('done'); this.panel_item.icon.gicon = MISC_UTILS.getIcon('timepp-todo-loading-symbolic'); let view = new VIEW_LOADING.ViewLoading(this.ext, this); this.view_manager.show_view({ view : view, view_name : G.View.LOADING, actors : [view.actor], focused_actor : view.loading_msg, close_callback : () => { view.close(); this.panel_item.icon.gicon = MISC_UTILS.getIcon('timepp-todo-symbolic'); this._toggle_panel_item_mode(); } }); } show_view__search (search_str = false, unlock = false) { if (unlock) this.view_manager.lock = false; else if (this.view_manager.lock) return; this.view_manager.close_current_view(); let view = new VIEW_SEARCH.ViewSearch(this.ext, this); this.view_manager.show_view({ view : view, view_name : G.View.SEARCH, focused_actor : view.search_entry, actors : [view.actor], close_callback : () => view.close(), }); if (search_str) view.search_entry.text = search_str; } show_view__kanban_switcher (unlock = false) { if (unlock) this.view_manager.lock = false; else if (this.view_manager.lock) return; let view = new VIEW_KANBAN_SWITCHER.KanbanSwitcher(this.ext, this); this.view_manager.show_view({ view : view, view_name : G.View.KANBAN_SWITCHER, actors : [view.actor], focused_actor : view.entry, close_callback : () => view.close(), }); } show_view__clear_completed (unlock = false) { if (unlock) this.view_manager.lock = false; else if (this.view_manager.lock) return; let view = new VIEW_CLEAR.ViewClearTasks(this.ext, this); this.view_manager.show_view({ view : view, view_name : G.View.CLEAR, actors : [view.actor], focused_actor : view.button_cancel, close_callback : () => view.close(), }); view.connect('delete-all', () => { let incompleted_tasks = []; for (let i = 0, len = this.tasks.length; i < len; i++) { if (!this.tasks[i].completed || this.tasks[i].rec_str) incompleted_tasks.push(this.tasks[i]); } this.tasks = incompleted_tasks; this.on_tasks_changed(); }); view.connect('archive-all', () => { let completed_tasks = []; let incompleted_tasks = []; for (let task of this.tasks) { if (!task.completed || task.rec_str) incompleted_tasks.push(task); else completed_tasks.push(task); } this.archive_tasks(completed_tasks); this.tasks = incompleted_tasks; this.on_tasks_changed(); }); view.connect('cancel', () => { this.show_view__default(); }); } show_view__file_switcher (unlock = false) { if (unlock) this.view_manager.lock = false; else if (this.view_manager.lock) return; let view = new VIEW_FILE_SWITCHER.ViewFileSwitcher(this.ext, this); this.view_manager.show_view({ view : view, view_name : G.View.FILE_SWITCH, actors : [view.actor], focused_actor : this.cache.todo_files.length ? view.entry : view.button_add_file, close_callback : () => view.close(), }); if (this.cache.todo_files.length === 0) this.panel_item.set_mode('icon'); view.connect('update', (_, files) => { this.cache.todo_files = files; this.store_cache(); Main.panel.menuManager.ignoreRelease(); this._init_todo_file(); }); view.connect('cancel', () => { this.show_view__default(); }); } show_view__sort (unlock = false) { if (unlock) this.view_manager.lock = false; else if (this.view_manager.lock) return; let view = new VIEW_SORT.ViewSort(this.ext, this); this.view_manager.show_view({ view : view, view_name : G.View.SELECT_SORT, actors : [view.actor], focused_actor : view.button_ok, close_callback : () => view.close(), }); view.connect('update-sort', (_, new_sort, automatic_sort) => { let current = this.get_current_todo_file(); current.sorts = new_sort; current.automatic_sort = automatic_sort; this.sort_tasks(); this.store_cache(); this.show_view__default(); }); } show_view__filters (unlock = false) { if (unlock) this.view_manager.lock = false; else if (this.view_manager.lock) return; let view = new VIEW_FILTERS.ViewFilters(this.ext, this); this.view_manager.show_view({ view : view, view_name : G.View.SELECT_FILTER, actors : [view.actor], focused_actor : view.entry.entry, close_callback : () => view.close(), }); view.connect('filters-updated', (_, filters) => { this.get_current_todo_file().filters = filters; this.store_cache(); this.show_view__default(); }); } show_view__task_editor (task, unlock = false) { if (unlock) this.view_manager.lock = false; else if (this.view_manager.lock) return; this.view_manager.lock = true; let view = new VIEW_TASK_EDITOR.ViewTaskEditor(this.ext, this, task); this.view_manager.show_view({ view : view, view_name : G.View.EDITOR, actors : [view.actor], focused_actor : view.entry.entry, close_callback : () => view.close(), }); if (task) this.time_tracker.stop_tracking(task); view.connect('delete-task', (_, do_archive) => { if (do_archive) this.archive_tasks([task]); for (let i = 0, len = this.tasks.length; i < len; i++) { if (this.tasks[i] === task) { this.tasks.splice(i, 1); break; } } this.on_tasks_changed(); }); view.connect('add-task', (_, task) => { this.tasks.push(task); this.on_tasks_changed(); }); view.connect('edited-task', () => { this.on_tasks_changed(); }); view.connect('cancel', () => { this.show_view__default(true); }); } } Signals.addSignalMethods(SectionMain.prototype);