mirror of
https://github.com/callumalpass/tasknotes.git
synced 2026-07-22 12:50:26 +00:00
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:
parent
832ab59d5b
commit
52c4dc3af0
4 changed files with 274 additions and 4 deletions
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
77
tests/unit/components/CardComponent.normalizeUrl.test.ts
Normal file
77
tests/unit/components/CardComponent.normalizeUrl.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
151
tests/unit/issues/issue-1013-webcal-url-validation.test.ts
Normal file
151
tests/unit/issues/issue-1013-webcal-url-validation.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in a new issue