📄 src/api.ts
import { CancelledError, DownloadError } from './errors';

/**
 * API client for the VF e-paper backend (Textalk/Prenly), replicating the
 * reader app's own calls. All requests are made from the page's origin by
 * the content script, so CORS behaves exactly as it does for the site itself.
 */

const CONTENT_API = 'https://content.textalk.se/api/web-reader/v1';
const CDN_API = 'https://apicdn.prenly.com/api/web-reader/v1';

export interface PdfTokenResponse {
  token: string;
  url: string;
}

export interface Title {
  id: number;
  name: string;
  downloadable_as_pdf: boolean;
}

function acceptLanguage(): string {
  return navigator.language || 'sv-SE';
}

/** Fetch a short-lived (~30 min) CDN context token for the signed-in user. */
export async function fetchContextToken(
  clientToken: string,
  sessionToken: string,
): Promise<string> {
  const response = await guardedFetch(`${CONTENT_API}/context-token`, {
    headers: {
      'X-Textalk-Content-Client-Authorize': clientToken,
      'X-Textalk-Content-Product-Type': 'webapp',
      'Accept-Language': acceptLanguage(),
      Authorization: `Bearer ${sessionToken}`,
    },
  });
  const data = (await response.json()) as { token?: string };
  if (!data.token) throw new DownloadError('server', 'context-token response missing token');
  return data.token;
}

/** Which titles (publications) the account can download as PDF. */
export async function fetchDownloadableTitleIds(contextToken: string): Promise<Set<string>> {
  const url = `${CDN_API}/titles?context_token=${encodeURIComponent(contextToken)}`;
  const response = await guardedFetch(url, {
    headers: { 'Accept-Language': acceptLanguage() },
  });
  const titles = (await response.json()) as Title[];
  return new Set(
    titles.filter((title) => title.downloadable_as_pdf).map((title) => String(title.id)),
  );
}

/** Exchange an issue id for a one-time PDF download token + media URL. */
export async function fetchPdfToken(
  issueId: string,
  clientToken: string,
  sessionToken: string,
): Promise<PdfTokenResponse> {
  const url = `${CONTENT_API}/issues/${encodeURIComponent(issueId)}/pdf-token`;
  const response = await guardedFetch(url, {
    headers: {
      'X-Textalk-Content-Client-Authorize': clientToken,
      'Accept-Language': acceptLanguage(),
      Authorization: `Bearer ${sessionToken}`,
    },
  });
  const data = (await response.json()) as PdfTokenResponse;
  if (!data.url) throw new DownloadError('server', 'pdf-token response missing url');
  return data;
}

/**
 * Download the PDF as a Blob, reporting progress along the way.
 * The Accept header must be the app's exact PDF Accept string (it is part of
 * the media hash in the URL).
 */
export async function fetchPdfBlob(
  url: string,
  accept: string,
  signal: AbortSignal,
  onProgress: (loadedBytes: number, totalBytes: number | null) => void,
): Promise<Blob> {
  try {
    const response = await fetch(url, { headers: { Accept: accept }, credentials: 'omit', signal });
    if (!response.ok) {
      throw new DownloadError('server', `PDF request failed with HTTP ${response.status}`);
    }
    const contentType = response.headers.get('Content-Type') ?? '';
    if (!contentType.includes('application/pdf')) {
      throw new DownloadError('server', `unexpected content type: ${contentType || 'unknown'}`);
    }
    const totalHeader = response.headers.get('Content-Length');
    const totalBytes =
      totalHeader !== null && /^\d+$/.test(totalHeader) ? Number(totalHeader) : null;

    if (!response.body) {
      const blob = await response.blob();
      onProgress(blob.size, totalBytes ?? blob.size);
      return blob;
    }

    const reader = response.body.getReader();
    const chunks: BlobPart[] = [];
    let loadedBytes = 0;
    for (;;) {
      const { done, value } = await reader.read();
      if (done) break;
      if (value) {
        chunks.push(value);
        loadedBytes += value.byteLength;
        onProgress(loadedBytes, totalBytes);
      }
    }
    return new Blob(chunks, { type: 'application/pdf' });
  } catch (error) {
    if (signal.aborted) throw new CancelledError();
    if (error instanceof DownloadError || error instanceof CancelledError) throw error;
    throw new DownloadError('network', error instanceof Error ? error.message : undefined);
  }
}

async function guardedFetch(url: string, init: RequestInit): Promise<Response> {
  let response: Response;
  try {
    response = await fetch(url, { ...init, credentials: 'omit' });
  } catch (error) {
    throw new DownloadError('network', error instanceof Error ? error.message : undefined);
  }
  if (response.status === 401 || response.status === 403) {
    throw new DownloadError('session-expired', `HTTP ${response.status}`);
  }
  if (!response.ok) {
    throw new DownloadError('server', `HTTP ${response.status}`);
  }
  return response;
}