jkbrzt / rrule

JavaScript library for working with recurrence rules for calendar dates as defined in the iCalendar RFC and more.
https://jkbrzt.github.io/rrule
Other
3.34k stars 511 forks source link

RuleSet and rrulestr doesn't work as intended #332

Open florianchevallier opened 5 years ago

florianchevallier commented 5 years ago

Reporting an issue

Thank you for taking an interest in rrule! Please include the following in your report:

rrule version : ^2.6.0 on MacOS date: Lun 18 mar 2019 16:47:35 CET


Hi,

I'm trying to create a rruleset from a string passed in the DB, and I have a different behavior when using two rruleset.rrule or just one.

I'm not really clear, so here is the code sample :

const { RRuleSet, rrulestr } = require('rrule');
const moment = require('moment');

const rruleset = new RRuleSet();
const rruleset2 = new RRuleSet();

// creating a first ruleset with two different rrules
rruleset.rrule(rrulestr('DTSTART;TZID=Europe/Paris:20190311T070000\nRRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20190324T230000'));
rruleset.rrule(rrulestr('DTSTART;TZID=Europe/Paris:20190325T120000Z\nRRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20210325T120000Z'));
console.log(rruleset.toString());

console.log('---------');

// creating a rruleset from the first rruleset
rruleset2.rrule(rrulestr(rruleset.toString(), { forceset: true }));
console.log(rruleset2.toString());

console.log('---------');
console.log(rruleset.between(moment('2019-03-18 00:00').toDate(), moment('2019-04-01 00:00').toDate()));
console.log('--------');
console.log(rruleset2.between(moment('2019-03-18 00:00').toDate(), moment('2019-04-01 00:00').toDate()));

And here is the console.log associated, with the difference of the toString() of the two methods highlighted with a + and - :

DTSTART;TZID=Europe/Paris:20190311T070000
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20190324T230000
- DTSTART;TZID=Europe/Paris:20190325T120000
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20210325T120000
---------
DTSTART;TZID=Europe/Paris:20190311T070000
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20190324T230000
+ DTSTART;TZID=Europe/Paris:20190311T070000
RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;UNTIL=20210325T120000
---------
[ 2019-03-18T07:00:00.000Z,
  2019-03-19T07:00:00.000Z,
  2019-03-20T07:00:00.000Z,
  2019-03-21T07:00:00.000Z,
  2019-03-22T07:00:00.000Z,
  2019-03-25T12:00:00.000Z,
  2019-03-26T12:00:00.000Z,
  2019-03-27T12:00:00.000Z,
  2019-03-28T12:00:00.000Z,
  2019-03-29T12:00:00.000Z ]
--------
[ 2019-03-20T17:41:03.000Z ]

Obviously, the second result acts weirdly. I don't know if it's a bug, but it feels like one.

eyalcohen4 commented 5 years ago

I've encountered something similar too, and I think it's the Z at the ISO date end. Weird

ephys commented 3 years ago

From what I can tell, it's a bug in rrulestr where it reuses the DTSTART from the first RRule for subsequent rrules

@davidgoli If you're interested I can open a new PR for this once my current one is merged

ephys commented 3 years ago

Until this gets fixed I implemented an alternative to rrulestr that should handle DTSTART as expected:

import { RRule, RRuleSet, rrulestr } from 'rrule';

/**
 * Parse a rrule string as a RRuleSet.
 *
 * This also fixes a bug in {@link rrulestr} where the dtstart of the rrules following the first one are lost.
 * {@link https://github.com/jakubroztocil/rrule/issues/332}
 *
 * Does not support EXRULE, RDATE, or EXDATE
 *
 * @throws Error if the rrule cannot be parsed into a rruleset.
 * @param {string} rruleStr
 * @returns {RRuleSet}
 */
export function parseRRuleSet(rruleStr: string): RRuleSet {
  const set = new RRuleSet();

  rruleStr = rruleStr.trim();

  let dtStart = '';
  for (let line of rruleStr.split('\n')) {
    line = line.trim();

    const { name, value, parms } = breakDownLine(line);

    if (name !== 'RRULE' && dtStart) {
      throw new Error('Incorrectly placed DTSTART found. Must be placed one line before RRULE');
    }

    switch (name) {
      case 'RDATE': {
        const dates = parseRDate(value, parms);

        for (const date of dates) {
          set.rdate(date);
        }

        break;
      }

      case 'DTSTART':
        dtStart = line;
        continue;

      case 'RRULE': {
        const rrule = parseRrule(`${dtStart}\n${line}`);
        set.rrule(rrule);
        dtStart = '';
        break;
      }

      default:
        throw new Error('parseRRuleSet only supports DTSTART, RDATE & RRULE for now');
    }
  }

  return set;
}

export function parseRrule(rruleStr: string): RRule {
  const rrule = rrulestr(rruleStr);

  if (!(rrule instanceof RRule)) {
    throw new Error('Cannot parse input as RRule. Is it an RRuleSet?');
  }

  return rrule;
}

function parseRDate(rdateval, parms): Date[] {
  validateDateParm(parms);

  return rdateval
    .split(',')
    .map(datestr => {
      return parseRRuleDate(datestr);
    });
}

function parseRRuleDate(until: string): Date {
  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 RRule Date value: ${until}`);
  }

  const parseInt = Number.parseInt;

  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,
  ));
}

function validateDateParm(parms) {
  parms.forEach(parm => {
    if (!/(VALUE=DATE(-TIME)?)|(TZID=)/.test(parm)) {
      throw new Error(`unsupported RDATE/EXDATE parm: ${parm}`);
    }
  });
}

function breakDownLine(line) {
  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 extractName(line) {
  if (line.indexOf(':') === -1) {
    return {
      name: 'RRULE',
      value: line,
    };
  }

  const [name, value] = pythonSplit(line, ':', 1);

  return {
    name,
    value,
  };
}

function pythonSplit(str: string, sep: string, splitCount?: number) {
  const splits = str.split(sep);

  return splitCount
    ? splits.slice(0, splitCount).concat([splits.slice(splitCount).join(sep)])
    : splits;
}
ArnaudBan commented 3 years ago

Thank you so mutch this works for me.