From 4da61b06d356b0a5acdfae1609a1e5ff6902471a Mon Sep 17 00:00:00 2001 From: Samuel Greene Date: Wed, 17 Jan 2024 17:12:08 -0800 Subject: [PATCH] first pass at toSentence code --- packages/client/src/sentence.js | 114 ++++++++++++++++++++++++ packages/client/src/sentence.test.js | 125 +++++++++++++++++++++++++++ 2 files changed, 239 insertions(+) create mode 100644 packages/client/src/sentence.js create mode 100644 packages/client/src/sentence.test.js diff --git a/packages/client/src/sentence.js b/packages/client/src/sentence.js new file mode 100644 index 000000000..0a74e847d --- /dev/null +++ b/packages/client/src/sentence.js @@ -0,0 +1,114 @@ +import F from 'futil' +import _ from 'lodash/fp.js' + +let toJoinSentence = (join, values) => + F.toSentenceWith(', ', ` ${join} `, values) + +let toSentenceTypes = { + facet: (node) => { + let join = node.mode == 'exclude' ? 'nor' : 'or' + return node.values.length + ? `is ${join == 'nor' ? 'not ' : ''}${toJoinSentence(join, node.values)}` + : 'is anything' + }, + number: (node) => + node.min || node.max + ? `is ${F.compactJoin(' and ', [ + F.wrap('greater than ', '', node.min), + F.wrap('less than ', '', node.max), + ])}` + : 'is anything', + date: (node) => + node.from || node.to + ? `is ${F.compactJoin(' and ', [ + F.wrap('after ', '', node.from), + F.wrap('before ', '', node.to), + ])}` + : 'is anything', + exists: (node) => (node.value ? 'exists' : 'does not exist'), + geo: (node) => + node.radius && node.location + ? `${node.operator} ${node.radius} miles of ${node.location}` + : 'is anywhere', + tagsText: (node) => { + if (!node.values.length) return 'is anything' + // TODO: Make available at the type level + let join = { any: 'or', all: 'and', none: 'nor' }[node.join] + let operator = { + containsWord: { + label: 'Field Contains', + notLabel: 'field does not contain', + }, + wordStartsWith: { + label: 'Word Starts With', + notLabel: 'word does not start with', + }, + wordEndsWith: { + label: 'Word Ends With', + notLabel: 'word does not end with', + }, + containsExact: { + label: 'Word Is Exactly', + notLabel: 'word is not exactly', + }, + startsWith: { + label: 'Field Starts With', + notLabel: 'field does not start with', + }, + endsWith: { + label: 'Field Ends With', + notLabel: 'field does not end with', + }, + is: { + label: 'Field Is Exactly', + notLabel: 'field is not exactly', + }, + // isNot: { label: 'Is Not' }, + // contains: { label: 'Contains', notLabel: 'does not contain' }, + // doesNotContain: { label: 'Does Not Contain' }, + }[node.operator][node.join === 'none' ? 'notLabel' : 'label'] + return `${operator} ${toJoinSentence(join, node.values)}` + }, + tagsQuery: (node) => { + if (!node.tags.length) return 'matches anything' + let join = { any: 'or', all: 'and', none: 'nor' }[node.join] + let tags = _.map((tag) => { + let word = F.when(_.includes(' ', tag.word), F.quote, tag.word) + + let misspellings = tag.misspellings ? ' and any common misspellings' : '' + + let plural = tag.distance != 1 ? 's' : '' + let order = tag.anyOrder ? 'any' : 'that' + let distance = tag.distance + ? ` within ${tag.distance} word${plural} of each other in ${order} order` + : '' + + return word + misspellings + distance + }, node.tags) + let matches = node.join == 'none' ? 'does not match' : 'matches' + let exactly = node.exact ? ' exactly' : '' + return `${matches}${exactly} ${toJoinSentence(join, tags)}` + }, +} + +// TODO: runTypeFunction +let getTypeSpecific = (node) => + _.getOr(() => '', node.type, toSentenceTypes)(node) + +// TODO: schemas, etc +let getLabel = (node) => node.field || '' + +export let toSentence = (node) => + node.children + ? _.flow( + F.compactMap((child) => + F.when( + _.size(child.children) > 1, // && node.join !== child.join, + F.parens, + toSentence(child) + ) + ), + F.toSentenceWith(', ', ` ${node.join} `) + )(node.children) + : // node.hasValue && + F.concatStrings([getLabel(node), getTypeSpecific(node)]) diff --git a/packages/client/src/sentence.test.js b/packages/client/src/sentence.test.js new file mode 100644 index 000000000..8d91817dd --- /dev/null +++ b/packages/client/src/sentence.test.js @@ -0,0 +1,125 @@ +import { toSentence } from './sentence' + +describe('toSentence', () => { + it('should remove excess parens', () => { + let tree = { + key: 'root', + join: 'and', + children: [ + { + key: 'analysis', + join: 'and', + children: [{ key: 'results', type: 'results' }], + }, + { + key: 'criteria', + join: 'and', + children: [ + { + key: 'agencies', + field: 'City.Name', + type: 'facet', + values: ['City of Boca', 'City of Reno'], + }, + ], + }, + ], + } + let result = toSentence(tree) + expect(result).toEqual('City.Name is City of Boca or City of Reno') + }) + describe('number', () => { + it('should work', () => { + let tree = { + key: 'filter', + field: 'price', + type: 'number', + min: 1, + max: 5, + } + let result = toSentence(tree) + expect(result).toEqual('price is greater than 1 and less than 5') + }) + it('should work with one value', () => { + let tree = { + key: 'filter', + field: 'price', + type: 'number', + max: 5, + } + let result = toSentence(tree) + expect(result).toEqual('price is less than 5') + }) + it('handles no values', () => { + let tree = { + key: 'filter', + field: 'price', + type: 'number', + } + let result = toSentence(tree) + expect(result).toEqual('price is anything') + }) + }) + describe('facet', () => { + it('should work', () => { + let tree = { + key: 'filter', + field: 'City.Name', + type: 'facet', + values: ['City of Boca', 'City of Reno'], + } + let result = toSentence(tree) + expect(result).toEqual('City.Name is City of Boca or City of Reno') + }) + it('handles blank values', () => { + let tree = { + key: 'filter', + field: 'City.Name', + type: 'facet', + values: [], + } + let result = toSentence(tree) + expect(result).toEqual('City.Name is anything') + }) + it('should hand exclude', () => { + let tree = { + key: 'filter', + field: 'City.Name', + type: 'facet', + values: ['City of Boca', 'City of Reno'], + mode: 'exclude', + } + let result = toSentence(tree) + expect(result).toEqual('City.Name is not City of Boca nor City of Reno') + }) + }) + describe('tagsQuery', () => { + it('should work', () => { + let tree = { + key: 'filter', + field: 'text', + type: 'tagsQuery', + tags: [{ word: 'City of Boca' }, { word: 'City of Reno' }], + join: 'any', + } + let result = toSentence(tree) + expect(result).toEqual('text matches "City of Boca" or "City of Reno"') + }) + it('should hand distance and word mispellings', () => { + let tree = { + key: 'filter', + field: 'text', + type: 'tagsQuery', + tags: [ + { word: 'City of Boca', distance: 3 }, + { word: 'Reno', misspellings: true }, + ], + join: 'any', + } + let result = toSentence(tree) + expect(result).toEqual( + 'text matches "City of Boca" within 3 words of each other in that order or Reno and any common misspellings' + ) + }) + }) +})