@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.
- package/.github/workflows/melinda-node-tests.yml +13 -9
- package/LICENSE.txt +21 -165
- package/README.md +2 -2
- package/eslint.config.js +52 -0
- package/example.env +4 -0
- package/package.json +29 -79
- package/src/candidate-search/candidate-search-utils.js +1 -28
- package/src/candidate-search/choose-queries.js +93 -0
- package/src/candidate-search/index.js +85 -119
- package/src/candidate-search/{index.spec.js → index.test.js} +30 -43
- package/src/candidate-search/query-list/bib.js +54 -55
- package/src/candidate-search/query-list/bib.test.js +38 -0
- package/src/candidate-search/query-list/component.js +127 -0
- package/src/candidate-search/query-list/index.js +31 -35
- package/src/cli.js +177 -0
- package/src/index.js +27 -68
- package/src/{index.spec.js → index.test.js} +11 -42
- package/src/match-detection/features/bib/all-source-ids.js +2 -29
- package/src/match-detection/features/bib/authors.js +5 -30
- package/src/match-detection/features/bib/bibliographic-level.js +0 -27
- package/src/match-detection/features/bib/host-component.js +0 -27
- package/src/match-detection/features/bib/index.js +16 -43
- package/src/match-detection/features/bib/index.test.js +52 -0
- package/src/match-detection/features/bib/isbn.js +2 -29
- package/src/match-detection/features/bib/issn.js +1 -28
- package/src/match-detection/features/bib/language.js +5 -32
- package/src/match-detection/features/bib/media-type.js +2 -28
- package/src/match-detection/features/bib/melinda-id.js +1 -28
- package/src/match-detection/features/bib/melinda-identifier-factory.js +5 -32
- package/src/match-detection/features/bib/other-standard-identifier.js +1 -28
- package/src/match-detection/features/bib/publication-time-allow-cons-years-multi.js +7 -34
- package/src/match-detection/features/bib/publication-time-allow-cons-years.js +1 -28
- package/src/match-detection/features/bib/publication-time.js +2 -28
- package/src/match-detection/features/bib/record-type.js +0 -27
- package/src/match-detection/features/bib/standard-identifier-factory.js +1 -28
- package/src/match-detection/features/bib/title-version-original.js +2 -28
- package/src/match-detection/features/bib/title.js +6 -34
- package/src/match-detection/features/index.js +1 -28
- package/src/match-detection/index.js +1 -28
- package/src/match-detection/{index.spec.js → index.test.js} +9 -35
- package/src/matching-utils.js +8 -37
- package/dist/candidate-search/candidate-search-utils.js +0 -55
- package/dist/candidate-search/candidate-search-utils.js.map +0 -1
- package/dist/candidate-search/index.js +0 -254
- package/dist/candidate-search/index.js.map +0 -1
- package/dist/candidate-search/index.spec.js +0 -144
- package/dist/candidate-search/index.spec.js.map +0 -1
- package/dist/candidate-search/query-list/bib.js +0 -439
- package/dist/candidate-search/query-list/bib.js.map +0 -1
- package/dist/candidate-search/query-list/bib.spec.js +0 -64
- package/dist/candidate-search/query-list/bib.spec.js.map +0 -1
- package/dist/candidate-search/query-list/index.js +0 -66
- package/dist/candidate-search/query-list/index.js.map +0 -1
- package/dist/index.js +0 -579
- package/dist/index.js.map +0 -1
- package/dist/index.spec.js +0 -127
- package/dist/index.spec.js.map +0 -1
- package/dist/match-detection/features/bib/all-source-ids.js +0 -119
- package/dist/match-detection/features/bib/all-source-ids.js.map +0 -1
- package/dist/match-detection/features/bib/authors.js +0 -113
- package/dist/match-detection/features/bib/authors.js.map +0 -1
- package/dist/match-detection/features/bib/bibliographic-level.js +0 -42
- package/dist/match-detection/features/bib/bibliographic-level.js.map +0 -1
- package/dist/match-detection/features/bib/host-component.js +0 -42
- package/dist/match-detection/features/bib/host-component.js.map +0 -1
- package/dist/match-detection/features/bib/index.js +0 -119
- package/dist/match-detection/features/bib/index.js.map +0 -1
- package/dist/match-detection/features/bib/index.spec.js +0 -93
- package/dist/match-detection/features/bib/index.spec.js.map +0 -1
- package/dist/match-detection/features/bib/isbn.js +0 -77
- package/dist/match-detection/features/bib/isbn.js.map +0 -1
- package/dist/match-detection/features/bib/issn.js +0 -51
- package/dist/match-detection/features/bib/issn.js.map +0 -1
- package/dist/match-detection/features/bib/language.js +0 -141
- package/dist/match-detection/features/bib/language.js.map +0 -1
- package/dist/match-detection/features/bib/media-type.js +0 -83
- package/dist/match-detection/features/bib/media-type.js.map +0 -1
- package/dist/match-detection/features/bib/melinda-id.js +0 -48
- package/dist/match-detection/features/bib/melinda-id.js.map +0 -1
- package/dist/match-detection/features/bib/melinda-identifier-factory.js +0 -89
- package/dist/match-detection/features/bib/melinda-identifier-factory.js.map +0 -1
- package/dist/match-detection/features/bib/other-standard-identifier.js +0 -52
- package/dist/match-detection/features/bib/other-standard-identifier.js.map +0 -1
- package/dist/match-detection/features/bib/publication-time-allow-cons-years-multi.js +0 -256
- package/dist/match-detection/features/bib/publication-time-allow-cons-years-multi.js.map +0 -1
- package/dist/match-detection/features/bib/publication-time-allow-cons-years.js +0 -71
- package/dist/match-detection/features/bib/publication-time-allow-cons-years.js.map +0 -1
- package/dist/match-detection/features/bib/publication-time.js +0 -48
- package/dist/match-detection/features/bib/publication-time.js.map +0 -1
- package/dist/match-detection/features/bib/record-type.js +0 -45
- package/dist/match-detection/features/bib/record-type.js.map +0 -1
- package/dist/match-detection/features/bib/standard-identifier-factory.js +0 -215
- package/dist/match-detection/features/bib/standard-identifier-factory.js.map +0 -1
- package/dist/match-detection/features/bib/title-version-original.js +0 -79
- package/dist/match-detection/features/bib/title-version-original.js.map +0 -1
- package/dist/match-detection/features/bib/title.js +0 -98
- package/dist/match-detection/features/bib/title.js.map +0 -1
- package/dist/match-detection/features/index.js +0 -11
- package/dist/match-detection/features/index.js.map +0 -1
- package/dist/match-detection/index.js +0 -201
- package/dist/match-detection/index.js.map +0 -1
- package/dist/match-detection/index.spec.js +0 -91
- package/dist/match-detection/index.spec.js.map +0 -1
- package/dist/matching-utils.js +0 -143
- package/dist/matching-utils.js.map +0 -1
- package/src/candidate-search/query-list/bib.spec.js +0 -54
- 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
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
86
|
-
|
|
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,
|
|
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
|
|
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,
|
|
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,
|
|
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: [],
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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: [
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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) {
|
|
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
|
-
|
|
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
|
-
|
|
81
|
+
assert.match(errorMessage, new RegExp(expectedSearchError, 'u'));
|
|
93
82
|
|
|
94
83
|
if (expectedErrorStatus) {
|
|
95
|
-
|
|
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
|
-
|
|
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('
|
|
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
|
|
206
|
-
debug('
|
|
207
|
-
|
|
208
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
|
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
|
+
});
|