Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

18 changes: 16 additions & 2 deletions components/bulk-options.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
type PageTab,
} from "../src/bulk";
import { getOptions, type Options } from "../src/options";
import { reach } from "../src/progress";
import { errString, safeFilename } from "../src/utils";

// the panel unmounts the moment another tab is picked, so what's chosen, what's
Expand Down Expand Up @@ -126,6 +127,12 @@ function Favicon({ page }: { page: PageTab }): ReactElement {
}
}

// a step reports when it starts and then as it goes, so between reports the
// browser eases the ring on toward the end of the step; the long curve is
// front-loaded, which is why a slow step still looks like it's moving
const DRIFT = "6s cubic-bezier(0, 0.7, 0.25, 1)";
const SETTLE = "400ms cubic-bezier(0.4, 0, 0.2, 1)";

/** the page's icon, ringed by how far along its export is */
function PageIcon({
page,
Expand Down Expand Up @@ -157,11 +164,18 @@ function PageIcon({
/>
<CircularProgress
variant="determinate"
value={state.kind === "running" ? state.progress * 100 : 100}
value={state.kind === "running" ? reach(state) * 100 : 100}
size={28}
thickness={4}
color={state.kind === "failed" ? "error" : "primary"}
sx={{ position: "absolute" }}
sx={{
position: "absolute",
"& circle": {
transition: `stroke-dashoffset ${
state.kind === "running" ? DRIFT : SETTLE
}`,
},
}}
/>
</>
)}
Expand Down
4 changes: 1 addition & 3 deletions components/options-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -168,9 +168,7 @@ async function uploadFile(deviceToken: string, file: File): Promise<void> {
const { epub, title = file_title } = await render(
mhtml.buffer as ArrayBuffer,
opts,
file_title,
undefined,
false,
{ title: file_title, summarize: false },
);
await uploadEpub(epub, title, deviceToken, opts);
} else {
Expand Down
4 changes: 4 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,8 @@
"base64-js": "^1.5.1",
"codemirror": "^6.0.2",
"css-tree": "^3.2.1",
"d3-ease": "^3.0.1",
"d3-timer": "^3.0.1",
"defuddle": "^0.19.4",
"entities": "^8.1.0",
"jsdom": "^30.0.1",
Expand All @@ -59,6 +61,8 @@
"@types/bun": "^1.4.2",
"@types/chrome": "^0.2.7",
"@types/css-tree": "^3.2.0",
"@types/d3-ease": "^3.0.2",
"@types/d3-timer": "^3.0.2",
"@types/jsdom": "^30.0.0",
"@types/react": "^19.3.0",
"@types/react-dom": "^19.3.0",
Expand Down
27 changes: 16 additions & 11 deletions src/background.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,15 @@ import { clearProgress } from "./icon";
import type { TitleRequest } from "./messages";
import { getOptions } from "./options";
import { registerPrinter } from "./printer";
import { steps, within } from "./progress";
import { render } from "./render";
import { getTab } from "./status";
import { uploadEpub } from "./upload";
import { safeFilename, sleep } from "./utils";

/** the parts of a conversion, weighted by how long each tends to take */
const stage = steps({ capture: 2, render: 6, send: 2 });

/**
* create and upload epub
*/
Expand All @@ -21,7 +25,7 @@ async function rePub(
// get active state for clearing the icon with navigation
const tab = getTab(tabId);
try {
await tab.init();
tab.init();

const { deviceToken, outputStyle, downloadAsk, promptTitle, ...opts } =
await getOptions();
Expand All @@ -35,18 +39,19 @@ async function rePub(
return;
}

await tab.progress(25);
tab.progress(within(stage.capture, 0));

const mhtml = await pageCapture(tabId);
await tab.progress(50);
tab.progress(within(stage.render, 0));

const { epub, title = "missing title" } = await render(
mhtml,
opts,
initTitle,
initAuthor,
);
await tab.progress(75);
const { epub, title = "missing title" } = await render(mhtml, opts, {
title: initTitle,
author: initAuthor,
onProgress: (portion) => {
tab.progress(within(stage.render, portion));
},
});
tab.progress(within(stage.send, 0));

// upload
if (outputStyle === "download") {
Expand All @@ -68,7 +73,7 @@ async function rePub(
);
}

await tab.complete();
tab.complete();
} catch (ex) {
const msg = ex instanceof Error ? ex.toString() : "unknown error";
await Promise.all([
Expand Down
34 changes: 30 additions & 4 deletions src/bulk.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@ import {
listable,
type PageState,
type PageTab,
stage,
} from "./bulk";
import { defaultOptions, type Options } from "./options";
import { within } from "./progress";

const page: PageTab = {
tabId: 1,
Expand Down Expand Up @@ -139,14 +141,38 @@ test("upload a page", async () => {
expect(woke).toEqual([1]);
expect(sent).toEqual(["parsed title"]);
expect(states).toEqual([
{ kind: "running", progress: 0.1 },
{ kind: "running", progress: 0.25 },
{ kind: "running", progress: 0.5 },
{ kind: "running", progress: 0.75 },
{ kind: "running", ...within(stage.wake, 0) },
{ kind: "running", ...within(stage.capture, 0) },
{ kind: "running", ...within(stage.render, 0) },
{ kind: "running", ...within(stage.send, 0) },
{ kind: "finished" },
]);
});

test("a render reports its own progress", async () => {
const opts: Options = { ...defaultOptions, deviceToken: "token" };
const [states, report] = collect();

await exportPage(
page,
undefined,
opts,
report,
fakeEnv({
convert: async (_, __, extra) => {
extra?.onProgress?.(0.5);
return { epub: new Uint8Array([1]), title: "parsed title" };
},
}),
);

// half way through the render lands half way through the render's own share
expect(states).toContainEqual({
kind: "running",
...within(stage.render, 0.5),
});
});

test("upload needs a token", async () => {
const [states, report] = collect();
await expect(
Expand Down
29 changes: 21 additions & 8 deletions src/bulk.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { fromByteArray } from "base64-js";
import { pageCapture } from "./capture";
import { showFailed, showProgress } from "./icon";
import type { EpubOptions, Options, UploadOptions } from "./options";
import { steps, within } from "./progress";
import { render } from "./render";
import { uploadEpub } from "./upload";
import { errString, Semaphore, safeFilename, timeout } from "./utils";
Expand All @@ -12,6 +13,9 @@ const WAKE_TIMEOUT = 30_000;

const room = new Semaphore(4);

/** the parts of an export, weighted by how long each tends to take */
export const stage = steps({ wake: 1, capture: 2, render: 6, send: 2 });

/** an open tab offered for export */
export interface PageTab {
tabId: number;
Expand All @@ -26,7 +30,7 @@ export interface PageTab {

/** how far along a page is, or what became of it */
export type PageState =
| { kind: "running"; progress: number }
| { kind: "running"; progress: number; upto: number }
| { kind: "finished" }
| { kind: "failed"; error: string };

Expand All @@ -37,6 +41,7 @@ interface ExportEnv {
convert: (
mhtml: ArrayBuffer,
opts: EpubOptions,
extra?: { onProgress?: (portion: number) => void },
) => Promise<{ epub: Uint8Array; title?: string }>;
send: (
epub: Uint8Array,
Expand All @@ -53,9 +58,9 @@ async function markTab(tabId: number, state: PageState): Promise<void> {
if (state.kind === "failed") {
await showFailed(tabId);
} else if (state.kind === "finished") {
await showProgress(tabId, 1);
showProgress(tabId, { progress: 1, upto: 1 });
} else {
await showProgress(tabId, state.progress);
showProgress(tabId, state);
}
}

Expand Down Expand Up @@ -210,13 +215,21 @@ export async function exportPage(
const given = name?.trim() || undefined;

await using _permit = await room.lock();
report({ kind: "running", progress: 0.1 });
report({ kind: "running", ...within(stage.wake, 0) });
await wake(page.tabId);
report({ kind: "running", progress: 0.25 });
report({ kind: "running", ...within(stage.capture, 0) });
const mhtml = await capture(page.tabId);
report({ kind: "running", progress: 0.5 });
const { epub, title = given ?? "missing title" } = await convert(mhtml, rest);
report({ kind: "running", progress: 0.75 });
report({ kind: "running", ...within(stage.render, 0) });
const { epub, title = given ?? "missing title" } = await convert(
mhtml,
rest,
{
onProgress: (portion) => {
report({ kind: "running", ...within(stage.render, portion) });
},
},
);
report({ kind: "running", ...within(stage.send, 0) });

if (outputStyle === "download") {
await save(
Expand Down
1 change: 0 additions & 1 deletion src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -192,7 +192,6 @@ global.Image = dom.window["Image"] as typeof Image;
rotateTables: false,
tableResolution: 1,
},
true,
);

if (args.verbose > 0) {
Expand Down
35 changes: 34 additions & 1 deletion src/icon.spec.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { expect, test } from "bun:test";
import { arc } from "./icon";
import { arc, ringAt } from "./icon";

test("arc", () => {
const top = -Math.PI / 2;
Expand All @@ -12,3 +12,36 @@ test("arc", () => {
expect(arc(-1)).toEqual([top, top]);
expect(arc(2)).toEqual([top, full]);
});

test("ringAt eases to what was reported", () => {
const spot = { progress: 0.5, upto: 0.5 };

expect(ringAt(0.2, spot, 0)).toBe(0.2);
// the ease is fastest at the start, so it's over half way by half way
expect(ringAt(0.2, spot, 175)).toBeGreaterThan(0.35);
expect(ringAt(0.2, spot, 350)).toBeCloseTo(0.5, 10);
expect(ringAt(0.2, spot, 10_000)).toBeCloseTo(0.5, 10);
});

test("ringAt drifts on through the step", () => {
const spot = { progress: 0.2, upto: 0.6 };
const arrived = ringAt(0.2, spot, 350);
const drifted = ringAt(0.2, spot, 4_000);
const later = ringAt(0.2, spot, 60_000);

expect(arrived).toBeCloseTo(0.2, 10);
expect(drifted).toBeGreaterThan(arrived);
expect(later).toBeGreaterThan(drifted);
// the drift stops short, so a step that drags doesn't look finished
expect(later).toBeLessThan(0.6);
});

test("ringAt only moves forward", () => {
const spot = { progress: 0.3, upto: 0.9 };
let last = 0;
for (let elapsed = 0; elapsed < 30_000; elapsed += 40) {
const at = ringAt(0.1, spot, elapsed);
expect(at).toBeGreaterThanOrEqual(last);
last = at;
}
});
Loading
Loading