@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.
Files changed (58) hide show
  1. package/README.en.md +90 -0
  2. package/README.i18n.yaml +6 -0
  3. package/README.md +90 -0
  4. package/cordis.patch.yml +13 -0
  5. package/lib/client.js +15119 -0
  6. package/lib/index.js +3660 -0
  7. package/lib/invariant.js +38 -0
  8. package/lib/typert.host.d.ts +3 -0
  9. package/lib/typert.host.js +1738 -0
  10. package/lib/typert.remote-client.d.ts +82 -0
  11. package/lib/typert.remote-client.js +1115 -0
  12. package/lib/types/arxiv.d.ts +47 -0
  13. package/lib/types/arxiv.js +76 -0
  14. package/lib/types/client/ReaderPane.d.ts +71 -0
  15. package/lib/types/client/ReaderPane.js +3135 -0
  16. package/lib/types/client/contract.d.ts +254 -0
  17. package/lib/types/client/contract.js +1 -0
  18. package/lib/types/client/definition.d.ts +27 -0
  19. package/lib/types/client/definition.js +32 -0
  20. package/lib/types/client/extract-article.d.ts +102 -0
  21. package/lib/types/client/extract-article.js +1024 -0
  22. package/lib/types/client/icons.d.ts +27 -0
  23. package/lib/types/client/icons.js +11 -0
  24. package/lib/types/client/index.d.ts +34 -0
  25. package/lib/types/client/index.js +222 -0
  26. package/lib/types/client/locales.d.ts +391 -0
  27. package/lib/types/client/locales.js +382 -0
  28. package/lib/types/client/parse-rss.d.ts +69 -0
  29. package/lib/types/client/parse-rss.js +656 -0
  30. package/lib/types/client/quote.d.ts +89 -0
  31. package/lib/types/client/quote.js +119 -0
  32. package/lib/types/client/selectors.d.ts +167 -0
  33. package/lib/types/client/selectors.js +413 -0
  34. package/lib/types/client/session.d.ts +171 -0
  35. package/lib/types/client/session.js +223 -0
  36. package/lib/types/client/store.d.ts +181 -0
  37. package/lib/types/client/store.js +226 -0
  38. package/lib/types/client/translate.d.ts +379 -0
  39. package/lib/types/client/translate.js +873 -0
  40. package/lib/types/index.d.ts +48 -0
  41. package/lib/types/index.js +25 -0
  42. package/lib/types/invariant.d.ts +29 -0
  43. package/lib/types/invariant.js +38 -0
  44. package/lib/types/link-resolvers.d.ts +107 -0
  45. package/lib/types/link-resolvers.js +299 -0
  46. package/lib/types/remote.d.ts +224 -0
  47. package/lib/types/remote.js +275 -0
  48. package/lib/types/schedule.d.ts +78 -0
  49. package/lib/types/schedule.js +114 -0
  50. package/lib/types/service.d.ts +633 -0
  51. package/lib/types/service.js +1697 -0
  52. package/lib/types/store.d.ts +224 -0
  53. package/lib/types/store.js +916 -0
  54. package/lib/types/types.d.ts +710 -0
  55. package/lib/types/types.js +182 -0
  56. package/locale/en.json +6 -0
  57. package/locale/zh.json +6 -0
  58. package/package.json +128 -0
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Ref-block formatting and the draft merge the conversation write needs.
3
+ *
4
+ * Both are pure and both are copies-with-intent from `@khorsheed/dsh-quote`:
5
+ * this plugin must NOT depend on quote (a composition may omit it), and the
6
+ * conversation input's `setDraft` REPLACES the whole draft, so every writer in
7
+ * this ecosystem has to read-merge-write. Keeping the two functions here means
8
+ * a change in quote cannot change this plugin's output, at the cost of a
9
+ * deliberate, documented duplication.
10
+ *
11
+ * @module @khorsheed/dsh-reader/client/quote
12
+ */
13
+ import type { ReaderSourceSummary } from '../types.ts';
14
+ import type { ReaderEntry } from './parse-rss.ts';
15
+ /** The provenance line every reader ref carries. */
16
+ export interface ReaderRefProvenance {
17
+ readonly title: string;
18
+ readonly source: string;
19
+ /** Publication time, when the entry declares one. */
20
+ readonly publishedAt?: string;
21
+ readonly author?: string;
22
+ readonly link?: string;
23
+ }
24
+ /**
25
+ * Build the provenance block for one entry.
26
+ *
27
+ * @param entry - the entry being quoted.
28
+ * @param source - its source, for the label.
29
+ * @returns the provenance.
30
+ */
31
+ export declare function provenanceOf(entry: Pick<ReaderEntry, 'title' | 'author' | 'publishedAt' | 'link'>, source: Pick<ReaderSourceSummary, 'label'>): ReaderRefProvenance;
32
+ /**
33
+ * Format a quote block: the quoted text, then an attribution line naming the
34
+ * article, its source and its link.
35
+ *
36
+ * A blockquote rather than a bare paste because the reader is a citation tool:
37
+ * what comes out of it has to stay traceable to the article it came from, even
38
+ * after the conversation has moved on.
39
+ *
40
+ * @param text - the passage (or the whole entry's summary) being quoted.
41
+ * @param provenance - where it came from.
42
+ * @returns the markdown block to insert.
43
+ */
44
+ export declare function formatReaderRef(text: string, provenance: ReaderRefProvenance): string;
45
+ /**
46
+ * Merge a block into the existing draft. The input's `setDraft` replaces the
47
+ * whole value, so an appending writer has to do the read-merge itself; a
48
+ * non-empty draft gets a blank line between the two.
49
+ *
50
+ * @param draft - the draft as it stands.
51
+ * @param block - the block to append.
52
+ * @returns the draft to write back.
53
+ */
54
+ export declare function mergedDraft(draft: string, block: string): string;
55
+ /**
56
+ * A relative "when" for a card, from a timestamp and a reference instant. The
57
+ * reference is a parameter so the formatting is testable without a clock.
58
+ *
59
+ * @param iso - the entry's publication timestamp.
60
+ * @param now - the reference instant.
61
+ * @returns a key plus its parameters, for the caller to translate.
62
+ */
63
+ export type RelativeWhenKey = 'when.justNow' | 'when.minutes' | 'when.hours' | 'when.yesterday' | 'when.days';
64
+ /**
65
+ * A relative "when" for a card, from a timestamp and a reference instant. The
66
+ * reference is a parameter so the formatting is testable without a clock.
67
+ *
68
+ * @param iso - the entry's publication timestamp.
69
+ * @param now - the reference instant.
70
+ * @returns a dictionary key plus its parameters, for the caller to translate.
71
+ */
72
+ /**
73
+ * The wall-clock time of an ISO-8601 instant, in the reader's own zone.
74
+ *
75
+ * The footer states when the next scheduled refresh is due as a time of day
76
+ * ("每日 10:00"), because that is how the schedule itself is configured — a
77
+ * relative "in 14 h" would be a different number every time you look at it.
78
+ *
79
+ * @param iso - the instant.
80
+ * @returns `HH:MM`, or an empty string when the value is unusable.
81
+ */
82
+ export declare function clockOf(iso: string | undefined): string;
83
+ export declare function relativeWhen(iso: string | undefined, now: Date): {
84
+ key: RelativeWhenKey;
85
+ count?: number;
86
+ };
87
+ /** Format an ISO timestamp as a stable absolute date (detail header, title attrs). */
88
+ export declare function absoluteDate(iso: string | undefined): string | undefined;
89
+ //# sourceMappingURL=quote.d.ts.map
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Build the provenance block for one entry.
3
+ *
4
+ * @param entry - the entry being quoted.
5
+ * @param source - its source, for the label.
6
+ * @returns the provenance.
7
+ */
8
+ export function provenanceOf(entry, source) {
9
+ return {
10
+ title: entry.title,
11
+ source: source.label,
12
+ ...(entry.publishedAt !== undefined ? { publishedAt: entry.publishedAt } : {}),
13
+ ...(entry.author !== undefined ? { author: entry.author } : {}),
14
+ ...(entry.link !== undefined ? { link: entry.link } : {}),
15
+ };
16
+ }
17
+ /**
18
+ * Format a quote block: the quoted text, then an attribution line naming the
19
+ * article, its source and its link.
20
+ *
21
+ * A blockquote rather than a bare paste because the reader is a citation tool:
22
+ * what comes out of it has to stay traceable to the article it came from, even
23
+ * after the conversation has moved on.
24
+ *
25
+ * @param text - the passage (or the whole entry's summary) being quoted.
26
+ * @param provenance - where it came from.
27
+ * @returns the markdown block to insert.
28
+ */
29
+ export function formatReaderRef(text, provenance) {
30
+ const trimmed = text.trim();
31
+ const quoted = trimmed.length > 0 ? trimmed : provenance.title;
32
+ const quotedLines = quoted
33
+ .split('\n')
34
+ .map(line => (line.length > 0 ? `> ${line}` : '>'))
35
+ .join('\n');
36
+ const attribution = [
37
+ `— 《${provenance.title}》`,
38
+ provenance.source,
39
+ provenance.author,
40
+ provenance.publishedAt === undefined ? undefined : formatDate(provenance.publishedAt),
41
+ ].filter((part) => part !== undefined && part.length > 0).join(' · ');
42
+ const link = provenance.link === undefined ? '' : `\n${provenance.link}`;
43
+ return `${quotedLines}\n\n${attribution}${link}`;
44
+ }
45
+ /**
46
+ * Merge a block into the existing draft. The input's `setDraft` replaces the
47
+ * whole value, so an appending writer has to do the read-merge itself; a
48
+ * non-empty draft gets a blank line between the two.
49
+ *
50
+ * @param draft - the draft as it stands.
51
+ * @param block - the block to append.
52
+ * @returns the draft to write back.
53
+ */
54
+ export function mergedDraft(draft, block) {
55
+ const trimmedDraft = draft.replace(/\s+$/, '');
56
+ return trimmedDraft.length === 0 ? block : `${trimmedDraft}\n\n${block}`;
57
+ }
58
+ /** A short absolute date for the attribution line. */
59
+ function formatDate(iso) {
60
+ const parsed = new Date(iso);
61
+ if (Number.isNaN(parsed.getTime()))
62
+ return iso;
63
+ const year = parsed.getFullYear();
64
+ const month = `${parsed.getMonth() + 1}`.padStart(2, '0');
65
+ const day = `${parsed.getDate()}`.padStart(2, '0');
66
+ return `${year}-${month}-${day}`;
67
+ }
68
+ /**
69
+ * A relative "when" for a card, from a timestamp and a reference instant. The
70
+ * reference is a parameter so the formatting is testable without a clock.
71
+ *
72
+ * @param iso - the entry's publication timestamp.
73
+ * @param now - the reference instant.
74
+ * @returns a dictionary key plus its parameters, for the caller to translate.
75
+ */
76
+ /**
77
+ * The wall-clock time of an ISO-8601 instant, in the reader's own zone.
78
+ *
79
+ * The footer states when the next scheduled refresh is due as a time of day
80
+ * ("每日 10:00"), because that is how the schedule itself is configured — a
81
+ * relative "in 14 h" would be a different number every time you look at it.
82
+ *
83
+ * @param iso - the instant.
84
+ * @returns `HH:MM`, or an empty string when the value is unusable.
85
+ */
86
+ export function clockOf(iso) {
87
+ if (iso === undefined)
88
+ return '';
89
+ const at = new Date(iso);
90
+ if (Number.isNaN(at.getTime()))
91
+ return '';
92
+ return `${String(at.getHours()).padStart(2, '0')}:${String(at.getMinutes()).padStart(2, '0')}`;
93
+ }
94
+ export function relativeWhen(iso, now) {
95
+ if (iso === undefined)
96
+ return { key: 'when.justNow' };
97
+ const then = new Date(iso);
98
+ if (Number.isNaN(then.getTime()))
99
+ return { key: 'when.justNow' };
100
+ const minutes = Math.floor((now.getTime() - then.getTime()) / 60_000);
101
+ if (minutes < 1)
102
+ return { key: 'when.justNow' };
103
+ if (minutes < 60)
104
+ return { key: 'when.minutes', count: minutes };
105
+ const hours = Math.floor(minutes / 60);
106
+ if (hours < 24)
107
+ return { key: 'when.hours', count: hours };
108
+ const days = Math.floor(hours / 24);
109
+ if (days === 1)
110
+ return { key: 'when.yesterday' };
111
+ return { key: 'when.days', count: days };
112
+ }
113
+ /** Format an ISO timestamp as a stable absolute date (detail header, title attrs). */
114
+ export function absoluteDate(iso) {
115
+ if (iso === undefined)
116
+ return undefined;
117
+ const parsed = new Date(iso);
118
+ return Number.isNaN(parsed.getTime()) ? undefined : formatDate(iso);
119
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * Pure view selectors: the filter, the search and the sort the list applies to
3
+ * the parsed entries.
4
+ *
5
+ * All three are LOCAL by design (design decision D14). Entries are parsed in
6
+ * this process, so searching and re-sorting never costs a host round trip — and
7
+ * keeping them as pure functions is what makes them testable without a DOM.
8
+ *
9
+ * @module @khorsheed/dsh-reader/client/selectors
10
+ */
11
+ import type { ReaderEntry } from './parse-rss.ts';
12
+ import type { ReaderFilter, ReaderSort } from './store.ts';
13
+ import { type ReaderEntryFetchState, type ReaderSourceKind } from '../types.ts';
14
+ /** How many entries the pane will show before it stops rendering cards. */
15
+ export declare const LIST_RENDER_LIMIT = 300;
16
+ /** One entry plus the source it came from, which is what a card needs. */
17
+ export interface ReaderRow {
18
+ readonly entry: ReaderEntry;
19
+ readonly sourceId: string;
20
+ readonly sourceLabel: string;
21
+ readonly sourceTile: string;
22
+ readonly sourceHue: string;
23
+ /** When the source was added — the time key for an entry that has no date. */
24
+ readonly sourceAddedAt: string;
25
+ /** The source kind, so a card knows whether "link only" can apply to it. */
26
+ readonly sourceKind: ReaderSourceKind;
27
+ /** True for an entry this session has not opened yet. */
28
+ readonly unread: boolean;
29
+ }
30
+ /** The per-source metadata a row needs (kept structural so the selector is pure). */
31
+ export interface SourcePresentation {
32
+ readonly id: string;
33
+ readonly label: string;
34
+ readonly tile: string;
35
+ readonly hue: string;
36
+ /** The kind, so a kind query can narrow the wall without a second lookup. */
37
+ readonly kind: ReaderSourceKind;
38
+ /**
39
+ * When the reader added this source.
40
+ *
41
+ * A saved link has no publication date, so this is the only date it has —
42
+ * and the wall's time sort falls back to it. Without the fallback a
43
+ * freshly added link sorts as the oldest card on the wall, which is exactly
44
+ * where the reader who just added it does not look.
45
+ */
46
+ readonly addedAt: string;
47
+ }
48
+ /** Every parsed entry, flattened in source order. */
49
+ export declare function flattenEntries(parsed: Readonly<Record<string, {
50
+ entries: readonly ReaderEntry[];
51
+ }>>): ReaderEntry[];
52
+ /**
53
+ * What the plugin holds for one entry, as every surface reads it.
54
+ *
55
+ * The host's annotation is the only authority (a cached body, an in-flight or
56
+ * stored-raw fetch, a classified failure with its reason); the pane mirrors it
57
+ * per entry, and this is the ONE read path into that mirror — including the
58
+ * default, so no surface spells "no record" as anything other than `none`.
59
+ *
60
+ * @param states - the pane's mirror of the host's fetch states.
61
+ * @param entryId - the entry being shown.
62
+ * @returns the entry's fetch state.
63
+ */
64
+ export declare function fetchStateOf(states: Readonly<Record<string, ReaderEntryFetchState>>, entryId: string): ReaderEntryFetchState;
65
+ /**
66
+ * Apply the filter, the query and the sort.
67
+ *
68
+ * @param entries - the parsed entries.
69
+ * @param sources - presentation metadata per source id.
70
+ * @param options - the current filter/query/sort plus the session's read cursor.
71
+ * @returns the rows to render, newest-first unless the sort says otherwise.
72
+ */
73
+ export declare function selectRows(entries: readonly ReaderEntry[], sources: ReadonlyMap<string, SourcePresentation>, options: {
74
+ readonly filter: ReaderFilter;
75
+ readonly query: string;
76
+ readonly unreadOnly: boolean;
77
+ readonly sort: ReaderSort;
78
+ readonly read: Readonly<Record<string, true>>;
79
+ /** The reader's own tag ids per entry — the third filter dimension. */
80
+ readonly tags?: Readonly<Record<string, readonly string[]>>;
81
+ readonly now: Date;
82
+ }): ReaderRow[];
83
+ /**
84
+ * The row for one entry, when its source is still known.
85
+ *
86
+ * `selectRows` builds rows for the LIST; the detail view needs the same row for
87
+ * one entry it was told to reopen (a restored reading position), and rebuilding
88
+ * the row by hand there would be a second, drifting copy of this mapping.
89
+ *
90
+ * @param entry - the entry to present.
91
+ * @param sources - presentation metadata per source id.
92
+ * @param read - the session's read cursor.
93
+ * @returns the row, or `undefined` when the entry's source is gone.
94
+ */
95
+ export declare function rowFor(entry: ReaderEntry, sources: ReadonlyMap<string, SourcePresentation>, read: Readonly<Record<string, true>>): ReaderRow | undefined;
96
+ /**
97
+ * Whether an entry belongs in the "today" view.
98
+ *
99
+ * An entry with NO usable date is kept, not dropped. The default filter is
100
+ * `today`, and a saved article link carries no `publishedAt` at all — dropping
101
+ * undated entries would make the primary add-a-link flow produce a link the
102
+ * reader can never see. A date the reader cannot compare is likewise not
103
+ * evidence that the entry is old.
104
+ *
105
+ * @param iso - the feed-declared timestamp, when there is one.
106
+ * @param now - the reference day.
107
+ * @returns true when the entry is on that day, or carries no comparable date.
108
+ */
109
+ export declare function isToday(iso: string | undefined, now: Date): boolean;
110
+ /**
111
+ * The query that selects exactly one source's entries.
112
+ *
113
+ * The search box and the source strip are the same mechanism — one local
114
+ * predicate (D14) — so a source filter is a query the reader can also see and
115
+ * edit. The two-character prefix keeps it out of the way of real search terms:
116
+ * no title search will accidentally match it, and the id is stable across
117
+ * renames.
118
+ *
119
+ * @param sourceId - the source to select.
120
+ * @returns the query string.
121
+ */
122
+ export declare function sourceQuery(sourceId: string): string;
123
+ /**
124
+ * The query that selects everything carrying one tag.
125
+ *
126
+ * @param tagId - the tag to select.
127
+ * @returns the query string.
128
+ */
129
+ export declare function tagQuery(tagId: string): string;
130
+ /** How many of these rows are still unread. */
131
+ export declare function countUnread(rows: readonly ReaderRow[]): number;
132
+ /** The result of hiding duplicates behind their survivors. */
133
+ export interface DedupedRows {
134
+ /** The rows to render: one per group, in the input's order. */
135
+ readonly rows: ReaderRow[];
136
+ /** Survivor entry id → the rows it hides (input order). */
137
+ readonly dupesBy: ReadonlyMap<string, readonly ReaderRow[]>;
138
+ readonly hiddenCount: number;
139
+ }
140
+ /**
141
+ * Fold duplicate rows into their survivors.
142
+ *
143
+ * Survivor choice, in order: a member whose body the host already holds
144
+ * (a `ready` fetch state — the fetched copy opens instantly) beats a NEWER
145
+ * member, and a tie keeps the input order. Same-id copies share the id-keyed
146
+ * fetch-state record, so between them the choice is always the input order —
147
+ * they share every annotation anyway. The survivor sits at its own position in
148
+ * the row order. Read state merges across the group: the card reads as read
149
+ * when ANY copy was read (free via the shared id for same-id copies). Hidden
150
+ * rows are reported, never deleted — the card badges them.
151
+ *
152
+ * All bookkeeping is POSITIONAL (row indexes), never id-keyed for the fold
153
+ * itself: two copies of one article can share ONE entry id by design, and
154
+ * id-keyed folding keeps both — the survivor's id is the hidden copy's id.
155
+ * Only the badge lookup (`dupesBy`) is id-keyed, which is safe: the surviving
156
+ * card is the only rendered row carrying that id.
157
+ *
158
+ * @param rows - the wall's rows (selectRows' output: filtered and sorted).
159
+ * @param fetchStates - the pane's fetch-state mirror, for the ready signal.
160
+ * @returns the rows to render, and where the hidden ones went.
161
+ */
162
+ export declare function dedupeRows(rows: readonly ReaderRow[], fetchStates?: Readonly<Record<string, ReaderEntryFetchState>>): DedupedRows;
163
+ /** A short, stable, source-derived monogram tile (design decision D11). */
164
+ export declare function tileForSource(label: string): string;
165
+ /** A stable hue derived from the source label, so a source keeps its colour. */
166
+ export declare function hueForSource(label: string): string;
167
+ //# sourceMappingURL=selectors.d.ts.map