-
-
Notifications
You must be signed in to change notification settings - Fork 37
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
- Loading branch information
Showing
21 changed files
with
979 additions
and
901 deletions.
There are no files selected for viewing
This file was deleted.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,10 @@ | ||
import { generateLexArray } from './query/lex'; | ||
import { parseTokens } from './query/parse'; | ||
import { parseTerm } from './query/term'; | ||
|
||
function parseSearch(query: string) { | ||
const tokens = generateLexArray(query, parseTerm); | ||
return parseTokens(tokens); | ||
} | ||
|
||
export default parseSearch; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,106 @@ | ||
import { makeDateMatcher } from '../date'; | ||
|
||
function daysAgo(days: number) { | ||
return new Date(Date.now() - days * 86400000).toISOString(); | ||
} | ||
|
||
describe('Date parsing', () => { | ||
it('should match relative dates (upper bound)', () => { | ||
const matcher = makeDateMatcher('3 days ago', 'lte'); | ||
|
||
expect(matcher(daysAgo(4), 'created_at', 0)).toBe(true); | ||
expect(matcher(daysAgo(2), 'created_at', 0)).toBe(false); | ||
}); | ||
|
||
it('should match relative dates (lower bound)', () => { | ||
const matcher = makeDateMatcher('3 days ago', 'gte'); | ||
|
||
expect(matcher(daysAgo(4), 'created_at', 0)).toBe(false); | ||
expect(matcher(daysAgo(2), 'created_at', 0)).toBe(true); | ||
}); | ||
|
||
it('should match absolute date ranges', () => { | ||
const ltMatcher = makeDateMatcher('2025', 'lt'); | ||
const gtMatcher = makeDateMatcher('2023', 'gt'); | ||
|
||
expect(ltMatcher(new Date(Date.UTC(2025, 5, 21)).toISOString(), 'created_at', 0)).toBe(false); | ||
expect(ltMatcher(new Date(Date.UTC(2024, 5, 21)).toISOString(), 'created_at', 0)).toBe(true); | ||
expect(ltMatcher(new Date(Date.UTC(2023, 5, 21)).toISOString(), 'created_at', 0)).toBe(true); | ||
|
||
expect(gtMatcher(new Date(Date.UTC(2025, 5, 21)).toISOString(), 'created_at', 0)).toBe(true); | ||
expect(gtMatcher(new Date(Date.UTC(2024, 5, 21)).toISOString(), 'created_at', 0)).toBe(true); | ||
expect(gtMatcher(new Date(Date.UTC(2023, 5, 21)).toISOString(), 'created_at', 0)).toBe(false); | ||
}); | ||
|
||
it('should match absolute dates through years', () => { | ||
const matcher = makeDateMatcher('2024', 'eq'); | ||
|
||
expect(matcher(new Date(Date.UTC(2025, 5, 21)).toISOString(), 'created_at', 0)).toBe(false); | ||
expect(matcher(new Date(Date.UTC(2024, 5, 21)).toISOString(), 'created_at', 0)).toBe(true); | ||
expect(matcher(new Date(Date.UTC(2023, 5, 21)).toISOString(), 'created_at', 0)).toBe(false); | ||
}); | ||
|
||
it('should match absolute dates through months', () => { | ||
const matcher = makeDateMatcher('2024-06', 'eq'); | ||
|
||
expect(matcher(new Date(Date.UTC(2024, 6, 21)).toISOString(), 'created_at', 0)).toBe(false); | ||
expect(matcher(new Date(Date.UTC(2024, 5, 21)).toISOString(), 'created_at', 0)).toBe(true); | ||
expect(matcher(new Date(Date.UTC(2024, 4, 21)).toISOString(), 'created_at', 0)).toBe(false); | ||
}); | ||
|
||
it('should match absolute dates through days', () => { | ||
const matcher = makeDateMatcher('2024-06-21', 'eq'); | ||
|
||
expect(matcher(new Date(Date.UTC(2024, 5, 22)).toISOString(), 'created_at', 0)).toBe(false); | ||
expect(matcher(new Date(Date.UTC(2024, 5, 21)).toISOString(), 'created_at', 0)).toBe(true); | ||
expect(matcher(new Date(Date.UTC(2024, 5, 20)).toISOString(), 'created_at', 0)).toBe(false); | ||
}); | ||
|
||
it('should match absolute dates through hours', () => { | ||
const matcher = makeDateMatcher('2024-06-21T06', 'eq'); | ||
|
||
expect(matcher(new Date(Date.UTC(2024, 5, 21, 7)).toISOString(), 'created_at', 0)).toBe(false); | ||
expect(matcher(new Date(Date.UTC(2024, 5, 21, 6)).toISOString(), 'created_at', 0)).toBe(true); | ||
expect(matcher(new Date(Date.UTC(2024, 5, 21, 5)).toISOString(), 'created_at', 0)).toBe(false); | ||
}); | ||
|
||
it('should match absolute dates through minutes', () => { | ||
const matcher = makeDateMatcher('2024-06-21T06:21', 'eq'); | ||
|
||
expect(matcher(new Date(Date.UTC(2024, 5, 21, 6, 22)).toISOString(), 'created_at', 0)).toBe(false); | ||
expect(matcher(new Date(Date.UTC(2024, 5, 21, 6, 21)).toISOString(), 'created_at', 0)).toBe(true); | ||
expect(matcher(new Date(Date.UTC(2024, 5, 21, 6, 20)).toISOString(), 'created_at', 0)).toBe(false); | ||
}); | ||
|
||
it('should match absolute dates through seconds', () => { | ||
const matcher = makeDateMatcher('2024-06-21T06:21:30Z', 'eq'); | ||
|
||
expect(matcher(new Date(Date.UTC(2024, 5, 21, 6, 21, 31)).toISOString(), 'created_at', 0)).toBe(false); | ||
expect(matcher(new Date(Date.UTC(2024, 5, 21, 6, 21, 30)).toISOString(), 'created_at', 0)).toBe(true); | ||
expect(matcher(new Date(Date.UTC(2024, 5, 21, 6, 21, 29)).toISOString(), 'created_at', 0)).toBe(false); | ||
}); | ||
|
||
it('should match absolute dates through seconds with positive timezone offset', () => { | ||
const matcher = makeDateMatcher('2024-06-21T06:21:30+01:30', 'eq'); | ||
|
||
expect(matcher(new Date(Date.UTC(2024, 5, 21, 4, 51, 31)).toISOString(), 'created_at', 0)).toBe(false); | ||
expect(matcher(new Date(Date.UTC(2024, 5, 21, 4, 51, 30)).toISOString(), 'created_at', 0)).toBe(true); | ||
expect(matcher(new Date(Date.UTC(2024, 5, 21, 4, 51, 29)).toISOString(), 'created_at', 0)).toBe(false); | ||
}); | ||
|
||
it('should match absolute dates through seconds with negative timezone offset', () => { | ||
const matcher = makeDateMatcher('2024-06-21T06:21:30-01:30', 'eq'); | ||
|
||
expect(matcher(new Date(Date.UTC(2024, 5, 21, 7, 51, 31)).toISOString(), 'created_at', 0)).toBe(false); | ||
expect(matcher(new Date(Date.UTC(2024, 5, 21, 7, 51, 30)).toISOString(), 'created_at', 0)).toBe(true); | ||
expect(matcher(new Date(Date.UTC(2024, 5, 21, 7, 51, 29)).toISOString(), 'created_at', 0)).toBe(false); | ||
}); | ||
|
||
it('should not match malformed absolute date expressions', () => { | ||
expect(() => makeDateMatcher('2024-06-21T06:21:30+01:3020', 'eq')).toThrow('Cannot parse date string: 2024-06-21T06:21:30+01:3020'); | ||
}); | ||
|
||
it('should not match malformed relative date expressions', () => { | ||
expect(() => makeDateMatcher('3 test failures ago', 'eq')).toThrow('Cannot parse date string: 3 test failures ago'); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,53 @@ | ||
import { makeNumberMatcher } from '../number'; | ||
|
||
describe('Number parsing', () => { | ||
it('should match numbers directly', () => { | ||
const intMatch = makeNumberMatcher(2067, 0, 'eq'); | ||
|
||
expect(intMatch('2066', 'value', 0)).toBe(false); | ||
expect(intMatch('2067', 'value', 0)).toBe(true); | ||
expect(intMatch('2068', 'value', 0)).toBe(false); | ||
expect(intMatch('20677', 'value', 0)).toBe(false); | ||
}); | ||
|
||
it('should match number ranges', () => { | ||
const ltMatch = makeNumberMatcher(2067, 0, 'lt'); | ||
const lteMatch = makeNumberMatcher(2067, 0, 'lte'); | ||
const gtMatch = makeNumberMatcher(2067, 0, 'gt'); | ||
const gteMatch = makeNumberMatcher(2067, 0, 'gte'); | ||
|
||
expect(ltMatch('2066', 'value', 0)).toBe(true); | ||
expect(ltMatch('2067', 'value', 0)).toBe(false); | ||
expect(ltMatch('2068', 'value', 0)).toBe(false); | ||
expect(lteMatch('2066', 'value', 0)).toBe(true); | ||
expect(lteMatch('2067', 'value', 0)).toBe(true); | ||
expect(lteMatch('2068', 'value', 0)).toBe(false); | ||
expect(gtMatch('2066', 'value', 0)).toBe(false); | ||
expect(gtMatch('2067', 'value', 0)).toBe(false); | ||
expect(gtMatch('2068', 'value', 0)).toBe(true); | ||
expect(gteMatch('2066', 'value', 0)).toBe(false); | ||
expect(gteMatch('2067', 'value', 0)).toBe(true); | ||
expect(gteMatch('2068', 'value', 0)).toBe(true); | ||
}); | ||
|
||
it('should not match unparsed values', () => { | ||
const matcher = makeNumberMatcher(2067, 0, 'eq'); | ||
|
||
expect(matcher('NaN', 'value', 0)).toBe(false); | ||
expect(matcher('test', 'value', 0)).toBe(false); | ||
}); | ||
|
||
it('should interpret fuzz as an inclusive range around the value', () => { | ||
const matcher = makeNumberMatcher(2067, 3, 'eq'); | ||
|
||
expect(matcher('2063', 'value', 0)).toBe(false); | ||
expect(matcher('2064', 'value', 0)).toBe(true); | ||
expect(matcher('2065', 'value', 0)).toBe(true); | ||
expect(matcher('2066', 'value', 0)).toBe(true); | ||
expect(matcher('2067', 'value', 0)).toBe(true); | ||
expect(matcher('2068', 'value', 0)).toBe(true); | ||
expect(matcher('2069', 'value', 0)).toBe(true); | ||
expect(matcher('2070', 'value', 0)).toBe(true); | ||
expect(matcher('2071', 'value', 0)).toBe(false); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
import { AstMatcher } from './types'; | ||
|
||
export function matchAny(...matchers: AstMatcher[]): AstMatcher { | ||
return (e: HTMLElement) => { | ||
for (const matcher of matchers) { | ||
if (matcher(e)) { | ||
return true; | ||
} | ||
} | ||
return false; | ||
}; | ||
} | ||
|
||
export function matchAll(...matchers: AstMatcher[]): AstMatcher { | ||
return (e: HTMLElement) => { | ||
for (const matcher of matchers) { | ||
if (!matcher(e)) { | ||
return false; | ||
} | ||
} | ||
return true; | ||
}; | ||
} | ||
|
||
export function matchNot(matcher: AstMatcher): AstMatcher { | ||
return (e: HTMLElement) => { | ||
return !matcher(e); | ||
}; | ||
} | ||
|
||
export function matchNone(): AstMatcher { | ||
return () => { | ||
return false; | ||
}; | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,144 @@ | ||
import { FieldMatcher, RangeEqualQualifier } from './types'; | ||
|
||
type Year = number; | ||
type Month = number; | ||
type Day = number; | ||
type Hours = number; | ||
type Minutes = number; | ||
type Seconds = number; | ||
type AbsoluteDate = [Year, Month, Day, Hours, Minutes, Seconds]; | ||
type TimeZoneOffset = [Hours, Minutes]; | ||
type PosixTimeMs = number; | ||
|
||
function makeMatcher(bottomDate: PosixTimeMs, topDate: PosixTimeMs, qual: RangeEqualQualifier): FieldMatcher { | ||
// The open-left, closed-right date range specified by the | ||
// date/time format limits the types of comparisons that are | ||
// done compared to numeric ranges. | ||
switch (qual) { | ||
case 'lte': | ||
return v => new Date(v).getTime() < topDate; | ||
case 'gte': | ||
return v => new Date(v).getTime() >= bottomDate; | ||
case 'lt': | ||
return v => new Date(v).getTime() < bottomDate; | ||
case 'gt': | ||
return v => new Date(v).getTime() >= topDate; | ||
case 'eq': | ||
default: | ||
return v => { | ||
const t = new Date(v).getTime(); | ||
return t >= bottomDate && t < topDate; | ||
}; | ||
} | ||
} | ||
|
||
function makeRelativeDateMatcher(dateVal: string, qual: RangeEqualQualifier): FieldMatcher { | ||
const match = /(\d+) (second|minute|hour|day|week|month|year)s? ago/.exec(dateVal); | ||
const bounds: Record<string, number> = { | ||
second: 1000, | ||
minute: 60000, | ||
hour: 3600000, | ||
day: 86400000, | ||
week: 604800000, | ||
month: 2592000000, | ||
year: 31536000000 | ||
}; | ||
|
||
if (match) { | ||
const amount = parseInt(match[1], 10); | ||
const scale = bounds[match[2]]; | ||
|
||
const now = new Date().getTime(); | ||
const bottomDate = new Date(now - amount * scale).getTime(); | ||
const topDate = new Date(now - (amount - 1) * scale).getTime(); | ||
|
||
return makeMatcher(bottomDate, topDate, qual); | ||
} | ||
|
||
throw new Error(`Cannot parse date string: ${dateVal}`); | ||
} | ||
|
||
function makeAbsoluteDateMatcher(dateVal: string, qual: RangeEqualQualifier): FieldMatcher { | ||
const parseRes: RegExp[] = [ | ||
/^(\d{4})/, | ||
/^-(\d{2})/, | ||
/^-(\d{2})/, | ||
/^(?:\s+|T|t)(\d{2})/, | ||
/^:(\d{2})/, | ||
/^:(\d{2})/ | ||
]; | ||
const timeZoneOffset: TimeZoneOffset = [0, 0]; | ||
const timeData: AbsoluteDate = [0, 0, 1, 0, 0, 0]; | ||
|
||
const origDateVal: string = dateVal; | ||
let localDateVal = origDateVal; | ||
|
||
const offsetMatch = /([+-])(\d{2}):(\d{2})$/.exec(localDateVal); | ||
if (offsetMatch) { | ||
timeZoneOffset[0] = parseInt(offsetMatch[2], 10); | ||
timeZoneOffset[1] = parseInt(offsetMatch[3], 10); | ||
if (offsetMatch[1] === '-') { | ||
timeZoneOffset[0] *= -1; | ||
timeZoneOffset[1] *= -1; | ||
} | ||
localDateVal = localDateVal.substr(0, localDateVal.length - 6); | ||
} | ||
else { | ||
localDateVal = localDateVal.replace(/[Zz]$/, ''); | ||
} | ||
|
||
let matchIndex = 0; | ||
for (; matchIndex < parseRes.length; matchIndex += 1) { | ||
if (localDateVal.length === 0) { | ||
break; | ||
} | ||
|
||
const componentMatch = parseRes[matchIndex].exec(localDateVal); | ||
if (componentMatch) { | ||
if (matchIndex === 1) { | ||
// Months are offset by 1. | ||
timeData[matchIndex] = parseInt(componentMatch[1], 10) - 1; | ||
} | ||
else { | ||
// All other components are not offset. | ||
timeData[matchIndex] = parseInt(componentMatch[1], 10); | ||
} | ||
|
||
// Slice string. | ||
localDateVal = localDateVal.substr( | ||
componentMatch[0].length, localDateVal.length - componentMatch[0].length | ||
); | ||
} | ||
else { | ||
throw new Error(`Cannot parse date string: ${origDateVal}`); | ||
} | ||
} | ||
|
||
if (localDateVal.length > 0) { | ||
throw new Error(`Cannot parse date string: ${origDateVal}`); | ||
} | ||
|
||
// Apply the user-specified time zone offset. The JS Date constructor | ||
// is very flexible here. | ||
timeData[3] -= timeZoneOffset[0]; | ||
timeData[4] -= timeZoneOffset[1]; | ||
|
||
const asPosix = (data: AbsoluteDate) => { | ||
return new Date(Date.UTC.apply(Date, data)).getTime(); | ||
}; | ||
|
||
const bottomDate = asPosix(timeData); | ||
timeData[matchIndex - 1] += 1; | ||
const topDate = asPosix(timeData); | ||
|
||
return makeMatcher(bottomDate, topDate, qual); | ||
} | ||
|
||
export function makeDateMatcher(dateVal: string, qual: RangeEqualQualifier): FieldMatcher { | ||
try { | ||
return makeAbsoluteDateMatcher(dateVal, qual); | ||
} | ||
catch (_) { | ||
return makeRelativeDateMatcher(dateVal, qual); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,37 @@ | ||
import { FieldName } from './types'; | ||
|
||
type AttributeName = string; | ||
|
||
export const numberFields: FieldName[] = | ||
['id', 'width', 'height', 'aspect_ratio', | ||
'comment_count', 'score', 'upvotes', 'downvotes', | ||
'faves', 'tag_count']; | ||
|
||
export const dateFields: FieldName[] = ['created_at']; | ||
|
||
export const literalFields = | ||
['tags', 'orig_sha512_hash', 'sha512_hash', | ||
'score', 'uploader', 'source_url', 'description']; | ||
|
||
export const termSpaceToImageField: Record<FieldName, AttributeName> = { | ||
tags: 'data-image-tag-aliases', | ||
score: 'data-score', | ||
upvotes: 'data-upvotes', | ||
downvotes: 'data-downvotes', | ||
uploader: 'data-uploader', | ||
// Yeah, I don't think this is reasonably supportable. | ||
// faved_by: 'data-faved-by', | ||
id: 'data-image-id', | ||
width: 'data-width', | ||
height: 'data-height', | ||
/* eslint-disable camelcase */ | ||
aspect_ratio: 'data-aspect-ratio', | ||
comment_count: 'data-comment-count', | ||
tag_count: 'data-tag-count', | ||
source_url: 'data-source-url', | ||
faves: 'data-faves', | ||
sha512_hash: 'data-sha512', | ||
orig_sha512_hash: 'data-orig-sha512', | ||
created_at: 'data-created-at' | ||
/* eslint-enable camelcase */ | ||
}; |
Oops, something went wrong.