rspace-online/modules/rtasks/lib/clickup-mapping.ts

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