| Name | Message | Date |
|---|---|---|
| 📁 ui | 10 hours ago | |
| 📄 api.ts | 10 hours ago | |
| 📄 auth.ts | 10 hours ago | |
| 📄 content.ts | 10 hours ago | |
| 📄 errors.ts | 10 hours ago | |
| 📄 hash.ts | 10 hours ago | |
| 📄 md5.ts | 10 hours ago | |
| 📄 selectors.ts | 10 hours ago | |
| 📄 titles.ts | 10 hours ago |
📄
src/api.ts
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
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
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;
}