@natlibfi/melinda-record-matching 4.4.0-alpha.1 → 5.0.0-alpha.1

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 (107) hide show
  1. package/.github/workflows/melinda-node-tests.yml +13 -9
  2. package/LICENSE.txt +21 -165
  3. package/README.md +2 -2
  4. package/eslint.config.js +52 -0
  5. package/example.env +4 -0
  6. package/package.json +29 -79
  7. package/src/candidate-search/candidate-search-utils.js +1 -28
  8. package/src/candidate-search/choose-queries.js +93 -0
  9. package/src/candidate-search/index.js +85 -119
  10. package/src/candidate-search/{index.spec.js → index.test.js} +30 -43
  11. package/src/candidate-search/query-list/bib.js +54 -55
  12. package/src/candidate-search/query-list/bib.test.js +38 -0
  13. package/src/candidate-search/query-list/component.js +127 -0
  14. package/src/candidate-search/query-list/index.js +31 -35
  15. package/src/cli.js +177 -0
  16. package/src/index.js +27 -68
  17. package/src/{index.spec.js → index.test.js} +11 -42
  18. package/src/match-detection/features/bib/all-source-ids.js +2 -29
  19. package/src/match-detection/features/bib/authors.js +5 -30
  20. package/src/match-detection/features/bib/bibliographic-level.js +0 -27
  21. package/src/match-detection/features/bib/host-component.js +0 -27
  22. package/src/match-detection/features/bib/index.js +16 -43
  23. package/src/match-detection/features/bib/index.test.js +52 -0
  24. package/src/match-detection/features/bib/isbn.js +2 -29
  25. package/src/match-detection/features/bib/issn.js +1 -28
  26. package/src/match-detection/features/bib/language.js +5 -32
  27. package/src/match-detection/features/bib/media-type.js +2 -28
  28. package/src/match-detection/features/bib/melinda-id.js +1 -28
  29. package/src/match-detection/features/bib/melinda-identifier-factory.js +5 -32
  30. package/src/match-detection/features/bib/other-standard-identifier.js +1 -28
  31. package/src/match-detection/features/bib/publication-time-allow-cons-years-multi.js +7 -34
  32. package/src/match-detection/features/bib/publication-time-allow-cons-years.js +1 -28
  33. package/src/match-detection/features/bib/publication-time.js +2 -28
  34. package/src/match-detection/features/bib/record-type.js +0 -27
  35. package/src/match-detection/features/bib/standard-identifier-factory.js +1 -28
  36. package/src/match-detection/features/bib/title-version-original.js +2 -28
  37. package/src/match-detection/features/bib/title.js +6 -34
  38. package/src/match-detection/features/index.js +1 -28
  39. package/src/match-detection/index.js +1 -28
  40. package/src/match-detection/{index.spec.js → index.test.js} +9 -35
  41. package/src/matching-utils.js +8 -37
  42. package/dist/candidate-search/candidate-search-utils.js +0 -55
  43. package/dist/candidate-search/candidate-search-utils.js.map +0 -1
  44. package/dist/candidate-search/index.js +0 -254
  45. package/dist/candidate-search/index.js.map +0 -1
  46. package/dist/candidate-search/index.spec.js +0 -144
  47. package/dist/candidate-search/index.spec.js.map +0 -1
  48. package/dist/candidate-search/query-list/bib.js +0 -439
  49. package/dist/candidate-search/query-list/bib.js.map +0 -1
  50. package/dist/candidate-search/query-list/bib.spec.js +0 -64
  51. package/dist/candidate-search/query-list/bib.spec.js.map +0 -1
  52. package/dist/candidate-search/query-list/index.js +0 -66
  53. package/dist/candidate-search/query-list/index.js.map +0 -1
  54. package/dist/index.js +0 -579
  55. package/dist/index.js.map +0 -1
  56. package/dist/index.spec.js +0 -127
  57. package/dist/index.spec.js.map +0 -1
  58. package/dist/match-detection/features/bib/all-source-ids.js +0 -119
  59. package/dist/match-detection/features/bib/all-source-ids.js.map +0 -1
  60. package/dist/match-detection/features/bib/authors.js +0 -113
  61. package/dist/match-detection/features/bib/authors.js.map +0 -1
  62. package/dist/match-detection/features/bib/bibliographic-level.js +0 -42
  63. package/dist/match-detection/features/bib/bibliographic-level.js.map +0 -1
  64. package/dist/match-detection/features/bib/host-component.js +0 -42
  65. package/dist/match-detection/features/bib/host-component.js.map +0 -1
  66. package/dist/match-detection/features/bib/index.js +0 -119
  67. package/dist/match-detection/features/bib/index.js.map +0 -1
  68. package/dist/match-detection/features/bib/index.spec.js +0 -93
  69. package/dist/match-detection/features/bib/index.spec.js.map +0 -1
  70. package/dist/match-detection/features/bib/isbn.js +0 -77
  71. package/dist/match-detection/features/bib/isbn.js.map +0 -1
  72. package/dist/match-detection/features/bib/issn.js +0 -51
  73. package/dist/match-detection/features/bib/issn.js.map +0 -1
  74. package/dist/match-detection/features/bib/language.js +0 -141
  75. package/dist/match-detection/features/bib/language.js.map +0 -1
  76. package/dist/match-detection/features/bib/media-type.js +0 -83
  77. package/dist/match-detection/features/bib/media-type.js.map +0 -1
  78. package/dist/match-detection/features/bib/melinda-id.js +0 -48
  79. package/dist/match-detection/features/bib/melinda-id.js.map +0 -1
  80. package/dist/match-detection/features/bib/melinda-identifier-factory.js +0 -89
  81. package/dist/match-detection/features/bib/melinda-identifier-factory.js.map +0 -1
  82. package/dist/match-detection/features/bib/other-standard-identifier.js +0 -52
  83. package/dist/match-detection/features/bib/other-standard-identifier.js.map +0 -1
  84. package/dist/match-detection/features/bib/publication-time-allow-cons-years-multi.js +0 -256
  85. package/dist/match-detection/features/bib/publication-time-allow-cons-years-multi.js.map +0 -1
  86. package/dist/match-detection/features/bib/publication-time-allow-cons-years.js +0 -71
  87. package/dist/match-detection/features/bib/publication-time-allow-cons-years.js.map +0 -1
  88. package/dist/match-detection/features/bib/publication-time.js +0 -48
  89. package/dist/match-detection/features/bib/publication-time.js.map +0 -1
  90. package/dist/match-detection/features/bib/record-type.js +0 -45
  91. package/dist/match-detection/features/bib/record-type.js.map +0 -1
  92. package/dist/match-detection/features/bib/standard-identifier-factory.js +0 -215
  93. package/dist/match-detection/features/bib/standard-identifier-factory.js.map +0 -1
  94. package/dist/match-detection/features/bib/title-version-original.js +0 -79
  95. package/dist/match-detection/features/bib/title-version-original.js.map +0 -1
  96. package/dist/match-detection/features/bib/title.js +0 -98
  97. package/dist/match-detection/features/bib/title.js.map +0 -1
  98. package/dist/match-detection/features/index.js +0 -11
  99. package/dist/match-detection/features/index.js.map +0 -1
  100. package/dist/match-detection/index.js +0 -201
  101. package/dist/match-detection/index.js.map +0 -1
  102. package/dist/match-detection/index.spec.js +0 -91
  103. package/dist/match-detection/index.spec.js.map +0 -1
  104. package/dist/matching-utils.js +0 -143
  105. package/dist/matching-utils.js.map +0 -1
  106. package/src/candidate-search/query-list/bib.spec.js +0 -54
  107. package/src/match-detection/features/bib/index.spec.js +0 -78
@@ -1,45 +1,17 @@
1
- /**
2
- *
3
- * @licstart The following is the entire license notice for the JavaScript code in this file.
4
- *
5
- * Melinda record matching modules for Javascript
6
- *
7
- * Copyright (C) 2020-2022 University Of Helsinki (The National Library Of Finland)
8
- *
9
- * This file is part of melinda-record-matching-js
10
- *
11
- * melinda-record-matching-js program is free software: you can redistribute it and/or modify
12
- * it under the terms of the GNU Lesser General Public License as
13
- * published by the Free Software Foundation, either version 3 of the
14
- * License, or (at your option) any later version.
15
- *
16
- * melinda-record-matching-js is distributed in the hope that it will be useful,
17
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
18
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
- * GNU Lesser General Public License for more details.
20
- *
21
- * You should have received a copy of the GNU Affero General Public License
22
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
23
- *
24
- * @licend The above is the entire license notice
25
- * for the JavaScript code in this file.
26
- *
27
- */
28
-
29
1
  import createDebugLogger from 'debug';
30
2
  import createClient, {SruSearchError} from '@natlibfi/sru-client';
31
3
  import {MarcRecord} from '@natlibfi/marc-record';
32
- import {MARCXML} from '@natlibfi/marc-record-serializers';
33
- import generateQueryList from './query-list';
34
- import {Error as MatchingError} from '@natlibfi/melinda-commons';
35
4
 
36
- export {searchTypes} from './query-list';
5
+ import generateQueryList from './query-list/index.js';
6
+ import chooseQueries from './choose-queries.js';
7
+
8
+ export {searchTypes} from './query-list/index.js';
37
9
 
38
- export class CandidateSearchError extends Error {}
10
+ export class CandidateSearchError extends Error { }
39
11
 
40
12
  // serverMaxResults : maximum size of total search result available from the server, defaults to Aleph's 20000
41
13
 
42
- export default ({record, searchSpec, url, maxCandidates, maxRecordsPerRequest = 50, serverMaxResult = 20000}) => {
14
+ export default async ({record, searchSpec, url, maxCandidates, maxRecordsPerRequest = 50, serverMaxResult = 20000}) => {
43
15
  MarcRecord.setValidationOptions({subfieldValues: false});
44
16
 
45
17
  const debug = createDebugLogger('@natlibfi/melinda-record-matching:candidate-search');
@@ -54,25 +26,43 @@ export default ({record, searchSpec, url, maxCandidates, maxRecordsPerRequest =
54
26
  // Do not retrieve more candidates than defined in maxCandidates
55
27
  const adjustedMaxRecordsPerRequest = maxRecordsPerRequest >= maxCandidates ? maxCandidates : maxRecordsPerRequest;
56
28
 
57
- const inputRecordId = getRecordId(record);
58
- const queryList = generateQueryList(record, searchSpec);
59
29
  const client = createClient({
60
30
  url,
61
31
  maxRecordsPerRequest: adjustedMaxRecordsPerRequest,
62
32
  version: '2.0',
63
- retrieveAll: false
33
+ retrieveAll: false,
34
+ metadataFormat: 'marcJson'
64
35
  });
65
36
 
66
- debug(`Searching matches for ${inputRecordId}`);
67
- debug(`Generated queryList ${JSON.stringify(queryList)}`);
68
-
37
+ const inputRecordId = getRecordId(record);
38
+ const queryListResult = await generateQueryList(record, searchSpec, client);
39
+ const queryList = queryListResult[0]?.queryList ? queryListResult[0].queryList : queryListResult;
40
+ const queryListType = queryListResult[0]?.queryListType ? queryListResult[0].queryListType : undefined;
69
41
 
70
42
  // if generateQueryList errored we should throw 422
71
-
72
43
  if (queryList.length === 0) {
44
+ debug(`Empty list`);
73
45
  throw new CandidateSearchError(`Generated query list contains no queries`);
74
46
  }
47
+ if (queryListType && queryListType !== 'alternates') {
48
+ debug(`Unknown queryListType`);
49
+ throw new CandidateSearchError(`Generated query list has invalid type`);
50
+ }
51
+
52
+ debug(`Searching matches for ${inputRecordId}`);
53
+ const chosenQueryList = await filterQueryList({queryList, queryListType});
54
+ debug(`Chosen queries: ${JSON.stringify(chosenQueryList)}`);
55
+
56
+ async function filterQueryList({queryList, queryListType, maxCandidates}) {
57
+ debug(`Generated queryList (type: ${queryListType}) ${JSON.stringify(queryList)}`);
75
58
 
59
+ if (queryListType === 'alternates' && queryList.length > 1) {
60
+ const queryListResult = await chooseQueries({url, queryList, queryListType, maxCandidates});
61
+ debug(`queryListResult: ${JSON.stringify(queryListResult)}`);
62
+ return queryListResult.map(elem => elem.query);
63
+ }
64
+ return queryList;
65
+ }
76
66
  // state.totalRecords : amount of candidate records available to the current query (undefined, if there was no queries left)
77
67
  // state.query : current query (undefined if there was no queries left)
78
68
  // state.searchCounter : sequence for current search for current query (undefined, if there we no queries left)
@@ -81,98 +71,38 @@ export default ({record, searchSpec, url, maxCandidates, maxRecordsPerRequest =
81
71
  // state.queryCounter : sequence for current query
82
72
  // state.maxedQueries : queries that resulted in more than serverMaxResults hits
83
73
 
74
+ return {search};
84
75
 
85
- return async ({queryOffset = 0, resultSetOffset = 1, totalRecords = 0, searchCounter = 0, queryCandidateCounter = 0, queryCounter = 0, maxedQueries = []}) => {
86
- const query = queryList[queryOffset];
76
+ // eslint-disable-next-line max-statements
77
+ async function search({queryOffset = 0, resultSetOffset = 1, totalRecords = 0, searchCounter = 0, queryCandidateCounter = 0, queryCounter = 0, maxedQueries = []}) {
78
+ const query = chosenQueryList[queryOffset];
79
+ debug(`Running query ${JSON.stringify(query)} (${queryOffset})`);
87
80
 
88
81
  if (query) {
89
- const {records, failures, nextOffset, total} = await retrieveRecords();
82
+ const {records, nextOffset, total} = await retrieveRecords(client, query, resultSetOffset);
90
83
 
91
84
  // If resultSetOffset === 1 this is the first search for the current query
92
85
  debugData(`ResultSetOffset: ${resultSetOffset}`);
93
86
  const newTotalRecords = resultSetOffset === 1 ? total : totalRecords;
94
87
  const newQueryCounter = resultSetOffset === 1 ? queryCounter + 1 : queryCounter;
95
88
  const newSearchCounter = resultSetOffset === 1 ? 1 : searchCounter + 1;
96
- const newQueryCandidateCounter = resultSetOffset === 1 ? records.length + failures.length : queryCandidateCounter + records.length + failures.length;
89
+ const newQueryCandidateCounter = resultSetOffset === 1 ? records.length : queryCandidateCounter + records.length;
97
90
 
98
91
  const maxedQuery = resultSetOffset === 1 ? checkMaxedQuery(query, total, serverMaxResult) : undefined;
99
92
  const newMaxedQueries = maxedQuery ? maxedQueries.concat(maxedQuery) : maxedQueries;
100
93
 
101
94
  if (typeof nextOffset === 'number') {
102
95
  debug(`Next search will be for query ${queryOffset} ${query}, starting from record ${nextOffset}`);
103
- return {records, failures, queryOffset, resultSetOffset: nextOffset, queriesLeft: queryList.length - (queryOffset + 1), totalRecords: newTotalRecords, query, searchCounter: newSearchCounter, queryCandidateCounter: newQueryCandidateCounter, queryCounter: newQueryCounter, maxedQueries: newMaxedQueries};
96
+ return {records, queryOffset, resultSetOffset: nextOffset, queriesLeft: queryList.length - (queryOffset + 1), totalRecords: newTotalRecords, query, searchCounter: newSearchCounter, queryCandidateCounter: newQueryCandidateCounter, queryCounter: newQueryCounter, maxedQueries: newMaxedQueries};
104
97
  }
105
98
  debug(`Query ${queryOffset} ${query} done.`);
106
99
  debug(`There are (${queryList.length - (queryOffset + 1)} queries left)`);
107
- return {records, failures, queryOffset: queryOffset + 1, queriesLeft: queryList.length - (queryOffset + 1), totalRecords: newTotalRecords, query, searchCounter: newSearchCounter, queryCandidateCounter: newQueryCandidateCounter, queryCounter: newQueryCounter, maxedQueries: newMaxedQueries};
100
+ return {records, queryOffset: queryOffset + 1, queriesLeft: queryList.length - (queryOffset + 1), totalRecords: newTotalRecords, query, searchCounter: newSearchCounter, queryCandidateCounter: newQueryCandidateCounter, queryCounter: newQueryCounter, maxedQueries: newMaxedQueries};
108
101
  }
109
102
 
110
103
  debug(`All ${queryList.length} queries done, there's no query for ${queryOffset}`);
111
- return {records: [], failures: [], queriesLeft: 0, queryCounter, maxedQueries};
112
-
113
- function retrieveRecords() {
114
- return new Promise((resolve, reject) => {
115
- const promises = [];
116
- // eslint-disable-next-line functional/no-let
117
- let totalRecords = 0;
118
-
119
- debug(`Searching for candidates with query: ${query} (Offset ${resultSetOffset})`);
120
-
121
- client.searchRetrieve(query, {startRecord: resultSetOffset})
122
- .on('error', err => {
123
- // eslint-disable-next-line functional/no-conditional-statements
124
- if (err instanceof SruSearchError) {
125
- debug(`SRU SruSearchError for query: ${query}: ${err}`);
126
- reject(new CandidateSearchError(`SRU SruSearchError for query: ${query}: ${err}`));
127
- }
128
- debug(`SRU error for query: ${query}: ${err}`);
129
- reject(new CandidateSearchError(`SRU error for query: ${query}: ${err}`));
130
- })
131
- .on('total', total => {
132
- debug(`Got total: ${total}`);
133
- totalRecords += total;
134
- })
135
- .on('end', async nextOffset => {
136
- try {
137
- const recordPromises = await Promise.allSettled(promises);
138
- debugData(`All recordPromises: ${JSON.stringify(recordPromises)}`);
139
- const filtered = recordPromises.filter(r => r.status === 'fulfilled').map(r => r.value);
140
- const failures = recordPromises.filter(r => r.status === 'rejected').map(r => ({status: r.reason.status, payload: r.reason.payload}));
141
-
142
- debug(`Found ${recordPromises.length} records`);
143
- debug(`Found ${filtered.length} convertable candidates`);
144
- debug(`Found ${failures.length} NON-convertable candidates`);
145
- debugData(`Converted: ${JSON.stringify(filtered)}.`);
146
- debugData(`Not converted: ${JSON.stringify(failures)}.`);
147
-
148
-
149
- resolve({nextOffset, records: filtered, failures, total: totalRecords});
150
- } catch (err) {
151
- debug(`Error caught on END`);
152
- reject(err);
153
- }
154
- })
155
- .on('record', recordXML => {
156
- promises.push(handleRecord()); // eslint-disable-line functional/immutable-data
157
-
158
- async function handleRecord() {
159
- try {
160
- const recordMarc = await MARCXML.from(recordXML, {subfieldValues: false});
161
- const recordId = getRecordId(recordMarc);
162
-
163
- return {record: recordMarc, id: recordId};
164
- } catch (err) {
165
- // What should this do?
166
- const idFromXML = getRecordIdFromXML(recordXML);
167
- debugData(`Failed converting record: ${err.message}, id: ${idFromXML}, data: ${recordXML}`);
168
- //return {message: `Failed converting record: ${err.message}`, id: idFromXML, data: recordXML};
169
- throw new MatchingError(422, {message: `Failed converting record: ${err.message}`, id: idFromXML || '000000000', data: recordXML});
170
- }
171
- }
172
- });
173
- });
174
- }
175
- };
104
+ return {records: [], queriesLeft: 0, queryCounter, maxedQueries};
105
+ }
176
106
 
177
107
  function checkMaxedQuery(query, total, serverMaxResult) {
178
108
  if (total >= serverMaxResult) {
@@ -181,16 +111,52 @@ export default ({record, searchSpec, url, maxCandidates, maxRecordsPerRequest =
181
111
  }
182
112
  }
183
113
 
184
-
185
114
  function getRecordId(record) {
186
115
  const [field] = record.get(/^001$/u);
187
116
  return field ? field.value : '';
188
117
  }
118
+ };
189
119
 
190
- function getRecordIdFromXML(recordXML) {
191
- //<controlfield tag=\"001\">015376846</controlfield
192
- debug(`Cannot yet find possible database record id from recordXML (length ${recordXML.length})`);
193
- return undefined;
194
- }
120
+ export function retrieveRecords(client, query, resultSetOffset) {
121
+ const debug = createDebugLogger('@natlibfi/melinda-record-matching:candidate-search:retrieveRecords');
122
+ // eslint-disable-next-line no-unused-vars
123
+ const debugData = debug.extend('data');
195
124
 
196
- };
125
+ return new Promise((resolve, reject) => {
126
+ const records = [];
127
+ let totalRecords = 0;
128
+
129
+ debug(`Searching for candidates with query: ${query} (Offset ${resultSetOffset})`);
130
+
131
+ client.searchRetrieve(query, {startRecord: resultSetOffset})
132
+ .on('error', err => {
133
+ if (err instanceof SruSearchError) {
134
+ debug(`SRU SruSearchError for query: ${query}: ${err}`);
135
+ reject(new CandidateSearchError(`SRU SruSearchError for query: ${query}: ${err}`));
136
+ }
137
+
138
+ debug(`SRU error for query: ${query}: ${err}`);
139
+ reject(new CandidateSearchError(`SRU error for query: ${query}: ${err}`));
140
+ })
141
+ .on('total', total => {
142
+ debug(`Got total: ${total}`);
143
+ totalRecords += total;
144
+ })
145
+ .on('end', nextOffset => {
146
+ try {
147
+ debug(`Found ${records.length} records`);
148
+
149
+ resolve({nextOffset, records, total: totalRecords});
150
+ } catch (err) {
151
+ debug(`Error caught on END`);
152
+ reject(err);
153
+ }
154
+ })
155
+ .on('record', record => {
156
+ const [field] = record.get(/^001$/u);
157
+ debug(field);
158
+ const id = field.value ? field.value : '';
159
+ records.push({record, id});
160
+ });
161
+ });
162
+ }
@@ -1,37 +1,10 @@
1
- /**
2
- *
3
- * @licstart The following is the entire license notice for the JavaScript code in this file.
4
- *
5
- * Melinda record matching modules for Javascript
6
- *
7
- * Copyright (C) 2020-2022 University Of Helsinki (The National Library Of Finland)
8
- *
9
- * This file is part of melinda-record-matching-js
10
- *
11
- * melinda-record-matching-js program is free software: you can redistribute it and/or modify
12
- * it under the terms of the GNU Lesser General Public License as
13
- * published by the Free Software Foundation, either version 3 of the
14
- * License, or (at your option) any later version.
15
- *
16
- * melinda-record-matching-js is distributed in the hope that it will be useful,
17
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
18
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
19
- * GNU Lesser General Public License for more details.
20
- *
21
- * You should have received a copy of the GNU Affero General Public License
22
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
23
- *
24
- * @licend The above is the entire license notice
25
- * for the JavaScript code in this file.
26
- *
27
- */
28
-
29
- import {expect} from 'chai';
1
+ import assert from 'node:assert';
2
+ import {describe} from 'node:test';
30
3
  import {READERS} from '@natlibfi/fixura';
31
4
  import generateTests from '@natlibfi/fixugen-http-client';
32
5
  import {MarcRecord} from '@natlibfi/marc-record';
33
6
  import {Error as MatchingError} from '@natlibfi/melinda-commons';
34
- import createSearchInterface, {CandidateSearchError} from '.';
7
+ import createSearchInterface, {CandidateSearchError} from './index.js';
35
8
  import createDebugLogger from 'debug';
36
9
 
37
10
  const debug = createDebugLogger('@natlibfi/melinda-record-matching:candidate-search:test');
@@ -39,13 +12,14 @@ const debug = createDebugLogger('@natlibfi/melinda-record-matching:candidate-sea
39
12
  describe('candidate-search', () => {
40
13
  generateTests({
41
14
  callback,
42
- path: [__dirname, '..', '..', 'test-fixtures', 'candidate-search', 'index'],
15
+ path: [import.meta.dirname, '..', '..', 'test-fixtures', 'candidate-search', 'index'],
43
16
  recurse: false,
44
17
  fixura: {
45
18
  reader: READERS.JSON
46
19
  }
47
20
  });
48
21
 
22
+ // eslint-disable-next-line max-statements
49
23
  async function callback({getFixture, factoryOptions, searchOptions, expectedFactoryError = false, expectedSearchError = false, enabled = true}) {
50
24
  const url = 'http://foo.bar';
51
25
 
@@ -54,16 +28,31 @@ describe('candidate-search', () => {
54
28
  }
55
29
 
56
30
  if (expectedFactoryError) {
31
+ debug(`We're expecting an error`);
57
32
  if (expectedFactoryError.isCandidateSearchError) {
58
- expect(() => createSearchInterface({...formatFactoryOptions(), url})).to.throw(CandidateSearchError, new RegExp(expectedFactoryError, 'u'));
59
- return;
33
+ try {
34
+ const result = await createSearchInterface({...formatFactoryOptions(), url});
35
+ debug(result);
36
+ } catch (err) {
37
+ assert.equal(err instanceof CandidateSearchError, true);
38
+ assert.match(err.message, new RegExp(expectedFactoryError.message));
39
+ } finally {
40
+ return;
41
+ }
60
42
  }
61
43
 
62
- expect(() => createSearchInterface({...formatFactoryOptions(), url})).to.throw(new RegExp(expectedFactoryError, 'u'));
63
- return;
44
+ try {
45
+ const result = await createSearchInterface({...formatFactoryOptions(), url});
46
+ debug(result);
47
+ } catch (err) {
48
+ assert.equal(err instanceof Error, true);
49
+ assert.match(err.message, new RegExp(expectedFactoryError.message));
50
+ } finally {
51
+ return;
52
+ }
64
53
  }
65
54
 
66
- const search = createSearchInterface({...formatFactoryOptions(), url});
55
+ const {search} = await createSearchInterface({...formatFactoryOptions(), url});
67
56
  await iterate({searchOptions, expectedSearchError});
68
57
 
69
58
  function formatFactoryOptions() {
@@ -79,30 +68,29 @@ describe('candidate-search', () => {
79
68
  async function iterate({searchOptions, expectedSearchError, expectedErrorStatus, count = 1}) {
80
69
  const expectedResults = getFixture(`expectedResults${count}.json`);
81
70
 
82
- if (expectedSearchError) { // eslint-disable-line functional/no-conditional-statements
71
+ if (expectedSearchError) {
83
72
  try {
84
73
  await search(searchOptions);
85
74
  throw new Error('Expected an error');
86
75
  } catch (err) {
87
76
  debug(`Got an error: ${err}`);
88
- expect(err).to.be.an('error');
77
+ assert(err instanceof Error);
89
78
  const errorMessage = err instanceof MatchingError ? err.payload.message : err.message;
90
79
  const errorStatus = err instanceof MatchingError ? err.status : undefined;
91
80
  debug(`errorMessage: ${errorMessage}, errorStatus: ${errorStatus}`);
92
- expect(errorMessage).to.match(new RegExp(expectedSearchError, 'u'));
81
+ assert.match(errorMessage, new RegExp(expectedSearchError, 'u'));
93
82
 
94
83
  if (expectedErrorStatus) {
95
- expect(errorStatus).to.be(expectedErrorStatus);
84
+ assert.equal(errorStatus, expectedErrorStatus);
96
85
  return;
97
86
  }
98
87
  return;
99
88
  }
100
89
  }
101
90
 
102
- // eslint-disable-next-line functional/no-conditional-statements
103
91
  if (!expectedSearchError) {
104
92
  const results = await search(searchOptions);
105
- expect(formatResults(results)).to.eql(expectedResults);
93
+ assert.deepStrictEqual(formatResults(results), expectedResults);
106
94
  }
107
95
 
108
96
  function formatResults(results) {
@@ -112,7 +100,6 @@ describe('candidate-search', () => {
112
100
  records: results.records.map(({record, id}) => ({id, record: record.toObject()}))
113
101
  };
114
102
  }
115
-
116
103
  }
117
104
  }
118
105
  });
@@ -1,34 +1,6 @@
1
-
2
- /**
3
- *
4
- * @licstart The following is the entire license notice for the JavaScript code in this file.
5
- *
6
- * Melinda record matching modules for Javascript
7
- *
8
- * Copyright (C) 2020-2022 University Of Helsinki (The National Library Of Finland)
9
- *
10
- * This file is part of melinda-record-matching-js
11
- *
12
- * melinda-record-matching-js program is free software: you can redistribute it and/or modify
13
- * it under the terms of the GNU Lesser General Public License as
14
- * published by the Free Software Foundation, either version 3 of the
15
- * License, or (at your option) any later version.
16
- *
17
- * melinda-record-matching-js is distributed in the hope that it will be useful,
18
- * but WITHOUT ANY WARRANTY; without even the implied warranty of
19
- * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
20
- * GNU Lesser General Public License for more details.
21
- *
22
- * You should have received a copy of the GNU Affero General Public License
23
- * along with this program. If not, see <http://www.gnu.org/licenses/>.
24
- *
25
- * @licend The above is the entire license notice
26
- * for the JavaScript code in this file.
27
- *
28
- */
29
1
  import createDebugLogger from 'debug';
30
- import {toQueries} from '../candidate-search-utils';
31
- import {getMelindaIdsF035, validateSidFieldSubfieldCounts, getSubfieldValues, testStringOrNumber} from '../../matching-utils';
2
+ import {toQueries} from '../candidate-search-utils.js';
3
+ import {getMelindaIdsF035, validateSidFieldSubfieldCounts, getSubfieldValues, testStringOrNumber} from '../../matching-utils.js';
32
4
 
33
5
  const debug = createDebugLogger('@natlibfi/melinda-record-matching:candidate-search:query');
34
6
 
@@ -151,7 +123,13 @@ export function bibMelindaIds(record) {
151
123
  // bibHostComponents returns host id from the first subfield $w of first field f773, see test-fixtures 04 and 05
152
124
  // bibHostComponents should search all 773 $ws for possible host id, but what should it do in case of multiple host ids?
153
125
  export function bibHostComponents(record) {
126
+ const debug = createDebugLogger('@natlibfi/melinda-record-matching:candidate-search:query:bibHostComponents');
127
+ const debugData = debug.extend('data');
128
+ debug(`Creating queries for hostIds`);
129
+
154
130
  const id = getHostId();
131
+ debugData(`Found id: ${JSON.stringify(id)}`);
132
+
155
133
  return testStringOrNumber(id) ? [`melinda.partsofhost=${id}`] : [];
156
134
 
157
135
  function getHostId() {
@@ -195,18 +173,25 @@ export function bibTitleAuthor(record) {
195
173
  }
196
174
 
197
175
  export function bibTitleAuthorYear(record) {
198
- debug('bibTitleAuthorYear');
176
+ debug('bibTitleAuthorYearAlternates');
199
177
  // We use onlyTitleLength that is longer than our formatted length to
200
178
  // get an author or an publisher always
201
179
 
202
180
  return bibTitleAuthorPublisher({record, onlyTitleLength: 100, addYear: true});
203
181
  }
204
182
 
205
- export function bibTitleAuthorPublisher({record, onlyTitleLength, addYear = false}) {
206
- debug('bibTitleAuthorPublisher');
207
- const title = getTitle();
208
- const booleanStartWords = ['and', 'or', 'nor', 'not'];
183
+ export function bibTitleAuthorYearAlternates(record) {
184
+ debug('bibTitleAuthorYearAlternates');
185
+ // We use onlyTitleLength that is longer than our formatted length to
186
+ // get an author or an publisher always
187
+ const origQueryList = bibTitleAuthorPublisher({record, onlyTitleLength: 100, addYear: true, alternates: true, alternateQueries: []});
188
+ debug(`${JSON.stringify(origQueryList)}`);
189
+ return {queryList: Array.from(origQueryList).reverse(), queryListType: 'alternates'};
190
+ }
209
191
 
192
+ export function bibTitleAuthorPublisher({record, onlyTitleLength, addYear = false, alternates = false, alternateQueries = []}) {
193
+ debug(`bibTitleAuthorPublisher, onlyTitleLength: ${onlyTitleLength}, addYear: ${addYear}, alternates: ${alternates}`);
194
+ const title = getTitle();
210
195
  if (testStringOrNumber(title)) {
211
196
  const formatted = String(title)
212
197
  .replace(/[^\w\s\p{Alphabetic}]/gu, '')
@@ -217,10 +202,10 @@ export function bibTitleAuthorPublisher({record, onlyTitleLength, addYear = fals
217
202
  .trim();
218
203
 
219
204
  // use word search for titles starting with a boolean
220
- const useWordSearch = booleanStartWords.some(word => formatted.toLowerCase().startsWith(word));
205
+ const useWordSearch = checkUseWordSearch(formatted);
221
206
  // Prevent too many matches / SRU crashing by having a minimum length
222
207
  // Note that currently this fails matching if there are no matches from previous matchers
223
- if (formatted.length >= onlyTitleLength) {
208
+ if (formatted.length >= onlyTitleLength && !alternates) {
224
209
  return [`dc.title="${useWordSearch ? '' : '^'}${formatted}*"`];
225
210
  }
226
211
  const queryIsOkAlone = formatted.length >= 5;
@@ -228,45 +213,50 @@ export function bibTitleAuthorPublisher({record, onlyTitleLength, addYear = fals
228
213
  // use word search without ending * also in combination searches to avoid SRU-server crashes [MRA-189]
229
214
  const query = `dc.title="${useWordSearch || !queryIsOkAlone ? '' : '^'}${formatted}${queryIsOkAlone ? '*' : ''}"`;
230
215
  debug(`query: ${query}`);
231
- return addAuthorsToSearch({query, queryIsOkAlone, addYear});
216
+ const newAlternateQueries = alternates ? [...alternateQueries, query] : alternateQueries;
217
+
218
+ return addAuthorsToSearch({query, queryIsOkAlone, addYear, alternates, alternateQueries: newAlternateQueries});
232
219
  }
233
220
 
234
221
  return [];
235
222
 
236
- function addAuthorsToSearch({query, queryIsOkAlone = false, addYear = false}) {
223
+ function addAuthorsToSearch({query, queryIsOkAlone = false, addYear = false, alternates = false, alternateQueries = []}) {
237
224
  debug('addAuthorsToSearch');
238
225
  const [authorQuery] = bibAuthors(record);
239
226
  if (authorQuery !== undefined) {
240
227
  if (addYear) {
241
- return addYearToSearch({query: `${authorQuery} AND ${query}`, queryIsOkAlone: true});
228
+ const newAlternateQueries = alternates ? [...alternateQueries, `${authorQuery} AND ${query}`] : alternateQueries;
229
+ return addYearToSearch({query: `${authorQuery} AND ${query}`, queryIsOkAlone: true, alternates, alternateQueries: newAlternateQueries});
242
230
  }
243
- return [`${authorQuery} AND ${query}`];
231
+ return alternates ? alternateQueries : [`${authorQuery} AND ${query}`];
244
232
  }
245
- return addPublisherToSearch({query, queryIsOkAlone, addYear});
233
+ return addPublisherToSearch({query, queryIsOkAlone, addYear, alternates, alternateQueries});
246
234
  //return [];
247
235
  }
248
236
 
249
- function addPublisherToSearch({query, queryIsOkAlone = false, addYear = false}) {
237
+ function addPublisherToSearch({query, queryIsOkAlone = false, addYear = false, alternates = false, alternateQueries = []}) {
250
238
  const [publisherQuery] = bibPublishers(record);
251
239
  if (publisherQuery !== undefined) {
252
240
  if (addYear) {
253
- return addYearToSearch({query: `${publisherQuery} AND ${query}`, queryIsOkAlone: true});
241
+ const newAlternateQueries = alternates ? [...alternateQueries, `${publisherQuery} AND ${query}`] : alternateQueries;
242
+ return addYearToSearch({query: `${publisherQuery} AND ${query}`, queryIsOkAlone: true, alternates, alternateQueries: newAlternateQueries});
254
243
  }
255
- return [`${publisherQuery} AND ${query}`];
244
+ return alternates ? alternateQueries : [`${publisherQuery} AND ${query}`];
256
245
  }
257
246
  if (queryIsOkAlone && !addYear) {
258
- return [`${query}`];
247
+ return alternates ? alternateQueries : [`${query}`];
259
248
  }
260
- return addYearToSearch({query, queryIsOkAlone});
249
+ return addYearToSearch({query, queryIsOkAlone, alternates, alternateQueries});
261
250
  }
262
251
 
263
- function addYearToSearch({query, queryIsOkAlone = false}) {
252
+ function addYearToSearch({query, queryIsOkAlone = false, alternates = false, alternateQueries = []}) {
264
253
  const [yearQuery] = bibYear(record);
265
254
  if (yearQuery !== undefined) {
266
- return [`${yearQuery} AND ${query}`];
255
+ const newAlternateQueries = alternates ? [...alternateQueries, `${yearQuery} AND ${query}`] : alternateQueries;
256
+ return alternates ? newAlternateQueries : [`${yearQuery} AND ${query}`];
267
257
  }
268
258
  if (queryIsOkAlone) {
269
- return [`${query}`];
259
+ return alternates ? alternateQueries : [`${query}`];
270
260
  }
271
261
  return [];
272
262
  }
@@ -296,8 +286,6 @@ export function bibAuthors(record) {
296
286
  //debugData(record);
297
287
 
298
288
  const author = getAuthor(record);
299
- const booleanStartWords = ['and', 'or', 'nor', 'not'];
300
-
301
289
 
302
290
  if (testStringOrNumber(author)) {
303
291
  const formatted = String(author)
@@ -309,7 +297,7 @@ export function bibAuthors(record) {
309
297
  .trim();
310
298
 
311
299
  // use word search for authors starting with a boolean
312
- const useWordSearch = booleanStartWords.some(word => formatted.toLowerCase().startsWith(word));
300
+ const useWordSearch = checkUseWordSearch(formatted);
313
301
  // Prevent too many matches by having a minimum length
314
302
  debugData(`Author string: ${formatted}`);
315
303
  if (formatted.length >= 5) {
@@ -384,7 +372,6 @@ export function bibYear(record) {
384
372
  const debug = createDebugLogger('@natlibfi/melinda-record-matching:candidate-search:query:bibYear');
385
373
  const debugData = debug.extend('data');
386
374
  debug(`Creating query for the publishing year`);
387
- debugData(record);
388
375
 
389
376
  const year = getYear(record);
390
377
  if (year) {
@@ -393,11 +380,23 @@ export function bibYear(record) {
393
380
  return [];
394
381
 
395
382
  function getYear(record) {
396
- const value = record.get(/^008$/u)?.[0]?.value || undefined;
383
+ const [f008] = record.get(/^008$/u);
384
+ if (f008 === undefined) {
385
+ debug('f008 missing');
386
+ return false;
387
+ }
388
+
389
+ debugData(`f008: ${JSON.stringify(f008)}`);
390
+ const {value} = f008;
397
391
  return testStringOrNumber(value) ? String(value).slice(7, 11) : undefined;
398
392
  }
399
393
  }
400
394
 
395
+ export function checkUseWordSearch(formatted) {
396
+ // Note: add a space to startWords to catch just actual boolean words
397
+ const booleanStartWords = ['and ', 'or ', 'nor ', 'not '];
398
+ return booleanStartWords.some(word => formatted.toLowerCase().startsWith(word));
399
+ }
401
400
 
402
401
  export function bibStandardIdentifiers(record) {
403
402
 
@@ -0,0 +1,38 @@
1
+ import assert from 'node:assert';
2
+ import {describe} from 'node:test';
3
+ import createDebugLogger from 'debug';
4
+ import generateTests from '@natlibfi/fixugen';
5
+ import {READERS} from '@natlibfi/fixura';
6
+ import {MarcRecord} from '@natlibfi/marc-record';
7
+ import * as generators from './bib.js';
8
+
9
+ const debug = createDebugLogger('@natlibfi/melinda-record-matching:candidate-search:query:bibHostComponents:test');
10
+ const debugData = debug.extend('data');
11
+
12
+ describe('candidate-search/query-list/bib/', () => {
13
+ generateTests({
14
+ path: [import.meta.dirname, '..', '..', '..', 'test-fixtures', 'candidate-search', 'query-list', 'bib'],
15
+ useMetadataFile: true,
16
+ fixura: {
17
+ reader: READERS.JSON
18
+ },
19
+ callback: ({type, inputRecord, expectedQuery, expectedQueryListType, enabled = true}) => {
20
+ const generate = generators[type];
21
+ const record = new MarcRecord(inputRecord, {subfieldValues: false});
22
+
23
+ if (!enabled) {
24
+ return;
25
+ }
26
+
27
+ const result = generate(record);
28
+ debugData(`Result: ${JSON.stringify(result)}`);
29
+
30
+ if (result.queryListType) {
31
+ assert.deepStrictEqual(result.queryList, expectedQuery);
32
+ assert.equal(result.queryListType, expectedQueryListType);
33
+ return;
34
+ }
35
+ assert.deepStrictEqual(result, expectedQuery);
36
+ }
37
+ });
38
+ });