gloomberb 0.8.2 → 0.9.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -7
- package/package.json +18 -13
- package/patches/{@opentui%2Fcore@0.1.90.patch → @opentui%2Fcore@0.3.2.patch} +175 -90
- package/patches/electrobun@1.18.1.patch +85 -0
- package/src/app/global-shortcuts.test.tsx +66 -4
- package/src/app/global-shortcuts.ts +12 -6
- package/src/assets/gloomberb-logo-windows.ico +0 -0
- package/src/assets/gloomberb-logo-windows.png +0 -0
- package/src/assets/gloomberb-logo.ico +0 -0
- package/src/components/command-bar/action-runtime.ts +1 -0
- package/src/components/command-bar/layout-items/current-layout.test.ts +115 -0
- package/src/components/command-bar/layout-items/current-layout.ts +33 -0
- package/src/components/command-bar/list/view.tsx +3 -1
- package/src/components/command-bar/pane-templates/items.ts +4 -1
- package/src/components/command-bar/panel/body.tsx +1 -0
- package/src/components/command-bar/routes/root/results.ts +0 -30
- package/src/components/command-bar/routes/root/runtime.ts +2 -20
- package/src/components/command-bar/routes/ticker-search/actions.ts +42 -4
- package/src/components/command-bar/routes/ticker-search/route.ts +4 -1
- package/src/components/command-bar/routing/launch-request.ts +14 -7
- package/src/components/command-bar/routing/list-state.ts +1 -3
- package/src/components/command-bar/surface/index.test.tsx +76 -4
- package/src/components/command-bar/surface/index.tsx +0 -2
- package/src/components/command-bar/surface/pane.test.tsx +1 -0
- package/src/components/command-bar/surface/test-harness.tsx +1 -0
- package/src/components/command-bar/view-model.ts +3 -3
- package/src/components/layout/detached-pane-shell.tsx +8 -4
- package/src/components/layout/header.tsx +13 -6
- package/src/components/layout/shell/index.test.tsx +120 -2
- package/src/components/layout/shell/index.tsx +2 -0
- package/src/components/layout/shell/menu.ts +1 -7
- package/src/components/layout/shell/pane/actions.ts +8 -0
- package/src/components/layout/shell/pane/management-shortcuts.ts +12 -2
- package/src/components/layout/shell/shortcuts.ts +14 -6
- package/src/components/layout/shell/window-mode/index.ts +8 -3
- package/src/components/layout/titlebar-overlay.ts +16 -0
- package/src/components/layout/window-controls.tsx +90 -0
- package/src/components/layout/window-edit/presentation.ts +4 -4
- package/src/components/onboarding/onboarding-steps.tsx +3 -2
- package/src/components/ticker/list-table-view.tsx +6 -0
- package/src/components/ui/remote-image.tsx +1 -14
- package/src/components/ui/tabs.tsx +95 -80
- package/src/components/ui/ui.test.tsx +54 -0
- package/src/core/state/app/types.ts +12 -4
- package/src/plugins/builtin/chat/content/channel-navigation.ts +44 -11
- package/src/plugins/builtin/chat/content/index.tsx +5 -4
- package/src/plugins/builtin/chat/content/shortcuts.ts +7 -0
- package/src/plugins/builtin/chat/sidebar.test.tsx +71 -0
- package/src/plugins/builtin/help/components.tsx +1 -1
- package/src/plugins/builtin/help/index.test.tsx +105 -30
- package/src/plugins/builtin/help/index.tsx +324 -168
- package/src/plugins/builtin/help/shortcut-model.ts +5 -6
- package/src/plugins/builtin/portfolio-list/column-values.ts +304 -1
- package/src/plugins/builtin/portfolio-list/metrics.test.ts +93 -0
- package/src/plugins/builtin/portfolio-list/pane/data.ts +7 -0
- package/src/plugins/builtin/portfolio-list/pane/index.tsx +25 -2
- package/src/plugins/builtin/portfolio-list/pane/supplemental.ts +281 -0
- package/src/plugins/builtin/portfolio-list/settings.ts +27 -0
- package/src/plugins/builtin/portfolio-list/table.tsx +4 -2
- package/src/plugins/builtin/portfolio-list/use-throttled-cursor-symbol.ts +10 -68
- package/src/plugins/builtin/substack/api/auth.ts +185 -0
- package/src/plugins/builtin/substack/api/cache.ts +78 -0
- package/src/plugins/builtin/substack/api/loaders.ts +146 -0
- package/src/plugins/builtin/substack/api/store.ts +186 -0
- package/src/plugins/builtin/substack/api/types.ts +40 -0
- package/src/plugins/builtin/substack/api.test.ts +298 -0
- package/src/plugins/builtin/substack/article-detail.tsx +359 -0
- package/src/plugins/builtin/substack/article-stack.tsx +129 -0
- package/src/plugins/builtin/substack/content.ts +478 -0
- package/src/plugins/builtin/substack/feed-tabs.tsx +41 -0
- package/src/plugins/builtin/substack/index.tsx +49 -0
- package/src/plugins/builtin/substack/login-view.tsx +126 -0
- package/src/plugins/builtin/substack/model.test.ts +155 -0
- package/src/plugins/builtin/substack/normalize.ts +343 -0
- package/src/plugins/builtin/substack/pane-footer.ts +71 -0
- package/src/plugins/builtin/substack/pane-state.ts +170 -0
- package/src/plugins/builtin/substack/pane.tsx +500 -0
- package/src/plugins/builtin/substack/table.ts +111 -0
- package/src/plugins/builtin/substack/types.ts +71 -0
- package/src/plugins/builtin/substack/utils.ts +131 -0
- package/src/plugins/builtin/ticker-detail/index.test.tsx +16 -2
- package/src/plugins/builtin/ticker-detail/index.tsx +1 -0
- package/src/plugins/builtin/ticker-detail/pane.tsx +13 -1
- package/src/plugins/catalog-ui.ts +2 -0
- package/src/plugins/pane-manager/layout-state.ts +11 -0
- package/src/plugins/pane-manager.ts +1 -1
- package/src/react/use-throttled-commit-value.ts +116 -0
- package/src/renderers/electrobun/bun/application-menu/index.test.ts +12 -0
- package/src/renderers/electrobun/bun/application-menu/index.ts +4 -0
- package/src/renderers/electrobun/bun/desktop/detached-windows.ts +43 -2
- package/src/renderers/electrobun/bun/desktop/host-requests.ts +31 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.test.ts +42 -0
- package/src/renderers/electrobun/bun/desktop/http-fetch.ts +5 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.test.ts +53 -0
- package/src/renderers/electrobun/bun/desktop/window-controls.ts +64 -0
- package/src/renderers/electrobun/bun/desktop/window-style.ts +5 -0
- package/src/renderers/electrobun/bun/desktop/windows-icons.ts +123 -0
- package/src/renderers/electrobun/bun/index.ts +28 -6
- package/src/renderers/electrobun/bun/window/frame.test.ts +7 -0
- package/src/renderers/electrobun/bun/window/frame.ts +5 -0
- package/src/renderers/electrobun/view/build-assets.ts +2 -1
- package/src/renderers/electrobun/view/host/text.tsx +29 -9
- package/src/renderers/electrobun/view/http-fetch.ts +10 -3
- package/src/renderers/electrobun/view/styles.css +20 -0
- package/src/renderers/electrobun/view/ui-host.tsx +14 -0
- package/src/state/app/context/index.test.tsx +16 -0
- package/src/tickers/search/index.ts +1 -0
- package/src/ui/host.tsx +2 -0
- package/src/updater.test.ts +47 -2
- package/src/updater.ts +67 -10
- package/src/version.ts +1 -1
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { PersistedResourceValue } from "../../../../types/persistence";
|
|
2
|
+
import {
|
|
3
|
+
sortSubscriptionsByLatest,
|
|
4
|
+
} from "../normalize";
|
|
5
|
+
import type {
|
|
6
|
+
SubstackArticleDetail,
|
|
7
|
+
SubstackArticleSummary,
|
|
8
|
+
SubstackPublication,
|
|
9
|
+
} from "../types";
|
|
10
|
+
import type { SubstackCachedData, SubstackHomeData, SubstackPublicationFeedPage } from "./types";
|
|
11
|
+
import { readResource } from "./store";
|
|
12
|
+
|
|
13
|
+
const PUBLICATION_ARCHIVE_MAX_CACHED_PAGES = 20;
|
|
14
|
+
|
|
15
|
+
export function publicationCacheKey(publication: SubstackPublication, offset: number): string {
|
|
16
|
+
return `${publication.baseUrl ?? publication.subdomain ?? publication.id}:offset:${offset}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function cachedDataFromResource<T>(entry: PersistedResourceValue<T> | null): SubstackCachedData<T> | null {
|
|
20
|
+
if (!entry) return null;
|
|
21
|
+
return {
|
|
22
|
+
data: entry.value,
|
|
23
|
+
fetchedAt: entry.fetchedAt,
|
|
24
|
+
stale: Boolean(entry.stale),
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function getCachedSubstackHome(): SubstackHomeData | null {
|
|
29
|
+
const subscriptionsEntry = readResource<SubstackPublication[]>("subscriptions", "me", false);
|
|
30
|
+
const feedEntry = readResource<SubstackArticleSummary[]>("feed", "subscribed", false);
|
|
31
|
+
if (!subscriptionsEntry || !feedEntry) return null;
|
|
32
|
+
return {
|
|
33
|
+
subscriptions: sortSubscriptionsByLatest(subscriptionsEntry.value, feedEntry.value),
|
|
34
|
+
feed: feedEntry.value,
|
|
35
|
+
fetchedAt: Math.max(subscriptionsEntry.fetchedAt, feedEntry.fetchedAt),
|
|
36
|
+
stale: Boolean(subscriptionsEntry.stale) || Boolean(feedEntry.stale),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function getCachedSubstackPublicationFeed(
|
|
41
|
+
publication: SubstackPublication,
|
|
42
|
+
): SubstackCachedData<SubstackPublicationFeedPage> | null {
|
|
43
|
+
let offset: number | null = 0;
|
|
44
|
+
let pageCount = 0;
|
|
45
|
+
let merged: SubstackPublicationFeedPage | null = null;
|
|
46
|
+
let fetchedAt = 0;
|
|
47
|
+
let stale = false;
|
|
48
|
+
|
|
49
|
+
while (offset != null && pageCount < PUBLICATION_ARCHIVE_MAX_CACHED_PAGES) {
|
|
50
|
+
const entry: PersistedResourceValue<SubstackPublicationFeedPage> | null = readResource<SubstackPublicationFeedPage>(
|
|
51
|
+
"publication",
|
|
52
|
+
publicationCacheKey(publication, offset),
|
|
53
|
+
false,
|
|
54
|
+
);
|
|
55
|
+
if (!entry) break;
|
|
56
|
+
const itemsById = new Map<string, SubstackArticleSummary>();
|
|
57
|
+
for (const article of merged?.items ?? []) itemsById.set(article.id, article);
|
|
58
|
+
for (const article of entry.value.items) itemsById.set(article.id, article);
|
|
59
|
+
merged = {
|
|
60
|
+
items: [...itemsById.values()],
|
|
61
|
+
nextOffset: entry.value.nextOffset,
|
|
62
|
+
hasMore: entry.value.hasMore,
|
|
63
|
+
};
|
|
64
|
+
fetchedAt = Math.max(fetchedAt, entry.fetchedAt);
|
|
65
|
+
stale = stale || Boolean(entry.stale);
|
|
66
|
+
offset = entry.value.nextOffset;
|
|
67
|
+
pageCount += 1;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (!merged) return null;
|
|
71
|
+
return { data: merged, fetchedAt, stale };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function getCachedSubstackArticleDetail(
|
|
75
|
+
article: Pick<SubstackArticleSummary, "id">,
|
|
76
|
+
): SubstackCachedData<SubstackArticleDetail> | null {
|
|
77
|
+
return cachedDataFromResource(readResource<SubstackArticleDetail>("post", article.id, false));
|
|
78
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeFeedItems,
|
|
3
|
+
normalizePostDetail,
|
|
4
|
+
normalizeSubscriptions,
|
|
5
|
+
sortSubscriptionsByLatest,
|
|
6
|
+
} from "../normalize";
|
|
7
|
+
import type {
|
|
8
|
+
SubstackArticleSummary,
|
|
9
|
+
SubstackPublication,
|
|
10
|
+
} from "../types";
|
|
11
|
+
import { SubstackAuthError, type SubstackCachedData, type SubstackHomeData, type SubstackPublicationFeedPage } from "./types";
|
|
12
|
+
import { publicationCacheKey } from "./cache";
|
|
13
|
+
import {
|
|
14
|
+
fetchJsonAuthenticated,
|
|
15
|
+
loadCachedResource,
|
|
16
|
+
requireAuth,
|
|
17
|
+
SUBSTACK_ORIGIN,
|
|
18
|
+
} from "./store";
|
|
19
|
+
|
|
20
|
+
const READER_FEED_TARGET_ITEMS = 50;
|
|
21
|
+
const READER_FEED_MAX_PAGES = 12;
|
|
22
|
+
const PUBLICATION_ARCHIVE_PAGE_LIMIT = 12;
|
|
23
|
+
|
|
24
|
+
export async function loadSubstackHome(force = false): Promise<SubstackHomeData> {
|
|
25
|
+
const auth = requireAuth();
|
|
26
|
+
const [subscriptionsEntry, feedEntry] = await Promise.all([
|
|
27
|
+
loadCachedResource("subscriptions", "me", force, async () => {
|
|
28
|
+
const payload = await fetchJsonAuthenticated<unknown>(`${SUBSTACK_ORIGIN}/api/v1/subscriptions/page_v2`, auth);
|
|
29
|
+
return normalizeSubscriptions(payload);
|
|
30
|
+
}),
|
|
31
|
+
loadCachedResource("feed", "subscribed", force, async () => {
|
|
32
|
+
return fetchReaderFeedItems(auth, "subscribed");
|
|
33
|
+
}),
|
|
34
|
+
]);
|
|
35
|
+
return {
|
|
36
|
+
subscriptions: sortSubscriptionsByLatest(subscriptionsEntry.data, feedEntry.data),
|
|
37
|
+
feed: feedEntry.data,
|
|
38
|
+
fetchedAt: Math.max(subscriptionsEntry.fetchedAt, feedEntry.fetchedAt),
|
|
39
|
+
stale: subscriptionsEntry.stale || feedEntry.stale,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function payloadCursor(payload: unknown): string | null {
|
|
44
|
+
const record = payload && typeof payload === "object" && !Array.isArray(payload)
|
|
45
|
+
? payload as Record<string, unknown>
|
|
46
|
+
: null;
|
|
47
|
+
const cursor = record?.nextCursor ?? record?.next_cursor;
|
|
48
|
+
return typeof cursor === "string" && cursor.trim() ? cursor : null;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
async function fetchReaderFeedItems(auth: ReturnType<typeof requireAuth>, tab: string): Promise<SubstackArticleSummary[]> {
|
|
52
|
+
const collected = new Map<string, SubstackArticleSummary>();
|
|
53
|
+
let cursor: string | null = null;
|
|
54
|
+
for (let page = 0; page < READER_FEED_MAX_PAGES && collected.size < READER_FEED_TARGET_ITEMS; page += 1) {
|
|
55
|
+
const url = new URL(`${SUBSTACK_ORIGIN}/api/v1/reader/feed`);
|
|
56
|
+
url.searchParams.set("tab", tab);
|
|
57
|
+
if (cursor) url.searchParams.set("cursor", cursor);
|
|
58
|
+
const payload = await fetchJsonAuthenticated<unknown>(url.toString(), auth);
|
|
59
|
+
for (const article of normalizeFeedItems(payload)) {
|
|
60
|
+
collected.set(article.id, article);
|
|
61
|
+
if (collected.size >= READER_FEED_TARGET_ITEMS) break;
|
|
62
|
+
}
|
|
63
|
+
const nextCursor = payloadCursor(payload);
|
|
64
|
+
if (!nextCursor || nextCursor === cursor) break;
|
|
65
|
+
cursor = nextCursor;
|
|
66
|
+
}
|
|
67
|
+
return [...collected.values()];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function loadSubstackPublicationFeed(
|
|
71
|
+
publication: SubstackPublication,
|
|
72
|
+
force = false,
|
|
73
|
+
offset = 0,
|
|
74
|
+
): Promise<SubstackCachedData<SubstackPublicationFeedPage>> {
|
|
75
|
+
const auth = requireAuth();
|
|
76
|
+
const baseUrl = publication.baseUrl;
|
|
77
|
+
if (!baseUrl) {
|
|
78
|
+
return {
|
|
79
|
+
data: { items: [], nextOffset: null, hasMore: false },
|
|
80
|
+
fetchedAt: Date.now(),
|
|
81
|
+
stale: false,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
return loadCachedResource("publication", publicationCacheKey(publication, offset), force, async () => {
|
|
85
|
+
const url = new URL("/api/v1/archive", baseUrl);
|
|
86
|
+
url.searchParams.set("sort", "new");
|
|
87
|
+
url.searchParams.set("limit", String(PUBLICATION_ARCHIVE_PAGE_LIMIT));
|
|
88
|
+
if (offset > 0) url.searchParams.set("offset", String(offset));
|
|
89
|
+
const payload = await fetchJsonAuthenticated<unknown>(url.toString(), auth);
|
|
90
|
+
const items = normalizeFeedItems(payload, publication);
|
|
91
|
+
const hasMore = items.length >= PUBLICATION_ARCHIVE_PAGE_LIMIT;
|
|
92
|
+
return {
|
|
93
|
+
items,
|
|
94
|
+
nextOffset: hasMore ? offset + PUBLICATION_ARCHIVE_PAGE_LIMIT : null,
|
|
95
|
+
hasMore,
|
|
96
|
+
};
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function detailCandidateUrls(article: SubstackArticleSummary): string[] {
|
|
101
|
+
const urls: string[] = [];
|
|
102
|
+
if (/^\d+$/.test(article.id)) {
|
|
103
|
+
urls.push(new URL(`/api/v1/posts/by-id/${article.id}`, SUBSTACK_ORIGIN).toString());
|
|
104
|
+
}
|
|
105
|
+
const baseUrl = article.publicationBaseUrl
|
|
106
|
+
?? (article.url ? new URL(article.url).origin : null);
|
|
107
|
+
if (baseUrl) {
|
|
108
|
+
if (/^\d+$/.test(article.id)) {
|
|
109
|
+
urls.push(new URL(`/api/v1/posts/by-id/${article.id}`, baseUrl).toString());
|
|
110
|
+
}
|
|
111
|
+
if (article.slug) {
|
|
112
|
+
urls.push(new URL(`/api/v1/posts/${article.slug}`, baseUrl).toString());
|
|
113
|
+
}
|
|
114
|
+
if (article.url) {
|
|
115
|
+
try {
|
|
116
|
+
const pathSlug = new URL(article.url).pathname.match(/\/p\/([^/?#]+)/)?.[1];
|
|
117
|
+
if (pathSlug) urls.push(new URL(`/api/v1/posts/${decodeURIComponent(pathSlug)}`, baseUrl).toString());
|
|
118
|
+
} catch {
|
|
119
|
+
// Ignore malformed article URLs; the direct post id/slug paths above still apply.
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
return [...new Set(urls)];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function loadSubstackArticleDetail(
|
|
127
|
+
article: SubstackArticleSummary,
|
|
128
|
+
force = false,
|
|
129
|
+
): Promise<SubstackCachedData<ReturnType<typeof normalizePostDetail>>> {
|
|
130
|
+
const auth = requireAuth();
|
|
131
|
+
return loadCachedResource("post", article.id, force, async () => {
|
|
132
|
+
const urls = detailCandidateUrls(article);
|
|
133
|
+
const errors: string[] = [];
|
|
134
|
+
for (const url of urls) {
|
|
135
|
+
try {
|
|
136
|
+
const payload = await fetchJsonAuthenticated<unknown>(url, auth);
|
|
137
|
+
return normalizePostDetail(payload, article);
|
|
138
|
+
} catch (error) {
|
|
139
|
+
if (error instanceof SubstackAuthError) throw error;
|
|
140
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
if (article.bodyHtml || urls.length === 0) return normalizePostDetail(article, article);
|
|
144
|
+
throw new Error(errors[0] ?? "Substack post content unavailable");
|
|
145
|
+
});
|
|
146
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import type { PluginPersistence } from "../../../../types/plugin";
|
|
2
|
+
import type { CachePolicy, PersistedResourceValue } from "../../../../types/persistence";
|
|
3
|
+
import { createThrottledFetch, type ThrottledFetchTransport } from "../../../../utils/throttled-fetch";
|
|
4
|
+
import { normalizedHttpUrl } from "../../../../utils/url";
|
|
5
|
+
import { SubstackAuthError, type SubstackAuthState, type SubstackCachedData } from "./types";
|
|
6
|
+
|
|
7
|
+
export const SUBSTACK_ORIGIN = "https://substack.com";
|
|
8
|
+
|
|
9
|
+
const AUTH_STATE_KEY = "auth";
|
|
10
|
+
const AUTH_SCHEMA_VERSION = 1;
|
|
11
|
+
export const CACHE_SOURCE = "substack";
|
|
12
|
+
export const CACHE_SCHEMA_VERSION = 3;
|
|
13
|
+
const DEFAULT_HEADERS = {
|
|
14
|
+
accept: "application/json,text/plain,*/*",
|
|
15
|
+
"accept-language": "en-US,en;q=0.9",
|
|
16
|
+
"user-agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36",
|
|
17
|
+
};
|
|
18
|
+
const CACHE_POLICIES = {
|
|
19
|
+
subscriptions: { staleMs: 30 * 60 * 1000, expireMs: 14 * 24 * 60 * 60 * 1000 },
|
|
20
|
+
feed: { staleMs: 5 * 60 * 1000, expireMs: 3 * 24 * 60 * 60 * 1000 },
|
|
21
|
+
publication: { staleMs: 10 * 60 * 1000, expireMs: 7 * 24 * 60 * 60 * 1000 },
|
|
22
|
+
post: { staleMs: 30 * 24 * 60 * 60 * 1000, expireMs: 365 * 24 * 60 * 60 * 1000 },
|
|
23
|
+
} satisfies Record<string, CachePolicy>;
|
|
24
|
+
|
|
25
|
+
let substackPersistence: PluginPersistence | null = null;
|
|
26
|
+
let substackClient = createThrottledFetch({
|
|
27
|
+
requestsPerMinute: 60,
|
|
28
|
+
maxRetries: 1,
|
|
29
|
+
timeoutMs: 15_000,
|
|
30
|
+
defaultHeaders: DEFAULT_HEADERS,
|
|
31
|
+
dedupeGetRequests: false,
|
|
32
|
+
});
|
|
33
|
+
const activeFetches = new Map<string, Promise<SubstackCachedData<unknown>>>();
|
|
34
|
+
|
|
35
|
+
export function attachSubstackPersistence(persistence: PluginPersistence): void {
|
|
36
|
+
substackPersistence = persistence;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function resetSubstackPersistence(): void {
|
|
40
|
+
substackPersistence = null;
|
|
41
|
+
activeFetches.clear();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function setSubstackFetchTransportForTests(transport: ThrottledFetchTransport | null): void {
|
|
45
|
+
substackClient = createThrottledFetch({
|
|
46
|
+
requestsPerMinute: 10_000,
|
|
47
|
+
maxRetries: 0,
|
|
48
|
+
timeoutMs: 15_000,
|
|
49
|
+
defaultHeaders: DEFAULT_HEADERS,
|
|
50
|
+
dedupeGetRequests: false,
|
|
51
|
+
transport: transport ?? undefined,
|
|
52
|
+
});
|
|
53
|
+
activeFetches.clear();
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function isAuthState(value: unknown): value is SubstackAuthState {
|
|
57
|
+
const record = value as Partial<SubstackAuthState> | null;
|
|
58
|
+
return !!record
|
|
59
|
+
&& typeof record === "object"
|
|
60
|
+
&& typeof record.email === "string"
|
|
61
|
+
&& typeof record.sid === "string"
|
|
62
|
+
&& record.sid.length > 0
|
|
63
|
+
&& typeof record.loggedInAt === "number";
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function getStoredSubstackAuth(): SubstackAuthState | null {
|
|
67
|
+
const auth = substackPersistence?.getState<unknown>(AUTH_STATE_KEY, { schemaVersion: AUTH_SCHEMA_VERSION });
|
|
68
|
+
return isAuthState(auth) ? auth : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function storeSubstackAuth(auth: SubstackAuthState): void {
|
|
72
|
+
substackPersistence?.setState(AUTH_STATE_KEY, auth, { schemaVersion: AUTH_SCHEMA_VERSION });
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function clearSubstackAuth(): void {
|
|
76
|
+
substackPersistence?.deleteState(AUTH_STATE_KEY);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function requireAuth(): SubstackAuthState {
|
|
80
|
+
const auth = getStoredSubstackAuth();
|
|
81
|
+
if (!auth) throw new SubstackAuthError();
|
|
82
|
+
return auth;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function authHeaders(auth: SubstackAuthState): Record<string, string> {
|
|
86
|
+
return {
|
|
87
|
+
cookie: `substack.sid=${auth.sid}; substack.lli=${auth.lli?.trim() || "1"}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function parseErrorText(response: Response): Promise<string> {
|
|
92
|
+
try {
|
|
93
|
+
const text = await response.text();
|
|
94
|
+
return text.replace(/\s+/g, " ").trim().slice(0, 240);
|
|
95
|
+
} catch {
|
|
96
|
+
return "";
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export async function substackFetch(url: string, init?: RequestInit): Promise<Response> {
|
|
101
|
+
return substackClient.fetch(url, init);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export async function fetchJsonAuthenticated<T = unknown>(url: string, auth: SubstackAuthState): Promise<T> {
|
|
105
|
+
const response = await substackClient.fetch(url, {
|
|
106
|
+
headers: authHeaders(auth),
|
|
107
|
+
});
|
|
108
|
+
if (response.status === 401 || response.status === 403) {
|
|
109
|
+
clearSubstackAuth();
|
|
110
|
+
throw new SubstackAuthError("Substack session expired");
|
|
111
|
+
}
|
|
112
|
+
if (!response.ok) {
|
|
113
|
+
const detail = await parseErrorText(response);
|
|
114
|
+
throw new Error(`Substack HTTP ${response.status}${detail ? `: ${detail}` : ""}`);
|
|
115
|
+
}
|
|
116
|
+
return response.json() as Promise<T>;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function readResource<T>(kind: string, key: string, allowExpired = false): PersistedResourceValue<T> | null {
|
|
120
|
+
return substackPersistence?.getResource<T>(kind, key, {
|
|
121
|
+
sourceKey: CACHE_SOURCE,
|
|
122
|
+
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
123
|
+
allowExpired,
|
|
124
|
+
}) ?? null;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function writeResource<T>(kind: string, key: string, value: T, cachePolicy: CachePolicy): PersistedResourceValue<T> | null {
|
|
128
|
+
return substackPersistence?.setResource<T>(kind, key, value, {
|
|
129
|
+
sourceKey: CACHE_SOURCE,
|
|
130
|
+
schemaVersion: CACHE_SCHEMA_VERSION,
|
|
131
|
+
cachePolicy,
|
|
132
|
+
}) ?? null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export async function loadCachedResource<T>(
|
|
136
|
+
kind: keyof typeof CACHE_POLICIES,
|
|
137
|
+
key: string,
|
|
138
|
+
force: boolean,
|
|
139
|
+
loader: () => Promise<T>,
|
|
140
|
+
): Promise<SubstackCachedData<T>> {
|
|
141
|
+
const fresh = readResource<T>(kind, key, false);
|
|
142
|
+
if (!force && fresh && !fresh.stale) {
|
|
143
|
+
return { data: fresh.value, fetchedAt: fresh.fetchedAt, stale: false };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const activeKey = `${kind}:${key}`;
|
|
147
|
+
const active = activeFetches.get(activeKey);
|
|
148
|
+
if (active) return active as Promise<SubstackCachedData<T>>;
|
|
149
|
+
|
|
150
|
+
const fallback = fresh ?? readResource<T>(kind, key, true);
|
|
151
|
+
const promise = loader()
|
|
152
|
+
.then((data) => {
|
|
153
|
+
const record = writeResource(kind, key, data, CACHE_POLICIES[kind]);
|
|
154
|
+
return {
|
|
155
|
+
data,
|
|
156
|
+
fetchedAt: record?.fetchedAt ?? Date.now(),
|
|
157
|
+
stale: false,
|
|
158
|
+
};
|
|
159
|
+
})
|
|
160
|
+
.catch((error) => {
|
|
161
|
+
if (fallback) {
|
|
162
|
+
return {
|
|
163
|
+
data: fallback.value,
|
|
164
|
+
fetchedAt: fallback.fetchedAt,
|
|
165
|
+
stale: true,
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
throw error;
|
|
169
|
+
})
|
|
170
|
+
.finally(() => {
|
|
171
|
+
activeFetches.delete(activeKey);
|
|
172
|
+
});
|
|
173
|
+
activeFetches.set(activeKey, promise as Promise<SubstackCachedData<unknown>>);
|
|
174
|
+
return promise;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function resolveSubstackUrl(value: unknown, baseUrl = SUBSTACK_ORIGIN): string | null {
|
|
178
|
+
if (typeof value !== "string" || !value.trim()) return null;
|
|
179
|
+
const direct = normalizedHttpUrl(value);
|
|
180
|
+
if (direct) return direct;
|
|
181
|
+
try {
|
|
182
|
+
return normalizedHttpUrl(new URL(value, baseUrl).toString());
|
|
183
|
+
} catch {
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
SubstackArticleDetail,
|
|
3
|
+
SubstackArticleSummary,
|
|
4
|
+
SubstackPublication,
|
|
5
|
+
} from "../types";
|
|
6
|
+
|
|
7
|
+
export interface SubstackAuthState {
|
|
8
|
+
email: string;
|
|
9
|
+
sid: string;
|
|
10
|
+
lli: string | null;
|
|
11
|
+
loggedInAt: number;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface SubstackHomeData {
|
|
15
|
+
subscriptions: SubstackPublication[];
|
|
16
|
+
feed: SubstackArticleSummary[];
|
|
17
|
+
fetchedAt: number;
|
|
18
|
+
stale: boolean;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface SubstackPublicationFeedPage {
|
|
22
|
+
items: SubstackArticleSummary[];
|
|
23
|
+
nextOffset: number | null;
|
|
24
|
+
hasMore: boolean;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface SubstackCachedData<T> {
|
|
28
|
+
data: T;
|
|
29
|
+
fetchedAt: number;
|
|
30
|
+
stale: boolean;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class SubstackAuthError extends Error {
|
|
34
|
+
constructor(message = "Substack login required") {
|
|
35
|
+
super(message);
|
|
36
|
+
this.name = "SubstackAuthError";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export type { SubstackArticleDetail, SubstackArticleSummary, SubstackPublication };
|