hlquery-node-client 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,196 @@
1
+ /**
2
+ * hlquery Node.js Client - Optional PDF parsing helper
3
+ *
4
+ * Copyright (C) 2021-2026, Carlos F. Ferry <carlos.ferry@gmail.com>
5
+ *
6
+ * This file is part of hlquery, released under the BSD License version 3.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const crypto = require('crypto');
12
+ const { ValidationException } = require('../lib/Exceptions');
13
+
14
+ class PDFParser {
15
+ /**
16
+ * Parse a PDF file from disk.
17
+ *
18
+ * @param {string} filePath
19
+ * @param {object} options
20
+ * @returns {Promise<object>}
21
+ */
22
+ static async parseFile(filePath, options = {}) {
23
+ this.validateFilePath(filePath);
24
+
25
+ const resolvedPath = path.resolve(filePath);
26
+ const stat = fs.statSync(resolvedPath);
27
+
28
+ if (!stat.isFile()) {
29
+ throw new ValidationException(`PDF path is not a file: ${resolvedPath}`);
30
+ }
31
+
32
+ const pdfParse = this.loadPDFParse();
33
+ const buffer = fs.readFileSync(resolvedPath);
34
+ const parsed = await pdfParse(buffer);
35
+ const metadata = parsed && parsed.metadata && typeof parsed.metadata === 'object'
36
+ ? parsed.metadata
37
+ : {};
38
+
39
+ const content = this.normalizeText(parsed && parsed.text ? parsed.text : '', options);
40
+ const title = this.resolveTitle(resolvedPath, metadata, options);
41
+ const fileName = path.basename(resolvedPath);
42
+
43
+ return {
44
+ filePath: resolvedPath,
45
+ fileName: fileName,
46
+ title: title,
47
+ content: content,
48
+ pageCount: parsed && typeof parsed.numpages === 'number' ? parsed.numpages : null,
49
+ info: parsed && parsed.info ? parsed.info : {},
50
+ metadata: metadata,
51
+ version: parsed && parsed.version ? parsed.version : null,
52
+ sizeBytes: stat.size
53
+ };
54
+ }
55
+
56
+ /**
57
+ * Build a standard hlquery document from parsed PDF data.
58
+ *
59
+ * @param {object} parsed
60
+ * @param {object} options
61
+ * @returns {object}
62
+ */
63
+ static buildDocumentFromParsedPDF(parsed, options = {}) {
64
+ const contentField = options.contentField || 'content';
65
+ const titleField = options.titleField || 'title';
66
+ const document = Object.assign({}, options.document || {});
67
+
68
+ document.id = options.id || document.id || this.generateDocumentId(parsed.fileName);
69
+ document[titleField] = options.title || document[titleField] || parsed.title;
70
+ document[contentField] = parsed.content;
71
+
72
+ if (options.includeMetadata !== false) {
73
+ document.file_name = parsed.fileName;
74
+ document.file_path = parsed.filePath;
75
+ document.mime_type = 'application/pdf';
76
+
77
+ if (parsed.pageCount !== null) {
78
+ document.page_count = String(parsed.pageCount);
79
+ }
80
+
81
+ if (parsed.sizeBytes !== null) {
82
+ document.file_size_bytes = String(parsed.sizeBytes);
83
+ }
84
+
85
+ this.attachMetadata(document, parsed.info, 'pdf_info', options);
86
+ this.attachMetadata(document, parsed.metadata, 'pdf_meta', options);
87
+ }
88
+
89
+ return document;
90
+ }
91
+
92
+ static attachMetadata(document, metadata, prefix, options = {}) {
93
+ if (!metadata || typeof metadata !== 'object') {
94
+ return;
95
+ }
96
+
97
+ const fieldPrefix = options.metadataPrefix || prefix;
98
+
99
+ for (const [key, value] of Object.entries(metadata)) {
100
+ if (value === null || value === undefined) {
101
+ continue;
102
+ }
103
+
104
+ const normalizedKey = this.normalizeFieldName(`${fieldPrefix}_${key}`);
105
+ const normalizedValue = this.normalizeMetadataValue(value, options);
106
+
107
+ if (normalizedKey && normalizedValue !== '') {
108
+ document[normalizedKey] = normalizedValue;
109
+ }
110
+ }
111
+ }
112
+
113
+ static normalizeMetadataValue(value, options = {}) {
114
+ if (typeof value === 'string') {
115
+ return this.normalizeText(value, options);
116
+ }
117
+
118
+ if (typeof value === 'number' || typeof value === 'boolean') {
119
+ return String(value);
120
+ }
121
+
122
+ return this.normalizeText(JSON.stringify(value), options);
123
+ }
124
+
125
+ static normalizeFieldName(name) {
126
+ return String(name)
127
+ .trim()
128
+ .toLowerCase()
129
+ .replace(/[^a-z0-9_]+/g, '_')
130
+ .replace(/^_+|_+$/g, '')
131
+ .slice(0, 64);
132
+ }
133
+
134
+ static normalizeText(text, options = {}) {
135
+ let output = String(text || '');
136
+
137
+ output = output.replace(/\r\n/g, '\n');
138
+
139
+ if (options.stripNullBytes !== false) {
140
+ output = output.replace(/\0/g, '');
141
+ }
142
+
143
+ if (options.sanitizeCommas !== false) {
144
+ output = output.replace(/,/g, ' ');
145
+ }
146
+
147
+ if (options.collapseWhitespace !== false) {
148
+ output = output.replace(/[ \t]+/g, ' ');
149
+ output = output.replace(/\n{3,}/g, '\n\n');
150
+ }
151
+
152
+ return output.trim();
153
+ }
154
+
155
+ static resolveTitle(filePath, metadata, options = {}) {
156
+ if (options.title) {
157
+ return this.normalizeText(options.title, options);
158
+ }
159
+
160
+ if (metadata && typeof metadata.Title === 'string' && metadata.Title.trim() !== '') {
161
+ return this.normalizeText(metadata.Title, options);
162
+ }
163
+
164
+ return this.normalizeText(path.basename(filePath, path.extname(filePath)), options);
165
+ }
166
+
167
+ static generateDocumentId(fileName) {
168
+ const stem = path.basename(fileName, path.extname(fileName))
169
+ .toLowerCase()
170
+ .replace(/[^a-z0-9._-]+/g, '_')
171
+ .replace(/^_+|_+$/g, '')
172
+ .slice(0, 40);
173
+ const suffix = crypto.randomBytes(4).toString('hex');
174
+
175
+ return `${stem || 'pdf'}_${suffix}`;
176
+ }
177
+
178
+ static validateFilePath(filePath) {
179
+ if (!filePath || typeof filePath !== 'string') {
180
+ throw new ValidationException('PDF file path must be a non-empty string');
181
+ }
182
+ }
183
+
184
+ static loadPDFParse() {
185
+ try {
186
+ return require('pdf-parse');
187
+ } catch (error) {
188
+ throw new ValidationException(
189
+ 'PDF parsing requires the optional dependency `pdf-parse`. ' +
190
+ 'Install it in etc/api/node with `npm install pdf-parse`.'
191
+ );
192
+ }
193
+ }
194
+ }
195
+
196
+ module.exports = PDFParser;
@@ -0,0 +1,143 @@
1
+ /**
2
+ * hlquery Node.js Client - Input Validation
3
+ *
4
+ * Copyright (C) 2021-2026, Carlos F. Ferry <carlos.ferry@gmail.com>
5
+ *
6
+ * This file is part of hlquery, released under the BSD License version 3.
7
+ */
8
+
9
+ const { ValidationException } = require('../lib/Exceptions');
10
+
11
+ /**
12
+ * Input validation utilities
13
+ */
14
+ class Validator {
15
+ /**
16
+ * Validate collection name
17
+ */
18
+ static validateCollectionName(name) {
19
+ if (!name || typeof name !== 'string' || name.trim() === '') {
20
+ throw new ValidationException('Collection name must be a non-empty string');
21
+ }
22
+
23
+ // Check name length (matches server validation: 1-64 characters)
24
+ if (name.length > 64) {
25
+ throw new ValidationException('Collection name must be between 1 and 64 characters');
26
+ }
27
+
28
+ // Collection names should be URL-safe
29
+ if (!/^[a-zA-Z0-9_-]+$/.test(name)) {
30
+ throw new ValidationException(
31
+ 'Collection name contains invalid characters. ' +
32
+ 'Use only letters, numbers, underscores, and hyphens'
33
+ );
34
+ }
35
+
36
+ // Check if name starts with letter or underscore (matches server validation)
37
+ const firstChar = name[0];
38
+ if (!/[a-zA-Z]/.test(firstChar) && firstChar !== '_') {
39
+ throw new ValidationException('Collection name must start with a letter or underscore');
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Validate document ID
45
+ */
46
+ static validateDocumentId(id) {
47
+ if (!id || (typeof id !== 'string' && typeof id !== 'number')) {
48
+ throw new ValidationException('Document ID must be a non-empty string or number');
49
+ }
50
+
51
+ // Convert to string for validation
52
+ const idStr = String(id);
53
+
54
+ // Check name length (matches server validation: 1-64 characters)
55
+ if (idStr.length > 64) {
56
+ throw new ValidationException('Document ID must be between 1 and 64 characters');
57
+ }
58
+
59
+ // Document IDs should be URL-safe: alphanumeric, underscores, and hyphens only
60
+ if (!/^[a-zA-Z0-9_-]+$/.test(idStr)) {
61
+ throw new ValidationException(
62
+ 'Document ID contains invalid characters. ' +
63
+ 'Use only letters, numbers, underscores, and hyphens'
64
+ );
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Validate pagination parameters
70
+ */
71
+ static validatePagination(offset, limit) {
72
+ if (offset !== undefined && (typeof offset !== 'number' || offset < 0)) {
73
+ throw new ValidationException('Offset must be a non-negative number');
74
+ }
75
+ if (limit !== undefined && (typeof limit !== 'number' || limit < 1)) {
76
+ throw new ValidationException('Limit must be a positive number');
77
+ }
78
+ }
79
+
80
+ /**
81
+ * Validate search parameters
82
+ */
83
+ static validateSearchParams(params) {
84
+ if (params && typeof params !== 'object') {
85
+ throw new ValidationException('Search parameters must be an object');
86
+ }
87
+
88
+ if (params) {
89
+ if (params.offset !== undefined && (typeof params.offset !== 'number' || params.offset < 0)) {
90
+ throw new ValidationException('Offset must be a non-negative number');
91
+ }
92
+ if (params.limit !== undefined && (typeof params.limit !== 'number' || params.limit < 1)) {
93
+ throw new ValidationException('Limit must be a positive number');
94
+ }
95
+ if (params.from !== undefined && (typeof params.from !== 'number' || params.from < 0)) {
96
+ throw new ValidationException('From must be a non-negative number');
97
+ }
98
+ if (params.size !== undefined && (typeof params.size !== 'number' || params.size < 1)) {
99
+ throw new ValidationException('Size must be a positive number');
100
+ }
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Validate document field values for invalid characters
106
+ * Commas are not allowed in string field values as they're reserved for internal parsing
107
+ *
108
+ * @param {object} document - Document object to validate
109
+ * @throws {ValidationException} If document contains invalid characters
110
+ */
111
+ static validateDocumentFields(document) {
112
+ if (!document || typeof document !== 'object' || Array.isArray(document)) {
113
+ return; // Skip validation for non-objects or arrays (will be validated per-item)
114
+ }
115
+
116
+ for (const [key, value] of Object.entries(document)) {
117
+ // Skip the 'id' field as it has its own validation
118
+ if (key === 'id') continue;
119
+
120
+ // Check string values for commas
121
+ if (typeof value === 'string' && value.includes(',')) {
122
+ throw new ValidationException(
123
+ `Field '${key}' contains invalid character: comma (`,`). ` +
124
+ `Commas are not allowed in field values. Use underscores (_) or spaces instead, or use arrays for multiple values.`
125
+ );
126
+ }
127
+
128
+ // Check array values - ensure they don't contain strings with commas
129
+ if (Array.isArray(value)) {
130
+ for (const item of value) {
131
+ if (typeof item === 'string' && item.includes(',')) {
132
+ throw new ValidationException(
133
+ `Field '${key}' contains invalid character: comma (`,`). ` +
134
+ `Array items cannot contain commas. Use underscores (_) or spaces instead.`
135
+ );
136
+ }
137
+ }
138
+ }
139
+ }
140
+ }
141
+ }
142
+
143
+ module.exports = Validator;
@@ -0,0 +1,51 @@
1
+ /**
2
+ * hlquery Node.js Client - Ranking Helpers
3
+ */
4
+
5
+ const DEFAULT_WEIGHTS = {
6
+ popularity_log: 1.15,
7
+ hit_log: 0.95,
8
+ popularity_sqrt: 0.25,
9
+ hit_log_sqrt: 0.15,
10
+ };
11
+
12
+ function computeRankSignal(popularity, hitLog, weights = {}) {
13
+ const w = { ...DEFAULT_WEIGHTS };
14
+
15
+ for (const key of Object.keys(weights)) {
16
+ if (Object.prototype.hasOwnProperty.call(w, key) && typeof weights[key] === 'number') {
17
+ w[key] = weights[key];
18
+ }
19
+ }
20
+
21
+ return (
22
+ Math.log(popularity + 1) * w.popularity_log
23
+ + Math.log(hitLog + 1) * w.hit_log
24
+ + Math.sqrt(popularity) * w.popularity_sqrt
25
+ + Math.sqrt(hitLog) * w.hit_log_sqrt
26
+ );
27
+ }
28
+
29
+ function attachRankSort(params, field = 'rank_signal', direction = 'desc') {
30
+ if (!params || typeof params !== 'object') {
31
+ return;
32
+ }
33
+
34
+ direction = direction.toLowerCase();
35
+ if (direction !== 'asc' && direction !== 'desc') {
36
+ direction = 'desc';
37
+ }
38
+
39
+ const sortInstruction = `${field}:${direction}`;
40
+
41
+ if (typeof params.sort_by === 'string' && params.sort_by.trim()) {
42
+ params.sort_by = `${params.sort_by},${sortInstruction}`;
43
+ } else {
44
+ params.sort_by = sortInstruction;
45
+ }
46
+ }
47
+
48
+ module.exports = {
49
+ computeRankSignal,
50
+ attachRankSort
51
+ };