mirror of
https://github.com/chrisbenincasa/tunarr.git
synced 2026-04-18 09:03:35 -04:00
feat: search parser overhaul
Overhaul of the search parser to include: * Numeric range queries with support for both inclusive, exclusive, and half open ranges * Better parsing of dates * Virtual fields (search on "minutes" and have it translated to a "duration" query) * More tests * First class support for date fields
This commit is contained in:
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -461,6 +461,9 @@ importers:
|
|||||||
tsup:
|
tsup:
|
||||||
specifier: ^8.0.2
|
specifier: ^8.0.2
|
||||||
version: 8.0.2(@microsoft/api-extractor@7.43.0(@types/node@22.10.7))(@swc/core@1.13.5)(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.10.7)(typescript@5.7.3))(typescript@5.7.3)
|
version: 8.0.2(@microsoft/api-extractor@7.43.0(@types/node@22.10.7))(@swc/core@1.13.5)(postcss@8.5.6)(ts-node@10.9.2(@swc/core@1.13.5)(@types/node@22.10.7)(typescript@5.7.3))(typescript@5.7.3)
|
||||||
|
tsx:
|
||||||
|
specifier: ^4.20.5
|
||||||
|
version: 4.20.6
|
||||||
typescript:
|
typescript:
|
||||||
specifier: 5.7.3
|
specifier: 5.7.3
|
||||||
version: 5.7.3
|
version: 5.7.3
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { dayjsMod as mod } from '@tunarr/shared/util';
|
import { dayjsMod as mod } from '@tunarr/shared/util';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
|
import customParseFormat from 'dayjs/plugin/customParseFormat.js';
|
||||||
import duration from 'dayjs/plugin/duration.js';
|
import duration from 'dayjs/plugin/duration.js';
|
||||||
import timezone from 'dayjs/plugin/timezone.js';
|
import timezone from 'dayjs/plugin/timezone.js';
|
||||||
import utc from 'dayjs/plugin/utc.js';
|
import utc from 'dayjs/plugin/utc.js';
|
||||||
@@ -8,5 +9,6 @@ dayjs.extend(duration);
|
|||||||
dayjs.extend(timezone);
|
dayjs.extend(timezone);
|
||||||
dayjs.extend(utc);
|
dayjs.extend(utc);
|
||||||
dayjs.extend(mod);
|
dayjs.extend(mod);
|
||||||
|
dayjs.extend(customParseFormat);
|
||||||
|
|
||||||
export default dayjs;
|
export default dayjs;
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
"build-dev": "tsup --dts --watch",
|
"build-dev": "tsup --dts --watch",
|
||||||
"clean": "rimraf ./dist/",
|
"clean": "rimraf ./dist/",
|
||||||
"dev": "tsc --declaration --watch",
|
"dev": "tsc --declaration --watch",
|
||||||
|
"generate-search-diagram": "tsx scripts/generate_search_diagram.ts",
|
||||||
"test": "vitest --run"
|
"test": "vitest --run"
|
||||||
},
|
},
|
||||||
"exports": {
|
"exports": {
|
||||||
@@ -50,6 +51,7 @@
|
|||||||
"rimraf": "^5.0.5",
|
"rimraf": "^5.0.5",
|
||||||
"ts-essentials": "^9.4.2",
|
"ts-essentials": "^9.4.2",
|
||||||
"tsup": "^8.0.2",
|
"tsup": "^8.0.2",
|
||||||
|
"tsx": "^4.20.5",
|
||||||
"typescript": "catalog:",
|
"typescript": "catalog:",
|
||||||
"vitest": "^3.2.4"
|
"vitest": "^3.2.4"
|
||||||
},
|
},
|
||||||
|
|||||||
13
shared/scripts/generate_search_diagram.ts
Normal file
13
shared/scripts/generate_search_diagram.ts
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { createSyntaxDiagramsCode } from 'chevrotain';
|
||||||
|
import fs from 'node:fs';
|
||||||
|
import path, { dirname } from 'node:path';
|
||||||
|
import { fileURLToPath } from 'node:url';
|
||||||
|
import { SearchParser } from '../src/util/searchUtil.js';
|
||||||
|
|
||||||
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||||
|
const parser = new SearchParser();
|
||||||
|
const serializedGrammar = parser.getSerializedGastProductions();
|
||||||
|
const htmlText = createSyntaxDiagramsCode(serializedGrammar, {});
|
||||||
|
// Write the HTML file to disk
|
||||||
|
const outPath = path.resolve(__dirname, './');
|
||||||
|
fs.writeFileSync(outPath + '/generated_diagrams.html', htmlText);
|
||||||
940
shared/scripts/generated_diagrams.html
Normal file
940
shared/scripts/generated_diagrams.html
Normal file
@@ -0,0 +1,940 @@
|
|||||||
|
|
||||||
|
<!-- This is a generated file -->
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
background-color: hsl(30, 20%, 95%)
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
|
||||||
|
<link rel='stylesheet' href='https://unpkg.com/chevrotain@11.0.3/diagrams/diagrams.css'>
|
||||||
|
|
||||||
|
<script src='https://unpkg.com/chevrotain@11.0.3/diagrams/vendor/railroad-diagrams.js'></script>
|
||||||
|
<script src='https://unpkg.com/chevrotain@11.0.3/diagrams/src/diagrams_builder.js'></script>
|
||||||
|
<script src='https://unpkg.com/chevrotain@11.0.3/diagrams/src/diagrams_behavior.js'></script>
|
||||||
|
<script src='https://unpkg.com/chevrotain@11.0.3/diagrams/src/main.js'></script>
|
||||||
|
|
||||||
|
<div id="diagrams" align="center"></div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
window.serializedGrammar = [
|
||||||
|
{
|
||||||
|
"type": "Rule",
|
||||||
|
"name": "searchValue",
|
||||||
|
"orgText": "",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 0,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "Quote",
|
||||||
|
"label": "Quote",
|
||||||
|
"idx": 0,
|
||||||
|
"terminalLabel": "str_open",
|
||||||
|
"pattern": "\""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "RepetitionMandatory",
|
||||||
|
"idx": 0,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Repetition",
|
||||||
|
"idx": 0,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 2,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "Identifier",
|
||||||
|
"label": "Identifier",
|
||||||
|
"idx": 2,
|
||||||
|
"terminalLabel": "query",
|
||||||
|
"pattern": "[a-zA-Z0-9\\-]+"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "Integer",
|
||||||
|
"label": "Integer",
|
||||||
|
"idx": 2,
|
||||||
|
"terminalLabel": "query",
|
||||||
|
"pattern": "\\d+"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "Quote",
|
||||||
|
"label": "Quote",
|
||||||
|
"idx": 3,
|
||||||
|
"terminalLabel": "str_close",
|
||||||
|
"pattern": "\""
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Repetition",
|
||||||
|
"idx": 2,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 3,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "Identifier",
|
||||||
|
"label": "Identifier",
|
||||||
|
"idx": 4,
|
||||||
|
"terminalLabel": "query",
|
||||||
|
"pattern": "[a-zA-Z0-9\\-]+"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "Integer",
|
||||||
|
"label": "Integer",
|
||||||
|
"idx": 4,
|
||||||
|
"terminalLabel": "query",
|
||||||
|
"pattern": "\\d+"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Rule",
|
||||||
|
"name": "parenGroup",
|
||||||
|
"orgText": "",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "OpenParenGroup",
|
||||||
|
"label": "OpenParenGroup",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "\\("
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "RepetitionMandatory",
|
||||||
|
"idx": 0,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "searchClause",
|
||||||
|
"idx": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "CloseParenGroup",
|
||||||
|
"label": "CloseParenGroup",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "\\)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Rule",
|
||||||
|
"name": "string_operator",
|
||||||
|
"orgText": "",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 0,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 2,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "EqOperator",
|
||||||
|
"label": "EqOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": ":|="
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "NeqOperator",
|
||||||
|
"label": "NeqOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "!="
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "LTEOperator",
|
||||||
|
"label": "LTEOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "<="
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "LTOperator",
|
||||||
|
"label": "LTOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "<"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "ContainsOperator",
|
||||||
|
"label": "ContainsOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "~"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "searchValue",
|
||||||
|
"idx": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "InOperator",
|
||||||
|
"label": "InOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "in"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "OpenArray",
|
||||||
|
"label": "OpenArray",
|
||||||
|
"idx": 2,
|
||||||
|
"pattern": "\\["
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "RepetitionMandatoryWithSeparator",
|
||||||
|
"idx": 0,
|
||||||
|
"separator": {
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "Comma",
|
||||||
|
"label": "Comma",
|
||||||
|
"idx": 1,
|
||||||
|
"pattern": ","
|
||||||
|
},
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "searchValue",
|
||||||
|
"idx": 2
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "CloseArray",
|
||||||
|
"label": "CloseArray",
|
||||||
|
"idx": 2,
|
||||||
|
"pattern": "]"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Rule",
|
||||||
|
"name": "numeric_operator",
|
||||||
|
"orgText": "",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 0,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 2,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "EqOperator",
|
||||||
|
"label": "EqOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": ":|="
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "NeqOperator",
|
||||||
|
"label": "NeqOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "!="
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "LTEOperator",
|
||||||
|
"label": "LTEOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "<="
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "GTEOperator",
|
||||||
|
"label": "GTEOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": ">="
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "LTOperator",
|
||||||
|
"label": "LTOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "<"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "GTOperator",
|
||||||
|
"label": "GTOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": ">"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "numeric_value",
|
||||||
|
"idx": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "BetweenOperator",
|
||||||
|
"label": "BetweenOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "between"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 3,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "OpenParenGroup",
|
||||||
|
"label": "OpenParenGroup",
|
||||||
|
"idx": 2,
|
||||||
|
"pattern": "\\("
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "OpenArray",
|
||||||
|
"label": "OpenArray",
|
||||||
|
"idx": 2,
|
||||||
|
"pattern": "\\["
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "numeric_value",
|
||||||
|
"idx": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Option",
|
||||||
|
"idx": 0,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "Comma",
|
||||||
|
"label": "Comma",
|
||||||
|
"idx": 2,
|
||||||
|
"pattern": ","
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "numeric_value",
|
||||||
|
"idx": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 4,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "CloseParenGroup",
|
||||||
|
"label": "CloseParenGroup",
|
||||||
|
"idx": 3,
|
||||||
|
"pattern": "\\)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "CloseArray",
|
||||||
|
"label": "CloseArray",
|
||||||
|
"idx": 3,
|
||||||
|
"pattern": "]"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Rule",
|
||||||
|
"name": "numeric_value",
|
||||||
|
"orgText": "",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 0,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "FloatingPoint",
|
||||||
|
"label": "FloatingPoint",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "\\d+\\.\\d+"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "Integer",
|
||||||
|
"label": "Integer",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "\\d+"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Rule",
|
||||||
|
"name": "date_operator",
|
||||||
|
"orgText": "",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 0,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 2,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "EqOperator",
|
||||||
|
"label": "EqOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": ":|="
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "LTEOperator",
|
||||||
|
"label": "LTEOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "<="
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "LTOperator",
|
||||||
|
"label": "LTOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "<"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "GTEOperator",
|
||||||
|
"label": "GTEOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": ">="
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "GTOperator",
|
||||||
|
"label": "GTOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": ">"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "searchValue",
|
||||||
|
"idx": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "BetweenOperator",
|
||||||
|
"label": "BetweenOperator",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "between"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 3,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "OpenParenGroup",
|
||||||
|
"label": "OpenParenGroup",
|
||||||
|
"idx": 2,
|
||||||
|
"pattern": "\\("
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "OpenArray",
|
||||||
|
"label": "OpenArray",
|
||||||
|
"idx": 2,
|
||||||
|
"pattern": "\\["
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "searchValue",
|
||||||
|
"idx": 2
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Option",
|
||||||
|
"idx": 0,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "Comma",
|
||||||
|
"label": "Comma",
|
||||||
|
"idx": 2,
|
||||||
|
"pattern": ","
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "searchValue",
|
||||||
|
"idx": 3
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 4,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "CloseParenGroup",
|
||||||
|
"label": "CloseParenGroup",
|
||||||
|
"idx": 3,
|
||||||
|
"pattern": "\\)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "CloseArray",
|
||||||
|
"label": "CloseArray",
|
||||||
|
"idx": 3,
|
||||||
|
"pattern": "]"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Rule",
|
||||||
|
"name": "singleStringSearch",
|
||||||
|
"orgText": "",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "StringField",
|
||||||
|
"label": "StringField",
|
||||||
|
"idx": 0,
|
||||||
|
"terminalLabel": "field",
|
||||||
|
"pattern": "actor|genre|director|writer|library_id|title|video_codec|video_dynamic_range|audio_codec|tags|rating|type"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "string_operator",
|
||||||
|
"idx": 0,
|
||||||
|
"label": "op"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Rule",
|
||||||
|
"name": "singleNumericSearch",
|
||||||
|
"orgText": "",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "NumericField",
|
||||||
|
"label": "NumericField",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "duration|minutes|seconds|video_bit_depth|video_height|video_width|audio_channels|release_year"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "numeric_operator",
|
||||||
|
"idx": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Rule",
|
||||||
|
"name": "singleDateSearch",
|
||||||
|
"orgText": "",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "DateField",
|
||||||
|
"label": "DateField",
|
||||||
|
"idx": 0,
|
||||||
|
"terminalLabel": "field",
|
||||||
|
"pattern": "release_date"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "date_operator",
|
||||||
|
"idx": 0,
|
||||||
|
"label": "op"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Rule",
|
||||||
|
"name": "singleSearch",
|
||||||
|
"orgText": "",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 0,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "singleStringSearch",
|
||||||
|
"idx": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "singleNumericSearch",
|
||||||
|
"idx": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "singleDateSearch",
|
||||||
|
"idx": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Rule",
|
||||||
|
"name": "searchClause",
|
||||||
|
"orgText": "",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 0,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "parenGroup",
|
||||||
|
"idx": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "singleSearch",
|
||||||
|
"idx": 0
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Option",
|
||||||
|
"idx": 0,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternation",
|
||||||
|
"idx": 2,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "CombineOr",
|
||||||
|
"label": "CombineOr",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "OR"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "CombineAnd",
|
||||||
|
"label": "CombineAnd",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "AND"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Alternative",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "Terminal",
|
||||||
|
"name": "WhiteSpace",
|
||||||
|
"label": "WhiteSpace",
|
||||||
|
"idx": 0,
|
||||||
|
"pattern": "\\s+"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "searchClause",
|
||||||
|
"idx": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "Rule",
|
||||||
|
"name": "searchExpression",
|
||||||
|
"orgText": "",
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "RepetitionMandatory",
|
||||||
|
"idx": 0,
|
||||||
|
"definition": [
|
||||||
|
{
|
||||||
|
"type": "NonTerminal",
|
||||||
|
"name": "searchClause",
|
||||||
|
"idx": 0
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
var diagramsDiv = document.getElementById("diagrams");
|
||||||
|
main.drawDiagramsFromSerializedGrammar(serializedGrammar, diagramsDiv);
|
||||||
|
</script>
|
||||||
@@ -1,41 +1,335 @@
|
|||||||
import { createSyntaxDiagramsCode } from 'chevrotain';
|
import { SearchFilter } from '@tunarr/types/api';
|
||||||
import fs from 'node:fs';
|
import dayjs from 'dayjs';
|
||||||
import { inspect } from 'node:util';
|
import customParseFormat from 'dayjs/plugin/customParseFormat.js';
|
||||||
import path, { dirname } from 'path';
|
import {
|
||||||
import { fileURLToPath } from 'url';
|
parsedSearchToRequest,
|
||||||
import { SearchParser, tokenizeSearchQuery } from './searchUtil.js';
|
SearchClause,
|
||||||
|
SearchParser,
|
||||||
|
tokenizeSearchQuery,
|
||||||
|
} from './searchUtil.js';
|
||||||
|
|
||||||
|
dayjs.extend(customParseFormat);
|
||||||
|
|
||||||
|
function parseAndCheckExpression(input: string) {
|
||||||
|
const lexerResult = tokenizeSearchQuery(input);
|
||||||
|
expect(lexerResult.errors, JSON.stringify(lexerResult.errors)).toHaveLength(
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const parser = new SearchParser();
|
||||||
|
parser.input = lexerResult.tokens;
|
||||||
|
const query = parser.searchExpression();
|
||||||
|
expect(parser.errors, JSON.stringify(parser.errors, null, 2)).toHaveLength(0);
|
||||||
|
return query;
|
||||||
|
}
|
||||||
|
|
||||||
describe('search parser', () => {
|
describe('search parser', () => {
|
||||||
test('simple parse', () => {
|
test('simple parse', () => {
|
||||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
||||||
|
|
||||||
const input = 'genre IN [comedy, horror] OR title ~ "XYZ"';
|
const input = 'genre IN [comedy, horror] OR title ~ "XYZ"';
|
||||||
|
const query = parseAndCheckExpression(input);
|
||||||
|
expect(query).toMatchObject({
|
||||||
|
type: 'binary_clause',
|
||||||
|
lhs: {
|
||||||
|
type: 'single_query',
|
||||||
|
field: 'genre',
|
||||||
|
op: 'in',
|
||||||
|
value: ['comedy', 'horror'],
|
||||||
|
},
|
||||||
|
op: 'or',
|
||||||
|
rhs: {
|
||||||
|
type: 'single_query',
|
||||||
|
field: 'title',
|
||||||
|
op: 'contains',
|
||||||
|
value: 'XYZ',
|
||||||
|
},
|
||||||
|
} satisfies SearchClause);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parse string fields', () => {
|
||||||
|
const input =
|
||||||
|
'library_id = ddd327c3-aea2-4b27-a2c0-a8ce190d25d0 AND title <= A';
|
||||||
|
const query = parseAndCheckExpression(input);
|
||||||
|
expect(query).toMatchObject({
|
||||||
|
type: 'binary_clause',
|
||||||
|
lhs: {
|
||||||
|
type: 'single_query',
|
||||||
|
field: 'library_id',
|
||||||
|
op: '=',
|
||||||
|
value: 'ddd327c3-aea2-4b27-a2c0-a8ce190d25d0',
|
||||||
|
},
|
||||||
|
op: 'and',
|
||||||
|
rhs: {
|
||||||
|
type: 'single_query',
|
||||||
|
field: 'title',
|
||||||
|
op: '<=',
|
||||||
|
value: 'A',
|
||||||
|
},
|
||||||
|
} satisfies SearchClause);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parse date fields', () => {
|
||||||
|
const input = 'release_date = 2025-01-01';
|
||||||
|
const query = parseAndCheckExpression(input);
|
||||||
|
expect(query).toMatchObject({
|
||||||
|
type: 'single_date_query',
|
||||||
|
field: 'release_date',
|
||||||
|
op: '=',
|
||||||
|
value: '2025-01-01',
|
||||||
|
} satisfies SearchClause);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('parse numeric fields', () => {
|
||||||
|
const input = 'duration >= 10';
|
||||||
const lexerResult = tokenizeSearchQuery(input);
|
const lexerResult = tokenizeSearchQuery(input);
|
||||||
const parser = new SearchParser();
|
const parser = new SearchParser();
|
||||||
parser.input = lexerResult.tokens;
|
parser.input = lexerResult.tokens;
|
||||||
const serializedGrammar = parser.getSerializedGastProductions();
|
console.log(lexerResult.tokens, parser.errors);
|
||||||
|
|
||||||
// console.log(inspect(lexerResult, false, null));
|
|
||||||
const result = parser.searchExpression();
|
|
||||||
console.log(inspect(result, false, null));
|
|
||||||
|
|
||||||
// const visitor = new SearchExpressionVisitor();
|
|
||||||
// visitor.visit(result);
|
|
||||||
|
|
||||||
const htmlText = createSyntaxDiagramsCode(serializedGrammar, {});
|
|
||||||
|
|
||||||
// Write the HTML file to disk
|
|
||||||
const outPath = path.resolve(__dirname, './');
|
|
||||||
fs.writeFileSync(outPath + '/generated_diagrams.html', htmlText);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('can parse uuids', () => {
|
test('can parse uuids', () => {
|
||||||
const input =
|
const input =
|
||||||
'libraryId = ddd327c3-aea2-4b27-a2c0-a8ce190d25d0 AND title <= A';
|
'library_id = ddd327c3-aea2-4b27-a2c0-a8ce190d25d0 AND title <= A';
|
||||||
const lexerResult = tokenizeSearchQuery(input);
|
const lexerResult = tokenizeSearchQuery(input);
|
||||||
const parser = new SearchParser();
|
const parser = new SearchParser();
|
||||||
parser.input = lexerResult.tokens;
|
parser.input = lexerResult.tokens;
|
||||||
const query = parser.searchExpression();
|
const query = parser.searchExpression();
|
||||||
console.log(query);
|
console.log(query);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('supports numeric between inclusive', () => {
|
||||||
|
const input = 'duration between [100, 200]';
|
||||||
|
const query = parseAndCheckExpression(input);
|
||||||
|
expect(query).toMatchObject({
|
||||||
|
type: 'single_numeric_query',
|
||||||
|
field: 'duration',
|
||||||
|
includeHigher: true,
|
||||||
|
includeLow: true,
|
||||||
|
op: 'between',
|
||||||
|
value: [100, 200],
|
||||||
|
} satisfies SearchClause);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('supports numeric between half open', () => {
|
||||||
|
const input = 'duration between [100, 200)';
|
||||||
|
const query = parseAndCheckExpression(input);
|
||||||
|
expect(query).toMatchObject({
|
||||||
|
type: 'single_numeric_query',
|
||||||
|
field: 'duration',
|
||||||
|
includeHigher: false,
|
||||||
|
includeLow: true,
|
||||||
|
op: 'between',
|
||||||
|
value: [100, 200],
|
||||||
|
} satisfies SearchClause);
|
||||||
|
|
||||||
|
const input2 = 'duration between (100, 200]';
|
||||||
|
const query2 = parseAndCheckExpression(input2);
|
||||||
|
expect(query2).toMatchObject({
|
||||||
|
type: 'single_numeric_query',
|
||||||
|
field: 'duration',
|
||||||
|
includeHigher: true,
|
||||||
|
includeLow: false,
|
||||||
|
op: 'between',
|
||||||
|
value: [100, 200],
|
||||||
|
} satisfies SearchClause);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compound queries', () => {
|
||||||
|
const input = 'duration between [100, 200] AND title <= A';
|
||||||
|
const query = parseAndCheckExpression(input);
|
||||||
|
expect(query).toMatchObject({
|
||||||
|
type: 'binary_clause',
|
||||||
|
op: 'and',
|
||||||
|
lhs: {
|
||||||
|
type: 'single_numeric_query',
|
||||||
|
field: 'duration',
|
||||||
|
includeHigher: true,
|
||||||
|
includeLow: true,
|
||||||
|
op: 'between',
|
||||||
|
value: [100, 200],
|
||||||
|
},
|
||||||
|
rhs: {
|
||||||
|
type: 'single_query',
|
||||||
|
field: 'title',
|
||||||
|
op: '<=',
|
||||||
|
value: 'A',
|
||||||
|
},
|
||||||
|
} satisfies SearchClause);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('compound query with date', () => {
|
||||||
|
const input = 'release_date < 2020-01-01 AND title <= A';
|
||||||
|
const query = parseAndCheckExpression(input);
|
||||||
|
expect(query).toMatchObject({
|
||||||
|
type: 'binary_clause',
|
||||||
|
op: 'and',
|
||||||
|
lhs: {
|
||||||
|
type: 'single_date_query',
|
||||||
|
field: 'release_date',
|
||||||
|
op: '<',
|
||||||
|
value: '2020-01-01',
|
||||||
|
},
|
||||||
|
rhs: {
|
||||||
|
type: 'single_query',
|
||||||
|
field: 'title',
|
||||||
|
op: '<=',
|
||||||
|
value: 'A',
|
||||||
|
},
|
||||||
|
} satisfies SearchClause);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('parsedSearchToRequest', () => {
|
||||||
|
test('handles inclusive numeric between', () => {
|
||||||
|
const clause = {
|
||||||
|
type: 'single_numeric_query',
|
||||||
|
field: 'duration',
|
||||||
|
includeHigher: true,
|
||||||
|
includeLow: true,
|
||||||
|
op: 'between',
|
||||||
|
value: [100, 200],
|
||||||
|
} satisfies SearchClause;
|
||||||
|
|
||||||
|
const request = parsedSearchToRequest(clause);
|
||||||
|
|
||||||
|
expect(request).toEqual({
|
||||||
|
type: 'value',
|
||||||
|
fieldSpec: {
|
||||||
|
key: 'duration',
|
||||||
|
name: '',
|
||||||
|
op: 'to',
|
||||||
|
type: 'numeric',
|
||||||
|
value: [100, 200],
|
||||||
|
},
|
||||||
|
} satisfies SearchFilter);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles exclusive numeric between', () => {
|
||||||
|
const clause = {
|
||||||
|
type: 'single_numeric_query',
|
||||||
|
field: 'duration',
|
||||||
|
includeHigher: false,
|
||||||
|
includeLow: false,
|
||||||
|
op: 'between',
|
||||||
|
value: [100, 200],
|
||||||
|
} satisfies SearchClause;
|
||||||
|
|
||||||
|
const request = parsedSearchToRequest(clause);
|
||||||
|
|
||||||
|
const lhs = {
|
||||||
|
type: 'value',
|
||||||
|
fieldSpec: {
|
||||||
|
key: 'duration',
|
||||||
|
name: '',
|
||||||
|
op: '>',
|
||||||
|
type: 'numeric',
|
||||||
|
value: 100,
|
||||||
|
},
|
||||||
|
} satisfies SearchFilter;
|
||||||
|
|
||||||
|
const rhs = {
|
||||||
|
type: 'value',
|
||||||
|
fieldSpec: {
|
||||||
|
key: 'duration',
|
||||||
|
name: '',
|
||||||
|
op: '<',
|
||||||
|
type: 'numeric',
|
||||||
|
value: 200,
|
||||||
|
},
|
||||||
|
} satisfies SearchFilter;
|
||||||
|
|
||||||
|
expect(request).toEqual({
|
||||||
|
type: 'op',
|
||||||
|
op: 'and',
|
||||||
|
children: [lhs, rhs],
|
||||||
|
} satisfies SearchFilter);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles date parsing with YYYY-MM-DD', () => {
|
||||||
|
const clause = {
|
||||||
|
type: 'single_date_query',
|
||||||
|
field: 'release_date',
|
||||||
|
op: '=',
|
||||||
|
value: '2023-01-01',
|
||||||
|
} satisfies SearchClause;
|
||||||
|
|
||||||
|
const request = parsedSearchToRequest(clause);
|
||||||
|
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
type: 'value',
|
||||||
|
fieldSpec: {
|
||||||
|
key: 'originalReleaseDate',
|
||||||
|
op: '=',
|
||||||
|
type: 'date',
|
||||||
|
value: +dayjs('2023-01-01', 'YYYY-MM-DD'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles date parsing with YYYYMMDD', () => {
|
||||||
|
const clause = {
|
||||||
|
type: 'single_date_query',
|
||||||
|
field: 'release_date',
|
||||||
|
op: '=',
|
||||||
|
value: '20230101',
|
||||||
|
} satisfies SearchClause;
|
||||||
|
|
||||||
|
const request = parsedSearchToRequest(clause);
|
||||||
|
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
type: 'value',
|
||||||
|
fieldSpec: {
|
||||||
|
key: 'originalReleaseDate',
|
||||||
|
op: '=',
|
||||||
|
type: 'date',
|
||||||
|
value: +dayjs('20230101', 'YYYYMMDD'),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('handles date between query', () => {
|
||||||
|
const clause = {
|
||||||
|
type: 'single_date_query',
|
||||||
|
field: 'release_date',
|
||||||
|
op: 'between',
|
||||||
|
value: ['2023-01-01', '2023-12-31'],
|
||||||
|
includeLow: true,
|
||||||
|
includeHigher: true,
|
||||||
|
} satisfies SearchClause;
|
||||||
|
|
||||||
|
const request = parsedSearchToRequest(clause);
|
||||||
|
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
type: 'value',
|
||||||
|
fieldSpec: {
|
||||||
|
key: 'originalReleaseDate',
|
||||||
|
op: 'to',
|
||||||
|
type: 'date',
|
||||||
|
value: [
|
||||||
|
+dayjs('2023-01-01', 'YYYY-MM-DD'),
|
||||||
|
+dayjs('2023-12-31', 'YYYY-MM-DD'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('converts virtual field key and value', () => {
|
||||||
|
const clause = {
|
||||||
|
type: 'single_numeric_query',
|
||||||
|
field: 'minutes',
|
||||||
|
op: '<=',
|
||||||
|
value: 30,
|
||||||
|
} satisfies SearchClause;
|
||||||
|
|
||||||
|
const request = parsedSearchToRequest(clause);
|
||||||
|
|
||||||
|
expect(request).toMatchObject({
|
||||||
|
type: 'value',
|
||||||
|
fieldSpec: {
|
||||||
|
key: 'duration',
|
||||||
|
name: '',
|
||||||
|
op: '<=',
|
||||||
|
type: 'numeric',
|
||||||
|
value: 30 * 60 * 1000,
|
||||||
|
},
|
||||||
|
} satisfies SearchFilter);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,15 +4,20 @@ import type {
|
|||||||
TupleToUnion,
|
TupleToUnion,
|
||||||
} from '@tunarr/types';
|
} from '@tunarr/types';
|
||||||
import type {
|
import type {
|
||||||
|
NumericOperators,
|
||||||
SearchFilter,
|
SearchFilter,
|
||||||
SearchFilterOperatorNode,
|
SearchFilterOperatorNode,
|
||||||
SearchFilterValueNode,
|
SearchFilterValueNode,
|
||||||
StringOperators,
|
StringOperators,
|
||||||
} from '@tunarr/types/api';
|
} from '@tunarr/types/api';
|
||||||
import { FreeSearchQueryKeyMappings } from '@tunarr/types/api';
|
|
||||||
import { createToken, EmbeddedActionsParser, Lexer } from 'chevrotain';
|
import { createToken, EmbeddedActionsParser, Lexer } from 'chevrotain';
|
||||||
import { isArray, isNumber } from 'lodash-es';
|
import dayjs from 'dayjs';
|
||||||
import type { NonEmptyArray } from 'ts-essentials';
|
import customParseFormat from 'dayjs/plugin/customParseFormat.js';
|
||||||
|
import { identity, isArray, isNumber } from 'lodash-es';
|
||||||
|
import type { NonEmptyArray, StrictExclude, StrictOmit } from 'ts-essentials';
|
||||||
|
import { match } from 'ts-pattern';
|
||||||
|
|
||||||
|
dayjs.extend(customParseFormat);
|
||||||
|
|
||||||
const Integer = createToken({ name: 'Integer', pattern: /\d+/ });
|
const Integer = createToken({ name: 'Integer', pattern: /\d+/ });
|
||||||
|
|
||||||
@@ -26,6 +31,52 @@ const Identifier = createToken({
|
|||||||
pattern: /[a-zA-Z0-9-]+/,
|
pattern: /[a-zA-Z0-9-]+/,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const StringFields = [
|
||||||
|
'actor',
|
||||||
|
'genre',
|
||||||
|
'director',
|
||||||
|
'writer',
|
||||||
|
'library_id',
|
||||||
|
'title',
|
||||||
|
'video_codec',
|
||||||
|
'video_dynamic_range',
|
||||||
|
'audio_codec',
|
||||||
|
'tags',
|
||||||
|
'rating',
|
||||||
|
'type',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const StringField = createToken({
|
||||||
|
name: 'StringField',
|
||||||
|
pattern: new RegExp(StringFields.join('|')),
|
||||||
|
longer_alt: Identifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
const DateFields = ['release_date'] as const;
|
||||||
|
|
||||||
|
const DateField = createToken({
|
||||||
|
name: 'DateField',
|
||||||
|
pattern: new RegExp(DateFields.join('|')),
|
||||||
|
longer_alt: Identifier,
|
||||||
|
});
|
||||||
|
|
||||||
|
const NumericFields = [
|
||||||
|
'duration',
|
||||||
|
'minutes',
|
||||||
|
'seconds',
|
||||||
|
'video_bit_depth',
|
||||||
|
'video_height',
|
||||||
|
'video_width',
|
||||||
|
'audio_channels',
|
||||||
|
'release_year',
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const NumericField = createToken({
|
||||||
|
name: 'NumericField',
|
||||||
|
pattern: new RegExp(NumericFields.join('|')),
|
||||||
|
longer_alt: Identifier,
|
||||||
|
});
|
||||||
|
|
||||||
const WhiteSpace = createToken({
|
const WhiteSpace = createToken({
|
||||||
name: 'WhiteSpace',
|
name: 'WhiteSpace',
|
||||||
pattern: /\s+/,
|
pattern: /\s+/,
|
||||||
@@ -97,6 +148,11 @@ const GreaterThanOperator = createToken({ name: 'GTOperator', pattern: />/ });
|
|||||||
|
|
||||||
const InOperator = createToken({ name: 'InOperator', pattern: /in/i });
|
const InOperator = createToken({ name: 'InOperator', pattern: /in/i });
|
||||||
|
|
||||||
|
const BetweenOperator = createToken({
|
||||||
|
name: 'BetweenOperator',
|
||||||
|
pattern: /between/i,
|
||||||
|
});
|
||||||
|
|
||||||
const allTokens = [
|
const allTokens = [
|
||||||
WhiteSpace,
|
WhiteSpace,
|
||||||
Comma,
|
Comma,
|
||||||
@@ -114,11 +170,16 @@ const allTokens = [
|
|||||||
LessThanOperator,
|
LessThanOperator,
|
||||||
GreaterThanOperator,
|
GreaterThanOperator,
|
||||||
InOperator,
|
InOperator,
|
||||||
|
BetweenOperator,
|
||||||
ContainsOperator,
|
ContainsOperator,
|
||||||
// Order matters here. float is more specific
|
// Order matters here. float is more specific
|
||||||
// than int.
|
// than int.
|
||||||
FloatingPoint,
|
FloatingPoint,
|
||||||
Integer,
|
Integer,
|
||||||
|
// Fields
|
||||||
|
StringField,
|
||||||
|
DateField,
|
||||||
|
NumericField,
|
||||||
// Catch all
|
// Catch all
|
||||||
Identifier,
|
Identifier,
|
||||||
];
|
];
|
||||||
@@ -127,7 +188,10 @@ const SearchExpressionLexer = new Lexer(allTokens);
|
|||||||
|
|
||||||
const StringOps = ['=', '!=', '<', '<=', 'in', 'contains'] as const;
|
const StringOps = ['=', '!=', '<', '<=', 'in', 'contains'] as const;
|
||||||
type StringOps = TupleToUnion<typeof StringOps>;
|
type StringOps = TupleToUnion<typeof StringOps>;
|
||||||
type Ops = '=' | '!=' | '<' | '<=' | '>' | '>=';
|
const NumericOps = ['=', '!=', '<', '<=', '>', '>=', 'between'] as const;
|
||||||
|
type NumericOps = TupleToUnion<typeof NumericOps>;
|
||||||
|
const DateOps = ['=', '<', '<=', '>', '>=', 'between'] as const;
|
||||||
|
type DateOps = TupleToUnion<typeof DateOps>;
|
||||||
|
|
||||||
const StringOpToApiType = {
|
const StringOpToApiType = {
|
||||||
'<': 'starts with',
|
'<': 'starts with',
|
||||||
@@ -138,6 +202,17 @@ const StringOpToApiType = {
|
|||||||
in: 'in',
|
in: 'in',
|
||||||
} satisfies Record<StringOps, StringOperators>;
|
} satisfies Record<StringOps, StringOperators>;
|
||||||
|
|
||||||
|
const NumericOpToApiType = {
|
||||||
|
'!=': '!=',
|
||||||
|
'<': '<',
|
||||||
|
'<=': '<=',
|
||||||
|
'=': '=',
|
||||||
|
'>': '>',
|
||||||
|
'>=': '>=',
|
||||||
|
// This depends on inclusivity
|
||||||
|
between: 'to',
|
||||||
|
} satisfies Record<NumericOps, NumericOperators>;
|
||||||
|
|
||||||
export type SearchGroup = {
|
export type SearchGroup = {
|
||||||
type: 'search_group';
|
type: 'search_group';
|
||||||
clauses: SearchClause[];
|
clauses: SearchClause[];
|
||||||
@@ -150,15 +225,42 @@ export type SingleStringSearchQuery = {
|
|||||||
value: string | NonEmptyArray<string>;
|
value: string | NonEmptyArray<string>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type SingleNumericQuery = {
|
export type SingleNumericQuery =
|
||||||
type: 'single_numeric_query';
|
| {
|
||||||
field: string;
|
type: 'single_numeric_query';
|
||||||
// TODO: Use real types
|
field: string;
|
||||||
op: Ops;
|
op: StrictExclude<NumericOps, 'between'>;
|
||||||
value: number;
|
value: number;
|
||||||
};
|
}
|
||||||
|
| {
|
||||||
|
type: 'single_numeric_query';
|
||||||
|
op: 'between';
|
||||||
|
field: string;
|
||||||
|
value: [number, number];
|
||||||
|
includeLow: boolean;
|
||||||
|
includeHigher: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type SingleSearch = SingleNumericQuery | SingleStringSearchQuery;
|
export type SingleDateSearchQuery =
|
||||||
|
| {
|
||||||
|
type: 'single_date_query';
|
||||||
|
field: string;
|
||||||
|
op: StrictExclude<DateOps, 'between'>;
|
||||||
|
value: string;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
type: 'single_date_query';
|
||||||
|
op: 'between';
|
||||||
|
field: string;
|
||||||
|
value: [string, string];
|
||||||
|
includeLow: boolean;
|
||||||
|
includeHigher: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type SingleSearch =
|
||||||
|
| SingleNumericQuery
|
||||||
|
| SingleStringSearchQuery
|
||||||
|
| SingleDateSearchQuery;
|
||||||
|
|
||||||
export type SearchClause =
|
export type SearchClause =
|
||||||
| SearchGroup
|
| SearchGroup
|
||||||
@@ -173,6 +275,48 @@ export type BinarySearchClause = {
|
|||||||
rhs: SearchClause;
|
rhs: SearchClause;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const virtualFieldToIndexField: Record<string, string> = {
|
||||||
|
genre: 'genres.name',
|
||||||
|
actor: 'actors.name',
|
||||||
|
writer: 'writer.name',
|
||||||
|
director: 'director.name',
|
||||||
|
studio: 'studio.name',
|
||||||
|
year: 'originalReleaseYear',
|
||||||
|
release_date: 'originalReleaseDate',
|
||||||
|
release_year: 'originalReleaseYear',
|
||||||
|
// these get mapped to the duration field and their
|
||||||
|
// values get converted to the appropriate units
|
||||||
|
minutes: 'duration',
|
||||||
|
seconds: 'duration',
|
||||||
|
// This isn't really true, since this could map to multiple fields
|
||||||
|
// TODO: Make grouping-tyhpe specific subdocs
|
||||||
|
show_genre: 'grandparent.genre',
|
||||||
|
show_title: 'grandparent.title',
|
||||||
|
show_tag: 'grandparent.tag',
|
||||||
|
grandparent_genre: 'grandparent.genre',
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeReleaseDate(value: string) {
|
||||||
|
for (const format of ['YYYY-MM-DD', 'YYYYMMDD']) {
|
||||||
|
const d = dayjs(value, format, true);
|
||||||
|
if (d.isValid()) {
|
||||||
|
return +d;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error(`Could not parse inputted date string: ${value}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
type Converter<In, Out = In> = (input: In) => Out;
|
||||||
|
|
||||||
|
const numericFieldNormalizersByField = {
|
||||||
|
minutes: (mins: number) => mins * 60 * 1000,
|
||||||
|
seconds: (secs: number) => secs * 1000,
|
||||||
|
} satisfies Record<string, Converter<number>>;
|
||||||
|
|
||||||
|
const dateFieldNormalizersByField = {
|
||||||
|
release_date: normalizeReleaseDate,
|
||||||
|
} satisfies Record<string, Converter<string, number>>;
|
||||||
|
|
||||||
export class SearchParser extends EmbeddedActionsParser {
|
export class SearchParser extends EmbeddedActionsParser {
|
||||||
constructor() {
|
constructor() {
|
||||||
super(allTokens, { recoveryEnabled: false });
|
super(allTokens, { recoveryEnabled: false });
|
||||||
@@ -181,28 +325,61 @@ export class SearchParser extends EmbeddedActionsParser {
|
|||||||
|
|
||||||
private searchValue = this.RULE('searchValue', () => {
|
private searchValue = this.RULE('searchValue', () => {
|
||||||
const valueParts: string[] = [];
|
const valueParts: string[] = [];
|
||||||
this.OR([
|
return this.OR([
|
||||||
{
|
{
|
||||||
|
// Attempt to consume a quoted string.
|
||||||
ALT: () => {
|
ALT: () => {
|
||||||
this.CONSUME(Quote, { LABEL: 'str_open' });
|
this.CONSUME(Quote, { LABEL: 'str_open' });
|
||||||
this.AT_LEAST_ONE({
|
this.AT_LEAST_ONE({
|
||||||
DEF: () => {
|
DEF: () => {
|
||||||
valueParts.push(
|
this.MANY(() => {
|
||||||
this.CONSUME(Identifier, { LABEL: 'query' }).image,
|
this.OR2([
|
||||||
);
|
{
|
||||||
|
ALT: () =>
|
||||||
|
valueParts.push(
|
||||||
|
this.CONSUME2(Identifier, { LABEL: 'query' }).image,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ALT: () =>
|
||||||
|
valueParts.push(
|
||||||
|
this.CONSUME2(Integer, { LABEL: 'query' }).image,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
this.CONSUME2(Quote, { LABEL: 'str_close' });
|
this.CONSUME3(Quote, { LABEL: 'str_close' });
|
||||||
|
return valueParts.join(' ');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// Attempt to consume an unquoted string. Consumes both "integers" and "identifiers"
|
||||||
|
// and joins them with empty string to complete a singular string. This handles things
|
||||||
|
// like dates, e.g. 2025-03-02
|
||||||
ALT: () => {
|
ALT: () => {
|
||||||
valueParts.push(this.CONSUME2(Identifier, { LABEL: 'query' }).image);
|
this.MANY2(() => {
|
||||||
|
this.OR3([
|
||||||
|
{
|
||||||
|
ALT: () =>
|
||||||
|
valueParts.push(
|
||||||
|
this.CONSUME4(Identifier, { LABEL: 'query' }).image,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ALT: () =>
|
||||||
|
valueParts.push(
|
||||||
|
this.CONSUME4(Integer, { LABEL: 'query' }).image,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return valueParts.join('');
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return valueParts.join(' ');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
private parenGroup = this.RULE('parenGroup', () => {
|
private parenGroup = this.RULE('parenGroup', () => {
|
||||||
@@ -219,7 +396,7 @@ export class SearchParser extends EmbeddedActionsParser {
|
|||||||
} satisfies SearchGroup;
|
} satisfies SearchGroup;
|
||||||
});
|
});
|
||||||
|
|
||||||
private operator = this.RULE('operator', () => {
|
private stringOperator = this.RULE('string_operator', () => {
|
||||||
return this.OR<{ op: StringOps; value: string | NonEmptyArray<string> }>([
|
return this.OR<{ op: StringOps; value: string | NonEmptyArray<string> }>([
|
||||||
{
|
{
|
||||||
ALT: () => {
|
ALT: () => {
|
||||||
@@ -228,7 +405,6 @@ export class SearchParser extends EmbeddedActionsParser {
|
|||||||
ALT: () => {
|
ALT: () => {
|
||||||
const tok = this.CONSUME(EqOperator);
|
const tok = this.CONSUME(EqOperator);
|
||||||
return tok.image === ':' ? '=' : (tok.image as StringOps);
|
return tok.image === ':' ? '=' : (tok.image as StringOps);
|
||||||
// const value = this
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -238,9 +414,6 @@ export class SearchParser extends EmbeddedActionsParser {
|
|||||||
ALT: () =>
|
ALT: () =>
|
||||||
this.CONSUME(LessThanOrEqualOperator).image as StringOps,
|
this.CONSUME(LessThanOrEqualOperator).image as StringOps,
|
||||||
},
|
},
|
||||||
// {
|
|
||||||
// ALT: () => this.CONSUME(GreaterThanOrEqualOperator).image as StringOps,
|
|
||||||
// },
|
|
||||||
{
|
{
|
||||||
ALT: () => this.CONSUME(LessThanOperator).image as StringOps,
|
ALT: () => this.CONSUME(LessThanOperator).image as StringOps,
|
||||||
},
|
},
|
||||||
@@ -279,27 +452,82 @@ export class SearchParser extends EmbeddedActionsParser {
|
|||||||
});
|
});
|
||||||
|
|
||||||
private numericOperator = this.RULE('numeric_operator', () => {
|
private numericOperator = this.RULE('numeric_operator', () => {
|
||||||
return this.OR<Ops>([
|
return this.OR<StrictOmit<SingleNumericQuery, 'field'>>([
|
||||||
{
|
{
|
||||||
ALT: () => {
|
ALT: () => {
|
||||||
const tok = this.CONSUME(EqOperator);
|
const op = this.OR2<StrictExclude<NumericOps, 'between'>>([
|
||||||
return tok.image === ':' ? '=' : (tok.image as Ops);
|
{
|
||||||
|
ALT: () => {
|
||||||
|
const tok = this.CONSUME(EqOperator);
|
||||||
|
return tok.image === ':' ? '=' : (tok.image as '=');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ALT: () => this.CONSUME(NeqOperator).image as '!=',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ALT: () => this.CONSUME(LessThanOrEqualOperator).image as '<=',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ALT: () => this.CONSUME(GreaterThanOrEqualOperator).image as '>=',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ALT: () => this.CONSUME(LessThanOperator).image as '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ALT: () => this.CONSUME(GreaterThanOperator).image as '>',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const value = this.SUBRULE(this.numericValue);
|
||||||
|
return {
|
||||||
|
op,
|
||||||
|
value,
|
||||||
|
type: 'single_numeric_query',
|
||||||
|
} satisfies StrictOmit<SingleNumericQuery, 'field'>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
ALT: () => this.CONSUME(NeqOperator).image as Ops,
|
ALT: () => {
|
||||||
},
|
const op = this.CONSUME(
|
||||||
{
|
BetweenOperator,
|
||||||
ALT: () => this.CONSUME(LessThanOrEqualOperator).image as Ops,
|
).image.toLowerCase() as 'between';
|
||||||
},
|
let inclLow = false,
|
||||||
{
|
inclHi = false;
|
||||||
ALT: () => this.CONSUME(GreaterThanOrEqualOperator).image as Ops,
|
this.OR3([
|
||||||
},
|
{
|
||||||
{
|
ALT: () => this.CONSUME2(OpenParenGroup),
|
||||||
ALT: () => this.CONSUME(LessThanOperator).image as Ops,
|
},
|
||||||
},
|
{
|
||||||
{
|
ALT: () => {
|
||||||
ALT: () => this.CONSUME(GreaterThanOperator).image as Ops,
|
this.CONSUME2(OpenArray);
|
||||||
|
inclLow = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const values: number[] = [];
|
||||||
|
values.push(this.SUBRULE2(this.numericValue));
|
||||||
|
this.OPTION(() => this.CONSUME2(Comma));
|
||||||
|
values.push(this.SUBRULE3(this.numericValue));
|
||||||
|
this.OR4([
|
||||||
|
{
|
||||||
|
ALT: () => this.CONSUME3(CloseParenGroup),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ALT: () => {
|
||||||
|
this.CONSUME3(CloseArray);
|
||||||
|
inclHi = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
op,
|
||||||
|
value: values as [number, number],
|
||||||
|
includeHigher: inclHi,
|
||||||
|
includeLow: inclLow,
|
||||||
|
type: 'single_numeric_query',
|
||||||
|
} satisfies StrictOmit<SingleNumericQuery, 'field'>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
@@ -315,9 +543,87 @@ export class SearchParser extends EmbeddedActionsParser {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
private dateOperatorAndValue = this.RULE('date_operator', () => {
|
||||||
|
return this.OR<StrictOmit<SingleDateSearchQuery, 'field'>>([
|
||||||
|
{
|
||||||
|
ALT: () => {
|
||||||
|
const op = this.OR2<StrictExclude<DateOps, 'between'>>([
|
||||||
|
{
|
||||||
|
ALT: () => {
|
||||||
|
const tok = this.CONSUME(EqOperator);
|
||||||
|
return tok.image === ':' ? '=' : (tok.image as '=');
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ALT: () => this.CONSUME(LessThanOrEqualOperator).image as '<=',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ALT: () => this.CONSUME(LessThanOperator).image as '<',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ALT: () => this.CONSUME(GreaterThanOrEqualOperator).image as '>=',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ALT: () => this.CONSUME(GreaterThanOperator).image as '>',
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
const value = this.SUBRULE(this.searchValue);
|
||||||
|
return {
|
||||||
|
type: 'single_date_query',
|
||||||
|
op,
|
||||||
|
value,
|
||||||
|
} satisfies StrictOmit<SingleDateSearchQuery, 'field'>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ALT: () => {
|
||||||
|
const op = this.CONSUME(
|
||||||
|
BetweenOperator,
|
||||||
|
).image.toLowerCase() as 'between';
|
||||||
|
let inclLow = false,
|
||||||
|
inclHi = false;
|
||||||
|
this.OR3([
|
||||||
|
{
|
||||||
|
ALT: () => this.CONSUME2(OpenParenGroup),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ALT: () => {
|
||||||
|
this.CONSUME2(OpenArray);
|
||||||
|
inclLow = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
const values: string[] = [];
|
||||||
|
values.push(this.SUBRULE2(this.searchValue));
|
||||||
|
this.OPTION(() => this.CONSUME2(Comma));
|
||||||
|
values.push(this.SUBRULE3(this.searchValue));
|
||||||
|
this.OR4([
|
||||||
|
{
|
||||||
|
ALT: () => this.CONSUME3(CloseParenGroup),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
ALT: () => {
|
||||||
|
this.CONSUME3(CloseArray);
|
||||||
|
inclHi = true;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
return {
|
||||||
|
op,
|
||||||
|
value: values as [string, string],
|
||||||
|
includeHigher: inclHi,
|
||||||
|
includeLow: inclLow,
|
||||||
|
type: 'single_date_query',
|
||||||
|
} satisfies StrictOmit<SingleDateSearchQuery, 'field'>;
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
private singleStringSearch = this.RULE('singleStringSearch', () => {
|
private singleStringSearch = this.RULE('singleStringSearch', () => {
|
||||||
const field = this.CONSUME(Identifier, { LABEL: 'field' }).image;
|
const field = this.CONSUME(StringField, { LABEL: 'field' }).image;
|
||||||
const { op, value } = this.SUBRULE(this.operator, { LABEL: 'op' });
|
const { op, value } = this.SUBRULE(this.stringOperator, { LABEL: 'op' });
|
||||||
return {
|
return {
|
||||||
type: 'single_query' as const,
|
type: 'single_query' as const,
|
||||||
field,
|
field,
|
||||||
@@ -327,14 +633,50 @@ export class SearchParser extends EmbeddedActionsParser {
|
|||||||
});
|
});
|
||||||
|
|
||||||
private singleNumericSearch = this.RULE('singleNumericSearch', () => {
|
private singleNumericSearch = this.RULE('singleNumericSearch', () => {
|
||||||
|
const field = this.CONSUME(NumericField).image;
|
||||||
|
const opRet = this.SUBRULE(this.numericOperator);
|
||||||
|
|
||||||
|
if (opRet.op === 'between') {
|
||||||
|
return {
|
||||||
|
type: 'single_numeric_query' as const,
|
||||||
|
field,
|
||||||
|
op: 'between',
|
||||||
|
value: opRet.value,
|
||||||
|
includeHigher: opRet.includeHigher,
|
||||||
|
includeLow: opRet.includeLow,
|
||||||
|
} satisfies SingleNumericQuery;
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'single_numeric_query' as const,
|
type: 'single_numeric_query' as const,
|
||||||
field: this.CONSUME(Identifier).image,
|
field,
|
||||||
op: this.SUBRULE(this.numericOperator),
|
op: opRet.op,
|
||||||
value: this.SUBRULE(this.numericValue),
|
value: opRet.value,
|
||||||
} satisfies SingleNumericQuery;
|
} satisfies SingleNumericQuery;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
private singleDateSearch = this.RULE('singleDateSearch', () => {
|
||||||
|
const field = this.CONSUME(DateField, { LABEL: 'field' }).image;
|
||||||
|
const opRet = this.SUBRULE(this.dateOperatorAndValue, { LABEL: 'op' });
|
||||||
|
if (opRet.op === 'between') {
|
||||||
|
return {
|
||||||
|
type: 'single_date_query',
|
||||||
|
field,
|
||||||
|
op: 'between',
|
||||||
|
value: opRet.value,
|
||||||
|
includeHigher: opRet.includeHigher,
|
||||||
|
includeLow: opRet.includeLow,
|
||||||
|
} satisfies SingleDateSearchQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'single_date_query',
|
||||||
|
field,
|
||||||
|
op: opRet.op,
|
||||||
|
value: opRet.value,
|
||||||
|
} satisfies SingleDateSearchQuery;
|
||||||
|
});
|
||||||
|
|
||||||
private singleSearch = this.RULE('singleSearch', () => {
|
private singleSearch = this.RULE('singleSearch', () => {
|
||||||
return this.OR<SingleSearch>([
|
return this.OR<SingleSearch>([
|
||||||
{
|
{
|
||||||
@@ -343,6 +685,9 @@ export class SearchParser extends EmbeddedActionsParser {
|
|||||||
{
|
{
|
||||||
ALT: () => this.SUBRULE(this.singleNumericSearch),
|
ALT: () => this.SUBRULE(this.singleNumericSearch),
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
ALT: () => this.SUBRULE(this.singleDateSearch),
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -427,31 +772,120 @@ export function parsedSearchToRequest(input: SearchClause): SearchFilter {
|
|||||||
} satisfies SearchFilterOperatorNode;
|
} satisfies SearchFilterOperatorNode;
|
||||||
}
|
}
|
||||||
case 'single_numeric_query': {
|
case 'single_numeric_query': {
|
||||||
return {
|
const key: string = virtualFieldToIndexField[input.field] ?? input.field;
|
||||||
type: 'value',
|
const valueConverter: Converter<number> =
|
||||||
fieldSpec: {
|
input.field in numericFieldNormalizersByField
|
||||||
key: input.field,
|
? numericFieldNormalizersByField[
|
||||||
name: '',
|
input.field as keyof typeof numericFieldNormalizersByField
|
||||||
// TODO: Fix
|
]
|
||||||
op: input.op,
|
: identity;
|
||||||
// TODO: derive better type based on field
|
|
||||||
type: 'numeric' as const,
|
// Anything other than full inclusive needs to be translated
|
||||||
value: input.value,
|
// to a binary query.
|
||||||
},
|
if (input.op === 'between') {
|
||||||
} satisfies SearchFilterValueNode;
|
return match([input.includeLow, input.includeHigher])
|
||||||
}
|
.returnType<SearchFilter>()
|
||||||
case 'single_query': {
|
.with(
|
||||||
const key: string =
|
[true, true],
|
||||||
FreeSearchQueryKeyMappings[input.field] ?? input.field;
|
() =>
|
||||||
|
({
|
||||||
|
type: 'value',
|
||||||
|
fieldSpec: {
|
||||||
|
key,
|
||||||
|
name: '',
|
||||||
|
op: NumericOpToApiType[input.op],
|
||||||
|
type: 'numeric' as const,
|
||||||
|
value: [
|
||||||
|
valueConverter(input.value[0]),
|
||||||
|
valueConverter(input.value[1]),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}) satisfies SearchFilterValueNode,
|
||||||
|
)
|
||||||
|
.otherwise(([inclLow, inclHi]) => {
|
||||||
|
const lhs = {
|
||||||
|
type: 'value',
|
||||||
|
fieldSpec: {
|
||||||
|
key,
|
||||||
|
name: '',
|
||||||
|
op: inclLow ? '>=' : '>',
|
||||||
|
type: 'numeric',
|
||||||
|
value: valueConverter(input.value[0]),
|
||||||
|
},
|
||||||
|
} satisfies SearchFilterValueNode;
|
||||||
|
|
||||||
|
const rhs = {
|
||||||
|
type: 'value',
|
||||||
|
fieldSpec: {
|
||||||
|
key,
|
||||||
|
name: '',
|
||||||
|
op: inclHi ? '<=' : '<',
|
||||||
|
type: 'numeric',
|
||||||
|
value: valueConverter(input.value[1]),
|
||||||
|
},
|
||||||
|
} satisfies SearchFilterValueNode;
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'op',
|
||||||
|
op: 'and',
|
||||||
|
children: [lhs, rhs],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'value',
|
||||||
|
fieldSpec: {
|
||||||
|
key,
|
||||||
|
name: '',
|
||||||
|
op: NumericOpToApiType[input.op],
|
||||||
|
type: 'numeric' as const,
|
||||||
|
value: valueConverter(input.value),
|
||||||
|
},
|
||||||
|
} satisfies SearchFilterValueNode;
|
||||||
|
}
|
||||||
|
case 'single_date_query': {
|
||||||
|
const key: string = virtualFieldToIndexField[input.field] ?? input.field;
|
||||||
|
const converter =
|
||||||
|
input.field in dateFieldNormalizersByField
|
||||||
|
? dateFieldNormalizersByField[
|
||||||
|
input.field as keyof typeof dateFieldNormalizersByField
|
||||||
|
]
|
||||||
|
: (input: string) => parseInt(input);
|
||||||
|
|
||||||
|
if (input.op === 'between') {
|
||||||
|
return {
|
||||||
|
type: 'value',
|
||||||
|
fieldSpec: {
|
||||||
|
key,
|
||||||
|
name: '',
|
||||||
|
op: NumericOpToApiType[input.op],
|
||||||
|
type: 'date' as const,
|
||||||
|
value: [converter(input.value[0]), converter(input.value[1])],
|
||||||
|
},
|
||||||
|
} satisfies SearchFilterValueNode;
|
||||||
|
} else {
|
||||||
|
return {
|
||||||
|
type: 'value',
|
||||||
|
fieldSpec: {
|
||||||
|
key,
|
||||||
|
name: '',
|
||||||
|
op: NumericOpToApiType[input.op],
|
||||||
|
type: 'date' as const,
|
||||||
|
value: converter(input.value),
|
||||||
|
},
|
||||||
|
} satisfies SearchFilterValueNode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case 'single_query': {
|
||||||
|
const key: string = virtualFieldToIndexField[input.field] ?? input.field;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
type: 'value',
|
type: 'value',
|
||||||
fieldSpec: {
|
fieldSpec: {
|
||||||
// HACK for now
|
|
||||||
key,
|
key,
|
||||||
name: '',
|
name: '',
|
||||||
op: StringOpToApiType[input.op],
|
op: StringOpToApiType[input.op],
|
||||||
// TODO: derive better type based on field
|
|
||||||
type: 'string' as const,
|
type: 'string' as const,
|
||||||
value: isArray(input.value) ? input.value : [input.value],
|
value: isArray(input.value) ? input.value : [input.value],
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -31,6 +31,7 @@
|
|||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"./src/**/*.ts",
|
"./src/**/*.ts",
|
||||||
|
"./scripts/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": [
|
"exclude": [
|
||||||
"./dist/**/*",
|
"./dist/**/*",
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ const DateSearchFieldSchema = z.object({
|
|||||||
|
|
||||||
export type DateSearchField = z.infer<typeof DateSearchFieldSchema>;
|
export type DateSearchField = z.infer<typeof DateSearchFieldSchema>;
|
||||||
|
|
||||||
export const SearchFieldSchema = z.union([
|
export const SearchFieldSchema = z.discriminatedUnion('type', [
|
||||||
StringSearchFieldSchema,
|
StringSearchFieldSchema,
|
||||||
FactedStringSearchFieldSchema,
|
FactedStringSearchFieldSchema,
|
||||||
NumericSearchFieldSchema,
|
NumericSearchFieldSchema,
|
||||||
@@ -78,7 +78,7 @@ export type SearchFilterOperatorNode = {
|
|||||||
// Hack to get recursive types working in zod
|
// Hack to get recursive types working in zod
|
||||||
export const SearchFilterOperatorNodeSchema = z.object({
|
export const SearchFilterOperatorNodeSchema = z.object({
|
||||||
type: z.literal('op'),
|
type: z.literal('op'),
|
||||||
op: z.union([z.literal('or'), z.literal('and')]),
|
op: z.enum(['or', 'and']),
|
||||||
get children(): z.ZodArray<
|
get children(): z.ZodArray<
|
||||||
z.ZodDiscriminatedUnion<
|
z.ZodDiscriminatedUnion<
|
||||||
[
|
[
|
||||||
@@ -102,7 +102,7 @@ export type SearchFilter = z.infer<typeof SearchFilterQuerySchema>;
|
|||||||
|
|
||||||
export const SearchSortSchema = z.object({
|
export const SearchSortSchema = z.object({
|
||||||
field: z.string(),
|
field: z.string(),
|
||||||
direction: z.union([z.literal('asc'), z.literal('desc')]),
|
direction: z.enum(['asc', 'desc']),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type SearchSort = z.infer<typeof SearchSortSchema>;
|
export type SearchSort = z.infer<typeof SearchSortSchema>;
|
||||||
@@ -132,25 +132,4 @@ export type SearchFieldSpec<Key extends string = string> = {
|
|||||||
| ReadonlyArray<MediaSourceLibrary['mediaType']>;
|
| ReadonlyArray<MediaSourceLibrary['mediaType']>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SearchFieldToType = {
|
|
||||||
'genres.name': 'facted_string',
|
|
||||||
'actors.name': 'string',
|
|
||||||
'director.name': 'string',
|
|
||||||
'writer.name': 'string',
|
|
||||||
duration: 'numeric',
|
|
||||||
type: 'facted_string',
|
|
||||||
originalReleaseDate: 'numeric',
|
|
||||||
originalReleaseYear: 'numeric',
|
|
||||||
libraryId: 'string',
|
|
||||||
mediaSourceId: 'string',
|
|
||||||
tags: 'string',
|
|
||||||
rating: 'string',
|
|
||||||
title: 'string',
|
|
||||||
} satisfies Record<string, SearchFieldType>;
|
|
||||||
|
|
||||||
export const FreeSearchQueryKeyMappings: Record<string, string> = {
|
|
||||||
genre: 'genres.name',
|
|
||||||
actor: 'actors.name',
|
|
||||||
};
|
|
||||||
|
|
||||||
z.globalRegistry.add(SearchFilterQuerySchema, { id: 'SearchFilter' });
|
z.globalRegistry.add(SearchFilterQuerySchema, { id: 'SearchFilter' });
|
||||||
|
|||||||
Reference in New Issue
Block a user