Skip to content

Commit

Permalink
wip
Browse files Browse the repository at this point in the history
  • Loading branch information
liamwhite committed Mar 13, 2024
1 parent 886539c commit 80aa247
Show file tree
Hide file tree
Showing 21 changed files with 979 additions and 901 deletions.
877 changes: 0 additions & 877 deletions assets/js/match_query.js

This file was deleted.

10 changes: 10 additions & 0 deletions assets/js/match_query.ts
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;
106 changes: 106 additions & 0 deletions assets/js/query/__tests__/date.spec.ts
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');
});
});
53 changes: 53 additions & 0 deletions assets/js/query/__tests__/number.spec.ts
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);
});
});
35 changes: 35 additions & 0 deletions assets/js/query/boolean.ts
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;
};
}
144 changes: 144 additions & 0 deletions assets/js/query/date.ts
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);
}
}
37 changes: 37 additions & 0 deletions assets/js/query/fields.ts
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 */
};
Loading

0 comments on commit 80aa247

Please sign in to comment.