dsh-mishu 0.7.2 → 0.7.4

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.
@@ -0,0 +1,171 @@
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 { applyVerdict } from "../fold.js";
14
+ import { bi, MishuError } from "../i18n.js";
15
+ import { shortDay } from "../time.js";
16
+ import { describeWatchError, readWatchBody } from "./fetch.js";
17
+ import { WATCH_SOURCE, watchReportKey, watchSeenKey, watchStateKey } from "./source.js";
18
+ /** How many entry keys one watch remembers. A feed longer than this is fine; the tail ages out. */
19
+ const SEEN_LIMIT = 500;
20
+ function readState(store, id) {
21
+ return store.get(watchStateKey(id)) ?? { seen: [], primed: false };
22
+ }
23
+ /** Which items are new since last time, and whether the whole page changed. */
24
+ export function diffItems(items, state) {
25
+ const seen = new Set(state.seen);
26
+ const fresh = items.filter(item => !seen.has(item.key));
27
+ // A single-item read is a page: its key is a hash of the text, so "new" means "the text changed".
28
+ const digest = items.length === 1 ? items[0].key : items.map(i => i.key).sort().join(',');
29
+ return { fresh, changed: state.digest !== undefined && state.digest !== digest, digest };
30
+ }
31
+ function matches(item, keywords) {
32
+ if (keywords.length === 0)
33
+ return undefined;
34
+ const haystack = `${item.title}\n${item.text}`.toLowerCase();
35
+ return keywords.find(word => haystack.includes(word));
36
+ }
37
+ function quote(items, lang) {
38
+ const shown = items.slice(0, 3).map(i => `「${i.title.slice(0, 80)}」`);
39
+ const more = items.length - shown.length;
40
+ if (lang === 'zh')
41
+ return more > 0 ? `${shown.join('、')} 等 ${items.length} 条` : shown.join('、');
42
+ return more > 0 ? `${shown.join(', ')} and ${more} more` : shown.join(', ');
43
+ }
44
+ export function judgeWatch(options) {
45
+ const { source, items, fresh, changed, first, now } = options;
46
+ const hit = (list) => list
47
+ .map(item => ({ item, word: matches(item, source.keywords) }))
48
+ .filter((x) => x.word !== undefined);
49
+ const freshHits = hit(fresh);
50
+ const allHits = hit(items);
51
+ const keywordMode = source.keywords.length > 0;
52
+ let reason;
53
+ let relevant;
54
+ let word;
55
+ if (keywordMode) {
56
+ // A word you watch for is worth a card even on the very first check.
57
+ relevant = (first ? allHits : freshHits).map(x => x.item);
58
+ if (relevant.length === 0)
59
+ return undefined;
60
+ word = (first ? allHits : freshHits)[0].word;
61
+ reason = 'watch-keyword';
62
+ }
63
+ else if (first) {
64
+ return undefined;
65
+ }
66
+ else if (fresh.length > 0) {
67
+ relevant = fresh;
68
+ reason = items.length > 1 ? 'watch-new' : 'watch-changed';
69
+ }
70
+ else if (changed) {
71
+ relevant = items.slice(0, 1);
72
+ reason = 'watch-changed';
73
+ }
74
+ else {
75
+ return undefined;
76
+ }
77
+ const when = shortDay(now);
78
+ const what = bi(`你关注「${source.label}」(${new URL(source.url).hostname})`, `The thing you watch: "${source.label}" (${new URL(source.url).hostname})`);
79
+ const why = keywordMode && word !== undefined
80
+ ? bi(`${when} 检查时,这里有内容提到你关注的字「${word}」:${quote(relevant, 'zh')}。`, `On ${when} it mentioned "${word}", one of your words: ${quote(relevant, 'en')}.`)
81
+ : reason === 'watch-new'
82
+ ? bi(`${when} 检查时出现了 ${fresh.length} 条新内容:${quote(relevant, 'zh')}。`, `On ${when} there were ${fresh.length} new entries: ${quote(relevant, 'en')}.`)
83
+ : bi(`${when} 检查时,${source.label} 的内容变了:${quote(relevant, 'zh')}。`, `On ${when} "${source.label}" changed: ${quote(relevant, 'en')}.`);
84
+ const link = relevant.find(i => i.url !== null)?.url ?? source.url;
85
+ return {
86
+ matterKey: `watch:${source.id}`,
87
+ kind: 'watch',
88
+ title: source.label,
89
+ url: link,
90
+ status: 'open',
91
+ reason,
92
+ note: bi(keywordMode ? `提到「${word}」` : reason === 'watch-new' ? `${fresh.length} 条新内容` : '内容有变化', keywordMode ? `mentions "${word}"` : reason === 'watch-new' ? `${fresh.length} new entries` : 'changed'),
93
+ explain: {
94
+ what,
95
+ why,
96
+ fix: bi('打开看一眼;处理完点"办完了"。不想再关注了,在设置里删掉这一条。', 'Open it and have a look; mark it done afterwards. To stop watching, delete it in Settings.'),
97
+ link: { label: bi('打开看看', 'Open it'), url: link },
98
+ },
99
+ };
100
+ }
101
+ export async function syncWatch(options) {
102
+ const { store, source, fetcher, now } = options;
103
+ const report = { at: now, id: source.id, items: 0, opened: 0, closed: 0 };
104
+ let body;
105
+ try {
106
+ body = await fetcher(source.url);
107
+ }
108
+ catch (error) {
109
+ report.error = describeWatchError(error, source);
110
+ store.set(watchReportKey(source.id), report);
111
+ return report;
112
+ }
113
+ if (body.status >= 400) {
114
+ report.error = body.status === 404
115
+ ? bi(`${source.label} 打不开了(404):这个网址可能已经变了。`, `${source.label} is gone (404): the address may have moved.`)
116
+ : bi(`${source.label} 返回 ${body.status},读不到内容。`, `${source.label} answered ${body.status}, so there was nothing to read.`);
117
+ store.set(watchReportKey(source.id), report);
118
+ return report;
119
+ }
120
+ if (body.body.trim() === '') {
121
+ report.error = bi(`${source.label} 这次读到的是空页面。`, `${source.label} came back empty.`);
122
+ store.set(watchReportKey(source.id), report);
123
+ return report;
124
+ }
125
+ const items = readWatchBody({ body: body.body, contentType: body.contentType, url: source.url, now });
126
+ if (items.length === 0) {
127
+ report.error = bi(`${source.label} 这次读到的是空页面。`, `${source.label} came back empty.`);
128
+ store.set(watchReportKey(source.id), report);
129
+ return report;
130
+ }
131
+ report.items = items.length;
132
+ const state = readState(store, source.id);
133
+ const { fresh, changed, digest } = diffItems(items, state);
134
+ const verdict = judgeWatch({ source, items, fresh, changed, first: !state.primed, now });
135
+ store.transaction(() => {
136
+ if (verdict !== undefined) {
137
+ const counts = { opened: 0, closed: 0 };
138
+ applyVerdict(store, WATCH_SOURCE, { ...verdict, activityAt: Math.max(...items.map(i => i.at), now) }, now, counts);
139
+ report.opened = counts.opened;
140
+ report.closed = counts.closed;
141
+ }
142
+ const seen = [...state.seen, ...fresh.map(i => i.key)];
143
+ const trimmed = seen.length > SEEN_LIMIT ? seen.slice(seen.length - SEEN_LIMIT) : seen;
144
+ store.set(watchStateKey(source.id), { seen: trimmed, digest, primed: true });
145
+ store.set(watchSeenKey(source.id), trimmed.length);
146
+ });
147
+ store.set(watchReportKey(source.id), report);
148
+ return report;
149
+ }
150
+ /** Everything about one watch the settings page shows. */
151
+ export function describeWatch(store, source, lang) {
152
+ const report = store.get(watchReportKey(source.id));
153
+ if (report === undefined)
154
+ return lang === 'zh' ? '还没看过' : 'not checked yet';
155
+ if (report.error !== undefined)
156
+ return report.error[lang];
157
+ return lang === 'zh'
158
+ ? `${shortDay(report.at)} 看过,${report.items} 条内容`
159
+ : `checked ${shortDay(report.at)}, ${report.items} entries`;
160
+ }
161
+ /** The words, for showing back to you. */
162
+ export function describeKeywords(source, lang) {
163
+ if (source.keywords.length === 0)
164
+ return lang === 'zh' ? '有任何变化就告诉我' : 'tell me about any change';
165
+ return lang === 'zh' ? `提到 ${source.keywords.join('、')} 时告诉我` : `tell me when it mentions ${source.keywords.join(', ')}`;
166
+ }
167
+ /** Guard for the settings page: an address that cannot be a watch at all. */
168
+ export function assertWatchable(source) {
169
+ if (!/^https?:\/\//.test(source.url))
170
+ throw new MishuError(bi('只支持 http 和 https 的网址', 'only http and https addresses work'));
171
+ }
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) => {
@@ -117,6 +151,13 @@ export function apiHandlers(runtime, clock = Date.now) {
117
151
  feishu: { configured: (await runtime.secrets.get(FEISHU_WEBHOOK).catch(() => undefined)) !== undefined },
118
152
  last: store.get(NOTIFY_REPORT_KEY) ?? null,
119
153
  },
154
+ // DSH's own scheduled tasks; null when this Host has no Schedule service.
155
+ // A failing service must not take the rest of the settings page down.
156
+ schedule: runtime.schedule === undefined ? null : await runtime.schedule.view(lang).catch(() => ({
157
+ available: true,
158
+ tasks: [],
159
+ brief: { id: null, time: notifySettings(store).brief.time, timeZone: notifySettings(store).timeZone, armed: false, error: lang === 'zh' ? '读不到 DSH 的自动化任务' : 'could not read DSH\'s scheduled tasks' },
160
+ })),
120
161
  presets: MAIL_PRESETS.map(p => ({
121
162
  id: p.id, label: p.label[lang], host: p.host, port: p.port, secure: p.secure, supported: p.supported, howTo: p.howTo[lang],
122
163
  domains: p.domains, passwordLength: p.passwordLength ?? null,
@@ -194,7 +235,7 @@ export function apiHandlers(runtime, clock = Date.now) {
194
235
  '/ask': async (body) => {
195
236
  const lang = langOf(body);
196
237
  const ask = (body.ask ?? {});
197
- const kinds = ['matter', 'series', 'summary', 'github-error', 'mail-error', 'mail-setup'];
238
+ const kinds = ['matter', 'series', 'summary', 'github-error', 'mail-error', 'mail-setup', 'watch-error', 'watch-setup'];
198
239
  if (typeof ask.kind !== 'string' || !kinds.includes(ask.kind))
199
240
  throw new ApiError(lang === 'zh' ? '不认识要问什么' : 'Unknown question');
200
241
  const request = {
@@ -202,6 +243,7 @@ export function apiHandlers(runtime, clock = Date.now) {
202
243
  ...ask.id === undefined ? {} : { id: Number(ask.id) },
203
244
  ...ask.accountId === undefined ? {} : { accountId: Number(ask.accountId) },
204
245
  ...ask.preset === undefined ? {} : { preset: String(ask.preset) },
246
+ ...ask.watchId === undefined ? {} : { watchId: Number(ask.watchId) },
205
247
  ...typeof ask.error === 'string' && ask.error !== '' ? { error: ask.error.slice(0, 1000) } : {},
206
248
  };
207
249
  const prompt = buildAsk(need(lang), request, { now: clock(), lang, markRead: runtime.settings().markRead, accounts: runtime.accounts() });
@@ -247,6 +289,8 @@ export function apiHandlers(runtime, clock = Date.now) {
247
289
  '/notify/save': async (body) => {
248
290
  const lang = langOf(body);
249
291
  saveNotifySettings(need(lang), (body.notify ?? {}), clock());
292
+ // A brief that is also a scheduled task follows the time you just set.
293
+ await runtime.schedule?.followBrief(lang);
250
294
  return { view: await settingsView(lang) };
251
295
  },
252
296
  /** One test message through one channel, turned on or not. */
@@ -310,8 +354,96 @@ export function apiHandlers(runtime, clock = Date.now) {
310
354
  }
311
355
  return { view: await settingsView(lang) };
312
356
  },
313
- /** Whether the page's "restart DSH" button can work here, and if not, what to do instead. */
314
- '/restart/info': async (body) => {
357
+ // ── things you watch yourself ─────────────────────────
358
+ '/watch/save': async (body) => {
359
+ const lang = langOf(body);
360
+ const source = saveWatch(need(lang), watchInput(body.watch), clock());
361
+ return { id: source.id, view: await settingsView(lang) };
362
+ },
363
+ /**
364
+ * Read it once without saving: how many entries came back, and what the
365
+ * first one is — enough to tell a feed from a login wall.
366
+ */
367
+ '/watch/test': async (body) => {
368
+ const lang = langOf(body);
369
+ const input = watchInput(body.watch);
370
+ const source = resolveWatch(input, input.id ?? 0);
371
+ let page;
372
+ try {
373
+ page = await runtime.watchFetch(source.url);
374
+ }
375
+ catch (error) {
376
+ throw new MishuError(describeWatchError(error, source));
377
+ }
378
+ if (page.status >= 400)
379
+ throw new ApiError(lang === 'zh' ? `这个网址返回 ${page.status}` : `The address answered ${page.status}`);
380
+ const items = readWatchBody({ body: page.body, contentType: page.contentType, url: source.url, now: clock() });
381
+ if (items.length === 0)
382
+ throw new ApiError(lang === 'zh' ? '读到了,但里面没有内容' : 'It loaded, but there was nothing in it');
383
+ const first = items[0];
384
+ const words = parseKeywords(input.keywords);
385
+ const haystack = `${first.title}\n${first.text}`.toLowerCase();
386
+ const hit = words.find(word => haystack.includes(word));
387
+ return {
388
+ items: items.length,
389
+ title: first.title.slice(0, 120),
390
+ excerpt: first.text.slice(0, 200),
391
+ ...words.length === 0 ? {} : { keywordHit: hit !== undefined, ...hit === undefined ? {} : { keyword: hit } },
392
+ };
393
+ },
394
+ '/watch/delete': async (body) => {
395
+ const lang = langOf(body);
396
+ if (!deleteWatch(need(lang), Number(body.id), clock()))
397
+ throw new ApiError(lang === 'zh' ? `没有 #${body.id} 这个关注` : `There is no watch #${body.id}`);
398
+ return { view: await settingsView(lang) };
399
+ },
400
+ /** Look at it right now, whatever the interval says. */
401
+ '/watch/sync': async (body) => {
402
+ const lang = langOf(body);
403
+ const store = need(lang);
404
+ const source = watchById(store, Number(body.id));
405
+ if (source === undefined)
406
+ throw new ApiError(lang === 'zh' ? `没有 #${body.id} 这个关注` : `There is no watch #${body.id}`);
407
+ const report = await runtime.syncWatchSource(source);
408
+ if (report?.error !== undefined)
409
+ throw new ApiError(report.error[lang]);
410
+ return { report: report ?? null, board: board(store, lang), view: await settingsView(lang) };
411
+ },
412
+ // ── DSH's own scheduled tasks ─────────────────────────
413
+ /** Hand the daily brief to DSH's Schedule: it then arrives as a conversation, not a notification. */
414
+ '/schedule/brief/arm': async (body) => {
415
+ const lang = langOf(body);
416
+ const bridge = runtime.schedule;
417
+ if (bridge === undefined)
418
+ throw new ApiError(lang === 'zh' ? '这个 DSH 没有自动化任务服务,简报只会用桌面通知发' : 'This DSH has no scheduled tasks service; the brief stays a desktop notification');
419
+ // Arm first, then read the page back: the task list and the armed flag are only right afterwards.
420
+ const schedule = await bridge.armBrief(lang);
421
+ return { view: await settingsView(lang), schedule };
422
+ },
423
+ '/schedule/brief/disarm': async (body) => {
424
+ const lang = langOf(body);
425
+ const bridge = runtime.schedule;
426
+ if (bridge === undefined)
427
+ throw new ApiError(lang === 'zh' ? '这个 DSH 没有自动化任务服务' : 'This DSH has no scheduled tasks service');
428
+ const schedule = await bridge.disarmBrief(lang);
429
+ return { view: await settingsView(lang), schedule };
430
+ },
431
+ /** Run any task now. */
432
+ '/schedule/run': async (body) => {
433
+ const lang = langOf(body);
434
+ const bridge = runtime.schedule;
435
+ if (bridge === undefined)
436
+ throw new ApiError(lang === 'zh' ? '这个 DSH 没有自动化任务服务' : 'This DSH has no scheduled tasks service');
437
+ return { schedule: await bridge.run(String(body.id ?? ''), lang) };
438
+ },
439
+ '/schedule/delete': async (body) => {
440
+ const lang = langOf(body);
441
+ const bridge = runtime.schedule;
442
+ if (bridge === undefined)
443
+ throw new ApiError(lang === 'zh' ? '这个 DSH 没有自动化任务服务' : 'This DSH has no scheduled tasks service');
444
+ return { schedule: await bridge.delete(String(body.id ?? ''), lang) };
445
+ },
446
+ /** Whether the page's "restart DSH" button can work here, and if not, what to do instead. */ '/restart/info': async (body) => {
315
447
  const plan = runtime.restarter.plan();
316
448
  return plan.kind === 'none' ? { supported: false, why: plan.why[langOf(body)] } : { supported: true };
317
449
  },
package/lib/dsh/plugin.js CHANGED
@@ -111,6 +111,15 @@ export function apply(ctx, config, runtime = new Runtime(config, ctx.logger)) {
111
111
  }));
112
112
  }
113
113
  });
114
+ // DSH's own scheduled tasks ("自动化任务"). Optional: a Host without the
115
+ // service simply keeps the brief as a desktop notification.
116
+ ctx.inject(['schedule'], (scheduleCtx) => {
117
+ const service = scheduleCtx.schedule;
118
+ if (service === undefined || service === null)
119
+ return;
120
+ runtime.attachSchedule(service);
121
+ scheduleCtx.effect(() => () => { runtime.schedule = undefined; });
122
+ });
114
123
  ctx.inject(['commands'], (commandCtx) => {
115
124
  const commands = commandCtx.commands;
116
125
  commandCtx.effect(() => commands.register({
@@ -4,12 +4,15 @@
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, DEFAULT_NOTIFY, } from "../core/index.js";
8
+ import { ScheduleBridge } from "./schedule.js";
8
9
  export class Runtime {
9
10
  store;
10
11
  config;
11
12
  secrets;
12
13
  fetcher;
14
+ /** How watched addresses are read; tests swap it for a fake. */
15
+ watchFetch = httpFetch;
13
16
  logger;
14
17
  syncing;
15
18
  /** Where reminders can go; tests swap them for recorders. */
@@ -19,6 +22,11 @@ export class Runtime {
19
22
  githubLogin;
20
23
  /** How DSH restarts; tests swap it so they never restart anything. */
21
24
  restarter = { plan: () => restartPlan(), schedule: plan => scheduleRestart(plan) };
25
+ /**
26
+ * DSH's own scheduled tasks, when this Host has them. Attached by the shell
27
+ * once the `schedule` service shows up; absent on a Host without it.
28
+ */
29
+ schedule;
22
30
  constructor(config, logger, secrets = new KeychainOrFile(systemSecrets(), new FileSecrets(mishuPaths(config.home).secrets)), fetcher = new ImapFetcher()) {
23
31
  this.config = config;
24
32
  this.logger = logger;
@@ -44,6 +52,26 @@ export class Runtime {
44
52
  accounts() {
45
53
  return this.store === undefined ? [] : mailAccounts(this.store);
46
54
  }
55
+ watches() {
56
+ return this.store === undefined ? [] : watchSources(this.store);
57
+ }
58
+ /**
59
+ * Wire in DSH's Schedule service, once. The bridge reads the brief's time
60
+ * from the reminder settings, so a brief moved on this page moves the task
61
+ * too. Called again with the same service (a service restart) keeps the
62
+ * bridge and hands it the new handle.
63
+ */
64
+ attachSchedule(service) {
65
+ if (this.schedule !== undefined) {
66
+ this.schedule.replace(service);
67
+ return this.schedule;
68
+ }
69
+ this.schedule = new ScheduleBridge(service, () => this.store, () => this.lang(), () => {
70
+ const settings = this.store === undefined ? DEFAULT_NOTIFY : notifySettings(this.store);
71
+ return { time: settings.brief.time, timeZone: settings.timeZone };
72
+ });
73
+ return this.schedule;
74
+ }
47
75
  brief(lang = this.lang()) {
48
76
  const store = this.store;
49
77
  if (store === undefined)
@@ -55,6 +83,7 @@ export class Runtime {
55
83
  now, lang, upcomingDays: this.config.upcomingDays,
56
84
  github: { enabled: settings.githubEnabled, markRead: settings.markRead },
57
85
  mailAccounts: this.accounts(),
86
+ watches: this.watches(),
58
87
  });
59
88
  }
60
89
  /** The password for an account: keychain first, then the documented environment variable. */
@@ -109,6 +138,15 @@ export class Runtime {
109
138
  this.logger.info(`dsh-mishu: mail #${account.id} ${report.error === undefined ? `ok (${report.messages} unread, +${report.opened} open, ${report.closed} closed)` : 'failed'}`);
110
139
  return report;
111
140
  }
141
+ /** One watch; the result is also kept as its last report. */
142
+ async syncWatchSource(source) {
143
+ const store = this.store;
144
+ if (store === undefined)
145
+ return undefined;
146
+ const report = await syncWatch({ store, source, fetcher: this.watchFetch, now: Date.now() });
147
+ this.logger.info(`dsh-mishu: watch #${source.id} ${report.error === undefined ? `ok (${report.items} items, +${report.opened} open, ${report.closed} closed)` : 'failed'}`);
148
+ return report;
149
+ }
112
150
  /** The channels that are turned on and work on this computer. */
113
151
  async activeChannels() {
114
152
  const store = this.store;
@@ -125,7 +163,7 @@ export class Runtime {
125
163
  /** The board as the brief describes it. */
126
164
  board(now, lang = this.lang()) {
127
165
  const settings = this.settings();
128
- return buildBoard(this.store, { now, lang, github: { enabled: settings.githubEnabled, markRead: settings.markRead }, mailAccounts: this.accounts() });
166
+ return buildBoard(this.store, { now, lang, github: { enabled: settings.githubEnabled, markRead: settings.markRead }, mailAccounts: this.accounts(), watches: this.watches() });
129
167
  }
130
168
  /** Send what is due now: urgent items, the daily brief. One at a time. */
131
169
  notify(now = Date.now()) {
@@ -151,6 +189,9 @@ export class Runtime {
151
189
  for (const account of this.accounts().filter(a => a.enabled)) {
152
190
  await this.syncMailAccount(account).catch(error => this.logger.warn(`dsh-mishu: mail #${account.id} sync failed (${error.message})`));
153
191
  }
192
+ for (const source of dueWatches(store, Date.now())) {
193
+ await this.syncWatchSource(source).catch(error => this.logger.warn(`dsh-mishu: watch #${source.id} failed (${error.message})`));
194
+ }
154
195
  // What the sync turned up may be urgent.
155
196
  await this.notify();
156
197
  })().finally(() => { this.syncing = undefined; });