使用moment js生成带有动态VTIMEZONE的ics

Generate ics with dynamic VTIMEZONE using moment js

正在尝试创建一个包含 VTIMEZONE 组件的 .ics 文件,该组件基于提供的时区动态设置标准时间和夏令时。

只是一个例子:

BEGIN:VTIMEZONE
TZID:America/New_York
LAST-MODIFIED:20050809T050000Z
BEGIN:STANDARD
DTSTART:20071104T020000
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
TZNAME:EST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:20070311T020000
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
TZNAME:EDT
END:DAYLIGHT
END:VTIMEZONE

在尝试解决这个问题时,我创建了一个 moment.tz.zone(timezone) 对象,它基于时刻 https://momentjs.com/timezone/docs/#/zone-object/ 的文档,我假设它包含必要的数据 untils(应该是 TZOFFSETFROM,TZOFFSETTO)和 offsets(DTSTART)。

但我找不到关于如何提取这些数据的明确文档。

想知道是否有人可以在瞬间提取标准时间和夏令时的 DTSTART、TZOFFSETFROM 和 TZOFFSETTO-timezone.js

您可以在这里下载预制的 VTIMEZONE 组件:

http://tzurl.org/

正如您在问题中已经提到的,您可以使用 moment.tz.zone(name) 方法。这将为您提供一个 Zone 对象,其中包含 untils 属性 中的时间戳列表,然后您可以应用您的逻辑在 VTIMEZONE 中获取所需的时间戳(我在我的代码示例中使用了 untils 数组的第一个时间戳。

您可以使用 moment.tz and format() on a timestamp to get DTSTART. You can pass ZZ token to format() 获取 TZOFFSETFROMTZOFFSETTO 的偏移量。

您可以使用 abbrs 个值来获得 TZNAME

这是一个活生生的例子:

const MAX_OCCUR = 2;
const getVtimezoneFromMomentZone = (tzName) => {
  const zone = moment.tz.zone(tzName);
  const header = `BEGIN:VTIMEZONE\nTZID:${tzName}`;
  const footer = 'END:VTIMEZONE';
  
  let zTZitems = '';
  for(let i=0; i<MAX_OCCUR && i+1<zone.untils.length; i++){
    const type = i%2 == 0 ? 'STANDARD' : 'DAYLIGHT';
    const momDtStart = moment.tz(zone.untils[i], tzName);
    const momNext = moment.tz(zone.untils[i+1], tzName);
    const item = 
`BEGIN:${type}
DTSTART:${momDtStart.format('YYYYMMDDTHHmmss')}
TZOFFSETFROM:${momDtStart.format('ZZ')}
TZOFFSETTO:${momNext.format('ZZ')}
TZNAME:${zone.abbrs[i]}
END:${type}\n`;
    zTZitems += item;
  }
  const result = `${header}\n${zTZitems}${footer}\n`;
  return result;
};

console.log(getVtimezoneFromMomentZone('America/New_York'));
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment.js/2.23.0/moment-with-locales.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/moment-timezone/0.5.23/moment-timezone-with-data-2012-2022.min.js"></script>

要以稳健的方式做到这一点有点挑战。

总结

  • 使用 RRULE 避免 ics 膨胀并支持长运行 或开放式循环事件。
    • moment-timezone 不会以任何方式公开底层区域信息数据,这将使为给定区域构建 RRULE 变得容易(据我所知)。
  • 对于具有固定日期的一次性事件,您可以根据事件日期从 moment.tz.zone('America/New_York').untils 中选择正确的时间间隔以包含在 ics 中。

详情

例如:moment.tz.zone('America/New_York').untils 包括从 1918 年到 2037 年的 235 个时间间隔(多年来 DAYLIGHTSTANDARD)。
您不想将它们全部包含在您的 ics 中。
如果您只在 VTIMEZONE 中包含前两个,它将无效,除了 1918/1919 的某些事件。

var timezoneName = 'America/New_York',
   {untils, abbrs, offsets} = moment.tz.zone(timezone);


console.log(untils.length); 
// 236
console.log(moment.tz(untils[0], timezoneName).format('YYYY-MM-DD HH:mm:ss'));
// 1918-03-31 03:00:00
console.log(moment.tz(untils[untils.length-2], timezoneName).format('YYYY-MM-DD HH:mm:ss')); 
// 2037-11-01 01:00:00
console.log(untils[untils.length-1]);  
// Infinity

您可以将所有 235 个这些间隔放入一个 ICS,但它会非常臃肿。

RFC section on VTIMEZONE 包含一些示例...

  This is an example showing time zone information for New York City
  using only the "DTSTART" property.  Note that this is only
  suitable for a recurring event that starts on or later than March
  11, 2007 at 03:00:00 EDT (i.e., the earliest effective transition
  date and time) and ends no later than March 9, 2008 at 01:59:59 EST (i.e., latest valid date and time for EST in this scenario).
  For example, this can be used for a recurring event that occurs
  every Friday, 8:00 A.M.-9:00 A.M., starting June 1, 2007, ending
  December 31, 2007,

   BEGIN:VTIMEZONE
   TZID:America/New_York
   LAST-MODIFIED:20050809T050000Z
   BEGIN:STANDARD
   DTSTART:20071104T020000
   TZOFFSETFROM:-0400
   TZOFFSETTO:-0500
   TZNAME:EST
   END:STANDARD
   BEGIN:DAYLIGHT
   DTSTART:20070311T020000
   TZOFFSETFROM:-0500
   TZOFFSETTO:-0400
   TZNAME:EDT
   END:DAYLIGHT
   END:VTIMEZONE

重点是示例中的 VTIMEZONEusing only the "DTSTART" property...在这种情况下 VTIMEZONE 仅对 [=22= 涵盖的事件日期有效] 和 DAYLIGHT 间隔在 VTIMEZONE.

中明确列出

来自 RFC 的另一个示例...

  This is a simple example showing the current time zone rules for
  New York City using a "RRULE" recurrence pattern.  Note that there
  is no effective end date to either of the Standard Time or
  Daylight Time rules.  This information would be valid for a
  recurring event starting today and continuing indefinitely.

   BEGIN:VTIMEZONE
   TZID:America/New_York
   LAST-MODIFIED:20050809T050000Z
   TZURL:http://zones.example.com/tz/America-New_York.ics
   BEGIN:STANDARD
   DTSTART:20071104T020000
   RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
   TZOFFSETFROM:-0400
   TZOFFSETTO:-0500
   TZNAME:EST
   END:STANDARD
   BEGIN:DAYLIGHT
   DTSTART:20070311T020000
   RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
   TZOFFSETFROM:-0500
   TZOFFSETTO:-0400
   TZNAME:EDT
   END:DAYLIGHT
   END:VTIMEZONE

请注意,在这种情况下,RRULE 的存在解释了这些 STANDARDDAYLIGHT 间隔何时再次出现意味着我们不必显式添加所有特定间隔这些年来。您只需要 RRULE 更改的最近(在您的事件之前)间隔。如果您的事件是重复发生的并且跨越规则更改,那么您必须包含更多的时间间隔和相应的规则以涵盖规则更改之前的事件以及规则更改之后的事件。

实际上,检查由 Apple 的 macOS 日历应用程序生成的 ICS,以了解 2021 年 8 月 19 日在时区 Europe/Berlin 的事件,包括以下内容 VTIMEZONE(为了便于阅读而缩进)...

BEGIN:VTIMEZONE
TZID:Europe/Berlin
    BEGIN:DAYLIGHT
        TZOFFSETFROM:+0100
        RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU
        DTSTART:19810329T020000
        TZNAME:GMT+2
        TZOFFSETTO:+0200
    END:DAYLIGHT
    
    BEGIN:STANDARD
        TZOFFSETFROM:+0200
        RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU
        DTSTART:19961027T030000
        TZNAME:GMT+1
        TZOFFSETTO:+0100
    END:STANDARD
END:VTIMEZONE

请注意,STANDARD 在 1996 年有一个 DTSTARTDAYLIGHT 在 1981 年有一个 DTSTART,尽管事件发生在 2021 年。 [=16 的存在=] 允许他们避免包含更多 STANDARD/DAYLIGHT 间隔。

最佳解决方案

...大概是为了生成RRULE。这使您可以最大限度地减少 ics 文件的大小,同时支持遥远未来的重复事件。

缺点:我找不到用 moment-timezone 生成 RRULE 的任何简单方法...但是似乎有一些其他的库可能会有所帮助(还没有玩过它们还没有)。

如果有人有一些 tips/experience 生成 RRULEs,很高兴听到你的经验。

选项 2:针对特定用例的解决方法

如果您要为已知事件日期(或重复事件的日期范围)的单个事件或重复事件动态生成 ICS 文件,那么您可以过滤 moment.tz.zone('America/New_York').untils 以确保您拥有您需要的所有 STANDARDDAYLIGHT 间隔来涵盖您的事件 date/range.

缺点:对于长时间 运行 或开放式重复事件,这可能不是一个好的选择,因为 ics 文件中必须包含太多间隔(膨胀)。

但是对于单一的、固定日期的事件,这可能是一个不错的选择。

选项 2 的快速示例...

我只对 RFC 进行了粗略的扫描,为了安全起见,我在结束日期之后包含了转换,因此即使您在单个时间戳中有一个事件,您也将始终至少有 2 个转换。一种转变发生在事件日期之前,另一种转变发生在事件日期之后。这可能不是必需的。

function generateVTimezone (timezoneName, tsRangeStart, tsRangeEnd) {
    var zone = moment.tz.zone(timezoneName),
        {untils, abbrs, offsets} = zone,
        i, dtStart, utcOffsetBefore, utcOffsetDuring, periodType,
        vtz = [
            `BEGIN:VTIMEZONE`,
            `TZID:${timezoneName}`,
        ];

    tsRangeStart = tsRangeStart || 0;
    tsRangeEnd = tsRangeEnd || Math.pow(2,31)-1;

    // https://momentjs.com/timezone/docs/#/data-formats/unpacked-format/
    // > between `untils[n-1]` and `untils[n]`, the `abbr` should be 
    // > `abbrs[n]` and the `offset` should be `offsets[n]`
    for (i=0; i<untils.length - 1; i++) {
        // filter to intervals that include our start/end range timestamps
        if (untils[i+1] < tsRangeStart) continue; // interval ends before our start, skip
        if (i>0 && untils[i-1] > tsRangeEnd) break; // interval starts after interval we end in, break

        utcOffsetBefore = formatUtcOffset(offsets[i]); // offset BEFORE dtStart
        dtStart = moment.tz(untils[i], timezoneName).format('YYYYMMDDTHHmmss');
        utcOffsetDuring = formatUtcOffset(offsets[i+1]); // offset AFTER dtStart
        periodType = offsets[i+1] < offsets[i] ? 'DAYLIGHT' : 'STANDARD'; // spring-forward, DAYLIGHT, fall-back: STANDARD.
        
        vtz.push(`BEGIN:${periodType}`);
        vtz.push(`DTSTART:${dtStart}`);      // local date-time when change
        vtz.push(`TZOFFSETFROM:${utcOffsetBefore}`); // utc offset BEFORE DTSTART
        vtz.push(`TZOFFSETTO:${utcOffsetDuring}`);   // utc offset AFTER DTSTART
        vtz.push(`TZNAME:${abbrs[i+1]}`);
        vtz.push(`END:${periodType}`);
    }
    vtz.push(`END:VTIMEZONE`);
    return vtz.join('\r\n');  // rfc5545 says CRLF
}

function formatUtcOffset(minutes) {
    var hours = Math.floor(Math.abs(minutes) / 60).toString(),
        mins = (Math.abs(minutes) % 60).toString(),
        sign = minutes > 0 ? '-' : '+', // sign inverted, see https://momentjs.com/timezone/docs/#/zone-object/offset/
        output = [sign];

    // zero-padding
    if (hours.length < 2) output.push('0');
    output.push(hours);
    if (mins.length < 2) output.push('0');
    output.push(mins);

    return output.join('');
}

function test() {
    var timezone = 'America/New_York',
        startTS = moment.tz('2013-11-18 11:55', timezone).unix()*1000,
        endTS = moment.tz('2013-11-18 11:55', timezone).unix()*1000;

    console.log(generateVTimezone(timezone, startTS, endTS));
}

test();

产生输出...

BEGIN:VTIMEZONE
TZID:America/New_York
BEGIN:STANDARD
DTSTART:20131103T010000
TZOFFSETFROM:-0400
TZOFFSETTO:-0500
TZNAME:EST
END:STANDARD
BEGIN:DAYLIGHT
DTSTART:20140309T030000
TZOFFSETFROM:-0500
TZOFFSETTO:-0400
TZNAME:EDT
END:DAYLIGHT
END:VTIMEZONE