├── .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 |
6 |
7 |
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 |
--------------------------------------------------------------------------------