Newer
Older
gnexus-tasks / extension / src / api.ts
// Тонкий клиент gntodo API из попапа расширения.
// Аутентификация — Bearer MCP-токен (Настройки → «Токены MCP»): сессионная
// кука сервиса SameSite=Lax из extension-контекста не уходит (проверено).

export interface User {
  user_id: string
  email: string
  name: string
  avatar_url: string
  locale: string
}

export interface Project {
  id: number
  name: string
  relevance_status: 'active' | 'paused'
  is_archived: boolean
}

export interface Tag {
  id: number
  name: string
}

export interface TaskOut {
  id: number
  title: string
  status: string
  project: Project | null
  tags: Tag[]
  deadline_date: string | null
}

// Токен выставляет Popup после загрузки из chrome.storage.local.
let authToken = ''

export function setToken(token: string): void {
  authToken = token
}

export async function req<T>(
  base: string,
  path: string,
  init: RequestInit = {},
): Promise<{ status: number; data: T | null; headers: Headers }> {
  const res = await fetch(`${base}${path}`, {
    ...init,
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json',
      ...(authToken ? { Authorization: `Bearer ${authToken}` } : {}),
      ...(init.headers ?? {}),
    },
  })
  // 204/пустое тело не парсим
  const data = res.status === 204 ? null : await res.json().catch(() => null)
  return { status: res.status, data: data as T | null, headers: res.headers }
}

export interface Me {
  user: { name: string; email: string }
}

export async function fetchMe(base: string): Promise<Me | null> {
  const { status, data } = await req<{ user: { name: string; email: string } }>(
    base,
    '/auth/me',
  )
  if (status === 401) return null
  if (status !== 200 || !data) throw new Error(`auth/me: ${status}`)
  return data
}

export async function listProjects(base: string): Promise<Project[]> {
  const { status, data } = await req<Project[]>(base, '/api/projects?archived=false')
  return status === 200 && Array.isArray(data) ? data : []
}

export async function listTasks(base: string): Promise<TaskOut[]> {
  const { status, data } = await req<TaskOut[]>(base, '/api/tasks')
  if (status !== 200 || !Array.isArray(data)) throw new Error(`tasks: ${status}`)
  return data
}

// Разбор многострочного ввода: первая строка — тайтл, остальное — описание.
export function splitTitle(text: string): { title: string; description: string } {
  const trimmed = text.trim()
  const nl = trimmed.indexOf('\n')
  if (nl === -1) return { title: trimmed, description: '' }
  return { title: trimmed.slice(0, nl).trim(), description: trimmed.slice(nl + 1).trim() }
}

export interface CreatedTask {
  id: number
  xp: number | null
}

export async function createTask(
  base: string,
  text: string,
  projectId: number | null,
): Promise<{ id: number; xp: number | null }> {
  const { title, description } = splitTitle(text)
  const body: Record<string, unknown> = { title }
  if (description) body.description = description
  const res = await req<{ id: number }>(base, '/api/tasks', {
    method: 'POST',
    body: JSON.stringify(body),
  })
  if (res.status !== 200 || !res.data) throw new Error(`Не удалось создать задачу (${res.status})`)
  const id = res.data.id
  const xpHeader = res.headers.get('X-Created-XP')
  const xp = xpHeader === null ? null : Number(xpHeader)
  if (projectId !== null) {
    const patch = await req(base, `/api/tasks/${id}`, {
      method: 'PATCH',
      body: JSON.stringify({ project_id: projectId }),
    })
    if (patch.status !== 200) {
      throw new Error(`Задача создана, но проект не прикрепился (${patch.status})`)
    }
  }
  return { id, xp: Number.isFinite(xp) ? xp : null }
}

export function openTab(base: string, path = '/'): void {
  chrome.tabs.create({ url: `${base}${path}` })
}