190 lines
5.5 KiB
TypeScript
190 lines
5.5 KiB
TypeScript
/**
|
|
* Bidirectional mapping between rTasks and ClickUp fields.
|
|
*
|
|
* Pure functions — no side effects, no API calls.
|
|
*/
|
|
|
|
// ── Status mapping ──
|
|
|
|
const DEFAULT_STATUS_TO_CLICKUP: Record<string, string> = {
|
|
TODO: 'to do',
|
|
IN_PROGRESS: 'in progress',
|
|
REVIEW: 'in review',
|
|
DONE: 'complete',
|
|
};
|
|
|
|
const DEFAULT_CLICKUP_TO_STATUS: Record<string, string> = {
|
|
'to do': 'TODO',
|
|
'open': 'TODO',
|
|
'pending': 'TODO',
|
|
'in progress': 'IN_PROGRESS',
|
|
'in review': 'REVIEW',
|
|
'review': 'REVIEW',
|
|
'complete': 'DONE',
|
|
'closed': 'DONE',
|
|
'done': 'DONE',
|
|
};
|
|
|
|
export function toClickUpStatus(
|
|
rTasksStatus: string,
|
|
customMap?: Record<string, string>,
|
|
): string {
|
|
if (customMap?.[rTasksStatus]) return customMap[rTasksStatus];
|
|
return DEFAULT_STATUS_TO_CLICKUP[rTasksStatus] || rTasksStatus.toLowerCase().replace(/_/g, ' ');
|
|
}
|
|
|
|
export function fromClickUpStatus(
|
|
clickUpStatus: string,
|
|
reverseMap?: Record<string, string>,
|
|
): string {
|
|
const lower = clickUpStatus.toLowerCase();
|
|
if (reverseMap?.[lower]) return reverseMap[lower];
|
|
if (DEFAULT_CLICKUP_TO_STATUS[lower]) return DEFAULT_CLICKUP_TO_STATUS[lower];
|
|
// Fuzzy: check if any known keyword is contained
|
|
for (const [pattern, mapped] of Object.entries(DEFAULT_CLICKUP_TO_STATUS)) {
|
|
if (lower.includes(pattern)) return mapped;
|
|
}
|
|
return 'TODO'; // fallback
|
|
}
|
|
|
|
// ── Priority mapping ──
|
|
|
|
const PRIORITY_TO_CLICKUP: Record<string, number> = {
|
|
URGENT: 1,
|
|
HIGH: 2,
|
|
MEDIUM: 3,
|
|
LOW: 4,
|
|
};
|
|
|
|
const CLICKUP_TO_PRIORITY: Record<number, string> = {
|
|
1: 'URGENT',
|
|
2: 'HIGH',
|
|
3: 'MEDIUM',
|
|
4: 'LOW',
|
|
};
|
|
|
|
export function toClickUpPriority(priority: string | null): number | null {
|
|
if (!priority) return null;
|
|
return PRIORITY_TO_CLICKUP[priority] ?? 3;
|
|
}
|
|
|
|
export function fromClickUpPriority(cuPriority: number | null | undefined): string | null {
|
|
if (cuPriority == null) return null;
|
|
return CLICKUP_TO_PRIORITY[cuPriority] ?? 'MEDIUM';
|
|
}
|
|
|
|
// ── Content hashing for change detection ──
|
|
|
|
export async function contentHash(parts: {
|
|
title: string;
|
|
description: string;
|
|
status: string;
|
|
priority: string | null;
|
|
}): Promise<string> {
|
|
const raw = `${parts.title}\x00${parts.description}\x00${parts.status}\x00${parts.priority ?? ''}`;
|
|
const buf = new TextEncoder().encode(raw);
|
|
const hash = await crypto.subtle.digest('SHA-256', buf);
|
|
return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join('');
|
|
}
|
|
|
|
// ── Conflict detection ──
|
|
|
|
export interface FieldDiff {
|
|
field: string;
|
|
local: string;
|
|
remote: string;
|
|
}
|
|
|
|
export function detectConflicts(
|
|
localFields: { title: string; description: string; status: string; priority: string | null },
|
|
remoteFields: { title: string; description: string; status: string; priority: string | null },
|
|
baseFields: { title: string; description: string; status: string; priority: string | null },
|
|
): { conflicts: FieldDiff[]; localOnly: string[]; remoteOnly: string[] } {
|
|
const conflicts: FieldDiff[] = [];
|
|
const localOnly: string[] = [];
|
|
const remoteOnly: string[] = [];
|
|
|
|
for (const field of ['title', 'description', 'status', 'priority'] as const) {
|
|
const base = baseFields[field] ?? '';
|
|
const local = localFields[field] ?? '';
|
|
const remote = remoteFields[field] ?? '';
|
|
|
|
const localChanged = local !== base;
|
|
const remoteChanged = remote !== base;
|
|
|
|
if (localChanged && remoteChanged && local !== remote) {
|
|
conflicts.push({ field, local, remote });
|
|
} else if (localChanged && !remoteChanged) {
|
|
localOnly.push(field);
|
|
} else if (!localChanged && remoteChanged) {
|
|
remoteOnly.push(field);
|
|
}
|
|
}
|
|
|
|
return { conflicts, localOnly, remoteOnly };
|
|
}
|
|
|
|
// ── ClickUp task → rTasks fields ──
|
|
|
|
export function mapClickUpTaskToRTasks(cuTask: any, reverseStatusMap?: Record<string, string>): {
|
|
title: string;
|
|
description: string;
|
|
status: string;
|
|
priority: string | null;
|
|
labels: string[];
|
|
clickUpMeta: {
|
|
taskId: string;
|
|
listId: string;
|
|
url: string;
|
|
};
|
|
} {
|
|
return {
|
|
title: cuTask.name || 'Untitled',
|
|
description: cuTask.description || cuTask.text_content || '',
|
|
status: fromClickUpStatus(cuTask.status?.status || 'to do', reverseStatusMap),
|
|
priority: fromClickUpPriority(cuTask.priority?.id ? Number(cuTask.priority.id) : null),
|
|
labels: (cuTask.tags || []).map((t: any) => t.name),
|
|
clickUpMeta: {
|
|
taskId: cuTask.id,
|
|
listId: cuTask.list?.id || '',
|
|
url: cuTask.url || `https://app.clickup.com/t/${cuTask.id}`,
|
|
},
|
|
};
|
|
}
|
|
|
|
// ── rTasks fields → ClickUp API body ──
|
|
|
|
export function mapRTasksToClickUpBody(
|
|
task: { title: string; description: string; status: string; priority: string | null; labels: string[] },
|
|
statusMap?: Record<string, string>,
|
|
): Record<string, any> {
|
|
const body: Record<string, any> = {
|
|
name: task.title,
|
|
description: task.description,
|
|
status: toClickUpStatus(task.status, statusMap),
|
|
};
|
|
const p = toClickUpPriority(task.priority);
|
|
if (p != null) body.priority = p;
|
|
if (task.labels?.length) body.tags = task.labels;
|
|
return body;
|
|
}
|
|
|
|
// ── Build reverse status map from ClickUp list statuses ──
|
|
|
|
export function buildStatusMaps(clickUpStatuses: Array<{ status: string; type: string }>): {
|
|
statusMap: Record<string, string>;
|
|
reverseStatusMap: Record<string, string>;
|
|
} {
|
|
const statusMap: Record<string, string> = {};
|
|
const reverseStatusMap: Record<string, string> = {};
|
|
|
|
for (const s of clickUpStatuses) {
|
|
const mapped = fromClickUpStatus(s.status);
|
|
reverseStatusMap[s.status.toLowerCase()] = mapped;
|
|
// First ClickUp status for each rTasks status wins
|
|
if (!statusMap[mapped]) statusMap[mapped] = s.status;
|
|
}
|
|
|
|
return { statusMap, reverseStatusMap };
|
|
}
|