nippotion 0.0.3 → 0.1.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.md +19 -5
- package/dist/cli.js +79 -6
- package/package.json +6 -6
package/README.md
CHANGED
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
A CLI tool that delivers daily reports (diary entries) written in a Notion database to Slack channels based on their labels, every morning. Runs with **`npx nippotion` and a single config file**.
|
|
4
4
|
|
|
5
|
-
- Delivers **the previous business day's** entries on weekday mornings (weekends are skipped; set `"holidays": "jp"` to also skip Japanese holidays via [@holiday-jp/holiday_jp](https://github.com/holiday-jp/holiday_jp). Monday delivers Friday
|
|
5
|
+
- Delivers **the previous business day's** entries on weekday mornings (weekends are skipped; set `"holidays": "jp"` to also skip Japanese holidays via [@holiday-jp/holiday_jp](https://github.com/holiday-jp/holiday_jp). Monday delivers entries dated Friday through Sunday)
|
|
6
6
|
- Routes entries to multiple Slack channels based on their labels (multi-select)
|
|
7
|
-
- Randomly picks one entry each time to feature as the "pickup" entry
|
|
7
|
+
- Randomly picks one entry each time to feature as the "pickup" entry (shared by every channel, even ones whose labels don't match it). Unfilled template copies — entries still titled after a template that nobody edited between creation and delivery — are left out of the draw (`pickup.templateCopyMinEditGapMs`)
|
|
8
8
|
- Timezone, holiday skipping, language (Japanese/English), and Slack message wording are all configurable (works for non-Japan offices and English-speaking workspaces too)
|
|
9
9
|
|
|
10
10
|
```bash
|
|
@@ -66,7 +66,7 @@ NOTION_API_TOKEN=xxx SLACK_BOT_API_TOKEN=xoxb-xxx npx nippotion
|
|
|
66
66
|
| `SLACK_BOT_API_TOKEN` | Slack Bot User OAuth Token (not needed with `--debug`) |
|
|
67
67
|
| `NIPPOTION_CONFIG` | Path to the config file (same as `--config`; the flag takes precedence) |
|
|
68
68
|
| `NIPPOTION_DEBUG` | Set to `1` for debug mode (same as `--debug`) |
|
|
69
|
-
| `NIPPOTION_LANG` | Language for CLI help
|
|
69
|
+
| `NIPPOTION_LANG` | Language for CLI help, log/error messages, and the default Slack message wording (`ja` / `en`, default: `en`). The config's `language` takes precedence if set |
|
|
70
70
|
| `TZ` | Fallback timezone for date evaluation. Prefer setting `timezone` in the config file |
|
|
71
71
|
|
|
72
72
|
### Scheduled runs with GitHub Actions
|
|
@@ -92,7 +92,7 @@ jobs:
|
|
|
92
92
|
- uses: actions/setup-node@v6
|
|
93
93
|
with:
|
|
94
94
|
node-version: 24
|
|
95
|
-
- run: npx nippotion@
|
|
95
|
+
- run: npx nippotion@0 ${{ github.event.inputs.debug_mode == 'true' && '--debug' || '' }}
|
|
96
96
|
env:
|
|
97
97
|
NOTION_API_TOKEN: ${{ secrets.NOTION_API_TOKEN }}
|
|
98
98
|
SLACK_BOT_API_TOKEN: ${{ secrets.SLACK_BOT_API_TOKEN }}
|
|
@@ -108,6 +108,9 @@ To verify it works, trigger it manually from the Actions tab with `debug_mode: t
|
|
|
108
108
|
"timezone": "America/New_York",
|
|
109
109
|
"language": "en",
|
|
110
110
|
"holidays": "jp",
|
|
111
|
+
"pickup": {
|
|
112
|
+
"templateCopyMinEditGapMs": 10000
|
|
113
|
+
},
|
|
111
114
|
"properties": {
|
|
112
115
|
"title": "Name of the title property",
|
|
113
116
|
"labels": "Name of the multi-select property",
|
|
@@ -132,10 +135,21 @@ To verify it works, trigger it manually from the Actions tab with `debug_mode: t
|
|
|
132
135
|
|
|
133
136
|
- `channelId` can be found at the end of the Slack channel's link URL, or from the channel details
|
|
134
137
|
- Listing the same label in multiple recipients delivers it to multiple channels
|
|
138
|
+
- As long as at least one entry exists for the day, every recipient channel receives a message — a channel whose labels match no entries still gets the header, pickup, and footer (delivery is skipped entirely only when there are no entries at all)
|
|
135
139
|
- `timezone` is the IANA timezone name used to evaluate business days and "the previous business day" (defaults to the runtime's local timezone if omitted)
|
|
136
|
-
- `language` sets the default Slack message wording and the log/error message language (`ja` / `en
|
|
140
|
+
- `language` sets the default Slack message wording and the log/error message language (`ja` / `en`; **defaults to `NIPPOTION_LANG`, or `en` if neither is set**). Set `"language": "ja"` to use it in Japanese
|
|
137
141
|
- Setting `holidays` to a country code also excludes that country's holidays from business days (**weekends only if omitted**; currently only `jp` — Japanese holidays — is supported). Non-business days are not delivered, and are also skipped when computing "the previous business day" (entries from a holiday arrive together on the next business day)
|
|
138
142
|
- `messages` lets you override individual pieces of Slack wording. All fields are optional (defaults follow `language`). `{database}` is replaced with a link to the database, and `{writer}` with the entry's author name
|
|
143
|
+
- `pickup.templateCopyMinEditGapMs` drops an entry from the **pickup candidates** when **both** of the following hold (default: `10000`, i.e. 10 seconds). Such entries are still delivered to their channels as usual — only the "today's pick" draw skips them. Set `0` to disable the rule and keep every entry as a candidate
|
|
144
|
+
1. the entry's title matches one of the data source's **template names** exactly, and
|
|
145
|
+
2. the gap between its `created_time` and its `last_edited_time` is **below** this value — that is, nobody edited it between creation and delivery
|
|
146
|
+
- Neither condition alone is usable: plenty of people leave the title as their template's name and still write a full report, and plenty of reports are composed elsewhere and submitted in a single write. Requiring both is what keeps those two groups in the draw
|
|
147
|
+
- Every run logs the entries it skipped (URL, title, and the measured gap), so you can check what the setting is actually doing
|
|
148
|
+
- **Known limits.** Please read these before turning the threshold up:
|
|
149
|
+
- A writer who keeps their template's name as the title **and** submits in one write is skipped. In the 3-day sample this rule was built from, that was 2 writers across 4 entries, one of whom was affected on every day of the sample
|
|
150
|
+
- A template copy whose title is not in the template list — because the template was renamed, or created after the run started — is **not** skipped
|
|
151
|
+
- If the template list cannot be fetched, **nothing is skipped**. Listing templates requires the data source to be shared with your integration, and the endpoint is documented against a newer `Notion-Version` than the Notion SDK sends by default. Rather than guess, nippotion logs the reason and leaves every entry in the draw
|
|
152
|
+
- Notion is not documented to guarantee sub-second precision on these timestamps. Where it reports them at minute granularity the gap lands on multiples of 60000, and any threshold below a minute behaves as "created and last edited within the same minute" — the same intent, so no configuration change is needed
|
|
139
153
|
|
|
140
154
|
### Example: Japanese setup
|
|
141
155
|
|
package/dist/cli.js
CHANGED
|
@@ -27,6 +27,8 @@ Notion\u306E\u65E5\u5831\u30C7\u30FC\u30BF\u30D9\u30FC\u30B9\u304B\u3089\u524D\u
|
|
|
27
27
|
config: {
|
|
28
28
|
notObject: "\u8A2D\u5B9A\u306F\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059",
|
|
29
29
|
mustBeString: '\u8A2D\u5B9A\u306E "{{keyPath}}" \u306F\u6587\u5B57\u5217\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\uFF08\u5B9F\u969B\u306E\u578B: {{type}}\uFF09',
|
|
30
|
+
mustBeNumber: '\u8A2D\u5B9A\u306E "{{keyPath}}" \u306F\u6709\u9650\u306E\u6570\u5024\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059\uFF08\u5B9F\u969B\u306E\u578B: {{type}}\uFF09',
|
|
31
|
+
mustBeNonNegativeInteger: '\u8A2D\u5B9A\u306E "{{keyPath}}" \u306F0\u4EE5\u4E0A\u306E\u6574\u6570\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059',
|
|
30
32
|
mustBeStringArray: '\u8A2D\u5B9A\u306E "{{keyPath}}" \u306F\u6587\u5B57\u5217\u306E\u914D\u5217\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059',
|
|
31
33
|
mustBeObject: '\u8A2D\u5B9A\u306E "{{keyPath}}" \u306F\u30AA\u30D6\u30B8\u30A7\u30AF\u30C8\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059',
|
|
32
34
|
mustBeArray: '\u8A2D\u5B9A\u306E "{{keyPath}}" \u306F\u914D\u5217\u3067\u3042\u308B\u5FC5\u8981\u304C\u3042\u308A\u307E\u3059',
|
|
@@ -40,7 +42,11 @@ Notion\u306E\u65E5\u5831\u30C7\u30FC\u30BF\u30D9\u30FC\u30B9\u304B\u3089\u524D\u
|
|
|
40
42
|
missing: "\u5FC5\u9808\u74B0\u5883\u5909\u6570\u304C\u8A2D\u5B9A\u3055\u308C\u3066\u3044\u307E\u305B\u3093: {{vars}}"
|
|
41
43
|
},
|
|
42
44
|
notion: {
|
|
43
|
-
propertyNotFound: '\u30D7\u30ED\u30D1\u30C6\u30A3 "{{name}}" \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093'
|
|
45
|
+
propertyNotFound: '\u30D7\u30ED\u30D1\u30C6\u30A3 "{{name}}" \u304C\u898B\u3064\u304B\u308A\u307E\u305B\u3093',
|
|
46
|
+
templateListFailed: "\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u4E00\u89A7\u3092\u53D6\u5F97\u3067\u304D\u306A\u304B\u3063\u305F\u305F\u3081\u3001pickup\u5019\u88DC\u306E\u9664\u5916\u3092\u884C\u3044\u307E\u305B\u3093: {{message}}"
|
|
47
|
+
},
|
|
48
|
+
pickup: {
|
|
49
|
+
skipped: "pickup\u5019\u88DC\u304B\u3089\u9664\u5916\uFF08\u30BF\u30A4\u30C8\u30EB\u304C\u30C6\u30F3\u30D7\u30EC\u30FC\u30C8\u540D\u306E\u307E\u307E\u3001\u4F5C\u6210\u304B\u3089\u914D\u4FE1\u307E\u3067\u672A\u7DE8\u96C6\uFF09: {{count}}\u4EF6"
|
|
44
50
|
},
|
|
45
51
|
slack: {
|
|
46
52
|
header: "\u524D\u55B6\u696D\u65E5\u306E{database}\u306F\u3053\u3061\u3089",
|
|
@@ -71,6 +77,8 @@ Environment variables:
|
|
|
71
77
|
config: {
|
|
72
78
|
notObject: "Config must be an object",
|
|
73
79
|
mustBeString: 'Config "{{keyPath}}" must be a string (got: {{type}})',
|
|
80
|
+
mustBeNumber: 'Config "{{keyPath}}" must be a finite number (got: {{type}})',
|
|
81
|
+
mustBeNonNegativeInteger: 'Config "{{keyPath}}" must be an integer of 0 or greater',
|
|
74
82
|
mustBeStringArray: 'Config "{{keyPath}}" must be an array of strings',
|
|
75
83
|
mustBeObject: 'Config "{{keyPath}}" must be an object',
|
|
76
84
|
mustBeArray: 'Config "{{keyPath}}" must be an array',
|
|
@@ -84,7 +92,11 @@ Environment variables:
|
|
|
84
92
|
missing: "Required environment variables are not set: {{vars}}"
|
|
85
93
|
},
|
|
86
94
|
notion: {
|
|
87
|
-
propertyNotFound: 'Property "{{name}}" was not found in the data source'
|
|
95
|
+
propertyNotFound: 'Property "{{name}}" was not found in the data source',
|
|
96
|
+
templateListFailed: "Could not list the data source templates, so no entry is skipped from the pickup candidates: {{message}}"
|
|
97
|
+
},
|
|
98
|
+
pickup: {
|
|
99
|
+
skipped: "Skipped from pickup candidates (still titled after a template, never edited between creation and delivery): {{count}}"
|
|
88
100
|
},
|
|
89
101
|
slack: {
|
|
90
102
|
header: "Here are the entries from {database} for the previous business day",
|
|
@@ -145,6 +157,7 @@ var getPreviousWorkday = (date, holidays) => {
|
|
|
145
157
|
|
|
146
158
|
// src/config.ts
|
|
147
159
|
var LANGUAGES = ["ja", "en"];
|
|
160
|
+
var DEFAULT_TEMPLATE_COPY_MIN_EDIT_GAP_MS = 1e4;
|
|
148
161
|
var isPlainObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
|
|
149
162
|
var requireString = (value, keyPath) => {
|
|
150
163
|
if (typeof value !== "string") {
|
|
@@ -152,6 +165,12 @@ var requireString = (value, keyPath) => {
|
|
|
152
165
|
}
|
|
153
166
|
return value;
|
|
154
167
|
};
|
|
168
|
+
var requireNumber = (value, keyPath) => {
|
|
169
|
+
if (typeof value !== "number" || !Number.isFinite(value)) {
|
|
170
|
+
throw new Error(t("config.mustBeNumber", { keyPath, type: typeof value }));
|
|
171
|
+
}
|
|
172
|
+
return value;
|
|
173
|
+
};
|
|
155
174
|
var requireStringArray = (value, keyPath) => {
|
|
156
175
|
if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
|
|
157
176
|
throw new Error(t("config.mustBeStringArray", { keyPath }));
|
|
@@ -191,6 +210,17 @@ var validateHolidays = (value, keyPath) => {
|
|
|
191
210
|
}
|
|
192
211
|
return country;
|
|
193
212
|
};
|
|
213
|
+
var validatePickup = (value, keyPath) => {
|
|
214
|
+
if (!isPlainObject(value)) {
|
|
215
|
+
throw new Error(t("config.mustBeObject", { keyPath }));
|
|
216
|
+
}
|
|
217
|
+
const gapPath = `${keyPath}.templateCopyMinEditGapMs`;
|
|
218
|
+
const templateCopyMinEditGapMs = requireNumber(value.templateCopyMinEditGapMs, gapPath);
|
|
219
|
+
if (!Number.isInteger(templateCopyMinEditGapMs) || templateCopyMinEditGapMs < 0) {
|
|
220
|
+
throw new Error(t("config.mustBeNonNegativeInteger", { keyPath: gapPath }));
|
|
221
|
+
}
|
|
222
|
+
return { templateCopyMinEditGapMs };
|
|
223
|
+
};
|
|
194
224
|
var validateLanguage = (value, keyPath) => {
|
|
195
225
|
const language = requireString(value, keyPath);
|
|
196
226
|
if (!LANGUAGES.includes(language)) {
|
|
@@ -249,6 +279,9 @@ var validateConfig = (data) => {
|
|
|
249
279
|
if (data.timezone !== void 0) {
|
|
250
280
|
config.timezone = validateTimezone(data.timezone, "timezone");
|
|
251
281
|
}
|
|
282
|
+
if (data.pickup !== void 0) {
|
|
283
|
+
config.pickup = validatePickup(data.pickup, "pickup");
|
|
284
|
+
}
|
|
252
285
|
return config;
|
|
253
286
|
};
|
|
254
287
|
var loadConfig = (filePath) => {
|
|
@@ -257,14 +290,14 @@ var loadConfig = (filePath) => {
|
|
|
257
290
|
raw = readFileSync(filePath, "utf-8");
|
|
258
291
|
} catch (error) {
|
|
259
292
|
const message = error instanceof Error ? error.message : String(error);
|
|
260
|
-
throw new Error(t("config.readFailed", { filePath, message }));
|
|
293
|
+
throw new Error(t("config.readFailed", { filePath, message }), { cause: error });
|
|
261
294
|
}
|
|
262
295
|
let data;
|
|
263
296
|
try {
|
|
264
297
|
data = JSON.parse(raw);
|
|
265
298
|
} catch (error) {
|
|
266
299
|
const message = error instanceof Error ? error.message : String(error);
|
|
267
|
-
throw new Error(t("config.parseFailed", { filePath, message }));
|
|
300
|
+
throw new Error(t("config.parseFailed", { filePath, message }), { cause: error });
|
|
268
301
|
}
|
|
269
302
|
return validateConfig(data);
|
|
270
303
|
};
|
|
@@ -371,6 +404,31 @@ var getAllPages = async (client, dataSourceId, filter, filterProperties) => {
|
|
|
371
404
|
);
|
|
372
405
|
return results.filter(isFullPage);
|
|
373
406
|
};
|
|
407
|
+
var editGapMs = (page) => {
|
|
408
|
+
const gap = Date.parse(page.last_edited_time) - Date.parse(page.created_time);
|
|
409
|
+
return Number.isFinite(gap) ? Math.max(0, gap) : 0;
|
|
410
|
+
};
|
|
411
|
+
var fetchTemplateNames = async (client, dataSourceId) => {
|
|
412
|
+
const names = /* @__PURE__ */ new Set();
|
|
413
|
+
try {
|
|
414
|
+
let startCursor;
|
|
415
|
+
do {
|
|
416
|
+
const response = await withRetry(() => client.dataSources.listTemplates({
|
|
417
|
+
data_source_id: dataSourceId,
|
|
418
|
+
start_cursor: startCursor
|
|
419
|
+
}));
|
|
420
|
+
for (const template of response.templates) names.add(template.name);
|
|
421
|
+
startCursor = response.next_cursor ?? void 0;
|
|
422
|
+
} while (startCursor);
|
|
423
|
+
} catch (error) {
|
|
424
|
+
console.error(t("notion.templateListFailed", {
|
|
425
|
+
message: error instanceof Error ? error.message : String(error)
|
|
426
|
+
}));
|
|
427
|
+
return /* @__PURE__ */ new Set();
|
|
428
|
+
}
|
|
429
|
+
return names;
|
|
430
|
+
};
|
|
431
|
+
var isPickupCandidate = (entry, templateNames, minEditGapMs) => !(templateNames.has(entry.title) && entry.editGapMs < minEditGapMs);
|
|
374
432
|
var getTitle = (page, titleProperty) => {
|
|
375
433
|
const prop = page.properties[titleProperty];
|
|
376
434
|
if (prop?.type !== "title") return "";
|
|
@@ -394,7 +452,8 @@ var toEntry = (page, properties) => {
|
|
|
394
452
|
title,
|
|
395
453
|
writerName: getWriterName(page, properties.author),
|
|
396
454
|
labels: getLabels(page, properties.labels),
|
|
397
|
-
url: page.url
|
|
455
|
+
url: page.url,
|
|
456
|
+
editGapMs: editGapMs(page)
|
|
398
457
|
};
|
|
399
458
|
};
|
|
400
459
|
|
|
@@ -507,6 +566,10 @@ var checkRequiredEnvVars = (debug) => {
|
|
|
507
566
|
process.exit(1);
|
|
508
567
|
}
|
|
509
568
|
};
|
|
569
|
+
var selectPickup = (entries, templateNames, minEditGapMs, rng = Math.random) => {
|
|
570
|
+
const candidates = entries.filter((entry) => isPickupCandidate(entry, templateNames, minEditGapMs));
|
|
571
|
+
return candidates[Math.floor(rng() * candidates.length)] ?? null;
|
|
572
|
+
};
|
|
510
573
|
var main = async () => {
|
|
511
574
|
const debug = process.env.NIPPOTION_DEBUG === "1";
|
|
512
575
|
checkRequiredEnvVars(debug);
|
|
@@ -544,7 +607,17 @@ var main = async () => {
|
|
|
544
607
|
const entries = pages.map((page) => toEntry(page, config.properties)).filter((entry) => entry !== null);
|
|
545
608
|
console.log("entries:", JSON.stringify({ pages: pages.length, entries: entries.length }));
|
|
546
609
|
if (entries.length === 0) return;
|
|
547
|
-
const
|
|
610
|
+
const minEditGapMs = config.pickup?.templateCopyMinEditGapMs ?? DEFAULT_TEMPLATE_COPY_MIN_EDIT_GAP_MS;
|
|
611
|
+
const templateNames = await fetchTemplateNames(client, config.dataSourceId);
|
|
612
|
+
const skipped = entries.filter((entry) => !isPickupCandidate(entry, templateNames, minEditGapMs));
|
|
613
|
+
if (skipped.length > 0) {
|
|
614
|
+
console.log(t("pickup.skipped", { count: skipped.length }), JSON.stringify(
|
|
615
|
+
skipped.map(({ url: url2, title: title2, editGapMs: editGapMs2 }) => ({ url: url2, title: title2, editGapMs: editGapMs2 })),
|
|
616
|
+
null,
|
|
617
|
+
2
|
|
618
|
+
));
|
|
619
|
+
}
|
|
620
|
+
const pickup = selectPickup(entries, templateNames, minEditGapMs);
|
|
548
621
|
const ctx = {
|
|
549
622
|
slack,
|
|
550
623
|
entries,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nippotion",
|
|
3
|
-
"version": "0.0
|
|
3
|
+
"version": "0.1.0",
|
|
4
4
|
"description": "Deliver daily reports written in Notion to Slack channels based on their labels",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -17,19 +17,19 @@
|
|
|
17
17
|
"dependencies": {
|
|
18
18
|
"@holiday-jp/holiday_jp": "^2.5.1",
|
|
19
19
|
"@notionhq/client": "^5.22.0",
|
|
20
|
-
"@slack/web-api": "^
|
|
20
|
+
"@slack/web-api": "^8.0.0",
|
|
21
21
|
"dayjs": "^1.11.21",
|
|
22
22
|
"i18next": "^26.3.6",
|
|
23
23
|
"p-retry": "^8.0.0"
|
|
24
24
|
},
|
|
25
25
|
"devDependencies": {
|
|
26
|
-
"@eslint/js": "
|
|
27
|
-
"@types/node": "^
|
|
28
|
-
"eslint": "
|
|
26
|
+
"@eslint/js": "10.0.1",
|
|
27
|
+
"@types/node": "^26.1.1",
|
|
28
|
+
"eslint": "10.8.1",
|
|
29
29
|
"tsup": "8.5.1",
|
|
30
30
|
"tsx": "^4.22.4",
|
|
31
31
|
"typescript": "^6.0.3",
|
|
32
|
-
"typescript-eslint": "8.
|
|
32
|
+
"typescript-eslint": "8.67.0",
|
|
33
33
|
"vitest": "^4.1.9"
|
|
34
34
|
},
|
|
35
35
|
"overrides": {
|