@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
@@ -1,18 +1,5 @@
1
- /*
2
- //import createInterface from './standard-identifier-factory.js';
3
-
4
-
5
- export default () => {
6
- const {extract, compare} = createInterface({pattern: /^022$/u, subfieldCodes: ['a', 'z', 'y']});
7
- return {extract, compare, name: 'ISSN'};
8
- };
9
- */
10
-
11
-
12
1
  import createDebugLogger from 'debug';
13
2
 
14
- import {isComponentRecord} from '@natlibfi/melinda-commons';
15
-
16
3
  const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features:issn');
17
4
  const debugData = debug.extend('data');
18
5
 
@@ -21,18 +8,16 @@ export default () => ({
21
8
  extract: ({record/*, recordExternal*/}) => {
22
9
  //const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';
23
10
 
24
- const isHost = !isComponentRecord(record, false, []);
25
-
26
- // debug(`\t Is HOST: '${isHost}'`);
27
11
  return getIssns();
28
12
 
29
13
  function getIssns() {
30
- const fields = getRelevantFields();
14
+ const fields = record.get(/^022$/u);
31
15
  if (fields.length === 0) {
32
16
  return [];
33
17
  }
18
+
34
19
  debug(`\t ${fields.length} potential ISSN fields (${fields[0].tag})`);
35
- const subfieldCodes = getRelevantSubfieldCodes();
20
+ const subfieldCodes = ['a', 'y', 'z']
36
21
  //debug(`\t subfield codes: '${subfieldCodes.join("', '")}'`);
37
22
  const subfieldValues = fields.flatMap(f => f.subfields.filter(sf => subfieldCodes.includes(sf.code)).map(sf => sf.value));
38
23
  //debug(`\t cand values: '${subfieldValues.join("', '")}'`);
@@ -44,13 +29,6 @@ export default () => ({
44
29
  return uniqArray(validSubfieldValues);
45
30
  }
46
31
 
47
- function getRelevantFields() {
48
- if (isHost) {
49
- return record.get(/^022$/u);
50
- }
51
- return record.get(/^[79]73$/u);
52
- }
53
-
54
32
  function normalizeSubfieldValue(val) {
55
33
  return val.replace(/[., :;].*$/u, '');
56
34
  }
@@ -58,13 +36,6 @@ export default () => ({
58
36
  function isValidIssn(val) {
59
37
  return /^[0-9][0-9][0-9][0-9]-[0-9][0-9][0-9][0-9X]$/u.test(val);
60
38
  }
61
-
62
- function getRelevantSubfieldCodes() {
63
- if (isHost) {
64
- return ['a', 'y', 'z'];
65
- }
66
- return ['x'];
67
- }
68
39
  },
69
40
  // eslint-disable-next-line max-statements
70
41
  compare: (aa, bb) => {
@@ -31,10 +31,11 @@ export default () => ({
31
31
 
32
32
  FixSami041().fix(clonedRecord); // Handle 'smi' adding if needed
33
33
  Remove041zxx().fix(clonedRecord); // Remove 'zxx' from f041s
34
+ clonedRecord.fields = clonedRecord.fields.filter(f => ['008', '041'].includes(f.tag));
34
35
  // Add other language code normalizations?
35
36
 
36
37
  // Should we return all the fields, or just the relevant fields?
37
- return [{leader: clonedRecord.leader, fields: clonedRecord.fields}, label];
38
+ return [clonedRecord.fields, label];
38
39
  },
39
40
  // eslint-disable-next-line max-statements
40
41
  compare: (aa, bb) => {
@@ -50,9 +51,6 @@ export default () => ({
50
51
  if (score > 0.1) { // Keeps the original max, we might have 0.10 (041) + 0.05 (008/35-37) = 0.15 now
51
52
  return 0.1;
52
53
  }
53
- if (score < -1) {
54
- return -1.0;
55
- }
56
54
  return score;
57
55
  }
58
56
 
@@ -73,7 +71,7 @@ export default () => ({
73
71
  return 0.05;
74
72
  }
75
73
 
76
- if (a008 === 'mul' || b008 === 'mul') {
74
+ if (a008 === 'mul' || b008 === 'mul') { // We'll let 041 decide
77
75
  return 0.0;
78
76
  }
79
77
 
@@ -87,6 +85,7 @@ export default () => ({
87
85
  function compare041() {
88
86
  const a041s = getFields041(a);
89
87
  const b041s = getFields041(b);
88
+
90
89
  if (a041s.length === 0 || b041s.length === 0) {
91
90
  return 0.0; // Should we punish these for badness?
92
91
  }
@@ -97,14 +96,14 @@ export default () => ({
97
96
  const scoreInd1 = mainPenalty === 0.0 ? 0.0 : indicator1Penalty(a041s[0], b041s[0]);
98
97
  return mainPenalty + sourcePenalty + scoreInd1;
99
98
  }
100
- // Things are already complicated (likely to pe penalized, so no nedd to worrty about language code sources, I daresay.
99
+ // Things are already complicated (likely to pe penalized, so no nedd to worry about language code sources, I daresay.
101
100
  return compareLanguageCodeLists(getLanguageCodesFrom041Fields(a), getLanguageCodesFrom041Fields(b));
102
101
 
103
- function getFields041(record) {
104
- if (!record.fields) {
102
+ function getFields041(fields) {
103
+ if (!fields) {
105
104
  return [];
106
105
  }
107
- return record.fields.filter(f => f.tag === '041');
106
+ return fields.filter(f => f.tag === '041');
108
107
  }
109
108
 
110
109
  }
@@ -139,7 +138,7 @@ export default () => ({
139
138
  }
140
139
 
141
140
  function compareLanguageCodeLists(a, b) {
142
- if (a.length === 0 || b.length === 0) {
141
+ if (a.length === 0 || b.length === 0 || a.every(val => containsNoData(val)) || b.every(val => containsNoData(val))) {
143
142
  debugData(`No language to compare`);
144
143
  return 0;
145
144
  }
@@ -176,19 +175,9 @@ export default () => ({
176
175
 
177
176
 
178
177
  // Damage control:
179
-
180
- // Not using the generic solution here as eg. 'und' needs a special treatment
181
- const sharedValues = getSharedValues(a, b);
182
- const aOnly = a.filter(val => !sharedValues.includes(val));
183
- const bOnly = b.filter(val => !sharedValues.includes(val));
184
- const hasUnd = [...aOnly, ...bOnly].includes('und');
178
+ const sharedValues = getSharedValues(a, b).filter(val => !containsNoData(val));
185
179
 
186
180
  if (sharedValues.length < 1) {
187
- console.info(`NV: ${sharedValues.join(", ")}`);
188
- if (aOnly.length === 1 && bOnly.length === 1 && hasUnd) {
189
- debug(`Both have languages, but none of these match. However, the benefit of doubt is given: '${aOnly[0]}' and '${bOnly[0]}' might mean the same}`);
190
- return 0;
191
- }
192
181
  debug(`Both have languages, but none of these match.`); // includes 'mul' vs a single language code
193
182
  return -1.0;
194
183
  }
@@ -218,11 +207,11 @@ export default () => ({
218
207
 
219
208
  });
220
209
 
221
- function get008Value(record, label = 'record') {
222
- if (!record || !record.fields) {
210
+ function get008Value(fields, label = 'record') {
211
+ if (!fields) {
223
212
  return;
224
213
  }
225
- const f008 = record.fields.find(f => f.tag === '008');
214
+ const f008 = fields.find(f => f.tag === '008');
226
215
  if (!f008) {
227
216
  return undefined;
228
217
  }
@@ -269,12 +258,12 @@ function isLangCodeForALanguage(code, label = 'record', encoding = undefined) {
269
258
 
270
259
 
271
260
 
272
- function getLanguageCodesFrom041Fields(record) {
261
+ function getLanguageCodesFrom041Fields(fields) {
273
262
  // NB! We brutally don't check $2 (language code source) as marc's language codes is practically a subset of ISO 639-2,
274
263
  // and also ISO 639-2 and ISO 639-3 overlap to a degree. If we ever run into a trouble with some ISO 639-2 vs 639-3 mismatch, then we'll work it out.
275
264
  // Also we could write a validator that converts ISO 639-2 to marc if applicable and maybe even partial support for ISO-639-3.
276
265
  // Note that ISO 639-2-B values correspond with marc beteer that ISO 639-2-T. Eg. code for Chinese 'zho' should/code be normalized to 'chi'!
277
- const values = record.fields.filter(f => f.tag === '041').flatMap(f => getFieldsLanguageCodes(f));
266
+ const values = fields.filter(f => f.tag === '041').flatMap(f => getFieldsLanguageCodes(f));
278
267
  /*
279
268
  // .filter(({ind2}) => ind2 === ' ')
280
269
  .map(({subfields}) => subfields)
@@ -1,6 +1,7 @@
1
1
 
2
2
  import createDebugLogger from 'debug';
3
3
  import {testStringOrNumber} from '../../../matching-utils.js';
4
+ import {matchingYears} from './publication-time.js';
4
5
 
5
6
  // We should also get copyright time and copyright/publication times from 26x
6
7
 
@@ -16,14 +17,16 @@ export default () => ({
16
17
 
17
18
  const [firstA] = a;
18
19
  const [firstB] = b;
20
+ // Sanity check: If either of years is a non string/number, values are not comparable
21
+ if (!testStringOrNumber(firstA) || !testStringOrNumber(firstB)) {
22
+ return 0.0;
23
+ }
19
24
 
20
25
  if (firstA === firstB) {
21
26
  return 0.1;
22
27
  }
23
-
24
- // If either of years is a non string/number, values are not comparable
25
- if (!testStringOrNumber(firstA) || !testStringOrNumber(firstB)) {
26
- return 0;
28
+ if (matchingYears(firstA, firstB)) { // Handle 'u' Eg. '18uu' vs '1812'
29
+ return 0.05;
27
30
  }
28
31
 
29
32
  const firstANumber = parseInt(firstA, 10);
@@ -1,18 +1,23 @@
1
+ // We should also get copyright time and copyright/publication times from 26x
2
+ // see publication-time-allow-cons-years for a version allowing consequent years to match
1
3
 
4
+ import {extractPublicationYearFrom773} from '../../../candidate-search/query-list/component.js';
5
+
6
+ const MAX = 0.1;
7
+ const MIN = -1.0;
2
8
 
3
- import {testStringOrNumber} from '../../../matching-utils.js';
4
9
 
5
- // We should also get copyright time and copyright/publication times from 26x
6
- // see publication-time-allow-cons-years for a version allowing consequent years to match
7
10
 
8
11
  export default () => ({
9
12
  name: 'Publication time',
10
13
  extract: ({record}) => {
11
- return getDateData();
12
- //const value = record.get(/^008$/u)?.[0]?.value || undefined;
13
- //return testStringOrNumber(value) ? [String(value).slice(6, 15)] : [];
14
+ let dateData = getDataFrom008();
15
+ const [f773] = record.get(/^773$/u);
16
+ const hostYear = f773 && extractPublicationYearFrom773(f773) || null;
17
+ dateData.hostYear = hostYear;
18
+ return dateData;
14
19
 
15
- function getDateData() {
20
+ function getDataFrom008() {
16
21
  const [f008] = record.get(/^008$/u);
17
22
  if (!f008 || f008.value.length < 16) {
18
23
  return splitDateData('| ');
@@ -29,50 +34,91 @@ export default () => ({
29
34
  },
30
35
 
31
36
  compare: (aa, bb) => {
32
- if (aa.typeOfDate === 'b') { // Berfore Christ. No really makes sense in our domain, though.
37
+ // Be happy with a f773$g match:
38
+ if (aa.hostYear && bb.hostYear && aa.hostYear === bb.hostYear) {
39
+ return MAX;
40
+ }
41
+ // Check 008
42
+ if (aa.typeOfDate === 'b') { // 008/06: Before Christ. No really makes sense in our domain, though.
33
43
  if (bb.typeOfDate === 'b') {
34
44
  return 0;
35
45
  }
36
- return -1.0;
46
+ return MIN;
37
47
  }
38
48
  if (aa.typeOfDate === 'n' || bb.typeOfDate === 'n') { // n=unknown
39
49
  return 0;
40
50
  }
41
51
 
52
+ // Try to handle questionable dates:
53
+ if (aa.typeOfDate === 'q') { // questionable data
54
+ if (bb.typeOfDate !== 'q') {
55
+ if (yearInBetween(aa.date1, bb.date1, aa.date2)) {
56
+ return MAX;
57
+ }
58
+ return MIN;
59
+ }
60
+
61
+ // What if there are questionable dates on both sides?
62
+ if (aa.date1 === bb.date1 && bb.date1 === bb.date2) {
63
+ return MAX;
64
+ }
65
+ // Lazily return 0. (We could check for overlap etc. but not really worth the effort)
66
+ return 0.0;
67
+ }
68
+ if (bb.typeOfDate === 'q') {
69
+ if (yearInBetween(bb.date1, aa.date1, bb.date2)) {
70
+ return MAX;
71
+ }
72
+ return MIN;
73
+ }
74
+
42
75
  const skipList = [' ', '||||', 'uuuu'];
43
76
  if (skipList.includes(aa.date1) || skipList.includes(bb.date1)) { // 008/07-10 carries no information
44
77
  return 0;
45
78
  }
46
79
 
47
80
  if (matchingYears(aa.date1, bb.date1)) { // 'u' support
48
- return 0.1;
81
+ return MAX;
49
82
  }
50
83
 
51
- // TODO: add $q support here
52
-
53
- return -1.0;
54
-
55
- function matchingYears(yyyy1, yyyy2) {
56
- if (yyyy1.length === 0) { // All digits have been succesfully consumed -> success
57
- return true;
58
- }
59
- if (yyyy1[0] === 'u' || yyyy2[0] === 'u') { // Ignore 'u' (it refers to unknown millenia, century, decade or year)
60
- return matchingYears(yyyy1.slice(1), yyyy2.slice(1));
61
- }
62
- if (yyyy1[0] !== yyyy2[0]) {
63
- return false;
84
+ if (isValidYear(aa.date1) && bb.date1) {
85
+ const difference = Math.abs(aa.date1 - bb.date1);
86
+ if (difference === 1) {
87
+ return MIN/8;
64
88
  }
65
- // Should we require that yyyy[0] is a digit at this point?
66
- return matchingYears(yyyy1.slice(1), yyyy2.slice(1));
67
89
  }
68
90
 
69
- /*
70
- function hasFourDigits(yyyy) { // Will be needed by 'q' support
71
- return yyyy.match(/^[0-9]{4}$/u);
72
- }
73
- */
91
+ return MIN;
74
92
  }
75
93
 
76
94
  });
77
95
 
96
+ function yearInBetween(start, curr, end) {
97
+ if (!isValidYear(start) || !isValidYear(curr) || !isValidYear(end)) {
98
+ return false;
99
+ }
100
+ return start <= curr && curr <= end;
101
+ }
102
+
103
+ export function matchingYears(yyyy1, yyyy2) {
104
+ if (yyyy1.length === 0) { // All digits have been succesfully consumed -> success
105
+ return true;
106
+ }
107
+ if (yyyy1[0] === 'u' || yyyy2[0] === 'u') { // Ignore 'u' (it refers to unknown millenia, century, decade or year)
108
+ return matchingYears(yyyy1.slice(1), yyyy2.slice(1));
109
+ }
110
+ if (yyyy1[0] !== yyyy2[0]) {
111
+ return false;
112
+ }
113
+ if (yyyy1[0] >= '0' && yyyy1[0] <= '9') { // Require that yyyy[0] is a digit at this point? (What if year is 983?)
114
+ return matchingYears(yyyy1.slice(1), yyyy2.slice(1));
115
+ }
116
+ return false;
117
+ }
118
+
119
+
120
+ const validYearRegexp = /^(?:1[89][0-9][0-9]|20[012][0-9])$/u;
78
121
 
122
+ function isValidYear(yyyy) {
123
+ return validYearRegexp.test(yyyy); // Currently supports 1800-2029
124
+ }
@@ -3,19 +3,28 @@ import naturalPkg from 'natural';
3
3
  const {LevenshteinDistance: leven} = naturalPkg;
4
4
  import {testStringOrNumber} from '../../../matching-utils.js';
5
5
 
6
- const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features:title');
7
- const debugData = debug.extend('data');
8
6
 
7
+ const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features/bib/title');
8
+ const debugData = debug.extend('data');
9
9
 
10
- export default ({threshold = 10} = {}) => ({
10
+ export default ({threshold = 0.9} = {}) => ({
11
11
  name: 'Title',
12
12
  extract: ({record, recordExternal}) => {
13
13
  const label = recordExternal && recordExternal.label ? recordExternal.label : 'record';
14
- const title = getTitle();
15
- debug(`${label}: title: ${title}`);
14
+ const a = getTitle(record, ['a']);
15
+ debug(`${label}: title: ${a}`);
16
+
17
+ if (a) {
18
+ const b = normalizeTitle(getTitle(record, ['b'])) || '';
19
+ const n = normalizeTitle(getTitle(record, ['n'])) || '';
20
+ const p = normalizeTitle(getTitle(record, ['p'])) || '';
21
+ return [normalizeTitle(a), b, n, p];
22
+ }
23
+
24
+ return ['', '', '', ''];
16
25
 
17
- if (testStringOrNumber(title)) {
18
- const titleAsNormalizedString = String(title)
26
+ function normalizeTitle(title) {
27
+ return title
19
28
  // decompose unicode diacritics
20
29
  .normalize('NFD')
21
30
  // strip non-letters/numbers
@@ -24,48 +33,117 @@ export default ({threshold = 10} = {}) => ({
24
33
  // - see validator normalize-utf8-diacritics for details
25
34
  .replace(/[^\p{Letter}\p{Number}]/gu, '')
26
35
  .toLowerCase();
27
- debug(`${label}: titleString: ${titleAsNormalizedString}`);
28
- return [titleAsNormalizedString];
29
36
  }
30
37
 
31
- return [];
32
-
33
- function getTitle() {
34
- const [field] = record.get(/^245$/u);
35
- debugData(`${label}: titleField: ${JSON.stringify(field)}`);
36
-
37
- if (field) {
38
- return field.subfields
39
- // get also $n:s and $p:s here
40
- .filter(({code}) => ['a', 'b', 'n', 'p'].includes(code))
41
- .map(({value}) => testStringOrNumber(value) ? String(value) : '')
42
- .join('');
43
- }
44
- return false;
45
- }
46
38
  },
47
39
  compare: (a, b) => {
48
- const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features/bib/title');
49
- const distance = leven(a[0], b[0]);
40
+ const [aa, ab, an, ap] = a;
41
+ const [ba, bb, bn, bp] = b;
42
+
43
+ if (isEmpty(aa) || isEmpty(ba)) {
44
+ return -1.0;
45
+ }
46
+ // F245$n information is critical; it can not mismatch at all:
47
+ if (an.length && bn.length && an !== bn) { // If these exists, they must be the same (we might convert Roman numbers to Arabic numbers though)
48
+ return -1.0;
49
+ }
50
+
51
+ const aFull = toFullTitle(a);
52
+ const bFull = toFullTitle(b);
53
+
54
+ const [distance, maxLength, correctness] = doLevenshtein(aFull, bFull);
55
+
56
+ debug(`'${aFull}' vs '${bFull}': Max length = ${maxLength}, distance = ${distance}, correctness = ${correctness}`);
50
57
 
51
58
  if (distance === 0) {
52
59
  return 0.5;
53
60
  }
54
61
 
55
- const maxLength = getMaxLength();
56
- const percentage = distance / maxLength * 100;
62
+ if (correctness >= threshold) {
63
+ return 0.4;
64
+ }
65
+
66
+ if (an && bn) {
67
+ // There seems to be some wobble between $b and $p, for example:
68
+ if (ab && isEmpty(ap) && bp && isEmpty(bb)) {
69
+ return compare([aa, ap, an, ab], [ba, bb, bn, bp]);
70
+ }
71
+ if (ap && isEmpty(ab) && bb && isEmpty(bp)) {
72
+ return compare([aa, ab, an, ap], [ba, bp, bn, bb]);
73
+ }
74
+ }
75
+
76
+ // Try the same without $p:
77
+ if (localXor(ap, bp)) {
78
+ const result = compare([aa, ab, an, ''], [ba, bb, bn, '']);
79
+ return result > 0.0 ? result * 0.8 : result;
80
+ }
81
+
82
+ if (isEmpty(ap) && isEmpty(bp) && localXor(ab, bb)) {
83
+ // Try the same without $b ($p is not here)
84
+ const result = compare([aa, '', an, ''], [ba, '', bn, '']);
85
+ return result > 0.0 ? result * 0.8 : result;
86
+ }
87
+
88
+ return -0.5; // Not likely
89
+
90
+ function isEmpty(x) {
91
+ return !x || x.length === 0;
92
+ }
57
93
 
58
- debug(`'${a}' vs '${b}': Max length = ${maxLength}, distance = ${distance}, percentage = ${percentage}`);
94
+ function localXor(x, y) {
95
+ if (isEmpty(x)) {
96
+ return !isEmpty(y);
97
+ }
98
+ // 'x' exists, thus 'y' can not exist:
99
+ return isEmpty(y);
100
+ }
59
101
 
60
- if (percentage <= threshold) {
61
- return 0.3;
102
+ function doLevenshtein(string1, string2) {
103
+ const distance = leven(string1, string2);
104
+ const len = getMaxLength(string1, string2);
105
+ const correctness = 1.0 - (distance / len);
106
+ return [distance, len, correctness];
62
107
  }
63
108
 
64
- return -0.5;
109
+ function toFullTitle(arr) {
110
+ const relevant = arr.filter(val => typeof val === 'string' && val.length);
111
+ return relevant.join(' ');
112
+ }
65
113
 
66
- function getMaxLength() {
67
- return a[0].length > b[0].length ? a[0].length : b[0].length;
114
+ function getMaxLength(str1, str2) {
115
+ return str1.length > str2.length ? str1.length : str2.length;
68
116
  }
69
117
 
70
118
  }
71
119
  });
120
+
121
+ export function getTitle(record, targetSubfieldCodes = ['a', 'b', 'n', 'p']) {
122
+ const [field] = record.get(/^245$/u);
123
+ debugData(`titleField: ${JSON.stringify(field)}`);
124
+
125
+ if (field) {
126
+ const title = field.subfields
127
+ // get also $n:s and $p:s here
128
+ .filter(({code}) => targetSubfieldCodes.includes(code))
129
+ // Would be nice to normalize $n values...
130
+ .map(({value}) => testStringOrNumber(value) ? String(value) : '')
131
+ .join(' ')
132
+ .replace(/\[[^\]]*\]/ug, ' ') // Remove [whatever] stuff. ADD TEST FOR THIS
133
+ .replace(/ [=\/:](?:$| )/ug, ' ') // Strip punctuation
134
+ // Also đ vs d pairs seen:
135
+ //.replace(/(?:đ|ȧ|t̄|ǩ|ǧ|s̆|c̆)/ug, '') // Hack. Saamelaisbibliografia has often dropped this wierd characters (oft old articles, no longer used in sami either)
136
+ // trim:
137
+ .replace(/ +/ug, ' ')
138
+ .replace(/^ +/u, '')
139
+ .replace(/ +$/u, '');
140
+
141
+ // Skip non-filing indicator (note that '9' is a magic indicator value, so we don't do it):
142
+ if (/^[1-8]$/u.test(field.ind2)) { // Skip non-filing characters
143
+ return title.slice(parseInt(field.ind2));
144
+ }
145
+ return title;
146
+
147
+ }
148
+ return false;
149
+ }
@@ -5,7 +5,8 @@ import * as features from './features/index.js';
5
5
  export {features};
6
6
 
7
7
  export default ({strategy, threshold = 0.8999}, returnStrategy = false, localSettings = {}) => ({recordA, recordB, recordAExternal = {}, recordBExternal = {}}) => {
8
- const minProbabilityQuantifier = 0.5;
8
+ const MIN_PROPABILITY_QUALIFIER = 0.4; // Title perfect match: 0.5
9
+ const FORCE_FAIL = -1.0;
9
10
 
10
11
  const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection');
11
12
  const debugData = debug.extend('data');
@@ -41,13 +42,18 @@ export default ({strategy, threshold = 0.8999}, returnStrategy = false, localSet
41
42
  const featurePairs = generateFeaturePairs(featuresA, featuresB);
42
43
  const similarityVector = generateSimilarityVector(featurePairs);
43
44
 
44
- if (similarityVector.some(v => v >= minProbabilityQuantifier)) {
45
+ if (similarityVector.some(v => v >= MIN_PROPABILITY_QUALIFIER)) {
45
46
  const probability = calculateProbability(similarityVector);
46
47
  debug(`probability: ${probability} (Threshold: ${threshold})`);
48
+ if (similarityVector.some(v => v <= FORCE_FAIL)) {
49
+ debug(`Some feature resulted in utter failure (${FORCE_FAIL} or less)`);
50
+ return returnResult({match: false, probability: 0.0});
51
+ }
52
+
47
53
  return returnResult({match: probability >= threshold, probability});
48
54
  }
49
55
 
50
- debugData(`No feature yielded minimum probability amount of points (${minProbabilityQuantifier})`);
56
+ debugData(`No feature yielded minimum probability amount of points (${MIN_PROPABILITY_QUALIFIER})`);
51
57
  return returnResult({match: false, probability: 0.0});
52
58
  }
53
59
 
@@ -112,3 +112,20 @@ export function getMatchCounts(aValues, bValues) {
112
112
  return aToB < bToA ? aToB : bToA;
113
113
  }
114
114
  }
115
+
116
+
117
+ export function stringAfter(string, substring) {
118
+ const pos = string.indexOf(substring);
119
+ if (pos === -1) {
120
+ return string;
121
+ }
122
+ return string.substring(pos+substring.length);
123
+ }
124
+
125
+ export function stringBefore(string, substring) {
126
+ const pos = string.indexOf(substring);
127
+ if (pos === -1) {
128
+ return string;
129
+ }
130
+ return string.substring(0, pos);
131
+ }
@@ -1,37 +0,0 @@
1
- import createDebugLogger from "debug";
2
- import naturalPkg from "natural";
3
- const { LevenshteinDistance: leven } = naturalPkg;
4
- export default ({ threshold = 10 } = {}) => ({
5
- name: "titleVersionOriginal",
6
- extract: ({ record }) => {
7
- const title = getTitle();
8
- if (title) {
9
- return [title.replace(/[^\p{Letter}\p{Number}]/gu, "").toLowerCase()];
10
- }
11
- return [];
12
- function getTitle() {
13
- const [field] = record.get(/^245$/u);
14
- if (field) {
15
- return field.subfields.filter(({ code }) => ["a", "b"].includes(code)).map(({ value }) => value).join("");
16
- }
17
- }
18
- },
19
- compare: (a, b) => {
20
- const debug = createDebugLogger("@natlibfi/melinda-record-matching:match-detection:features/bib/title-version-original");
21
- const distance = leven(a[0], b[0]);
22
- if (distance === 0) {
23
- return 0.5;
24
- }
25
- const maxLength = getMaxLength();
26
- const percentage = distance / maxLength * 100;
27
- debug(`'${a}' vs '${b}': Max length = ${maxLength}, distance = ${distance}, percentage = ${percentage}`);
28
- if (percentage <= threshold) {
29
- return 0.3;
30
- }
31
- return -0.5;
32
- function getMaxLength() {
33
- return a[0].length > b[0].length ? a[0].length : b[0].length;
34
- }
35
- }
36
- });
37
- //# sourceMappingURL=title-version-original.js.map
@@ -1,7 +0,0 @@
1
- {
2
- "version": 3,
3
- "sources": ["../../../../src/match-detection/features/bib/title-version-original.js"],
4
- "sourcesContent": ["\nimport createDebugLogger from 'debug';\nimport naturalPkg from 'natural';\nconst {LevenshteinDistance: leven} = naturalPkg;\n\nexport default ({threshold = 10} = {}) => ({\n name: 'titleVersionOriginal',\n extract: ({record}) => {\n const title = getTitle();\n\n if (title) {\n return [title.replace(/[^\\p{Letter}\\p{Number}]/gu, '').toLowerCase()];\n }\n\n return [];\n\n function getTitle() {\n const [field] = record.get(/^245$/u);\n\n if (field) {\n return field.subfields\n .filter(({code}) => ['a', 'b'].includes(code))\n .map(({value}) => value)\n .join('');\n }\n }\n },\n compare: (a, b) => {\n const debug = createDebugLogger('@natlibfi/melinda-record-matching:match-detection:features/bib/title-version-original');\n const distance = leven(a[0], b[0]);\n\n if (distance === 0) {\n return 0.5;\n }\n\n const maxLength = getMaxLength();\n const percentage = distance / maxLength * 100;\n\n debug(`'${a}' vs '${b}': Max length = ${maxLength}, distance = ${distance}, percentage = ${percentage}`);\n\n if (percentage <= threshold) {\n return 0.3;\n }\n\n return -0.5;\n\n function getMaxLength() {\n return a[0].length > b[0].length ? a[0].length : b[0].length;\n }\n\n }\n});\n"],
5
- "mappings": "AACA,OAAO,uBAAuB;AAC9B,OAAO,gBAAgB;AACvB,MAAM,EAAC,qBAAqB,MAAK,IAAI;AAErC,eAAe,CAAC,EAAC,YAAY,GAAE,IAAI,CAAC,OAAO;AAAA,EACzC,MAAM;AAAA,EACN,SAAS,CAAC,EAAC,OAAM,MAAM;AACrB,UAAM,QAAQ,SAAS;AAEvB,QAAI,OAAO;AACT,aAAO,CAAC,MAAM,QAAQ,6BAA6B,EAAE,EAAE,YAAY,CAAC;AAAA,IACtE;AAEA,WAAO,CAAC;AAER,aAAS,WAAW;AAClB,YAAM,CAAC,KAAK,IAAI,OAAO,IAAI,QAAQ;AAEnC,UAAI,OAAO;AACT,eAAO,MAAM,UACV,OAAO,CAAC,EAAC,KAAI,MAAM,CAAC,KAAK,GAAG,EAAE,SAAS,IAAI,CAAC,EAC5C,IAAI,CAAC,EAAC,MAAK,MAAM,KAAK,EACtB,KAAK,EAAE;AAAA,MACZ;AAAA,IACF;AAAA,EACF;AAAA,EACA,SAAS,CAAC,GAAG,MAAM;AACjB,UAAM,QAAQ,kBAAkB,uFAAuF;AACvH,UAAM,WAAW,MAAM,EAAE,CAAC,GAAG,EAAE,CAAC,CAAC;AAEjC,QAAI,aAAa,GAAG;AAClB,aAAO;AAAA,IACT;AAEA,UAAM,YAAY,aAAa;AAC/B,UAAM,aAAa,WAAW,YAAY;AAE1C,UAAM,IAAI,CAAC,SAAS,CAAC,mBAAmB,SAAS,gBAAgB,QAAQ,kBAAkB,UAAU,EAAE;AAEvG,QAAI,cAAc,WAAW;AAC3B,aAAO;AAAA,IACT;AAEA,WAAO;AAEP,aAAS,eAAe;AACtB,aAAO,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE,SAAS,EAAE,CAAC,EAAE;AAAA,IACxD;AAAA,EAEF;AACF;",
6
- "names": []
7
- }