├── .github └── workflows │ └── node.js.yml ├── .gitignore ├── LICENSE ├── jest.config.js ├── package-lock.json ├── package.json ├── readme.md ├── src ├── index.test.ts └── index.ts └── tsconfig.json /.github/workflows/node.js.yml: -------------------------------------------------------------------------------- 1 | # This workflow will do a clean install of node dependencies, build the source code and run tests across different versions of node 2 | # For more information see: https://help.github.com/actions/language-and-framework-guides/using-nodejs-with-github-actions 3 | 4 | name: Node.js CI 5 | 6 | on: 7 | push: 8 | branches: [ master ] 9 | pull_request: 10 | branches: [ master ] 11 | 12 | jobs: 13 | build: 14 | 15 | runs-on: ubuntu-latest 16 | 17 | strategy: 18 | matrix: 19 | node-version: [10.x, 12.x, 14.x, 15.x] 20 | 21 | steps: 22 | - uses: actions/checkout@v2 23 | - name: Use Node.js ${{ matrix.node-version }} 24 | uses: actions/setup-node@v1 25 | with: 26 | node-version: ${{ matrix.node-version }} 27 | - run: npm ci 28 | - run: npm run build --if-present 29 | - run: npx jest 30 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | 3 | # Logs 4 | logs 5 | *.log 6 | npm-debug.log* 7 | yarn-debug.log* 8 | yarn-error.log* 9 | lerna-debug.log* 10 | 11 | # Diagnostic reports (https://nodejs.org/api/report.html) 12 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 13 | 14 | # Runtime data 15 | pids 16 | *.pid 17 | *.seed 18 | *.pid.lock 19 | 20 | # Directory for instrumented libs generated by jscoverage/JSCover 21 | lib-cov 22 | 23 | # Coverage directory used by tools like istanbul 24 | coverage 25 | *.lcov 26 | 27 | # nyc test coverage 28 | .nyc_output 29 | 30 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 31 | .grunt 32 | 33 | # Bower dependency directory (https://bower.io/) 34 | bower_components 35 | 36 | # node-waf configuration 37 | .lock-wscript 38 | 39 | # Compiled binary addons (https://nodejs.org/api/addons.html) 40 | build/Release 41 | 42 | # Dependency directories 43 | node_modules/ 44 | jspm_packages/ 45 | 46 | # Snowpack dependency directory (https://snowpack.dev/) 47 | web_modules/ 48 | 49 | # TypeScript cache 50 | *.tsbuildinfo 51 | 52 | # Optional npm cache directory 53 | .npm 54 | 55 | # Optional eslint cache 56 | .eslintcache 57 | 58 | # Microbundle cache 59 | .rpt2_cache/ 60 | .rts2_cache_cjs/ 61 | .rts2_cache_es/ 62 | .rts2_cache_umd/ 63 | 64 | # Optional REPL history 65 | .node_repl_history 66 | 67 | # Output of 'npm pack' 68 | *.tgz 69 | 70 | # Yarn Integrity file 71 | .yarn-integrity 72 | 73 | # dotenv environment variables file 74 | .env 75 | .env.test 76 | 77 | # parcel-bundler cache (https://parceljs.org/) 78 | .cache 79 | .parcel-cache 80 | 81 | # Next.js build output 82 | .next 83 | out 84 | 85 | # Nuxt.js build / generate output 86 | .nuxt 87 | dist 88 | 89 | # Gatsby files 90 | .cache/ 91 | # Comment in the public line in if your project uses Gatsby and not Next.js 92 | # https://nextjs.org/blog/next-9-1#public-directory-support 93 | # public 94 | 95 | # vuepress build output 96 | .vuepress/dist 97 | 98 | # Serverless directories 99 | .serverless/ 100 | 101 | # FuseBox cache 102 | .fusebox/ 103 | 104 | # DynamoDB Local files 105 | .dynamodb/ 106 | 107 | # TernJS port file 108 | .tern-port 109 | 110 | # Stores VSCode versions used for testing VSCode extensions 111 | .vscode-test 112 | 113 | # yarn v2 114 | .yarn/cache 115 | .yarn/unplugged 116 | .yarn/build-state.yml 117 | .yarn/install-state.gz 118 | .pnp.* 119 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Patrick George Wyndham Smith 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | preset: 'ts-jest', 3 | testEnvironment: 'node', 4 | transform: {} 5 | }; 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "soonerorlater", 3 | "version": "0.1.1", 4 | "description": "Parse natural dates", 5 | "source": "src/index.ts", 6 | "main": "dist/soonerorlater.js", 7 | "module": "dist/soonerorlater.modern.js", 8 | "unpkg": "dist/soonerorlater.umd.js", 9 | "typings": "dist/index.d.ts", 10 | "scripts": { 11 | "prepack": "npm run build", 12 | "dev": "microbundle watch", 13 | "build": "microbundle --format modern,cjs,umd", 14 | "test": "jest --watch" 15 | }, 16 | "author": "Patrick Smith", 17 | "license": "MIT", 18 | "devDependencies": { 19 | "jest": "^26.6.3", 20 | "microbundle": "^0.12.4", 21 | "ts-jest": "^26.4.4", 22 | "ts-node": "^9.1.1", 23 | "typescript": "^4.1.3" 24 | }, 25 | "dependencies": { 26 | "parcook": "^0.2.0" 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 |
2 |

💬 📅 Sooner or Later

3 |

Parse natural language dates

4 | 5 | minified and gzipped size 6 | minified size 7 | zero dependencies 8 | 9 |
10 | 11 | ## Install 12 | 13 | ```console 14 | npm add soonerorlater 15 | ``` 16 | 17 | - 18 | - 19 | - 20 | 21 | ## Examples 22 | 23 | ```javascript 24 | import { parse } from "soonerorlater"; 25 | 26 | parse('Every Wednesday'); 27 | /* 28 | { 29 | repeats: 'weekly', 30 | weekdays: new Set(['wednesday']) 31 | } 32 | */ 33 | 34 | parse('Mondays and Thursdays at 9:30am to 10:30am'); 35 | /* 36 | { 37 | repeats: 'weekly', 38 | weekdays: new Set(['monday', 'thursday']), 39 | startTime: { hours: 9, minutes: 30 }, 40 | endTime: { hours: 10, minutes: 30 } 41 | } 42 | */ 43 | ``` 44 | 45 | ## Notes 46 | 47 | - Uses the library [parcook](https://github.com/RoyalIcing/parcook) for parsing. 48 | - TODO: support standard representations as result 49 | -------------------------------------------------------------------------------- /src/index.test.ts: -------------------------------------------------------------------------------- 1 | import { parse } from "./index"; 2 | 3 | test("parse()", () => { 4 | test.each([ 5 | ['Monday', { weekdays: new Set(['monday']) }], 6 | ['Wednesday', { weekdays: new Set(['wednesday']) }], 7 | [' Wednesday ', { weekdays: new Set(['wednesday']) }], 8 | ['Wednesday and Saturday', { weekdays: new Set(['wednesday', 'saturday']) }], 9 | ['Wednesday or Saturday', { weekdays: new Set(['wednesday', 'saturday']) }], 10 | ['Wednesday, Saturday', { weekdays: new Set(['wednesday', 'saturday']) }], 11 | ['Wednesday and, Saturday', { weekdays: new Set(['wednesday', 'saturday']) }], 12 | ['Every Wednesday', { repeats: 'weekly', weekdays: new Set(['wednesday']) }], 13 | [' Every Wednesday ', { repeats: 'weekly', weekdays: new Set(['wednesday']) }], 14 | ['Every Wednesday or Saturday', { repeats: 'weekly', weekdays: new Set(['wednesday', 'saturday']) }], 15 | ['Wednesdays', { repeats: 'weekly', weekdays: new Set(['wednesday']) }], 16 | [' Wednesdays ', { repeats: 'weekly', weekdays: new Set(['wednesday']) }], 17 | ['Wednesdays and Tuesdays', { repeats: 'weekly', weekdays: new Set(['wednesday', 'tuesday']) }], 18 | [' Wednesdays and Tuesdays ', { repeats: 'weekly', weekdays: new Set(['wednesday', 'tuesday']) }], 19 | ['Wednesdays and Tuesdays and Fridays and Wednesdays', { repeats: 'weekly', weekdays: new Set(['wednesday', 'tuesday', 'friday']) }], 20 | ['Wednesdays at 9', { repeats: 'weekly', weekdays: new Set(['wednesday']), startTime: { hours: 9 } }], 21 | [' Wednesdays at 9 ', { repeats: 'weekly', weekdays: new Set(['wednesday']), startTime: { hours: 9 } }], 22 | ['Wednesdays at 9:30', { repeats: 'weekly', weekdays: new Set(['wednesday']), startTime: { hours: 9, minutes: 30 } }], 23 | ['Wednesdays at 9:59', { repeats: 'weekly', weekdays: new Set(['wednesday']), startTime: { hours: 9, minutes: 59 } }], 24 | ['Wednesdays at 9:30am', { repeats: 'weekly', weekdays: new Set(['wednesday']), startTime: { hours: 9, minutes: 30 } }], 25 | ['Wednesdays at 9:30pm', { repeats: 'weekly', weekdays: new Set(['wednesday']), startTime: { hours: 21, minutes: 30 } }], 26 | ['Mondays at 11:30', { repeats: 'weekly', weekdays: new Set(['monday']), startTime: { hours: 11, minutes: 30 } }], 27 | ['Mondays at 9:30 to 10:30', { repeats: 'weekly', weekdays: new Set(['monday']), startTime: { hours: 9, minutes: 30 }, endTime: { hours: 10, minutes: 30 } }], 28 | ['Mondays 9:30–10:30', { repeats: 'weekly', weekdays: new Set(['monday']), startTime: { hours: 9, minutes: 30 }, endTime: { hours: 10, minutes: 30 } }], 29 | ['Mondays and Thursdays at 9:30 to 10:30', { repeats: 'weekly', weekdays: new Set(['monday', 'thursday']), startTime: { hours: 9, minutes: 30 }, endTime: { hours: 10, minutes: 30 } }], 30 | ['Mondays at 9:30pm to 10:30pm', { repeats: 'weekly', weekdays: new Set(['monday']), startTime: { hours: 21, minutes: 30 }, endTime: { hours: 22, minutes: 30 } }], 31 | ['Fridays from 11:15am to 12:30pm', { repeats: 'weekly', weekdays: new Set(['friday']), startTime: { hours: 11, minutes: 15 }, endTime: { hours: 12, minutes: 30 } }], 32 | ['Fridays from 11:15am to 12:00am', { repeats: 'weekly', weekdays: new Set(['friday']), startTime: { hours: 11, minutes: 15 }, endTime: { hours: 24, minutes: 0 } }], 33 | ])('%o', (input: string, output) => { 34 | expect(parse(input)).toEqual(output); 35 | }); 36 | }) -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { has, optional, parse as parseWith, ParseGenerator, ParseYieldable } from 'parcook'; 2 | 3 | const whitespaceOptional = /^\s*/; 4 | 5 | function* ParseInt() { 6 | const [stringValue]: [string] = yield /^\d+/; 7 | return parseInt(stringValue, 10); 8 | } 9 | 10 | const weekdayChoices = Object.freeze(['monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday', 'sunday'] as const); 11 | type Weekday = (typeof weekdayChoices)[0 | 1 | 2 | 3 | 4 | 5 | 6]; 12 | 13 | function* WeekdayParser() { 14 | let repeats: boolean = yield has(/^every\b/); 15 | yield optional(/^next\b/); 16 | 17 | yield whitespaceOptional; 18 | 19 | const weekday: Weekday = yield weekdayChoices; 20 | repeats = repeats || (yield has(/^[s]\b/)); 21 | 22 | return { weekday, repeats }; 23 | } 24 | 25 | function* AnotherWeekdayParser() { 26 | yield whitespaceOptional; 27 | yield optional('and', 'or'); 28 | yield whitespaceOptional; 29 | return yield WeekdayParser; 30 | } 31 | 32 | function* WeekdaysParser() { 33 | let repeats = false; 34 | 35 | const weekdays = new Set(); 36 | 37 | let result: { weekday: Weekday, repeats: boolean }; 38 | result = yield WeekdayParser; 39 | 40 | weekdays.add(result.weekday); 41 | repeats = repeats || result.repeats; 42 | 43 | while (result = yield optional(AnotherWeekdayParser)) { 44 | weekdays.add(result.weekday); 45 | repeats = repeats || result.repeats; 46 | } 47 | 48 | return { weekdays, repeats }; 49 | } 50 | 51 | function* MinutesSuffixParser() { 52 | yield ':'; 53 | const minutes = yield ParseInt; 54 | return minutes; 55 | } 56 | 57 | function* TimeOfDayParser() { 58 | let hours = yield ParseInt; 59 | const minutes = yield optional(MinutesSuffixParser); 60 | const amOrPm = yield optional('am', 'pm'); 61 | if (amOrPm === 'pm' && hours <= 11) { 62 | hours += 12; 63 | } else if (amOrPm === 'am' && hours === 12) { 64 | hours = 24; 65 | } 66 | return { hours, minutes }; 67 | } 68 | 69 | function* TimespanSuffixParser() { 70 | const started = yield optional('to', '-', '–', '—', 'until'); 71 | if (started === undefined) return undefined; 72 | yield whitespaceOptional; 73 | return yield TimeOfDayParser; 74 | } 75 | 76 | function* TimespanParser() { 77 | yield ['from', 'at', '']; 78 | yield whitespaceOptional; 79 | const startTime = yield TimeOfDayParser; 80 | yield whitespaceOptional; 81 | const endTime = yield optional(TimespanSuffixParser); 82 | return { startTime, endTime }; 83 | } 84 | 85 | export interface Result { 86 | weekdays: Set; 87 | repeats: undefined | 'weekly'; 88 | startTime: { hours: number, minutes?: number }; 89 | endTime: { hours: number, minutes?: number }; 90 | } 91 | 92 | function* NaturalDateParser(): ParseGenerator { 93 | yield whitespaceOptional; 94 | const { weekdays, repeats } = yield WeekdaysParser; 95 | yield whitespaceOptional; 96 | 97 | yield whitespaceOptional; 98 | const timespan = yield optional(TimespanParser); 99 | yield whitespaceOptional; 100 | 101 | return { repeats: repeats ? 'weekly' : undefined, weekdays, ...(timespan as any) }; 102 | } 103 | 104 | export function parse(input: string): Result | null { 105 | input = input.toLowerCase(); 106 | input = input.replace(/[,]/g, ''); 107 | const parsedResult = parseWith(input, NaturalDateParser()); 108 | return parsedResult.success ? parsedResult.result : null; 109 | } 110 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | // see https://www.typescriptlang.org/tsconfig to better understand tsconfigs 3 | "include": ["src", "types"], 4 | "exclude": ["node_modules"], 5 | "compilerOptions": { 6 | "module": "esnext", 7 | "target": "esnext", 8 | "lib": ["dom", "esnext"], 9 | "importHelpers": true, 10 | // output .d.ts declaration files for consumers 11 | "declaration": true, 12 | // output .js.map sourcemap files for consumers 13 | "sourceMap": true, 14 | // match output dir to input dir. e.g. dist/index instead of dist/src/index 15 | "rootDir": "./src", 16 | // stricter type-checking for stronger correctness. Recommended by TS 17 | // "strict": true, 18 | "strictNullChecks": true, 19 | // linter checks for common issues 20 | "noImplicitReturns": true, 21 | "noFallthroughCasesInSwitch": true, 22 | // noUnused* overlap with @typescript-eslint/no-unused-vars, can disable if duplicative 23 | // "noUnusedLocals": true, 24 | // "noUnusedParameters": true, 25 | // use Node's module resolution algorithm, instead of the legacy TS one 26 | "moduleResolution": "node", 27 | // interop between ESM and CJS modules. Recommended by TS 28 | "esModuleInterop": true, 29 | // significant perf increase by skipping checking .d.ts files, particularly those in node_modules. Recommended by TS 30 | "skipLibCheck": true, 31 | // error out if import and file system have a casing mismatch. Recommended by TS 32 | "forceConsistentCasingInFileNames": true, 33 | // `tsdx build` ignores this option, but it is commonly used when type-checking separately with `tsc` 34 | "noEmit": true 35 | }, 36 | "ts-node": { 37 | "transpileOnly": true, 38 | "compilerOptions": { 39 | "module": "commonjs" 40 | }, 41 | "include": [ 42 | "tests/**/*" 43 | ] 44 | }, 45 | "jest": { 46 | "module": "esnext", 47 | "target": "esnext" 48 | } 49 | } 50 | --------------------------------------------------------------------------------