@natlibfi/melinda-record-matching 5.0.4 → 5.0.5

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 (47) hide show
  1. package/.github/workflows/melinda-node-tests-and-publish.yml +4 -4
  2. package/dist/candidate-search/query-list/bib.js +84 -67
  3. package/dist/candidate-search/query-list/bib.js.map +3 -3
  4. package/dist/candidate-search/query-list/component.js +94 -28
  5. package/dist/candidate-search/query-list/component.js.map +3 -3
  6. package/dist/cli.js +12 -4
  7. package/dist/cli.js.map +2 -2
  8. package/dist/match-detection/features/bib/f773.js +82 -0
  9. package/dist/match-detection/features/bib/f773.js.map +7 -0
  10. package/dist/match-detection/features/bib/index.js +1 -1
  11. package/dist/match-detection/features/bib/index.js.map +2 -2
  12. package/dist/match-detection/features/bib/index.test.js +1 -1
  13. package/dist/match-detection/features/bib/index.test.js.map +2 -2
  14. package/dist/match-detection/features/bib/isbn.js +3 -13
  15. package/dist/match-detection/features/bib/isbn.js.map +2 -2
  16. package/dist/match-detection/features/bib/issn.js +2 -16
  17. package/dist/match-detection/features/bib/issn.js.map +2 -2
  18. package/dist/match-detection/features/bib/language.js +12 -22
  19. package/dist/match-detection/features/bib/language.js.map +2 -2
  20. package/dist/match-detection/features/bib/publication-time-allow-cons-years.js +6 -2
  21. package/dist/match-detection/features/bib/publication-time-allow-cons-years.js.map +2 -2
  22. package/dist/match-detection/features/bib/publication-time.js +62 -16
  23. package/dist/match-detection/features/bib/publication-time.js.map +2 -2
  24. package/dist/match-detection/features/bib/title.js +76 -26
  25. package/dist/match-detection/features/bib/title.js.map +3 -3
  26. package/dist/match-detection/index.js +8 -3
  27. package/dist/match-detection/index.js.map +2 -2
  28. package/dist/matching-utils.js +14 -0
  29. package/dist/matching-utils.js.map +2 -2
  30. package/package.json +8 -12
  31. package/src/candidate-search/query-list/bib.js +100 -77
  32. package/src/candidate-search/query-list/component.js +100 -23
  33. package/src/cli.js +8 -4
  34. package/src/match-detection/features/bib/f773.js +118 -0
  35. package/src/match-detection/features/bib/index.js +1 -1
  36. package/src/match-detection/features/bib/index.test.js +1 -1
  37. package/src/match-detection/features/bib/isbn.js +3 -17
  38. package/src/match-detection/features/bib/issn.js +3 -32
  39. package/src/match-detection/features/bib/language.js +15 -26
  40. package/src/match-detection/features/bib/publication-time-allow-cons-years.js +7 -4
  41. package/src/match-detection/features/bib/publication-time.js +76 -30
  42. package/src/match-detection/features/bib/title.js +112 -34
  43. package/src/match-detection/index.js +9 -3
  44. package/src/matching-utils.js +17 -0
  45. package/dist/match-detection/features/bib/title-version-original.js +0 -37
  46. package/dist/match-detection/features/bib/title-version-original.js.map +0 -7
  47. package/src/match-detection/features/bib/title-version-original.js +0 -52
package/package.json CHANGED
@@ -13,7 +13,7 @@
13
13
  "url": "https://github.com/NatLibFi/melinda-record-matching-js"
14
14
  },
15
15
  "license": "MIT",
16
- "version": "5.0.4",
16
+ "version": "5.0.5",
17
17
  "type": "module",
18
18
  "main": "./dist/index.js",
19
19
  "bin": "./dist/cli.js",
@@ -40,27 +40,23 @@
40
40
  },
41
41
  "dependencies": {
42
42
  "@natlibfi/marc-record": "^10.0.1",
43
- "@natlibfi/marc-record-serializers": "^11.0.1",
44
- "@natlibfi/marc-record-validators-melinda": "^12.0.7",
43
+ "@natlibfi/marc-record-serializers": "^11.0.2",
44
+ "@natlibfi/marc-record-validators-melinda": "^12.0.15",
45
45
  "@natlibfi/melinda-commons": "^14.0.2",
46
46
  "@natlibfi/sru-client": "^7.0.1",
47
47
  "debug": "^4.4.3",
48
48
  "isbn3": "^2.0.6",
49
49
  "moment": "^2.30.1",
50
50
  "natural": "^8.1.1",
51
- "uuid": "^13.0.0",
51
+ "uuid": "^14.0.0",
52
52
  "yargs": "^18.0.0"
53
53
  },
54
54
  "devDependencies": {
55
55
  "@natlibfi/fixugen": "^3.0.1",
56
- "@natlibfi/fixugen-http-client": "^4.0.2",
56
+ "@natlibfi/fixugen-http-client": "^4.0.3",
57
57
  "@natlibfi/fixura": "^4.0.1",
58
58
  "cross-env": "^10.1.0",
59
- "esbuild": "^0.27.3",
60
- "eslint": "^10.0.2"
61
- },
62
- "overrides": {
63
- "nanoid": "^3.3.8"
64
- },
65
- "nvolkComment": "cld3-asm 4.0.0 uses emscripten-wasm-loader ^3.0.3 which uses problematic, non-secure nanoid version 2.X.X."
59
+ "esbuild": "^0.28.0",
60
+ "eslint": "^10.2.1"
61
+ }
66
62
  }
@@ -1,9 +1,14 @@
1
1
  import createDebugLogger from 'debug';
2
2
  import {toQueries} from '../candidate-search-utils.js';
3
3
  import {getMelindaIdsF035, validateSidFieldSubfieldCounts, getSubfieldValues, testStringOrNumber} from '../../matching-utils.js';
4
+ import {extractHostMelindaIdsFromField, extractPublicationYearFrom773, getHostMelindaFields} from './component.js';
5
+ import {fieldToString} from '@natlibfi/marc-record-validators-melinda';
6
+ import {getTitle} from '../../match-detection/features/bib/title.js';
4
7
 
5
8
  const debug = createDebugLogger('@natlibfi/melinda-record-matching:candidate-search:query');
6
9
 
10
+ const IS_OK_ALONE_THRESHOLD = 5;
11
+
7
12
  export function bibSourceIds(record) {
8
13
 
9
14
  /* Melinda's SRU-index melinda.sourceid includes source IDs from SID fields in Melinda records
@@ -126,31 +131,27 @@ export function bibHostComponents(record) {
126
131
  const debug = createDebugLogger('@natlibfi/melinda-record-matching:candidate-search:query:bibHostComponents');
127
132
  const debugData = debug.extend('data');
128
133
  debug(`Creating queries for hostIds`);
134
+ const f773s = getHostMelindaFields(record);
135
+ if (f773s.length !== 1) { // This needs some thinking...
136
+ return [];
137
+ }
129
138
 
130
- const id = getHostId();
131
- debugData(`Found id: ${JSON.stringify(id)}`);
139
+ const [id] = extractHostMelindaIdsFromField(f773s[0]);
140
+ debug(`Found id: ${JSON.stringify(id)}`);
132
141
 
133
- return testStringOrNumber(id) ? [`melinda.partsofhost=${id}`] : [];
142
+ // NB! record.isCR() does not work if LDR/07=a, so we get year from 773$g!
143
+ const year = extractPublicationYearFrom773(f773s[0]);
144
+ debug(`${fieldToString(f773s[0])} => ${year ? year : 'N/A'}`);
145
+ if (year) {
146
+ return [`dc.date=${year} AND melinda.partsofhost=${id}`];
147
+ }
134
148
 
135
- function getHostId() {
136
- const [field] = record.get(/^773$/u);
149
+ return [`melinda.partsofhost=${id}`];
137
150
 
138
- if (field) {
139
- const {value} = field.subfields.find(({code}) => code === 'w') || {};
151
+ }
140
152
 
141
- if (testStringOrNumber(value) && (/^\(FI-MELINDA\)/u).test(String(value))) {
142
- return String(value).replace(/^\(FI-MELINDA\)/u, '');
143
- }
144
153
 
145
- if (testStringOrNumber(value) && (/^\(FIN01\)/u).test(String(value))) {
146
- return String(value).replace(/^\(FIN01\)/u, '');
147
- }
148
154
 
149
- return false;
150
- }
151
- return false;
152
- }
153
- }
154
155
 
155
156
  // SRU search dc.title with a search phrase starting with ^ maps currently in Melinda to (probably) to *headings* index TIT
156
157
  // - Aleph cannot currently handle headings searches starting with a boolean - in these cases use word search
@@ -184,43 +185,78 @@ export function bibTitleAuthorYearAlternates(record) {
184
185
  return {queryList: Array.from(origQueryList).reverse(), queryListType: 'alternates'};
185
186
  }
186
187
 
188
+ function getTitleForQuery(record) {
189
+ const title = getTitle(record, ['a']);
190
+ if (title) {
191
+ const nWords = title.split(' ');
192
+ // If lone $a is deemed too short, fetch $b as well:
193
+ // (NV: I've seen pairs with 245$a-only vs 245$a$b, so I'd like to use $a only if it is long enough)
194
+
195
+ if (nWords < 3 || title.length < 12) {
196
+ const [f245] = record.get('245');
197
+ // If punctuation is ' =' I think that f245$a is good enough despite shortness. Trying to balance between two bad situations...
198
+ // "Suo (= short title) siellä, vetelä (= missing $b name in another language) täällä..."
199
+ if (f245 && f245.subfields.find(sf => sf.code === 'a' && sf.value.match(/ =$/u)) && title.length >= IS_OK_ALONE_THRESHOLD) {
200
+ return title;
201
+ }
202
+ return getTitle(record, ['a', 'b']); // Try to get a longer title
203
+ }
204
+ }
205
+ return title;
206
+ }
207
+
208
+ function dcTitle(record, onlyTitleLength, alternates = false) {
209
+ const title = getTitleForQuery(record);
210
+ if (!testStringOrNumber(title)) {
211
+ return [];
212
+ }
213
+
214
+ const formatted = getFormattedTitle(title);
215
+
216
+ // use word search for titles starting with a boolean
217
+ const useWordSearch = checkUseWordSearch(formatted);
218
+ // Prevent too many matches / SRU crashing by having a minimum length
219
+ // Note that currently this fails matching if there are no matches from previous matchers
220
+ if (formatted.length >= onlyTitleLength && !alternates) {
221
+ return [`dc.title="${useWordSearch ? '' : '^'}${formatted}*"`, formatted, true];
222
+ }
223
+
224
+ const queryIsOkAlone = !useWordSearch && formatted.length >= IS_OK_ALONE_THRESHOLD;
225
+
226
+ // use word search without ending * also in combination searches to avoid SRU-server crashes [MRA-189]
227
+ return [`dc.title="${useWordSearch || !queryIsOkAlone ? '' : '^'}${formatted}${queryIsOkAlone ? '*' : ''}"`, formatted, queryIsOkAlone];
228
+
229
+ function getFormattedTitle(title) {
230
+ const formatted = String(title)
231
+ .replace(/[\-+ !"(){}\[\]<>;:.?/@*%=^_`~]/gu, ' ')
232
+ .replace(/[^\w\s\p{Alphabetic}]/gu, '') // Apparently matches to/works with non-aplhabetic scripts such as Chinese as well
233
+ .replace(/ +/gu, ' ') // Clean up concurrent spaces from eg. subfield changes
234
+ .trim()
235
+ .replace(/^(.{30}\S*) .*$/, "$1")
236
+ .trim();
237
+
238
+ return formatted;
239
+ }
240
+ }
241
+
187
242
  export function bibTitleAuthorPublisher({record, onlyTitleLength, addYear = false, alternates = false, alternateQueries = []}) {
188
243
  debug(`bibTitleAuthorPublisher, onlyTitleLength: ${onlyTitleLength}, addYear: ${addYear}, alternates: ${alternates}`);
189
- const title = getTitle();
190
- if (testStringOrNumber(title)) {
191
- const formatted = getFormatted(title);
244
+ const [query, formatted, queryIsOkAlone] = dcTitle(record, onlyTitleLength, alternates);
245
+ if (query === undefined) {
246
+ return [];
247
+ }
192
248
 
193
- // use word search for titles starting with a boolean
194
- const useWordSearch = checkUseWordSearch(formatted);
195
- // Prevent too many matches / SRU crashing by having a minimum length
196
- // Note that currently this fails matching if there are no matches from previous matchers
197
- if (formatted.length >= onlyTitleLength && !alternates) {
198
- return [`dc.title="${useWordSearch ? '' : '^'}${formatted}*"`];
199
- }
200
- const queryIsOkAlone = formatted.length >= 5;
201
-
202
- // use word search without ending * also in combination searches to avoid SRU-server crashes [MRA-189]
203
- const query = `dc.title="${useWordSearch || !queryIsOkAlone ? '' : '^'}${formatted}${queryIsOkAlone ? '*' : ''}"`;
204
- debug(`query: ${query}`);
205
- const newAlternateQueries = alternates ? [...alternateQueries, query] : alternateQueries;
206
-
207
- return addAuthorsToSearch({query, queryIsOkAlone, addYear, alternates, alternateQueries: newAlternateQueries});
208
-
209
- function getFormatted(title) {
210
- const formatted = String(title)
211
- .replace(/[\-+ !"(){}\[\]<>;:.?/@*%=^_`~]/gu, ' ')
212
- .replace(/[^\w\s\p{Alphabetic}]/gu, '')
213
- // Clean up concurrent spaces from fe. subfield changes
214
- .replace(/ +/gu, ' ')
215
- .trim()
216
- .replace(/^(.{30}\S*) .*$/, "$1")
217
- .trim();
249
+ debug(`query: ${query}`);
218
250
 
219
- return formatted;
220
- }
251
+ // Prevent too many matches / SRU crashing by having a minimum length
252
+ // Note that currently this fails matching if there are no matches from previous matchers
253
+ if (formatted.length >= onlyTitleLength && !alternates) {
254
+ return [query];
221
255
  }
222
256
 
223
- return [];
257
+ const newAlternateQueries = alternates ? [...alternateQueries, query] : alternateQueries;
258
+
259
+ return addAuthorsToSearch({query, queryIsOkAlone, addYear, alternates, alternateQueries: newAlternateQueries});
224
260
 
225
261
  function addAuthorsToSearch({query, queryIsOkAlone = false, addYear = false, alternates = false, alternateQueries = []}) {
226
262
  debug('addAuthorsToSearch');
@@ -254,8 +290,8 @@ export function bibTitleAuthorPublisher({record, onlyTitleLength, addYear = fals
254
290
  function addYearToSearch({query, queryIsOkAlone = false, alternates = false, alternateQueries = []}) {
255
291
  const [yearQuery] = bibYear(record);
256
292
  if (yearQuery !== undefined) {
257
- const newAlternateQueries = alternates ? [...alternateQueries, `${yearQuery} AND ${query}`] : alternateQueries;
258
- return alternates ? newAlternateQueries : [`${yearQuery} AND ${query}`];
293
+ const yearAndOtherQueries = `${yearQuery} AND ${query}`;
294
+ return alternates ? [...alternateQueries, yearAndOtherQueries] : [yearAndOtherQueries];
259
295
  }
260
296
  if (queryIsOkAlone) {
261
297
  return alternates ? alternateQueries : [`${query}`];
@@ -263,26 +299,6 @@ export function bibTitleAuthorPublisher({record, onlyTitleLength, addYear = fals
263
299
  return [];
264
300
  }
265
301
 
266
- function getTitle() {
267
- const [field] = record.get(/^245$/u);
268
-
269
- if (field) {
270
- const titleString = field.subfields
271
- //.filter(({code}) => ['a', 'b', 'n', 'p'].includes(code))
272
- .filter(({code}) => ['a', 'b'].includes(code))
273
- //.filter(({code}) => ['a'].includes(code))
274
- .map(({value}) => testStringOrNumber(value) ? String(value) : '')
275
- .filter(value => value)
276
- // In Melinda's index subfield separators are indexed as ' '
277
- .join(' ');
278
-
279
- if (/^[1-9]$/u.test(field.ind2)) { // Skip non-filing characters
280
- return titleString.slice(parseInt(field.ind2));
281
- }
282
- return titleString;
283
- }
284
- return false;
285
- }
286
302
  }
287
303
 
288
304
  export function bibAuthors(record) {
@@ -349,7 +365,10 @@ export function bibPublishers(record) {
349
365
  .slice(0, 30)
350
366
  .trim();
351
367
 
352
- debugData(`Publisher string: ${formatted}`);
368
+ debugData(`Publisher string: '${formatted}'`);
369
+ if (formatted.length === 0) {
370
+ return [];
371
+ }
353
372
  // use non-wildcard word search from dc.publisher
354
373
  return [`dc.publisher="${formatted}"`];
355
374
  }
@@ -358,7 +377,7 @@ export function bibPublishers(record) {
358
377
 
359
378
  function getPublisher(record) {
360
379
  //debugData(record);
361
- const [field] = record.get(/^(?:260)|(?:264)$/u);
380
+ const [field] = record.get(/^26[04]$/u).filter(f => f.subfields.some(sf => sf.code === 'b')); // Filter removes copyright-only fields
362
381
  //debugData(field);
363
382
 
364
383
  if (field) {
@@ -367,7 +386,8 @@ export function bibPublishers(record) {
367
386
  .map(({value}) => testStringOrNumber(value) ? String(value) : '')
368
387
  .filter(value => value)
369
388
  // In Melinda's index subfield separators are indexed as ' '
370
- .join(' ');
389
+ .join(' ')
390
+ .trim();
371
391
  return publisherString;
372
392
  }
373
393
  return false;
@@ -387,14 +407,17 @@ export function bibYear(record) {
387
407
 
388
408
  function getYear(record) {
389
409
  const [f008] = record.get(/^008$/u);
390
- if (f008 === undefined) {
391
- debug('f008 missing');
410
+ if (!f008 || !f008.value || f008.value.length < 11) {
411
+ debug('f008 missing/crappy');
392
412
  return false;
393
413
  }
394
414
 
395
415
  debugData(`f008: ${JSON.stringify(f008)}`);
396
416
  const {value} = f008;
397
- return testStringOrNumber(value) ? String(value).slice(7, 11) : undefined;
417
+ if (value === '||||' || value === ' ') { // Meaningless values
418
+ return false;
419
+ }
420
+ return value.slice(7, 11);
398
421
  }
399
422
  }
400
423
 
@@ -1,40 +1,117 @@
1
1
  import createDebugLogger from 'debug';
2
2
  import {promisify} from 'util';
3
3
  import {toQueries} from '../candidate-search-utils.js';
4
- import {getSubfieldValues, testStringOrNumber, toMelindaIds} from '../../matching-utils.js';
4
+ import {getSubfieldValues, stringAfter, stringBefore, testStringOrNumber, toMelindaIds} from '../../matching-utils.js';
5
+ import {fieldToString, uniqArray} from '@natlibfi/marc-record-validators-melinda/dist/utils.js';
5
6
 
6
7
  const setTimeoutPromise = promisify(setTimeout); // eslint-disable-line
7
8
 
8
- export function hostIdMelinda(record) {
9
- const debug = createDebugLogger('@natlibfi/melinda-record-matching:candidate-search:query:hostIdMelinda');
10
- const debugData = debug.extend('data'); // eslint-disable-line
11
- debug(`Creating query for the Melinda Id host`);
9
+ const debug = createDebugLogger('@natlibfi/melinda-record-matching:candidate-search:query:component');
10
+ const debugData = debug.extend('data'); // eslint-disable-line
11
+
12
12
 
13
- const ids = hostId(record);
14
- if (ids.length > 0) {
15
- return toQueries(ids, 'melinda.partsofhost');
13
+
14
+ export function parse773g(field) {
15
+ const g = field?.subfields?.find(sf => sf.code === 'g'); // NB! returns the first $g
16
+
17
+ if (!g) {
18
+ return {};
16
19
  }
17
20
 
18
- debug(`No valid Melinda Id host found`);
19
- return [];
21
+ const value = normalizeValue(g.value);
20
22
 
21
- function hostId(record) {
22
- // Multi 773 handling
23
- const f773s = record.get(/^773$/u)
24
- .filter(f773 => f773.subfields.some(sub => sub.code === 'w' && (/\(FI-MELINDA\).*/ui).test(sub.value)));
23
+ return {
24
+ // NB! We are not currently interested in volume (vuosikerta)
25
+ year: gToYear(value) || null,
26
+ number: gToNumber(value) || null,
27
+ pages: gToPages(value) || null // Might also be eg. Raita 5. However, returns only the number part
28
+ }
25
29
 
26
- if (f773s.length === 0) {
27
- return false;
30
+ function normalizeValue(value) {
31
+ return value.replace(/(?:\. -|\.)$/u, '');
32
+ }
33
+
34
+ function gToNumber(value) {
35
+ // Allow up to 3-digit numbers
36
+ const extract1 = value.replace(/^.*: ([1-9][0-9]?[0-9]), (?:p\.|page|pp\.|s\.|Seite|sida|sidor|sivu).*$/ui, '$1');
37
+ if (extract1 !== value) {
38
+ return extract1;
28
39
  }
40
+ return undefined;
41
+ }
29
42
 
30
- // Multi $w handling
31
- // $w (prefix)<id> handling
32
- // $w <id> & $w (prefix)<id> Match
33
- const melindaIds = f773s.map(f773 => toMelindaIds(f773, ['w'])).flat()
34
- .filter(value => testStringOrNumber(value)) // drop invalid values
35
- .filter((value, index, array) => array.indexOf(value) === index); // unique values;
36
- return melindaIds;
43
+ function gToPages(value) {
44
+ if (value.match(/^[0-9]+(?:-[0-9]+)?(?:, [0-9]+(?:-[0-9]+)?)*$/u)) {
45
+ return value;
46
+ }
47
+ const numberPartOnly = value.replace(/^.*(?:p\.|raidat|raita|s\.|Seite|sivut?|pages?) /ui, '');
48
+ if (numberPartOnly !== value) {
49
+ return gToPages(numberPartOnly);
50
+ }
51
+ return undefined;
52
+ }
53
+
54
+
55
+ function gToYear(value) {
56
+ debug(`EXTRACT YEAR FROM '${value}`);
57
+ // extract year from within parentheses: $g vsk (yyyy) AND $g (YYYY)
58
+ if (value.match(/^[1-9][0-9]?[0-9]? ?\((?:20[012][0-9]|19[0-9][0-9])\)/u) || value.match(/^\((?:20[012][0-9]|19[0-9][0-9])\)/u) ) {
59
+ return stringBefore(stringAfter(value, '('), ')');
60
+ }
61
+ // If volume is missing, the year often seems to come without them parentheses:
62
+ if (value.match(/^(?:20[012][0-9]|19[0-9][0-9]) :/u)) {
63
+ return stringBefore(value, ' ');
64
+ }
65
+ // Seen in SB $g (vsk)year joulukuu
66
+ if (value.match(/^\([1-9][0-9]?\) ?(?:20[012][0-9]|19[0-9][0-9])[^0-9]/)) {
67
+ return value.replace(/^\([0-9]+\) ?/u, '').replace(/[^0-9].*$/u, '');
68
+ }
69
+ // Some magazines use DD.MM.YYYY
70
+ if (value.match(/^[^0-9]*(?:[1-9]|[12][0-9]|30|31)\.(?:1[012]|[1-9])\. ?(?:20[012][0-9]|19[0-9][0-9])[^0-9]/u)) {
71
+ return value.replace(/^[^0-9]*[0-9]+\.[0-9]+\. ?/u, '').replace(/[^0-9].*$/u, '');
72
+ }
73
+
74
+ return undefined;
75
+ }
76
+ }
77
+
78
+ export function extractPublicationYearFrom773(field) {
79
+ const data = parse773g(field);
80
+ debug(`Extracted year ${data.year} from f773$g ${fieldToString(field)}`);
81
+ return data.year;
82
+ }
83
+
84
+ function isMelindaSubfieldW(subfield) {
85
+ if (subfield.code !== 'w') {
86
+ return false;
87
+ }
88
+ return (/^\((?:FI-MELINDA|FIN01)\)0[0-9]{8}$/ui).test(subfield.value);
89
+ }
90
+
91
+ function removeMelindaPrefixFromValue(value) {
92
+ return value.replace(/^(?:\(FI-MELINDA\)|\(FIN01\))/, '');
93
+ }
94
+
95
+ export function getHostMelindaFields(record) {
96
+ return record.get(/^773$/u).filter(f => f.subfields.some(sf => isMelindaSubfieldW(sf)));
97
+ }
98
+
99
+ export function extractHostMelindaIdsFromField(field) {
100
+ return field.subfields.filter(sf => isMelindaSubfieldW(sf)).map(sf => removeMelindaPrefixFromValue(sf.value));
101
+ }
102
+
103
+ export function getHostMelindaIds(record) {
104
+ return uniqArray(getHostMelindaFields(record).map(f => extractHostMelindaIdsFromField(f)).flat());
105
+ }
106
+
107
+ export function hostIdMelinda(record) { // old function with replaced code
108
+ debug(`Creating query for the Melinda Id host`);
109
+ const ids = getHostMelindaIds(record);
110
+ if (ids.length === 0) {
111
+ debug(`No valid Melinda Id host found`);
112
+ return [];
37
113
  }
114
+ return toQueries(getHostMelindaIds(record), 'melinda.partsofhost');
38
115
  }
39
116
 
40
117
  export async function hostIdOtherSource(record, client) {
package/src/cli.js CHANGED
@@ -76,7 +76,8 @@ async function cli() {
76
76
  if (['IDS'].includes(searchType)) {
77
77
  return [
78
78
  matchDetection.features.bib.melindaId(),
79
- matchDetection.features.bib.allSourceIds()
79
+ matchDetection.features.bib.allSourceIds(),
80
+ matchDetection.features.bib.f773() // Host data
80
81
  ];
81
82
  }
82
83
 
@@ -101,7 +102,8 @@ async function cli() {
101
102
  // normal publicationTime and a reprintPublication time
102
103
  matchDetection.features.bib.publicationTimeAllowConsYearsMulti(),
103
104
  matchDetection.features.bib.language(),
104
- matchDetection.features.bib.bibliographicLevel()
105
+ matchDetection.features.bib.bibliographicLevel(),
106
+ matchDetection.features.bib.f773() // Host data
105
107
  ];
106
108
  }
107
109
 
@@ -113,7 +115,8 @@ async function cli() {
113
115
  matchDetection.features.bib.title(),
114
116
  matchDetection.features.bib.language(),
115
117
  matchDetection.features.bib.authors(),
116
- matchDetection.features.bib.bibliographicLevel()
118
+ matchDetection.features.bib.bibliographicLevel(),
119
+ matchDetection.features.bib.f773() // Host data
117
120
  ];
118
121
  }
119
122
 
@@ -128,7 +131,8 @@ async function cli() {
128
131
  matchDetection.features.bib.recordType(),
129
132
  matchDetection.features.bib.publicationTime(),
130
133
  matchDetection.features.bib.language(),
131
- matchDetection.features.bib.bibliographicLevel()
134
+ matchDetection.features.bib.bibliographicLevel(),
135
+ matchDetection.features.bib.f773() // Host data
132
136
  ];
133
137
  }
134
138
 
@@ -0,0 +1,118 @@
1
+ // NB! This checks identifiers ($w=ID, $x=ISSN, $z=ISBN and $o=others) to check whether the host is the same.
2
+ // BB! $g is not checked *yet*. It could very strongly indicate that the records are the same... (I doubt $q could be useful here)
3
+ // Rationale:
4
+ // 773$w indicates sameness. However, non-Melinda records probably refer to non-melinda hosts.
5
+ // Thus check other identifiers subfields ($x, $z and $o as well)
6
+ //
7
+
8
+
9
+ import createDebugLogger from 'debug';
10
+
11
+ import {isComponentRecord} from '@natlibfi/melinda-commons';
12
+ import {uniqArray} from './issn.js';
13
+ import {parse773g} from '../../../candidate-search/query-list/component.js';
14
+
15
+ const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features:issn');
16
+ const debugData = debug.extend('data');
17
+
18
+ const MAX_IDENTIFIER = 0.1; // This should be pretty low: it only says something about the host
19
+ const MAX_G = 0.5; // $g is about the comp itself, so score can be high here
20
+
21
+ export default () => ({
22
+ name: 'f773 ',
23
+ extract: ({record/*, recordExternal*/}) => {
24
+ //const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';
25
+
26
+ if (!isComponentRecord(record, false, [])) {
27
+ return [];
28
+ }
29
+ const f773s = record.get(/^773$/u); // Identifier extract handles multiple 773...
30
+
31
+ // I think it's ok, if, say, $o and $x values match, so I'm not keeping the subfield codes
32
+ const values = f773s.map(f => f.subfields)
33
+ .flat()
34
+ .filter(sf => ['w', 'o', 'x', 'z'].includes(sf.code))
35
+ .map(sf => normalizeValue(sf.value));
36
+
37
+ // $g: only on $g is supported
38
+ const gData = parse773g(f773s[0]);
39
+
40
+ return [uniqArray(values), gData];
41
+
42
+ function normalizeValue(value) {
43
+ return value.replace(/(?:\. -|\.)$/u, '');
44
+ }
45
+
46
+
47
+ },
48
+
49
+ compare: (aa, bb) => {
50
+ const [aIdentifiers, ag] = aa;
51
+ const [bIdentifiers, bg] = bb;
52
+
53
+ const identifierScore = scoreIdentifiers();
54
+ const gScore = scoreG();
55
+
56
+ if (identifierScore === MAX_IDENTIFIER && gScore === MAX_G) { // Pretty impressive hit, even if title matches not
57
+ return 1.0;
58
+ }
59
+
60
+ return identifierScore + gScore;
61
+
62
+ function scoreG() {
63
+ // NB! $g contents are very noise, so wrong values may be extracted. Thus do not overpenalize.
64
+
65
+ // All exist match: things must be pretty good:
66
+ if (ag.number && ag.number === bg.number && ag.pages && ag.pages === bg.pages && ag.year && ag.year === bg.year) {
67
+ return MAX_G;
68
+ }
69
+ // Not comparing volume. It correlates with year.
70
+ return scoreYear() + scoreNumber() + scorePages();
71
+ }
72
+
73
+ function scoreNumber() {
74
+ if (!ag.number || !bg.number) {
75
+ return 0.0;
76
+ }
77
+ if (ag.number === bg.number) {
78
+ return 0.05;
79
+ }
80
+ return -0.02;
81
+ }
82
+
83
+ function scorePages() {
84
+ if (!ag.pages || !bg.pages) {
85
+ return 0.0;
86
+ }
87
+ if (ag.pages === bg.pages) { // If pages match, things must be pretty good
88
+ return 0.1;
89
+ }
90
+ return -0.05;
91
+ }
92
+
93
+ function scoreYear() { // publication-time.js also uses this, so don't score heavily here
94
+ if (!ag.year || !bg.year) {
95
+ return 0.0;
96
+ }
97
+ if (ag.year === bg.year) {
98
+ return 0.02;
99
+ }
100
+ return -0.02;
101
+ }
102
+
103
+ function scoreIdentifiers() {
104
+ debugData(`Comparing ISSN sets ${JSON.stringify(aIdentifiers)} and ${JSON.stringify(bIdentifiers)}`);
105
+ if (aIdentifiers.length === 0 || bIdentifiers.length === 0) {
106
+ // No data for decision
107
+ return 0;
108
+ }
109
+ const firstSharedIdentifier = aIdentifiers.find(val => bIdentifiers.includes(val));
110
+ if (firstSharedIdentifier) {
111
+ debug(`\t Shared identifier found: '${firstSharedIdentifier}'`);
112
+ return MAX_IDENTIFIER;
113
+ }
114
+ return -0.5;
115
+ }
116
+ }
117
+ });
118
+
@@ -4,7 +4,6 @@ export {default as isbn} from './isbn.js';
4
4
  export {default as issn} from './issn.js';
5
5
  export {default as otherStandardIdentifier} from './other-standard-identifier.js';
6
6
  export {default as title} from './title.js';
7
- export {default as titleVersionOriginal} from './title-version-original.js';
8
7
  export {default as authors} from './authors.js';
9
8
  export {default as recordType} from './record-type.js';
10
9
  export {default as publicationTime} from './publication-time.js';
@@ -15,3 +14,4 @@ export {default as bibliographicLevel} from './bibliographic-level.js';
15
14
  export {default as melindaId} from './melinda-id.js';
16
15
  export {default as allSourceIds} from './all-source-ids.js';
17
16
  export {default as mediaType} from './media-type.js';
17
+ export {default as f773} from './f773.js';
@@ -42,7 +42,7 @@ describe('match-detection/features/bib/', () => {
42
42
  const {featuresA, featuresB, expectedPoints} = expectations;
43
43
  const {compare} = features[feature](options);
44
44
 
45
- assert.equal(compare(featuresA, featuresB), expectedPoints);
45
+ assert.equal(Math.round(compare(featuresA, featuresB) *100)/100, expectedPoints);
46
46
  return;
47
47
  }
48
48
 
@@ -3,8 +3,6 @@ import createDebugLogger from 'debug';
3
3
  import {parse as isbnParse} from 'isbn3';
4
4
 
5
5
  import {uniqArray} from './issn.js';
6
- import {isComponentRecord} from '@natlibfi/melinda-commons';
7
-
8
6
 
9
7
  const debug = createDebugLogger(`@natlibfi/melinda-record-matching:match-detection:features:standard-identifiers:ISBN`);
10
8
  const debugData = debug.extend('data');
@@ -16,12 +14,7 @@ export default () => ({
16
14
  extract: ({record/*, recordExternal*/}) => {
17
15
  //const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';
18
16
 
19
- const host = !isComponentRecord(record, false, []);
20
-
21
- if (host) {
22
- return record.get('020').filter(f => f.subfields?.some(sf => ['a', 'z'].includes(sf.code) && sf.value));
23
- }
24
- return record.get('773').filter(f => f.subfields?.some(sf => ['z'].includes(sf.code) && sf.value));
17
+ return record.get('020').filter(f => f.subfields?.some(sf => ['a', 'z'].includes(sf.code) && sf.value));
25
18
  },
26
19
  // eslint-disable-next-line max-statements
27
20
  compare: (aa, bb) => {
@@ -31,7 +24,8 @@ export default () => ({
31
24
  return 0;
32
25
  }
33
26
 
34
- const [subfieldCodeForGoodValues, subfieldCodeForBadValues] = getSubfieldCodes(aa[0].tag);
27
+ const subfieldCodeForGoodValues = 'a';
28
+ const subfieldCodeForBadValues = 'z';
35
29
 
36
30
  const [aValidValuesA, aInvalidValuesA, zValidValuesA, zInvalidValuesA] = getValuesWrapper(aa, 'AA'); // initial 'a' and 'z' refer to 020 subfields codes
37
31
  const [aValidValuesB, aInvalidValuesB, zValidValuesB, zInvalidValuesB] = getValuesWrapper(bb, 'BB');
@@ -124,14 +118,6 @@ export default () => ({
124
118
  // We have values but they disagree:
125
119
  return -0.75; // Has good ISBNs on both records, but they did not match
126
120
 
127
-
128
- function getSubfieldCodes(tag) {
129
- if (tag === '773') {
130
- return ['z', undefined];
131
- }
132
- return ['a', 'z'];
133
- }
134
-
135
121
  function getValues(fields) {
136
122
  // Valid values are normalized to their isbn-13 form. Invalid values get their '-'s removed.
137
123
  const goodValues = fields.flatMap(f => f.subfields.filter(sf => sf.code === subfieldCodeForGoodValues)).map(sf => validatorAndNormalizer(sf.value));