Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion manifest.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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": {
Expand Down
16 changes: 10 additions & 6 deletions src/Clients/AzureDevopsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ export class AzureDevopsClient implements ITfsClient {
constructor(private app: App) {}

public async update(settings: AgileTaskNotesSettings): Promise<void> {
// 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 = {
Expand All @@ -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 {
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
170 changes: 170 additions & 0 deletions src/SyncManager.ts
Original file line number Diff line number Diff line change
@@ -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<string, SyncState> = new Map();

constructor(private app: App) {
this.loadSyncState();
}

private async loadSyncState(): Promise<void> {
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<void> {
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);
}
}
}
3 changes: 2 additions & 1 deletion versions.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}