diff --git a/manifest.json b/manifest.json index 68f944a..d3b6441 100644 --- a/manifest.json +++ b/manifest.json @@ -1,7 +1,7 @@ { "id": "obsidian-agile-task-notes", "name": "Agile Task Notes", - "version": "1.3.2", + "version": "1.4.0", "minAppVersion": "0.12.0", "description": "Import your tasks from your TFS (Azure or Jira) to take notes on them and make todo-lists!", "author": "BoxThatBeat", diff --git a/package-lock.json b/package-lock.json index e1dce1a..121f4fe 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "obsidian-agile-task-notes", - "version": "1.3.2", + "version": "1.4.0", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "obsidian-agile-task-notes", - "version": "1.3.2", + "version": "1.4.0", "license": "MIT", "dependencies": { "sanitizer": "^0.1.3" diff --git a/package.json b/package.json index 8bb711f..110b762 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "obsidian-agile-task-notes", - "version": "1.3.2", + "version": "1.4.0", "description": "Automated grabbing of tasks from TFS (AzureDevops or Jira)", "main": "main.js", "scripts": { diff --git a/src/Clients/AzureDevopsClient.ts b/src/Clients/AzureDevopsClient.ts index e2c6ded..a4f8344 100644 --- a/src/Clients/AzureDevopsClient.ts +++ b/src/Clients/AzureDevopsClient.ts @@ -34,6 +34,10 @@ export class AzureDevopsClient implements ITfsClient { constructor(private app: App) {} public async update(settings: AgileTaskNotesSettings): Promise { + // Clean up instance URL - remove protocol if included + let instance = settings.azureDevopsSettings.instance; + instance = instance.replace(/^https?:\/\//, '').replace(/\/$/, ''); + const encoded64PAT = Buffer.from(`:${settings.azureDevopsSettings.accessToken}`).toString('base64'); const headers = { @@ -44,9 +48,9 @@ export class AzureDevopsClient implements ITfsClient { let BaseURL = ''; if (settings.azureDevopsSettings.collection) { - BaseURL = `https://${settings.azureDevopsSettings.instance}/${settings.azureDevopsSettings.collection}/${settings.azureDevopsSettings.project}`; + BaseURL = `https://${instance}/${settings.azureDevopsSettings.collection}/${settings.azureDevopsSettings.project}`; } else { - BaseURL = `https://${settings.azureDevopsSettings.instance}/${settings.azureDevopsSettings.project}`; + BaseURL = `https://${instance}/${settings.azureDevopsSettings.project}`; } try { @@ -194,10 +198,10 @@ export class AzureDevopsClient implements ITfsClient { new Setting(container) .setName('Instance') - .setDesc('TFS server name (ex: dev.azure.com/OrgName)') + .setDesc('Azure DevOps base URL. For cloud: "dev.azure.com/YourOrganization". For on-premises: "yourserver.com" only. Protocol (http:// or https://) will be automatically removed.') .addText((text) => text - .setPlaceholder('Enter instance base url') + .setPlaceholder('dev.azure.com/YourOrganization') .setValue(plugin.settings.azureDevopsSettings.instance) .onChange(async (value) => { plugin.settings.azureDevopsSettings.instance = value; @@ -207,10 +211,10 @@ export class AzureDevopsClient implements ITfsClient { new Setting(container) .setName('Collection') - .setDesc('The name of the Azure DevOps collection (leave empty if it does not apply)') + .setDesc('ONLY for on-premises Azure DevOps Server (e.g., "DefaultCollection"). Leave EMPTY for Azure DevOps Services (dev.azure.com).') .addText((text) => text - .setPlaceholder('Enter Collection Name') + .setPlaceholder('Leave empty for cloud') .setValue(plugin.settings.azureDevopsSettings.collection) .onChange(async (value) => { plugin.settings.azureDevopsSettings.collection = value; diff --git a/src/SyncManager.ts b/src/SyncManager.ts new file mode 100644 index 0000000..a127d38 --- /dev/null +++ b/src/SyncManager.ts @@ -0,0 +1,170 @@ +import { App, Notice } from 'obsidian'; +import { Task } from './Task'; + +export interface SyncState { + taskId: string; + lastLocalUpdate: Date; + lastRemoteUpdate: Date; + localHash: string; + remoteHash: string; +} + +export interface SyncConflict { + taskId: string; + localTask: Task; + remoteTask: Task; + conflictType: 'both-modified' | 'deleted-remotely' | 'deleted-locally'; +} + +export interface SyncResult { + synced: number; + conflicts: SyncConflict[]; + errors: string[]; +} + +export class SyncManager { + private syncStateFile = '.obsidian/plugins/agile-task-notes/sync-state.json'; + private syncStates: Map = new Map(); + + constructor(private app: App) { + this.loadSyncState(); + } + + private async loadSyncState(): Promise { + try { + const adapter = this.app.vault.adapter; + if (await adapter.exists(this.syncStateFile)) { + const content = await adapter.read(this.syncStateFile); + const states = JSON.parse(content); + this.syncStates = new Map(Object.entries(states)); + console.log(`Loaded sync state with ${this.syncStates.size} task states`); + } + } catch (error) { + console.error('Failed to load sync state:', error); + } + } + + private async saveSyncState(): Promise { + try { + const adapter = this.app.vault.adapter; + const dir = this.syncStateFile.substring(0, this.syncStateFile.lastIndexOf('/')); + if (!(await adapter.exists(dir))) { + await adapter.mkdir(dir); + } + const states = Object.fromEntries(this.syncStates); + await adapter.write(this.syncStateFile, JSON.stringify(states, null, 2)); + } catch (error) { + console.error('Failed to save sync state:', error); + } + } + + public getSyncState(taskId: string): SyncState | undefined { + return this.syncStates.get(taskId); + } + + public updateSyncState(taskId: string, state: SyncState): void { + this.syncStates.set(taskId, state); + this.saveSyncState(); + } + + public removeSyncState(taskId: string): void { + this.syncStates.delete(taskId); + this.saveSyncState(); + } + + private calculateHash(task: Task): string { + // Simple hash based on task properties that matter for sync + const content = `${task.state}|${task.title}|${task.desc}|${task.assignedTo}`; + return this.simpleHash(content); + } + + private simpleHash(str: string): string { + let hash = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hash = ((hash << 5) - hash) + char; + hash = hash & hash; // Convert to 32bit integer + } + return hash.toString(16); + } + + public detectConflicts(localTasks: Task[], remoteTasks: Task[]): SyncConflict[] { + const conflicts: SyncConflict[] = []; + const remoteTaskMap = new Map(remoteTasks.map(t => [t.id, t])); + + for (const localTask of localTasks) { + const syncState = this.getSyncState(localTask.id); + const remoteTask = remoteTaskMap.get(localTask.id); + + if (!remoteTask) { + // Task exists locally but not remotely + if (syncState && syncState.remoteHash) { + // Task was deleted remotely + conflicts.push({ + taskId: localTask.id, + localTask, + remoteTask: localTask, // placeholder + conflictType: 'deleted-remotely' + }); + } + continue; + } + + if (syncState) { + const localHash = this.calculateHash(localTask); + const remoteHash = this.calculateHash(remoteTask); + + const localModified = localHash !== syncState.localHash; + const remoteModified = remoteHash !== syncState.remoteHash; + + if (localModified && remoteModified) { + conflicts.push({ + taskId: localTask.id, + localTask, + remoteTask, + conflictType: 'both-modified' + }); + } + } + } + + return conflicts; + } + + public updateAfterSync(task: Task, isLocal: boolean): void { + const hash = this.calculateHash(task); + const syncState = this.getSyncState(task.id) || { + taskId: task.id, + lastLocalUpdate: new Date(), + lastRemoteUpdate: new Date(), + localHash: hash, + remoteHash: hash + }; + + if (isLocal) { + syncState.lastLocalUpdate = new Date(); + syncState.localHash = hash; + } else { + syncState.lastRemoteUpdate = new Date(); + syncState.remoteHash = hash; + } + + this.updateSyncState(task.id, syncState); + } + + public showSyncResult(result: SyncResult): void { + if (result.conflicts.length === 0 && result.errors.length === 0) { + new Notice(`Sync completed successfully. ${result.synced} tasks synchronized.`); + } else { + let message = `Sync completed with issues:\n`; + message += `- ${result.synced} tasks synchronized\n`; + if (result.conflicts.length > 0) { + message += `- ${result.conflicts.length} conflicts detected\n`; + } + if (result.errors.length > 0) { + message += `- ${result.errors.length} errors occurred`; + } + new Notice(message, 5000); + } + } +} \ No newline at end of file diff --git a/versions.json b/versions.json index d5a85ad..2e30f53 100644 --- a/versions.json +++ b/versions.json @@ -9,5 +9,6 @@ "1.2.0": "0.12.0", "1.3.0": "0.12.0", "1.3.1": "0.12.0", - "1.3.2": "0.12.0" + "1.3.2": "0.12.0", + "1.4.0": "0.12.0" } \ No newline at end of file