@khorsheed/dsh-reader 0.2.0
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.en.md +90 -0
- package/README.i18n.yaml +6 -0
- package/README.md +90 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.js +15119 -0
- package/lib/index.js +3660 -0
- package/lib/invariant.js +38 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +1738 -0
- package/lib/typert.remote-client.d.ts +82 -0
- package/lib/typert.remote-client.js +1115 -0
- package/lib/types/arxiv.d.ts +47 -0
- package/lib/types/arxiv.js +76 -0
- package/lib/types/client/ReaderPane.d.ts +71 -0
- package/lib/types/client/ReaderPane.js +3135 -0
- package/lib/types/client/contract.d.ts +254 -0
- package/lib/types/client/contract.js +1 -0
- package/lib/types/client/definition.d.ts +27 -0
- package/lib/types/client/definition.js +32 -0
- package/lib/types/client/extract-article.d.ts +102 -0
- package/lib/types/client/extract-article.js +1024 -0
- package/lib/types/client/icons.d.ts +27 -0
- package/lib/types/client/icons.js +11 -0
- package/lib/types/client/index.d.ts +34 -0
- package/lib/types/client/index.js +222 -0
- package/lib/types/client/locales.d.ts +391 -0
- package/lib/types/client/locales.js +382 -0
- package/lib/types/client/parse-rss.d.ts +69 -0
- package/lib/types/client/parse-rss.js +656 -0
- package/lib/types/client/quote.d.ts +89 -0
- package/lib/types/client/quote.js +119 -0
- package/lib/types/client/selectors.d.ts +167 -0
- package/lib/types/client/selectors.js +413 -0
- package/lib/types/client/session.d.ts +171 -0
- package/lib/types/client/session.js +223 -0
- package/lib/types/client/store.d.ts +181 -0
- package/lib/types/client/store.js +226 -0
- package/lib/types/client/translate.d.ts +379 -0
- package/lib/types/client/translate.js +873 -0
- package/lib/types/index.d.ts +48 -0
- package/lib/types/index.js +25 -0
- package/lib/types/invariant.d.ts +29 -0
- package/lib/types/invariant.js +38 -0
- package/lib/types/link-resolvers.d.ts +107 -0
- package/lib/types/link-resolvers.js +299 -0
- package/lib/types/remote.d.ts +224 -0
- package/lib/types/remote.js +275 -0
- package/lib/types/schedule.d.ts +78 -0
- package/lib/types/schedule.js +114 -0
- package/lib/types/service.d.ts +633 -0
- package/lib/types/service.js +1697 -0
- package/lib/types/store.d.ts +224 -0
- package/lib/types/store.js +916 -0
- package/lib/types/types.d.ts +710 -0
- package/lib/types/types.js +182 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +128 -0
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
const PLACE_KEY = 'dsh-reader:place';
|
|
2
|
+
/**
|
|
3
|
+
* The persisted layer, read ONCE at module load.
|
|
4
|
+
*
|
|
5
|
+
* It is a separate layer from the page's memory rather than something rebuilt
|
|
6
|
+
* from it: the memory only holds the fields this page has touched, so writing
|
|
7
|
+
* the place from the memory alone erased everything the page had not touched yet
|
|
8
|
+
* — which is exactly the case a page restart creates, and it made the restore
|
|
9
|
+
* find nothing on the very first mount.
|
|
10
|
+
*/
|
|
11
|
+
let PLACE = null;
|
|
12
|
+
/** The persisted layer, read on first use (so the module can be imported before
|
|
13
|
+
* storage exists — and so a test can seed it before mounting the pane). */
|
|
14
|
+
function place() {
|
|
15
|
+
if (PLACE === null)
|
|
16
|
+
PLACE = loadPlace();
|
|
17
|
+
return PLACE;
|
|
18
|
+
}
|
|
19
|
+
/** The page's one snapshot. */
|
|
20
|
+
let PAGE = {};
|
|
21
|
+
/** The place as `sessionStorage` has it, field by field. */
|
|
22
|
+
function loadPlace() {
|
|
23
|
+
try {
|
|
24
|
+
const raw = globalThis.sessionStorage?.getItem(PLACE_KEY);
|
|
25
|
+
if (raw === null || raw === undefined || raw === '')
|
|
26
|
+
return {};
|
|
27
|
+
const parsed = JSON.parse(raw);
|
|
28
|
+
if (typeof parsed !== 'object' || parsed === null)
|
|
29
|
+
return {};
|
|
30
|
+
const record = parsed;
|
|
31
|
+
// Field by field: the value in storage is from an older revision of this
|
|
32
|
+
// package as often as not, and one bad field must not poison the rest.
|
|
33
|
+
return {
|
|
34
|
+
...(record.view === 'list' || record.view === 'detail' || record.view === 'manage' || record.view === 'recent' ? { view: record.view } : {}),
|
|
35
|
+
...(typeof record.openEntryId === 'string' || record.openEntryId === null ? { openEntryId: record.openEntryId } : {}),
|
|
36
|
+
...(typeof record.openSourceId === 'string' || record.openSourceId === null ? { openSourceId: record.openSourceId } : {}),
|
|
37
|
+
...(typeof record.scroll === 'object' && record.scroll !== null ? { scroll: record.scroll } : {}),
|
|
38
|
+
...(typeof record.wallScroll === 'number' ? { wallScroll: record.wallScroll } : {}),
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
// A sandboxed or full storage is not an error: the pane still works, it just
|
|
43
|
+
// forgets where it was when the page restarts.
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** Persist the place; a storage that refuses is a degraded pane, not a failure. */
|
|
48
|
+
function writePlace() {
|
|
49
|
+
try {
|
|
50
|
+
globalThis.sessionStorage?.setItem(PLACE_KEY, JSON.stringify(place()));
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
// ignore
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** Bring the persisted layer in step with a patch, for the fields it owns. */
|
|
57
|
+
function updatePlace(patch) {
|
|
58
|
+
const next = { ...place() };
|
|
59
|
+
if ('view' in patch)
|
|
60
|
+
next.view = patch.view;
|
|
61
|
+
if ('openEntryId' in patch)
|
|
62
|
+
next.openEntryId = patch.openEntryId;
|
|
63
|
+
if ('openSourceId' in patch)
|
|
64
|
+
next.openSourceId = patch.openSourceId;
|
|
65
|
+
if ('scroll' in patch)
|
|
66
|
+
next.scroll = patch.scroll;
|
|
67
|
+
if ('wallScroll' in patch)
|
|
68
|
+
next.wallScroll = patch.wallScroll;
|
|
69
|
+
PLACE = next;
|
|
70
|
+
writePlace();
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* What the page remembers, or `{}` when it remembers nothing yet.
|
|
74
|
+
*
|
|
75
|
+
* The persisted place sits UNDER the in-memory snapshot: within one page the
|
|
76
|
+
* memory is the truth (it has fields the place does not), and after a restart
|
|
77
|
+
* the place is all there is.
|
|
78
|
+
*
|
|
79
|
+
* @returns the snapshot (a copy of the recorded fields).
|
|
80
|
+
*/
|
|
81
|
+
export function readSession() {
|
|
82
|
+
return { ...place(), ...PAGE };
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Record what changed.
|
|
86
|
+
*
|
|
87
|
+
* A field set to `undefined` is dropped rather than stored: "no record" and
|
|
88
|
+
* "recorded as nothing" would otherwise be two spellings of the same state.
|
|
89
|
+
*
|
|
90
|
+
* @param patch - the fields to replace.
|
|
91
|
+
*/
|
|
92
|
+
export function patchSession(patch) {
|
|
93
|
+
const next = { ...PAGE };
|
|
94
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
95
|
+
if (value === undefined)
|
|
96
|
+
delete next[key];
|
|
97
|
+
else
|
|
98
|
+
Object.assign(next, { [key]: value });
|
|
99
|
+
}
|
|
100
|
+
PAGE = next;
|
|
101
|
+
updatePlace(patch);
|
|
102
|
+
}
|
|
103
|
+
/** Drop the snapshot, here and in storage (the specs call this between cases). */
|
|
104
|
+
export function forgetSession() {
|
|
105
|
+
PAGE = {};
|
|
106
|
+
// Back to "not read yet", not to "nothing": the next read must consult storage
|
|
107
|
+
// again (clearing it first, so what it finds is the truth).
|
|
108
|
+
PLACE = null;
|
|
109
|
+
try {
|
|
110
|
+
globalThis.sessionStorage?.removeItem(PLACE_KEY);
|
|
111
|
+
}
|
|
112
|
+
catch {
|
|
113
|
+
// ignore
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
/**
|
|
117
|
+
* Remember where the reader was inside one entry.
|
|
118
|
+
*
|
|
119
|
+
* @param entryId - the entry whose body was on screen.
|
|
120
|
+
* @param anchor - the place in the document the viewport top sat at.
|
|
121
|
+
*/
|
|
122
|
+
export function rememberReadingPosition(entryId, anchor) {
|
|
123
|
+
patchSession({
|
|
124
|
+
scroll: {
|
|
125
|
+
...PAGE.scroll,
|
|
126
|
+
[entryId]: {
|
|
127
|
+
block: Math.max(0, Math.round(anchor.block)),
|
|
128
|
+
offset: Math.max(0, Math.round(anchor.offset)),
|
|
129
|
+
top: Math.max(0, Math.round(anchor.top)),
|
|
130
|
+
// Optional, so an anchor the scroll handler cannot measure (a block
|
|
131
|
+
// with no text) keeps the old shape exactly.
|
|
132
|
+
...(anchor.text === undefined ? {} : { text: Math.max(0, Math.round(anchor.text)) }),
|
|
133
|
+
...(anchor.textLength === undefined ? {} : { textLength: Math.max(0, Math.round(anchor.textLength)) }),
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Remember where the reader had scrolled the wall itself.
|
|
140
|
+
*
|
|
141
|
+
* The wall is the other long scroller in this pane, and losing its place is the
|
|
142
|
+
* same complaint as losing the place inside an article.
|
|
143
|
+
*
|
|
144
|
+
* @param top - the wall scroller's offset in pixels.
|
|
145
|
+
*/
|
|
146
|
+
export function rememberWallScroll(top) {
|
|
147
|
+
patchSession({ wallScroll: Math.max(0, Math.round(top)) });
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* Remember that one entry is showing a translation, and in which view.
|
|
151
|
+
*
|
|
152
|
+
* @param entryId - the entry that was translated.
|
|
153
|
+
* @param view - the view that was on screen, or `'orig'` to forget it.
|
|
154
|
+
* @param source - the source language the translator was built for.
|
|
155
|
+
*/
|
|
156
|
+
export function rememberTranslation(entryId, view, source) {
|
|
157
|
+
const translationView = { ...PAGE.translationView };
|
|
158
|
+
const translationSource = { ...PAGE.translationSource };
|
|
159
|
+
if (view === 'orig') {
|
|
160
|
+
// The globe is off: that is the state to restore, and a record that outlived
|
|
161
|
+
// it would turn the globe back on the next time this entry is opened.
|
|
162
|
+
delete translationView[entryId];
|
|
163
|
+
delete translationSource[entryId];
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
translationView[entryId] = view;
|
|
167
|
+
translationSource[entryId] = source;
|
|
168
|
+
}
|
|
169
|
+
patchSession({ translationView, translationSource });
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* Forget one entry's translation record (a re-fetch replaced its body).
|
|
173
|
+
*
|
|
174
|
+
* @param entryId - the entry whose body changed.
|
|
175
|
+
*/
|
|
176
|
+
export function forgetTranslation(entryId) {
|
|
177
|
+
if (PAGE.translationView?.[entryId] === undefined)
|
|
178
|
+
return;
|
|
179
|
+
const translationView = { ...PAGE.translationView };
|
|
180
|
+
const translationSource = { ...PAGE.translationSource };
|
|
181
|
+
delete translationView[entryId];
|
|
182
|
+
delete translationSource[entryId];
|
|
183
|
+
patchSession({ translationView, translationSource });
|
|
184
|
+
}
|
|
185
|
+
/* ------------------------------------------------------- translator sessions */
|
|
186
|
+
/**
|
|
187
|
+
* Translator sessions that are already built, keyed by language pair.
|
|
188
|
+
*
|
|
189
|
+
* Both halves of the pane share this: a session the wall built is the one the
|
|
190
|
+
* detail view reuses, and — more importantly — a session that already exists can
|
|
191
|
+
* translate WITHOUT a fresh `Translator.create()`, which is what Chrome requires
|
|
192
|
+
* user activation for. That is what makes "the globe is still on when you come
|
|
193
|
+
* back" possible without asking for a click.
|
|
194
|
+
*/
|
|
195
|
+
const TRANSLATOR_SESSIONS = new Map();
|
|
196
|
+
/** The cache key for one language pair. */
|
|
197
|
+
export function translationKey(sources, targets) {
|
|
198
|
+
return `${sources.join(',')}→${targets.join(',')}`;
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* A built session for this pair, when one exists in this page.
|
|
202
|
+
*
|
|
203
|
+
* @param sources - source language candidates, in priority order.
|
|
204
|
+
* @param targets - target language candidates, in priority order.
|
|
205
|
+
* @returns the session, or `undefined`.
|
|
206
|
+
*/
|
|
207
|
+
export function cachedTranslator(sources, targets) {
|
|
208
|
+
return TRANSLATOR_SESSIONS.get(translationKey(sources, targets));
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Remember a session this page built.
|
|
212
|
+
*
|
|
213
|
+
* @param sources - source language candidates, in priority order.
|
|
214
|
+
* @param targets - target language candidates, in priority order.
|
|
215
|
+
* @param session - the built session.
|
|
216
|
+
*/
|
|
217
|
+
export function rememberTranslator(sources, targets, session) {
|
|
218
|
+
TRANSLATOR_SESSIONS.set(translationKey(sources, targets), session);
|
|
219
|
+
}
|
|
220
|
+
/** Drop the translator sessions (the specs call this between cases). */
|
|
221
|
+
export function forgetTranslators() {
|
|
222
|
+
TRANSLATOR_SESSIONS.clear();
|
|
223
|
+
}
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reader pane's transient store: what the user is looking at, not what the
|
|
3
|
+
* host stores.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is session state. Sources and their payloads live in the
|
|
6
|
+
* host's `state.json` (see `src/store.ts`), while the parsed entries, the
|
|
7
|
+
* extracted article bodies, the read cursor, the filter and the sort are
|
|
8
|
+
* per-session and deliberately NOT persisted — see the design's storage
|
|
9
|
+
* decision: persisting a per-entry read cursor would need a second,
|
|
10
|
+
* write-heavy document for a feature whose whole value is "what is new since I
|
|
11
|
+
* last looked".
|
|
12
|
+
*
|
|
13
|
+
* @module @khorsheed/dsh-reader/client/store
|
|
14
|
+
*/
|
|
15
|
+
import { type EngineStoreHandle } from '@deepseek-ai/dsh-client-store';
|
|
16
|
+
import type { ReaderEntryFetchState, ReaderRecentEntry, ReaderSourceSummary, ReaderTag } from '../types.ts';
|
|
17
|
+
import type { ReaderEntry } from './parse-rss.ts';
|
|
18
|
+
/** Which slice of the list the pane shows. */
|
|
19
|
+
export type ReaderFilter = 'today' | 'all';
|
|
20
|
+
/** Which surface the pane is showing. */
|
|
21
|
+
export type ReaderView = 'list' | 'detail' | 'manage' | 'recent';
|
|
22
|
+
/** How the list is ordered. */
|
|
23
|
+
export type ReaderSort = 'newest' | 'oldest' | 'source';
|
|
24
|
+
/** One source's parsed state, held in memory for the session. */
|
|
25
|
+
export interface ReaderParsedSource {
|
|
26
|
+
readonly id: string;
|
|
27
|
+
readonly entries: readonly ReaderEntry[];
|
|
28
|
+
/** Why parsing produced nothing, when it did. */
|
|
29
|
+
readonly error?: string;
|
|
30
|
+
/**
|
|
31
|
+
* True when the payload was cut off by the host's fetch cap, so the entries
|
|
32
|
+
* here are a salvage rather than the whole feed.
|
|
33
|
+
*/
|
|
34
|
+
readonly incomplete?: boolean;
|
|
35
|
+
/** When the source was last fetched, for the "N 条 · 刷出于 …" line. */
|
|
36
|
+
readonly fetchedAt?: string;
|
|
37
|
+
}
|
|
38
|
+
/** The pane's state. */
|
|
39
|
+
export interface ReaderState {
|
|
40
|
+
/** Sources as the host reported them. */
|
|
41
|
+
sources: ReaderSourceSummary[];
|
|
42
|
+
/** Parsed entries per source id. */
|
|
43
|
+
parsed: Record<string, ReaderParsedSource>;
|
|
44
|
+
/** The extracted body of the entry currently open, if any. */
|
|
45
|
+
articleHtml: string | null;
|
|
46
|
+
/** Whether that body is incomplete, and why nothing could be extracted. */
|
|
47
|
+
articleTruncated: boolean;
|
|
48
|
+
articleError: string | null;
|
|
49
|
+
/** The open entry's id (null = the list is showing). */
|
|
50
|
+
openEntryId: string | null;
|
|
51
|
+
/** The open entry's source id. */
|
|
52
|
+
openSourceId: string | null;
|
|
53
|
+
/** The tag vocabulary, with usage counts (from the host). */
|
|
54
|
+
tags: ReaderTag[];
|
|
55
|
+
tagCounts: Record<string, number>;
|
|
56
|
+
/** Tag ids per entry id — what the detail view and the filters read. */
|
|
57
|
+
entryTagIds: Record<string, string[]>;
|
|
58
|
+
/** Entry ids whose article is being fetched right now. */
|
|
59
|
+
fetching: Record<string, true>;
|
|
60
|
+
/** Entry id → whether its cached body is past its deadline. */
|
|
61
|
+
staleBodies: Record<string, true>;
|
|
62
|
+
/** The article cache policy, from the host. */
|
|
63
|
+
cacheTtlHours: number;
|
|
64
|
+
/** The automatic full-text backfill: how many are wanted, and how many are done. */
|
|
65
|
+
backfill: {
|
|
66
|
+
total: number;
|
|
67
|
+
done: number;
|
|
68
|
+
} | null;
|
|
69
|
+
/** Entry ids whose body arrived from a backfill (the cards mark them). */
|
|
70
|
+
backfilled: Record<string, true>;
|
|
71
|
+
/** What the plugin holds per entry, as the host reports it. */
|
|
72
|
+
fetchStates: Record<string, ReaderEntryFetchState>;
|
|
73
|
+
/**
|
|
74
|
+
* The entries the reader opened, newest first, as the host stores them.
|
|
75
|
+
*
|
|
76
|
+
* Host state, not session state: "what was I reading" has to outlive a reload
|
|
77
|
+
* and a restart, and it is the one list that keeps a title after the feed has
|
|
78
|
+
* rolled the entry out of its window.
|
|
79
|
+
*/
|
|
80
|
+
recent: ReaderRecentEntry[];
|
|
81
|
+
/** Which surface is up: the wall, one entry, or subscription management. */
|
|
82
|
+
view: ReaderView;
|
|
83
|
+
/** Which slice of the list to show. */
|
|
84
|
+
filter: ReaderFilter;
|
|
85
|
+
/** The live search query. */
|
|
86
|
+
query: string;
|
|
87
|
+
/** Whether to show only entries with no read cursor. */
|
|
88
|
+
unreadOnly: boolean;
|
|
89
|
+
/** Whether the wall folds republished duplicates behind one card (default ON). */
|
|
90
|
+
hideDupes: boolean;
|
|
91
|
+
/** The list order. */
|
|
92
|
+
sort: ReaderSort;
|
|
93
|
+
/** Source ids the user has opened (the session's read cursor). */
|
|
94
|
+
read: Record<string, true>;
|
|
95
|
+
/** When the host last completed a refresh run, from the handshake. */
|
|
96
|
+
lastRefreshAt: string | null;
|
|
97
|
+
/** When the next scheduled refresh is due, from the handshake. */
|
|
98
|
+
nextRefreshAt: string | null;
|
|
99
|
+
/** Whether a host round trip is in flight. */
|
|
100
|
+
loading: boolean;
|
|
101
|
+
/** A human-readable failure from the last round trip. */
|
|
102
|
+
error: string | null;
|
|
103
|
+
/** True while a refresh round trip is in flight (the wall stays visible). */
|
|
104
|
+
refreshing: boolean;
|
|
105
|
+
/** Bumped to force a reload of the same source set. */
|
|
106
|
+
rev: number;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* The fields a pane can bring back when it remounts inside one page.
|
|
110
|
+
*
|
|
111
|
+
* Deliberately narrow: only "where the reader was looking" crossings the host's
|
|
112
|
+
* own `state.json` does not already hold (see `client/session.ts`). Host-owned
|
|
113
|
+
* facts (sources, parsed payloads, fetch states) are re-read on mount, never
|
|
114
|
+
* restored, so the pane can never show a stale copy of what the host knows.
|
|
115
|
+
*/
|
|
116
|
+
export interface ReaderSessionRestore {
|
|
117
|
+
readonly view?: ReaderView;
|
|
118
|
+
readonly openEntryId?: string | null;
|
|
119
|
+
readonly openSourceId?: string | null;
|
|
120
|
+
readonly filter?: ReaderFilter;
|
|
121
|
+
readonly query?: string;
|
|
122
|
+
readonly sort?: ReaderSort;
|
|
123
|
+
readonly unreadOnly?: boolean;
|
|
124
|
+
/** The dedupe switch rides the same restore as every other narrowing. */
|
|
125
|
+
readonly hideDupes?: boolean;
|
|
126
|
+
readonly read?: Record<string, true>;
|
|
127
|
+
}
|
|
128
|
+
/** Annotation twin of the actions literal below. */
|
|
129
|
+
export type ReaderActions = {
|
|
130
|
+
setSources: (draft: ReaderState, sources: ReaderSourceSummary[]) => void;
|
|
131
|
+
setParsed: (draft: ReaderState, parsed: ReaderParsedSource) => void;
|
|
132
|
+
clearParsed: (draft: ReaderState) => void;
|
|
133
|
+
/** Bring back the session-local narrowing this pane had before it unmounted. */
|
|
134
|
+
hydrate: (draft: ReaderState, restore: ReaderSessionRestore) => void;
|
|
135
|
+
openEntry: (draft: ReaderState, entryId: string, sourceId: string) => void;
|
|
136
|
+
setView: (draft: ReaderState, view: ReaderView) => void;
|
|
137
|
+
setTags: (draft: ReaderState, tags: ReaderTag[], counts: Record<string, number>) => void;
|
|
138
|
+
setEntryTags: (draft: ReaderState, entryId: string, tagIds: string[]) => void;
|
|
139
|
+
/** Drop a tag from the vocabulary, from every entry, and from the counts. */
|
|
140
|
+
dropTag: (draft: ReaderState, tagId: string) => void;
|
|
141
|
+
setFetching: (draft: ReaderState, entryId: string, fetching: boolean) => void;
|
|
142
|
+
setStaleBody: (draft: ReaderState, entryId: string, stale: boolean) => void;
|
|
143
|
+
setCacheTtl: (draft: ReaderState, hours: number) => void;
|
|
144
|
+
setBackfill: (draft: ReaderState, progress: {
|
|
145
|
+
total: number;
|
|
146
|
+
done: number;
|
|
147
|
+
} | null) => void;
|
|
148
|
+
setFetchStates: (draft: ReaderState, states: Record<string, ReaderEntryFetchState>) => void;
|
|
149
|
+
/** Replace the recent list with what the host reports. */
|
|
150
|
+
setRecent: (draft: ReaderState, entries: ReaderRecentEntry[]) => void;
|
|
151
|
+
noteBackfilled: (draft: ReaderState, entryId: string, filled: boolean) => void;
|
|
152
|
+
setRefreshing: (draft: ReaderState, refreshing: boolean) => void;
|
|
153
|
+
/** Record that a refresh run finished, including one with failures. */
|
|
154
|
+
noteRefreshed: (draft: ReaderState, at: string) => void;
|
|
155
|
+
setArticle: (draft: ReaderState, html: string, truncated: boolean, error: string | null) => void;
|
|
156
|
+
closeEntry: (draft: ReaderState) => void;
|
|
157
|
+
setFilter: (draft: ReaderState, filter: ReaderFilter) => void;
|
|
158
|
+
setQuery: (draft: ReaderState, query: string) => void;
|
|
159
|
+
toggleUnreadOnly: (draft: ReaderState) => void;
|
|
160
|
+
toggleHideDupes: (draft: ReaderState) => void;
|
|
161
|
+
setSort: (draft: ReaderState, sort: ReaderSort) => void;
|
|
162
|
+
markRead: (draft: ReaderState, entryId: string) => void;
|
|
163
|
+
setSchedule: (draft: ReaderState, lastRefreshAt: string | undefined, nextRefreshAt: string | undefined) => void;
|
|
164
|
+
/** Replace one source's display label (the feed's own title, once parsed). */
|
|
165
|
+
setSourceLabel: (draft: ReaderState, id: string, label: string) => void;
|
|
166
|
+
/** Upgrade a saved link's card to the article's own title/excerpt; feed entries are never touched. */
|
|
167
|
+
noteExtractedMeta: (draft: ReaderState, entryId: string, meta: {
|
|
168
|
+
title?: string;
|
|
169
|
+
excerpt?: string;
|
|
170
|
+
}) => void;
|
|
171
|
+
setLoading: (draft: ReaderState, loading: boolean) => void;
|
|
172
|
+
setError: (draft: ReaderState, error: string | null) => void;
|
|
173
|
+
refresh: (draft: ReaderState) => void;
|
|
174
|
+
};
|
|
175
|
+
/**
|
|
176
|
+
* Create the reader pane's store handle.
|
|
177
|
+
*
|
|
178
|
+
* @returns the store handle (spec, type, identity and factory in one).
|
|
179
|
+
*/
|
|
180
|
+
export declare function createReaderStore(): EngineStoreHandle<ReaderState, ReaderActions>;
|
|
181
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The reader pane's transient store: what the user is looking at, not what the
|
|
3
|
+
* host stores.
|
|
4
|
+
*
|
|
5
|
+
* Everything here is session state. Sources and their payloads live in the
|
|
6
|
+
* host's `state.json` (see `src/store.ts`), while the parsed entries, the
|
|
7
|
+
* extracted article bodies, the read cursor, the filter and the sort are
|
|
8
|
+
* per-session and deliberately NOT persisted — see the design's storage
|
|
9
|
+
* decision: persisting a per-entry read cursor would need a second,
|
|
10
|
+
* write-heavy document for a feature whose whole value is "what is new since I
|
|
11
|
+
* last looked".
|
|
12
|
+
*
|
|
13
|
+
* @module @khorsheed/dsh-reader/client/store
|
|
14
|
+
*/
|
|
15
|
+
import { defineStore } from '@deepseek-ai/dsh-client-store';
|
|
16
|
+
const INITIAL = {
|
|
17
|
+
sources: [],
|
|
18
|
+
parsed: {},
|
|
19
|
+
articleHtml: null,
|
|
20
|
+
articleTruncated: false,
|
|
21
|
+
articleError: null,
|
|
22
|
+
openEntryId: null,
|
|
23
|
+
openSourceId: null,
|
|
24
|
+
tags: [],
|
|
25
|
+
tagCounts: {},
|
|
26
|
+
entryTagIds: {},
|
|
27
|
+
fetching: {},
|
|
28
|
+
staleBodies: {},
|
|
29
|
+
cacheTtlHours: 24,
|
|
30
|
+
backfill: null,
|
|
31
|
+
backfilled: {},
|
|
32
|
+
fetchStates: {},
|
|
33
|
+
recent: [],
|
|
34
|
+
view: 'list',
|
|
35
|
+
// 'all' rather than 'today': a subscription's entries are usually NOT from
|
|
36
|
+
// today (measured: the acceptance instance's feed's newest item was 8 days
|
|
37
|
+
// old), and a freshly subscribed source whose items the default filter hides
|
|
38
|
+
// reads as "the subscribe did not work".
|
|
39
|
+
filter: 'all',
|
|
40
|
+
query: '',
|
|
41
|
+
unreadOnly: false,
|
|
42
|
+
// Duplicates fold by default: a republished article is one card, and showing
|
|
43
|
+
// it twice is the noise the reader reported.
|
|
44
|
+
hideDupes: true,
|
|
45
|
+
sort: 'newest',
|
|
46
|
+
read: {},
|
|
47
|
+
lastRefreshAt: null,
|
|
48
|
+
nextRefreshAt: null,
|
|
49
|
+
refreshing: false,
|
|
50
|
+
loading: false,
|
|
51
|
+
error: null,
|
|
52
|
+
rev: 0,
|
|
53
|
+
};
|
|
54
|
+
/**
|
|
55
|
+
* Create the reader pane's store handle.
|
|
56
|
+
*
|
|
57
|
+
* @returns the store handle (spec, type, identity and factory in one).
|
|
58
|
+
*/
|
|
59
|
+
export function createReaderStore() {
|
|
60
|
+
return defineStore({
|
|
61
|
+
init: () => ({ ...INITIAL, parsed: {}, read: {} }),
|
|
62
|
+
actions: {
|
|
63
|
+
setSources: (d, sources) => {
|
|
64
|
+
d.sources = sources;
|
|
65
|
+
d.loading = false;
|
|
66
|
+
d.error = null;
|
|
67
|
+
},
|
|
68
|
+
setParsed: (d, parsed) => {
|
|
69
|
+
d.parsed = { ...d.parsed, [parsed.id]: parsed };
|
|
70
|
+
d.loading = false;
|
|
71
|
+
d.error = null;
|
|
72
|
+
},
|
|
73
|
+
clearParsed: (d) => { d.parsed = {}; },
|
|
74
|
+
hydrate: (d, restore) => {
|
|
75
|
+
// Field by field, so an absent key keeps the store's own default (and so
|
|
76
|
+
// a session snapshot from an older revision can never inject `undefined`
|
|
77
|
+
// into a field the UI dereferences).
|
|
78
|
+
if (restore.view !== undefined)
|
|
79
|
+
d.view = restore.view;
|
|
80
|
+
if (restore.openEntryId !== undefined)
|
|
81
|
+
d.openEntryId = restore.openEntryId;
|
|
82
|
+
if (restore.openSourceId !== undefined)
|
|
83
|
+
d.openSourceId = restore.openSourceId;
|
|
84
|
+
if (restore.filter !== undefined)
|
|
85
|
+
d.filter = restore.filter;
|
|
86
|
+
if (restore.query !== undefined)
|
|
87
|
+
d.query = restore.query;
|
|
88
|
+
if (restore.sort !== undefined)
|
|
89
|
+
d.sort = restore.sort;
|
|
90
|
+
if (restore.unreadOnly !== undefined)
|
|
91
|
+
d.unreadOnly = restore.unreadOnly;
|
|
92
|
+
if (restore.hideDupes !== undefined)
|
|
93
|
+
d.hideDupes = restore.hideDupes;
|
|
94
|
+
if (restore.read !== undefined)
|
|
95
|
+
d.read = restore.read;
|
|
96
|
+
},
|
|
97
|
+
setView: (d, view) => { d.view = view; },
|
|
98
|
+
setTags: (d, tags, counts) => { d.tags = tags; d.tagCounts = counts; },
|
|
99
|
+
setEntryTags: (d, entryId, tagIds) => {
|
|
100
|
+
d.entryTagIds = { ...d.entryTagIds, [entryId]: tagIds };
|
|
101
|
+
},
|
|
102
|
+
dropTag: (d, tagId) => {
|
|
103
|
+
// Three places hold a tag id, and a deleted tag must leave all of them
|
|
104
|
+
// or the wall keeps drawing a chip that can no longer be clicked.
|
|
105
|
+
d.tags = d.tags.filter(tag => tag.id !== tagId);
|
|
106
|
+
const { [tagId]: _dropped, ...counts } = d.tagCounts;
|
|
107
|
+
d.tagCounts = counts;
|
|
108
|
+
const entryTagIds = {};
|
|
109
|
+
for (const [entryId, ids] of Object.entries(d.entryTagIds)) {
|
|
110
|
+
entryTagIds[entryId] = ids.filter(id => id !== tagId);
|
|
111
|
+
}
|
|
112
|
+
d.entryTagIds = entryTagIds;
|
|
113
|
+
},
|
|
114
|
+
setFetching: (d, entryId, fetching) => {
|
|
115
|
+
const next = { ...d.fetching };
|
|
116
|
+
if (fetching)
|
|
117
|
+
next[entryId] = true;
|
|
118
|
+
else
|
|
119
|
+
delete next[entryId];
|
|
120
|
+
d.fetching = next;
|
|
121
|
+
},
|
|
122
|
+
setStaleBody: (d, entryId, stale) => {
|
|
123
|
+
const next = { ...d.staleBodies };
|
|
124
|
+
if (stale)
|
|
125
|
+
next[entryId] = true;
|
|
126
|
+
else
|
|
127
|
+
delete next[entryId];
|
|
128
|
+
d.staleBodies = next;
|
|
129
|
+
},
|
|
130
|
+
setCacheTtl: (d, hours) => { d.cacheTtlHours = hours; },
|
|
131
|
+
setBackfill: (d, progress) => { d.backfill = progress; },
|
|
132
|
+
setFetchStates: (d, states) => {
|
|
133
|
+
const next = { ...d.fetchStates, ...states };
|
|
134
|
+
// Same values, same object: a poll that learns nothing must not re-render
|
|
135
|
+
// the wall (and must not look like "something changed" to any effect).
|
|
136
|
+
const changed = Object.keys(next).length !== Object.keys(d.fetchStates).length
|
|
137
|
+
|| Object.entries(next).some(([id, value]) => JSON.stringify(d.fetchStates[id]) !== JSON.stringify(value));
|
|
138
|
+
if (changed)
|
|
139
|
+
d.fetchStates = next;
|
|
140
|
+
},
|
|
141
|
+
setRecent: (d, entries) => { d.recent = entries; },
|
|
142
|
+
noteBackfilled: (d, entryId, filled) => {
|
|
143
|
+
d.backfilled = { ...d.backfilled, [entryId]: true };
|
|
144
|
+
// Done counting regardless: a failure also advances the run, and the
|
|
145
|
+
// card simply keeps showing the feed's own text.
|
|
146
|
+
if (d.backfill !== null)
|
|
147
|
+
d.backfill = { ...d.backfill, done: Math.min(d.backfill.done + 1, d.backfill.total) };
|
|
148
|
+
if (!filled)
|
|
149
|
+
delete d.backfilled[entryId];
|
|
150
|
+
if (filled) {
|
|
151
|
+
// A fresh body is also the end of "expired": the marker is about the
|
|
152
|
+
// copy the host held, and that copy was just replaced.
|
|
153
|
+
const stale = { ...d.staleBodies };
|
|
154
|
+
delete stale[entryId];
|
|
155
|
+
d.staleBodies = stale;
|
|
156
|
+
}
|
|
157
|
+
},
|
|
158
|
+
setRefreshing: (d, refreshing) => { d.refreshing = refreshing; },
|
|
159
|
+
noteRefreshed: (d, at) => {
|
|
160
|
+
d.lastRefreshAt = at;
|
|
161
|
+
d.refreshing = false;
|
|
162
|
+
},
|
|
163
|
+
openEntry: (d, entryId, sourceId) => {
|
|
164
|
+
d.openEntryId = entryId;
|
|
165
|
+
d.openSourceId = sourceId;
|
|
166
|
+
d.articleHtml = null;
|
|
167
|
+
d.articleTruncated = false;
|
|
168
|
+
d.articleError = null;
|
|
169
|
+
d.read = { ...d.read, [entryId]: true };
|
|
170
|
+
},
|
|
171
|
+
setArticle: (d, html, truncated, error) => {
|
|
172
|
+
d.articleHtml = html;
|
|
173
|
+
d.articleTruncated = truncated;
|
|
174
|
+
d.articleError = error;
|
|
175
|
+
},
|
|
176
|
+
closeEntry: (d) => {
|
|
177
|
+
d.openEntryId = null;
|
|
178
|
+
d.openSourceId = null;
|
|
179
|
+
d.view = 'list';
|
|
180
|
+
d.articleHtml = null;
|
|
181
|
+
d.articleTruncated = false;
|
|
182
|
+
d.articleError = null;
|
|
183
|
+
},
|
|
184
|
+
setFilter: (d, filter) => { d.filter = filter; },
|
|
185
|
+
setQuery: (d, query) => { d.query = query; },
|
|
186
|
+
toggleUnreadOnly: (d) => { d.unreadOnly = !d.unreadOnly; },
|
|
187
|
+
toggleHideDupes: (d) => { d.hideDupes = !d.hideDupes; },
|
|
188
|
+
setSort: (d, sort) => { d.sort = sort; },
|
|
189
|
+
markRead: (d, entryId) => { d.read = { ...d.read, [entryId]: true }; },
|
|
190
|
+
setSourceLabel: (d, id, label) => {
|
|
191
|
+
d.sources = d.sources.map(source => source.id === id ? { ...source, label } : source);
|
|
192
|
+
},
|
|
193
|
+
noteExtractedMeta: (d, entryId, meta) => {
|
|
194
|
+
if (meta.title === undefined && meta.excerpt === undefined)
|
|
195
|
+
return;
|
|
196
|
+
for (const [sourceId, parsed] of Object.entries(d.parsed)) {
|
|
197
|
+
const index = parsed.entries.findIndex(entry => entry.id === entryId);
|
|
198
|
+
if (index === -1)
|
|
199
|
+
continue;
|
|
200
|
+
// A feed entry's title is the publisher's own — the upgrade exists
|
|
201
|
+
// for saved links, whose card otherwise wears the URL forever.
|
|
202
|
+
if (d.sources.find(source => source.id === sourceId)?.kind !== 'link')
|
|
203
|
+
return;
|
|
204
|
+
const entries = parsed.entries.slice();
|
|
205
|
+
entries[index] = {
|
|
206
|
+
...entries[index],
|
|
207
|
+
...(meta.title === undefined ? {} : { title: meta.title }),
|
|
208
|
+
...(meta.excerpt === undefined ? {} : { summary: meta.excerpt }),
|
|
209
|
+
};
|
|
210
|
+
d.parsed = { ...d.parsed, [sourceId]: { ...parsed, entries } };
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
setSchedule: (d, lastRefreshAt, nextRefreshAt) => {
|
|
215
|
+
d.lastRefreshAt = lastRefreshAt ?? null;
|
|
216
|
+
d.nextRefreshAt = nextRefreshAt ?? null;
|
|
217
|
+
},
|
|
218
|
+
setLoading: (d, loading) => { d.loading = loading; },
|
|
219
|
+
setError: (d, error) => {
|
|
220
|
+
d.error = error;
|
|
221
|
+
d.loading = false;
|
|
222
|
+
},
|
|
223
|
+
refresh: (d) => { d.rev += 1; },
|
|
224
|
+
},
|
|
225
|
+
});
|
|
226
|
+
}
|