Files
wehub-resource-sync bf9395e022
CI / license-header (push) Has been skipped
CI / e2e-dry-run (push) Has been skipped
CI / fast-gate (push) Failing after 0s
Test PR Label Logic / test-pr-labels (push) Failing after 1s
Skill Format Check / check-format (push) Failing after 2s
CI / security (push) Failing after 5s
CI / unit-test (push) Has been skipped
CI / lint (push) Has been skipped
CI / script-test (push) Has been skipped
CI / deterministic-gate (push) Has been skipped
CI / coverage (push) Has been skipped
CI / results (push) Has been cancelled
CI / deadcode (push) Has been cancelled
CI / e2e-live (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:22:54 +08:00

223 lines
7.0 KiB
Go

// Copyright (c) 2026 Lark Technologies Pte. Ltd.
// SPDX-License-Identifier: MIT
package ics
import (
"strings"
"time"
)
// mailtoScheme is the canonical case for the RFC 5545 ORGANIZER / ATTENDEE
// CAL-ADDRESS URI scheme. Emitted by the builder in upper-case to match
// Feishu client output; matched case-insensitively by the parser.
const mailtoScheme = "MAILTO:"
// ParsedEvent holds key fields extracted from an ICS VCALENDAR.
type ParsedEvent struct {
Method string // VCALENDAR-level METHOD (REQUEST/REPLY/CANCEL)
IsLarkDraft bool // true when VCALENDAR contains X-LARK-MAIL-DRAFT (Feishu private property indicating the event is editable)
UID string // VEVENT UID
Summary string // VEVENT SUMMARY, RFC 5545 TEXT unescaped
Location string // VEVENT LOCATION, RFC 5545 TEXT unescaped
Start time.Time // VEVENT DTSTART
End time.Time // VEVENT DTEND
Organizer string // ORGANIZER email (from MAILTO: URI or bare email)
Attendees []string // ATTENDEE emails (from MAILTO: URIs or bare emails)
OriginalTime int64 // RECURRENCE-ID as Unix seconds, 0 if not present. Used together with UID to derive the Feishu calendar event_id = UID + "_" + OriginalTime.
}
// ParseEvent extracts key fields from an ICS VCALENDAR string.
// Returns nil if no VEVENT is found.
func ParseEvent(icsText string) *ParsedEvent {
// Step 1: line unfolding (RFC 5545 §3.1)
unfolded := unfoldLines(icsText)
lines := strings.Split(unfolded, "\n")
var event ParsedEvent
inVEvent := false
foundVEvent := false
for _, line := range lines {
line = strings.TrimRight(line, "\r")
if line == "" {
continue
}
upper := strings.ToUpper(line)
// VCALENDAR-level properties
if !inVEvent && strings.HasPrefix(upper, "METHOD:") {
event.Method = strings.TrimSpace(line[len("METHOD:"):])
continue
}
if !inVEvent && strings.HasPrefix(upper, "X-LARK-MAIL-DRAFT:") {
event.IsLarkDraft = true
continue
}
if upper == "BEGIN:VEVENT" {
inVEvent = true
continue
}
if upper == "END:VEVENT" {
inVEvent = false
foundVEvent = true
continue
}
if !inVEvent {
continue
}
// VEVENT properties — RFC 5545 §3.1: property names are
// case-insensitive and may carry parameters (NAME;PARAM=v:value).
name, value := splitProperty(line)
propUpper := strings.ToUpper(name)
switch {
case propUpper == "UID" || strings.HasPrefix(propUpper, "UID;"):
event.UID = value
case propUpper == "SUMMARY" || strings.HasPrefix(propUpper, "SUMMARY;"):
event.Summary = unescapeTextValue(value)
case propUpper == "LOCATION" || strings.HasPrefix(propUpper, "LOCATION;"):
event.Location = unescapeTextValue(value)
case propUpper == "DTSTART" || strings.HasPrefix(propUpper, "DTSTART;"):
event.Start = parseICSTime(value, name)
case propUpper == "DTEND" || strings.HasPrefix(propUpper, "DTEND;"):
event.End = parseICSTime(value, name)
case propUpper == "RECURRENCE-ID" || strings.HasPrefix(propUpper, "RECURRENCE-ID;"):
if t := parseICSTime(value, name); !t.IsZero() {
event.OriginalTime = t.Unix()
}
case propUpper == "ORGANIZER" || strings.HasPrefix(propUpper, "ORGANIZER;"):
if email := extractMailto(value); email != "" {
event.Organizer = email
}
case propUpper == "ATTENDEE" || strings.HasPrefix(propUpper, "ATTENDEE;"):
if email := extractMailto(value); email != "" {
event.Attendees = append(event.Attendees, email)
}
}
}
if !foundVEvent {
return nil
}
return &event
}
// unfoldLines reverses RFC 5545 line folding: CRLF (or bare LF) followed by
// a single whitespace character is merged back into the preceding line.
// CRLF forms are handled first so that "\r\n " is consumed as a unit and does
// not leave a stray "\r" for the LF-only pass to mis-process.
func unfoldLines(s string) string {
s = strings.ReplaceAll(s, "\r\n ", "")
s = strings.ReplaceAll(s, "\r\n\t", "")
// LF-only folding — produced by some mail servers that strip \r.
s = strings.ReplaceAll(s, "\n ", "")
s = strings.ReplaceAll(s, "\n\t", "")
return s
}
// splitProperty splits "NAME;PARAMS:VALUE" into (name-with-params, value).
// It scans for the first colon that is not inside a double-quoted parameter
// value (e.g. CN="Doe: Jane"), per RFC 5545 §3.1.
func splitProperty(line string) (string, string) {
inQuote := false
for i := 0; i < len(line); i++ {
switch line[i] {
case '"':
inQuote = !inQuote
case ':':
if !inQuote {
return line[:i], line[i+1:]
}
}
}
return line, ""
}
// parseICSTime parses ICS datetime formats:
// - 20260420T060000Z (UTC)
// - TZID=Asia/Shanghai:20260420T140000 (with timezone in property params)
// - 20260420T140000 (local, treated as UTC)
func parseICSTime(value, propName string) time.Time {
value = strings.TrimSpace(value)
// Check for TZID in property params: DTSTART;TZID=Asia/Shanghai
// Case-insensitive search (RFC 5545 §3.2 param names are case-insensitive).
// Stop at the next ';' so trailing params like ;VALUE=DATE-TIME are excluded.
if idx := strings.Index(strings.ToUpper(propName), "TZID="); idx >= 0 {
tzPart := propName[idx+5:] // skip past "TZID="
if end := strings.IndexByte(tzPart, ';'); end >= 0 {
tzPart = tzPart[:end]
}
if loc, err := time.LoadLocation(tzPart); err == nil {
if t, err := time.ParseInLocation("20060102T150405", value, loc); err == nil {
return t
}
}
}
// UTC format: YYYYMMDDTHHMMSSZ
if t, err := time.Parse("20060102T150405Z", value); err == nil {
return t
}
// Date-only: YYYYMMDD (all-day events)
if t, err := time.Parse("20060102", value); err == nil {
return t
}
// Local time without timezone (treat as UTC)
if t, err := time.Parse("20060102T150405", value); err == nil {
return t
}
return time.Time{}
}
// unescapeTextValue reverses escapeTextValue per RFC 5545 §3.3.11, turning
// the ICS on-wire representation back into a plain Go string. Only applied
// to TEXT-typed properties (SUMMARY, LOCATION, DESCRIPTION, etc.) —
// identifiers, date-times, and URIs are parsed as-is.
func unescapeTextValue(s string) string {
if !strings.Contains(s, `\`) {
return s
}
var b strings.Builder
b.Grow(len(s))
for i := 0; i < len(s); i++ {
if s[i] == '\\' && i+1 < len(s) {
switch s[i+1] {
case 'n', 'N':
b.WriteByte('\n')
i++
continue
case '\\', ';', ',':
b.WriteByte(s[i+1])
i++
continue
}
}
b.WriteByte(s[i])
}
return b.String()
}
// extractMailto extracts the email address from an ICS ORGANIZER/ATTENDEE value.
// Accepts both "mailto:user@example.com" (RFC 5545 standard, case-insensitive per
// RFC 3986 §3.1) and a bare "user@example.com" value (observed in backend-regenerated
// ICS where the mailto: scheme prefix is dropped).
func extractMailto(value string) string {
value = strings.TrimSpace(value)
lower := strings.ToLower(value)
if idx := strings.Index(lower, strings.ToLower(mailtoScheme)); idx >= 0 {
return strings.TrimSpace(value[idx+len(mailtoScheme):])
}
if strings.Contains(value, "@") && !strings.ContainsAny(value, " \t") {
return value
}
return ""
}