lfabl 1 місяць тому
коміт
c86865e6bc

+ 4 - 0
.eslintignore

@@ -0,0 +1,4 @@
+dist/
+coverage/
+webpack.config.js
+.eslintrc.js

+ 138 - 0
.eslintrc.js

@@ -0,0 +1,138 @@
+module.exports = {
+  env: {
+    browser: true,
+    node: true,
+  },
+  extends: [
+    'prettier',
+    'eslint:recommended',
+    'plugin:@typescript-eslint/recommended',
+  ],
+  parser: '@typescript-eslint/parser',
+  parserOptions: {
+    project: 'tsconfig.json',
+    sourceType: 'module',
+  },
+  plugins: [
+    'eslint-plugin-import',
+    'eslint-plugin-jsdoc',
+    '@typescript-eslint',
+  ],
+  root: true,
+  rules: {
+    '@typescript-eslint/await-thenable': 'error',
+    '@typescript-eslint/consistent-type-assertions': 'error',
+    '@typescript-eslint/member-delimiter-style': [
+      'error',
+      {
+        multiline: {
+          delimiter: 'none',
+          requireLast: true,
+        },
+        singleline: {
+          delimiter: 'semi',
+          requireLast: false,
+        },
+      },
+    ],
+    '@typescript-eslint/naming-convention': 'off',
+    '@typescript-eslint/no-empty-function': 'error',
+    '@typescript-eslint/no-extra-semi': 'off',
+    '@typescript-eslint/no-explicit-any': 'error',
+    '@typescript-eslint/no-floating-promises': 'error',
+    '@typescript-eslint/no-misused-new': 'error',
+    '@typescript-eslint/no-unnecessary-qualifier': 'error',
+    '@typescript-eslint/no-unnecessary-type-assertion': 'error',
+    '@typescript-eslint/no-unused-expressions': [
+      'error',
+      {
+        allowTaggedTemplates: true,
+        allowShortCircuit: true,
+      },
+    ],
+    '@typescript-eslint/prefer-namespace-keyword': 'error',
+    '@typescript-eslint/quotes': [
+      'error',
+      'single',
+      {
+        avoidEscape: true,
+      },
+    ],
+    '@typescript-eslint/semi': 'off',
+    '@typescript-eslint/triple-slash-reference': [
+      'error',
+      {
+        path: 'always',
+        types: 'prefer-import',
+        lib: 'always',
+      },
+    ],
+    '@typescript-eslint/type-annotation-spacing': 'error',
+    '@typescript-eslint/unified-signatures': 'error',
+    'brace-style': ['error', '1tbs'],
+    'comma-dangle': 'off',
+    curly: ['error', 'multi-line'],
+    'eol-last': 'error',
+    eqeqeq: ['error', 'smart'],
+    'id-denylist': [
+      'error',
+      'any',
+      'Number',
+      'String',
+      'string',
+      'Boolean',
+      'boolean',
+      'Undefined',
+      'undefined',
+    ],
+    'id-match': 'error',
+    'import/no-deprecated': 'error',
+    'jsdoc/check-alignment': 'error',
+    'jsdoc/check-indentation': 'error',
+    'jsdoc/newline-after-description': 'error',
+    'new-parens': 'error',
+    'no-caller': 'error',
+    'no-case-declarations': 'off',
+    'no-cond-assign': 'error',
+    'no-constant-condition': 'error',
+    'no-control-regex': 'error',
+    'no-duplicate-imports': 'error',
+    'no-empty': 'error',
+    'no-empty-function': 'error',
+    'no-eval': 'error',
+    'no-fallthrough': 'error',
+    'no-invalid-regexp': 'error',
+    'no-multiple-empty-lines': 'error',
+    'no-redeclare': 'error',
+    'no-regex-spaces': 'error',
+    'no-return-await': 'error',
+    'no-throw-literal': 'error',
+    'no-trailing-spaces': 'error',
+    'no-underscore-dangle': 'off',
+    'no-unexpected-multiline': 'off',
+    'no-unused-expressions': 'off',
+    'no-unused-labels': 'error',
+    'no-var': 'error',
+    'one-var': ['error', 'never'],
+    quotes: 'off',
+    radix: 'error',
+    semi: 'off',
+    'space-in-parens': ['error', 'never'],
+    'spaced-comment': [
+      'error',
+      'always',
+      {
+        markers: ['/'],
+      },
+    ],
+    'use-isnan': 'error',
+  },
+  overrides: [
+    {
+      files: ['*.test.*'],
+      rules: {
+        '@typescript-eslint/no-unused-expressions': 'off',
+      },
+    },
+  ],
+}

+ 9 - 0
.gitignore

@@ -0,0 +1,9 @@
+node_modules/
+bower_components/
+.idea
+.vscode
+*.log
+coverage
+.nyc_output
+dist/
+/.history

+ 8 - 0
.prettierignore

@@ -0,0 +1,8 @@
+node_modules/
+bower_components/
+.idea
+.vscode
+*.log
+coverage
+.nyc_output
+dist/

+ 6 - 0
.prettierrc.json

@@ -0,0 +1,6 @@
+{
+  "semi": false,
+  "tabWidth": 2,
+  "useTabs": false,
+  "singleQuote": true
+}

+ 1 - 0
README.md

@@ -0,0 +1 @@
+# RRULE

+ 91 - 0
package.json

@@ -0,0 +1,91 @@
+{
+  "name": "rrule-n",
+  "version": "2.8.0",
+  "description": "JavaScript library for working with recurrence rules for calendar dates.",
+  "homepage": "https://git.nibgat.space/rrule/",
+  "license": "BSD-3-Clause",
+  "keywords": [
+    "dates",
+    "recurrences",
+    "calendar",
+    "icalendar",
+    "rfc"
+  ],
+  "author": "Jakub Roztocil, Lars Schöning, and David Golightly and nibgat",
+  "main": "dist/es5/rrule.js",
+  "module": "dist/esm/index.js",
+  "types": "dist/esm/index.d.ts",
+  "repository": {
+    "type": "git",
+    "url": "git://git.nibgat.space/nibgat-community/rrule.git"
+  },
+  "scripts": {
+    "prebuild": "yarn clean",
+    "prepublish": "yarn build",
+    "build": "yarn lint && yarn format-check && tsc -b tsconfig.build.json && webpack && tsc dist/esm/**/*.d.ts",
+    "clean": "rm -rf dist/",
+    "lint": "yarn eslint . --fix --config .eslintrc.js",
+    "format": "yarn prettier --write .",
+    "format-check": "yarn prettier --check .",
+    "run-ts": "TS_NODE_PROJECT=tsconfig.json node --loader ts-node/esm",
+    "test": "jest **/*.test.ts",
+    "test-ci": "yarn run-ts ./node_modules/.bin/nyc jest **/*.test.ts"
+  },
+  "nyc": {
+    "extension": [
+      ".ts",
+      ".tsx"
+    ],
+    "exclude": [
+      "**/*.d.ts"
+    ],
+    "reporter": [
+      "html"
+    ],
+    "all": true
+  },
+  "lint-staged": {
+    "*.ts": [
+      "yarn lint",
+      "yarn format"
+    ]
+  },
+  "devDependencies": {
+    "@types/assert": "^1.4.3",
+    "@types/jest": "^29.5.8",
+    "@types/mockdate": "^3.0.0",
+    "@types/node": "^17.0.41",
+    "@typescript-eslint/eslint-plugin": "^5.27.1",
+    "@typescript-eslint/parser": "^5.27.1",
+    "coverage": "^0.4.1",
+    "eslint": "^8.17.0",
+    "eslint-config-prettier": "^8.5.0",
+    "eslint-plugin-import": "^2.26.0",
+    "eslint-plugin-jsdoc": "^39.3.2",
+    "eslint-plugin-prettier": "^4.0.0",
+    "husky": "^8.0.1",
+    "jest": "^29.7.0",
+    "lint-staged": "^13.0.1",
+    "mockdate": "^3.0.5",
+    "nyc": "^15.1.0",
+    "prettier": "^2.6.2",
+    "source-map-loader": "^3.0.1",
+    "source-map-support": "^0.5.16",
+    "terser-webpack-plugin": "^5.3.3",
+    "ts-jest": "^29.1.1",
+    "ts-loader": "^9.3.0",
+    "ts-node": "^10.8.1",
+    "typescript": "^4.7.3",
+    "unminified-webpack-plugin": "^3.0.0",
+    "webpack": "^5.73.0",
+    "webpack-cli": "^4.9.2"
+  },
+  "files": [
+    "dist",
+    "README.md"
+  ],
+  "peerDependencies": {},
+  "dependencies": {
+    "tslib": "^2.4.0"
+  }
+}

+ 106 - 0
src/cache.ts

@@ -0,0 +1,106 @@
+import IterResult, { IterArgs } from './iterresult'
+import { clone, cloneDates } from './dateutil'
+import { isArray } from './helpers'
+
+export type CacheKeys = 'before' | 'after' | 'between'
+
+function argsMatch(
+  left: IterArgs[keyof IterArgs] | undefined,
+  right: IterArgs[keyof IterArgs] | undefined
+) {
+  if (Array.isArray(left)) {
+    if (!Array.isArray(right)) return false
+    if (left.length !== right.length) return false
+    return left.every((date, i) => date.getTime() === right[i].getTime())
+  }
+
+  if (left instanceof Date) {
+    return right instanceof Date && left.getTime() === right.getTime()
+  }
+
+  return left === right
+}
+
+export class Cache {
+  all: Date[] | Partial<IterArgs> | false = false
+  before: IterArgs[] = []
+  after: IterArgs[] = []
+  between: IterArgs[] = []
+
+  /**
+   * @param {String} what - all/before/after/between
+   * @param {Array,Date} value - an array of dates, one date, or null
+   * @param {Object?} args - _iter arguments
+   */
+  public _cacheAdd(
+    what: CacheKeys | 'all',
+    value: Date[] | Date | null,
+    args?: Partial<IterArgs>
+  ) {
+    if (value) {
+      value = value instanceof Date ? clone(value) : cloneDates(value)
+    }
+
+    if (what === 'all') {
+      this.all = value as Date[]
+    } else {
+      args._value = value
+      this[what].push(args as IterArgs)
+    }
+  }
+
+  /**
+   * @return false - not in the cache
+   * @return null  - cached, but zero occurrences (before/after)
+   * @return Date  - cached (before/after)
+   * @return []    - cached, but zero occurrences (all/between)
+   * @return [Date1, DateN] - cached (all/between)
+   */
+  public _cacheGet(
+    what: CacheKeys | 'all',
+    args?: Partial<IterArgs>
+  ): Date | Date[] | false | null {
+    let cached: Date | Date[] | false | null = false
+    const argsKeys = args ? (Object.keys(args) as (keyof IterArgs)[]) : []
+    const findCacheDiff = function (item: IterArgs) {
+      for (let i = 0; i < argsKeys.length; i++) {
+        const key = argsKeys[i]
+        if (!argsMatch(args[key], item[key])) {
+          return true
+        }
+      }
+      return false
+    }
+
+    const cachedObject = this[what]
+    if (what === 'all') {
+      cached = this.all as Date[]
+    } else if (isArray(cachedObject)) {
+      // Let's see whether we've already called the
+      // 'what' method with the same 'args'
+      for (let i = 0; i < cachedObject.length; i++) {
+        const item = cachedObject[i] as IterArgs
+        if (argsKeys.length && findCacheDiff(item)) continue
+        cached = item._value
+        break
+      }
+    }
+
+    if (!cached && this.all) {
+      // Not in the cache, but we already know all the occurrences,
+      // so we can find the correct dates from the cached ones.
+      const iterResult = new IterResult(what, args)
+      for (let i = 0; i < (this.all as Date[]).length; i++) {
+        if (!iterResult.accept((this.all as Date[])[i])) break
+      }
+      cached = iterResult.getValue() as Date
+      this._cacheAdd(what, cached, args)
+    }
+
+    return isArray(cached)
+      ? cloneDates(cached)
+      : cached instanceof Date
+      ? clone(cached)
+      : cached
+  }
+}

+ 29 - 0
src/callbackiterresult.ts

@@ -0,0 +1,29 @@
+import IterResult, { IterArgs } from './iterresult'
+
+type Iterator = (d: Date, len: number) => boolean
+
+/**
+ * IterResult subclass that calls a callback function on each add,
+ * and stops iterating when the callback returns false.
+ */
+export default class CallbackIterResult extends IterResult<'all' | 'between'> {
+  private iterator: Iterator
+
+  constructor(
+    method: 'all' | 'between',
+    args: Partial<IterArgs>,
+    iterator: Iterator
+  ) {
+    super(method, args)
+
+    this.iterator = iterator
+  }
+
+  add(date: Date) {
+    if (this.iterator(date, this._result.length)) {
+      this._result.push(date)
+      return true
+    }
+    return false
+  }
+}

+ 268 - 0
src/datetime.ts

@@ -0,0 +1,268 @@
+import { ParsedOptions, Frequency } from './types'
+import { pymod, divmod, empty, includes } from './helpers'
+import { getWeekday, MAXYEAR, monthRange } from './dateutil'
+
+export class Time {
+  public hour: number
+  public minute: number
+  public second: number
+  public millisecond: number
+
+  constructor(
+    hour: number,
+    minute: number,
+    second: number,
+    millisecond: number
+  ) {
+    this.hour = hour
+    this.minute = minute
+    this.second = second
+    this.millisecond = millisecond || 0
+  }
+
+  getHours() {
+    return this.hour
+  }
+
+  getMinutes() {
+    return this.minute
+  }
+
+  getSeconds() {
+    return this.second
+  }
+
+  getMilliseconds() {
+    return this.millisecond
+  }
+
+  getTime() {
+    return (
+      (this.hour * 60 * 60 + this.minute * 60 + this.second) * 1000 +
+      this.millisecond
+    )
+  }
+}
+
+export class DateTime extends Time {
+  public day: number
+  public month: number
+  public year: number
+
+  static fromDate(date: Date) {
+    return new this(
+      date.getUTCFullYear(),
+      date.getUTCMonth() + 1,
+      date.getUTCDate(),
+      date.getUTCHours(),
+      date.getUTCMinutes(),
+      date.getUTCSeconds(),
+      date.valueOf() % 1000
+    )
+  }
+
+  constructor(
+    year: number,
+    month: number,
+    day: number,
+    hour: number,
+    minute: number,
+    second: number,
+    millisecond: number
+  ) {
+    super(hour, minute, second, millisecond)
+    this.year = year
+    this.month = month
+    this.day = day
+  }
+
+  getWeekday() {
+    return getWeekday(new Date(this.getTime()))
+  }
+
+  getTime() {
+    return new Date(
+      Date.UTC(
+        this.year,
+        this.month - 1,
+        this.day,
+        this.hour,
+        this.minute,
+        this.second,
+        this.millisecond
+      )
+    ).getTime()
+  }
+
+  getDay() {
+    return this.day
+  }
+
+  getMonth() {
+    return this.month
+  }
+
+  getYear() {
+    return this.year
+  }
+
+  public addYears(years: number) {
+    this.year += years
+  }
+
+  public addMonths(months: number) {
+    this.month += months
+    if (this.month > 12) {
+      const yearDiv = Math.floor(this.month / 12)
+      const monthMod = pymod(this.month, 12)
+      this.month = monthMod
+      this.year += yearDiv
+      if (this.month === 0) {
+        this.month = 12
+        --this.year
+      }
+    }
+  }
+
+  public addWeekly(days: number, wkst: number) {
+    if (wkst > this.getWeekday()) {
+      this.day += -(this.getWeekday() + 1 + (6 - wkst)) + days * 7
+    } else {
+      this.day += -(this.getWeekday() - wkst) + days * 7
+    }
+
+    this.fixDay()
+  }
+
+  public addDaily(days: number) {
+    this.day += days
+    this.fixDay()
+  }
+
+  public addHours(hours: number, filtered: boolean, byhour: number[]) {
+    if (filtered) {
+      // Jump to one iteration before next day
+      this.hour += Math.floor((23 - this.hour) / hours) * hours
+    }
+
+    for (;;) {
+      this.hour += hours
+      const { div: dayDiv, mod: hourMod } = divmod(this.hour, 24)
+      if (dayDiv) {
+        this.hour = hourMod
+        this.addDaily(dayDiv)
+      }
+
+      if (empty(byhour) || includes(byhour, this.hour)) break
+    }
+  }
+
+  public addMinutes(
+    minutes: number,
+    filtered: boolean,
+    byhour: number[],
+    byminute: number[]
+  ) {
+    if (filtered) {
+      // Jump to one iteration before next day
+      this.minute +=
+        Math.floor((1439 - (this.hour * 60 + this.minute)) / minutes) * minutes
+    }
+
+    for (;;) {
+      this.minute += minutes
+      const { div: hourDiv, mod: minuteMod } = divmod(this.minute, 60)
+      if (hourDiv) {
+        this.minute = minuteMod
+        this.addHours(hourDiv, false, byhour)
+      }
+
+      if (
+        (empty(byhour) || includes(byhour, this.hour)) &&
+        (empty(byminute) || includes(byminute, this.minute))
+      ) {
+        break
+      }
+    }
+  }
+
+  public addSeconds(
+    seconds: number,
+    filtered: boolean,
+    byhour: number[],
+    byminute: number[],
+    bysecond: number[]
+  ) {
+    if (filtered) {
+      // Jump to one iteration before next day
+      this.second +=
+        Math.floor(
+          (86399 - (this.hour * 3600 + this.minute * 60 + this.second)) /
+            seconds
+        ) * seconds
+    }
+
+    for (;;) {
+      this.second += seconds
+      const { div: minuteDiv, mod: secondMod } = divmod(this.second, 60)
+      if (minuteDiv) {
+        this.second = secondMod
+        this.addMinutes(minuteDiv, false, byhour, byminute)
+      }
+
+      if (
+        (empty(byhour) || includes(byhour, this.hour)) &&
+        (empty(byminute) || includes(byminute, this.minute)) &&
+        (empty(bysecond) || includes(bysecond, this.second))
+      ) {
+        break
+      }
+    }
+  }
+
+  public fixDay() {
+    if (this.day <= 28) {
+      return
+    }
+
+    let daysinmonth = monthRange(this.year, this.month - 1)[1]
+    if (this.day <= daysinmonth) {
+      return
+    }
+
+    while (this.day > daysinmonth) {
+      this.day -= daysinmonth
+      ++this.month
+      if (this.month === 13) {
+        this.month = 1
+        ++this.year
+        if (this.year > MAXYEAR) {
+          return
+        }
+      }
+
+      daysinmonth = monthRange(this.year, this.month - 1)[1]
+    }
+  }
+
+  public add(options: ParsedOptions, filtered: boolean) {
+    const { freq, interval, wkst, byhour, byminute, bysecond } = options
+
+    switch (freq) {
+      case Frequency.YEARLY:
+        return this.addYears(interval)
+      case Frequency.MONTHLY:
+        return this.addMonths(interval)
+      case Frequency.WEEKLY:
+        return this.addWeekly(interval, wkst)
+      case Frequency.DAILY:
+        return this.addDaily(interval)
+      case Frequency.HOURLY:
+        return this.addHours(interval, filtered, byhour)
+      case Frequency.MINUTELY:
+        return this.addMinutes(interval, filtered, byhour, byminute)
+      case Frequency.SECONDLY:
+        return this.addSeconds(interval, filtered, byhour, byminute, bysecond)
+    }
+  }
+}

+ 222 - 0
src/dateutil.ts

@@ -0,0 +1,222 @@
+import { padStart } from './helpers'
+import { Time } from './datetime'
+
+type Datelike = Pick<Date, 'getTime'>
+
+export const datetime = function (
+  y: number,
+  m: number,
+  d: number,
+  h = 0,
+  i = 0,
+  s = 0
+) {
+  return new Date(Date.UTC(y, m - 1, d, h, i, s))
+}
+
+/**
+ * General date-related utilities.
+ * Also handles several incompatibilities between JavaScript and Python
+ *
+ */
+export const MONTH_DAYS = [31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31]
+
+/**
+ * Number of milliseconds of one day
+ */
+export const ONE_DAY = 1000 * 60 * 60 * 24
+
+/**
+ * @see: <http://docs.python.org/library/datetime.html#datetime.MAXYEAR>
+ */
+export const MAXYEAR = 9999
+
+/**
+ * Python uses 1-Jan-1 as the base for calculating ordinals but we don't
+ * want to confuse the JS engine with milliseconds > Number.MAX_NUMBER,
+ * therefore we use 1-Jan-1970 instead
+ */
+export const ORDINAL_BASE = datetime(1970, 1, 1)
+
+/**
+ * Python: MO-SU: 0 - 6
+ * JS: SU-SAT 0 - 6
+ */
+export const PY_WEEKDAYS = [6, 0, 1, 2, 3, 4, 5]
+
+/**
+ * py_date.timetuple()[7]
+ */
+export const getYearDay = function (date: Date) {
+  const dateNoTime = new Date(
+    date.getUTCFullYear(),
+    date.getUTCMonth(),
+    date.getUTCDate()
+  )
+  return (
+    Math.ceil(
+      (dateNoTime.valueOf() - new Date(date.getUTCFullYear(), 0, 1).valueOf()) /
+        ONE_DAY
+    ) + 1
+  )
+}
+
+export const isLeapYear = function (year: number) {
+  return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
+}
+
+export const isDate = function (value: unknown): value is Date {
+  return value instanceof Date
+}
+
+export const isValidDate = function (value: unknown): value is Date {
+  return isDate(value) && !isNaN(value.getTime())
+}
+
+/**
+ * @return {Number} the date's timezone offset in ms
+ */
+export const tzOffset = function (date: Date) {
+  return date.getTimezoneOffset() * 60 * 1000
+}
+
+/**
+ * @see: <http://www.mcfedries.com/JavaScript/DaysBetween.asp>
+ */
+export const daysBetween = function (date1: Date, date2: Date) {
+  // The number of milliseconds in one day
+  // Convert both dates to milliseconds
+  const date1ms = date1.getTime()
+  const date2ms = date2.getTime()
+
+  // Calculate the difference in milliseconds
+  const differencems = date1ms - date2ms
+
+  // Convert back to days and return
+  return Math.round(differencems / ONE_DAY)
+}
+
+/**
+ * @see: <http://docs.python.org/library/datetime.html#datetime.date.toordinal>
+ */
+export const toOrdinal = function (date: Date) {
+  return daysBetween(date, ORDINAL_BASE)
+}
+
+/**
+ * @see - <http://docs.python.org/library/datetime.html#datetime.date.fromordinal>
+ */
+export const fromOrdinal = function (ordinal: number) {
+  return new Date(ORDINAL_BASE.getTime() + ordinal * ONE_DAY)
+}
+
+export const getMonthDays = function (date: Date) {
+  const month = date.getUTCMonth()
+  return month === 1 && isLeapYear(date.getUTCFullYear())
+    ? 29
+    : MONTH_DAYS[month]
+}
+
+/**
+ * @return {Number} python-like weekday
+ */
+export const getWeekday = function (date: Date) {
+  return PY_WEEKDAYS[date.getUTCDay()]
+}
+
+/**
+ * @see: <http://docs.python.org/library/calendar.html#calendar.monthrange>
+ */
+export const monthRange = function (year: number, month: number) {
+  const date = datetime(year, month + 1, 1)
+  return [getWeekday(date), getMonthDays(date)]
+}
+
+/**
+ * @see: <http://docs.python.org/library/datetime.html#datetime.datetime.combine>
+ */
+export const combine = function (date: Date, time: Date | Time) {
+  time = time || date
+  return new Date(
+    Date.UTC(
+      date.getUTCFullYear(),
+      date.getUTCMonth(),
+      date.getUTCDate(),
+      time.getHours(),
+      time.getMinutes(),
+      time.getSeconds(),
+      time.getMilliseconds()
+    )
+  )
+}
+
+export const clone = function (date: Date | Time) {
+  const dolly = new Date(date.getTime())
+  return dolly
+}
+
+export const cloneDates = function (dates: Date[] | Time[]) {
+  const clones = []
+  for (let i = 0; i < dates.length; i++) {
+    clones.push(clone(dates[i]))
+  }
+  return clones
+}
+
+/**
+ * Sorts an array of Date or Time objects
+ */
+export const sort = function <T extends Datelike>(dates: T[]) {
+  dates.sort(function (a, b) {
+    return a.getTime() - b.getTime()
+  })
+}
+
+export const timeToUntilString = function (time: number, utc = true) {
+  const date = new Date(time)
+  return [
+    padStart(date.getUTCFullYear().toString(), 4, '0'),
+    padStart(date.getUTCMonth() + 1, 2, '0'),
+    padStart(date.getUTCDate(), 2, '0'),
+    'T',
+    padStart(date.getUTCHours(), 2, '0'),
+    padStart(date.getUTCMinutes(), 2, '0'),
+    padStart(date.getUTCSeconds(), 2, '0'),
+    utc ? 'Z' : '',
+  ].join('')
+}
+
+export const untilStringToDate = function (until: string) {
+  const re = /^(\d{4})(\d{2})(\d{2})(T(\d{2})(\d{2})(\d{2})Z?)?$/
+  const bits = re.exec(until)
+
+  if (!bits) throw new Error(`Invalid UNTIL value: ${until}`)
+
+  return new Date(
+    Date.UTC(
+      parseInt(bits[1], 10),
+      parseInt(bits[2], 10) - 1,
+      parseInt(bits[3], 10),
+      parseInt(bits[5], 10) || 0,
+      parseInt(bits[6], 10) || 0,
+      parseInt(bits[7], 10) || 0
+    )
+  )
+}
+
+const dateTZtoISO8601 = function (date: Date, timeZone: string) {
+  // date format for sv-SE is almost ISO8601
+  const dateStr = date.toLocaleString('sv-SE', { timeZone })
+  // '2023-02-07 10:41:36'
+  return dateStr.replace(' ', 'T') + 'Z'
+}
+
+export const dateInTimeZone = function (date: Date, timeZone: string) {
+  const localTimeZone = Intl.DateTimeFormat().resolvedOptions().timeZone
+  // Date constructor can only reliably parse dates in ISO8601 format
+  const dateInLocalTZ = new Date(dateTZtoISO8601(date, localTimeZone))
+  const dateInTargetTZ = new Date(dateTZtoISO8601(date, timeZone ?? 'UTC'))
+  const tzOffset = dateInTargetTZ.getTime() - dateInLocalTZ.getTime()
+
+  return new Date(date.getTime() - tzOffset)
+}

+ 39 - 0
src/datewithzone.ts

@@ -0,0 +1,39 @@
+import { dateInTimeZone, timeToUntilString } from './dateutil'
+
+export class DateWithZone {
+  public date: Date
+  public tzid?: string | null
+
+  constructor(date: Date, tzid?: string | null) {
+    if (isNaN(date.getTime())) {
+      throw new RangeError('Invalid date passed to DateWithZone')
+    }
+    this.date = date
+    this.tzid = tzid
+  }
+
+  private get isUTC() {
+    return !this.tzid || this.tzid.toUpperCase() === 'UTC'
+  }
+
+  public toString() {
+    const datestr = timeToUntilString(this.date.getTime(), this.isUTC)
+    if (!this.isUTC) {
+      return `;TZID=${this.tzid}:${datestr}`
+    }
+
+    return `:${datestr}`
+  }
+
+  public getTime() {
+    return this.date.getTime()
+  }
+
+  public rezonedDate() {
+    if (this.isUTC) {
+      return this.date
+    }
+
+    return dateInTimeZone(this.date, this.tzid)
+  }
+}

+ 137 - 0
src/helpers.ts

@@ -0,0 +1,137 @@
+// =============================================================================
+// Helper functions
+// =============================================================================
+
+import { ALL_WEEKDAYS, WeekdayStr } from './weekday'
+
+export const isPresent = function <T>(
+  value?: T | null | undefined
+): value is T {
+  return value !== null && value !== undefined
+}
+
+export const isNumber = function (value: unknown): value is number {
+  return typeof value === 'number'
+}
+
+export const isWeekdayStr = function (value: unknown): value is WeekdayStr {
+  return typeof value === 'string' && ALL_WEEKDAYS.includes(value as WeekdayStr)
+}
+
+export const isArray = Array.isArray
+
+/**
+ * Simplified version of python's range()
+ */
+export const range = function (start: number, end: number = start): number[] {
+  if (arguments.length === 1) {
+    end = start
+    start = 0
+  }
+  const rang = []
+  for (let i = start; i < end; i++) rang.push(i)
+  return rang
+}
+
+export const clone = function <T>(array: T[]): T[] {
+  return ([] as T[]).concat(array)
+}
+
+export const repeat = function <T>(value: T | T[], times: number): (T | T[])[] {
+  let i = 0
+  const array: (T | T[])[] = []
+
+  if (isArray(value)) {
+    for (; i < times; i++) array[i] = ([] as T[]).concat(value)
+  } else {
+    for (; i < times; i++) array[i] = value
+  }
+  return array
+}
+
+export const toArray = function <T>(item: T | T[]): T[] {
+  if (isArray(item)) {
+    return item
+  }
+
+  return [item]
+}
+
+export function padStart(
+  item: string | number,
+  targetLength: number,
+  padString = ' '
+) {
+  const str = String(item)
+  targetLength = targetLength >> 0
+  if (str.length > targetLength) {
+    return String(str)
+  }
+
+  targetLength = targetLength - str.length
+  if (targetLength > padString.length) {
+    padString += repeat(padString, targetLength / padString.length)
+  }
+
+  return padString.slice(0, targetLength) + String(str)
+}
+
+/**
+ * Python like split
+ */
+export const split = function (str: string, sep: string, num: number) {
+  const splits = str.split(sep)
+  return num
+    ? splits.slice(0, num).concat([splits.slice(num).join(sep)])
+    : splits
+}
+
+/**
+ * closure/goog/math/math.js:modulo
+ * Copyright 2006 The Closure Library Authors.
+ * The % operator in JavaScript returns the remainder of a / b, but differs from
+ * some other languages in that the result will have the same sign as the
+ * dividend. For example, -1 % 8 == -1, whereas in some other languages
+ * (such as Python) the result would be 7. This function emulates the more
+ * correct modulo behavior, which is useful for certain applications such as
+ * calculating an offset index in a circular list.
+ *
+ * @param {number} a The dividend.
+ * @param {number} b The divisor.
+ * @return {number} a % b where the result is between 0 and b (either 0 <= x < b
+ * or b < x <= 0, depending on the sign of b).
+ */
+export const pymod = function (a: number, b: number) {
+  const r = a % b
+  // If r and b differ in sign, add b to wrap the result to the correct sign.
+  return r * b < 0 ? r + b : r
+}
+
+/**
+ * @see: <http://docs.python.org/library/functions.html#divmod>
+ */
+export const divmod = function (a: number, b: number) {
+  return { div: Math.floor(a / b), mod: pymod(a, b) }
+}
+
+export const empty = function <T>(obj: T[] | null | undefined) {
+  return !isPresent(obj) || obj.length === 0
+}
+
+/**
+ * Python-like boolean
+ *
+ * @return {Boolean} value of an object/primitive, taking into account
+ * the fact that in Python an empty list's/tuple's
+ * boolean value is False, whereas in JS it's true
+ */
+export const notEmpty = function <T>(obj: T[] | null | undefined): obj is T[] {
+  return !empty(obj)
+}
+
+/**
+ * Return true if a value is in an array
+ */
+export const includes = function <T>(arr: T[] | null | undefined, val: T) {
+  return notEmpty(arr) && arr.indexOf(val) !== -1
+}

+ 24 - 0
src/index.ts

@@ -0,0 +1,24 @@
+/* !
+ * rrule.js - Library for working with recurrence rules for calendar dates.
+ * https://github.com/jakubroztocil/rrule
+ *
+ * Copyright 2010, Jakub Roztocil and Lars Schoning
+ * Licenced under the BSD licence.
+ * https://github.com/jakubroztocil/rrule/blob/master/LICENCE
+ *
+ * Based on:
+ * python-dateutil - Extensions to the standard Python datetime module.
+ * Copyright (c) 2003-2011 - Gustavo Niemeyer <gustavo@niemeyer.net>
+ * Copyright (c) 2012 - Tomi Pieviläinen <tomi.pievilainen@iki.fi>
+ * https://github.com/jakubroztocil/rrule/blob/master/LICENCE
+ *
+ */
+
+export { RRule } from './rrule'
+export { RRuleSet } from './rruleset'
+
+export { rrulestr } from './rrulestr'
+export { Frequency, ByWeekday, Options } from './types'
+export { Weekday, WeekdayStr, ALL_WEEKDAYS } from './weekday'
+export { RRuleStrOptions } from './rrulestr'
+export { datetime } from './dateutil'

+ 209 - 0
src/iter/index.ts

@@ -0,0 +1,209 @@
+import IterResult from '../iterresult'
+import { ParsedOptions, freqIsDailyOrGreater, QueryMethodTypes } from '../types'
+import { combine, fromOrdinal, MAXYEAR } from '../dateutil'
+import Iterinfo from '../iterinfo/index'
+import { RRule } from '../rrule'
+import { buildTimeset } from '../parseoptions'
+import { notEmpty, includes, isPresent } from '../helpers'
+import { DateWithZone } from '../datewithzone'
+import { buildPoslist } from './poslist'
+import { Time, DateTime } from '../datetime'
+
+export function iter<M extends QueryMethodTypes>(
+  iterResult: IterResult<M>,
+  options: ParsedOptions
+) {
+  const { dtstart, freq, interval, until, bysetpos } = options
+
+  let count = options.count
+  if (count === 0 || interval === 0) {
+    return emitResult(iterResult)
+  }
+
+  const counterDate = DateTime.fromDate(dtstart)
+
+  const ii = new Iterinfo(options)
+  ii.rebuild(counterDate.year, counterDate.month)
+
+  let timeset = makeTimeset(ii, counterDate, options)
+
+  for (;;) {
+    const [dayset, start, end] = ii.getdayset(freq)(
+      counterDate.year,
+      counterDate.month,
+      counterDate.day
+    )
+
+    const filtered = removeFilteredDays(dayset, start, end, ii, options)
+
+    if (notEmpty(bysetpos)) {
+      const poslist = buildPoslist(bysetpos, timeset, start, end, ii, dayset)
+
+      for (let j = 0; j < poslist.length; j++) {
+        const res = poslist[j]
+        if (until && res > until) {
+          return emitResult(iterResult)
+        }
+
+        if (res >= dtstart) {
+          const rezonedDate = rezoneIfNeeded(res, options)
+          if (!iterResult.accept(rezonedDate)) {
+            return emitResult(iterResult)
+          }
+
+          if (count) {
+            --count
+            if (!count) {
+              return emitResult(iterResult)
+            }
+          }
+        }
+      }
+    } else {
+      for (let j = start; j < end; j++) {
+        const currentDay = dayset[j]
+        if (!isPresent(currentDay)) {
+          continue
+        }
+
+        const date = fromOrdinal(ii.yearordinal + currentDay)
+        for (let k = 0; k < timeset.length; k++) {
+          const time = timeset[k]
+          const res = combine(date, time)
+          if (until && res > until) {
+            return emitResult(iterResult)
+          }
+
+          if (res >= dtstart) {
+            const rezonedDate = rezoneIfNeeded(res, options)
+            if (!iterResult.accept(rezonedDate)) {
+              return emitResult(iterResult)
+            }
+
+            if (count) {
+              --count
+              if (!count) {
+                return emitResult(iterResult)
+              }
+            }
+          }
+        }
+      }
+    }
+    if (options.interval === 0) {
+      return emitResult(iterResult)
+    }
+
+    // Handle frequency and interval
+    counterDate.add(options, filtered)
+
+    if (counterDate.year > MAXYEAR) {
+      return emitResult(iterResult)
+    }
+
+    if (!freqIsDailyOrGreater(freq)) {
+      timeset = ii.gettimeset(freq)(
+        counterDate.hour,
+        counterDate.minute,
+        counterDate.second,
+        0
+      )
+    }
+
+    ii.rebuild(counterDate.year, counterDate.month)
+  }
+}
+
+function isFiltered(
+  ii: Iterinfo,
+  currentDay: number,
+  options: ParsedOptions
+): boolean {
+  const {
+    bymonth,
+    byweekno,
+    byweekday,
+    byeaster,
+    bymonthday,
+    bynmonthday,
+    byyearday,
+  } = options
+
+  return (
+    (notEmpty(bymonth) && !includes(bymonth, ii.mmask[currentDay])) ||
+    (notEmpty(byweekno) && !ii.wnomask[currentDay]) ||
+    (notEmpty(byweekday) && !includes(byweekday, ii.wdaymask[currentDay])) ||
+    (notEmpty(ii.nwdaymask) && !ii.nwdaymask[currentDay]) ||
+    (byeaster !== null && !includes(ii.eastermask, currentDay)) ||
+    ((notEmpty(bymonthday) || notEmpty(bynmonthday)) &&
+      !includes(bymonthday, ii.mdaymask[currentDay]) &&
+      !includes(bynmonthday, ii.nmdaymask[currentDay])) ||
+    (notEmpty(byyearday) &&
+      ((currentDay < ii.yearlen &&
+        !includes(byyearday, currentDay + 1) &&
+        !includes(byyearday, -ii.yearlen + currentDay)) ||
+        (currentDay >= ii.yearlen &&
+          !includes(byyearday, currentDay + 1 - ii.yearlen) &&
+          !includes(byyearday, -ii.nextyearlen + currentDay - ii.yearlen))))
+  )
+}
+
+function rezoneIfNeeded(date: Date, options: ParsedOptions) {
+  return new DateWithZone(date, options.tzid).rezonedDate()
+}
+
+function emitResult<M extends QueryMethodTypes>(iterResult: IterResult<M>) {
+  return iterResult.getValue()
+}
+
+function removeFilteredDays(
+  dayset: (number | null)[],
+  start: number,
+  end: number,
+  ii: Iterinfo,
+  options: ParsedOptions
+) {
+  let filtered = false
+  for (let dayCounter = start; dayCounter < end; dayCounter++) {
+    const currentDay = dayset[dayCounter]
+
+    filtered = isFiltered(ii, currentDay, options)
+
+    if (filtered) dayset[currentDay] = null
+  }
+
+  return filtered
+}
+
+function makeTimeset(
+  ii: Iterinfo,
+  counterDate: DateTime,
+  options: ParsedOptions
+): Time[] | null {
+  const { freq, byhour, byminute, bysecond } = options
+
+  if (freqIsDailyOrGreater(freq)) {
+    return buildTimeset(options)
+  }
+
+  if (
+    (freq >= RRule.HOURLY &&
+      notEmpty(byhour) &&
+      !includes(byhour, counterDate.hour)) ||
+    (freq >= RRule.MINUTELY &&
+      notEmpty(byminute) &&
+      !includes(byminute, counterDate.minute)) ||
+    (freq >= RRule.SECONDLY &&
+      notEmpty(bysecond) &&
+      !includes(bysecond, counterDate.second))
+  ) {
+    return []
+  }
+
+  return ii.gettimeset(freq)(
+    counterDate.hour,
+    counterDate.minute,
+    counterDate.second,
+    counterDate.millisecond
+  )
+}

+ 53 - 0
src/iter/poslist.ts

@@ -0,0 +1,53 @@
+import { combine, fromOrdinal, sort } from '../dateutil'
+import Iterinfo from '../iterinfo/index'
+import { pymod, isPresent, includes } from '../helpers'
+import { Time } from '../datetime'
+
+export function buildPoslist(
+  bysetpos: number[],
+  timeset: Time[],
+  start: number,
+  end: number,
+  ii: Iterinfo,
+  dayset: (number | null)[]
+) {
+  const poslist: Date[] = []
+
+  for (let j = 0; j < bysetpos.length; j++) {
+    let daypos: number
+    let timepos: number
+    const pos = bysetpos[j]
+
+    if (pos < 0) {
+      daypos = Math.floor(pos / timeset.length)
+      timepos = pymod(pos, timeset.length)
+    } else {
+      daypos = Math.floor((pos - 1) / timeset.length)
+      timepos = pymod(pos - 1, timeset.length)
+    }
+
+    const tmp = []
+    for (let k = start; k < end; k++) {
+      const val = dayset[k]
+      if (!isPresent(val)) continue
+      tmp.push(val)
+    }
+    let i: number
+    if (daypos < 0) {
+      i = tmp.slice(daypos)[0]
+    } else {
+      i = tmp[daypos]
+    }
+
+    const time = timeset[timepos]
+    const date = fromOrdinal(ii.yearordinal + i)
+    const res = combine(date, time)
+    // XXX: can this ever be in the array?
+    // - compare the actual date instead?
+    if (!includes(poslist, res)) poslist.push(res)
+  }
+
+  sort(poslist)
+
+  return poslist
+}

+ 20 - 0
src/iterinfo/easter.ts

@@ -0,0 +1,20 @@
+export function easter(y: number, offset = 0) {
+  const a = y % 19
+  const b = Math.floor(y / 100)
+  const c = y % 100
+  const d = Math.floor(b / 4)
+  const e = b % 4
+  const f = Math.floor((b + 8) / 25)
+  const g = Math.floor((b - f + 1) / 3)
+  const h = Math.floor(19 * a + b - d - g + 15) % 30
+  const i = Math.floor(c / 4)
+  const k = c % 4
+  const l = Math.floor(32 + 2 * e + 2 * i - h - k) % 7
+  const m = Math.floor((a + 11 * h + 22 * l) / 451)
+  const month = Math.floor((h + l - 7 * m + 114) / 31)
+  const day = ((h + l - 7 * m + 114) % 31) + 1
+  const date = Date.UTC(y, month - 1, day + offset)
+  const yearStart = Date.UTC(y, 0, 1)
+
+  return [Math.ceil((date - yearStart) / (1000 * 60 * 60 * 24))]
+}

+ 180 - 0
src/iterinfo/index.ts

@@ -0,0 +1,180 @@
+import { notEmpty, repeat, range, isPresent } from '../helpers'
+import { ParsedOptions, Frequency } from '../types'
+import { YearInfo, rebuildYear } from './yearinfo'
+import { rebuildMonth, MonthInfo } from './monthinfo'
+import { easter } from './easter'
+import { Time } from '../datetime'
+import { datetime, sort, toOrdinal } from '../dateutil'
+
+export type DaySet = [(number | null)[], number, number]
+export type GetDayset = () => DaySet
+
+// =============================================================================
+// Iterinfo
+// =============================================================================
+
+export default class Iterinfo {
+  public yearinfo: YearInfo
+  public monthinfo: MonthInfo
+  public eastermask: number[] | null
+
+  // eslint-disable-next-line no-empty-function
+  constructor(private options: ParsedOptions) {}
+
+  rebuild(year: number, month: number) {
+    const options = this.options
+
+    if (year !== this.lastyear) {
+      this.yearinfo = rebuildYear(year, options)
+    }
+
+    if (
+      notEmpty(options.bynweekday) &&
+      (month !== this.lastmonth || year !== this.lastyear)
+    ) {
+      const { yearlen, mrange, wdaymask } = this.yearinfo
+      this.monthinfo = rebuildMonth(
+        year,
+        month,
+        yearlen,
+        mrange,
+        wdaymask,
+        options
+      )
+    }
+
+    if (isPresent(options.byeaster)) {
+      this.eastermask = easter(year, options.byeaster)
+    }
+  }
+
+  get lastyear() {
+    return this.monthinfo ? this.monthinfo.lastyear : null
+  }
+
+  get lastmonth() {
+    return this.monthinfo ? this.monthinfo.lastmonth : null
+  }
+
+  get yearlen() {
+    return this.yearinfo.yearlen
+  }
+
+  get yearordinal() {
+    return this.yearinfo.yearordinal
+  }
+
+  get mrange() {
+    return this.yearinfo.mrange
+  }
+
+  get wdaymask() {
+    return this.yearinfo.wdaymask
+  }
+
+  get mmask() {
+    return this.yearinfo.mmask
+  }
+
+  get wnomask() {
+    return this.yearinfo.wnomask
+  }
+
+  get nwdaymask() {
+    return this.monthinfo ? this.monthinfo.nwdaymask : []
+  }
+
+  get nextyearlen() {
+    return this.yearinfo.nextyearlen
+  }
+
+  get mdaymask() {
+    return this.yearinfo.mdaymask
+  }
+
+  get nmdaymask() {
+    return this.yearinfo.nmdaymask
+  }
+
+  ydayset() {
+    return [range(this.yearlen), 0, this.yearlen]
+  }
+
+  mdayset(_: unknown, month: number) {
+    const start = this.mrange[month - 1]
+    const end = this.mrange[month]
+    const set = repeat<number | null>(null, this.yearlen)
+    for (let i = start; i < end; i++) set[i] = i
+    return [set, start, end]
+  }
+
+  wdayset(year: number, month: number, day: number) {
+    // We need to handle cross-year weeks here.
+    const set = repeat<number | null>(null, this.yearlen + 7)
+    let i = toOrdinal(datetime(year, month, day)) - this.yearordinal
+    const start = i
+    for (let j = 0; j < 7; j++) {
+      set[i] = i
+      ++i
+      if (this.wdaymask[i] === this.options.wkst) break
+    }
+    return [set, start, i]
+  }
+
+  ddayset(year: number, month: number, day: number) {
+    const set = repeat(null, this.yearlen) as (number | null)[]
+    const i = toOrdinal(datetime(year, month, day)) - this.yearordinal
+    set[i] = i
+    return [set, i, i + 1]
+  }
+
+  htimeset(hour: number, _: number, second: number, millisecond: number) {
+    let set: Time[] = []
+    this.options.byminute.forEach((minute) => {
+      set = set.concat(this.mtimeset(hour, minute, second, millisecond))
+    })
+    sort(set)
+    return set
+  }
+
+  mtimeset(hour: number, minute: number, _: number, millisecond: number) {
+    const set = this.options.bysecond.map(
+      (second) => new Time(hour, minute, second, millisecond)
+    )
+
+    sort(set)
+    return set
+  }
+
+  stimeset(hour: number, minute: number, second: number, millisecond: number) {
+    return [new Time(hour, minute, second, millisecond)]
+  }
+
+  getdayset(freq: Frequency): (y: number, m: number, d: number) => DaySet {
+    switch (freq) {
+      case Frequency.YEARLY:
+        return this.ydayset.bind(this)
+      case Frequency.MONTHLY:
+        return this.mdayset.bind(this)
+      case Frequency.WEEKLY:
+        return this.wdayset.bind(this)
+      case Frequency.DAILY:
+        return this.ddayset.bind(this)
+      default:
+        return this.ddayset.bind(this)
+    }
+  }
+
+  gettimeset(
+    freq: Frequency.HOURLY | Frequency.MINUTELY | Frequency.SECONDLY
+  ): (h: number, m: number, s: number, ms: number) => Time[] {
+    switch (freq) {
+      case Frequency.HOURLY:
+        return this.htimeset.bind(this)
+      case Frequency.MINUTELY:
+        return this.mtimeset.bind(this)
+      case Frequency.SECONDLY:
+        return this.stimeset.bind(this)
+    }
+  }
+}

+ 67 - 0
src/iterinfo/monthinfo.ts

@@ -0,0 +1,67 @@
+import { ParsedOptions } from '../types'
+import { RRule } from '../rrule'
+import { empty, repeat, pymod } from '../helpers'
+
+export interface MonthInfo {
+  lastyear: number
+  lastmonth: number
+  nwdaymask: number[]
+}
+
+export function rebuildMonth(
+  year: number,
+  month: number,
+  yearlen: number,
+  mrange: number[],
+  wdaymask: number[],
+  options: ParsedOptions
+) {
+  const result: MonthInfo = {
+    lastyear: year,
+    lastmonth: month,
+    nwdaymask: [],
+  }
+
+  let ranges: number[][] = []
+  if (options.freq === RRule.YEARLY) {
+    if (empty(options.bymonth)) {
+      ranges = [[0, yearlen]]
+    } else {
+      for (let j = 0; j < options.bymonth.length; j++) {
+        month = options.bymonth[j]
+        ranges.push(mrange.slice(month - 1, month + 1))
+      }
+    }
+  } else if (options.freq === RRule.MONTHLY) {
+    ranges = [mrange.slice(month - 1, month + 1)]
+  }
+
+  if (empty(ranges)) {
+    return result
+  }
+
+  // Weekly frequency won't get here, so we may not
+  // care about cross-year weekly periods.
+  result.nwdaymask = repeat(0, yearlen) as number[]
+
+  for (let j = 0; j < ranges.length; j++) {
+    const rang = ranges[j]
+    const first = rang[0]
+    const last = rang[1] - 1
+
+    for (let k = 0; k < options.bynweekday.length; k++) {
+      let i
+      const [wday, n] = options.bynweekday[k]
+      if (n < 0) {
+        i = last + (n + 1) * 7
+        i -= pymod(wdaymask[i] - wday, 7)
+      } else {
+        i = first + (n - 1) * 7
+        i += pymod(7 - wdaymask[i] + wday, 7)
+      }
+      if (first <= i && i <= last) result.nwdaymask[i] = 1
+    }
+  }
+
+  return result
+}

+ 169 - 0
src/iterinfo/yearinfo.ts

@@ -0,0 +1,169 @@
+import { ParsedOptions } from '../types'
+import { datetime, getWeekday, isLeapYear, toOrdinal } from '../dateutil'
+import { empty, repeat, pymod, includes } from '../helpers'
+import {
+  M365MASK,
+  MDAY365MASK,
+  NMDAY365MASK,
+  WDAYMASK,
+  M365RANGE,
+  M366MASK,
+  MDAY366MASK,
+  NMDAY366MASK,
+  M366RANGE,
+} from '../masks'
+
+export interface YearInfo {
+  yearlen: 365 | 366
+  nextyearlen: 365 | 366
+  yearordinal: number
+  yearweekday: number
+  mmask: number[]
+  mrange: number[]
+  mdaymask: number[]
+  nmdaymask: number[]
+  wdaymask: number[]
+  wnomask: number[] | null
+}
+
+export function rebuildYear(year: number, options: ParsedOptions) {
+  const firstyday = datetime(year, 1, 1)
+
+  const yearlen = isLeapYear(year) ? 366 : 365
+  const nextyearlen = isLeapYear(year + 1) ? 366 : 365
+  const yearordinal = toOrdinal(firstyday)
+  const yearweekday = getWeekday(firstyday)
+
+  const result: YearInfo = {
+    yearlen,
+    nextyearlen,
+    yearordinal,
+    yearweekday,
+    ...baseYearMasks(year),
+    wnomask: null,
+  }
+
+  if (empty(options.byweekno)) {
+    return result
+  }
+
+  result.wnomask = repeat(0, yearlen + 7) as number[]
+  let firstwkst: number
+  let wyearlen: number
+  let no1wkst = (firstwkst = pymod(7 - yearweekday + options.wkst, 7))
+
+  if (no1wkst >= 4) {
+    no1wkst = 0
+    // Number of days in the year, plus the days we got
+    // from last year.
+    wyearlen = result.yearlen + pymod(yearweekday - options.wkst, 7)
+  } else {
+    // Number of days in the year, minus the days we
+    // left in last year.
+    wyearlen = yearlen - no1wkst
+  }
+
+  const div = Math.floor(wyearlen / 7)
+  const mod = pymod(wyearlen, 7)
+  const numweeks = Math.floor(div + mod / 4)
+
+  for (let j = 0; j < options.byweekno.length; j++) {
+    let n = options.byweekno[j]
+    if (n < 0) {
+      n += numweeks + 1
+    }
+    if (!(n > 0 && n <= numweeks)) {
+      continue
+    }
+
+    let i: number
+    if (n > 1) {
+      i = no1wkst + (n - 1) * 7
+      if (no1wkst !== firstwkst) {
+        i -= 7 - firstwkst
+      }
+    } else {
+      i = no1wkst
+    }
+
+    for (let k = 0; k < 7; k++) {
+      result.wnomask[i] = 1
+      i++
+      if (result.wdaymask[i] === options.wkst) break
+    }
+  }
+
+  if (includes(options.byweekno, 1)) {
+    // Check week number 1 of next year as well
+    // orig-TODO : Check -numweeks for next year.
+    let i = no1wkst + numweeks * 7
+    if (no1wkst !== firstwkst) i -= 7 - firstwkst
+    if (i < yearlen) {
+      // If week starts in next year, we
+      // don't care about it.
+      for (let j = 0; j < 7; j++) {
+        result.wnomask[i] = 1
+        i += 1
+        if (result.wdaymask[i] === options.wkst) break
+      }
+    }
+  }
+
+  if (no1wkst) {
+    // Check last week number of last year as
+    // well. If no1wkst is 0, either the year
+    // started on week start, or week number 1
+    // got days from last year, so there are no
+    // days from last year's last week number in
+    // this year.
+    let lnumweeks: number
+    if (!includes(options.byweekno, -1)) {
+      const lyearweekday = getWeekday(datetime(year - 1, 1, 1))
+
+      let lno1wkst = pymod(7 - lyearweekday.valueOf() + options.wkst, 7)
+
+      const lyearlen = isLeapYear(year - 1) ? 366 : 365
+      let weekst: number
+      if (lno1wkst >= 4) {
+        lno1wkst = 0
+        weekst = lyearlen + pymod(lyearweekday - options.wkst, 7)
+      } else {
+        weekst = yearlen - no1wkst
+      }
+
+      lnumweeks = Math.floor(52 + pymod(weekst, 7) / 4)
+    } else {
+      lnumweeks = -1
+    }
+
+    if (includes(options.byweekno, lnumweeks)) {
+      for (let i = 0; i < no1wkst; i++) result.wnomask[i] = 1
+    }
+  }
+
+  return result
+}
+
+function baseYearMasks(year: number) {
+  const yearlen = isLeapYear(year) ? 366 : 365
+  const firstyday = datetime(year, 1, 1)
+  const wday = getWeekday(firstyday)
+
+  if (yearlen === 365) {
+    return {
+      mmask: M365MASK as number[],
+      mdaymask: MDAY365MASK,
+      nmdaymask: NMDAY365MASK,
+      wdaymask: WDAYMASK.slice(wday),
+      mrange: M365RANGE,
+    }
+  }
+
+  return {
+    mmask: M366MASK as number[],
+    mdaymask: MDAY366MASK,
+    nmdaymask: NMDAY366MASK,
+    wdaymask: WDAYMASK.slice(wday),
+    mrange: M366RANGE,
+  }
+}

+ 101 - 0
src/iterresult.ts

@@ -0,0 +1,101 @@
+import { QueryMethodTypes, IterResultType } from './types'
+
+// =============================================================================
+// Results
+// =============================================================================
+
+export interface IterArgs {
+  inc: boolean
+  before: Date
+  after: Date
+  dt: Date
+  _value: Date | Date[] | null
+}
+
+/**
+ * This class helps us to emulate python's generators, sorta.
+ */
+export default class IterResult<M extends QueryMethodTypes> {
+  public readonly method: M
+  public readonly args: Partial<IterArgs>
+  public readonly minDate: Date | null = null
+  public readonly maxDate: Date | null = null
+  public _result: Date[] = []
+  public total = 0
+
+  constructor(method: M, args: Partial<IterArgs>) {
+    this.method = method
+    this.args = args
+
+    if (method === 'between') {
+      this.maxDate = args.inc
+        ? args.before
+        : new Date(args.before.getTime() - 1)
+      this.minDate = args.inc ? args.after : new Date(args.after.getTime() + 1)
+    } else if (method === 'before') {
+      this.maxDate = args.inc ? args.dt : new Date(args.dt.getTime() - 1)
+    } else if (method === 'after') {
+      this.minDate = args.inc ? args.dt : new Date(args.dt.getTime() + 1)
+    }
+  }
+
+  /**
+   * Possibly adds a date into the result.
+   *
+   * @param {Date} date - the date isn't necessarly added to the result
+   * list (if it is too late/too early)
+   * @return {Boolean} true if it makes sense to continue the iteration
+   * false if we're done.
+   */
+  accept(date: Date) {
+    ++this.total
+    const tooEarly = this.minDate && date < this.minDate
+    const tooLate = this.maxDate && date > this.maxDate
+
+    if (this.method === 'between') {
+      if (tooEarly) return true
+      if (tooLate) return false
+    } else if (this.method === 'before') {
+      if (tooLate) return false
+    } else if (this.method === 'after') {
+      if (tooEarly) return true
+      this.add(date)
+      return false
+    }
+
+    return this.add(date)
+  }
+
+  /**
+   *
+   * @param {Date} date that is part of the result.
+   * @return {Boolean} whether we are interested in more values.
+   */
+  add(date: Date) {
+    this._result.push(date)
+    return true
+  }
+
+  /**
+   * 'before' and 'after' return only one date, whereas 'all'
+   * and 'between' an array.
+   *
+   * @return {Date,Array?}
+   */
+  getValue(): IterResultType<M> {
+    const res = this._result
+    switch (this.method) {
+      case 'all':
+      case 'between':
+        return res as IterResultType<M>
+      case 'before':
+      case 'after':
+      default:
+        return (res.length ? res[res.length - 1] : null) as IterResultType<M>
+    }
+  }
+
+  clone() {
+    return new IterResult(this.method, this.args)
+  }
+}

+ 78 - 0
src/iterset.ts

@@ -0,0 +1,78 @@
+import IterResult from './iterresult'
+import { RRule } from './rrule'
+import { DateWithZone } from './datewithzone'
+import { iter } from './iter'
+import { sort } from './dateutil'
+import { QueryMethodTypes, IterResultType } from './types'
+
+export function iterSet<M extends QueryMethodTypes>(
+  iterResult: IterResult<M>,
+  _rrule: RRule[],
+  _exrule: RRule[],
+  _rdate: Date[],
+  _exdate: Date[],
+  tzid: string | undefined
+) {
+  const _exdateHash: { [k: number]: boolean } = {}
+  const _accept = iterResult.accept
+
+  function evalExdate(after: Date, before: Date) {
+    _exrule.forEach(function (rrule) {
+      rrule.between(after, before, true).forEach(function (date) {
+        _exdateHash[Number(date)] = true
+      })
+    })
+  }
+
+  _exdate.forEach(function (date) {
+    const zonedDate = new DateWithZone(date, tzid).rezonedDate()
+    _exdateHash[Number(zonedDate)] = true
+  })
+
+  iterResult.accept = function (date) {
+    const dt = Number(date)
+    if (isNaN(dt)) return _accept.call(this, date)
+    if (!_exdateHash[dt]) {
+      evalExdate(new Date(dt - 1), new Date(dt + 1))
+      if (!_exdateHash[dt]) {
+        _exdateHash[dt] = true
+        return _accept.call(this, date)
+      }
+    }
+    return true
+  }
+
+  if (iterResult.method === 'between') {
+    evalExdate(iterResult.args.after, iterResult.args.before)
+    iterResult.accept = function (date) {
+      const dt = Number(date)
+      if (!_exdateHash[dt]) {
+        _exdateHash[dt] = true
+        return _accept.call(this, date)
+      }
+      return true
+    }
+  }
+
+  for (let i = 0; i < _rdate.length; i++) {
+    const zonedDate = new DateWithZone(_rdate[i], tzid).rezonedDate()
+    if (!iterResult.accept(new Date(zonedDate.getTime()))) break
+  }
+
+  _rrule.forEach(function (rrule) {
+    iter(iterResult, rrule.options)
+  })
+
+  const res = iterResult._result
+  sort(res)
+  switch (iterResult.method) {
+    case 'all':
+    case 'between':
+      return res as IterResultType<M>
+    case 'before':
+      return ((res.length && res[res.length - 1]) || null) as IterResultType<M>
+    case 'after':
+    default:
+      return ((res.length && res[0]) || null) as IterResultType<M>
+  }
+}

+ 134 - 0
src/masks.ts

@@ -0,0 +1,134 @@
+import { range, repeat } from './helpers'
+
+// =============================================================================
+// Date masks
+// =============================================================================
+
+// Every mask is 7 days longer to handle cross-year weekly periods.
+
+const M365MASK = [
+  ...repeat(1, 31),
+  ...repeat(2, 28),
+  ...repeat(3, 31),
+  ...repeat(4, 30),
+  ...repeat(5, 31),
+  ...repeat(6, 30),
+  ...repeat(7, 31),
+  ...repeat(8, 31),
+  ...repeat(9, 30),
+  ...repeat(10, 31),
+  ...repeat(11, 30),
+  ...repeat(12, 31),
+  ...repeat(1, 7),
+]
+
+const M366MASK = [
+  ...repeat(1, 31),
+  ...repeat(2, 29),
+  ...repeat(3, 31),
+  ...repeat(4, 30),
+  ...repeat(5, 31),
+  ...repeat(6, 30),
+  ...repeat(7, 31),
+  ...repeat(8, 31),
+  ...repeat(9, 30),
+  ...repeat(10, 31),
+  ...repeat(11, 30),
+  ...repeat(12, 31),
+  ...repeat(1, 7),
+]
+
+const M28 = range(1, 29)
+const M29 = range(1, 30)
+const M30 = range(1, 31)
+const M31 = range(1, 32)
+
+const MDAY366MASK = [
+  ...M31,
+  ...M29,
+  ...M31,
+  ...M30,
+  ...M31,
+  ...M30,
+  ...M31,
+  ...M31,
+  ...M30,
+  ...M31,
+  ...M30,
+  ...M31,
+  ...M31.slice(0, 7),
+]
+
+const MDAY365MASK = [
+  ...M31,
+  ...M28,
+  ...M31,
+  ...M30,
+  ...M31,
+  ...M30,
+  ...M31,
+  ...M31,
+  ...M30,
+  ...M31,
+  ...M30,
+  ...M31,
+  ...M31.slice(0, 7),
+]
+
+const NM28 = range(-28, 0)
+const NM29 = range(-29, 0)
+const NM30 = range(-30, 0)
+const NM31 = range(-31, 0)
+
+const NMDAY366MASK = [
+  ...NM31,
+  ...NM29,
+  ...NM31,
+  ...NM30,
+  ...NM31,
+  ...NM30,
+  ...NM31,
+  ...NM31,
+  ...NM30,
+  ...NM31,
+  ...NM30,
+  ...NM31,
+  ...NM31.slice(0, 7),
+]
+
+const NMDAY365MASK = [
+  ...NM31,
+  ...NM28,
+  ...NM31,
+  ...NM30,
+  ...NM31,
+  ...NM30,
+  ...NM31,
+  ...NM31,
+  ...NM30,
+  ...NM31,
+  ...NM30,
+  ...NM31,
+  ...NM31.slice(0, 7),
+]
+
+const M366RANGE = [0, 31, 60, 91, 121, 152, 182, 213, 244, 274, 305, 335, 366]
+const M365RANGE = [0, 31, 59, 90, 120, 151, 181, 212, 243, 273, 304, 334, 365]
+
+const WDAYMASK = (function () {
+  let wdaymask: number[] = []
+  for (let i = 0; i < 55; i++) wdaymask = wdaymask.concat(range(7))
+  return wdaymask
+})()
+
+export {
+  WDAYMASK,
+  M365MASK,
+  M365RANGE,
+  M366MASK,
+  M366RANGE,
+  MDAY365MASK,
+  MDAY366MASK,
+  NMDAY365MASK,
+  NMDAY366MASK,
+}

+ 83 - 0
src/nlp/i18n.ts

@@ -0,0 +1,83 @@
+// =============================================================================
+// i18n
+// =============================================================================
+
+export interface Language {
+  dayNames: string[]
+  monthNames: string[]
+  tokens: {
+    [k: string]: RegExp
+  }
+}
+
+const ENGLISH: Language = {
+  dayNames: [
+    'Sunday',
+    'Monday',
+    'Tuesday',
+    'Wednesday',
+    'Thursday',
+    'Friday',
+    'Saturday',
+  ],
+  monthNames: [
+    'January',
+    'February',
+    'March',
+    'April',
+    'May',
+    'June',
+    'July',
+    'August',
+    'September',
+    'October',
+    'November',
+    'December',
+  ],
+  tokens: {
+    SKIP: /^[ \r\n\t]+|^\.$/,
+    number: /^[1-9][0-9]*/,
+    numberAsText: /^(one|two|three)/i,
+    every: /^every/i,
+    'day(s)': /^days?/i,
+    'weekday(s)': /^weekdays?/i,
+    'week(s)': /^weeks?/i,
+    'hour(s)': /^hours?/i,
+    'minute(s)': /^minutes?/i,
+    'month(s)': /^months?/i,
+    'year(s)': /^years?/i,
+    on: /^(on|in)/i,
+    at: /^(at)/i,
+    the: /^the/i,
+    first: /^first/i,
+    second: /^second/i,
+    third: /^third/i,
+    nth: /^([1-9][0-9]*)(\.|th|nd|rd|st)/i,
+    last: /^last/i,
+    for: /^for/i,
+    'time(s)': /^times?/i,
+    until: /^(un)?til/i,
+    monday: /^mo(n(day)?)?/i,
+    tuesday: /^tu(e(s(day)?)?)?/i,
+    wednesday: /^we(d(n(esday)?)?)?/i,
+    thursday: /^th(u(r(sday)?)?)?/i,
+    friday: /^fr(i(day)?)?/i,
+    saturday: /^sa(t(urday)?)?/i,
+    sunday: /^su(n(day)?)?/i,
+    january: /^jan(uary)?/i,
+    february: /^feb(ruary)?/i,
+    march: /^mar(ch)?/i,
+    april: /^apr(il)?/i,
+    may: /^may/i,
+    june: /^june?/i,
+    july: /^july?/i,
+    august: /^aug(ust)?/i,
+    september: /^sep(t(ember)?)?/i,
+    october: /^oct(ober)?/i,
+    november: /^nov(ember)?/i,
+    december: /^dec(ember)?/i,
+    comma: /^(,\s*|(and|or)\s*)+/i,
+  },
+}
+
+export default ENGLISH

+ 141 - 0
src/nlp/index.ts

@@ -0,0 +1,141 @@
+import ToText, { DateFormatter, GetText } from './totext'
+import parseText from './parsetext'
+import { RRule } from '../rrule'
+import { Frequency } from '../types'
+import ENGLISH, { Language } from './i18n'
+
+/* !
+ * rrule.js - Library for working with recurrence rules for calendar dates.
+ * https://github.com/jakubroztocil/rrule
+ *
+ * Copyright 2010, Jakub Roztocil and Lars Schoning
+ * Licenced under the BSD licence.
+ * https://github.com/jakubroztocil/rrule/blob/master/LICENCE
+ *
+ */
+
+/**
+ *
+ * Implementation of RRule.fromText() and RRule::toText().
+ *
+ *
+ * On the client side, this file needs to be included
+ * when those functions are used.
+ *
+ */
+
+// =============================================================================
+// fromText
+// =============================================================================
+/**
+ * Will be able to convert some of the below described rules from
+ * text format to a rule object.
+ *
+ *
+ * RULES
+ *
+ * Every ([n])
+ * day(s)
+ * | [weekday], ..., (and) [weekday]
+ * | weekday(s)
+ * | week(s)
+ * | month(s)
+ * | [month], ..., (and) [month]
+ * | year(s)
+ *
+ *
+ * Plus 0, 1, or multiple of these:
+ *
+ * on [weekday], ..., (or) [weekday] the [monthday], [monthday], ... (or) [monthday]
+ *
+ * on [weekday], ..., (and) [weekday]
+ *
+ * on the [monthday], [monthday], ... (and) [monthday] (day of the month)
+ *
+ * on the [nth-weekday], ..., (and) [nth-weekday] (of the month/year)
+ *
+ *
+ * Plus 0 or 1 of these:
+ *
+ * for [n] time(s)
+ *
+ * until [date]
+ *
+ * Plus (.)
+ *
+ *
+ * Definitely no supported for parsing:
+ *
+ * (for year):
+ * in week(s) [n], ..., (and) [n]
+ *
+ * on the [yearday], ..., (and) [n] day of the year
+ * on day [yearday], ..., (and) [n]
+ *
+ *
+ * NON-TERMINALS
+ *
+ * [n]: 1, 2 ..., one, two, three ..
+ * [month]: January, February, March, April, May, ... December
+ * [weekday]: Monday, ... Sunday
+ * [nth-weekday]: first [weekday], 2nd [weekday], ... last [weekday], ...
+ * [monthday]: first, 1., 2., 1st, 2nd, second, ... 31st, last day, 2nd last day, ..
+ * [date]:
+ * - [month] (0-31(,) ([year])),
+ * - (the) 0-31.(1-12.([year])),
+ * - (the) 0-31/(1-12/([year])),
+ * - [weekday]
+ *
+ * [year]: 0000, 0001, ... 01, 02, ..
+ *
+ * Definitely not supported for parsing:
+ *
+ * [yearday]: first, 1., 2., 1st, 2nd, second, ... 366th, last day, 2nd last day, ..
+ *
+ * @param {String} text
+ * @return {Object, Boolean} the rule, or null.
+ */
+const fromText = function (text: string, language: Language = ENGLISH) {
+  return new RRule(parseText(text, language) || undefined)
+}
+
+const common = [
+  'count',
+  'until',
+  'interval',
+  'byweekday',
+  'bymonthday',
+  'bymonth',
+]
+
+ToText.IMPLEMENTED = []
+ToText.IMPLEMENTED[Frequency.HOURLY] = common
+ToText.IMPLEMENTED[Frequency.MINUTELY] = common
+ToText.IMPLEMENTED[Frequency.DAILY] = ['byhour'].concat(common)
+ToText.IMPLEMENTED[Frequency.WEEKLY] = common
+ToText.IMPLEMENTED[Frequency.MONTHLY] = common
+ToText.IMPLEMENTED[Frequency.YEARLY] = ['byweekno', 'byyearday'].concat(common)
+
+// =============================================================================
+// Export
+// =============================================================================
+
+const toText = function (
+  rrule: RRule,
+  gettext?: GetText,
+  language?: Language,
+  dateFormatter?: DateFormatter
+) {
+  return new ToText(rrule, gettext, language, dateFormatter).toString()
+}
+
+const { isFullyConvertible } = ToText
+
+export interface Nlp {
+  fromText: typeof fromText
+  parseText: typeof parseText
+  isFullyConvertible: typeof isFullyConvertible
+  toText: typeof toText
+}
+
+export { fromText, parseText, isFullyConvertible, toText }

+ 439 - 0
src/nlp/parsetext.ts

@@ -0,0 +1,439 @@
+import ENGLISH, { Language } from './i18n'
+import { RRule } from '../rrule'
+import { ByWeekday, Options } from '../types'
+import { WeekdayStr } from '../weekday'
+
+// =============================================================================
+// Parser
+// =============================================================================
+
+class Parser {
+  private readonly rules: { [k: string]: RegExp }
+  public text: string
+  public symbol: string | null
+  public value: RegExpExecArray | null
+  private done = true
+
+  constructor(rules: { [k: string]: RegExp }) {
+    this.rules = rules
+  }
+
+  start(text: string) {
+    this.text = text
+    this.done = false
+    return this.nextSymbol()
+  }
+
+  isDone() {
+    return this.done && this.symbol === null
+  }
+
+  nextSymbol() {
+    let best: RegExpExecArray | null
+    let bestSymbol: string
+
+    this.symbol = null
+    this.value = null
+    do {
+      if (this.done) return false
+
+      let rule: RegExp
+      best = null
+      for (const name in this.rules) {
+        rule = this.rules[name]
+        const match = rule.exec(this.text)
+        if (match) {
+          if (best === null || match[0].length > best[0].length) {
+            best = match
+            bestSymbol = name
+          }
+        }
+      }
+
+      if (best != null) {
+        this.text = this.text.substr(best[0].length)
+
+        if (this.text === '') this.done = true
+      }
+
+      if (best == null) {
+        this.done = true
+        this.symbol = null
+        this.value = null
+        return
+      }
+    } while (bestSymbol === 'SKIP')
+
+    this.symbol = bestSymbol
+    this.value = best
+    return true
+  }
+
+  accept(name: string) {
+    if (this.symbol === name) {
+      if (this.value) {
+        const v = this.value
+        this.nextSymbol()
+        return v
+      }
+
+      this.nextSymbol()
+      return true
+    }
+
+    return false
+  }
+
+  acceptNumber() {
+    return this.accept('number') as RegExpExecArray
+  }
+
+  expect(name: string) {
+    if (this.accept(name)) return true
+
+    throw new Error('expected ' + name + ' but found ' + this.symbol)
+  }
+}
+
+export default function parseText(text: string, language: Language = ENGLISH) {
+  const options: Partial<Options> = {}
+  const ttr = new Parser(language.tokens)
+
+  if (!ttr.start(text)) return null
+
+  S()
+  return options
+
+  function S() {
+    // every [n]
+    ttr.expect('every')
+    const n = ttr.acceptNumber()
+    if (n) options.interval = parseInt(n[0], 10)
+    if (ttr.isDone()) throw new Error('Unexpected end')
+
+    switch (ttr.symbol) {
+      case 'day(s)':
+        options.freq = RRule.DAILY
+        if (ttr.nextSymbol()) {
+          AT()
+          F()
+        }
+        break
+
+      // FIXME Note: every 2 weekdays != every two weeks on weekdays.
+      // DAILY on weekdays is not a valid rule
+      case 'weekday(s)':
+        options.freq = RRule.WEEKLY
+        options.byweekday = [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR]
+        ttr.nextSymbol()
+        AT()
+        F()
+        break
+
+      case 'week(s)':
+        options.freq = RRule.WEEKLY
+        if (ttr.nextSymbol()) {
+          ON()
+          AT()
+          F()
+        }
+        break
+
+      case 'hour(s)':
+        options.freq = RRule.HOURLY
+        if (ttr.nextSymbol()) {
+          ON()
+          F()
+        }
+        break
+
+      case 'minute(s)':
+        options.freq = RRule.MINUTELY
+        if (ttr.nextSymbol()) {
+          ON()
+          F()
+        }
+        break
+
+      case 'month(s)':
+        options.freq = RRule.MONTHLY
+        if (ttr.nextSymbol()) {
+          ON()
+          F()
+        }
+        break
+
+      case 'year(s)':
+        options.freq = RRule.YEARLY
+        if (ttr.nextSymbol()) {
+          ON()
+          F()
+        }
+        break
+
+      case 'monday':
+      case 'tuesday':
+      case 'wednesday':
+      case 'thursday':
+      case 'friday':
+      case 'saturday':
+      case 'sunday':
+        options.freq = RRule.WEEKLY
+        const key: WeekdayStr = ttr.symbol
+          .substr(0, 2)
+          .toUpperCase() as WeekdayStr
+        options.byweekday = [RRule[key]]
+
+        if (!ttr.nextSymbol()) return
+
+        // TODO check for duplicates
+        while (ttr.accept('comma')) {
+          if (ttr.isDone()) throw new Error('Unexpected end')
+
+          const wkd = decodeWKD() as keyof typeof RRule
+          if (!wkd) {
+            throw new Error(
+              'Unexpected symbol ' + ttr.symbol + ', expected weekday'
+            )
+          }
+
+          options.byweekday.push(RRule[wkd] as ByWeekday)
+          ttr.nextSymbol()
+        }
+        AT()
+        MDAYs()
+        F()
+        break
+
+      case 'january':
+      case 'february':
+      case 'march':
+      case 'april':
+      case 'may':
+      case 'june':
+      case 'july':
+      case 'august':
+      case 'september':
+      case 'october':
+      case 'november':
+      case 'december':
+        options.freq = RRule.YEARLY
+        options.bymonth = [decodeM() as number]
+
+        if (!ttr.nextSymbol()) return
+
+        // TODO check for duplicates
+        while (ttr.accept('comma')) {
+          if (ttr.isDone()) throw new Error('Unexpected end')
+
+          const m = decodeM()
+          if (!m) {
+            throw new Error(
+              'Unexpected symbol ' + ttr.symbol + ', expected month'
+            )
+          }
+
+          options.bymonth.push(m)
+          ttr.nextSymbol()
+        }
+
+        ON()
+        F()
+        break
+
+      default:
+        throw new Error('Unknown symbol')
+    }
+  }
+
+  function ON() {
+    const on = ttr.accept('on')
+    const the = ttr.accept('the')
+    if (!(on || the)) return
+
+    do {
+      const nth = decodeNTH()
+      const wkd = decodeWKD()
+      const m = decodeM()
+
+      // nth <weekday> | <weekday>
+      if (nth) {
+        // ttr.nextSymbol()
+
+        if (wkd) {
+          ttr.nextSymbol()
+          if (!options.byweekday) options.byweekday = [] as ByWeekday[]
+          ;(options.byweekday as ByWeekday[]).push(
+            RRule[wkd as WeekdayStr].nth(nth)
+          )
+        } else {
+          if (!options.bymonthday) options.bymonthday = [] as number[]
+          ;(options.bymonthday as number[]).push(nth)
+          ttr.accept('day(s)')
+        }
+        // <weekday>
+      } else if (wkd) {
+        ttr.nextSymbol()
+        if (!options.byweekday) options.byweekday = [] as ByWeekday[]
+        ;(options.byweekday as ByWeekday[]).push(RRule[wkd as WeekdayStr])
+      } else if (ttr.symbol === 'weekday(s)') {
+        ttr.nextSymbol()
+        if (!options.byweekday) {
+          options.byweekday = [RRule.MO, RRule.TU, RRule.WE, RRule.TH, RRule.FR]
+        }
+      } else if (ttr.symbol === 'week(s)') {
+        ttr.nextSymbol()
+        let n = ttr.acceptNumber()
+        if (!n) {
+          throw new Error(
+            'Unexpected symbol ' + ttr.symbol + ', expected week number'
+          )
+        }
+        options.byweekno = [parseInt(n[0], 10)]
+        while (ttr.accept('comma')) {
+          n = ttr.acceptNumber()
+          if (!n) {
+            throw new Error(
+              'Unexpected symbol ' + ttr.symbol + '; expected monthday'
+            )
+          }
+          options.byweekno.push(parseInt(n[0], 10))
+        }
+      } else if (m) {
+        ttr.nextSymbol()
+        if (!options.bymonth) options.bymonth = [] as number[]
+        ;(options.bymonth as number[]).push(m)
+      } else {
+        return
+      }
+    } while (ttr.accept('comma') || ttr.accept('the') || ttr.accept('on'))
+  }
+
+  function AT() {
+    const at = ttr.accept('at')
+    if (!at) return
+
+    do {
+      let n = ttr.acceptNumber()
+      if (!n) {
+        throw new Error('Unexpected symbol ' + ttr.symbol + ', expected hour')
+      }
+      options.byhour = [parseInt(n[0], 10)]
+      while (ttr.accept('comma')) {
+        n = ttr.acceptNumber()
+        if (!n) {
+          throw new Error('Unexpected symbol ' + ttr.symbol + '; expected hour')
+        }
+        options.byhour.push(parseInt(n[0], 10))
+      }
+    } while (ttr.accept('comma') || ttr.accept('at'))
+  }
+
+  function decodeM() {
+    switch (ttr.symbol) {
+      case 'january':
+        return 1
+      case 'february':
+        return 2
+      case 'march':
+        return 3
+      case 'april':
+        return 4
+      case 'may':
+        return 5
+      case 'june':
+        return 6
+      case 'july':
+        return 7
+      case 'august':
+        return 8
+      case 'september':
+        return 9
+      case 'october':
+        return 10
+      case 'november':
+        return 11
+      case 'december':
+        return 12
+      default:
+        return false
+    }
+  }
+
+  function decodeWKD() {
+    switch (ttr.symbol) {
+      case 'monday':
+      case 'tuesday':
+      case 'wednesday':
+      case 'thursday':
+      case 'friday':
+      case 'saturday':
+      case 'sunday':
+        return ttr.symbol.substr(0, 2).toUpperCase()
+      default:
+        return false
+    }
+  }
+
+  function decodeNTH() {
+    switch (ttr.symbol) {
+      case 'last':
+        ttr.nextSymbol()
+        return -1
+      case 'first':
+        ttr.nextSymbol()
+        return 1
+      case 'second':
+        ttr.nextSymbol()
+        return ttr.accept('last') ? -2 : 2
+      case 'third':
+        ttr.nextSymbol()
+        return ttr.accept('last') ? -3 : 3
+      case 'nth':
+        const v = parseInt(ttr.value[1], 10)
+        if (v < -366 || v > 366) throw new Error('Nth out of range: ' + v)
+
+        ttr.nextSymbol()
+        return ttr.accept('last') ? -v : v
+
+      default:
+        return false
+    }
+  }
+
+  function MDAYs() {
+    ttr.accept('on')
+    ttr.accept('the')
+
+    let nth = decodeNTH()
+    if (!nth) return
+
+    options.bymonthday = [nth]
+    ttr.nextSymbol()
+
+    while (ttr.accept('comma')) {
+      nth = decodeNTH()
+      if (!nth) {
+        throw new Error(
+          'Unexpected symbol ' + ttr.symbol + '; expected monthday'
+        )
+      }
+
+      options.bymonthday.push(nth)
+      ttr.nextSymbol()
+    }
+  }
+
+  function F() {
+    if (ttr.symbol === 'until') {
+      const date = Date.parse(ttr.text)
+
+      if (!date) throw new Error('Cannot parse until date:' + ttr.text)
+      options.until = new Date(date)
+    } else if (ttr.accept('for')) {
+      options.count = parseInt(ttr.value[0], 10)
+      ttr.expect('number')
+      // ttr.expect('times')
+    }
+  }
+}

+ 508 - 0
src/nlp/totext.ts

@@ -0,0 +1,508 @@
+import ENGLISH, { Language } from './i18n'
+import { RRule } from '../rrule'
+import { Options, ByWeekday } from '../types'
+import { Weekday } from '../weekday'
+import { isArray, isNumber, isPresent } from '../helpers'
+
+// =============================================================================
+// Helper functions
+// =============================================================================
+
+/**
+ * Return true if a value is in an array
+ */
+const contains = function (arr: string[], val: string) {
+  return arr.indexOf(val) !== -1
+}
+
+// =============================================================================
+// ToText
+// =============================================================================
+
+export type GetText = (id: string | number | Weekday) => string
+
+const defaultGetText: GetText = (id) => id.toString()
+
+export type DateFormatter = (year: number, month: string, day: number) => string
+
+const defaultDateFormatter: DateFormatter = (
+  year: number,
+  month: string,
+  day: number
+) => `${month} ${day}, ${year}`
+
+/**
+ *
+ * @param {RRule} rrule
+ * Optional:
+ * @param {Function} gettext function
+ * @param {Object} language definition
+ * @constructor
+ */
+export default class ToText {
+  static IMPLEMENTED: string[][]
+  private rrule: RRule
+  private text: string[]
+  private gettext: GetText
+  private dateFormatter: DateFormatter
+  private language: Language
+  private options: Partial<Options>
+  private origOptions: Partial<Options>
+  private bymonthday: Options['bymonthday'] | null
+  private byweekday: {
+    allWeeks: ByWeekday[] | null
+    someWeeks: ByWeekday[] | null
+    isWeekdays: boolean
+    isEveryDay: boolean
+  } | null
+
+  constructor(
+    rrule: RRule,
+    gettext: GetText = defaultGetText,
+    language: Language = ENGLISH,
+    dateFormatter: DateFormatter = defaultDateFormatter
+  ) {
+    this.text = []
+    this.language = language || ENGLISH
+    this.gettext = gettext
+    this.dateFormatter = dateFormatter
+    this.rrule = rrule
+    this.options = rrule.options
+    this.origOptions = rrule.origOptions
+
+    if (this.origOptions.bymonthday) {
+      const bymonthday = ([] as number[]).concat(this.options.bymonthday)
+      const bynmonthday = ([] as number[]).concat(this.options.bynmonthday)
+
+      bymonthday.sort((a, b) => a - b)
+      bynmonthday.sort((a, b) => b - a)
+      // 1, 2, 3, .., -5, -4, -3, ..
+      this.bymonthday = bymonthday.concat(bynmonthday)
+      if (!this.bymonthday.length) this.bymonthday = null
+    }
+
+    if (isPresent(this.origOptions.byweekday)) {
+      const byweekday = !isArray(this.origOptions.byweekday)
+        ? [this.origOptions.byweekday]
+        : this.origOptions.byweekday
+      const days = String(byweekday)
+
+      this.byweekday = {
+        allWeeks: byweekday.filter(function (weekday: Weekday) {
+          return !weekday.n
+        }),
+        someWeeks: byweekday.filter(function (weekday: Weekday) {
+          return Boolean(weekday.n)
+        }),
+        isWeekdays:
+          days.indexOf('MO') !== -1 &&
+          days.indexOf('TU') !== -1 &&
+          days.indexOf('WE') !== -1 &&
+          days.indexOf('TH') !== -1 &&
+          days.indexOf('FR') !== -1 &&
+          days.indexOf('SA') === -1 &&
+          days.indexOf('SU') === -1,
+        isEveryDay:
+          days.indexOf('MO') !== -1 &&
+          days.indexOf('TU') !== -1 &&
+          days.indexOf('WE') !== -1 &&
+          days.indexOf('TH') !== -1 &&
+          days.indexOf('FR') !== -1 &&
+          days.indexOf('SA') !== -1 &&
+          days.indexOf('SU') !== -1,
+      }
+
+      const sortWeekDays = function (a: Weekday, b: Weekday) {
+        return a.weekday - b.weekday
+      }
+
+      this.byweekday.allWeeks.sort(sortWeekDays)
+      this.byweekday.someWeeks.sort(sortWeekDays)
+
+      if (!this.byweekday.allWeeks.length) this.byweekday.allWeeks = null
+      if (!this.byweekday.someWeeks.length) this.byweekday.someWeeks = null
+    } else {
+      this.byweekday = null
+    }
+  }
+
+  /**
+   * Test whether the rrule can be fully converted to text.
+   *
+   * @param {RRule} rrule
+   * @return {Boolean}
+   */
+  static isFullyConvertible(rrule: RRule) {
+    const canConvert = true
+
+    if (!(rrule.options.freq in ToText.IMPLEMENTED)) return false
+    if (rrule.origOptions.until && rrule.origOptions.count) return false
+
+    for (const key in rrule.origOptions) {
+      if (contains(['dtstart', 'tzid', 'wkst', 'freq'], key)) return true
+      if (!contains(ToText.IMPLEMENTED[rrule.options.freq], key)) return false
+    }
+
+    return canConvert
+  }
+
+  isFullyConvertible() {
+    return ToText.isFullyConvertible(this.rrule)
+  }
+
+  /**
+   * Perform the conversion. Only some of the frequencies are supported.
+   * If some of the rrule's options aren't supported, they'll
+   * be omitted from the output an "(~ approximate)" will be appended.
+   *
+   * @return {*}
+   */
+  toString() {
+    const gettext = this.gettext
+
+    if (!(this.options.freq in ToText.IMPLEMENTED)) {
+      return gettext('RRule error: Unable to fully convert this rrule to text')
+    }
+
+    this.text = [gettext('every')]
+    // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+    // @ts-ignore
+    this[RRule.FREQUENCIES[this.options.freq]]()
+
+    if (this.options.until) {
+      this.add(gettext('until'))
+      const until = this.options.until
+      this.add(
+        this.dateFormatter(
+          until.getUTCFullYear(),
+          this.language.monthNames[until.getUTCMonth()],
+          until.getUTCDate()
+        )
+      )
+    } else if (this.options.count) {
+      this.add(gettext('for'))
+        .add(this.options.count.toString())
+        .add(
+          this.plural(this.options.count) ? gettext('times') : gettext('time')
+        )
+    }
+
+    if (!this.isFullyConvertible()) this.add(gettext('(~ approximate)'))
+
+    return this.text.join('')
+  }
+
+  HOURLY() {
+    const gettext = this.gettext
+
+    if (this.options.interval !== 1) this.add(this.options.interval.toString())
+
+    this.add(
+      this.plural(this.options.interval) ? gettext('hours') : gettext('hour')
+    )
+  }
+
+  MINUTELY() {
+    const gettext = this.gettext
+
+    if (this.options.interval !== 1) this.add(this.options.interval.toString())
+
+    this.add(
+      this.plural(this.options.interval)
+        ? gettext('minutes')
+        : gettext('minute')
+    )
+  }
+
+  DAILY() {
+    const gettext = this.gettext
+
+    if (this.options.interval !== 1) this.add(this.options.interval.toString())
+
+    if (this.byweekday && this.byweekday.isWeekdays) {
+      this.add(
+        this.plural(this.options.interval)
+          ? gettext('weekdays')
+          : gettext('weekday')
+      )
+    } else {
+      this.add(
+        this.plural(this.options.interval) ? gettext('days') : gettext('day')
+      )
+    }
+
+    if (this.origOptions.bymonth) {
+      this.add(gettext('in'))
+      this._bymonth()
+    }
+
+    if (this.bymonthday) {
+      this._bymonthday()
+    } else if (this.byweekday) {
+      this._byweekday()
+    } else if (this.origOptions.byhour) {
+      this._byhour()
+    }
+  }
+
+  WEEKLY() {
+    const gettext = this.gettext
+
+    if (this.options.interval !== 1) {
+      this.add(this.options.interval.toString()).add(
+        this.plural(this.options.interval) ? gettext('weeks') : gettext('week')
+      )
+    }
+
+    if (this.byweekday && this.byweekday.isWeekdays) {
+      if (this.options.interval === 1) {
+        this.add(
+          this.plural(this.options.interval)
+            ? gettext('weekdays')
+            : gettext('weekday')
+        )
+      } else {
+        this.add(gettext('on')).add(gettext('weekdays'))
+      }
+    } else if (this.byweekday && this.byweekday.isEveryDay) {
+      this.add(
+        this.plural(this.options.interval) ? gettext('days') : gettext('day')
+      )
+    } else {
+      if (this.options.interval === 1) this.add(gettext('week'))
+
+      if (this.origOptions.bymonth) {
+        this.add(gettext('in'))
+        this._bymonth()
+      }
+
+      if (this.bymonthday) {
+        this._bymonthday()
+      } else if (this.byweekday) {
+        this._byweekday()
+      }
+
+      if (this.origOptions.byhour) {
+        this._byhour()
+      }
+    }
+  }
+
+  MONTHLY() {
+    const gettext = this.gettext
+
+    if (this.origOptions.bymonth) {
+      if (this.options.interval !== 1) {
+        this.add(this.options.interval.toString()).add(gettext('months'))
+        if (this.plural(this.options.interval)) this.add(gettext('in'))
+      } else {
+        // this.add(gettext('MONTH'))
+      }
+      this._bymonth()
+    } else {
+      if (this.options.interval !== 1) {
+        this.add(this.options.interval.toString())
+      }
+      this.add(
+        this.plural(this.options.interval)
+          ? gettext('months')
+          : gettext('month')
+      )
+    }
+    if (this.bymonthday) {
+      this._bymonthday()
+    } else if (this.byweekday && this.byweekday.isWeekdays) {
+      this.add(gettext('on')).add(gettext('weekdays'))
+    } else if (this.byweekday) {
+      this._byweekday()
+    }
+  }
+
+  YEARLY() {
+    const gettext = this.gettext
+
+    if (this.origOptions.bymonth) {
+      if (this.options.interval !== 1) {
+        this.add(this.options.interval.toString())
+        this.add(gettext('years'))
+      } else {
+        // this.add(gettext('YEAR'))
+      }
+      this._bymonth()
+    } else {
+      if (this.options.interval !== 1) {
+        this.add(this.options.interval.toString())
+      }
+      this.add(
+        this.plural(this.options.interval) ? gettext('years') : gettext('year')
+      )
+    }
+
+    if (this.bymonthday) {
+      this._bymonthday()
+    } else if (this.byweekday) {
+      this._byweekday()
+    }
+
+    if (this.options.byyearday) {
+      this.add(gettext('on the'))
+        .add(this.list(this.options.byyearday, this.nth, gettext('and')))
+        .add(gettext('day'))
+    }
+
+    if (this.options.byweekno) {
+      this.add(gettext('in'))
+        .add(
+          this.plural((this.options.byweekno as number[]).length)
+            ? gettext('weeks')
+            : gettext('week')
+        )
+        .add(this.list(this.options.byweekno, undefined, gettext('and')))
+    }
+  }
+
+  private _bymonthday() {
+    const gettext = this.gettext
+    if (this.byweekday && this.byweekday.allWeeks) {
+      this.add(gettext('on'))
+        .add(
+          this.list(this.byweekday.allWeeks, this.weekdaytext, gettext('or'))
+        )
+        .add(gettext('the'))
+        .add(this.list(this.bymonthday, this.nth, gettext('or')))
+    } else {
+      this.add(gettext('on the')).add(
+        this.list(this.bymonthday, this.nth, gettext('and'))
+      )
+    }
+    // this.add(gettext('DAY'))
+  }
+
+  private _byweekday() {
+    const gettext = this.gettext
+    if (this.byweekday.allWeeks && !this.byweekday.isWeekdays) {
+      this.add(gettext('on')).add(
+        this.list(this.byweekday.allWeeks, this.weekdaytext)
+      )
+    }
+
+    if (this.byweekday.someWeeks) {
+      if (this.byweekday.allWeeks) this.add(gettext('and'))
+
+      this.add(gettext('on the')).add(
+        this.list(this.byweekday.someWeeks, this.weekdaytext, gettext('and'))
+      )
+    }
+  }
+
+  private _byhour() {
+    const gettext = this.gettext
+
+    this.add(gettext('at')).add(
+      this.list(this.origOptions.byhour, undefined, gettext('and'))
+    )
+  }
+
+  private _bymonth() {
+    this.add(
+      this.list(this.options.bymonth, this.monthtext, this.gettext('and'))
+    )
+  }
+
+  nth(n: number | string) {
+    n = parseInt(n.toString(), 10)
+    let nth: string
+    const gettext = this.gettext
+
+    if (n === -1) return gettext('last')
+
+    const npos = Math.abs(n)
+    switch (npos) {
+      case 1:
+      case 21:
+      case 31:
+        nth = npos + gettext('st')
+        break
+      case 2:
+      case 22:
+        nth = npos + gettext('nd')
+        break
+      case 3:
+      case 23:
+        nth = npos + gettext('rd')
+        break
+      default:
+        nth = npos + gettext('th')
+    }
+
+    return n < 0 ? nth + ' ' + gettext('last') : nth
+  }
+
+  monthtext(m: number) {
+    return this.language.monthNames[m - 1]
+  }
+
+  weekdaytext(wday: Weekday | number) {
+    const weekday = isNumber(wday) ? (wday + 1) % 7 : wday.getJsWeekday()
+    return (
+      ((wday as Weekday).n ? this.nth((wday as Weekday).n) + ' ' : '') +
+      this.language.dayNames[weekday]
+    )
+  }
+
+  plural(n: number) {
+    return n % 100 !== 1
+  }
+
+  add(s: string) {
+    this.text.push(' ')
+    this.text.push(s)
+    return this
+  }
+
+  list(
+    arr: ByWeekday | ByWeekday[],
+    callback?: GetText,
+    finalDelim?: string,
+    delim = ','
+  ) {
+    if (!isArray(arr)) {
+      arr = [arr]
+    }
+    const delimJoin = function (
+      array: string[],
+      delimiter: string,
+      finalDelimiter: string
+    ) {
+      let list = ''
+
+      for (let i = 0; i < array.length; i++) {
+        if (i !== 0) {
+          if (i === array.length - 1) {
+            list += ' ' + finalDelimiter + ' '
+          } else {
+            list += delimiter + ' '
+          }
+        }
+        list += array[i]
+      }
+      return list
+    }
+
+    callback =
+      callback ||
+      function (o) {
+        return o.toString()
+      }
+
+    const realCallback = (arg: ByWeekday) => {
+      return callback && callback.call(this, arg)
+    }
+
+    if (finalDelim) {
+      return delimJoin(arr.map(realCallback), delim, finalDelim)
+    } else {
+      return arr.map(realCallback).join(delim + ' ')
+    }
+  }
+}

+ 107 - 0
src/optionstostring.ts

@@ -0,0 +1,107 @@
+import { Options } from './types'
+import { RRule, DEFAULT_OPTIONS } from './rrule'
+import { includes, isPresent, isArray, isNumber, toArray } from './helpers'
+import { Weekday } from './weekday'
+import { timeToUntilString } from './dateutil'
+import { DateWithZone } from './datewithzone'
+
+export function optionsToString(options: Partial<Options>) {
+  const rrule: string[][] = []
+  let dtstart = ''
+  const keys: (keyof Options)[] = Object.keys(options) as (keyof Options)[]
+  const defaultKeys = Object.keys(DEFAULT_OPTIONS)
+
+  for (let i = 0; i < keys.length; i++) {
+    if (keys[i] === 'tzid') continue
+    if (!includes(defaultKeys, keys[i])) continue
+
+    let key = keys[i].toUpperCase()
+    const value = options[keys[i]]
+    let outValue = ''
+
+    if (!isPresent(value) || (isArray(value) && !value.length)) continue
+
+    switch (key) {
+      case 'FREQ':
+        outValue = RRule.FREQUENCIES[options.freq]
+        break
+      case 'WKST':
+        if (isNumber(value)) {
+          outValue = new Weekday(value).toString()
+        } else {
+          outValue = value.toString()
+        }
+        break
+      case 'BYWEEKDAY':
+        /*
+          NOTE: BYWEEKDAY is a special case.
+          RRule() deconstructs the rule.options.byweekday array
+          into an array of Weekday arguments.
+          On the other hand, rule.origOptions is an array of Weekdays.
+          We need to handle both cases here.
+          It might be worth change RRule to keep the Weekdays.
+
+          Also, BYWEEKDAY (used by RRule) vs. BYDAY (RFC)
+
+          */
+        key = 'BYDAY'
+        outValue = toArray<Weekday | number[] | number>(
+          value as Weekday | number[] | number
+        )
+          .map((wday) => {
+            if (wday instanceof Weekday) {
+              return wday
+            }
+
+            if (isArray(wday)) {
+              return new Weekday(wday[0], wday[1])
+            }
+
+            return new Weekday(wday)
+          })
+          .toString()
+
+        break
+      case 'DTSTART':
+        dtstart = buildDtstart(value as number, options.tzid)
+        break
+
+      case 'UNTIL':
+        outValue = timeToUntilString(value as number, !options.tzid)
+        break
+
+      default:
+        if (isArray(value)) {
+          const strValues: string[] = []
+          for (let j = 0; j < value.length; j++) {
+            strValues[j] = String(value[j])
+          }
+          outValue = strValues.toString()
+        } else {
+          outValue = String(value)
+        }
+    }
+
+    if (outValue) {
+      rrule.push([key, outValue])
+    }
+  }
+
+  const rules = rrule
+    .map(([key, value]) => `${key}=${value.toString()}`)
+    .join(';')
+  let ruleString = ''
+  if (rules !== '') {
+    ruleString = `RRULE:${rules}`
+  }
+
+  return [dtstart, ruleString].filter((x) => !!x).join('\n')
+}
+
+function buildDtstart(dtstart?: number, tzid?: string | null) {
+  if (!dtstart) {
+    return ''
+  }
+
+  return 'DTSTART' + new DateWithZone(new Date(dtstart), tzid).toString()
+}

+ 220 - 0
src/parseoptions.ts

@@ -0,0 +1,220 @@
+import { Options, ParsedOptions, freqIsDailyOrGreater } from './types'
+import {
+  includes,
+  notEmpty,
+  isPresent,
+  isNumber,
+  isArray,
+  isWeekdayStr,
+} from './helpers'
+import { RRule, defaultKeys, DEFAULT_OPTIONS } from './rrule'
+import { getWeekday, isDate, isValidDate } from './dateutil'
+import { Weekday } from './weekday'
+import { Time } from './datetime'
+
+export function initializeOptions(options: Partial<Options>) {
+  const invalid: string[] = []
+  const keys = Object.keys(options) as (keyof Options)[]
+
+  // Shallow copy for options and origOptions and check for invalid
+  for (const key of keys) {
+    if (!includes(defaultKeys, key)) invalid.push(key)
+    if (isDate(options[key]) && !isValidDate(options[key])) {
+      invalid.push(key)
+    }
+  }
+
+  if (invalid.length) {
+    throw new Error('Invalid options: ' + invalid.join(', '))
+  }
+
+  return { ...options }
+}
+
+export function parseOptions(options: Partial<Options>) {
+  const opts = { ...DEFAULT_OPTIONS, ...initializeOptions(options) }
+
+  if (isPresent(opts.byeaster)) opts.freq = RRule.YEARLY
+
+  if (!(isPresent(opts.freq) && RRule.FREQUENCIES[opts.freq])) {
+    throw new Error(`Invalid frequency: ${opts.freq} ${options.freq}`)
+  }
+
+  if (!opts.dtstart) opts.dtstart = new Date(new Date().setMilliseconds(0))
+
+  if (!isPresent(opts.wkst)) {
+    opts.wkst = RRule.MO.weekday
+  } else if (isNumber(opts.wkst)) {
+    // cool, just keep it like that
+  } else {
+    opts.wkst = opts.wkst.weekday
+  }
+
+  if (isPresent(opts.bysetpos)) {
+    if (isNumber(opts.bysetpos)) opts.bysetpos = [opts.bysetpos]
+
+    for (let i = 0; i < opts.bysetpos.length; i++) {
+      const v = opts.bysetpos[i]
+      if (v === 0 || !(v >= -366 && v <= 366)) {
+        throw new Error(
+          'bysetpos must be between 1 and 366,' + ' or between -366 and -1'
+        )
+      }
+    }
+  }
+
+  if (
+    !(
+      Boolean(opts.byweekno as number) ||
+      notEmpty(opts.byweekno as number[]) ||
+      notEmpty(opts.byyearday as number[]) ||
+      Boolean(opts.bymonthday) ||
+      notEmpty(opts.bymonthday as number[]) ||
+      isPresent(opts.byweekday) ||
+      isPresent(opts.byeaster)
+    )
+  ) {
+    switch (opts.freq) {
+      case RRule.YEARLY:
+        if (!opts.bymonth) opts.bymonth = opts.dtstart.getUTCMonth() + 1
+        opts.bymonthday = opts.dtstart.getUTCDate()
+        break
+      case RRule.MONTHLY:
+        opts.bymonthday = opts.dtstart.getUTCDate()
+        break
+      case RRule.WEEKLY:
+        opts.byweekday = [getWeekday(opts.dtstart)]
+        break
+    }
+  }
+
+  // bymonth
+  if (isPresent(opts.bymonth) && !isArray(opts.bymonth)) {
+    opts.bymonth = [opts.bymonth]
+  }
+
+  // byyearday
+  if (
+    isPresent(opts.byyearday) &&
+    !isArray(opts.byyearday) &&
+    isNumber(opts.byyearday)
+  ) {
+    opts.byyearday = [opts.byyearday]
+  }
+
+  // bymonthday
+  if (!isPresent(opts.bymonthday)) {
+    opts.bymonthday = []
+    opts.bynmonthday = []
+  } else if (isArray(opts.bymonthday)) {
+    const bymonthday = []
+    const bynmonthday = []
+
+    for (let i = 0; i < opts.bymonthday.length; i++) {
+      const v = opts.bymonthday[i]
+      if (v > 0) {
+        bymonthday.push(v)
+      } else if (v < 0) {
+        bynmonthday.push(v)
+      }
+    }
+    opts.bymonthday = bymonthday
+    opts.bynmonthday = bynmonthday
+  } else if (opts.bymonthday < 0) {
+    opts.bynmonthday = [opts.bymonthday]
+    opts.bymonthday = []
+  } else {
+    opts.bynmonthday = []
+    opts.bymonthday = [opts.bymonthday]
+  }
+
+  // byweekno
+  if (isPresent(opts.byweekno) && !isArray(opts.byweekno)) {
+    opts.byweekno = [opts.byweekno]
+  }
+
+  // byweekday / bynweekday
+  if (!isPresent(opts.byweekday)) {
+    opts.bynweekday = null
+  } else if (isNumber(opts.byweekday)) {
+    opts.byweekday = [opts.byweekday]
+    opts.bynweekday = null
+  } else if (isWeekdayStr(opts.byweekday)) {
+    opts.byweekday = [Weekday.fromStr(opts.byweekday).weekday]
+    opts.bynweekday = null
+  } else if (opts.byweekday instanceof Weekday) {
+    if (!opts.byweekday.n || opts.freq > RRule.MONTHLY) {
+      opts.byweekday = [opts.byweekday.weekday]
+      opts.bynweekday = null
+    } else {
+      opts.bynweekday = [[opts.byweekday.weekday, opts.byweekday.n]]
+      opts.byweekday = null
+    }
+  } else {
+    const byweekday: number[] = []
+    const bynweekday = []
+
+    for (let i = 0; i < opts.byweekday.length; i++) {
+      const wday = opts.byweekday[i]
+
+      if (isNumber(wday)) {
+        byweekday.push(wday)
+        continue
+      } else if (isWeekdayStr(wday)) {
+        byweekday.push(Weekday.fromStr(wday).weekday)
+        continue
+      }
+
+      if (!wday.n || opts.freq > RRule.MONTHLY) {
+        byweekday.push(wday.weekday)
+      } else {
+        bynweekday.push([wday.weekday, wday.n])
+      }
+    }
+    opts.byweekday = notEmpty(byweekday) ? byweekday : null
+    opts.bynweekday = notEmpty(bynweekday) ? bynweekday : null
+  }
+
+  // byhour
+  if (!isPresent(opts.byhour)) {
+    opts.byhour = opts.freq < RRule.HOURLY ? [opts.dtstart.getUTCHours()] : null
+  } else if (isNumber(opts.byhour)) {
+    opts.byhour = [opts.byhour]
+  }
+
+  // byminute
+  if (!isPresent(opts.byminute)) {
+    opts.byminute =
+      opts.freq < RRule.MINUTELY ? [opts.dtstart.getUTCMinutes()] : null
+  } else if (isNumber(opts.byminute)) {
+    opts.byminute = [opts.byminute]
+  }
+
+  // bysecond
+  if (!isPresent(opts.bysecond)) {
+    opts.bysecond =
+      opts.freq < RRule.SECONDLY ? [opts.dtstart.getUTCSeconds()] : null
+  } else if (isNumber(opts.bysecond)) {
+    opts.bysecond = [opts.bysecond]
+  }
+
+  return { parsedOptions: opts as ParsedOptions }
+}
+
+export function buildTimeset(opts: ParsedOptions) {
+  const millisecondModulo = opts.dtstart.getTime() % 1000
+  if (!freqIsDailyOrGreater(opts.freq)) {
+    return []
+  }
+
+  const timeset: Time[] = []
+  opts.byhour.forEach((hour) => {
+    opts.byminute.forEach((minute) => {
+      opts.bysecond.forEach((second) => {
+        timeset.push(new Time(hour, minute, second, millisecondModulo))
+      })
+    })
+  })
+
+  return timeset
+}

+ 147 - 0
src/parsestring.ts

@@ -0,0 +1,147 @@
+import { Options, Frequency } from './types'
+import { Weekday } from './weekday'
+import { untilStringToDate } from './dateutil'
+import { Days } from './rrule'
+
+export function parseString(rfcString: string): Partial<Options> {
+  const options = rfcString
+    .split('\n')
+    .map(parseLine)
+    .filter((x) => x !== null)
+  return { ...options[0], ...options[1] }
+}
+
+export function parseDtstart(line: string) {
+  const options: Partial<Options> = {}
+
+  const dtstartWithZone = /DTSTART(?:;TZID=([^:=]+?))?(?::|=)([^;\s]+)/i.exec(
+    line
+  )
+
+  if (!dtstartWithZone) {
+    return options
+  }
+
+  const [, tzid, dtstart] = dtstartWithZone
+
+  if (tzid) {
+    options.tzid = tzid
+  }
+  options.dtstart = untilStringToDate(dtstart)
+  return options
+}
+
+function parseLine(rfcString: string) {
+  rfcString = rfcString.replace(/^\s+|\s+$/, '')
+  if (!rfcString.length) return null
+
+  const header = /^([A-Z]+?)[:;]/.exec(rfcString.toUpperCase())
+  if (!header) {
+    return parseRrule(rfcString)
+  }
+
+  const [, key] = header
+  switch (key.toUpperCase()) {
+    case 'RRULE':
+    case 'EXRULE':
+      return parseRrule(rfcString)
+    case 'DTSTART':
+      return parseDtstart(rfcString)
+    default:
+      throw new Error(`Unsupported RFC prop ${key} in ${rfcString}`)
+  }
+}
+
+function parseRrule(line: string) {
+  const strippedLine = line.replace(/^RRULE:/i, '')
+  const options = parseDtstart(strippedLine)
+
+  const attrs = line.replace(/^(?:RRULE|EXRULE):/i, '').split(';')
+
+  attrs.forEach((attr) => {
+    const [key, value] = attr.split('=')
+    switch (key.toUpperCase()) {
+      case 'FREQ':
+        options.freq = Frequency[value.toUpperCase() as keyof typeof Frequency]
+        break
+      case 'WKST':
+        options.wkst = Days[value.toUpperCase() as keyof typeof Days]
+        break
+      case 'COUNT':
+      case 'INTERVAL':
+      case 'BYSETPOS':
+      case 'BYMONTH':
+      case 'BYMONTHDAY':
+      case 'BYYEARDAY':
+      case 'BYWEEKNO':
+      case 'BYHOUR':
+      case 'BYMINUTE':
+      case 'BYSECOND':
+        const num = parseNumber(value)
+        const optionKey = key.toLowerCase()
+        // eslint-disable-next-line @typescript-eslint/ban-ts-comment
+        // @ts-ignore
+        options[optionKey] = num
+        break
+      case 'BYWEEKDAY':
+      case 'BYDAY':
+        options.byweekday = parseWeekday(value)
+        break
+      case 'DTSTART':
+      case 'TZID':
+        // for backwards compatibility
+        const dtstart = parseDtstart(line)
+        options.tzid = dtstart.tzid
+        options.dtstart = dtstart.dtstart
+        break
+      case 'UNTIL':
+        options.until = untilStringToDate(value)
+        break
+      case 'BYEASTER':
+        options.byeaster = Number(value)
+        break
+      default:
+        throw new Error("Unknown RRULE property '" + key + "'")
+    }
+  })
+
+  return options
+}
+
+function parseNumber(value: string) {
+  if (value.indexOf(',') !== -1) {
+    const values = value.split(',')
+    return values.map(parseIndividualNumber)
+  }
+
+  return parseIndividualNumber(value)
+}
+
+function parseIndividualNumber(value: string) {
+  if (/^[+-]?\d+$/.test(value)) {
+    return Number(value)
+  }
+
+  return value
+}
+
+function parseWeekday(value: string) {
+  const days = value.split(',')
+
+  return days.map((day) => {
+    if (day.length === 2) {
+      // MO, TU, ...
+      return Days[day as keyof typeof Days] // wday instanceof Weekday
+    }
+
+    // -1MO, +3FR, 1SO, 13TU ...
+    const parts = day.match(/^([+-]?\d{1,2})([A-Z]{2})$/)
+    if (!parts || parts.length < 3) {
+      throw new SyntaxError(`Invalid weekday string: ${day}`)
+    }
+    const n = Number(parts[1])
+    const wdaypart = parts[2] as keyof typeof Days
+    const wday = Days[wdaypart].weekday
+    return new Weekday(wday, n)
+  })
+}

+ 281 - 0
src/rrule.ts

@@ -0,0 +1,281 @@
+import { isValidDate } from './dateutil'
+
+import IterResult, { IterArgs } from './iterresult'
+import CallbackIterResult from './callbackiterresult'
+import { Language } from './nlp/i18n'
+import { fromText, parseText, toText, isFullyConvertible } from './nlp/index'
+import { DateFormatter, GetText } from './nlp/totext'
+import {
+  ParsedOptions,
+  Options,
+  Frequency,
+  QueryMethods,
+  QueryMethodTypes,
+  IterResultType,
+} from './types'
+import { parseOptions, initializeOptions } from './parseoptions'
+import { parseString } from './parsestring'
+import { optionsToString } from './optionstostring'
+import { Cache, CacheKeys } from './cache'
+import { Weekday } from './weekday'
+import { iter } from './iter/index'
+
+// =============================================================================
+// RRule
+// =============================================================================
+
+export const Days = {
+  MO: new Weekday(0),
+  TU: new Weekday(1),
+  WE: new Weekday(2),
+  TH: new Weekday(3),
+  FR: new Weekday(4),
+  SA: new Weekday(5),
+  SU: new Weekday(6),
+}
+
+export const DEFAULT_OPTIONS: Options = {
+  freq: Frequency.YEARLY,
+  dtstart: null,
+  interval: 1,
+  wkst: Days.MO,
+  count: null,
+  until: null,
+  tzid: null,
+  bysetpos: null,
+  bymonth: null,
+  bymonthday: null,
+  bynmonthday: null,
+  byyearday: null,
+  byweekno: null,
+  byweekday: null,
+  bynweekday: null,
+  byhour: null,
+  byminute: null,
+  bysecond: null,
+  byeaster: null,
+}
+
+export const defaultKeys = Object.keys(DEFAULT_OPTIONS) as (keyof Options)[]
+
+/**
+ *
+ * @param {Options?} options - see <http://labix.org/python-dateutil/#head-cf004ee9a75592797e076752b2a889c10f445418>
+ * - The only required option is `freq`, one of RRule.YEARLY, RRule.MONTHLY, ...
+ * @constructor
+ */
+export class RRule implements QueryMethods {
+  public _cache: Cache | null
+  public origOptions: Partial<Options>
+  public options: ParsedOptions
+
+  // RRule class 'constants'
+
+  static readonly FREQUENCIES: (keyof typeof Frequency)[] = [
+    'YEARLY',
+    'MONTHLY',
+    'WEEKLY',
+    'DAILY',
+    'HOURLY',
+    'MINUTELY',
+    'SECONDLY',
+  ]
+
+  static readonly YEARLY = Frequency.YEARLY
+  static readonly MONTHLY = Frequency.MONTHLY
+  static readonly WEEKLY = Frequency.WEEKLY
+  static readonly DAILY = Frequency.DAILY
+  static readonly HOURLY = Frequency.HOURLY
+  static readonly MINUTELY = Frequency.MINUTELY
+  static readonly SECONDLY = Frequency.SECONDLY
+
+  static readonly MO = Days.MO
+  static readonly TU = Days.TU
+  static readonly WE = Days.WE
+  static readonly TH = Days.TH
+  static readonly FR = Days.FR
+  static readonly SA = Days.SA
+  static readonly SU = Days.SU
+
+  constructor(options: Partial<Options> = {}, noCache = false) {
+    // RFC string
+    this._cache = noCache ? null : new Cache()
+
+    // used by toString()
+    this.origOptions = initializeOptions(options)
+    const { parsedOptions } = parseOptions(options)
+    this.options = parsedOptions
+  }
+
+  static parseText(text: string, language?: Language) {
+    return parseText(text, language)
+  }
+
+  static fromText(text: string, language?: Language) {
+    return fromText(text, language)
+  }
+
+  static parseString = parseString
+
+  static fromString(str: string) {
+    return new RRule(RRule.parseString(str) || undefined)
+  }
+
+  static optionsToString = optionsToString
+
+  protected _iter<M extends QueryMethodTypes>(
+    iterResult: IterResult<M>
+  ): IterResultType<M> {
+    return iter(iterResult, this.options)
+  }
+
+  private _cacheGet(what: CacheKeys | 'all', args?: Partial<IterArgs>) {
+    if (!this._cache) return false
+    return this._cache._cacheGet(what, args)
+  }
+
+  public _cacheAdd(
+    what: CacheKeys | 'all',
+    value: Date[] | Date | null,
+    args?: Partial<IterArgs>
+  ) {
+    if (!this._cache) return
+    return this._cache._cacheAdd(what, value, args)
+  }
+
+  /**
+   * @param {Function} iterator - optional function that will be called
+   * on each date that is added. It can return false
+   * to stop the iteration.
+   * @return Array containing all recurrences.
+   */
+  all(iterator?: (d: Date, len: number) => boolean): Date[] {
+    if (iterator) {
+      return this._iter(new CallbackIterResult('all', {}, iterator))
+    }
+
+    let result = this._cacheGet('all') as Date[] | false
+    if (result === false) {
+      result = this._iter(new IterResult('all', {}))
+      this._cacheAdd('all', result)
+    }
+    return result
+  }
+
+  /**
+   * Returns all the occurrences of the rrule between after and before.
+   * The inc keyword defines what happens if after and/or before are
+   * themselves occurrences. With inc == True, they will be included in the
+   * list, if they are found in the recurrence set.
+   *
+   * @return Array
+   */
+  between(
+    after: Date,
+    before: Date,
+    inc = false,
+    iterator?: (d: Date, len: number) => boolean
+  ): Date[] {
+    if (!isValidDate(after) || !isValidDate(before)) {
+      throw new Error('Invalid date passed in to RRule.between')
+    }
+    const args = {
+      before,
+      after,
+      inc,
+    }
+
+    if (iterator) {
+      return this._iter(new CallbackIterResult('between', args, iterator))
+    }
+
+    let result = this._cacheGet('between', args)
+    if (result === false) {
+      result = this._iter(new IterResult('between', args))
+      this._cacheAdd('between', result, args)
+    }
+    return result as Date[]
+  }
+
+  /**
+   * Returns the last recurrence before the given datetime instance.
+   * The inc keyword defines what happens if dt is an occurrence.
+   * With inc == True, if dt itself is an occurrence, it will be returned.
+   *
+   * @return Date or null
+   */
+  before(dt: Date, inc = false): Date | null {
+    if (!isValidDate(dt)) {
+      throw new Error('Invalid date passed in to RRule.before')
+    }
+    const args = { dt: dt, inc: inc }
+    let result = this._cacheGet('before', args)
+    if (result === false) {
+      result = this._iter(new IterResult('before', args))
+      this._cacheAdd('before', result, args)
+    }
+    return result as Date | null
+  }
+
+  /**
+   * Returns the first recurrence after the given datetime instance.
+   * The inc keyword defines what happens if dt is an occurrence.
+   * With inc == True, if dt itself is an occurrence, it will be returned.
+   *
+   * @return Date or null
+   */
+  after(dt: Date, inc = false): Date | null {
+    if (!isValidDate(dt)) {
+      throw new Error('Invalid date passed in to RRule.after')
+    }
+    const args = { dt: dt, inc: inc }
+    let result = this._cacheGet('after', args)
+    if (result === false) {
+      result = this._iter(new IterResult('after', args))
+      this._cacheAdd('after', result, args)
+    }
+    return result as Date | null
+  }
+
+  /**
+   * Returns the number of recurrences in this set. It will have go trough
+   * the whole recurrence, if this hasn't been done before.
+   */
+  count(): number {
+    return this.all().length
+  }
+
+  /**
+   * Converts the rrule into its string representation
+   *
+   * @see <http://www.ietf.org/rfc/rfc2445.txt>
+   * @return String
+   */
+  toString() {
+    return optionsToString(this.origOptions)
+  }
+
+  /**
+   * Will convert all rules described in nlp:ToText
+   * to text.
+   */
+  toText(
+    gettext?: GetText,
+    language?: Language,
+    dateFormatter?: DateFormatter
+  ) {
+    return toText(this, gettext, language, dateFormatter)
+  }
+
+  isFullyConvertibleToText() {
+    return isFullyConvertible(this)
+  }
+
+  /**
+   * @return a RRule instance with the same freq and options
+   * as this one (cache is not cloned)
+   */
+  clone(): RRule {
+    return new RRule(this.origOptions)
+  }
+}

+ 231 - 0
src/rruleset.ts

@@ -0,0 +1,231 @@
+import { RRule } from './rrule'
+import { sort, timeToUntilString } from './dateutil'
+import { includes } from './helpers'
+import IterResult from './iterresult'
+import { iterSet } from './iterset'
+import { QueryMethodTypes, IterResultType } from './types'
+import { rrulestr } from './rrulestr'
+import { optionsToString } from './optionstostring'
+
+function createGetterSetter<T>(fieldName: string) {
+  return (field?: T) => {
+    if (field !== undefined) {
+      this[`_${fieldName}`] = field
+    }
+
+    if (this[`_${fieldName}`] !== undefined) {
+      return this[`_${fieldName}`]
+    }
+
+    for (let i = 0; i < this._rrule.length; i++) {
+      const field: T = this._rrule[i].origOptions[fieldName]
+      if (field) {
+        return field
+      }
+    }
+  }
+}
+
+export class RRuleSet extends RRule {
+  public readonly _rrule: RRule[]
+  public readonly _rdate: Date[]
+  public readonly _exrule: RRule[]
+  public readonly _exdate: Date[]
+
+  private _dtstart?: Date | null | undefined
+  private _tzid?: string
+
+  /**
+   *
+   * @param {Boolean?} noCache
+   * The same stratagy as RRule on cache, default to false
+   * @constructor
+   */
+  constructor(noCache = false) {
+    super({}, noCache)
+
+    this._rrule = []
+    this._rdate = []
+    this._exrule = []
+    this._exdate = []
+  }
+
+  dtstart = createGetterSetter.apply(this, ['dtstart'])
+  tzid = createGetterSetter.apply(this, ['tzid'])
+
+  _iter<M extends QueryMethodTypes>(
+    iterResult: IterResult<M>
+  ): IterResultType<M> {
+    return iterSet(
+      iterResult,
+      this._rrule,
+      this._exrule,
+      this._rdate,
+      this._exdate,
+      this.tzid()
+    )
+  }
+
+  /**
+   * Adds an RRule to the set
+   *
+   * @param {RRule}
+   */
+  rrule(rrule: RRule) {
+    _addRule(rrule, this._rrule)
+  }
+
+  /**
+   * Adds an EXRULE to the set
+   *
+   * @param {RRule}
+   */
+  exrule(rrule: RRule) {
+    _addRule(rrule, this._exrule)
+  }
+
+  /**
+   * Adds an RDate to the set
+   *
+   * @param {Date}
+   */
+  rdate(date: Date) {
+    _addDate(date, this._rdate)
+  }
+
+  /**
+   * Adds an EXDATE to the set
+   *
+   * @param {Date}
+   */
+  exdate(date: Date) {
+    _addDate(date, this._exdate)
+  }
+
+  /**
+   * Get list of included rrules in this recurrence set.
+   *
+   * @return List of rrules
+   */
+  rrules() {
+    return this._rrule.map((e) => rrulestr(e.toString()))
+  }
+
+  /**
+   * Get list of excluded rrules in this recurrence set.
+   *
+   * @return List of exrules
+   */
+  exrules() {
+    return this._exrule.map((e) => rrulestr(e.toString()))
+  }
+
+  /**
+   * Get list of included datetimes in this recurrence set.
+   *
+   * @return List of rdates
+   */
+  rdates() {
+    return this._rdate.map((e) => new Date(e.getTime()))
+  }
+
+  /**
+   * Get list of included datetimes in this recurrence set.
+   *
+   * @return List of exdates
+   */
+  exdates() {
+    return this._exdate.map((e) => new Date(e.getTime()))
+  }
+
+  valueOf() {
+    let result: string[] = []
+
+    if (!this._rrule.length && this._dtstart) {
+      result = result.concat(optionsToString({ dtstart: this._dtstart }))
+    }
+
+    this._rrule.forEach(function (rrule) {
+      result = result.concat(rrule.toString().split('\n'))
+    })
+
+    this._exrule.forEach(function (exrule) {
+      result = result.concat(
+        exrule
+          .toString()
+          .split('\n')
+          .map((line) => line.replace(/^RRULE:/, 'EXRULE:'))
+          .filter((line) => !/^DTSTART/.test(line))
+      )
+    })
+
+    if (this._rdate.length) {
+      result.push(rdatesToString('RDATE', this._rdate, this.tzid()))
+    }
+
+    if (this._exdate.length) {
+      result.push(rdatesToString('EXDATE', this._exdate, this.tzid()))
+    }
+
+    return result
+  }
+
+  /**
+   * to generate recurrence field such as:
+   * DTSTART:19970902T010000Z
+   * RRULE:FREQ=YEARLY;COUNT=2;BYDAY=TU
+   * RRULE:FREQ=YEARLY;COUNT=1;BYDAY=TH
+   */
+  toString() {
+    return this.valueOf().join('\n')
+  }
+
+  /**
+   * Create a new RRuleSet Object completely base on current instance
+   */
+  clone(): RRuleSet {
+    const rrs = new RRuleSet(!!this._cache)
+
+    this._rrule.forEach((rule) => rrs.rrule(rule.clone()))
+    this._exrule.forEach((rule) => rrs.exrule(rule.clone()))
+    this._rdate.forEach((date) => rrs.rdate(new Date(date.getTime())))
+    this._exdate.forEach((date) => rrs.exdate(new Date(date.getTime())))
+
+    return rrs
+  }
+}
+
+function _addRule(rrule: RRule, collection: RRule[]) {
+  if (!(rrule instanceof RRule)) {
+    throw new TypeError(String(rrule) + ' is not RRule instance')
+  }
+
+  if (!includes(collection.map(String), String(rrule))) {
+    collection.push(rrule)
+  }
+}
+
+function _addDate(date: Date, collection: Date[]) {
+  if (!(date instanceof Date)) {
+    throw new TypeError(String(date) + ' is not Date instance')
+  }
+  if (!includes(collection.map(Number), Number(date))) {
+    collection.push(date)
+    sort(collection)
+  }
+}
+
+function rdatesToString(
+  param: string,
+  rdates: Date[],
+  tzid: string | undefined
+) {
+  const isUTC = !tzid || tzid.toUpperCase() === 'UTC'
+  const header = isUTC ? `${param}:` : `${param};TZID=${tzid}:`
+
+  const dateString = rdates
+    .map((rdate) => timeToUntilString(rdate.valueOf(), isUTC))
+    .join(',')
+
+  return `${header}${dateString}`
+}

+ 251 - 0
src/rrulestr.ts

@@ -0,0 +1,251 @@
+import { RRule } from './rrule'
+import { RRuleSet } from './rruleset'
+import { untilStringToDate } from './dateutil'
+import { includes, split } from './helpers'
+import { Options } from './types'
+import { parseString, parseDtstart } from './parsestring'
+
+export interface RRuleStrOptions {
+  dtstart: Date | null
+  cache: boolean
+  unfold: boolean
+  forceset: boolean
+  compatible: boolean
+  tzid: string | null
+}
+
+/**
+ * RRuleStr
+ * To parse a set of rrule strings
+ */
+const DEFAULT_OPTIONS: RRuleStrOptions = {
+  dtstart: null,
+  cache: false,
+  unfold: false,
+  forceset: false,
+  compatible: false,
+  tzid: null,
+}
+
+export function parseInput(s: string, options: Partial<RRuleStrOptions>) {
+  const rrulevals: Partial<Options>[] = []
+  let rdatevals: Date[] = []
+  const exrulevals: Partial<Options>[] = []
+  let exdatevals: Date[] = []
+
+  const parsedDtstart = parseDtstart(s)
+  const { dtstart } = parsedDtstart
+  let { tzid } = parsedDtstart
+
+  const lines = splitIntoLines(s, options.unfold)
+
+  lines.forEach((line) => {
+    if (!line) return
+    const { name, parms, value } = breakDownLine(line)
+
+    switch (name.toUpperCase()) {
+      case 'RRULE':
+        if (parms.length) {
+          throw new Error(`unsupported RRULE parm: ${parms.join(',')}`)
+        }
+
+        rrulevals.push(parseString(line))
+        break
+
+      case 'RDATE':
+        const [, rdateTzid] = /RDATE(?:;TZID=([^:=]+))?/i.exec(line) ?? []
+        if (rdateTzid && !tzid) {
+          tzid = rdateTzid
+        }
+        rdatevals = rdatevals.concat(parseRDate(value, parms))
+        break
+
+      case 'EXRULE':
+        if (parms.length) {
+          throw new Error(`unsupported EXRULE parm: ${parms.join(',')}`)
+        }
+
+        exrulevals.push(parseString(value))
+        break
+
+      case 'EXDATE':
+        exdatevals = exdatevals.concat(parseRDate(value, parms))
+        break
+
+      case 'DTSTART':
+        break
+
+      default:
+        throw new Error('unsupported property: ' + name)
+    }
+  })
+
+  return {
+    dtstart,
+    tzid,
+    rrulevals,
+    rdatevals,
+    exrulevals,
+    exdatevals,
+  }
+}
+
+function buildRule(s: string, options: Partial<RRuleStrOptions>) {
+  const { rrulevals, rdatevals, exrulevals, exdatevals, dtstart, tzid } =
+    parseInput(s, options)
+
+  const noCache = options.cache === false
+
+  if (options.compatible) {
+    options.forceset = true
+    options.unfold = true
+  }
+
+  if (
+    options.forceset ||
+    rrulevals.length > 1 ||
+    rdatevals.length ||
+    exrulevals.length ||
+    exdatevals.length
+  ) {
+    const rset = new RRuleSet(noCache)
+
+    rset.dtstart(dtstart)
+    rset.tzid(tzid || undefined)
+
+    rrulevals.forEach((val) => {
+      rset.rrule(new RRule(groomRruleOptions(val, dtstart, tzid), noCache))
+    })
+
+    rdatevals.forEach((date) => {
+      rset.rdate(date)
+    })
+
+    exrulevals.forEach((val) => {
+      rset.exrule(new RRule(groomRruleOptions(val, dtstart, tzid), noCache))
+    })
+
+    exdatevals.forEach((date) => {
+      rset.exdate(date)
+    })
+
+    if (options.compatible && options.dtstart) rset.rdate(dtstart)
+    return rset
+  }
+
+  const val = rrulevals[0] || {}
+  return new RRule(
+    groomRruleOptions(
+      val,
+      val.dtstart || options.dtstart || dtstart,
+      val.tzid || options.tzid || tzid
+    ),
+    noCache
+  )
+}
+
+export function rrulestr(
+  s: string,
+  options: Partial<RRuleStrOptions> = {}
+): RRule | RRuleSet {
+  return buildRule(s, initializeOptions(options))
+}
+
+function groomRruleOptions(
+  val: Partial<Options>,
+  dtstart?: Date | null,
+  tzid?: string | null
+) {
+  return {
+    ...val,
+    dtstart,
+    tzid,
+  }
+}
+
+function initializeOptions(options: Partial<RRuleStrOptions>) {
+  const invalid: string[] = []
+  const keys = Object.keys(options) as (keyof typeof options)[]
+  const defaultKeys = Object.keys(
+    DEFAULT_OPTIONS
+  ) as (keyof typeof DEFAULT_OPTIONS)[]
+
+  keys.forEach(function (key) {
+    if (!includes(defaultKeys, key)) invalid.push(key)
+  })
+
+  if (invalid.length) {
+    throw new Error('Invalid options: ' + invalid.join(', '))
+  }
+
+  return { ...DEFAULT_OPTIONS, ...options }
+}
+
+function extractName(line: string) {
+  if (line.indexOf(':') === -1) {
+    return {
+      name: 'RRULE',
+      value: line,
+    }
+  }
+
+  const [name, value] = split(line, ':', 1)
+  return {
+    name,
+    value,
+  }
+}
+
+function breakDownLine(line: string) {
+  const { name, value } = extractName(line)
+  const parms = name.split(';')
+  if (!parms) throw new Error('empty property name')
+
+  return {
+    name: parms[0].toUpperCase(),
+    parms: parms.slice(1),
+    value,
+  }
+}
+
+function splitIntoLines(s: string, unfold = false) {
+  s = s && s.trim()
+  if (!s) throw new Error('Invalid empty string')
+
+  // More info about 'unfold' option
+  // Go head to http://www.ietf.org/rfc/rfc2445.txt
+  if (!unfold) {
+    return s.split(/\s/)
+  }
+
+  const lines = s.split('\n')
+  let i = 0
+  while (i < lines.length) {
+    // TODO
+    const line = (lines[i] = lines[i].replace(/\s+$/g, ''))
+    if (!line) {
+      lines.splice(i, 1)
+    } else if (i > 0 && line[0] === ' ') {
+      lines[i - 1] += line.slice(1)
+      lines.splice(i, 1)
+    } else {
+      i += 1
+    }
+  }
+
+  return lines
+}
+
+function validateDateParm(parms: string[]) {
+  parms.forEach((parm) => {
+    if (!/(VALUE=DATE(-TIME)?)|(TZID=)/.test(parm)) {
+      throw new Error('unsupported RDATE/EXDATE parm: ' + parm)
+    }
+  })
+}
+
+function parseRDate(rdateval: string, parms: string[]) {
+  validateDateParm(parms)
+
+  return rdateval.split(',').map((datestr) => untilStringToDate(datestr))
+}

+ 74 - 0
src/types.ts

@@ -0,0 +1,74 @@
+import { Weekday, WeekdayStr } from './weekday'
+
+export interface QueryMethods {
+  all(): Date[]
+  between(after: Date, before: Date, inc: boolean): Date[]
+  before(date: Date, inc: boolean): Date | null
+  after(date: Date, inc: boolean): Date | null
+}
+
+export type QueryMethodTypes = keyof QueryMethods
+export type IterResultType<M extends QueryMethodTypes> = M extends
+  | 'all'
+  | 'between'
+  ? Date[]
+  : Date | null
+
+export enum Frequency {
+  YEARLY = 0,
+  MONTHLY = 1,
+  WEEKLY = 2,
+  DAILY = 3,
+  HOURLY = 4,
+  MINUTELY = 5,
+  SECONDLY = 6,
+}
+
+export function freqIsDailyOrGreater(
+  freq: Frequency
+): freq is
+  | Frequency.YEARLY
+  | Frequency.MONTHLY
+  | Frequency.WEEKLY
+  | Frequency.DAILY {
+  return freq < Frequency.HOURLY
+}
+
+export interface Options {
+  freq: Frequency
+  dtstart: Date | null
+  interval: number
+  wkst: Weekday | number | null
+  count: number | null
+  until: Date | null
+  tzid: string | null
+  bysetpos: number | number[] | null
+  bymonth: number | number[] | null
+  bymonthday: number | number[] | null
+  bynmonthday: number[] | null
+  byyearday: number | number[] | null
+  byweekno: number | number[] | null
+  byweekday: ByWeekday | ByWeekday[] | null
+  bynweekday: number[][] | null
+  byhour: number | number[] | null
+  byminute: number | number[] | null
+  bysecond: number | number[] | null
+  byeaster: number | null
+}
+
+export interface ParsedOptions extends Options {
+  dtstart: Date
+  wkst: number
+  bysetpos: number[]
+  bymonth: number[]
+  bymonthday: number[]
+  bynmonthday: number[]
+  byyearday: number[]
+  byweekno: number[]
+  byweekday: number[]
+  byhour: number[]
+  byminute: number[]
+  bysecond: number[]
+}
+
+export type ByWeekday = WeekdayStr | number | Weekday

+ 51 - 0
src/weekday.ts

@@ -0,0 +1,51 @@
+// =============================================================================
+// Weekday
+// =============================================================================
+
+export type WeekdayStr = 'MO' | 'TU' | 'WE' | 'TH' | 'FR' | 'SA' | 'SU'
+export const ALL_WEEKDAYS: WeekdayStr[] = [
+  'MO',
+  'TU',
+  'WE',
+  'TH',
+  'FR',
+  'SA',
+  'SU',
+]
+
+export class Weekday {
+  public readonly weekday: number
+  public readonly n?: number
+
+  constructor(weekday: number, n?: number) {
+    if (n === 0) throw new Error("Can't create weekday with n == 0")
+    this.weekday = weekday
+    this.n = n
+  }
+
+  static fromStr(str: WeekdayStr): Weekday {
+    return new Weekday(ALL_WEEKDAYS.indexOf(str))
+  }
+
+  // __call__ - Cannot call the object directly, do it through
+  // e.g. RRule.TH.nth(-1) instead,
+  nth(n: number) {
+    return this.n === n ? this : new Weekday(this.weekday, n)
+  }
+
+  // __eq__
+  equals(other: Weekday) {
+    return this.weekday === other.weekday && this.n === other.n
+  }
+
+  // __repr__
+  toString() {
+    let s: string = ALL_WEEKDAYS[this.weekday]
+    if (this.n) s = (this.n > 0 ? '+' : '') + String(this.n) + s
+    return s
+  }
+
+  getJsWeekday() {
+    return this.weekday === 6 ? 0 : this.weekday + 1
+  }
+}

+ 19 - 0
tsconfig.build.json

@@ -0,0 +1,19 @@
+{
+  "compilerOptions": {
+    "baseUrl": "./dist/esm",
+    "outDir": "./dist/esm",
+    "declaration": true,
+    "declarationMap": true,
+    "noImplicitAny": true,
+    "sourceMap": true,
+    "module": "es2015",
+    "moduleResolution": "node",
+    "noEmitOnError": true,
+    "target": "es5",
+    "importHelpers": true,
+    "esModuleInterop": true,
+    "rootDirs": ["./src/"]
+  },
+  "include": ["./src/**/*"],
+  "exclude": ["node_modules", "./test/**/*"]
+}

+ 14 - 0
tsconfig.json

@@ -0,0 +1,14 @@
+{
+  "compilerOptions": {
+    "noEmit": true,
+    "noImplicitAny": true,
+    "sourceMap": true,
+    "module": "commonjs",
+    "noEmitOnError": true,
+    "esModuleInterop": true,
+    "target": "es6",
+    "rootDirs": ["./src/", "./test/"]
+  },
+  "include": ["./src/**/*", "./test/**/*"],
+  "exclude": ["node_modules"]
+}

+ 51 - 0
webpack.config.js

@@ -0,0 +1,51 @@
+const path = require('path')
+const TerserPlugin = require('terser-webpack-plugin')
+const UnminifiedWebpackPlugin = require('unminified-webpack-plugin')
+
+const paths = {
+  source: path.resolve(__dirname, 'src'),
+  es5: path.resolve(__dirname, 'dist', 'es5'),
+}
+
+const commonConfig = {
+  output: {
+    filename: '[name].min.js',
+    path: paths.es5,
+    library: 'rrule',
+    libraryTarget: 'umd',
+    globalObject: "typeof self !== 'undefined' ? self : this",
+  },
+  devtool: 'source-map',
+  mode: 'production',
+  resolve: {
+    extensions: ['.js', '.ts'],
+  },
+  module: {
+    rules: [
+      {
+        exclude: /node_modules/,
+        loader: 'ts-loader',
+        test: /\.ts$/,
+        options: {
+          configFile: 'tsconfig.build.json',
+        },
+      },
+    ],
+  },
+  optimization: {
+    minimize: true,
+    minimizer: [new TerserPlugin()],
+  },
+  plugins: [new UnminifiedWebpackPlugin()],
+}
+
+const rruleConfig = Object.assign(
+  {
+    entry: {
+      rrule: path.join(paths.source, 'index.ts'),
+    },
+  },
+  commonConfig
+)
+
+module.exports = [rruleConfig]