eyeling 1.26.7 → 1.27.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 (31) hide show
  1. package/HANDBOOK.md +32 -1
  2. package/dist/browser/eyeling.browser.js +396 -3
  3. package/examples/deck/act-barley-seed-lineage.md +1 -1
  4. package/examples/deck/faltings-genus2-finiteness.md +1 -1
  5. package/examples/deck/high-trust-rdf-bloom-envelope.md +1 -1
  6. package/examples/deck/odrl-dpv-risk-ranked.md +1 -1
  7. package/examples/deck/rdf-message-cold-chain-recall.md +71 -0
  8. package/examples/deck/rdf-message-flow.md +1 -1
  9. package/examples/deck/rdf-message-ldes-incremental.md +94 -0
  10. package/examples/deck/rdf-message-window-repair.md +1 -1
  11. package/examples/deck/schema-foaf-mapping.md +2 -0
  12. package/examples/input/rdf-message-cold-chain-recall.trig +668 -0
  13. package/examples/input/rdf-message-flow.trig +3 -0
  14. package/examples/input/rdf-message-ldes-incremental.trig +564 -0
  15. package/examples/input/rdf-message-microgrid.trig +3 -0
  16. package/examples/input/rdf-message-window-repair.trig +3 -0
  17. package/examples/input/rdf-messages.trig +3 -0
  18. package/examples/output/rdf-message-cold-chain-recall.md +13 -0
  19. package/examples/output/rdf-message-ldes-incremental.md +13 -0
  20. package/examples/rdf-message-cold-chain-recall.n3 +229 -0
  21. package/examples/rdf-message-flow.n3 +3 -0
  22. package/examples/rdf-message-ldes-incremental.n3 +231 -0
  23. package/examples/rdf-message-microgrid.n3 +3 -0
  24. package/examples/rdf-message-window-repair.n3 +3 -0
  25. package/examples/rdf-messages.n3 +3 -0
  26. package/eyeling.js +396 -3
  27. package/lib/cli.js +396 -3
  28. package/package.json +5 -3
  29. package/test/examples.test.js +25 -14
  30. package/test/fixtures/marc-rules-stream-messages.n3 +192 -0
  31. package/test/stream_messages.test.js +243 -0
@@ -0,0 +1,192 @@
1
+ # MARC extraction rules for the RDF Messages demo (see marc-sample.messages.nt).
2
+ #
3
+ # Each message is one bibliographic record shaped as a nested list of MARC
4
+ # fields: <record> :record (("245" "1" "0" "a" "Title…") ("650" …) …), where an
5
+ # inner list is (tag ind1 ind2 code value code value …). These backward helper
6
+ # rules (marc:field / marc:subf / marc:map / marc:join / marc:id) walk that
7
+ # structure to pull a subfield's value by (tag, subfield-code); the forward
8
+ # rules at the bottom emit :title / :subject / :type per record.
9
+ #
10
+
11
+ @prefix : <http://example.org/ns#> .
12
+ @prefix list: <http://www.w3.org/2000/10/swap/list#> .
13
+ @prefix log: <http://www.w3.org/2000/10/swap/log#> .
14
+ @prefix eymsg: <https://eyereasoner.github.io/eyeling/vocab/message#> .
15
+ @prefix string: <http://www.w3.org/2000/10/swap/string#> .
16
+ @prefix math: <http://www.w3.org/2000/10/swap/math#> .
17
+ @prefix marc: <https://codeberg.org/phochste/marcattacks#> .
18
+
19
+ ## ---- Helper functions (backward rules) ----
20
+
21
+ # marc:splice - drop the first ?Idx members of a list (here: the tag + indicators)
22
+ { (?List ?Idx) marc:splice ?Result }
23
+ <=
24
+ {
25
+ ( ?X {
26
+ ?List list:iterate (?Num ?X).
27
+ ?Num math:notLessThan ?Idx.
28
+ } ?Result ) log:collectAllIn _:x.
29
+ }.
30
+
31
+ # marc:join - join a list with a separator
32
+ { (?List ?Sep) marc:join ?Result }
33
+ <=
34
+ {
35
+ (?List ?Sep "") marc:join ?Result.
36
+ }.
37
+
38
+ { ( () ?Sep ?Acc ) marc:join ?Acc }
39
+ <= true.
40
+
41
+ { ( ?List ?Sep ?Acc ) marc:join ?Result }
42
+ <=
43
+ {
44
+ ?List list:firstRest (?H ?T).
45
+ ?Acc log:equalTo "".
46
+ ( ?T ?Sep ?H ) marc:join ?Result .
47
+ }.
48
+
49
+ { ( ?List ?Sep ?Acc ) marc:join ?Result }
50
+ <=
51
+ {
52
+ ?List list:firstRest (?H ?T).
53
+ ?Acc log:notEqualTo "".
54
+ ( ?Acc ?Sep ?H) string:concatenation ?AccNew.
55
+ ( ?T ?Sep ?AccNew ) marc:join ?Result .
56
+ }.
57
+
58
+ # marc:id - return the record id as an IRI (from the 001 control field)
59
+ { ?Record marc:id ?Result }
60
+ <=
61
+ {
62
+ (?Record "001") marc:field0 ?F001.
63
+ ?F001 marc:ctrl ?ID.
64
+ ( "http://lib.ugent.be/record/" ?ID ) string:concatenation ?IRI_ID.
65
+ ?Result log:uri ?IRI_ID.
66
+ }.
67
+
68
+ # marc:ctrl - return the control value of a field
69
+ { ?Field marc:ctrl ?Result }
70
+ <=
71
+ {
72
+ (?Field 3) list:memberAt "_" .
73
+ (?Field 4) list:memberAt ?Result.
74
+ }.
75
+
76
+ # marc:subf - return all values matching a subfield regex
77
+ { ( ?Field ?Regex) marc:subf ?Result }
78
+ <=
79
+ {
80
+ ( ?Field 3) marc:splice ?FieldData.
81
+ ( ?FieldData ?Regex ()) marc:subf ?Result.
82
+ } .
83
+
84
+ { ( () ?Regex ?Acc ) marc:subf ?Acc }
85
+ <= true.
86
+
87
+ { ( ?FieldData ?Regex ?Acc ) marc:subf ?Result }
88
+ <=
89
+ {
90
+ ?FieldData list:firstRest (?Subf ?Rest).
91
+ ?Rest list:firstRest (?Value ?Tail).
92
+ ?Subf string:matches ?Regex.
93
+ ( ?Acc (?Value)) list:append ?Acc2.
94
+ ( ?Tail ?Regex ?Acc2 ) marc:subf ?Result.
95
+ }.
96
+
97
+ { ( ?FieldData ?Regex ?Acc ) marc:subf ?Result }
98
+ <=
99
+ {
100
+ ?FieldData list:firstRest (?Subf ?Rest).
101
+ ?Rest list:firstRest (?Value ?Tail).
102
+ ?Subf string:notMatches ?Regex.
103
+ ( ?Tail ?Regex ?Acc ) marc:subf ?Result.
104
+ }.
105
+
106
+ # marc:field0 - collect the first row of a marc field
107
+ { ( ?Record ?Field) marc:field0 ?Result }
108
+ <=
109
+ {
110
+ ( ?Record ?Field) marc:field ?F.
111
+ ?F list:first ?Result.
112
+ }.
113
+
114
+ # marc:field - collect all data for a marc field
115
+ { ( ?Record ?Field) marc:field ?Result}
116
+ <=
117
+ {
118
+ ( ?Record ?Field ()) marc:field ?Result.
119
+ }.
120
+
121
+ { ( () ?Field ?Acc ) marc:field ?Acc}
122
+ <= true.
123
+
124
+ { ( ?L ?Field ?Acc ) marc:field ?Result }
125
+ <=
126
+ {
127
+ ?L list:firstRest (?H ?T).
128
+ ( ?H 0 ) list:memberAt ?Field.
129
+ ( ?Acc (?H) ) list:append ?AccNew.
130
+ (?T ?Field ?AccNew) marc:field ?Result.
131
+ }.
132
+
133
+ { (?L ?Field ?Acc) marc:field ?Result }
134
+ <=
135
+ {
136
+ ?L list:firstRest (?H ?T).
137
+ ( ?H 0 ) list:memberAt ?X.
138
+ ?Field log:notEqualTo ?X.
139
+ (?T ?Field ?Acc) marc:field ?Result.
140
+ }.
141
+
142
+ # marc:map - join all values of (tag, subfield) into one string
143
+ { ( ?Record ?Tag ?Subfield ) marc:map ?Result }
144
+ <=
145
+ {
146
+ (?Record ?Tag) marc:field ?FL.
147
+ ?FL list:member ?F.
148
+ (?F ?Subfield) marc:subf ?T .
149
+ (?T " ") marc:join ?Result.
150
+ }.
151
+
152
+ ## ---- Extraction rules (forward) ----
153
+
154
+ # In --stream-messages mode the current RDF Message payload is exposed as a
155
+ # quoted graph via eymsg:payloadGraph/log:nameOf. Match :record inside that
156
+ # payload graph, then reuse the ordinary MARC helper rules on the bound list.
157
+
158
+ {
159
+ ?Envelope eymsg:payloadGraph ?Payload.
160
+ ?Payload log:nameOf ?PayloadContext.
161
+ ?PayloadContext log:includes { ?X :record ?Record. }.
162
+ ?Record marc:id ?ID.
163
+ (?Record "245" "a") marc:map ?Val.
164
+ }
165
+ =>
166
+ {
167
+ ?ID :title ?Val.
168
+ }.
169
+
170
+ {
171
+ ?Envelope eymsg:payloadGraph ?Payload.
172
+ ?Payload log:nameOf ?PayloadContext.
173
+ ?PayloadContext log:includes { ?X :record ?Record. }.
174
+ ?Record marc:id ?ID.
175
+ (?Record "650" "a") marc:map ?Val.
176
+ }
177
+ =>
178
+ {
179
+ ?ID :subject ?Val.
180
+ }.
181
+
182
+ {
183
+ ?Envelope eymsg:payloadGraph ?Payload.
184
+ ?Payload log:nameOf ?PayloadContext.
185
+ ?PayloadContext log:includes { ?X :record ?Record. }.
186
+ ?Record marc:id ?ID.
187
+ (?Record "920" "a") marc:map ?Val.
188
+ }
189
+ =>
190
+ {
191
+ ?ID :type ?Val.
192
+ }.
@@ -0,0 +1,243 @@
1
+ 'use strict';
2
+
3
+ const assert = require('node:assert/strict');
4
+ const cp = require('node:child_process');
5
+ const fs = require('node:fs');
6
+ const os = require('node:os');
7
+ const path = require('node:path');
8
+
9
+ const root = path.resolve(__dirname, '..');
10
+ const eyelingJsPath = path.join(root, 'eyeling.js');
11
+
12
+ const TTY = process.stdout.isTTY;
13
+ const C = TTY
14
+ ? { g: '\x1b[32m', r: '\x1b[31m', y: '\x1b[33m', dim: '\x1b[2m', n: '\x1b[0m' }
15
+ : { g: '', r: '', y: '', dim: '', n: '' };
16
+
17
+ function ok(msg) {
18
+ console.log(`${C.g}OK ${C.n} ${msg}`);
19
+ }
20
+ function info(msg) {
21
+ console.log(`${C.y}==${C.n} ${msg}`);
22
+ }
23
+ function fail(msg) {
24
+ console.error(`${C.r}FAIL${C.n} ${msg}`);
25
+ }
26
+
27
+ function numberedName(index, name) {
28
+ return `${String(index + 1).padStart(3, '0')} ${name}`;
29
+ }
30
+
31
+ function msNow() {
32
+ return Date.now();
33
+ }
34
+
35
+ function mkTmpDir() {
36
+ return fs.mkdtempSync(path.join(os.tmpdir(), 'eyeling-stream-messages-'));
37
+ }
38
+
39
+ function rmrf(p) {
40
+ try {
41
+ fs.rmSync(p, { recursive: true, force: true });
42
+ } catch {}
43
+ }
44
+
45
+ function runEyeling(args, opts = {}) {
46
+ return cp.spawnSync(process.execPath, [eyelingJsPath, ...args], {
47
+ cwd: root,
48
+ encoding: 'utf8',
49
+ maxBuffer: opts.maxBuffer || 20 * 1024 * 1024,
50
+ });
51
+ }
52
+
53
+ function expectEyelingOk(args, opts = {}) {
54
+ const r = runEyeling(args, opts);
55
+ if (r.status === 0) return r.stdout;
56
+
57
+ throw new Error(
58
+ `eyeling failed with exit ${r.status}\n` +
59
+ `STDOUT:\n${r.stdout || ''}\n` +
60
+ `STDERR:\n${r.stderr || ''}`,
61
+ );
62
+ }
63
+
64
+ function writeScopedPayloadRules(file) {
65
+ fs.writeFileSync(
66
+ file,
67
+ `@prefix : <urn:test#>.\n` +
68
+ `@prefix eymsg: <https://eyereasoner.github.io/eyeling/vocab/message#>.\n` +
69
+ `@prefix log: <http://www.w3.org/2000/10/swap/log#>.\n` +
70
+ `{\n` +
71
+ ` ?Envelope eymsg:payloadGraph ?Payload.\n` +
72
+ ` ?Payload log:nameOf ?PayloadContext.\n` +
73
+ ` ?PayloadContext log:includes { ?Subject :line ?Line. }.\n` +
74
+ `} => {\n` +
75
+ ` ?Envelope log:outputString ?Line.\n` +
76
+ `}.\n`,
77
+ 'utf8',
78
+ );
79
+ }
80
+
81
+ function writeBasicMessageLog(file) {
82
+ fs.writeFileSync(
83
+ file,
84
+ `VERSION "1.2-messages"\n` +
85
+ `PREFIX : <urn:test#>\n` +
86
+ `\n` +
87
+ `:a :line "one\\n".\n` +
88
+ `MESSAGE\n` +
89
+ `\n` +
90
+ `:b :line "two\\n".\n` +
91
+ `MESSAGE\n` +
92
+ `# empty heartbeat\n` +
93
+ `MESSAGE\n` +
94
+ `:c :line "three\\n".\n`,
95
+ 'utf8',
96
+ );
97
+ }
98
+
99
+ function writeLargeMessageLog(file, count) {
100
+ let text = 'VERSION "1.2-messages"\nPREFIX : <urn:test#>\n';
101
+ for (let i = 1; i <= count; i += 1) {
102
+ text += `:m${i} :line "${i}\\n".\n`;
103
+ if (i < count) text += 'MESSAGE\n';
104
+ }
105
+ fs.writeFileSync(file, text, 'utf8');
106
+ }
107
+
108
+ function writeMarcMessageLog(file) {
109
+ fs.writeFileSync(
110
+ file,
111
+ `VERSION "1.2-messages"\n` +
112
+ `PREFIX : <http://example.org/ns#>\n` +
113
+ `\n` +
114
+ `:record1 :record (\n` +
115
+ ` ("001" "_" "_" "_" "42")\n` +
116
+ ` ("245" "1" "0" "a" "Streaming RDF Messages")\n` +
117
+ ` ("650" "_" "0" "a" "Semantic Web")\n` +
118
+ ` ("920" "_" "_" "a" "book")\n` +
119
+ `).\n` +
120
+ `MESSAGE\n` +
121
+ `:record2 :record (\n` +
122
+ ` ("001" "_" "_" "_" "43")\n` +
123
+ ` ("245" "1" "0" "a" "Incremental MARC Extraction")\n` +
124
+ ` ("650" "_" "0" "a" "Linked data")\n` +
125
+ ` ("920" "_" "_" "a" "article")\n` +
126
+ `).\n`,
127
+ 'utf8',
128
+ );
129
+ }
130
+
131
+ const cases = [
132
+ {
133
+ name: 'scoped payload rules run once per RDF Message',
134
+ run(tmp) {
135
+ const rules = path.join(tmp, 'rules.n3');
136
+ const log = path.join(tmp, 'messages.trig');
137
+ writeScopedPayloadRules(rules);
138
+ writeBasicMessageLog(log);
139
+
140
+ const out = expectEyelingOk(['-r', '--stream-messages', rules, log]);
141
+ assert.equal(out, 'one\ntwo\nthree\n');
142
+ },
143
+ },
144
+ {
145
+ name: 'empty heartbeat messages do not leak previous payloads',
146
+ run(tmp) {
147
+ const rules = path.join(tmp, 'rules.n3');
148
+ const log = path.join(tmp, 'messages.trig');
149
+ writeScopedPayloadRules(rules);
150
+ writeBasicMessageLog(log);
151
+
152
+ const out = expectEyelingOk(['-r', '--stream-messages', rules, log]);
153
+ assert.equal(out.trim().split('\n').length, 3);
154
+ assert.ok(!out.includes('one\none'));
155
+ },
156
+ },
157
+ {
158
+ name: 'large message logs stream without requiring one monolithic dataset',
159
+ run(tmp) {
160
+ const rules = path.join(tmp, 'rules.n3');
161
+ const log = path.join(tmp, 'large.trig');
162
+ writeScopedPayloadRules(rules);
163
+ writeLargeMessageLog(log, 1000);
164
+
165
+ const out = expectEyelingOk(['-r', '--stream-messages', rules, log]);
166
+ const lines = out.trim().split('\n');
167
+ assert.equal(lines.length, 1000);
168
+ assert.equal(new Set(lines).size, 1000);
169
+ assert.ok(lines.includes('1'));
170
+ assert.ok(lines.includes('999'));
171
+ assert.ok(lines.includes('1000'));
172
+ },
173
+ },
174
+ {
175
+ name: 'MARC extraction rules fire over each streamed payload graph',
176
+ run(tmp) {
177
+ const log = path.join(tmp, 'marc.messages.txt');
178
+ writeMarcMessageLog(log);
179
+
180
+ const fixture = path.join(root, 'test', 'fixtures', 'marc-rules-stream-messages.n3');
181
+ const out = expectEyelingOk(['-r', '--stream-messages', fixture, log]);
182
+ assert.deepEqual(out.trim().split('\n').sort(), [
183
+ '<http://lib.ugent.be/record/42> <http://example.org/ns#subject> "Semantic Web" .',
184
+ '<http://lib.ugent.be/record/42> <http://example.org/ns#title> "Streaming RDF Messages" .',
185
+ '<http://lib.ugent.be/record/42> <http://example.org/ns#type> "book" .',
186
+ '<http://lib.ugent.be/record/43> <http://example.org/ns#subject> "Linked data" .',
187
+ '<http://lib.ugent.be/record/43> <http://example.org/ns#title> "Incremental MARC Extraction" .',
188
+ '<http://lib.ugent.be/record/43> <http://example.org/ns#type> "article" .',
189
+ ]);
190
+ },
191
+ },
192
+ {
193
+ name: '--stream-messages requires RDF mode',
194
+ run(tmp) {
195
+ const rules = path.join(tmp, 'rules.n3');
196
+ const log = path.join(tmp, 'messages.trig');
197
+ writeScopedPayloadRules(rules);
198
+ writeBasicMessageLog(log);
199
+
200
+ const r = runEyeling(['--stream-messages', rules, log]);
201
+ assert.notEqual(r.status, 0);
202
+ assert.match(r.stderr, /requires -r\/--rdf/);
203
+ },
204
+ },
205
+ ];
206
+
207
+ (function main() {
208
+ const suiteStart = msNow();
209
+ info(`Running ${cases.length} stream-message tests`);
210
+
211
+ let passed = 0;
212
+ let failed = 0;
213
+
214
+ for (const [index, tc] of cases.entries()) {
215
+ const tmp = mkTmpDir();
216
+ const testName = numberedName(index, tc.name);
217
+ const start = msNow();
218
+
219
+ try {
220
+ tc.run(tmp);
221
+ ok(`${testName} ${C.dim}(${msNow() - start} ms)${C.n}`);
222
+ passed++;
223
+ } catch (e) {
224
+ fail(`${testName} ${C.dim}(${msNow() - start} ms)${C.n}`);
225
+ fail(e && e.stack ? e.stack : String(e));
226
+ failed++;
227
+ } finally {
228
+ rmrf(tmp);
229
+ }
230
+ }
231
+
232
+ console.log('');
233
+ const suiteMs = msNow() - suiteStart;
234
+ console.log(`${C.y}==${C.n} Total elapsed: ${suiteMs} ms (${(suiteMs / 1000).toFixed(2)} s)`);
235
+
236
+ if (failed === 0) {
237
+ ok(`All stream-message tests passed (${passed}/${cases.length})`);
238
+ process.exit(0);
239
+ }
240
+
241
+ fail(`Some stream-message tests failed (${passed}/${cases.length})`);
242
+ process.exit(1);
243
+ })();