@neurowire/core 0.1.0 → 0.4.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/dist/index.d.ts +88 -1
- package/dist/index.js +128 -0
- package/package.json +2 -2
package/dist/index.d.ts
CHANGED
|
@@ -296,6 +296,44 @@ type Mesh = z.infer<typeof MeshSchema>;
|
|
|
296
296
|
/** Validate an unknown value into a Mesh. Throws (ZodError) on invalid input. */
|
|
297
297
|
declare function parseMesh(data: unknown): Mesh;
|
|
298
298
|
|
|
299
|
+
/**
|
|
300
|
+
* Pure dedup helpers for watch-style polling. They compute a stable identity per
|
|
301
|
+
* entry and select the entries a caller has not seen yet. No I/O, no clock, no
|
|
302
|
+
* network: the seen-state lives entirely in the calling app layer.
|
|
303
|
+
*/
|
|
304
|
+
/** Stable identity for dedup: the entry id when present, else its link. */
|
|
305
|
+
declare function entryKey(entry: NeurowireEntry): string;
|
|
306
|
+
/**
|
|
307
|
+
* The feed's entries whose {@link entryKey} is not in `seen`, in original order.
|
|
308
|
+
* `seen` is any iterable of keys (an array, a Set, ...); it is read once.
|
|
309
|
+
*/
|
|
310
|
+
declare function newEntries(feed: NeurowireFeed, seen: Iterable<string>): NeurowireEntry[];
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Pure, deterministic entry filtering by field and pattern. A rule matches a
|
|
314
|
+
* single field (title, summary, source, author, tag) against a substring or a
|
|
315
|
+
* regular expression. `filterEntries` keeps entries that satisfy an include set
|
|
316
|
+
* and avoid an exclude set. No I/O, no clock, no DOM.
|
|
317
|
+
*/
|
|
318
|
+
type FilterField = 'title' | 'summary' | 'source' | 'author' | 'tag';
|
|
319
|
+
interface FilterRule {
|
|
320
|
+
field: FilterField;
|
|
321
|
+
pattern: string;
|
|
322
|
+
regex?: boolean;
|
|
323
|
+
}
|
|
324
|
+
interface FilterSpec {
|
|
325
|
+
include?: FilterRule[];
|
|
326
|
+
exclude?: FilterRule[];
|
|
327
|
+
}
|
|
328
|
+
/** Does any of the entry's values for the rule's field match the pattern? */
|
|
329
|
+
declare function matchRule(entry: NeurowireEntry, rule: FilterRule): boolean;
|
|
330
|
+
/**
|
|
331
|
+
* Keep an entry iff it matches at least one include rule (or there are none)
|
|
332
|
+
* and matches none of the exclude rules. An empty spec returns the feed
|
|
333
|
+
* entries unchanged.
|
|
334
|
+
*/
|
|
335
|
+
declare function filterEntries(feed: NeurowireFeed, spec: FilterSpec): NeurowireFeed;
|
|
336
|
+
|
|
299
337
|
/** One feed plus the source label its entries should carry in a merge. */
|
|
300
338
|
interface MergePart {
|
|
301
339
|
feed: NeurowireFeed;
|
|
@@ -317,6 +355,55 @@ interface MergeOptions {
|
|
|
317
355
|
*/
|
|
318
356
|
declare function mergeFeeds(title: string, parts: MergePart[], options?: MergeOptions): NeurowireFeed;
|
|
319
357
|
|
|
358
|
+
/**
|
|
359
|
+
* Pure, deterministic feed refinement: a time-window filter, a sort, and a
|
|
360
|
+
* limit. These operate on the canonical model and never touch the clock or the
|
|
361
|
+
* network themselves. Callers pass `now` (epoch ms) into `resolveWindow`, which
|
|
362
|
+
* keeps the whole module testable and side-effect free.
|
|
363
|
+
*/
|
|
364
|
+
type SortKey = 'date' | 'title' | 'source';
|
|
365
|
+
type SortOrder = 'asc' | 'desc';
|
|
366
|
+
interface SelectOptions {
|
|
367
|
+
/** Keep entries dated at or after this epoch-ms bound (inclusive). */
|
|
368
|
+
from?: number;
|
|
369
|
+
/** Keep entries dated at or before this epoch-ms bound (inclusive). */
|
|
370
|
+
to?: number;
|
|
371
|
+
sort?: SortKey;
|
|
372
|
+
order?: SortOrder;
|
|
373
|
+
/** Keep at most this many entries after filtering and sorting. */
|
|
374
|
+
limit?: number;
|
|
375
|
+
}
|
|
376
|
+
/** Parse a duration like "24h", "90m", or "7d" into milliseconds. */
|
|
377
|
+
declare function parseDuration(value: string): number | undefined;
|
|
378
|
+
interface WindowSpec {
|
|
379
|
+
/** Within this duration before now, e.g. "24h". */
|
|
380
|
+
since?: string;
|
|
381
|
+
/** Drop anything older than this duration, e.g. "7d". Same window as `since`. */
|
|
382
|
+
maxAge?: string;
|
|
383
|
+
/** Since midnight UTC today. */
|
|
384
|
+
today?: boolean;
|
|
385
|
+
/** Since midnight UTC of the current week (Monday). */
|
|
386
|
+
thisWeek?: boolean;
|
|
387
|
+
/** An explicit [start, end] pair of ISO dates (or anything Date.parse reads). */
|
|
388
|
+
between?: [string, string];
|
|
389
|
+
}
|
|
390
|
+
interface TimeWindow {
|
|
391
|
+
from?: number;
|
|
392
|
+
to?: number;
|
|
393
|
+
}
|
|
394
|
+
/**
|
|
395
|
+
* Turn a high-level window spec into concrete epoch-ms bounds, relative to
|
|
396
|
+
* `now`. Precedence when several are set: between > today > thisWeek > since/maxAge.
|
|
397
|
+
* Unparseable values yield an open-ended bound on that side.
|
|
398
|
+
*/
|
|
399
|
+
declare function resolveWindow(spec: WindowSpec, now: number): TimeWindow;
|
|
400
|
+
/**
|
|
401
|
+
* Apply a date window, an optional sort, and an optional limit to a feed.
|
|
402
|
+
* Date sorts default to newest-first; title and source sorts default to A-Z.
|
|
403
|
+
* Entries without a parseable date are dropped only when a date bound is set.
|
|
404
|
+
*/
|
|
405
|
+
declare function selectEntries(feed: NeurowireFeed, opts: SelectOptions): NeurowireFeed;
|
|
406
|
+
|
|
320
407
|
/** Serialize a feed to an Atom 1.0 document. */
|
|
321
408
|
declare function toAtom(feed: NeurowireFeed): string;
|
|
322
409
|
|
|
@@ -385,4 +472,4 @@ declare function isFormat(value: string): value is Format;
|
|
|
385
472
|
/** Serialize a feed to the requested format. */
|
|
386
473
|
declare function serialize(feed: NeurowireFeed, format: Format): string;
|
|
387
474
|
|
|
388
|
-
export { EXTENSIONS, EntrySchema, FORMATS, FeedSchema, type Format, GENERATOR, type JsonFeedDocument, MEDIA_TYPES, type MergeOptions, type MergePart, type Mesh, MeshSchema, type MeshSource, MeshSourceSchema, type NeurowireEntry, type NeurowireFeed, type NwfIssue, type NwfValidation, type Person, PersonSchema, fromNwf, isFormat, mergeFeeds, parseMesh, parseNeurowireFeed, serialize, toAtom, toJsonFeed, toJsonFeedObject, toMarkdown, toNwf, validateNwf };
|
|
475
|
+
export { EXTENSIONS, EntrySchema, FORMATS, FeedSchema, type FilterField, type FilterRule, type FilterSpec, type Format, GENERATOR, type JsonFeedDocument, MEDIA_TYPES, type MergeOptions, type MergePart, type Mesh, MeshSchema, type MeshSource, MeshSourceSchema, type NeurowireEntry, type NeurowireFeed, type NwfIssue, type NwfValidation, type Person, PersonSchema, type SelectOptions, type SortKey, type SortOrder, type TimeWindow, type WindowSpec, entryKey, filterEntries, fromNwf, isFormat, matchRule, mergeFeeds, newEntries, parseDuration, parseMesh, parseNeurowireFeed, resolveWindow, selectEntries, serialize, toAtom, toJsonFeed, toJsonFeedObject, toMarkdown, toNwf, validateNwf };
|
package/dist/index.js
CHANGED
|
@@ -1,3 +1,48 @@
|
|
|
1
|
+
// src/diff.ts
|
|
2
|
+
function entryKey(entry) {
|
|
3
|
+
return entry.id || entry.link;
|
|
4
|
+
}
|
|
5
|
+
function newEntries(feed, seen) {
|
|
6
|
+
const known = new Set(seen);
|
|
7
|
+
return feed.entries.filter((entry) => !known.has(entryKey(entry)));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// src/filter.ts
|
|
11
|
+
function fieldValues(entry, field) {
|
|
12
|
+
switch (field) {
|
|
13
|
+
case "title":
|
|
14
|
+
return [entry.title];
|
|
15
|
+
case "summary":
|
|
16
|
+
return [entry.summary ?? ""];
|
|
17
|
+
case "source":
|
|
18
|
+
return [entry.source?.name ?? ""];
|
|
19
|
+
case "author":
|
|
20
|
+
return [(entry.authors ?? []).map((a) => a.name).join(" ")];
|
|
21
|
+
case "tag":
|
|
22
|
+
return entry.tags ?? [];
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function matchRule(entry, rule) {
|
|
26
|
+
const values = fieldValues(entry, rule.field);
|
|
27
|
+
if (rule.regex) {
|
|
28
|
+
const re = new RegExp(rule.pattern, "i");
|
|
29
|
+
return values.some((value) => re.test(value));
|
|
30
|
+
}
|
|
31
|
+
const needle = rule.pattern.toLowerCase();
|
|
32
|
+
return values.some((value) => value.toLowerCase().includes(needle));
|
|
33
|
+
}
|
|
34
|
+
function filterEntries(feed, spec) {
|
|
35
|
+
const include = spec.include ?? [];
|
|
36
|
+
const exclude = spec.exclude ?? [];
|
|
37
|
+
if (include.length === 0 && exclude.length === 0) return feed;
|
|
38
|
+
const kept = feed.entries.filter((entry) => {
|
|
39
|
+
if (include.length > 0 && !include.some((rule) => matchRule(entry, rule))) return false;
|
|
40
|
+
if (exclude.some((rule) => matchRule(entry, rule))) return false;
|
|
41
|
+
return true;
|
|
42
|
+
});
|
|
43
|
+
return { ...feed, entries: kept };
|
|
44
|
+
}
|
|
45
|
+
|
|
1
46
|
// src/model.ts
|
|
2
47
|
import { z } from "zod";
|
|
3
48
|
var PersonSchema = z.object({
|
|
@@ -83,6 +128,82 @@ function mergeFeeds(title, parts, options = {}) {
|
|
|
83
128
|
};
|
|
84
129
|
}
|
|
85
130
|
|
|
131
|
+
// src/refine.ts
|
|
132
|
+
var DAY = 864e5;
|
|
133
|
+
var UNIT_MS = { m: 6e4, h: 36e5, d: 864e5 };
|
|
134
|
+
function parseDuration(value) {
|
|
135
|
+
const match = /^(\d+)\s*([mhd])$/.exec(value.trim());
|
|
136
|
+
if (!match) return void 0;
|
|
137
|
+
return Number(match[1]) * UNIT_MS[match[2]];
|
|
138
|
+
}
|
|
139
|
+
function startOfUtcDay(now) {
|
|
140
|
+
return Math.floor(now / DAY) * DAY;
|
|
141
|
+
}
|
|
142
|
+
function startOfUtcWeek(now) {
|
|
143
|
+
const dayNumber = Math.floor(now / DAY);
|
|
144
|
+
const weekday = ((dayNumber % 7 + 3) % 7 + 7) % 7;
|
|
145
|
+
return (dayNumber - weekday) * DAY;
|
|
146
|
+
}
|
|
147
|
+
function resolveWindow(spec, now) {
|
|
148
|
+
if (spec.between) {
|
|
149
|
+
const from = Date.parse(spec.between[0]);
|
|
150
|
+
const to = Date.parse(spec.between[1]);
|
|
151
|
+
return {
|
|
152
|
+
from: Number.isNaN(from) ? void 0 : from,
|
|
153
|
+
to: Number.isNaN(to) ? void 0 : to
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (spec.today) return { from: startOfUtcDay(now) };
|
|
157
|
+
if (spec.thisWeek) return { from: startOfUtcWeek(now) };
|
|
158
|
+
const duration = spec.since ?? spec.maxAge;
|
|
159
|
+
if (duration !== void 0) {
|
|
160
|
+
const ms = parseDuration(duration);
|
|
161
|
+
return ms === void 0 ? {} : { from: now - ms };
|
|
162
|
+
}
|
|
163
|
+
return {};
|
|
164
|
+
}
|
|
165
|
+
function entryTime2(entry) {
|
|
166
|
+
return Date.parse(entry.published ?? entry.updated ?? "");
|
|
167
|
+
}
|
|
168
|
+
function compareByTime(a, b) {
|
|
169
|
+
const ta = entryTime2(a);
|
|
170
|
+
const tb = entryTime2(b);
|
|
171
|
+
const aMissing = Number.isNaN(ta);
|
|
172
|
+
const bMissing = Number.isNaN(tb);
|
|
173
|
+
if (aMissing && bMissing) return 0;
|
|
174
|
+
if (aMissing) return -1;
|
|
175
|
+
if (bMissing) return 1;
|
|
176
|
+
return ta - tb;
|
|
177
|
+
}
|
|
178
|
+
function compare(key, a, b) {
|
|
179
|
+
if (key === "title") return a.title.localeCompare(b.title);
|
|
180
|
+
if (key === "source") {
|
|
181
|
+
return (a.source?.name ?? "").localeCompare(b.source?.name ?? "") || compareByTime(a, b);
|
|
182
|
+
}
|
|
183
|
+
return compareByTime(a, b);
|
|
184
|
+
}
|
|
185
|
+
function selectEntries(feed, opts) {
|
|
186
|
+
const { from, to, sort, order, limit } = opts;
|
|
187
|
+
let entries = feed.entries;
|
|
188
|
+
if (from !== void 0 || to !== void 0) {
|
|
189
|
+
entries = entries.filter((entry) => {
|
|
190
|
+
const t = entryTime2(entry);
|
|
191
|
+
if (Number.isNaN(t)) return false;
|
|
192
|
+
if (from !== void 0 && t < from) return false;
|
|
193
|
+
if (to !== void 0 && t > to) return false;
|
|
194
|
+
return true;
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
if (sort) {
|
|
198
|
+
const direction = (order ?? (sort === "date" ? "desc" : "asc")) === "asc" ? 1 : -1;
|
|
199
|
+
entries = [...entries].sort((a, b) => direction * compare(sort, a, b));
|
|
200
|
+
}
|
|
201
|
+
if (limit !== void 0 && limit >= 0) {
|
|
202
|
+
entries = entries.slice(0, limit);
|
|
203
|
+
}
|
|
204
|
+
return { ...feed, entries };
|
|
205
|
+
}
|
|
206
|
+
|
|
86
207
|
// src/serialize/atom.ts
|
|
87
208
|
var escapeText = (s) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
88
209
|
var escapeAttr = (s) => escapeText(s).replace(/"/g, """);
|
|
@@ -579,11 +700,18 @@ export {
|
|
|
579
700
|
MeshSchema,
|
|
580
701
|
MeshSourceSchema,
|
|
581
702
|
PersonSchema,
|
|
703
|
+
entryKey,
|
|
704
|
+
filterEntries,
|
|
582
705
|
fromNwf,
|
|
583
706
|
isFormat,
|
|
707
|
+
matchRule,
|
|
584
708
|
mergeFeeds,
|
|
709
|
+
newEntries,
|
|
710
|
+
parseDuration,
|
|
585
711
|
parseMesh,
|
|
586
712
|
parseNeurowireFeed,
|
|
713
|
+
resolveWindow,
|
|
714
|
+
selectEntries,
|
|
587
715
|
serialize,
|
|
588
716
|
toAtom,
|
|
589
717
|
toJsonFeed,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@neurowire/core",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Canonical feed model and serializers (Atom, JSON Feed 1.1, Markdown, nwf) for Neurowire.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -38,7 +38,7 @@
|
|
|
38
38
|
"url": "https://github.com/starside-io/neurowire/issues"
|
|
39
39
|
},
|
|
40
40
|
"engines": {
|
|
41
|
-
"node": ">=
|
|
41
|
+
"node": ">=24"
|
|
42
42
|
},
|
|
43
43
|
"publishConfig": {
|
|
44
44
|
"access": "public"
|