diff --git a/main.ts b/main.ts index 92f6274..092bd67 100644 --- a/main.ts +++ b/main.ts @@ -6,6 +6,7 @@ import {UserUser} from "./vikunja_sdk"; import {Label} from "./src/vikunja/labels"; import Commands from "./src/commands"; import {Projects} from "./src/vikunja/projects"; +import VaultTaskCache from "./src/settings/VaultTaskCache"; // Remember to rename these classes and interfaces! @@ -17,6 +18,7 @@ export default class VikunjaPlugin extends Plugin { processor: Processor; commands: Commands; projectsApi: Projects; + cache: VaultTaskCache; async onload() { await this.loadSettings(); @@ -38,17 +40,21 @@ export default class VikunjaPlugin extends Plugin { async loadSettings() { this.settings = Object.assign({}, DEFAULT_SETTINGS, await this.loadData()); + this.cache = VaultTaskCache.fromJson(this.settings.cache, this.app, this); } async saveSettings() { + this.settings.cache = this.cache.getCachedTasks().map(task => { + return task.toJson(); + }); await this.saveData(this.settings); } async checkLastLineForUpdate() { if (this.settings.debugging) console.log("Checking for task update"); const updateTask = await this.processor.checkUpdateInLineAvailable() - if (!!updateTask) { - await this.tasksApi.updateTask(updateTask.task); + if (updateTask !== undefined) { + await this.tasksApi.updateTask(updateTask); } else { if (this.settings.debugging) console.log("No task to update found"); } @@ -70,17 +76,6 @@ export default class VikunjaPlugin extends Plugin { this.registerDomEvent(document, 'keyup', this.handleUpDownEvent.bind(this)); this.registerDomEvent(document, 'click', this.handleClickEvent.bind(this)); this.registerEvent(this.app.workspace.on('editor-change', this.handleEditorChange.bind(this))); - - // When registering intervals, this function will automatically clear the interval when the plugin is disabled. - this.registerInterval(window - .setInterval(async () => { - // this runs anyway, also when cron not enabled, to be dynamically enabled by settings without disable/enable plugin - if (this.settings.enableCron) { - await this.processor.exec() - } - }, - this.settings.cronInterval * 1000) - ); } private setupAPIs() { @@ -88,6 +83,7 @@ export default class VikunjaPlugin extends Plugin { this.userObject = undefined; this.labelsApi = new Label(this.app, this); this.projectsApi = new Projects(this.app, this); + this.cache = new VaultTaskCache(this.app, this); } private setupCommands() { @@ -122,19 +118,42 @@ export default class VikunjaPlugin extends Plugin { }) } - private async handleEditorChange() { - if (this.settings.debugging) console.log("Editor changed"); + private async handleEditorChange(data: any) { + if (this.settings.debugging) console.log("Editor changed", data); + const currentFile = this.app.workspace.getActiveFile(); + if (!currentFile) { + if (this.settings.debugging) console.log("No file open"); + return; + } + + const tasks = await this.processor.getVaultSearcher().getTasksFromFile(this.processor.getTaskParser(), currentFile); + for (const task of tasks) { + if (task.task.id) { + const cachedTask = this.cache.get(task.task.id); + if (cachedTask === undefined || !cachedTask.isTaskEqual(task.task)) { + this.cache.update(task); + } else { + if (cachedTask.lineno !== task.lineno || cachedTask.filepath !== task.filepath) { + this.cache.updateFileInfos(task.task.id, task.filepath, task.lineno); + } + } + } + } + // FIXME the update line stuff should be communicated in settings return; - //await this.checkLastLineForUpdate(); } private async handleUpDownEvent(evt: KeyboardEvent) { - if (evt.key === 'ArrowUp' || evt.key === 'ArrowDown' || evt.key === 'PageUp' || evt.key === 'PageDown') { + if (evt.key === 'ArrowUp' || evt.key === 'ArrowDown' || evt.key === 'PageUp' || evt.key === 'PageDown' || evt.key === "Enter") { + if (this.settings.debugging) console.log("Line changed via keys"); await this.checkLastLineForUpdate(); } } private async handleClickEvent(evt: MouseEvent) { + if (!this.settings.updateOnCursorMovement) { + return; + } const target = evt.target as HTMLInputElement; if (this.app.workspace.activeEditor?.editor?.hasFocus()) { await this.checkLastLineForUpdate(); @@ -153,8 +172,12 @@ export default class VikunjaPlugin extends Plugin { const taskId = parseInt(match[1]); if (this.settings.debugging) console.log("Checkbox clicked for task", taskId); const task = await this.tasksApi.getTaskById(taskId); - task.done = target.checked; - await this.tasksApi.updateTask(task); + const cachedTask = this.cache.get(taskId); + if (cachedTask !== undefined) { + cachedTask.task = task; + cachedTask.task.done = target.checked; + await this.tasksApi.updateTask(cachedTask); + } } else { if (this.settings.debugging) console.log("No task id found for checkbox"); } diff --git a/src/commands/index.ts b/src/commands/index.ts index 19bb782..cb45819 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -79,7 +79,7 @@ export default class Commands { } if (this.plugin.settings.debugging) console.log("Move all tasks to default project"); - const tasks = (await this.getTasksFromVault()).filter(task => task.task.id !== undefined).map(task => task.task); + const tasks = (await this.getTasksFromVault()).filter(task => task.task.id !== undefined); await this.plugin.tasksApi.updateProjectsIdInVikunja(tasks, this.plugin.settings.defaultVikunjaProject); } @@ -141,6 +141,7 @@ export default class Commands { if (this.plugin.settings.debugging) console.log("Resetting tasks in Vikunja done"); await this.plugin.labelsApi.loadLabels(); + this.plugin.cache.reset(); new Notice("Resetting tasks and labels in Vikunja done"); } ).open(); diff --git a/src/processing/automaton.ts b/src/processing/automaton.ts index 05d7c38..091b2b4 100644 --- a/src/processing/automaton.ts +++ b/src/processing/automaton.ts @@ -8,6 +8,7 @@ import UpdateTasks from "./updateTasks"; import CreateTasks from "./createTasks"; import {Processor} from "./processor"; import {SyncLabels} from "./syncLabels"; +import CheckCache from "./checkCache"; interface StepsOutput { localTasks: PluginTask[]; @@ -41,6 +42,7 @@ class Automaton { this.steps = [ new GetTasks(app, plugin, processor), + new CheckCache(plugin, app, processor), new SyncLabels(app, plugin), new RemoveTasks(app, plugin), new CreateTasks(app, plugin, processor), diff --git a/src/processing/checkCache.ts b/src/processing/checkCache.ts new file mode 100644 index 0000000..70ce37d --- /dev/null +++ b/src/processing/checkCache.ts @@ -0,0 +1,41 @@ +import {PluginTask} from "src/vaultSearcher/vaultSearcher"; +import {ModelsTask} from "vikunja_sdk"; +import {IAutomatonSteps, StepsOutput} from "./automaton"; +import VikunjaPlugin from "../../main"; +import {App} from "obsidian"; +import {compareModelTasks, Processor} from "./processor"; + +export default class CheckCache implements IAutomatonSteps { + plugin: VikunjaPlugin; + app: App; + processor: Processor; + + constructor(plugin: VikunjaPlugin, app: App, processor: Processor) { + this.plugin = plugin; + this.app = app; + this.processor = processor; + } + + async step(localTasks: PluginTask[], vikunjaTasks: ModelsTask[]): Promise { + this.updateCacheFromVault(localTasks); + return {localTasks, vikunjaTasks}; + } + + updateCacheFromVault(localTasks: PluginTask[]) { + const tasksWithId = localTasks.filter(task => { + if (task.task.id === undefined) return false; // task has no id, so it is not in the cache, because not synced to vikunja + + const elem = this.plugin.cache.get(task.task.id) + if (elem === undefined) return false; // task is not in the cache, because not synced to vikunja + + return !compareModelTasks(elem.task, task.task); // filter elem, if it is equal to task in cache. False filters out. + } + ); + + if (tasksWithId.length === 0) { + if (this.plugin.settings.debugging) console.log("Step CheckCache: No changes in vault found. Cache is up to date."); + return; + } + if (this.plugin.settings.debugging) console.log("Step CheckCache: Something changed the vault without obsidian! Invalidate cache and creating anew"); + } +} diff --git a/src/processing/processor.ts b/src/processing/processor.ts index 20bcb2e..2d7d42c 100644 --- a/src/processing/processor.ts +++ b/src/processing/processor.ts @@ -75,9 +75,14 @@ class Processor { async saveToVault(task: PluginTask) { const newTask = this.getTaskContent(task); - await this.app.vault.process(task.file, data => { + const file = this.app.vault.getFileByPath(task.filepath); + if (file === null) { + return; + } + await this.app.vault.process(file, data => { + let content; if (this.plugin.settings.appendMode) { - return data + "\n" + newTask; + content = data + "\n" + newTask; } else { const lines = data.split("\n"); for (let i = 0; i < lines.length; i++) { @@ -86,8 +91,10 @@ class Processor { break; } } - return lines.join("\n"); + content = lines.join("\n"); } + this.plugin.cache.update(task); + return content; }); } @@ -169,24 +176,34 @@ class Processor { } const lastLine = this.lastLineChecked.get(currentFilename); - let pluginTask = undefined; + let updatedTask = undefined; if (!!lastLine) { const lastLineText = view.editor.getLine(lastLine); if (this.plugin.settings.debugging) console.log("Processor: Last line,", lastLine, "Last line text", lastLineText); try { const parsedTask = await this.taskParser.parse(lastLineText); - pluginTask = { - file: file, - lineno: lastLine, - task: parsedTask - }; + updatedTask = new PluginTask(file.path, lastLine, parsedTask); + if (updatedTask.task.id === undefined) { + return undefined; + } + const cacheTask = this.plugin.cache.get(updatedTask.task.id); + if (cacheTask === undefined) { + if (this.plugin.settings.debugging) console.error("Processor: Should not be here, because if this task is not in cache, but has an id, it circumvented the cache.") + return undefined; + } + if (compareModelTasks(updatedTask.task, cacheTask.task)) { + // Cache and current task are equal, so no update is needed + return undefined; + } + // no guard check fires, so there is an update. + this.plugin.cache.update(updatedTask); } catch (e) { if (this.plugin.settings.debugging) console.log("Processor: Error while parsing task", e); } } this.lastLineChecked.set(currentFilename, currentLine); - return pluginTask; + return updatedTask; } /* Update a task in the vault @@ -194,12 +211,18 @@ class Processor { */ async updateToVault(task: PluginTask, metadata: boolean = true) { const newTask = (metadata) ? this.getTaskContent(task) : this.getTaskContentWithoutVikunja(task); - - await this.app.vault.process(task.file, (data: string) => { + const file = this.app.vault.getFileByPath(task.filepath); + if (file === null) { + return; + } + await this.app.vault.process(file, (data: string) => { const lines = data.split("\n"); lines.splice(task.lineno, 1, newTask); - return lines.join("\n"); + const content = lines.join("\n"); + this.plugin.cache.update(task); + return content; }); + } getVaultSearcher(): VaultSearcher { @@ -252,8 +275,12 @@ class Processor { for (const task of tasksToPushToVault) { let file: TFile; const chosenFile = this.app.vault.getFileByPath(this.plugin.settings.chosenOutputFile); - // FIXME This should be the date of the vikunja created date, so the task is created in the correct daily note - const date = moment(); + const formattedDate = task.created; + let date = moment(); + if (formattedDate !== undefined) { + if (this.plugin.settings.debugging) console.log("Step CreateTask: Found formatted date", formattedDate, "using it as daily note"); + date = moment(formattedDate, "YYYY-MM-DDTHH:mm:ss[Z]"); + } const dailies = getAllDailyNotes() switch (this.plugin.settings.chooseOutputFile) { @@ -275,11 +302,7 @@ class Processor { default: throw new Error("No valid chooseOutputFile selected"); } - const pluginTask: PluginTask = { - file: file, - lineno: 0, - task: task - }; + const pluginTask = new PluginTask(file.path, 0, task); createdTasksInVault.push(pluginTask); } @@ -301,7 +324,19 @@ class Processor { await this.plugin.tasksApi.deleteTask(task.task); } } +} +function compareModelTasks(local: ModelsTask, vikunja: ModelsTask): boolean { + const title = local.title === vikunja.title; + const description = local.description === vikunja.description; + const dueDate = local.dueDate === vikunja.dueDate; + const labels = local.labels?.filter(label => vikunja.labels?.find(vikunjaLabel => vikunjaLabel.title === label.title)).length === local.labels?.length; + const priority = local.priority === vikunja.priority; + const status = local.done === vikunja.done; + const doneAt = local.doneAt === vikunja.doneAt; + const updated = local.updated === vikunja.updated; + + return title && description && dueDate && labels && priority && status && doneAt && updated; } -export {Processor}; +export {Processor, compareModelTasks}; diff --git a/src/processing/updateTasks.ts b/src/processing/updateTasks.ts index 40284cf..058f50b 100644 --- a/src/processing/updateTasks.ts +++ b/src/processing/updateTasks.ts @@ -55,7 +55,7 @@ class UpdateTasks implements IAutomatonSteps { if (this.plugin.settings.debugging) console.log("Step UpdateTask: updated field is not defined", task, vikunjaTask); throw new Error("Task updated field is not defined"); } - if (this.areTasksEqual(task.task, vikunjaTask)) { + if (task.isTaskEqual(vikunjaTask)) { if (this.plugin.settings.debugging) console.log("Step UpdateTask: Task is the same in both platforms", task, vikunjaTask); continue; } @@ -79,16 +79,6 @@ class UpdateTasks implements IAutomatonSteps { } private areTasksEqual(local: ModelsTask, vikunja: ModelsTask) { - const title = local.title === vikunja.title; - const description = local.description === vikunja.description; - const dueDate = local.dueDate === vikunja.dueDate; - const labels = local.labels?.filter(label => vikunja.labels?.find(vikunjaLabel => vikunjaLabel.title === label.title)).length === local.labels?.length; - const priority = local.priority === vikunja.priority; - const status = local.done === vikunja.done; - const doneAt = local.doneAt === vikunja.doneAt; -// const updatedAt = local.updated === vikunja.updated; not usable, because it is different if anything changes in local file - - return title && description && dueDate && labels && priority && status && doneAt; } private async updateTasks(tasksToUpdateInVault: PluginTask[], tasksToUpdateInVikunja: PluginTask[]) { @@ -100,9 +90,7 @@ class UpdateTasks implements IAutomatonSteps { private async updateTasksInVikunja(updateTasks: PluginTask[]) { if (this.plugin.settings.debugging) console.log("Step UpdateTask: Update tasks in vikunja"); - for (const task of updateTasks) { - await this.plugin.tasksApi.updateTask(task.task); - } + await Promise.all(updateTasks.map(task => this.plugin.tasksApi.updateTask(task))); } private async updateTasksInVault(updateTasks: PluginTask[]) { diff --git a/src/settings/VaultTaskCache.ts b/src/settings/VaultTaskCache.ts new file mode 100644 index 0000000..9b86eae --- /dev/null +++ b/src/settings/VaultTaskCache.ts @@ -0,0 +1,107 @@ +import VikunjaPlugin from "../../main"; +import {App, moment} from "obsidian"; +import {PluginTask} from "../vaultSearcher/vaultSearcher"; + + +interface Cache { + [key: number]: T; +} + +/* +* This class is used to cache tasks which are updated in Vault, but not in Vikunja. +* This should help to identify modifications in vault without Obsidian. +* Also it makes it possible to update only modified tasks. See issue #9 for more details. +*/ +export default class VaultTaskCache { + plugin: VikunjaPlugin; + app: App; + changesMade: boolean; + private cache: Map + + constructor(app: App, plugin: VikunjaPlugin) { + this.app = app; + this.plugin = plugin; + this.changesMade = false; + this.cache = new Map(); + } + + public static fromJson(json: any, app: App, plugin: VikunjaPlugin): VaultTaskCache { + const cache = new VaultTaskCache(app, plugin); + console.log("VaultTaskCache: Loading cache from disk", json); + const tempCache = Object.entries(json).map((taskJson: any) => { + const id = parseInt(taskJson[0]); + const task = PluginTask.fromJson(taskJson[1]); + if (task === undefined) { + return undefined + } + return [id, task]; + }).filter((task: any) => task !== undefined); + // @ts-ignore + cache.cache = new Map(tempCache); + console.log("VaultTaskCache: Loaded cache from disk", cache.cache); + return cache; + } + + async saveCacheToDisk() { + if (this.changesMade) { + if (this.plugin.settings.debugging) console.log("VaultTaskCache: Saving cache to disk"); + await this.plugin.saveSettings(); + } + this.changesMade = false; + } + + updateFileInfos(id: number, filepath: string, lineno: number) { + const cachedTask = this.get(id); + if (cachedTask === undefined) { + throw new Error("VaultTaskCache: Task is not in cache"); + } + if (this.plugin.settings.debugging) console.log("VaultTaskCache: Updating task", id, "with updated file infos", filepath, lineno); + cachedTask.filepath = filepath; + cachedTask.lineno = lineno; + this.cache.set(id, cachedTask); + this.changesMade = true; + } + + update(local: PluginTask) { + if (local.task.id === undefined) { + throw new Error("VaultTaskCache: Task id is not defined"); + } + + const cachedTask = this.get(local.task.id); + const currentDate = moment().format("YYYY-MM-DDTHH:mm:ss[Z]"); + if (cachedTask !== undefined && !cachedTask.isTaskEqual(local.task)) { + if (this.plugin.settings.debugging) console.log("VaultTaskCache: Updating task", local.task.id, "with updated date", currentDate); + local.task.updated = currentDate; + } + this.cache.set(local.task.id, local); + + console.log("VaultTaskCache: Updated cache", this.cache); + this.plugin.settings.cache = this.getCachedTasks().map(task => task.toJson()); + console.log("VaultTaskCache: Updated cache in settings", this.plugin.settings.cache); + this.changesMade = true; + } + + get(id: number): PluginTask | undefined { + return this.cache.get(id); + } + + /* + * Useful, when tasks are updated in vikunja and so the task in the cache is outdated. + */ + delete(id: number) { + this.cache.delete(id); + this.changesMade = true; + } + + /* + * Do not forget to call delete after processing the tasks. + */ + getCachedTasks(): PluginTask[] { + return Array.from(this.cache.values()); + } + + reset() { + this.cache.clear(); + this.changesMade = true; + } +} diff --git a/src/settings/mainSetting.ts b/src/settings/mainSetting.ts index 3a0e602..f8a38eb 100644 --- a/src/settings/mainSetting.ts +++ b/src/settings/mainSetting.ts @@ -3,6 +3,7 @@ import VikunjaPlugin from "../../main"; import {backendToFindTasks, chooseOutputFile, supportedTasksPluginsFormat} from "../enums"; import {ModelsProject, ModelsProjectView} from "../../vikunja_sdk"; import {appHasDailyNotesPluginLoaded} from "obsidian-daily-notes-interface"; +import {PluginTask} from "../vaultSearcher/vaultSearcher"; export interface VikunjaPluginSettings { mySetting: string; @@ -29,6 +30,9 @@ export interface VikunjaPluginSettings { availableViews: ModelsProjectView[], selectedView: number, selectBucketForDoneTasks: number, + cache: PluginTask[], // do not touch! Only via settings/VaultTaskCache.ts + saveCacheToDiskFrequency: number, + updateCompletedStatusImmediately: boolean, } export const DEFAULT_SETTINGS: VikunjaPluginSettings = { @@ -56,15 +60,23 @@ export const DEFAULT_SETTINGS: VikunjaPluginSettings = { availableViews: [], selectedView: 0, selectBucketForDoneTasks: 0, + cache: [], + saveCacheToDiskFrequency: 1, + updateCompletedStatusImmediately: false, } export class MainSetting extends PluginSettingTab { plugin: VikunjaPlugin; projects: ModelsProject[] = []; + private cacheListener: number; + private cronListener: number; constructor(app: App, plugin: VikunjaPlugin) { super(app, plugin); this.plugin = plugin; + + this.startCacheListener(); + this.startCronListener(); } display(): void { @@ -152,6 +164,7 @@ export class MainSetting extends PluginSettingTab { .onChange(async (value: boolean) => { this.plugin.settings.enableCron = value; await this.plugin.saveSettings(); + this.startCronListener(); this.display(); })); @@ -183,11 +196,32 @@ export class MainSetting extends PluginSettingTab { this.plugin.settings.cronInterval = parseInt(value); await this.plugin.saveSettings(); + this.startCronListener(); } - )) - ; + )); } + new Setting(containerEl) + .setName("Save cache to disk frequency") + .setDesc("This plugin uses a cache to calculate correct dates. Set the interval in minutes to save the cache to disk. Lower values will result in more frequent saves, but may cause performance issues. Set too high, task dates are not correctly calculated, because they are missing in cache in next startup. If you make bulk edits of tasks in your vault, you should set higher value. Cache will be only written, if changes were made since last check. If you are unsure, try lowest value and increase it, if you experience performance issues. Limits are 1 to 60 minutes.") + .addText(text => text + .setValue(this.plugin.settings.saveCacheToDiskFrequency.toString()) + .onChange(async (value: string) => { + const parsedNumber = parseInt(value); + if (Number.isNaN(parsedNumber)) { + return; + } + const lowerThanMax = Math.min(parsedNumber, 60); + if (this.plugin.settings.debugging) console.log("Save cache to disk frequency - high limits", lowerThanMax); + const higherThanMin = Math.max(lowerThanMax, 1); + if (this.plugin.settings.debugging) console.log("Save cache to disk frequency - low limits", higherThanMin); + this.plugin.settings.saveCacheToDiskFrequency = higherThanMin; + await this.plugin.saveSettings(); + this.startCacheListener(); + } + ) + ) + new Setting(containerEl).setHeading().setName('Vikunja Settings').setDesc('Settings to connect to Vikunja.'); const hostDesc = document.createDocumentFragment(); @@ -252,6 +286,97 @@ export class MainSetting extends PluginSettingTab { }, 2000); })); + new Setting(containerEl) + .setHeading() + .setName("Updates: Obsidian <-> Vikunja") + + new Setting(containerEl) + .setDesc("This plugin prioritizes changes in Obsidian over Vikunja. This means, that if you make changes in both systems, the changes in Obsidian will be used over the one in Vikunja. To prevent data loss, do not make any changes in your markdown files without Obsidian."); + + new Setting(containerEl) + .setName("Check for updates on startup") + .setDesc("This will check for changes in Vault and Vikunja and update the tasks vice versa, but prioritize the changes in Obsidian. Useful, if you want to use Vikunja, but do not make any changes directly on the markdown files while obsidian is closed.") + .addToggle(toggle => + toggle + .setValue(this.plugin.settings.updateOnStartup) + .onChange(async (value: boolean) => { + this.plugin.settings.updateOnStartup = value; + await this.plugin.saveSettings(); + })); + + + new Setting(containerEl) + .setName("Select default project") + .setDesc("This project will be used to place new tasks created by this plugin.") + .addDropdown(async dropdown => { + if (this.plugin.settings.debugging) { + console.log(`SettingsTab: Got projects:`, this.projects); + } + + for (const project of this.projects) { + if (project.id === undefined || project.title === undefined) { + throw new Error("Project id or title is undefined"); + } + dropdown.addOption(project.id.toString(), project.title); + } + + dropdown.setValue(this.plugin.settings.defaultVikunjaProject.toString()); + + dropdown.onChange(async (value: string) => { + this.plugin.settings.defaultVikunjaProject = parseInt(value); + if (this.plugin.settings.debugging) console.log(`SettingsTab: Selected Vikunja project:`, this.plugin.settings.defaultVikunjaProject); + + this.plugin.settings.availableViews = await this.plugin.projectsApi.getViewsByProjectId(this.plugin.settings.defaultVikunjaProject); + if (this.plugin.settings.debugging) console.log(`SettingsTab: Available views:`, this.plugin.settings.availableViews); + + if (this.plugin.settings.availableViews.length === 1) { + const id = this.plugin.settings.availableViews[0].id; + if (id === undefined) throw new Error("View id is undefined"); + this.plugin.settings.selectedView = id; + this.plugin.settings.selectBucketForDoneTasks = await this.plugin.projectsApi.getDoneBucketIdFromKanbanView(this.plugin.settings.defaultVikunjaProject); + if (this.plugin.settings.debugging) console.log(`SettingsTab: Done bucket set to:`, this.plugin.settings.selectBucketForDoneTasks); + } + await this.plugin.saveSettings(); + this.display(); + }); + } + ) + + if (this.plugin.settings.availableViews.length > 1) { + new Setting(containerEl) + .setName("Select bucket") + .setDesc("Because vikunja does not move done tasks to the correct bucket, you have to select the bucket where the done tasks are placed, so this plugin can do it for you.") + .addDropdown(dropdown => { + let i = 0; + for (const view of this.plugin.settings.availableViews) { + if (view.id === undefined || view.title === undefined) { + throw new Error("View id or title is undefined"); + } + dropdown.addOption((i++).toString(), view.title); + } + + dropdown.setValue(this.plugin.settings.selectedView.toString()); + + dropdown.onChange(async (value: string) => { + this.plugin.settings.selectedView = parseInt(value); + if (this.plugin.settings.debugging) console.log(`SettingsTab: Selected Vikunja bucket:`, this.plugin.settings.selectedView); + + this.plugin.settings.selectBucketForDoneTasks = await this.plugin.projectsApi.getDoneBucketIdFromKanbanView(this.plugin.settings.defaultVikunjaProject); + if (this.plugin.settings.debugging) console.log(`SettingsTab: Done bucket set to:`, this.plugin.settings.selectBucketForDoneTasks); + await this.plugin.saveSettings(); + }); + }); + } + + new Setting(containerEl) + .setName("Move all tasks to selected default project") + .setDesc("This will move all tasks from Vault to the selected default project in Vikunja. This will not delete any tasks in Vikunja, but only move them to the selected project. This helps, if you make a wrong decision in the past. This does not create any tasks in Vikunja.") + .addButton(button => button + .setButtonText("Move all tasks") + .onClick(async () => { + await this.plugin.commands.moveAllTasksToDefaultProject(); + } + )); new Setting(containerEl).setHeading().setName('Pull: Obsidian <- Vikunja').setDesc(''); @@ -343,6 +468,17 @@ export class MainSetting extends PluginSettingTab { ); } + new Setting(containerEl) + .setName("Pull tasks only from default project") + .setDesc("If enabled, only tasks from the default project will be pulled from Vikunja. Useful, if you use Vikunja with several apps or different projects and Obsidian is only one of them. Beware: If you select that labels should be deleted in vikunja, if not found in vault, this will sync all labels regardless of projects.") + .addToggle(toggle => + toggle + .setValue(this.plugin.settings.pullTasksOnlyFromDefaultProject) + .onChange(async (value: boolean) => { + this.plugin.settings.pullTasksOnlyFromDefaultProject = value; + await this.plugin.saveSettings(); + })); + new Setting(containerEl) .setHeading() @@ -421,28 +557,9 @@ export class MainSetting extends PluginSettingTab { return; } - - new Setting(containerEl) - .setHeading() - .setName("Updates: Obsidian <-> Vikunja") - new Setting(containerEl) - .setDesc("This plugin prioritizes changes in Obsidian over Vikunja. This means, that if you make changes in both systems, the changes in Obsidian will be used over the one in Vikunja. To prevent data loss, do not make any changes in your markdown files without Obsidian."); - - new Setting(containerEl) - .setName("Check for updates on startup") - .setDesc("This will check for changes in Vault and Vikunja and update the tasks vice versa, but prioritize the changes in Obsidian. Useful, if you want to use Vikunja, but do not make any changes directly on the markdown files while obsidian is closed.") - .addToggle(toggle => - toggle - .setValue(this.plugin.settings.updateOnStartup) - .onChange(async (value: boolean) => { - this.plugin.settings.updateOnStartup = value; - await this.plugin.saveSettings(); - })); - - new Setting(containerEl) - .setName("Check for updates on cursor movement") - .setDesc("This will check for changes only on cursors last line in Vault. Useful, if you want to reduce the load on your system and faster updates.") + .setName("Check for changes on cursor movement") + .setDesc("This will check for changes on cursors last line in Vault, too. Useful, if you want to reduce the load on your system and faster updates.") .addToggle(toggle => toggle .setValue(this.plugin.settings.updateOnCursorMovement) @@ -452,88 +569,17 @@ export class MainSetting extends PluginSettingTab { })); new Setting(containerEl) - .setName("Select default project") - .setDesc("This project will be used to place new tasks created by this plugin.") - .addDropdown(async dropdown => { - if (this.plugin.settings.debugging) { - console.log(`SettingsTab: Got projects:`, this.projects); - } - - for (const project of this.projects) { - if (project.id === undefined || project.title === undefined) { - throw new Error("Project id or title is undefined"); - } - dropdown.addOption(project.id.toString(), project.title); - } - - dropdown.setValue(this.plugin.settings.defaultVikunjaProject.toString()); - - dropdown.onChange(async (value: string) => { - this.plugin.settings.defaultVikunjaProject = parseInt(value); - if (this.plugin.settings.debugging) console.log(`SettingsTab: Selected Vikunja project:`, this.plugin.settings.defaultVikunjaProject); - - this.plugin.settings.availableViews = await this.plugin.projectsApi.getViewsByProjectId(this.plugin.settings.defaultVikunjaProject); - if (this.plugin.settings.debugging) console.log(`SettingsTab: Available views:`, this.plugin.settings.availableViews); - - if (this.plugin.settings.availableViews.length === 1) { - const id = this.plugin.settings.availableViews[0].id; - if (id === undefined) throw new Error("View id is undefined"); - this.plugin.settings.selectedView = id; - this.plugin.settings.selectBucketForDoneTasks = await this.plugin.projectsApi.getDoneBucketIdFromKanbanView(this.plugin.settings.defaultVikunjaProject); - if (this.plugin.settings.debugging) console.log(`SettingsTab: Done bucket set to:`, this.plugin.settings.selectBucketForDoneTasks); - } - await this.plugin.saveSettings(); - this.display(); - }); - } - ) - - if (this.plugin.settings.availableViews.length > 1) { - new Setting(containerEl) - .setName("Select bucket") - .setDesc("Because vikunja does not move done tasks to the correct bucket, you have to select the bucket where the done tasks are placed, so this plugin can do it for you.") - .addDropdown(dropdown => { - let i = 0; - for (const view of this.plugin.settings.availableViews) { - if (view.id === undefined || view.title === undefined) { - throw new Error("View id or title is undefined"); - } - dropdown.addOption((i++).toString(), view.title); - } - - dropdown.setValue(this.plugin.settings.selectedView.toString()); - - dropdown.onChange(async (value: string) => { - this.plugin.settings.selectedView = parseInt(value); - if (this.plugin.settings.debugging) console.log(`SettingsTab: Selected Vikunja bucket:`, this.plugin.settings.selectedView); - - this.plugin.settings.selectBucketForDoneTasks = await this.plugin.projectsApi.getDoneBucketIdFromKanbanView(this.plugin.settings.defaultVikunjaProject); - if (this.plugin.settings.debugging) console.log(`SettingsTab: Done bucket set to:`, this.plugin.settings.selectBucketForDoneTasks); - await this.plugin.saveSettings(); - }); - }); - } - - new Setting(containerEl) - .setName("Pull tasks only from default project") - .setDesc("If enabled, only tasks from the default project will be pulled from Vikunja. Useful, if you use Vikunja with several apps or different projects and Obsidian is only one of them. Beware: If you select that labels should be deleted in vikunja, if not found in vault, this will sync all labels regardless of projects.") + .setName("Update completed status immediately") + .setDesc("This will update the completed status of tasks immediately to Vikunja.") .addToggle(toggle => toggle - .setValue(this.plugin.settings.pullTasksOnlyFromDefaultProject) + .setValue(this.plugin.settings.updateCompletedStatusImmediately) .onChange(async (value: boolean) => { - this.plugin.settings.pullTasksOnlyFromDefaultProject = value; + this.plugin.settings.updateCompletedStatusImmediately = value; await this.plugin.saveSettings(); })); - new Setting(containerEl) - .setName("Move all tasks to selected default project") - .setDesc("This will move all tasks from Vault to the selected default project in Vikunja. This will not delete any tasks in Vikunja, but only move them to the selected project. This helps, if you make a wrong decision in the past. This does not create any tasks in Vikunja.") - .addButton(button => button - .setButtonText("Move all tasks") - .onClick(async () => { - await this.plugin.commands.moveAllTasksToDefaultProject(); - } - )); + } async loadApi() { @@ -548,6 +594,29 @@ export class MainSetting extends PluginSettingTab { this.display(); } + private startCacheListener() { + if (this.plugin.settings.debugging) console.log("SettingsTab: Start cache listener"); + window.clearInterval(this.cacheListener); + this.cacheListener = window.setInterval(async () => { + await this.plugin.cache.saveCacheToDisk() + }, this.plugin.settings.saveCacheToDiskFrequency * 60 * 1000); + this.plugin.registerInterval(this.cacheListener); + } + + private startCronListener() { + if (this.plugin.settings.debugging) console.log("SettingsTab: Start cron listener"); + window.clearInterval(this.cronListener); + this.cronListener = window + .setInterval(async () => { + // this runs anyway, also when cron not enabled, to be dynamically enabled by settings without disable/enable plugin + if (this.plugin.settings.enableCron) { + await this.plugin.processor.exec() + } + }, + this.plugin.settings.cronInterval * 1000) + this.plugin.registerInterval(this.cronListener); + } + private resetApis() { // TODO: Implement an event to reload API configurations this.plugin.tasksApi.init(); diff --git a/src/vaultSearcher/dataviewSearcher.ts b/src/vaultSearcher/dataviewSearcher.ts index 93ab38f..4638db4 100644 --- a/src/vaultSearcher/dataviewSearcher.ts +++ b/src/vaultSearcher/dataviewSearcher.ts @@ -1,9 +1,10 @@ import {App, moment, TFile} from "obsidian"; import VikunjaPlugin from "../../main"; -import {DataviewApi, getAPI} from "obsidian-dataview"; +import {DataArray, DataviewApi, getAPI} from "obsidian-dataview"; import {PluginTask, VaultSearcher} from "./vaultSearcher"; import {TaskParser} from "src/taskFormats/taskFormats"; + export class DataviewSearcher implements VaultSearcher { app: App; plugin: VikunjaPlugin; @@ -15,10 +16,31 @@ export class DataviewSearcher implements VaultSearcher { this.dataviewPlugin = getAPI(this.app); } + async getTasksFromFile(parser: TaskParser, file: TFile): Promise { + const dv = this.dataviewPlugin; + let tasks = undefined; + + const page = dv.page(file.path); + if (page === undefined) { + console.error("DataviewSearcher: Could not find page for file", file); + return []; + } + if (page.file.tasks === undefined) { + console.error("DataviewSearcher: Could not find tasks for page", page); + return []; + } + tasks = page.file.tasks; + return await this.parseTasks(tasks, parser); + } + async getTasks(parser: TaskParser): Promise { const dv = this.dataviewPlugin; - const tasks = dv.pages().file.tasks.values; + const tasks = dv.pages().file.tasks; + + return await this.parseTasks(tasks, parser); + } + private async parseTasks(tasks: DataArray, parser: TaskParser) { if (this.plugin.settings.debugging) console.log("DataviewSearcher: Found dataview tasks", tasks); const tasksFormatted: PluginTask[] = []; @@ -34,13 +56,20 @@ export class DataviewSearcher implements VaultSearcher { console.error("DataviewSearcher: Could not find file for task", task); continue; } - parsed.updated = moment(file.stat.mtime).format("YYYY-MM-DDTHH:mm:ss[Z]"); + let cachedTask = undefined; + const id = parsed.id; + if (id !== undefined) { + cachedTask = this.plugin.cache.get(id); + } + if (cachedTask !== undefined) { + if (this.plugin.settings.debugging) console.log("DataviewSearcher: Found cached task", cachedTask); + parsed.updated = cachedTask.task.updated; + } else { + if (this.plugin.settings.debugging) console.log("DataviewSearcher: Fallback to file modified date"); + parsed.updated = moment(file.stat.mtime).format("YYYY-MM-DDTHH:mm:ss[Z]"); + } - const vaultParsed: PluginTask = { - file: file, - lineno: task.line, - task: parsed - }; + const vaultParsed = new PluginTask(file.path, task.line, parsed); if (this.plugin.settings.debugging) console.log("DataviewSearcher: Parsed task", parsed); tasksFormatted.push(vaultParsed); } diff --git a/src/vaultSearcher/vaultSearcher.ts b/src/vaultSearcher/vaultSearcher.ts index a86a8d2..4ee6508 100644 --- a/src/vaultSearcher/vaultSearcher.ts +++ b/src/vaultSearcher/vaultSearcher.ts @@ -1,15 +1,60 @@ -import {ModelsTask} from "../../vikunja_sdk"; +import {ModelsTask, ModelsTaskFromJSON, ModelsTaskToJSON} from "../../vikunja_sdk"; import {TaskParser} from "../taskFormats/taskFormats"; import {TFile} from "obsidian"; +import {compareModelTasks} from "../processing/processor"; -interface PluginTask { - file: TFile; +class PluginTask { + filepath: string; lineno: number; task: ModelsTask; + + constructor(filepath: string, lineno: number, task: ModelsTask) { + this.filepath = filepath; + this.lineno = lineno; + this.task = task; + } + + public static fromJson(json: any): PluginTask | undefined { + try { + const file = json["file"]; + const lineno = json["lineno"]; + const taskObj = json["task"]; + const task = ModelsTaskFromJSON(taskObj); + + return new PluginTask(file, lineno, task); + } catch (error) { + console.error("Error parsing json", json, error); + return undefined; + } + } + + public toJson(): any { + return { + file: this.filepath, + lineno: this.lineno, + task: ModelsTaskToJSON(this.task) + }; + + } + + isEquals(pluginTask: PluginTask): boolean { + const file = this.filepath === pluginTask.filepath; + const lineno = this.lineno === pluginTask.lineno; + const task = this.isTaskEqual(pluginTask.task); + + return file && lineno && task; + } + + isTaskEqual(vikunja: ModelsTask): boolean { + return compareModelTasks(this.task, vikunja); + } } interface VaultSearcher { getTasks(parser: TaskParser): Promise; + + getTasksFromFile(parser: TaskParser, file: TFile): Promise; } -export type {VaultSearcher, PluginTask}; +export type {VaultSearcher}; +export {PluginTask}; diff --git a/src/vikunja/tasks.ts b/src/vikunja/tasks.ts index 3e854ba..b6f9d0d 100644 --- a/src/vikunja/tasks.ts +++ b/src/vikunja/tasks.ts @@ -12,6 +12,7 @@ import { TasksTaskLabelsPutRequest } from "../../vikunja_sdk"; import VikunjaAPI from "./VikunjaAPI"; +import {PluginTask} from "../vaultSearcher/vaultSearcher"; class Tasks implements VikunjaAPI { plugin: VikunjaPlugin; @@ -39,21 +40,19 @@ class Tasks implements VikunjaAPI { return await this.tasksApi.tasksAllGet(); } - async updateTask(task: ModelsTask): Promise { - if (!task.id) throw new Error("TasksApi: Task id is not defined"); - if (this.plugin.settings.debugging) console.log("TasksApi: Updating task", task.id, task); - if (task.done) { - task.bucketId = this.plugin.settings.selectBucketForDoneTasks; + async updateTask(task: PluginTask): Promise { + if (!task.task.id) { + throw new Error("TasksApi: Task id is not defined"); + } + if (this.plugin.settings.debugging) console.log("TasksApi: Updating task", task.task.id, task); + if (task.task.done) { + task.task.bucketId = this.plugin.settings.selectBucketForDoneTasks; } - await this.addLabelToTask(task); - - const param: TasksIdPostRequest = {id: task.id, task: task}; - return this.tasksApi.tasksIdPost(param); - } - - async updateTasks(tasks: ModelsTask[]): Promise { - return Promise.all(tasks.map(task => this.updateTask(task))); + await this.addLabelToTask(task.task); + this.plugin.cache.update(task); + const param: TasksIdPostRequest = {id: task.task.id, task: task.task}; + return await this.tasksApi.tasksIdPost(param); } async createTask(task: ModelsTask): Promise { @@ -144,30 +143,24 @@ class Tasks implements VikunjaAPI { } } - async getTaskById(taskId: number) { + async getTaskById(taskId: number): Promise { const param: TasksIdGetRequest = {id: taskId}; - return this.tasksApi.tasksIdGet(param); + return await this.tasksApi.tasksIdGet(param); } - async updateProjectsIdInVikunja(tasks: ModelsTask[], projectId: number) { + async updateProjectsIdInVikunja(tasks: PluginTask[], projectId: number) { if (this.plugin.settings.debugging) console.log("TasksApi: Updating project id in tasks", projectId); // FIXME there is a bulkPost in tasksApi, use it instead of update any task separately return await Promise.all(tasks.map(task => this.updateProjectIdInVikunja(task, projectId))); } - async updateProjectIdInVikunja(task: ModelsTask, projectId: number) { - if (!task.id) throw new Error("TasksApi: Task id is not defined"); - if (this.plugin.settings.debugging) console.log("TasksApi: Updating project id in task", task.id, projectId); + async updateProjectIdInVikunja(task: PluginTask, projectId: number) { + if (!task.task.id) throw new Error("TasksApi: Task id is not defined"); + if (this.plugin.settings.debugging) console.log("TasksApi: Updating project id in task", task.task.id, projectId); - task.projectId = projectId; + task.task.projectId = projectId; await this.updateTask(task); } - - async updateBucketInVikunja(task: ModelsTask, bucketId: number) { - if (!task.id) throw new Error("TasksApi: Task id is not defined"); - if (this.plugin.settings.debugging) console.log("TasksApi: Updating bucket in task", task.id, bucketId); - - } } export {Tasks};