• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

bpatrik / pigallery2 / 20490077344

24 Dec 2025 04:25PM UTC coverage: 68.255% (+0.2%) from 68.02%
20490077344

Pull #1106

github

web-flow
Merge 494bf5444 into 22640d71e
Pull Request #1106: Search improvements

1477 of 2426 branches covered (60.88%)

Branch coverage included in aggregate %.

91 of 95 new or added lines in 3 files covered. (95.79%)

2 existing lines in 2 files now uncovered.

5343 of 7566 relevant lines covered (70.62%)

4263.13 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.35
/src/common/SearchQueryParser.ts
1
import {
1✔
2
  ANDSearchQuery,
3
  DatePatternFrequency,
4
  DatePatternSearch,
5
  DistanceSearch,
6
  NegatableSearchQuery,
7
  OrientationSearch,
8
  ORSearchQuery,
9
  RangeSearch,
10
  SearchListQuery,
11
  SearchQueryDTO,
12
  SearchQueryTypes,
13
  SomeOfSearchQuery,
14
  TextSearch,
15
  TextSearchQueryMatchTypes,
16
  TextSearchQueryTypes,
17
} from './entities/SearchQueryDTO';
18
import {Utils} from './Utils';
1✔
19

20
export interface QueryKeywords {
21
  days_ago: string;
22
  years_ago: string;
23
  months_ago: string;
24
  weeks_ago: string;
25
  every_year: string;
26
  every_month: string;
27
  every_week: string;
28
  lastNDays: string;
29
  sameDay: string;
30
  portrait: string;
31
  landscape: string;
32
  orientation: string;
33
  kmFrom: string;
34
  resolution: string;
35
  rating: string;
36
  personCount: string;
37
  NSomeOf: string;
38
  someOf: string;
39
  or: string;
40
  and: string;
41
  date: string;
42
  any_text: string;
43
  caption: string;
44
  directory: string;
45
  file_name: string;
46
  keyword: string;
47
  person: string;
48
  position: string;
49
}
50

51
export const defaultQueryKeywords: QueryKeywords = {
1✔
52
  NSomeOf: 'of',
53
  and: 'and',
54
  or: 'or',
55

56
  date: 'date',
57

58
  rating: 'rating',
59
  personCount: 'person-count',
60
  resolution: 'resolution',
61

62
  kmFrom: 'km-from',
63
  orientation: 'orientation',
64
  landscape: 'landscape',
65
  portrait: 'portrait',
66

67

68
  years_ago: '%d-years-ago',
69
  months_ago: '%d-months-ago',
70
  weeks_ago: '%d-weeks-ago',
71
  days_ago: '%d-days-ago',
72
  every_year: 'every-year',
73
  every_month: 'every-month',
74
  every_week: 'every-week',
75
  lastNDays: 'last-%d-days',
76
  sameDay: 'same-day',
77

78
  any_text: 'any-text',
79
  keyword: 'keyword',
80
  caption: 'caption',
81
  directory: 'directory',
82
  file_name: 'file-name',
83
  person: 'person',
84
  position: 'position',
85
  someOf: 'some-of',
86
};
87

88
export class SearchQueryParser {
1✔
89
  constructor(private keywords: QueryKeywords = defaultQueryKeywords) {
235✔
90
  }
91

92
  public static stringifyText(
93
    text: string,
94
    matchType = TextSearchQueryMatchTypes.like
864,303✔
95
  ): string {
96
    if (matchType === TextSearchQueryMatchTypes.exact_match) {
864,326✔
97
      return '"' + text + '"';
23✔
98
    }
99
    if (text.indexOf(' ') !== -1) {
864,303✔
100
      return '(' + text + ')';
48✔
101
    }
102
    return text;
864,255✔
103
  }
104

105
  public static stringifyDate(time: number): string {
106
    if (!time) {
58✔
107
      return null;
21✔
108
    }
109
    const date = new Date(time);
37✔
110

111
    // simplify date with yeah only if its first of jan
112
    if (date.getMonth() === 0 && date.getDate() === 1) {
37✔
113
      return date.getFullYear().toString();
13✔
114
    }
115
    return this.stringifyText(date.toISOString().substring(0, 10));
24✔
116
  }
117

118
  public static humanToRegexpStr(str: string) {
119
    return str.replace(/%d/g, '\\d*');
476✔
120
  }
121

122
  /**
123
   * Returns the number of milliseconds between midnight, January 1, 1970 Universal Coordinated Time (UTC) (or GMT) and the specified date.
124
   * @param text
125
   * @private
126
   */
127
  private static parseDate(text: string): number {
128
    if (text.charAt(0) === '"' || text.charAt(0) === '(') {
25!
129
      text = text.substring(1);
×
130
    }
131
    if (
25!
132
      text.charAt(text.length - 1) === '"' ||
50✔
133
      text.charAt(text.length - 1) === ')'
134
    ) {
135
      text = text.substring(0, text.length - 1);
×
136
    }
137
    // it is the year only
138
    if (text.length === 4) {
25✔
139
      return Date.UTC(parseInt(text, 10), 0, 1, 0, 0, 0, 0);
11✔
140
    }
141
    let timestamp = null;
14✔
142
    // Parsing ISO string
143
    try {
14✔
144
      const parts = text.split('-').map((t) => parseInt(t, 10));
41✔
145
      if (parts && parts.length === 2) {
14✔
146
        timestamp = Date.UTC(parts[0], parts[1] - 1, 1, 0, 0, 0, 0); // Note: months are 0-based
1✔
147
      }
148
      if (parts && parts.length === 3) {
14✔
149
        timestamp = Date.UTC(parts[0], parts[1] - 1, parts[2], 0, 0, 0, 0); // Note: months are 0-based
13✔
150
      }
151
      // eslint-disable-next-line @typescript-eslint/no-unused-vars
152
    } catch (e) {
153
      // ignoring errors
154
    }
155
    // If it could not parse as ISO string, try our luck with Date.parse
156
    // https://stackoverflow.com/questions/2587345/why-does-date-parse-give-incorrect-results
157
    if (timestamp === null) {
14!
158
      timestamp = Date.parse(text);
×
159
    }
160
    if (isNaN(timestamp) || timestamp === null) {
14!
161
      throw new Error('Cannot parse date: ' + text);
×
162
    }
163

164
    return timestamp;
14✔
165
  }
166

167
  public parse(str: string, implicitAND = true): SearchQueryDTO {
259✔
168
    str = str
309✔
169
      .replace(/\s\s+/g, ' ') // remove double spaces
170
      .replace(/:\s+/g, ':')
171
      .trim();
172

173

174
    const intFromRegexp = (str: string) => {
309✔
175
      const numSTR = new RegExp(/\d+/).exec(str);
164✔
176
      if (!numSTR) {
164!
177
        return 0;
×
178
      }
179
      return parseInt(numSTR[0], 10);
164✔
180
    };
181
    if (str.charAt(0) === '(' && str.charAt(str.length - 1) === ')') {
309✔
182
      str = str.slice(1, str.length - 1);
2✔
183
    }
184
    const fistSpace = (start = 0) => {
309✔
185
      const bracketIn = [];
309✔
186
      let quotationMark = false;
309✔
187
      for (let i = start; i < str.length; ++i) {
309✔
188
        if (str.charAt(i) === '"') {
5,042✔
189
          quotationMark = !quotationMark;
40✔
190
          continue;
40✔
191
        }
192
        if (str.charAt(i) === '(') {
5,002✔
193
          bracketIn.push(i);
68✔
194
          continue;
68✔
195
        }
196
        if (str.charAt(i) === ')') {
4,934✔
197
          bracketIn.pop();
68✔
198
          continue;
68✔
199
        }
200

201
        if (
4,866✔
202
          quotationMark === false &&
13,557✔
203
          bracketIn.length === 0 &&
204
          str.charAt(i) === ' '
205
        ) {
206
          return i;
22✔
207
        }
208
      }
209
      return str.length - 1;
287✔
210
    };
211

212
    // tokenize
213
    const tokenEnd = fistSpace();
309✔
214

215
    if (tokenEnd !== str.length - 1) {
309✔
216
      if (str.startsWith(' ' + this.keywords.and, tokenEnd)) {
22✔
217
        const rest = this.parse(
8✔
218
          str.slice(tokenEnd + (' ' + this.keywords.and).length),
219
          implicitAND
220
        );
221
        return {
8✔
222
          type: SearchQueryTypes.AND,
223
          list: [
224
            this.parse(str.slice(0, tokenEnd), implicitAND), // trim brackets
225
            ...(rest.type === SearchQueryTypes.AND
8✔
226
              ? (rest as SearchListQuery).list
227
              : [rest]),
228
          ],
229
        } as ANDSearchQuery;
230
      } else if (str.startsWith(' ' + this.keywords.or, tokenEnd)) {
14✔
231
        const rest = this.parse(
4✔
232
          str.slice(tokenEnd + (' ' + this.keywords.or).length),
233
          implicitAND
234
        );
235
        return {
4✔
236
          type: SearchQueryTypes.OR,
237
          list: [
238
            this.parse(str.slice(0, tokenEnd), implicitAND), // trim brackets
239
            ...(rest.type === SearchQueryTypes.OR
4✔
240
              ? (rest as SearchListQuery).list
241
              : [rest]),
242
          ],
243
        } as ORSearchQuery;
244
      } else {
245
        // Relation cannot be detected
246
        const t =
247
          implicitAND === true
10✔
248
            ? SearchQueryTypes.AND
249
            : SearchQueryTypes.UNKNOWN_RELATION;
250
        const rest = this.parse(str.slice(tokenEnd), implicitAND);
10✔
251
        return {
10✔
252
          type: t,
253
          list: [
254
            this.parse(str.slice(0, tokenEnd), implicitAND), // trim brackets
255
            ...(rest.type === t ? (rest as SearchListQuery).list : [rest]),
10✔
256
          ],
257
        } as SearchListQuery;
258
      }
259
    }
260
    if (
287✔
261
      str.startsWith(this.keywords.someOf + ':') ||
572✔
262
      new RegExp('^\\d*-' + this.keywords.NSomeOf + ':').test(str)
263
    ) {
264
      const prefix = str.startsWith(this.keywords.someOf + ':')
6✔
265
        ? this.keywords.someOf + ':'
266
        : new RegExp('^\\d*-' + this.keywords.NSomeOf + ':').exec(str)[0];
267
      let tmpList: SearchQueryDTO | SearchQueryDTO[] = this.parse(str.slice(prefix.length + 1, -1), false); // trim brackets
6✔
268

269
      const unfoldList = (q: SearchListQuery): SearchQueryDTO[] => {
6✔
270
        if (q.list) {
23✔
271
          if (q.type === SearchQueryTypes.UNKNOWN_RELATION) {
7✔
272
            return q.list.map((e) => unfoldList(e as SearchListQuery)).flat();  // flatten array
15✔
273
          } else {
274
            q.list.forEach((e) => unfoldList(e as SearchListQuery));
2✔
275
          }
276
        }
277
        return [q];
17✔
278
      };
279
      tmpList = unfoldList(tmpList as SearchListQuery);
6✔
280
      const ret = {
6✔
281
        type: SearchQueryTypes.SOME_OF,
282
        list: tmpList,
283
      } as SomeOfSearchQuery;
284
      if (new RegExp('^\\d*-' + this.keywords.NSomeOf + ':').test(str)) {
6✔
285
        ret.min = parseInt(new RegExp(/^\d*/).exec(str)[0], 10);
4✔
286
      }
287
      return ret;
6✔
288
    }
289

290
    const kwStartsWith = (s: string, kw: string): boolean => {
281✔
291
      return s.startsWith(kw + ':') || s.startsWith(kw + '!:');
320✔
292
    };
293

294
    const addValueRangeParser = (matcher: string, type: SearchQueryTypes): RangeSearch => {
281✔
295

296

297
      const value =
298
        matcher === 'date'
1,036✔
299
          ? '(\\d{4}(?:-\\d{1,2})?(?:-\\d{1,2})?)' // YYYY-MM or YYYY-MM-DD
300
          : '(\\d+)';                     // number
301
      /**
302
       * Matching:
303
       * rating:4..6
304
       * rating:4
305
       * rating=4
306
       * rating!>3
307
       * rating>3
308
       * rating!>=3
309
       * rating>=3
310
       * rating!<3
311
       * rating<3
312
       * rating!<=3
313
       * rating<=3
314
       */
315
      const regex = new RegExp(
1,036✔
316
        `^${matcher}(!?[:=]|!?[<>]=?)${value}(?:\\.\\.${value})?$`
317
      );
318

319
      const m = str.match(regex);
1,036✔
320
      if (!m) {
1,036✔
321
        return null;
986✔
322
      }
323

324
      let relation = m[1];
50✔
325
      const rawA = m[2];
50✔
326
      const rawB = m[3];
50✔
327

328
      const toValue =
329
        matcher === this.keywords.date
50✔
330
          ? (v: string) => SearchQueryParser.parseDate(v)
25✔
331
          : (v: string) => Number(v);
33✔
332

333
      const addValue =
334
        matcher === this.keywords.date
50✔
335
          ? (v: number, a: number) => v + a * 24 * 60 * 60 * 1000
2✔
336
          : (v: number, a: number) => v + a;
5✔
337

338

339
      const a = toValue(rawA);
50✔
340
      const b = rawB !== undefined ? toValue(rawB) : undefined;
50✔
341

342
      let negate = false;
50✔
343
      if (relation.startsWith('!')) {
50✔
344
        negate = true;
18✔
345
        relation = relation.slice(1);
18✔
346
      }
347
      const base =
348
        relation === '='
50✔
349
          ? {type, min: a, max: a}
350
          : relation === ':'
44✔
351
            ? b === undefined
9✔
352
              ? {type, min: a, max: a}
353
              : {type, min: a, max: b}
354
            : relation === '>='
35✔
355
              ? {type, min: a}
356
              : relation === '>'
19✔
357
                ? {type, min: addValue(a, 1)}
358
                : relation === '<='
15✔
359
                  ? {type, max: a}
360
                  : relation === '<'
3!
361
                    ? {type, max: addValue(a, -1)}
362
                    : null;
363

364
      if (!base) {
50!
NEW
365
        return null;
×
366
      }
367

368
      return negate ? {...base, negate: true} : base;
50✔
369

370
    };
371

372

373
    const range =
374
      addValueRangeParser(this.keywords.rating, SearchQueryTypes.rating) ||
281✔
375
      addValueRangeParser(this.keywords.personCount, SearchQueryTypes.person_count) ||
376
      addValueRangeParser(this.keywords.date, SearchQueryTypes.date) ||
377
      addValueRangeParser(this.keywords.resolution, SearchQueryTypes.resolution);
378

379
    if (range) {
281✔
380
      return range;
50✔
381
    }
382

383

384
    if (new RegExp('^\\d*-' + this.keywords.kmFrom + '!?:').test(str)) {
231✔
385
      let from = str.slice(
8✔
386
        new RegExp('^\\d*-' + this.keywords.kmFrom + '!?:').exec(str)[0].length
387
      );
388
      if (
8✔
389
        (from.charAt(0) === '(' && from.charAt(from.length - 1) === ')') ||
16!
390
        (from.charAt(0) === '"' && from.charAt(from.length - 1) === '"')
391
      ) {
392
        from = from.slice(1, from.length - 1);
8✔
393
      }
394

395
      // Check if the from part matches coordinate pattern (number, number)
396
      const coordMatch = from.match(/^\s*(-?\d+\.?\d*)\s*,\s*(-?\d+\.?\d*)\s*$/);
8✔
397
      if (coordMatch) {
8✔
398
        // It's a coordinate pair
399
        const latitude = parseFloat(coordMatch[1]);
6✔
400
        const longitude = parseFloat(coordMatch[2]);
6✔
401
        return {
6✔
402
          type: SearchQueryTypes.distance,
403
          distance: intFromRegexp(str),
404
          from: {
405
            GPSData: {
406
              latitude,
407
              longitude
408
            }
409
          },
410
          // only add negate if the value is true
411
          ...(new RegExp('^\\d*-' + this.keywords.kmFrom + '!:').test(str) && {
8✔
412
            negate: true,
413
          }),
414
        } as DistanceSearch;
415
      }
416

417
      // If not coordinates, treat as location text
418
      return {
2✔
419
        type: SearchQueryTypes.distance,
420
        distance: intFromRegexp(str),
421
        from: {value: from},
422
        // only add negate if the value is true
423
        ...(new RegExp('^\\d*-' + this.keywords.kmFrom + '!:').test(str) && {
3✔
424
          negate: true,
425
        }),
426
      } as DistanceSearch;
427
    }
428

429
    if (str.startsWith(this.keywords.orientation + ':')) {
223✔
430
      return {
2✔
431
        type: SearchQueryTypes.orientation,
432
        landscape:
433
          str.slice((this.keywords.orientation + ':').length) ===
434
          this.keywords.landscape,
435
      } as OrientationSearch;
436
    }
437

438

439
    if (kwStartsWith(str, this.keywords.sameDay) ||
221✔
440
      new RegExp('^' + SearchQueryParser.humanToRegexpStr(this.keywords.lastNDays) + '!?:').test(str)) {
441

442
      const freqStr = str.indexOf('!:') === -1 ? str.slice(str.indexOf(':') + 1) : str.slice(str.indexOf('!:') + 2);
99✔
443
      let freq: DatePatternFrequency = null;
99✔
444
      let ago;
445
      if (freqStr == this.keywords.every_week) {
99✔
446
        freq = DatePatternFrequency.every_week;
11✔
447
      } else if (freqStr == this.keywords.every_month) {
88✔
448
        freq = DatePatternFrequency.every_month;
11✔
449
      } else if (freqStr == this.keywords.every_year) {
77✔
450
        freq = DatePatternFrequency.every_year;
11✔
451
      } else if (new RegExp('^' + SearchQueryParser.humanToRegexpStr(this.keywords.days_ago) + '$').test(freqStr)) {
66✔
452
        freq = DatePatternFrequency.days_ago;
22✔
453
        ago = intFromRegexp(freqStr);
22✔
454
      } else if (new RegExp('^' + SearchQueryParser.humanToRegexpStr(this.keywords.weeks_ago) + '$').test(freqStr)) {
44✔
455
        freq = DatePatternFrequency.weeks_ago;
11✔
456
        ago = intFromRegexp(freqStr);
11✔
457
      } else if (new RegExp('^' + SearchQueryParser.humanToRegexpStr(this.keywords.months_ago) + '$').test(freqStr)) {
33✔
458
        freq = DatePatternFrequency.months_ago;
11✔
459
        ago = intFromRegexp(freqStr);
11✔
460
      } else if (new RegExp('^' + SearchQueryParser.humanToRegexpStr(this.keywords.years_ago) + '$').test(freqStr)) {
22✔
461
        freq = DatePatternFrequency.years_ago;
22✔
462
        ago = intFromRegexp(freqStr);
22✔
463
      }
464

465
      if (freq) {
99✔
466
        return {
99✔
467
          type: SearchQueryTypes.date_pattern,
468
          daysLength: kwStartsWith(str, this.keywords.sameDay) ? 0 : intFromRegexp(str),
99✔
469
          frequency: freq,
470
          ...((new RegExp('^' + SearchQueryParser.humanToRegexpStr(this.keywords.lastNDays) + '!:').test(str) ||
199✔
471
            str.startsWith(this.keywords.sameDay + '!:')) && {
472
            negate: true
473
          }),
474
          ...(!isNaN(ago) && {agoNumber: ago})
165✔
475
        } as DatePatternSearch;
476
      }
477
    }
478

479
    // parse text search
480
    const tmp = TextSearchQueryTypes.map((type) => ({
854✔
481
      key: (this.keywords as never)[SearchQueryTypes[type]] + ':',
482
      queryTemplate: {type, value: ''} as TextSearch,
483
    })).concat(
484
      TextSearchQueryTypes.map((type) => ({
854✔
485
        key: (this.keywords as never)[SearchQueryTypes[type]] + '!:',
486
        queryTemplate: {type, value: '', negate: true} as TextSearch,
487
      }))
488
    );
489
    for (const typeTmp of tmp) {
122✔
490
      if (str.startsWith(typeTmp.key)) {
1,112✔
491
        const ret: TextSearch = Utils.clone(typeTmp.queryTemplate);
64✔
492
        // exact match
493
        if (
64✔
494
          str.charAt(typeTmp.key.length) === '"' &&
81✔
495
          str.charAt(str.length - 1) === '"'
496
        ) {
497
          ret.value = str.slice(typeTmp.key.length + 1, str.length - 1);
17✔
498
          ret.matchType = TextSearchQueryMatchTypes.exact_match;
17✔
499
          // like match
500
        } else if (
47✔
501
          str.charAt(typeTmp.key.length) === '(' &&
71✔
502
          str.charAt(str.length - 1) === ')'
503
        ) {
504
          ret.value = str.slice(typeTmp.key.length + 1, str.length - 1);
24✔
505
        } else {
506
          ret.value = str.slice(typeTmp.key.length);
23✔
507
        }
508
        return ret;
64✔
509
      }
510
    }
511

512
    return {type: SearchQueryTypes.any_text, value: str} as TextSearch;
58✔
513
  }
514

515
  public stringify(query: SearchQueryDTO): string {
516
    const ret = this.stringifyOneEntry(query);
445✔
517
    if (ret.charAt(0) === '(' && ret.charAt(ret.length - 1) === ')') {
445✔
518
      return ret.slice(1, ret.length - 1);
32✔
519
    }
520
    return ret;
413✔
521
  }
522

523
  private stringifyOneEntry(query: SearchQueryDTO): string {
524
    if (!query || !query.type) {
1,374,117!
525
      return '';
×
526
    }
527
    const negateSign = (query as NegatableSearchQuery).negate === true ? '!' : '';
1,374,117✔
528
    const colon = negateSign + ':';
1,374,117✔
529
    switch (query.type) {
1,374,117!
530
      case SearchQueryTypes.AND:
531
        return (
295,970✔
532
          '(' +
533
          (query as SearchListQuery).list
534
            .map((q) => this.stringifyOneEntry(q))
635,498✔
535
            .join(' ' + this.keywords.and + ' ') +
536
          ')'
537
        );
538

539
      case SearchQueryTypes.OR:
540
        return (
213,529✔
541
          '(' +
542
          (query as SearchListQuery).list
543
            .map((q) => this.stringifyOneEntry(q))
737,804✔
544
            .join(' ' + this.keywords.or + ' ') +
545
          ')'
546
        );
547

548
      case SearchQueryTypes.SOME_OF:
549
        if ((query as SomeOfSearchQuery).min) {
29✔
550
          return (
25✔
551
            (query as SomeOfSearchQuery).min +
552
            '-' +
553
            this.keywords.NSomeOf +
554
            ':(' +
555
            (query as SearchListQuery).list
556
              .map((q) => this.stringifyOneEntry(q))
362✔
557
              .join(' ') +
558
            ')'
559
          );
560
        }
561
        return (
4✔
562
          this.keywords.someOf +
563
          ':(' +
564
          (query as SearchListQuery).list
565
            .map((q) => this.stringifyOneEntry(q))
8✔
566
            .join(' ') +
567
          ')'
568
        );
569

570

571
      case SearchQueryTypes.date:
572
      case SearchQueryTypes.rating:
573
      case SearchQueryTypes.resolution:
574
      case SearchQueryTypes.person_count: {
575
        const dq = query as unknown as RangeSearch;
75✔
576
        let kw = this.keywords.date;
75✔
577
        if (dq.type == SearchQueryTypes.rating) {
75✔
578
          kw = this.keywords.rating;
22✔
579
        }
580
        if (dq.type == SearchQueryTypes.resolution) {
75✔
581
          kw = this.keywords.resolution;
8✔
582
        }
583
        if (dq.type == SearchQueryTypes.person_count) {
75✔
584
          kw = this.keywords.personCount;
16✔
585
        }
586
        let minStr = '' + dq.min;
75✔
587
        let maxStr = '' + dq.max;
75✔
588
        if (dq.type == SearchQueryTypes.date) {
75✔
589
          minStr = SearchQueryParser.stringifyDate(dq.min);
29✔
590
          maxStr = SearchQueryParser.stringifyDate(dq.max);
29✔
591
        }
592

593
        if (isNaN(dq.min) && isNaN(dq.max)) {
75!
UNCOV
594
          return '';
×
595
        }
596
        if (dq.min == dq.max) {
75✔
597
          return (
9✔
598
            kw +
599
            negateSign +
600
            '=' +
601
            minStr
602
          );
603
        } else if (!isNaN(dq.min) && !isNaN(dq.max)) {
66✔
604
          return (
13✔
605
            kw +
606
            negateSign +
607
            ':' +
608
            minStr +
609
            '..' +
610
            maxStr
611
          );
612
        } else if (!isNaN(dq.min)) {
53✔
613
          return (
29✔
614
            kw +
615
            negateSign +
616
            '>=' +
617
            minStr);
618
        }
619
        return (
24✔
620
          kw +
621
          negateSign +
622
          '<=' +
623
          maxStr);
624
      }
625

626
      case SearchQueryTypes.distance: {
627
        const distanceQuery = query as DistanceSearch;
10✔
628
        const value = distanceQuery.from.value;
10✔
629
        const coords = distanceQuery.from.GPSData;
10✔
630

631
        let locationStr = '';
10✔
632
        if (value) {
10✔
633
          // If we have location text, use that
634
          locationStr = value;
4✔
635
        } else if (coords && coords.latitude != null && coords.longitude != null) {
6✔
636
          // If we only have coordinates, use them
637
          locationStr = `${coords.latitude.toFixed(6)}, ${coords.longitude.toFixed(6)}`;
6✔
638
        }
639

640
        // Add brackets if the location string contains spaces
641
        if (locationStr.indexOf(' ') !== -1) {
10✔
642
          locationStr = `(${locationStr})`;
10✔
643
        }
644

645
        return `${distanceQuery.distance}-${this.keywords.kmFrom}${colon}${locationStr}`;
10✔
646
      }
647

648
      case SearchQueryTypes.orientation:
649
        return (
4✔
650
          this.keywords.orientation +
651
          ':' +
652
          ((query as OrientationSearch).landscape
4✔
653
            ? this.keywords.landscape
654
            : this.keywords.portrait)
655
        );
656
      case SearchQueryTypes.date_pattern: {
657
        const q = (query as DatePatternSearch);
198✔
658
        q.daysLength = q.daysLength || 0;
198✔
659
        let strBuilder = '';
198✔
660
        if (q.daysLength <= 0) {
198✔
661
          strBuilder += this.keywords.sameDay;
18✔
662
        } else {
663
          strBuilder += this.keywords.lastNDays.replace(/%d/g, q.daysLength.toString());
180✔
664
        }
665
        if (q.negate === true) {
198✔
666
          strBuilder += '!';
22✔
667
        }
668
        strBuilder += ':';
198✔
669
        switch (q.frequency) {
198✔
670
          case DatePatternFrequency.every_week:
671
            strBuilder += this.keywords.every_week;
22✔
672
            break;
22✔
673
          case DatePatternFrequency.every_month:
674
            strBuilder += this.keywords.every_month;
22✔
675
            break;
22✔
676
          case DatePatternFrequency.every_year:
677
            strBuilder += this.keywords.every_year;
22✔
678
            break;
22✔
679
          case DatePatternFrequency.days_ago:
680
            strBuilder += this.keywords.days_ago.replace(/%d/g, (q.agoNumber || 0).toString());
44✔
681
            break;
44✔
682
          case DatePatternFrequency.weeks_ago:
683
            strBuilder += this.keywords.weeks_ago.replace(/%d/g, (q.agoNumber || 0).toString());
22!
684
            break;
22✔
685
          case DatePatternFrequency.months_ago:
686
            strBuilder += this.keywords.months_ago.replace(/%d/g, (q.agoNumber || 0).toString());
22!
687
            break;
22✔
688
          case DatePatternFrequency.years_ago:
689
            strBuilder += this.keywords.years_ago.replace(/%d/g, (q.agoNumber || 0).toString());
44!
690
            break;
44✔
691
        }
692
        return strBuilder;
198✔
693
      }
694
      case SearchQueryTypes.any_text:
695
        if (!(query as TextSearch).negate) {
59✔
696
          return SearchQueryParser.stringifyText(
57✔
697
            (query as TextSearch).value,
698
            (query as TextSearch).matchType
699
          );
700
        } else {
701
          return (
2✔
702
            (this.keywords as never)[SearchQueryTypes[query.type]] +
703
            colon +
704
            SearchQueryParser.stringifyText(
705
              (query as TextSearch).value,
706
              (query as TextSearch).matchType
707
            )
708
          );
709
        }
710

711
      case SearchQueryTypes.person:
712
      case SearchQueryTypes.position:
713
      case SearchQueryTypes.keyword:
714
      case SearchQueryTypes.caption:
715
      case SearchQueryTypes.file_name:
716
      case SearchQueryTypes.directory:
717
        if (!(query as TextSearch).value) {
864,243!
718
          return '';
×
719
        }
720
        return (
864,243✔
721
          (this.keywords as never)[SearchQueryTypes[query.type]] +
722
          colon +
723
          SearchQueryParser.stringifyText(
724
            (query as TextSearch).value,
725
            (query as TextSearch).matchType
726
          )
727
        );
728

729
      default:
730
        throw new Error('Unknown type: ' + query.type);
×
731
    }
732
  }
733
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc