fix: support webcal:// URLs for Apple Calendar subscriptions (#1013)

Apple iCloud calendars use webcal:// and webcals:// URL protocols, which are
standard for iCalendar subscriptions. HTML5 <input type="url"> validation only
accepts http://, https://, and ftp://, preventing users from subscribing to
Apple calendars.

This fix implements automatic protocol normalization:
- webcal:// → http://
- webcals:// → https://

Changes:
- Updated createCardUrlInput to accept webcal protocols using type="text"
- Added normalizeCalendarUrl() utility function
- Updated integrationsTab to normalize URLs before saving
- Added comprehensive test coverage for normalization logic

Users can now paste Apple Calendar URLs directly without manual conversion.
This commit is contained in:
callumalpass 2025-11-02 15:38:44 +11:00
parent 832ab59d5b
commit 52c4dc3af0
4 changed files with 274 additions and 4 deletions

View file

@ -648,13 +648,50 @@ export function createCardNumberInput(
}
/**
* Creates a URL input with validation styling
* Normalizes calendar URLs by converting webcal:// and webcals:// protocols
* to their http:// and https:// equivalents.
*
* This allows users to paste Apple Calendar URLs (and other calendar URLs)
* that use the webcal:// protocol, which is the standard protocol for
* iCalendar subscriptions.
*
* @param url - The URL to normalize
* @returns The normalized URL with http:// or https:// protocol
*
* @example
* normalizeCalendarUrl("webcal://example.com/calendar.ics")
* // returns "http://example.com/calendar.ics"
*
* normalizeCalendarUrl("webcals://example.com/calendar.ics")
* // returns "https://example.com/calendar.ics"
*/
export function normalizeCalendarUrl(url: string): string {
if (!url) return url;
return url
.replace(/^webcal:\/\//i, 'http://')
.replace(/^webcals:\/\//i, 'https://');
}
/**
* Creates a URL input with validation styling.
*
* Accepts http://, https://, webcal://, and webcals:// protocols.
* The webcal protocols are commonly used for calendar subscriptions
* (especially Apple Calendar) and are automatically normalized to
* http/https when the URL is saved.
*/
export function createCardUrlInput(placeholder?: string, value?: string): HTMLInputElement {
const input = document.createElement("input");
input.type = "url";
// Use type="text" instead of type="url" to allow webcal:// and webcals:// protocols
// HTML5 type="url" validation only accepts http://, https://, and ftp://
input.type = "text";
input.addClass("tasknotes-settings__card-input");
// Add pattern validation to accept calendar URL protocols
input.pattern = "^(https?|webcals?)://.*";
input.title = "Enter an http://, https://, webcal://, or webcals:// URL";
if (placeholder) {
input.placeholder = placeholder;
}

View file

@ -24,6 +24,7 @@ import {
createCardNumberInput,
createInfoBadge,
showCardEmptyState,
normalizeCalendarUrl,
} from "../components/CardComponent";
// interface WebhookItem extends ListEditorItem, WebhookConfig {}
@ -662,7 +663,9 @@ function renderICSSubscriptionsList(
newSourceInput.addEventListener("blur", () => {
const value = (newSourceInput as HTMLInputElement).value.trim();
if (subscription.type === "remote") {
updateSubscription({ url: value });
// Normalize webcal:// and webcals:// URLs to http:// and https://
const normalizedUrl = normalizeCalendarUrl(value);
updateSubscription({ url: normalizedUrl });
} else {
updateSubscription({ filePath: value });
}
@ -702,7 +705,9 @@ function renderICSSubscriptionsList(
sourceInput.addEventListener("blur", () => {
const value = (sourceInput as HTMLInputElement).value.trim();
if (subscription.type === "remote") {
updateSubscription({ url: value });
// Normalize webcal:// and webcals:// URLs to http:// and https://
const normalizedUrl = normalizeCalendarUrl(value);
updateSubscription({ url: normalizedUrl });
} else {
updateSubscription({ filePath: value });
}

View file

@ -0,0 +1,77 @@
/**
* Test for normalizeCalendarUrl function
*
* Verifies that webcal:// and webcals:// URLs are correctly normalized
* to http:// and https:// for calendar subscriptions.
*/
import { normalizeCalendarUrl } from '../../../src/settings/components/CardComponent';
describe('normalizeCalendarUrl', () => {
it('should convert webcal:// to http://', () => {
const input = 'webcal://example.com/calendar.ics';
const output = normalizeCalendarUrl(input);
expect(output).toBe('http://example.com/calendar.ics');
});
it('should convert webcals:// to https://', () => {
const input = 'webcals://example.com/calendar.ics';
const output = normalizeCalendarUrl(input);
expect(output).toBe('https://example.com/calendar.ics');
});
it('should handle case-insensitive webcal:// protocol', () => {
const input = 'WEBCAL://example.com/calendar.ics';
const output = normalizeCalendarUrl(input);
expect(output).toBe('http://example.com/calendar.ics');
});
it('should handle case-insensitive webcals:// protocol', () => {
const input = 'WEBCALS://example.com/calendar.ics';
const output = normalizeCalendarUrl(input);
expect(output).toBe('https://example.com/calendar.ics');
});
it('should not modify http:// URLs', () => {
const input = 'http://example.com/calendar.ics';
const output = normalizeCalendarUrl(input);
expect(output).toBe('http://example.com/calendar.ics');
});
it('should not modify https:// URLs', () => {
const input = 'https://example.com/calendar.ics';
const output = normalizeCalendarUrl(input);
expect(output).toBe('https://example.com/calendar.ics');
});
it('should handle Apple iCloud calendar URLs', () => {
const input = 'webcal://p01-caldav.icloud.com/published/2/MTY3NDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI';
const output = normalizeCalendarUrl(input);
expect(output).toBe('http://p01-caldav.icloud.com/published/2/MTY3NDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI');
});
it('should handle secure Apple iCloud calendar URLs', () => {
const input = 'webcals://p01-caldav.icloud.com/published/2/MTY3NDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI';
const output = normalizeCalendarUrl(input);
expect(output).toBe('https://p01-caldav.icloud.com/published/2/MTY3NDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI');
});
it('should handle empty strings', () => {
const input = '';
const output = normalizeCalendarUrl(input);
expect(output).toBe('');
});
it('should handle URLs with query parameters', () => {
const input = 'webcal://example.com/calendar.ics?key=value&foo=bar';
const output = normalizeCalendarUrl(input);
expect(output).toBe('http://example.com/calendar.ics?key=value&foo=bar');
});
it('should only replace at the start of the URL', () => {
// Edge case: webcal:// should only be replaced at the start
const input = 'webcal://example.com/path/with/webcal://in/it';
const output = normalizeCalendarUrl(input);
expect(output).toBe('http://example.com/path/with/webcal://in/it');
});
});

View file

@ -0,0 +1,151 @@
/**
* Test for Issue #1013: Can't Subscribe to Apple Calendar
*
* This test demonstrates that the HTML5 URL input type validation
* rejects webcal:// and webcals:// URLs, preventing users from
* subscribing to Apple iCloud calendars.
*
* Issue: https://github.com/[owner]/[repo]/issues/1013
*/
describe('Issue #1013: Apple Calendar webcal:// URL Validation', () => {
// Note: These tests demonstrate browser behavior that is not fully implemented in JSDOM
// They are skipped in the test environment but document the real-world issue
it.skip('should demonstrate that HTML5 URL input rejects webcal:// protocol', () => {
// Create a URL input element (as used in CardComponent.ts)
const input = document.createElement('input');
input.type = 'url';
// Try to set a webcal:// URL (as provided by Apple Calendar)
const webcalUrl = 'webcal://p01-caldav.icloud.com/published/2/example';
input.value = webcalUrl;
// HTML5 validity check
const isValid = input.validity.valid;
console.log('Input type:', input.type);
console.log('URL value:', input.value);
console.log('Is valid:', isValid);
console.log('Validity state:', input.validity);
// This test DEMONSTRATES THE BUG: webcal:// URLs are rejected
expect(isValid).toBe(false);
expect(input.validity.typeMismatch).toBe(true);
});
it.skip('should demonstrate that HTML5 URL input rejects webcals:// protocol', () => {
const input = document.createElement('input');
input.type = 'url';
// Try to set a webcals:// URL (secure webcal)
const webcalsUrl = 'webcals://p01-caldav.icloud.com/published/2/example';
input.value = webcalsUrl;
const isValid = input.validity.valid;
console.log('Input type:', input.type);
console.log('URL value:', input.value);
console.log('Is valid:', isValid);
// This test DEMONSTRATES THE BUG: webcals:// URLs are also rejected
expect(isValid).toBe(false);
expect(input.validity.typeMismatch).toBe(true);
});
it('should show that HTML5 URL input accepts https:// URLs', () => {
const input = document.createElement('input');
input.type = 'url';
// Standard https:// URL
const httpsUrl = 'https://p01-caldav.icloud.com/published/2/example';
input.value = httpsUrl;
const isValid = input.validity.valid;
console.log('Input type:', input.type);
console.log('URL value:', input.value);
console.log('Is valid:', isValid);
// https:// URLs are accepted
expect(isValid).toBe(true);
});
it('should demonstrate that text input accepts any URL protocol', () => {
// This shows the fix: using type="text" instead of type="url"
const input = document.createElement('input');
input.type = 'text';
const webcalUrl = 'webcal://p01-caldav.icloud.com/published/2/example';
input.value = webcalUrl;
// Text inputs don't validate URL format
const isValid = input.validity.valid;
console.log('Input type:', input.type);
console.log('URL value:', input.value);
console.log('Is valid:', isValid);
// Text input accepts any value
expect(isValid).toBe(true);
});
it('should verify that webcal:// can be converted to https://', () => {
// Common workaround: webcal:// is just http:// for iCalendar feeds
// webcals:// is just https:// for iCalendar feeds
const webcalUrl = 'webcal://p01-caldav.icloud.com/published/2/example';
const webcalsUrl = 'webcals://p01-caldav.icloud.com/published/2/example';
// Simple protocol replacement
const httpUrl = webcalUrl.replace(/^webcal:\/\//, 'http://');
const httpsUrl = webcalsUrl.replace(/^webcals:\/\//, 'https://');
console.log('Original webcal:', webcalUrl);
console.log('Converted to http:', httpUrl);
console.log('Original webcals:', webcalsUrl);
console.log('Converted to https:', httpsUrl);
expect(httpUrl).toBe('http://p01-caldav.icloud.com/published/2/example');
expect(httpsUrl).toBe('https://p01-caldav.icloud.com/published/2/example');
// Verify these can be validated as URLs
const httpInput = document.createElement('input');
httpInput.type = 'url';
httpInput.value = httpUrl;
const httpsInput = document.createElement('input');
httpsInput.type = 'url';
httpsInput.value = httpsUrl;
expect(httpInput.validity.valid).toBe(true);
expect(httpsInput.validity.valid).toBe(true);
});
it.skip('should show realistic Apple iCloud calendar URL formats', () => {
// Document the actual URL formats users encounter
const examples = [
'webcal://p01-caldav.icloud.com/published/2/MTY3NDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI',
'webcals://p01-caldav.icloud.com/published/2/MTY3NDU2Nzg5MDEyMzQ1Njc4OTAxMjM0NTY3ODkwMTI',
'webcal://ical.mac.com/ical/US32Holidays.ics',
];
console.log('\nApple iCloud Calendar URL Examples:');
examples.forEach((url, i) => {
const input = document.createElement('input');
input.type = 'url';
input.value = url;
console.log(`${i + 1}. ${url}`);
console.log(` Valid with type="url": ${input.validity.valid}`);
});
// All should be invalid with type="url"
examples.forEach(url => {
const input = document.createElement('input');
input.type = 'url';
input.value = url;
expect(input.validity.valid).toBe(false);
});
});
});