7da41a65ad
Add a resizable home-screen widget on iOS and Android showing recent
in-progress books with cover, reading progress, and tap-to-open.
- One responsive widget: Android resizable 1x1 to 4x3 (one book per
column, up to 3); iOS Small/Medium/Large families. Covers are cropped,
rounded, with a percent badge and a progress bar (baked into the bitmap
on Android, SwiftUI overlays on iOS).
- TTS controls (previous, play-pause, next) appear in 2+ row sizes when
TTS is active, wired to the existing media session. Reading progress
stays live during background TTS via a fraction computed from the baked
offline locations.
- Publishes a snapshot plus downsized cover thumbnails to the iOS App
Group and Android SharedPreferences through a new update_reading_widget
native-bridge command; refresh is debounced and driven by library and
progress changes, TTS, and app backgrounding.
- Tapping a cover opens readest://book/{hash}, switching the reader in
place when one is already open.
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
128 lines
4.3 KiB
TypeScript
128 lines
4.3 KiB
TypeScript
import { READEST_WEB_BASE_URL } from '@/services/constants';
|
|
|
|
export type AnnotationDeepLink = {
|
|
bookHash: string;
|
|
noteId: string;
|
|
cfi?: string;
|
|
};
|
|
|
|
/**
|
|
* Which form of annotation link markdown export embeds: the custom-scheme
|
|
* `readest://` app deeplink or the universal `https://` web link.
|
|
*/
|
|
export type AnnotationLinkType = 'app' | 'web';
|
|
|
|
const ANNOTATION_PATH_PREFIX = '/o/book/';
|
|
|
|
/**
|
|
* Build the canonical HTTPS URL for an annotation. Used in markdown export
|
|
* and Readwise sync. Mobile App Links (web.readest.com) intercept this URL
|
|
* and open the native app; on desktop browsers it resolves to the smart
|
|
* landing page at /o/book/{hash}/annotation/{id}.
|
|
*/
|
|
export const buildAnnotationWebUrl = ({ bookHash, noteId, cfi }: AnnotationDeepLink): string => {
|
|
const base = `${READEST_WEB_BASE_URL}${ANNOTATION_PATH_PREFIX}${bookHash}/annotation/${noteId}`;
|
|
return cfi ? `${base}?cfi=${encodeURIComponent(cfi)}` : base;
|
|
};
|
|
|
|
/**
|
|
* Build the custom-scheme URL. Kept as a parallel form for share-sheet flows
|
|
* and direct deeplink scenarios. Markdown export uses the HTTPS form.
|
|
*/
|
|
export const buildAnnotationAppUrl = ({ bookHash, noteId, cfi }: AnnotationDeepLink): string => {
|
|
const base = `readest://book/${bookHash}/annotation/${noteId}`;
|
|
return cfi ? `${base}?cfi=${encodeURIComponent(cfi)}` : base;
|
|
};
|
|
|
|
/**
|
|
* Build the annotation link for the requested {@link AnnotationLinkType}.
|
|
* `app` yields the custom-scheme deeplink; `web` yields the universal HTTPS form.
|
|
*/
|
|
export const buildAnnotationUrl = (
|
|
link: AnnotationDeepLink,
|
|
linkType: AnnotationLinkType,
|
|
): string => (linkType === 'app' ? buildAnnotationAppUrl(link) : buildAnnotationWebUrl(link));
|
|
|
|
/**
|
|
* Parse an incoming readest:// or https://web.readest.com annotation URL.
|
|
* Accepts the new hierarchical form (book/{hash}/annotation/{id}) and the
|
|
* legacy flat form (annotation/{hash}/{id}) emitted by older Readwise syncs.
|
|
* Returns null if the URL doesn't match.
|
|
*/
|
|
export const parseAnnotationDeepLink = (url: string): AnnotationDeepLink | null => {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const isCustomScheme = parsed.protocol === 'readest:';
|
|
const isWebHost =
|
|
(parsed.protocol === 'https:' || parsed.protocol === 'http:') &&
|
|
parsed.host === 'web.readest.com';
|
|
if (!isCustomScheme && !isWebHost) return null;
|
|
|
|
// For readest:// URLs the URL parser stores the first path segment in the
|
|
// host. Reconstruct a uniform segment list across both schemes.
|
|
const segments: string[] = isCustomScheme
|
|
? [parsed.host, ...parsed.pathname.split('/')].filter(Boolean)
|
|
: parsed.pathname.split('/').filter(Boolean);
|
|
|
|
// HTTPS landing page is prefixed with /o/. Strip it for uniform parsing.
|
|
if (isWebHost) {
|
|
if (segments[0] !== 'o') return null;
|
|
segments.shift();
|
|
}
|
|
|
|
const cfiParam = parsed.searchParams.get('cfi');
|
|
const cfi = cfiParam ? cfiParam : undefined;
|
|
|
|
// Hierarchical: book/{hash}/annotation/{id}
|
|
if (segments.length === 4 && segments[0] === 'book' && segments[2] === 'annotation') {
|
|
return { bookHash: segments[1]!, noteId: segments[3]!, cfi };
|
|
}
|
|
|
|
// Legacy flat: annotation/{hash}/{id}
|
|
if (segments.length === 3 && segments[0] === 'annotation') {
|
|
return { bookHash: segments[1]!, noteId: segments[2]!, cfi };
|
|
}
|
|
|
|
return null;
|
|
};
|
|
|
|
/**
|
|
* Parse an incoming readest:// or https://web.readest.com book-open URL.
|
|
* Matches only the bare form `book/{hash}` (the widget tap target); the
|
|
* 4-segment annotation form `book/{hash}/annotation/{id}` is handled by
|
|
* parseAnnotationDeepLink and must NOT match here.
|
|
*/
|
|
export const parseBookDeepLink = (url: string): { bookHash: string } | null => {
|
|
let parsed: URL;
|
|
try {
|
|
parsed = new URL(url);
|
|
} catch {
|
|
return null;
|
|
}
|
|
|
|
const isCustomScheme = parsed.protocol === 'readest:';
|
|
const isWebHost =
|
|
(parsed.protocol === 'https:' || parsed.protocol === 'http:') &&
|
|
parsed.host === 'web.readest.com';
|
|
if (!isCustomScheme && !isWebHost) return null;
|
|
|
|
const segments: string[] = isCustomScheme
|
|
? [parsed.host, ...parsed.pathname.split('/')].filter(Boolean)
|
|
: parsed.pathname.split('/').filter(Boolean);
|
|
|
|
if (isWebHost) {
|
|
if (segments[0] !== 'o') return null;
|
|
segments.shift();
|
|
}
|
|
|
|
if (segments.length === 2 && segments[0] === 'book' && segments[1]) {
|
|
return { bookHash: segments[1] };
|
|
}
|
|
return null;
|
|
};
|