// Тонкий клиент 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}` })
}