Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
488 changes: 287 additions & 201 deletions README.md

Large diffs are not rendered by default.

5 changes: 3 additions & 2 deletions src/IEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,9 @@ export interface IEvent {
description: string; // Detailed description of the event
format: Calendar["format"]; // Format preference for the event
location: string; // Physical location where the event takes place, if applicable
callUrl: string; // URL for joining online meetings/calls associated with the event
callType: string; // Type of online meeting (e.g., Zoom, Skype, etc.)
callUrl: string; // URL for joining online meetings/calls associated with the event (backward compatibility)
callType: string; // Type of online meeting (e.g., Zoom, Skype, etc.) (backward compatibility)
extractedFields: Record<string, string[]>; // Generic field extraction results
organizer: IOrganizer; // Email of the organizer of the event
attendees: IAttendee[]; // Array of attendees
eventType: string; // Type of event (e.g., one-off, recurring, recurring override)
Expand Down
94 changes: 79 additions & 15 deletions src/icalUtils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,30 +3,94 @@ import { tz } from 'moment-timezone';
import { moment } from "obsidian";
import { WINDOWS_TO_IANA_TIMEZONES } from './generated/windowsTimezones';

export function extractMeetingInfo(e: any): { callUrl: string, callType: string } {
import { FieldExtractionPattern } from './settings/ICSSettings';

// Check for Google Meet conference data
if (e["GOOGLE-CONFERENCE"]) {
return { callUrl: e["GOOGLE-CONFERENCE"], callType: 'Google Meet' };
export function extractFields(e: any, patterns?: FieldExtractionPattern[]): Record<string, string[]> {
// If patterns not provided or empty, return empty object
if (!patterns || patterns.length === 0) {
return {};
}
// Check if the location contains a Zoom link
if (e.location && e.location.includes('zoom.us')) {
return { callUrl: e.location, callType: 'Zoom' };

const extractedFields: Record<string, string[]> = {};

// Sort patterns by priority (lower numbers = higher priority)
const sortedPatterns = patterns.sort((a, b) => a.priority - b.priority);

for (const pattern of sortedPatterns) {
const matches = findPatternMatches(e, pattern);
if (matches.length > 0) {
const fieldName = pattern.extractedFieldName;
if (!extractedFields[fieldName]) {
extractedFields[fieldName] = [];
}
extractedFields[fieldName].push(...matches);
}
}

// Deduplicate all extracted fields
for (const fieldName in extractedFields) {
extractedFields[fieldName] = [...new Set(extractedFields[fieldName])];
}

return extractedFields;
}

function findPatternMatches(e: any, pattern: FieldExtractionPattern): string[] {
const matches: string[] = [];

// Special handling for Google Meet conference data
if (pattern.pattern === "GOOGLE-CONFERENCE" && e["GOOGLE-CONFERENCE"]) {
matches.push(e["GOOGLE-CONFERENCE"]);
return matches;
}

// Check location field
if (e.location) {
const locationMatches = matchTextForPattern(e.location, pattern);
matches.push(...locationMatches);
}

// Check description field
if (e.description) {
const skypeMatch = e.description.match(/https:\/\/join.skype.com\/[a-zA-Z0-9]+/);
if (skypeMatch) {
return { callUrl: skypeMatch[0], callType: 'Skype' };
}
const descriptionMatches = matchTextForPattern(e.description, pattern);
matches.push(...descriptionMatches);
}

return matches;
}

function matchTextForPattern(text: string, pattern: FieldExtractionPattern): string[] {
const matches: string[] = [];

const teamsMatch = e.description.match(/(https:\/\/teams\.microsoft\.com\/l\/meetup-join\/[^>]+)/);
if (teamsMatch) {
return { callUrl: teamsMatch[0], callType: 'Microsoft Teams' };
try {
if (pattern.matchType === 'contains') {
if (text.includes(pattern.pattern)) {
// For contains match, try to extract URLs from the text
const urlMatches = text.match(/https?:\/\/[^\s<>"]+/g);
if (urlMatches) {
matches.push(...urlMatches);
} else {
// If no URLs found, return the original text
matches.push(text);
}
}
} else if (pattern.matchType === 'regex') {
const regex = new RegExp(pattern.pattern, 'g'); // Use global flag to find all matches
let match;
while ((match = regex.exec(text)) !== null) {
// If regex has capture groups, use the first group, otherwise use full match
matches.push(match[1] || match[0]);
}
}
} catch {
// Skip invalid regex patterns
console.warn(`Invalid regex pattern: ${pattern.pattern}`);
}
return { callUrl: null, callType: null };

return matches;
}


function applyRecurrenceDateAndTimezone(originalDate: Date, currentDate: Date, tzid: string): Date {
const originalMoment = tz(originalDate, tzid);

Expand Down
55 changes: 53 additions & 2 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Calendar,
ICSSettings,
DEFAULT_SETTINGS,
DEFAULT_FIELD_EXTRACTION_PATTERNS,
} from "./settings/ICSSettings";

import ICSSettingsTab from "./settings/ICSSettingsTab";
Expand All @@ -19,7 +20,7 @@ import {
Plugin,
request
} from 'obsidian';
import { parseIcs, filterMatchingEvents, extractMeetingInfo } from './icalUtils';
import { parseIcs, filterMatchingEvents, extractFields } from './icalUtils';
import { IEvent } from './IEvent';
import { DateNormalizer, FlexibleDateInput } from './DateNormalizer';

Expand Down Expand Up @@ -135,7 +136,17 @@ export default class ICSPlugin extends Plugin {

try {
dateEvents.forEach((e) => {
const { callUrl, callType } = extractMeetingInfo(e);
const patterns = this.data.fieldExtraction?.enabled ? this.data.fieldExtraction.patterns : [];
const extractedFields = extractFields(e, patterns);

// Backward compatibility: extract first Video Call URL and type
// Support both old singular and new plural field names
const videoCallUrls = extractedFields['Video Call URLs'] || extractedFields['Video Call URL'] || [];
const callUrl = videoCallUrls.length > 0 ? videoCallUrls[0] : null;

// For callType, we could derive it from the pattern name, but since we're going generic,
// let's just use "Video Call" as a generic type when we have a URL
const callType = callUrl ? "Video Call" : null;

const event: IEvent = {
utime: moment(e.start).format('X'),
Expand All @@ -152,6 +163,7 @@ export default class ICSPlugin extends Plugin {
location: e.location ? e.location : null,
callUrl: callUrl,
callType: callType,
extractedFields: extractedFields,
eventType: e.eventType,
organizer: { email: e.organizer?.val?.substring(7) || null, name: e.organizer?.params?.CN || null },
attendees: e.attendee ? (Array.isArray(e.attendee) ? e.attendee : [e.attendee]).map(attendee => ({
Expand Down Expand Up @@ -259,6 +271,45 @@ export default class ICSPlugin extends Plugin {

async loadSettings() {
this.data = Object.assign({}, DEFAULT_SETTINGS, await this.loadData());

// Migration: migrate from old videoCallExtraction to new fieldExtraction
let needsSave = false;

// If old videoCallExtraction exists, migrate it to fieldExtraction
if ((this.data as any).videoCallExtraction) {
const oldSettings = (this.data as any).videoCallExtraction;
this.data.fieldExtraction = {
enabled: oldSettings.enabled !== false,
patterns: DEFAULT_FIELD_EXTRACTION_PATTERNS
};
delete (this.data as any).videoCallExtraction;
needsSave = true;
}

// Ensure fieldExtraction settings exist and are hydrated
if (!this.data.fieldExtraction) {
this.data.fieldExtraction = {
enabled: true,
patterns: [...DEFAULT_FIELD_EXTRACTION_PATTERNS]
};
needsSave = true;
} else {
// Ensure patterns array exists
if (!this.data.fieldExtraction.patterns) {
this.data.fieldExtraction.patterns = [...DEFAULT_FIELD_EXTRACTION_PATTERNS];
needsSave = true;
}

// Ensure enabled field exists
if (this.data.fieldExtraction.enabled === undefined) {
this.data.fieldExtraction.enabled = true;
needsSave = true;
}
}

if (needsSave) {
await this.saveData(this.data);
}
}

async saveSettings() {
Expand Down
47 changes: 47 additions & 0 deletions src/settings/ICSSettings.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,21 @@
export interface FieldExtractionPattern {
name: string;
pattern: string;
matchType: 'regex' | 'contains';
priority: number;
extractedFieldName: string;
}

export interface ICSSettings {
format: {
timeFormat: string
dataViewSyntax: boolean,
},
calendars: Record < string, Calendar > ;
fieldExtraction: {
enabled: boolean;
patterns: FieldExtractionPattern[];
};
}

export interface Calendar {
Expand Down Expand Up @@ -43,11 +55,46 @@ export const DEFAULT_CALENDAR_FORMAT = {
showTransparentEvents: false
};

export const DEFAULT_FIELD_EXTRACTION_PATTERNS: FieldExtractionPattern[] = [
{
name: "Google Meet",
pattern: "GOOGLE-CONFERENCE",
matchType: "contains",
priority: 1,
extractedFieldName: "Video Call URLs"
},
{
name: "Zoom",
pattern: "zoom.us",
matchType: "contains",
priority: 2,
extractedFieldName: "Video Call URLs"
},
{
name: "Skype",
pattern: "https:\\/\\/join\\.skype\\.com\\/[a-zA-Z0-9]+",
matchType: "regex",
priority: 3,
extractedFieldName: "Video Call URLs"
},
{
name: "Microsoft Teams",
pattern: "https:\\/\\/teams\\.microsoft\\.com\\/l\\/meetup-join\\/[^>]+",
matchType: "regex",
priority: 4,
extractedFieldName: "Video Call URLs"
}
];

export const DEFAULT_SETTINGS: ICSSettings = {
format: {
timeFormat: "HH:mm",
dataViewSyntax: false,
},
calendars: {
},
fieldExtraction: {
enabled: true,
patterns: DEFAULT_FIELD_EXTRACTION_PATTERNS
}
};
Loading