File size: 3,486 Bytes
3d2098f
 
c5e0205
 
3d2098f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c5e0205
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3d2098f
 
c5e0205
 
 
 
3d2098f
 
a7b7286
 
 
 
 
 
 
 
 
 
 
3d2098f
 
 
 
 
 
 
 
 
 
 
 
 
 
c5e0205
 
 
 
 
 
3d2098f
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
// Thin fetch wrapper + job polling for the FastAPI backend.

import { saveBundle, deleteBundle } from './persist'

async function request(path, options = {}) {
  const res = await fetch(path, {
    headers: { 'Content-Type': 'application/json' },
    ...options,
  })
  if (!res.ok) {
    let detail = res.statusText
    try {
      const body = await res.json()
      detail = body.detail || JSON.stringify(body)
    } catch { /* keep statusText */ }
    throw new Error(detail)
  }
  return res.json()
}

// --- client-side persistence hooks -----------------------------------------
// After any mutation touching a project, refresh that project's cached bundle
// in the browser (debounced, so a burst of edits makes one export). See
// persist.js. `import`/`export` are excluded so we never recurse.

const PROJECT_RE = /^\/api\/projects\/([^/]+)/
const _syncTimers = {}

function projectIdFromPath(path) {
  const m = PROJECT_RE.exec(path)
  const id = m && m[1]
  return id && id !== 'import' ? id : null
}

async function syncProjectBundle(projectId) {
  try {
    saveBundle(await request(`/api/projects/${projectId}/export`))
  } catch { /* project may have just been deleted — ignore */ }
}

function scheduleBundleSync(projectId) {
  clearTimeout(_syncTimers[projectId])
  _syncTimers[projectId] = setTimeout(() => {
    delete _syncTimers[projectId]
    syncProjectBundle(projectId)
  }, 700)
}

async function mutate(path, options) {
  const result = await request(path, options)
  const pid = projectIdFromPath(path)
  if (pid) {
    // DELETE of the whole project (no trailing segment) drops its cache;
    // any other mutation re-syncs the bundle.
    if (options.method === 'DELETE' && path.replace(/\/$/, '') === `/api/projects/${pid}`) {
      deleteBundle(pid)
    } else {
      scheduleBundleSync(pid)
    }
  }
  return result
}

export const api = {
  get: (path) => request(path),
  post: (path, body) => mutate(path, { method: 'POST', body: JSON.stringify(body ?? {}) }),
  put: (path, body) => mutate(path, { method: 'PUT', body: JSON.stringify(body) }),
  patch: (path, body) => mutate(path, { method: 'PATCH', body: JSON.stringify(body) }),
  del: (path) => mutate(path, { method: 'DELETE' }),
}

// Multipart upload (files). Don't set Content-Type — the browser adds the boundary.
export async function upload(path, formData) {
  const res = await fetch(path, { method: 'POST', body: formData })
  if (!res.ok) {
    let detail = res.statusText
    try { detail = (await res.json()).detail || detail } catch { /* keep */ }
    throw new Error(detail)
  }
  return res.json()
}

// Poll a job until done/error. onProgress receives the list of messages.
export async function pollJob(jobId, onProgress, intervalMs = 1200) {
  for (;;) {
    const job = await api.get(`/api/jobs/${jobId}`)
    if (onProgress) onProgress(job.progress || [])
    if (job.status === 'done') return job.result
    if (job.status === 'error') throw new Error(job.error || 'Job failed')
    await new Promise((r) => setTimeout(r, intervalMs))
  }
}

// Convenience: POST that returns {job_id}, then poll it.
export async function runJob(path, body, onProgress) {
  const { job_id } = await api.post(path, body)
  const result = await pollJob(job_id, onProgress)
  // The job writes state as it runs, so re-cache the bundle once it's done
  // (the POST above only saw pre-job state).
  const pid = projectIdFromPath(path)
  if (pid) syncProjectBundle(pid)
  return result
}