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.
Files changed (3) hide show
  1. package/README.md +19 -5
  2. package/dist/cli.js +79 -6
  3. 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's entries)
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 and error messages (`ja` / `en`, default: `en`). The config's `language` takes precedence if set |
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@1 ${{ github.event.inputs.debug_mode == 'true' && '--debug' || '' }}
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`, **defaults to `en`**). Set `"language": "ja"` to use it in Japanese
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 pickup = entries[Math.floor(Math.random() * entries.length)] ?? null;
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",
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": "^7.17.0",
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": "9.39.5",
27
- "@types/node": "^24.13.3",
28
- "eslint": "9.39.5",
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.65.0",
32
+ "typescript-eslint": "8.67.0",
33
33
  "vitest": "^4.1.9"
34
34
  },
35
35
  "overrides": {