dot/.local/share/gnome-shell/extensions/timepp@zagortenay333/sections/todo/MAIN.js

909 lines
29 KiB
JavaScript

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);