.gitignore
+3
-0
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..3a77e8c
@@ -0,0 +1,3 @@
node_modules/
dist/
*.zip
.prettierrc
+6
-0
diff --git a/.prettierrc b/.prettierrc
new file mode 100644
index 0000000..521e271
@@ -0,0 +1,6 @@
{
"singleQuote": true,
"semi": true,
"printWidth": 100,
"trailingComma": "all"
}
README.md
+35
-0
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fec8642
@@ -0,0 +1,35 @@
# VF PDF-opener
A Manifest V3 extension for Microsoft Edge (works in any Chromium browser) that
opens issues from [etidning.vf.se](https://etidning.vf.se) directly as a PDF in
a new tab, borrowing the login from the web page itself. If the PDF can't be
downloaded, an error dialog appears and the issue opens in the regular reader
instead.
## Use
1. `npm install && npm run build`
2. In Edge, open `edge://extensions`, enable **Developer mode**, click
**Load unpacked**, and select the `dist/` folder.
3. Sign in to [etidning.vf.se](https://etidning.vf.se) as usual, then click an
issue.
## Package
`npm run package` builds `dist/` and creates `vf-pdf-opener-<version>.zip`,
ready to upload to a store or share.
## Publish (Microsoft Edge Add-ons)
The first submission must be created manually in
[Partner Center](https://partner.microsoft.com/dashboard/microsoftedge). For
subsequent updates, enable the Publish API in Partner Center and set:
- `EDGE_PRODUCT_ID` — the extension's product ID (Partner Center → Extension
overview)
- `EDGE_CLIENT_ID` — client ID (Partner Center → Publish API)
- `EDGE_API_KEY` — API key (same page)
- `EDGE_NOTES` *(optional)* — notes for certification
Then run `npm run publish`, which builds, packages, uploads the zip as a new
draft submission, and submits it for review.
icons/icon.svg
+9
-0
diff --git a/icons/icon.svg b/icons/icon.svg
new file mode 100644
index 0000000..08e1b53
@@ -0,0 +1,9 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128">
<!-- Design source for the extension icons; PNGs are generated into dist/ at build time. -->
<rect width="128" height="128" rx="28" fill="#bd2428"/>
<rect x="28" y="23" width="72" height="82" rx="4" fill="#fff"/>
<rect x="38.5" y="33" width="51" height="16" fill="#bd2428"/>
<rect x="38.5" y="57" width="51" height="4.5" fill="#bd2428"/>
<rect x="38.5" y="68" width="40" height="4.5" fill="#bd2428"/>
<rect x="38.5" y="79" width="28" height="4.5" fill="#bd2428"/>
</svg>
package-lock.json
+2885
-0
Large diff (2885 lines changed) - not displayed
package.json
+22
-0
diff --git a/package.json b/package.json
new file mode 100644
index 0000000..0e84503
@@ -0,0 +1,22 @@
{
"name": "vf-pdf-opener",
"version": "1.0.0",
"private": true,
"description": "Edge/Chrome extension that opens VF's e-paper issues directly as a PDF in a new tab.",
"type": "module",
"scripts": {
"build": "tsc --noEmit && tsup && node scripts/build-assets.mjs",
"watch": "tsup --watch",
"format": "prettier --write .",
"check-format": "prettier --check .",
"package": "npm run build && node scripts/package.mjs",
"publish": "npm run package && node scripts/publish-edge.mjs"
},
"devDependencies": {
"@resvg/resvg-js": "^2.6.2",
"archiver": "^7.0.1",
"prettier": "^3.4.2",
"tsup": "^8.3.6",
"typescript": "^5.7.3"
}
}
public/manifest.json
+19
-0
diff --git a/public/manifest.json b/public/manifest.json
new file mode 100644
index 0000000..dbd40ea
@@ -0,0 +1,19 @@
{
"manifest_version": 3,
"name": "VF PDF-opener",
"description": "Öppnar VF:s e-tidning direkt som PDF i en ny flik – med inloggningen från webbsidan.",
"version": "1.0.0",
"icons": {
"16": "icons/icon-16.png",
"32": "icons/icon-32.png",
"48": "icons/icon-48.png",
"128": "icons/icon-128.png"
},
"content_scripts": [
{
"matches": ["https://etidning.vf.se/*"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}
scripts/build-assets.mjs
+22
-0
diff --git a/scripts/build-assets.mjs b/scripts/build-assets.mjs
new file mode 100644
index 0000000..fac2da6
@@ -0,0 +1,22 @@
import { cpSync, mkdirSync } from 'node:fs';
import { readFile, writeFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { Resvg } from '@resvg/resvg-js';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const dist = join(root, 'dist');
const publicDir = join(root, 'public');
// 1) Static files (the manifest).
mkdirSync(join(dist, 'icons'), { recursive: true });
cpSync(publicDir, dist, { recursive: true });
// 2) Rasterize the icon SVG into the sizes referenced by the manifest.
const svg = await readFile(join(root, 'icons', 'icon.svg'));
for (const size of [16, 32, 48, 128]) {
const png = new Resvg(svg, { fitTo: { mode: 'width', value: size } }).render().asPng();
await writeFile(join(dist, 'icons', `icon-${size}.png`), png);
}
console.log('Assets ready: manifest + icons.');
scripts/package.mjs
+26
-0
diff --git a/scripts/package.mjs b/scripts/package.mjs
new file mode 100644
index 0000000..f4f5b26
@@ -0,0 +1,26 @@
import { createWriteStream } from 'node:fs';
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import archiver from 'archiver';
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const dist = join(root, 'dist');
const manifest = JSON.parse(await readFile(join(dist, 'manifest.json'), 'utf8'));
const outFile = join(root, `vf-pdf-opener-${manifest.version}.zip`);
await new Promise((resolve, reject) => {
const output = createWriteStream(outFile);
const archive = archiver('zip', { zlib: { level: 9 } });
output.on('close', resolve);
archive.on('warning', reject);
archive.on('error', reject);
archive.pipe(output);
// `false` strips the dist/ prefix so manifest.json sits at the zip root,
// which is what extension stores require.
archive.directory(dist, false);
void archive.finalize();
});
console.log(`Packaged ${outFile}`);
scripts/publish-edge.mjs
+104
-0
diff --git a/scripts/publish-edge.mjs b/scripts/publish-edge.mjs
new file mode 100644
index 0000000..16b60c4
@@ -0,0 +1,104 @@
// Publishes the packaged extension to Microsoft Edge Add-ons via the official
// Publish API (v1.1): upload the zip as a new draft package, wait for it to
// finish, then submit the draft for review.
//
// Docs: https://learn.microsoft.com/en-us/microsoft-edge/extensions/update/api/using-addons-api
//
// Required environment variables (Partner Center → Microsoft Edge → Publish API):
// EDGE_PRODUCT_ID product ID of the extension (Partner Center → Extension overview)
// EDGE_CLIENT_ID client ID from the Publish API page
// EDGE_API_KEY API key from the Publish API page
// Optional:
// EDGE_NOTES notes for certification
import { readFile } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const API_ROOT = 'https://api.addons.microsoftedge.microsoft.com';
const POLL_INTERVAL_MS = 3_000;
const POLL_TIMEOUT_MS = 5 * 60_000;
const root = dirname(dirname(fileURLToPath(import.meta.url)));
const { EDGE_PRODUCT_ID: productId, EDGE_CLIENT_ID: clientId, EDGE_API_KEY: apiKey } = process.env;
const notes = process.env.EDGE_NOTES ?? '';
if (!productId || !clientId || !apiKey) {
console.error(
'Missing credentials. Set EDGE_PRODUCT_ID, EDGE_CLIENT_ID and EDGE_API_KEY\n' +
'(Partner Center → Microsoft Edge → Publish API), then try again.',
);
process.exit(1);
}
const authHeaders = { Authorization: `ApiKey ${apiKey}`, 'X-ClientID': clientId };
const manifest = JSON.parse(await readFile(join(root, 'public', 'manifest.json'), 'utf8'));
const zipPath = join(root, `vf-pdf-opener-${manifest.version}.zip`);
let zip;
try {
zip = await readFile(zipPath);
} catch {
console.error(`Package not found: ${zipPath}\nRun "npm run package" first.`);
process.exit(1);
}
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
function operationIdFrom(location) {
if (!location) throw new Error('No operation ID (Location header) in response.');
return location.split('/').filter(Boolean).pop();
}
async function post(path, body, contentType) {
const response = await fetch(`${API_ROOT}${path}`, {
method: 'POST',
headers: { ...authHeaders, 'Content-Type': contentType },
body,
});
if (response.status !== 202) {
throw new Error(`POST ${path} → HTTP ${response.status}: ${await response.text()}`);
}
return operationIdFrom(response.headers.get('location'));
}
async function poll(path, label) {
const deadline = Date.now() + POLL_TIMEOUT_MS;
for (;;) {
if (Date.now() > deadline) throw new Error(`Timed out waiting for ${label}.`);
const response = await fetch(`${API_ROOT}${path}`, { headers: authHeaders });
if (!response.ok) {
throw new Error(`GET ${path} → HTTP ${response.status}: ${await response.text()}`);
}
const operation = await response.json();
console.log(` ${label}: ${operation.status ?? 'unknown'}`);
const status = String(operation.status ?? '').toLowerCase();
if (status === 'succeeded') return operation;
if (status === 'failed') throw new Error(`${label} failed: ${JSON.stringify(operation)}`);
await sleep(POLL_INTERVAL_MS);
}
}
console.log(`Publishing vf-pdf-opener v${manifest.version} to Microsoft Edge Add-ons…`);
console.log('Uploading package…');
const uploadOperation = await post(
`/v1/products/${productId}/submissions/draft/package`,
zip,
'application/zip',
);
await poll(
`/v1/products/${productId}/submissions/draft/package/operations/${uploadOperation}`,
'upload',
);
console.log('Submitting for review…');
const publishOperation = await post(
`/v1/products/${productId}/submissions`,
JSON.stringify({ notes }),
'application/json',
);
await poll(`/v1/products/${productId}/submissions/operations/${publishOperation}`, 'publish');
console.log('Done — the new version is in review.');
src/api.ts
+140
-0
diff --git a/src/api.ts b/src/api.ts
new file mode 100644
index 0000000..51cd0fe
@@ -0,0 +1,140 @@
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;
}
src/auth.ts
+54
-0
diff --git a/src/auth.ts b/src/auth.ts
new file mode 100644
index 0000000..44e06f6
@@ -0,0 +1,54 @@
/**
* Borrowed authentication, straight from the page — the extension never
* handles credentials itself.
*
* - The session token lives in localStorage (key below) as a plain string,
* set by the reader app after login. A cookie with the same key is used as
* fallback when localStorage is unavailable.
* - The client token is public and static per application; it is embedded in
* the page HTML as `PrenlyData.client = { "authToken": "…" }`. We re-parse
* it from the live page so it keeps working if the site rotates it.
*/
const SESSION_STORAGE_KEY = 'prenlyreadersessiontoken';
/** Last-resort fallback (current public token) if the page parse fails. */
const FALLBACK_CLIENT_TOKEN =
'YjNjODI3YzlmYmMwODY4OXw2MDEzOWVmNmM1ZDY4YzgxYzUzMDI3YWM4Yjc1NzI4YWU2MmUyN2JhYzYxOTc1Y2FjNjhhY2FkNjZkMjIyMDZk';
export function getSessionToken(): string | null {
try {
const value = window.localStorage.getItem(SESSION_STORAGE_KEY);
if (value) return value;
} catch {
// localStorage unavailable — fall through to the cookie fallback.
}
const match = document.cookie.match(`(?:^|;)\\s*${SESSION_STORAGE_KEY}\\s*=\\s*([^;]+)`);
return match?.[1] ?? null;
}
export function getClientToken(): string {
for (const script of Array.from(document.querySelectorAll('script'))) {
const token = parseClientToken(script.textContent ?? '');
if (token) return token;
}
return FALLBACK_CLIENT_TOKEN;
}
function parseClientToken(scriptText: string): string | null {
const markerIndex = scriptText.indexOf('PrenlyData.client');
if (markerIndex === -1) return null;
const rest = scriptText.slice(markerIndex);
const start = rest.indexOf('{');
const end = rest.lastIndexOf('}');
if (start === -1 || end <= start) return null;
try {
const parsed = JSON.parse(rest.slice(start, end + 1)) as { authToken?: unknown };
if (typeof parsed.authToken === 'string' && parsed.authToken.length > 0) {
return parsed.authToken;
}
} catch {
// Not the script we're looking for.
}
return null;
}
src/content.ts
+100
-0
diff --git a/src/content.ts b/src/content.ts
new file mode 100644
index 0000000..534b77d
@@ -0,0 +1,100 @@
import { getClientToken, getSessionToken } from './auth';
import { fetchPdfBlob, fetchPdfToken } from './api';
import { CancelledError, DownloadError, toFailureReason } from './errors';
import { PDF_ACCEPT, withMediaHash } from './hash';
import { parseIssueAnchor, type IssueRef } from './selectors';
import { isDownloadableTitle, warmTitlesCache } from './titles';
import { showErrorDialog } from './ui/dialog';
import { openLoadingTab, type LoadingTab } from './ui/loadingTab';
/**
* VF PDF-opener — content script for https://etidning.vf.se.
*
* Clicking an issue in a carousel opens the paper directly as a PDF in a new
* tab (borrowing the page's own login). If anything fails, a Swedish error
* dialog appears and the paper opens in the regular reader instead — the
* site always ends up working exactly as it would without the extension.
*/
let busy = false;
// Capture phase: runs before the reader app's own (React) click handlers.
document.addEventListener('click', onCaptureClick, true);
// Warm the "which titles are downloadable as PDF" cache on load (best effort).
warmTitlesCache(getClientToken(), getSessionToken());
function onCaptureClick(event: MouseEvent): void {
if (event.button !== 0) return;
if (event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
if (!(event.target instanceof Element)) return;
const anchor = event.target.closest('a');
if (!(anchor instanceof HTMLAnchorElement)) return;
const issue = parseIssueAnchor(anchor);
if (!issue) return;
if (busy) {
// A download is already running — swallow further issue clicks quietly.
event.preventDefault();
event.stopPropagation();
return;
}
const downloadable = isDownloadableTitle(issue.titleId);
if (downloadable === false) {
return; // Insert/bilaga (not downloadable as PDF) — native behaviour.
}
if (downloadable === null) {
// Cache cold — intercept optimistically, and warm for next time.
warmTitlesCache(getClientToken(), getSessionToken());
}
event.preventDefault();
event.stopPropagation();
void downloadAndOpenPdf(anchor, issue);
}
async function downloadAndOpenPdf(anchor: HTMLAnchorElement, issue: IssueRef): Promise<void> {
busy = true;
const fallbackUrl = anchor.getAttribute('href') ?? window.location.href;
const fallback = () => window.location.assign(fallbackUrl);
let tab: LoadingTab | null = null;
const abortController = new AbortController();
try {
tab = openLoadingTab();
if (!tab) throw new DownloadError('popup-blocked');
const loadingTab = tab;
const sessionToken = getSessionToken();
if (!sessionToken) throw new DownloadError('not-logged-in');
const clientToken = getClientToken();
const pdfToken = await fetchPdfToken(issue.issueId, clientToken, sessionToken);
const pdfUrl = withMediaHash(pdfToken.url, window.location.origin);
const blob = await fetchPdfBlob(
pdfUrl,
PDF_ACCEPT,
abortController.signal,
(loadedBytes, totalBytes) => {
if (loadingTab.isClosed()) {
abortController.abort(); // The user closed the loading tab — stop quietly.
return;
}
loadingTab.setProgress(loadedBytes, totalBytes);
},
);
if (loadingTab.isClosed()) throw new CancelledError();
loadingTab.openPdf(URL.createObjectURL(blob));
} catch (error) {
tab?.close();
if (error instanceof CancelledError) return;
showErrorDialog(toFailureReason(error), fallback);
} finally {
busy = false;
}
}
src/errors.ts
+30
-0
diff --git a/src/errors.ts b/src/errors.ts
new file mode 100644
index 0000000..d994a17
@@ -0,0 +1,30 @@
export type FailureReason =
'not-logged-in' | 'session-expired' | 'network' | 'popup-blocked' | 'server' | 'unknown';
/** An error that should be reported to the user with a Swedish message. */
export class DownloadError extends Error {
readonly reason: FailureReason;
readonly detail?: string;
constructor(reason: FailureReason, detail?: string) {
super(detail ?? reason);
this.name = 'DownloadError';
this.reason = reason;
this.detail = detail;
}
}
/** The user closed the loading tab — not an error, just stop quietly. */
export class CancelledError extends Error {
constructor() {
super('cancelled by user');
this.name = 'CancelledError';
}
}
/** Map any thrown value onto a user-facing failure reason. */
export function toFailureReason(error: unknown): FailureReason {
if (error instanceof DownloadError) return error.reason;
if (error instanceof TypeError) return 'network'; // fetch() network failure
return 'unknown';
}
src/hash.ts
+25
-0
diff --git a/src/hash.ts b/src/hash.ts
new file mode 100644
index 0000000..ce41f92
@@ -0,0 +1,25 @@
import { md5 } from './md5';
/**
* The exact Accept header the reader app sends when fetching a PDF.
* It is part of the media CDN hash, so it must match byte for byte.
*/
export const PDF_ACCEPT =
'application/pdf, image/webp, image/jpeg, image/png, image/gif, image/svg';
/**
* The media CDN "hotlink" hash, computed by the reader app as
* md5("<Accept header>|<origin>|<client-authorize header, always empty>").
* Verified against real traffic (download.har): the PDF Accept header and the
* origin "https://etidning.vf.se" produce the h value seen in the wild.
*/
export function mediaHash(origin: string): string {
return md5(`${PDF_ACCEPT}|${origin}|`);
}
/** Appends the h parameter to a media URL. */
export function withMediaHash(url: string, origin: string): string {
const parsed = new URL(url, origin);
parsed.searchParams.set('h', mediaHash(origin));
return parsed.toString();
}
src/md5.ts
+90
-0
diff --git a/src/md5.ts b/src/md5.ts
new file mode 100644
index 0000000..26d057f
@@ -0,0 +1,90 @@
/**
* Minimal, dependency-free MD5 (RFC 1321).
*
* Only used to compute the media CDN "hotlink" hash (see hash.ts) — not for
* anything security sensitive.
*/
const SHIFTS = new Uint8Array([
7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 7, 12, 17, 22, 5, 9, 14, 20, 5, 9, 14, 20, 5, 9, 14,
20, 5, 9, 14, 20, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 4, 11, 16, 23, 6, 10, 15, 21, 6,
10, 15, 21, 6, 10, 15, 21, 6, 10, 15, 21,
]);
const SINE_TABLE = new Uint32Array(64);
for (let i = 0; i < 64; i++) {
SINE_TABLE[i] = Math.floor(Math.abs(Math.sin(i + 1)) * 2 ** 32);
}
export function md5(message: string): string {
const messageBytes = new TextEncoder().encode(message);
const messageLength = messageBytes.length;
// Padded message: data + 0x80 + zeros + 64-bit little-endian bit length.
const paddedLength = (((messageLength + 8) >>> 6) + 1) << 6;
const padded = new Uint8Array(paddedLength);
padded.set(messageBytes);
padded[messageLength] = 0x80;
const view = new DataView(padded.buffer);
view.setUint32(paddedLength - 8, messageLength << 3, true);
view.setUint32(paddedLength - 4, Math.floor(messageLength / 2 ** 29), true);
let a0 = 0x67452301;
let b0 = 0xefcdab89;
let c0 = 0x98badcfe;
let d0 = 0x10325476;
const words = new Uint32Array(16);
for (let offset = 0; offset < paddedLength; offset += 64) {
for (let i = 0; i < 16; i++) {
words[i] = view.getUint32(offset + i * 4, true);
}
let a = a0;
let b = b0;
let c = c0;
let d = d0;
for (let i = 0; i < 64; i++) {
let f: number;
let g: number;
if (i < 16) {
f = (b & c) | (~b & d);
g = i;
} else if (i < 32) {
f = (d & b) | (~d & c);
g = (5 * i + 1) % 16;
} else if (i < 48) {
f = b ^ c ^ d;
g = (3 * i + 5) % 16;
} else {
f = c ^ (b | ~d);
g = (7 * i) % 16;
}
const temp = d;
d = c;
c = b;
const sum = (f + a + SINE_TABLE[i]! + words[g]!) >>> 0;
const shift = SHIFTS[i]!;
b = (b + ((sum << shift) | (sum >>> (32 - shift)))) >>> 0;
a = temp;
}
a0 = (a0 + a) >>> 0;
b0 = (b0 + b) >>> 0;
c0 = (c0 + c) >>> 0;
d0 = (d0 + d) >>> 0;
}
const digest = new DataView(new ArrayBuffer(16));
digest.setUint32(0, a0, true);
digest.setUint32(4, b0, true);
digest.setUint32(8, c0, true);
digest.setUint32(12, d0, true);
let hex = '';
for (let i = 0; i < 16; i++) {
hex += digest.getUint8(i).toString(16).padStart(2, '0');
}
return hex;
}
src/selectors.ts
+54
-0
diff --git a/src/selectors.ts b/src/selectors.ts
new file mode 100644
index 0000000..3753a6b
@@ -0,0 +1,54 @@
export interface IssueRef {
titleId: string;
issueId: string;
}
/**
* Primary selector: the reader's own URL contract (stable across site builds):
*
* /p/<title-slug>/<date>/p/<title_id>/<issue_id> (default view)
* /p/<title-slug>/<date>/r/<part>/<spread>/<title_id>/<issue_id> (replica view)
*
* The issue id is always the last path segment, the title id second to last.
* Article links (/a/…) are intentionally not matched.
*/
const ISSUE_HREF = /^\/p\/[^/]+\/\d{4}-\d{2}-\d{2}\/(?:p|r)(?:\/\d+)*\/(\d+)\/(\d+)\/?$/;
/** Article-mode links are never issue links, even in the loose fallback. */
const ARTICLE_HREF = /^\/p\/[^/]+\/\d{4}-\d{2}-\d{2}\/a\//;
/**
* Fallback classes observed on the site (Nov 2026). styled-components class
* names regenerate on every build of the site, so these are only a secondary
* signal — the href pattern above is the main selector.
*/
const FALLBACK_CELL_CLASS = 'sc-djvmMF';
const FALLBACK_LINK_CLASS = 'sc-iTONeN';
/** Loose shape: /p/… ending in two numeric segments (title id + issue id). */
const LOOSE_ISSUE_HREF = /^\/p\/.+\/(\d+)\/(\d+)\/?$/;
export function parseIssueAnchor(anchor: HTMLAnchorElement): IssueRef | null {
const href = anchor.getAttribute('href');
if (!href) return null;
const path = href.split(/[?#]/, 1)[0] ?? href;
const strict = ISSUE_HREF.exec(path);
if (strict) {
return { titleId: strict[1] ?? '', issueId: strict[2] ?? '' };
}
if (ARTICLE_HREF.test(path)) return null;
// Fallback for future URL tweaks: same rough shape + known carousel classes.
const hasClassSignal =
anchor.classList.contains(FALLBACK_LINK_CLASS) ||
anchor.closest(`.${FALLBACK_CELL_CLASS}`) !== null;
if (hasClassSignal) {
const loose = LOOSE_ISSUE_HREF.exec(path);
if (loose) {
return { titleId: loose[1] ?? '', issueId: loose[2] ?? '' };
}
}
return null;
}
src/titles.ts
+38
-0
diff --git a/src/titles.ts b/src/titles.ts
new file mode 100644
index 0000000..0915910
@@ -0,0 +1,38 @@
import { fetchContextToken, fetchDownloadableTitleIds } from './api';
/**
* Cache of title ids that are downloadable as PDF (the main paper — inserts
* and bilagor are not). Warmed on page load and on demand; used to leave
* non-downloadable issues completely untouched (native behaviour).
*/
let downloadableTitleIds: Set<string> | null = null;
let warming: Promise<void> | null = null;
/**
* Is this title downloadable as PDF?
* Returns null when unknown (cache cold) — callers should then intercept
* optimistically and rely on the graceful fallback.
*/
export function isDownloadableTitle(titleId: string): boolean | null {
if (downloadableTitleIds === null) return null;
return downloadableTitleIds.has(titleId);
}
/** Warm the cache (best effort, silent). Safe to call repeatedly. */
export function warmTitlesCache(clientToken: string, sessionToken: string | null): Promise<void> {
warming ??= doWarm(clientToken, sessionToken);
return warming;
}
async function doWarm(clientToken: string, sessionToken: string | null): Promise<void> {
try {
if (!sessionToken) return; // not logged in — nothing to warm yet
const contextToken = await fetchContextToken(clientToken, sessionToken);
downloadableTitleIds = await fetchDownloadableTitleIds(contextToken);
} catch {
downloadableTitleIds = null; // stay cold; next click intercepts optimistically
} finally {
warming = null;
}
}
src/ui/dialog.ts
+154
-0
diff --git a/src/ui/dialog.ts b/src/ui/dialog.ts
new file mode 100644
index 0000000..b6ae71d
@@ -0,0 +1,154 @@
import type { FailureReason } from '../errors';
import {
DIALOG_CLOSE_LABEL,
DIALOG_FALLBACK_BUTTON,
DIALOG_TITLE,
FAILURE_MESSAGES,
} from './strings';
/**
* A small, pretty error dialog (Swedish) rendered in a closed shadow root so
* the site's styles and ours can never clash. One clear action: open the
* paper the way the site would have without the extension.
*/
const HOST_ID = 'vf-pdf-opener-dialog';
const ICON_SVG = `<svg viewBox="0 0 24 24" width="40" height="40" aria-hidden="true">
<circle cx="12" cy="12" r="12" fill="#bd2428"/>
<path fill="#fff" d="M12 5.6a1.5 1.5 0 0 1 1.5 1.62l-.45 6.4a1.05 1.05 0 0 1-2.1 0l-.45-6.4A1.5 1.5 0 0 1 12 5.6Z"/>
<circle cx="12" cy="16.9" r="1.35" fill="#fff"/>
</svg>`;
const DIALOG_CSS = `
.overlay {
position: fixed;
inset: 0;
background: rgba(15, 17, 20, 0.55);
display: flex;
align-items: center;
justify-content: center;
font-family: 'Lato', -apple-system, 'Segoe UI', system-ui, sans-serif;
animation: vfpe-fade 0.15s ease;
}
@keyframes vfpe-fade { from { opacity: 0; } }
.card {
background: #fff;
border-radius: 14px;
box-shadow: 0 18px 60px rgba(0, 0, 0, 0.3);
width: min(400px, calc(100vw - 40px));
padding: 28px;
position: relative;
text-align: center;
animation: vfpe-pop 0.18s ease;
}
@keyframes vfpe-pop {
from { opacity: 0; transform: translateY(8px) scale(0.98); }
}
.icon { margin-bottom: 14px; }
h2 { font-size: 20px; color: #23262b; margin: 0 0 8px; font-weight: 700; }
p { font-size: 14.5px; line-height: 1.55; color: #55595f; margin: 0 0 22px; }
button.primary {
background: #bd2428;
color: #fff;
border: none;
font: inherit;
font-size: 15px;
font-weight: 700;
padding: 12px 24px;
border-radius: 9px;
cursor: pointer;
width: 100%;
}
button.primary:hover { background: #a41f23; }
button.primary:focus-visible { outline: 3px solid rgba(189, 36, 40, 0.35); outline-offset: 2px; }
button.close {
position: absolute;
top: 10px;
right: 10px;
background: none;
border: none;
cursor: pointer;
font-size: 20px;
line-height: 1;
color: #9aa0a8;
padding: 6px;
border-radius: 6px;
}
button.close:hover { color: #55595f; background: #f2f3f5; }
`;
export function showErrorDialog(reason: FailureReason, onFallback: () => void): void {
dismissDialog();
const host = document.createElement('div');
host.id = HOST_ID;
host.style.cssText = 'all: initial; position: fixed; inset: 0; z-index: 2147483647;';
const shadow = host.attachShadow({ mode: 'open' });
const style = document.createElement('style');
style.textContent = DIALOG_CSS;
const overlay = document.createElement('div');
overlay.className = 'overlay';
const card = document.createElement('div');
card.className = 'card';
card.setAttribute('role', 'alertdialog');
card.setAttribute('aria-modal', 'true');
card.setAttribute('aria-labelledby', 'vfpe-dialog-title');
card.setAttribute('aria-describedby', 'vfpe-dialog-message');
const closeButton = document.createElement('button');
closeButton.className = 'close';
closeButton.type = 'button';
closeButton.setAttribute('aria-label', DIALOG_CLOSE_LABEL);
closeButton.textContent = '✕';
const icon = document.createElement('div');
icon.className = 'icon';
icon.innerHTML = ICON_SVG;
const title = document.createElement('h2');
title.id = 'vfpe-dialog-title';
title.textContent = DIALOG_TITLE;
const message = document.createElement('p');
message.id = 'vfpe-dialog-message';
message.textContent = FAILURE_MESSAGES[reason];
const fallbackButton = document.createElement('button');
fallbackButton.className = 'primary';
fallbackButton.type = 'button';
fallbackButton.textContent = DIALOG_FALLBACK_BUTTON;
card.append(closeButton, icon, title, message, fallbackButton);
overlay.append(card);
shadow.append(style, overlay);
const cleanup = () => {
document.removeEventListener('keydown', onKeydown);
host.remove();
};
const onKeydown = (event: KeyboardEvent) => {
if (event.key === 'Escape') cleanup();
};
overlay.addEventListener('click', (event) => {
if (event.target === overlay) cleanup();
});
closeButton.addEventListener('click', cleanup);
fallbackButton.addEventListener('click', () => {
cleanup();
onFallback();
});
document.addEventListener('keydown', onKeydown);
document.body.append(host);
fallbackButton.focus();
}
/** Remove any dialog we previously opened. */
function dismissDialog(): void {
document.getElementById(HOST_ID)?.remove();
}
src/ui/loadingTab.ts
+139
-0
diff --git a/src/ui/loadingTab.ts b/src/ui/loadingTab.ts
new file mode 100644
index 0000000..09d14ae
@@ -0,0 +1,139 @@
import { LOADING_SUBTITLE, LOADING_TITLE, formatMegabytes } from './strings';
/**
* A helper tab that acts as the loading indicator: opened synchronously on
* click (so it is never popup-blocked), shows a spinner + progress bar while
* the PDF streams, and is then navigated to the finished PDF blob.
*/
export interface LoadingTab {
/** Navigate the helper tab to the finished PDF. */
openPdf(blobUrl: string): void;
/** Progress callback, wired into the PDF download. */
setProgress(loadedBytes: number, totalBytes: number | null): void;
/** Did the user close the helper tab? */
isClosed(): boolean;
close(): void;
}
const LOADING_HTML = `<!doctype html>
<html lang="sv">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>${LOADING_TITLE}</title>
<style>
:root { color-scheme: light; }
* { box-sizing: border-box; margin: 0; }
body {
font-family: 'Lato', -apple-system, 'Segoe UI', system-ui, sans-serif;
background: #f4f4f5;
color: #2a2d31;
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
}
.card {
background: #fff;
border-radius: 14px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.12);
padding: 40px 48px 32px;
width: min(420px, calc(100vw - 48px));
text-align: center;
}
.spinner {
width: 44px;
height: 44px;
margin: 0 auto 20px;
border-radius: 50%;
border: 4px solid #f0d5d6;
border-top-color: #bd2428;
animation: vfpe-spin 0.9s linear infinite;
}
@keyframes vfpe-spin { to { transform: rotate(360deg); } }
h1 { font-size: 20px; font-weight: 700; margin-bottom: 6px; }
.sub { font-size: 14px; color: #6b6f76; margin-bottom: 22px; line-height: 1.5; }
.track { height: 8px; border-radius: 999px; background: #ececed; overflow: hidden; }
.bar {
height: 100%;
width: 0%;
border-radius: 999px;
background: #bd2428;
transition: width 0.2s ease;
}
.bar.indeterminate { width: 35%; animation: vfpe-slide 1.1s ease-in-out infinite; }
@keyframes vfpe-slide {
0% { margin-left: -35%; }
100% { margin-left: 100%; }
}
.status { font-size: 13px; color: #6b6f76; margin-top: 12px; min-height: 1em; }
footer { margin-top: 26px; font-size: 12px; color: #a3a6ac; }
</style>
</head>
<body>
<main class="card">
<div class="spinner" role="status" aria-label="Laddar"></div>
<h1>${LOADING_TITLE}</h1>
<p class="sub">${LOADING_SUBTITLE}</p>
<div class="track"><div class="bar indeterminate" id="vfpe-bar"></div></div>
<p class="status" id="vfpe-status">Hämtar…</p>
<footer>VF PDF-opener</footer>
</main>
</body>
</html>`;
/** Open the loading tab. Returns null if the browser blocked the popup. */
export function openLoadingTab(): LoadingTab | null {
let win: Window | null = null;
try {
win = window.open('', '_blank');
} catch {
return null;
}
if (!win) return null;
try {
const doc = win.document;
doc.open();
doc.write(LOADING_HTML);
doc.close();
} catch {
try {
win.close();
} catch {
// Ignore — the tab will just sit there empty until closed manually.
}
return null;
}
const bar = win.document.getElementById('vfpe-bar') as HTMLDivElement | null;
const status = win.document.getElementById('vfpe-status') as HTMLParagraphElement | null;
return {
openPdf(blobUrl: string) {
try {
win!.location.replace(blobUrl);
} catch {
// The tab was closed between the check and the navigation — ignore.
}
},
setProgress(loadedBytes: number, totalBytes: number | null) {
if (!bar || !status) return;
if (totalBytes !== null && totalBytes > 0) {
bar.classList.remove('indeterminate');
const percent = Math.min(100, Math.round((loadedBytes / totalBytes) * 100));
bar.style.width = `${percent}%`;
status.textContent = `${formatMegabytes(loadedBytes)} av ${formatMegabytes(totalBytes)}`;
} else {
status.textContent = `${formatMegabytes(loadedBytes)} hämtat…`;
}
},
isClosed() {
return win!.closed;
},
close() {
if (!win!.closed) win!.close();
},
};
}
src/ui/strings.ts
+25
-0
diff --git a/src/ui/strings.ts b/src/ui/strings.ts
new file mode 100644
index 0000000..9ea9536
@@ -0,0 +1,25 @@
import type { FailureReason } from '../errors';
export const LOADING_TITLE = 'Laddar ned PDF…';
export const LOADING_SUBTITLE = 'E-tidningen hämtas och öppnas i den här fliken när den är klar.';
export const DIALOG_TITLE = 'Något gick fel';
export const DIALOG_FALLBACK_BUTTON = 'Öppna tidningen ändå';
export const DIALOG_CLOSE_LABEL = 'Stäng';
export const FAILURE_MESSAGES: Record<FailureReason, string> = {
'not-logged-in':
'Du verkar inte vara inloggad. Logga in på e-tidningens webbsida och försök igen.',
'session-expired':
'Din inloggning har upphört att gälla. Logga in igen på webbsidan och försök igen.',
network: 'Det gick inte att nå servern. Kontrollera internetanslutningen och försök igen.',
'popup-blocked':
'Webbläsaren blockerade öppningen av en ny flik. Tillåt pop-up-fönster för den här sidan och försök igen.',
server: 'Tyvärr kunde inte PDF:en laddas ned just nu. Försök igen om en stund.',
unknown: 'Tyvärr kunde inte PDF:en laddas ned.',
};
/** Format a byte count as megabytes with Swedish decimal comma, e.g. "13,4 MB". */
export function formatMegabytes(bytes: number): string {
return `${(bytes / 1024 / 1024).toFixed(1).replace('.', ',')} MB`;
}
tsconfig.json
+17
-0
diff --git a/tsconfig.json b/tsconfig.json
new file mode 100644
index 0000000..e3c3d08
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"module": "ESNext",
"moduleResolution": "Bundler",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noUncheckedIndexedAccess": true,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src"]
}
tsup.config.ts
+13
-0
diff --git a/tsup.config.ts b/tsup.config.ts
new file mode 100644
index 0000000..071407f
@@ -0,0 +1,13 @@
import { defineConfig } from 'tsup';
export default defineConfig({
entry: { content: 'src/content.ts' },
outDir: 'dist',
format: ['iife'],
target: 'es2022',
clean: true,
sourcemap: false,
minify: false,
treeshake: true,
outExtension: () => ({ js: '.js' }),
});