nippotion 0.0.4 → 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 +14 -1
- package/dist/cli.js +77 -4
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,7 +4,7 @@ A CLI tool that delivers daily reports (diary entries) written in a Notion datab
|
|
|
4
4
|
|
|
5
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
|
|
@@ -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",
|
|
@@ -137,6 +140,16 @@ To verify it works, trigger it manually from the Actions tab with `debug_mode: t
|
|
|
137
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
|
|
138
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)
|
|
139
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
|
|
140
153
|
|
|
141
154
|
### Example: Japanese setup
|
|
142
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) => {
|
|
@@ -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,
|