dsh-mishu 0.7.1 → 0.7.3
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 +31 -5
- package/lib/client.js +647 -384
- package/lib/core/ask.js +16 -0
- package/lib/core/board.js +15 -3
- package/lib/core/brief.js +16 -0
- package/lib/core/index.js +3 -0
- package/lib/core/watch/fetch.js +169 -0
- package/lib/core/watch/source.js +116 -0
- package/lib/core/watch/sync.js +171 -0
- package/lib/dsh/api.js +92 -2
- package/lib/dsh/runtime.js +20 -2
- package/lib/types/core/ask.d.ts +5 -0
- package/lib/types/core/board.d.ts +12 -1
- package/lib/types/core/brief.d.ts +5 -0
- package/lib/types/core/index.d.ts +3 -0
- package/lib/types/core/watch/fetch.d.ts +50 -0
- package/lib/types/core/watch/source.d.ts +68 -0
- package/lib/types/core/watch/sync.d.ts +51 -0
- package/lib/types/dsh/runtime.d.ts +6 -1
- package/package.json +1 -1
package/lib/dsh/api.js
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* travel inward: they go straight to the keychain, and no endpoint ever
|
|
10
10
|
* returns one.
|
|
11
11
|
*/
|
|
12
|
-
import { addDays, addSeries, bi, deleteFeishu, deliver, FEISHU_WEBHOOK, notifySettings, NOTIFY_REPORT_KEY, saveFeishu, saveNotifySettings, testMessage, buildAsk, buildBoard, buildDetail, dayOf, deleteGithubToken, deleteMailAccount, describeMailError, MishuError, describeSeries, detectLang, errorText, GitHubClient, MAIL_PRESETS, mailReportKey, NEW_TOKEN_URL, normalizePassword, providerMismatch, resolveSeries, saveGithubToken, saveMailAccount, saveSettings, secretAccount, secretEnvName, setStatusByUser, snoozeByUser, stopSeries, tickManual, } from "../core/index.js";
|
|
12
|
+
import { describeWatchError, addDays, addSeries, bi, deleteFeishu, deliver, FEISHU_WEBHOOK, notifySettings, NOTIFY_REPORT_KEY, saveFeishu, saveNotifySettings, testMessage, buildAsk, buildBoard, buildDetail, dayOf, deleteGithubToken, deleteMailAccount, describeMailError, MishuError, describeSeries, detectLang, errorText, GitHubClient, MAIL_PRESETS, mailReportKey, NEW_TOKEN_URL, normalizePassword, providerMismatch, resolveSeries, saveGithubToken, saveMailAccount, saveSettings, secretAccount, secretEnvName, setStatusByUser, snoozeByUser, stopSeries, tickManual, deleteWatch, describeKeywords, describeWatch, parseKeywords, readWatchBody, resolveWatch, saveWatch, watchById, watchReportKey, watchSources, } from "../core/index.js";
|
|
13
13
|
import { resolveAccount } from "../core/mail/account.js";
|
|
14
14
|
export const API_BASE = '/api/dsh-mishu';
|
|
15
15
|
class ApiError extends Error {
|
|
@@ -62,6 +62,38 @@ function accountInput(value) {
|
|
|
62
62
|
return out;
|
|
63
63
|
}
|
|
64
64
|
const password = (body) => (typeof body.password === 'string' && body.password !== '' ? body.password : undefined);
|
|
65
|
+
function watchInput(value) {
|
|
66
|
+
const w = (value ?? {});
|
|
67
|
+
const text = (v) => (typeof v === 'string' ? v : undefined);
|
|
68
|
+
const out = {};
|
|
69
|
+
if (Number.isInteger(Number(w.id)) && Number(w.id) > 0)
|
|
70
|
+
out.id = Number(w.id);
|
|
71
|
+
if (text(w.label) !== undefined)
|
|
72
|
+
out.label = text(w.label);
|
|
73
|
+
if (text(w.url) !== undefined)
|
|
74
|
+
out.url = text(w.url);
|
|
75
|
+
if (typeof w.keywords === 'string' || Array.isArray(w.keywords))
|
|
76
|
+
out.keywords = w.keywords;
|
|
77
|
+
if (typeof w.enabled === 'boolean')
|
|
78
|
+
out.enabled = w.enabled;
|
|
79
|
+
if (w.intervalMinutes !== undefined && w.intervalMinutes !== null && w.intervalMinutes !== '')
|
|
80
|
+
out.intervalMinutes = Number(w.intervalMinutes);
|
|
81
|
+
return out;
|
|
82
|
+
}
|
|
83
|
+
/** What the settings page shows for one watch. */
|
|
84
|
+
function watchView(store, source, lang) {
|
|
85
|
+
return {
|
|
86
|
+
id: source.id,
|
|
87
|
+
label: source.label,
|
|
88
|
+
url: source.url,
|
|
89
|
+
keywords: source.keywords.join(' '),
|
|
90
|
+
enabled: source.enabled,
|
|
91
|
+
intervalMinutes: source.intervalMinutes,
|
|
92
|
+
status: describeWatch(store, source, lang),
|
|
93
|
+
summary: describeKeywords(source, lang),
|
|
94
|
+
report: store.get(watchReportKey(source.id)) ?? null,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
65
97
|
export function apiHandlers(runtime, clock = Date.now) {
|
|
66
98
|
const langOf = (body) => runtime.fixedLang() ?? detectLang(typeof body.lang === 'string' ? body.lang : undefined);
|
|
67
99
|
const need = (lang) => {
|
|
@@ -77,6 +109,7 @@ export function apiHandlers(runtime, clock = Date.now) {
|
|
|
77
109
|
return buildBoard(store, {
|
|
78
110
|
now: at, lang, github: { enabled: settings.githubEnabled, markRead: settings.markRead },
|
|
79
111
|
mailAccounts: runtime.accounts(),
|
|
112
|
+
watches: runtime.watches(),
|
|
80
113
|
});
|
|
81
114
|
};
|
|
82
115
|
/** Everything the settings page shows. No secret is ever included. */
|
|
@@ -108,6 +141,7 @@ export function apiHandlers(runtime, clock = Date.now) {
|
|
|
108
141
|
...secrets.available && secrets.file !== undefined ? { file: { path: secrets.file.path, why: secrets.file.why[lang] } } : {},
|
|
109
142
|
},
|
|
110
143
|
accounts,
|
|
144
|
+
watches: watchSources(store).map(source => watchView(store, source, lang)),
|
|
111
145
|
notify: {
|
|
112
146
|
settings: notifySettings(store),
|
|
113
147
|
channels: await Promise.all(['desktop', 'feishu'].map(async (id) => {
|
|
@@ -194,7 +228,7 @@ export function apiHandlers(runtime, clock = Date.now) {
|
|
|
194
228
|
'/ask': async (body) => {
|
|
195
229
|
const lang = langOf(body);
|
|
196
230
|
const ask = (body.ask ?? {});
|
|
197
|
-
const kinds = ['matter', 'series', 'summary', 'github-error', 'mail-error', 'mail-setup'];
|
|
231
|
+
const kinds = ['matter', 'series', 'summary', 'github-error', 'mail-error', 'mail-setup', 'watch-error', 'watch-setup'];
|
|
198
232
|
if (typeof ask.kind !== 'string' || !kinds.includes(ask.kind))
|
|
199
233
|
throw new ApiError(lang === 'zh' ? '不认识要问什么' : 'Unknown question');
|
|
200
234
|
const request = {
|
|
@@ -202,6 +236,7 @@ export function apiHandlers(runtime, clock = Date.now) {
|
|
|
202
236
|
...ask.id === undefined ? {} : { id: Number(ask.id) },
|
|
203
237
|
...ask.accountId === undefined ? {} : { accountId: Number(ask.accountId) },
|
|
204
238
|
...ask.preset === undefined ? {} : { preset: String(ask.preset) },
|
|
239
|
+
...ask.watchId === undefined ? {} : { watchId: Number(ask.watchId) },
|
|
205
240
|
...typeof ask.error === 'string' && ask.error !== '' ? { error: ask.error.slice(0, 1000) } : {},
|
|
206
241
|
};
|
|
207
242
|
const prompt = buildAsk(need(lang), request, { now: clock(), lang, markRead: runtime.settings().markRead, accounts: runtime.accounts() });
|
|
@@ -310,6 +345,61 @@ export function apiHandlers(runtime, clock = Date.now) {
|
|
|
310
345
|
}
|
|
311
346
|
return { view: await settingsView(lang) };
|
|
312
347
|
},
|
|
348
|
+
// ── things you watch yourself ─────────────────────────
|
|
349
|
+
'/watch/save': async (body) => {
|
|
350
|
+
const lang = langOf(body);
|
|
351
|
+
const source = saveWatch(need(lang), watchInput(body.watch), clock());
|
|
352
|
+
return { id: source.id, view: await settingsView(lang) };
|
|
353
|
+
},
|
|
354
|
+
/**
|
|
355
|
+
* Read it once without saving: how many entries came back, and what the
|
|
356
|
+
* first one is — enough to tell a feed from a login wall.
|
|
357
|
+
*/
|
|
358
|
+
'/watch/test': async (body) => {
|
|
359
|
+
const lang = langOf(body);
|
|
360
|
+
const input = watchInput(body.watch);
|
|
361
|
+
const source = resolveWatch(input, input.id ?? 0);
|
|
362
|
+
let page;
|
|
363
|
+
try {
|
|
364
|
+
page = await runtime.watchFetch(source.url);
|
|
365
|
+
}
|
|
366
|
+
catch (error) {
|
|
367
|
+
throw new MishuError(describeWatchError(error, source));
|
|
368
|
+
}
|
|
369
|
+
if (page.status >= 400)
|
|
370
|
+
throw new ApiError(lang === 'zh' ? `这个网址返回 ${page.status}` : `The address answered ${page.status}`);
|
|
371
|
+
const items = readWatchBody({ body: page.body, contentType: page.contentType, url: source.url, now: clock() });
|
|
372
|
+
if (items.length === 0)
|
|
373
|
+
throw new ApiError(lang === 'zh' ? '读到了,但里面没有内容' : 'It loaded, but there was nothing in it');
|
|
374
|
+
const first = items[0];
|
|
375
|
+
const words = parseKeywords(input.keywords);
|
|
376
|
+
const haystack = `${first.title}\n${first.text}`.toLowerCase();
|
|
377
|
+
const hit = words.find(word => haystack.includes(word));
|
|
378
|
+
return {
|
|
379
|
+
items: items.length,
|
|
380
|
+
title: first.title.slice(0, 120),
|
|
381
|
+
excerpt: first.text.slice(0, 200),
|
|
382
|
+
...words.length === 0 ? {} : { keywordHit: hit !== undefined, ...hit === undefined ? {} : { keyword: hit } },
|
|
383
|
+
};
|
|
384
|
+
},
|
|
385
|
+
'/watch/delete': async (body) => {
|
|
386
|
+
const lang = langOf(body);
|
|
387
|
+
if (!deleteWatch(need(lang), Number(body.id), clock()))
|
|
388
|
+
throw new ApiError(lang === 'zh' ? `没有 #${body.id} 这个关注` : `There is no watch #${body.id}`);
|
|
389
|
+
return { view: await settingsView(lang) };
|
|
390
|
+
},
|
|
391
|
+
/** Look at it right now, whatever the interval says. */
|
|
392
|
+
'/watch/sync': async (body) => {
|
|
393
|
+
const lang = langOf(body);
|
|
394
|
+
const store = need(lang);
|
|
395
|
+
const source = watchById(store, Number(body.id));
|
|
396
|
+
if (source === undefined)
|
|
397
|
+
throw new ApiError(lang === 'zh' ? `没有 #${body.id} 这个关注` : `There is no watch #${body.id}`);
|
|
398
|
+
const report = await runtime.syncWatchSource(source);
|
|
399
|
+
if (report?.error !== undefined)
|
|
400
|
+
throw new ApiError(report.error[lang]);
|
|
401
|
+
return { report: report ?? null, board: board(store, lang), view: await settingsView(lang) };
|
|
402
|
+
},
|
|
313
403
|
/** Whether the page's "restart DSH" button can work here, and if not, what to do instead. */
|
|
314
404
|
'/restart/info': async (body) => {
|
|
315
405
|
const plan = runtime.restarter.plan();
|
package/lib/dsh/runtime.js
CHANGED
|
@@ -4,12 +4,14 @@
|
|
|
4
4
|
* what you set on the settings page on top), the keychain, and one sync of
|
|
5
5
|
* every source at a time.
|
|
6
6
|
*/
|
|
7
|
-
import { dayOf, detectLang, effectiveSettings, GitHubClient, ImapFetcher, mailAccounts, mailReportKey, renderBrief, buildBoard, DesktopChannel, FeishuChannel, FileSecrets, GithubDeviceLogin, KeychainOrFile, notifySettings, notifyTick, mishuPaths, resolveGithubToken, secretAccount, secretFromEnv, syncGithub, syncMail, systemSecrets, tickManual, bi, restartPlan, scheduleRestart, } from "../core/index.js";
|
|
7
|
+
import { dayOf, detectLang, effectiveSettings, GitHubClient, ImapFetcher, mailAccounts, mailReportKey, renderBrief, buildBoard, DesktopChannel, FeishuChannel, FileSecrets, GithubDeviceLogin, KeychainOrFile, notifySettings, notifyTick, mishuPaths, resolveGithubToken, secretAccount, secretFromEnv, syncGithub, syncMail, systemSecrets, tickManual, bi, restartPlan, scheduleRestart, dueWatches, httpFetch, syncWatch, watchReportKey, watchSources, } from "../core/index.js";
|
|
8
8
|
export class Runtime {
|
|
9
9
|
store;
|
|
10
10
|
config;
|
|
11
11
|
secrets;
|
|
12
12
|
fetcher;
|
|
13
|
+
/** How watched addresses are read; tests swap it for a fake. */
|
|
14
|
+
watchFetch = httpFetch;
|
|
13
15
|
logger;
|
|
14
16
|
syncing;
|
|
15
17
|
/** Where reminders can go; tests swap them for recorders. */
|
|
@@ -44,6 +46,9 @@ export class Runtime {
|
|
|
44
46
|
accounts() {
|
|
45
47
|
return this.store === undefined ? [] : mailAccounts(this.store);
|
|
46
48
|
}
|
|
49
|
+
watches() {
|
|
50
|
+
return this.store === undefined ? [] : watchSources(this.store);
|
|
51
|
+
}
|
|
47
52
|
brief(lang = this.lang()) {
|
|
48
53
|
const store = this.store;
|
|
49
54
|
if (store === undefined)
|
|
@@ -55,6 +60,7 @@ export class Runtime {
|
|
|
55
60
|
now, lang, upcomingDays: this.config.upcomingDays,
|
|
56
61
|
github: { enabled: settings.githubEnabled, markRead: settings.markRead },
|
|
57
62
|
mailAccounts: this.accounts(),
|
|
63
|
+
watches: this.watches(),
|
|
58
64
|
});
|
|
59
65
|
}
|
|
60
66
|
/** The password for an account: keychain first, then the documented environment variable. */
|
|
@@ -109,6 +115,15 @@ export class Runtime {
|
|
|
109
115
|
this.logger.info(`dsh-mishu: mail #${account.id} ${report.error === undefined ? `ok (${report.messages} unread, +${report.opened} open, ${report.closed} closed)` : 'failed'}`);
|
|
110
116
|
return report;
|
|
111
117
|
}
|
|
118
|
+
/** One watch; the result is also kept as its last report. */
|
|
119
|
+
async syncWatchSource(source) {
|
|
120
|
+
const store = this.store;
|
|
121
|
+
if (store === undefined)
|
|
122
|
+
return undefined;
|
|
123
|
+
const report = await syncWatch({ store, source, fetcher: this.watchFetch, now: Date.now() });
|
|
124
|
+
this.logger.info(`dsh-mishu: watch #${source.id} ${report.error === undefined ? `ok (${report.items} items, +${report.opened} open, ${report.closed} closed)` : 'failed'}`);
|
|
125
|
+
return report;
|
|
126
|
+
}
|
|
112
127
|
/** The channels that are turned on and work on this computer. */
|
|
113
128
|
async activeChannels() {
|
|
114
129
|
const store = this.store;
|
|
@@ -125,7 +140,7 @@ export class Runtime {
|
|
|
125
140
|
/** The board as the brief describes it. */
|
|
126
141
|
board(now, lang = this.lang()) {
|
|
127
142
|
const settings = this.settings();
|
|
128
|
-
return buildBoard(this.store, { now, lang, github: { enabled: settings.githubEnabled, markRead: settings.markRead }, mailAccounts: this.accounts() });
|
|
143
|
+
return buildBoard(this.store, { now, lang, github: { enabled: settings.githubEnabled, markRead: settings.markRead }, mailAccounts: this.accounts(), watches: this.watches() });
|
|
129
144
|
}
|
|
130
145
|
/** Send what is due now: urgent items, the daily brief. One at a time. */
|
|
131
146
|
notify(now = Date.now()) {
|
|
@@ -151,6 +166,9 @@ export class Runtime {
|
|
|
151
166
|
for (const account of this.accounts().filter(a => a.enabled)) {
|
|
152
167
|
await this.syncMailAccount(account).catch(error => this.logger.warn(`dsh-mishu: mail #${account.id} sync failed (${error.message})`));
|
|
153
168
|
}
|
|
169
|
+
for (const source of dueWatches(store, Date.now())) {
|
|
170
|
+
await this.syncWatchSource(source).catch(error => this.logger.warn(`dsh-mishu: watch #${source.id} failed (${error.message})`));
|
|
171
|
+
}
|
|
154
172
|
// What the sync turned up may be urgent.
|
|
155
173
|
await this.notify();
|
|
156
174
|
})().finally(() => { this.syncing = undefined; });
|
package/lib/types/core/ask.d.ts
CHANGED
|
@@ -17,7 +17,7 @@ export interface BoardCard {
|
|
|
17
17
|
/** Matter id, or series id for a series card. */
|
|
18
18
|
id: number;
|
|
19
19
|
target: 'matter' | 'series';
|
|
20
|
-
source: 'github' | 'mail' | 'manual';
|
|
20
|
+
source: 'github' | 'mail' | 'manual' | 'watch';
|
|
21
21
|
title: string;
|
|
22
22
|
/** One short line: when it is due, or why it is here. */
|
|
23
23
|
meta: string;
|
|
@@ -58,6 +58,12 @@ export interface BoardData {
|
|
|
58
58
|
state: 'never' | 'ok' | 'error';
|
|
59
59
|
text: string;
|
|
60
60
|
}>;
|
|
61
|
+
watch: Array<{
|
|
62
|
+
id: number;
|
|
63
|
+
label: string;
|
|
64
|
+
state: 'never' | 'ok' | 'error';
|
|
65
|
+
text: string;
|
|
66
|
+
}>;
|
|
61
67
|
};
|
|
62
68
|
markRead: MarkReadMode;
|
|
63
69
|
columns: {
|
|
@@ -83,6 +89,11 @@ export interface BoardOptions {
|
|
|
83
89
|
id: number;
|
|
84
90
|
label: string;
|
|
85
91
|
}>;
|
|
92
|
+
/** Things you watch yourself, for their status line. */
|
|
93
|
+
watches?: Array<{
|
|
94
|
+
id: number;
|
|
95
|
+
label: string;
|
|
96
|
+
}>;
|
|
86
97
|
}
|
|
87
98
|
export declare function buildBoard(store: MishuStore, options: BoardOptions): BoardData;
|
|
88
99
|
export interface MatterDetail {
|
|
@@ -27,6 +27,11 @@ export interface BriefOptions {
|
|
|
27
27
|
id: number;
|
|
28
28
|
label: string;
|
|
29
29
|
}>;
|
|
30
|
+
/** Things you watch yourself, for their status lines. */
|
|
31
|
+
watches?: Array<{
|
|
32
|
+
id: number;
|
|
33
|
+
label: string;
|
|
34
|
+
}>;
|
|
30
35
|
}
|
|
31
36
|
export declare function renderBrief(store: MishuStore, options: BriefOptions): string;
|
|
32
37
|
/** Recently closed matters with reasons, so a wrong closure can be reopened. */
|
|
@@ -28,6 +28,9 @@ export { DEFAULT_NOTIFY, deliver, inQuiet, localClock, notifySettings, notifyTic
|
|
|
28
28
|
export { restartPlan, scheduleRestart, type RestartPlan, type RestartStarted } from './restart.ts';
|
|
29
29
|
export { FileSecrets, KeychainOrFile, MemorySecrets, secretEnvName, secretFromEnv, systemSecrets, type SecretStatus, type SecretStore } from './secrets.ts';
|
|
30
30
|
export { deleteMailAccount, effectiveSettings, mailAccounts, saveMailAccount, saveSettings, type Defaults, type UserSettings } from './settings.ts';
|
|
31
|
+
export { deleteWatch, dueWatches, entryKey, parseKeywords, resolveWatch, saveWatch, watchById, watchReportKey, watchSources, WATCH_SOURCE, type WatchInput, type WatchReport, type WatchSource, } from './watch/source.ts';
|
|
32
|
+
export { describeWatchError, httpFetch, parseFeed, parseJson, parsePage, plainText, readWatchBody, type WatchFetch, type WatchItem } from './watch/fetch.ts';
|
|
33
|
+
export { describeKeywords, describeWatch, diffItems, judgeWatch, syncWatch } from './watch/sync.ts';
|
|
31
34
|
export declare const DEFAULT_HOME = "~/.mishu";
|
|
32
35
|
/** `~` and `~/…` expand to the home directory on every platform. */
|
|
33
36
|
export declare function expandHome(path: string): string;
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reading a watched address: a feed, a JSON list, or just a page.
|
|
3
|
+
*
|
|
4
|
+
* Whatever comes back is turned into a handful of items — a title, a bit of
|
|
5
|
+
* text and a stable key — so the rest of the code never has to care whether
|
|
6
|
+
* it was RSS, Atom, JSON or plain HTML. No parser library: the shapes that
|
|
7
|
+
* matter here are small, and a wrong guess costs nothing (the page is read
|
|
8
|
+
* as text and still works).
|
|
9
|
+
*/
|
|
10
|
+
import { type Bi } from '../i18n.ts';
|
|
11
|
+
import { type WatchSource } from './source.ts';
|
|
12
|
+
export interface WatchItem {
|
|
13
|
+
/** Stable across checks: the same entry twice is the same item. */
|
|
14
|
+
key: string;
|
|
15
|
+
title: string;
|
|
16
|
+
/** The text to look for keywords in, and to quote back. */
|
|
17
|
+
text: string;
|
|
18
|
+
url: string | null;
|
|
19
|
+
/** When the source says it happened; the fetch time when it does not. */
|
|
20
|
+
at: number;
|
|
21
|
+
}
|
|
22
|
+
export interface WatchFetch {
|
|
23
|
+
(url: string): Promise<{
|
|
24
|
+
status: number;
|
|
25
|
+
body: string;
|
|
26
|
+
contentType: string;
|
|
27
|
+
}>;
|
|
28
|
+
}
|
|
29
|
+
/** The real fetch: a plain GET with a short timeout and no cookies. */
|
|
30
|
+
export declare const httpFetch: WatchFetch;
|
|
31
|
+
/** Markup out, whitespace collapsed. */
|
|
32
|
+
export declare function plainText(markup: string): string;
|
|
33
|
+
/** RSS `<item>` and Atom `<entry>` blocks, whichever the document has. */
|
|
34
|
+
export declare function parseFeed(body: string, now: number, base: string): WatchItem[] | undefined;
|
|
35
|
+
/**
|
|
36
|
+
* A JSON body: an array of objects, or an object with one array inside. Each
|
|
37
|
+
* element's title-like and text-like fields become an item.
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseJson(body: string, now: number, base: string): WatchItem[] | undefined;
|
|
40
|
+
/** A page with no feed: its title and text, as one item keyed by a hash of the text. */
|
|
41
|
+
export declare function parsePage(body: string, now: number, base: string): WatchItem[];
|
|
42
|
+
/** Whatever the address returned, as items. */
|
|
43
|
+
export declare function readWatchBody(options: {
|
|
44
|
+
body: string;
|
|
45
|
+
contentType: string;
|
|
46
|
+
url: string;
|
|
47
|
+
now: number;
|
|
48
|
+
}): WatchItem[];
|
|
49
|
+
/** The sentence shown when a watch cannot be read. */
|
|
50
|
+
export declare function describeWatchError(error: unknown, source: WatchSource): Bi;
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Things you watch yourself: a page, a feed or an API you care about.
|
|
3
|
+
*
|
|
4
|
+
* GitHub and mail are sources the secretary knows how to read. Everything
|
|
5
|
+
* else a person cares about — a game's patch notes, a job posting, a price,
|
|
6
|
+
* a status page — is a URL. You add it here with the words that matter to
|
|
7
|
+
* you, and the secretary looks at it now and then: when it changes, or when
|
|
8
|
+
* one of your words shows up, it becomes a card like anything else.
|
|
9
|
+
*
|
|
10
|
+
* Only where and what to look for is kept here. What was seen last lives in
|
|
11
|
+
* the notebook's key-value table, so an entry never grows without bound.
|
|
12
|
+
*/
|
|
13
|
+
import { type Bi } from '../i18n.ts';
|
|
14
|
+
import type { MishuStore } from '../store.ts';
|
|
15
|
+
export declare const WATCH_SOURCE = "watch";
|
|
16
|
+
export interface WatchSource {
|
|
17
|
+
id: number;
|
|
18
|
+
/** What you call it: "补丁公告", "机票价格". */
|
|
19
|
+
label: string;
|
|
20
|
+
url: string;
|
|
21
|
+
/**
|
|
22
|
+
* Words to watch for, lower-cased. Empty: any change is worth a card.
|
|
23
|
+
* A feed is read per entry; a page is read as one text.
|
|
24
|
+
*/
|
|
25
|
+
keywords: string[];
|
|
26
|
+
enabled: boolean;
|
|
27
|
+
/** Minutes between checks. */
|
|
28
|
+
intervalMinutes: number;
|
|
29
|
+
}
|
|
30
|
+
export interface WatchInput {
|
|
31
|
+
id?: number;
|
|
32
|
+
label?: string;
|
|
33
|
+
url?: string;
|
|
34
|
+
keywords?: string | string[];
|
|
35
|
+
enabled?: boolean;
|
|
36
|
+
intervalMinutes?: number;
|
|
37
|
+
}
|
|
38
|
+
export interface WatchReport {
|
|
39
|
+
at: number;
|
|
40
|
+
id: number;
|
|
41
|
+
/** Entries or text pieces the fetch turned up. */
|
|
42
|
+
items: number;
|
|
43
|
+
opened: number;
|
|
44
|
+
closed: number;
|
|
45
|
+
error?: Bi;
|
|
46
|
+
}
|
|
47
|
+
export declare function watchSources(store: MishuStore): WatchSource[];
|
|
48
|
+
export declare function watchById(store: MishuStore, id: number): WatchSource | undefined;
|
|
49
|
+
export declare function watchReportKey(id: number): string;
|
|
50
|
+
/** Where one watch keeps what it has already seen. */
|
|
51
|
+
export declare function watchStateKey(id: number): string;
|
|
52
|
+
export declare function watchSeenKey(id: number): string;
|
|
53
|
+
/** A stable id for a feed entry: its guid, else its link, else its title. */
|
|
54
|
+
export declare function entryKey(entry: {
|
|
55
|
+
id?: string;
|
|
56
|
+
link?: string;
|
|
57
|
+
title?: string;
|
|
58
|
+
}): string;
|
|
59
|
+
/** Split what you typed into words: commas, 、, spaces or new lines all work. */
|
|
60
|
+
export declare function parseKeywords(value: string | string[] | undefined): string[];
|
|
61
|
+
/** Check and complete what the settings page sent; throws a sentence a person can act on. */
|
|
62
|
+
export declare function resolveWatch(input: WatchInput, id: number): WatchSource;
|
|
63
|
+
/** Add (no id) or update one watch. */
|
|
64
|
+
export declare function saveWatch(store: MishuStore, input: WatchInput, now: number): WatchSource;
|
|
65
|
+
/** Remove one watch and close whatever it still has open. */
|
|
66
|
+
export declare function deleteWatch(store: MishuStore, id: number, now: number): boolean;
|
|
67
|
+
/** The watches whose next check is due. */
|
|
68
|
+
export declare function dueWatches(store: MishuStore, now: number): WatchSource[];
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One watch, one check: read the address, compare with what was seen last
|
|
3
|
+
* time, and turn anything new into a card.
|
|
4
|
+
*
|
|
5
|
+
* A card per watch, not per entry: "补丁公告 · 3 条新内容" is what you want to
|
|
6
|
+
* see, not three cards that say the same thing. Marking it done closes it;
|
|
7
|
+
* the next change opens it again with what changed.
|
|
8
|
+
*
|
|
9
|
+
* The first check only writes down what is there now — otherwise adding a
|
|
10
|
+
* feed would hand you every entry it ever published. Words you asked to watch
|
|
11
|
+
* for are the exception: those are shown right away, because you asked.
|
|
12
|
+
*/
|
|
13
|
+
import type { MishuStore } from '../store.ts';
|
|
14
|
+
import type { Verdict } from '../github/rules.ts';
|
|
15
|
+
import { type WatchFetch, type WatchItem } from './fetch.ts';
|
|
16
|
+
import { type WatchReport, type WatchSource } from './source.ts';
|
|
17
|
+
interface WatchState {
|
|
18
|
+
/** Keys of entries already reported, newest last. */
|
|
19
|
+
seen: string[];
|
|
20
|
+
/** Fingerprint of the whole page, for addresses that are not feeds. */
|
|
21
|
+
digest?: string;
|
|
22
|
+
/** First check done. */
|
|
23
|
+
primed: boolean;
|
|
24
|
+
}
|
|
25
|
+
/** Which items are new since last time, and whether the whole page changed. */
|
|
26
|
+
export declare function diffItems(items: WatchItem[], state: WatchState): {
|
|
27
|
+
fresh: WatchItem[];
|
|
28
|
+
changed: boolean;
|
|
29
|
+
digest: string;
|
|
30
|
+
};
|
|
31
|
+
export declare function judgeWatch(options: {
|
|
32
|
+
source: WatchSource;
|
|
33
|
+
items: WatchItem[];
|
|
34
|
+
fresh: WatchItem[];
|
|
35
|
+
changed: boolean;
|
|
36
|
+
first: boolean;
|
|
37
|
+
now: number;
|
|
38
|
+
}): Verdict | undefined;
|
|
39
|
+
export declare function syncWatch(options: {
|
|
40
|
+
store: MishuStore;
|
|
41
|
+
source: WatchSource;
|
|
42
|
+
fetcher: WatchFetch;
|
|
43
|
+
now: number;
|
|
44
|
+
}): Promise<WatchReport>;
|
|
45
|
+
/** Everything about one watch the settings page shows. */
|
|
46
|
+
export declare function describeWatch(store: MishuStore, source: WatchSource, lang: 'zh' | 'en'): string;
|
|
47
|
+
/** The words, for showing back to you. */
|
|
48
|
+
export declare function describeKeywords(source: WatchSource, lang: 'zh' | 'en'): string;
|
|
49
|
+
/** Guard for the settings page: an address that cannot be a watch at all. */
|
|
50
|
+
export declare function assertWatchable(source: WatchSource): void;
|
|
51
|
+
export {};
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* what you set on the settings page on top), the keychain, and one sync of
|
|
5
5
|
* every source at a time.
|
|
6
6
|
*/
|
|
7
|
-
import { GithubDeviceLogin, type Defaults, type Lang, type MailAccount, type MailFetcher, type MailSyncReport, type MishuStore, type Channel, type ChannelId, type NotifyReport, type RestartPlan, type RestartStarted, type SecretStore, type TokenResult } from '../core/index.ts';
|
|
7
|
+
import { GithubDeviceLogin, type Defaults, type Lang, type MailAccount, type MailFetcher, type MailSyncReport, type MishuStore, type Channel, type ChannelId, type NotifyReport, type RestartPlan, type RestartStarted, type SecretStore, type TokenResult, type WatchFetch, type WatchReport, type WatchSource } from '../core/index.ts';
|
|
8
8
|
import type { MishuConfig } from './config.ts';
|
|
9
9
|
interface Logger {
|
|
10
10
|
info(message: string): void;
|
|
@@ -15,6 +15,8 @@ export declare class Runtime {
|
|
|
15
15
|
readonly config: MishuConfig;
|
|
16
16
|
readonly secrets: SecretStore;
|
|
17
17
|
readonly fetcher: MailFetcher;
|
|
18
|
+
/** How watched addresses are read; tests swap it for a fake. */
|
|
19
|
+
watchFetch: WatchFetch;
|
|
18
20
|
private readonly logger;
|
|
19
21
|
private syncing;
|
|
20
22
|
/** Where reminders can go; tests swap them for recorders. */
|
|
@@ -36,6 +38,7 @@ export declare class Runtime {
|
|
|
36
38
|
fixedLang(): Lang | undefined;
|
|
37
39
|
lang(): Lang;
|
|
38
40
|
accounts(): MailAccount[];
|
|
41
|
+
watches(): WatchSource[];
|
|
39
42
|
brief(lang?: Lang): string;
|
|
40
43
|
/** The password for an account: keychain first, then the documented environment variable. */
|
|
41
44
|
mailPassword(id: number): Promise<string | undefined>;
|
|
@@ -44,6 +47,8 @@ export declare class Runtime {
|
|
|
44
47
|
private syncGithubNow;
|
|
45
48
|
/** One account; the result is also kept as its last report. */
|
|
46
49
|
syncMailAccount(account: MailAccount): Promise<MailSyncReport | undefined>;
|
|
50
|
+
/** One watch; the result is also kept as its last report. */
|
|
51
|
+
syncWatchSource(source: WatchSource): Promise<WatchReport | undefined>;
|
|
47
52
|
/** The channels that are turned on and work on this computer. */
|
|
48
53
|
activeChannels(): Promise<Channel[]>;
|
|
49
54
|
/** The board as the brief describes it. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-mishu",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.3",
|
|
4
4
|
"description": "DSH secretary (小秘书) for DeepSeek Harness (dsh): watches GitHub notifications and the dated things you tell it (bills, renewals, documents, birthdays), closes what is already resolved, and keeps a short list of what actually needs you. Bilingual (中文 / English), macOS / Windows / Linux. · DSH 小秘书:提醒、通知、待办、消息、收件箱、秘书。GitHub 通知按当前状态分诊,交代一句就记下到期和周期事项。",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|