add URL validation guardrail for incorrect calendar URLs

- detect incorrect Google Calendar URL formats (public link, embed, app link)
- show helpful error message with "How do I get the correct URL?" button
- add help modal with instructions for Google, Outlook, and iCloud calendars
- update README with detailed Google Calendar setup instructions
- fixes #57
This commit is contained in:
Michalis Efstratiadis 2026-02-02 14:43:49 +02:00
parent cffedd314f
commit 99d26a9508
No known key found for this signature in database
4 changed files with 309 additions and 5 deletions

View file

@ -45,10 +45,31 @@ MemoChron supports both remote calendar URLs and local ICS files.
1. Go to Settings > MemoChron
2. Click "Add Calendar"
3. Enter your public iCalendar (ICS) URL (e.g., `https://calendar.google.com/calendar/ical/...`)
3. Enter your public iCalendar (ICS) URL
4. Give your calendar a name and optional tags
5. Enable/disable calendars as needed
##### Getting the Correct URL from Google Calendar
**Important**: You need the iCal feed URL, not the public calendar link. The public link opens a webpage but doesn't provide the calendar data that MemoChron needs.
To get the correct URL from Google Calendar:
1. Open [Google Calendar](https://calendar.google.com) in your browser
2. Click the gear icon (⚙️) → **Settings**
3. In the left sidebar, click on the calendar you want to add
4. Scroll down to **Integrate calendar**
5. Copy the **Secret address in iCal format** (recommended) or **Public address in iCal format**
![Google Calendar iCal URL](screenshots/gcal-ical-url.png)
**Correct URL format**: `https://calendar.google.com/calendar/ical/.../basic.ics`
**Incorrect URL formats** (these won't work):
- `https://calendar.google.com/calendar/u/0?cid=...` (public link - opens a webpage)
- `https://calendar.google.com/calendar/embed?src=...` (embed link - for websites)
- `https://calendar.google.com/calendar/r/...` (calendar app link)
#### Local ICS Files
You can also use ICS files stored in your Obsidian vault or local file system:

Binary file not shown.

After

Width:  |  Height:  |  Size: 78 KiB

View file

@ -296,11 +296,27 @@ export class SettingsTab extends PluginSettingTab {
if (!validation.valid) {
errorEl = urlSetting.descEl.createDiv({
cls: "memochron-error-message",
text: validation.error,
});
errorEl.style.color = "var(--text-error, #c92424)";
errorEl.style.fontSize = "0.9em";
errorEl.style.marginTop = "0.5em";
errorEl.createSpan({ text: validation.error });
// If it's a wrong URL type, show a help button
if (validation.isWrongUrlType) {
errorEl.createEl("br");
const helpBtn = errorEl.createEl("button", {
text: "How do I get the correct URL?",
cls: "memochron-help-btn",
});
helpBtn.style.marginTop = "0.5em";
helpBtn.style.fontSize = "0.85em";
this.plugin.registerDomEvent(helpBtn, "click", (e) => {
e.preventDefault();
new CalendarUrlHelpModal(this.app, this.plugin).open();
});
}
} else {
// Save valid URL
this.plugin.settings.calendarUrls[index].url = value;
@ -1206,17 +1222,75 @@ export class SettingsTab extends PluginSettingTab {
.filter((tag) => tag.length > 0);
}
private validateCalendarUrl(url: string): { valid: boolean; error?: string } {
private validateCalendarUrl(url: string): { valid: boolean; error?: string; warning?: string; isWrongUrlType?: boolean } {
if (!url || url.trim() === "") {
return { valid: false, error: "URL or file path is required" };
}
const trimmedUrl = url.trim();
const trimmedUrl = url.trim().toLowerCase();
// Check for HTTP/HTTPS URLs
if (trimmedUrl.startsWith("http://") || trimmedUrl.startsWith("https://")) {
try {
new URL(trimmedUrl);
const parsedUrl = new URL(url.trim());
// Detect incorrect Google Calendar URL formats
if (parsedUrl.hostname.includes("calendar.google.com")) {
// Check for common wrong URL patterns
// Public link format: calendar.google.com/calendar/u/0?cid=...
if (parsedUrl.searchParams.has("cid") ||
parsedUrl.pathname.match(/\/calendar\/u\/\d+$/)) {
return {
valid: false,
error: "This is a Google Calendar public link, not an iCal feed URL.",
isWrongUrlType: true
};
}
// Embed format: calendar.google.com/calendar/embed?src=...
if (parsedUrl.pathname.includes("/embed")) {
return {
valid: false,
error: "This is a Google Calendar embed link, not an iCal feed URL.",
isWrongUrlType: true
};
}
// Calendar app link: calendar.google.com/calendar/r/...
if (parsedUrl.pathname.includes("/calendar/r")) {
return {
valid: false,
error: "This is a Google Calendar app link, not an iCal feed URL.",
isWrongUrlType: true
};
}
// Valid Google Calendar ICS URL should contain /ical/ and end with .ics
if (!parsedUrl.pathname.includes("/ical/") || !parsedUrl.pathname.endsWith(".ics")) {
return {
valid: false,
error: "This doesn't appear to be a valid Google Calendar iCal URL.",
isWrongUrlType: true
};
}
}
// Detect incorrect Outlook/Office365 URL formats
if (parsedUrl.hostname.includes("outlook.live.com") ||
parsedUrl.hostname.includes("outlook.office365.com") ||
parsedUrl.hostname.includes("outlook.office.com")) {
// Check if it's a calendar sharing link, not an ICS feed
if (parsedUrl.pathname.includes("/calendar/published/") &&
!parsedUrl.pathname.endsWith(".ics")) {
return {
valid: false,
error: "This appears to be an Outlook sharing link. Please use the ICS subscription URL instead.",
isWrongUrlType: true
};
}
}
return { valid: true };
} catch {
return { valid: false, error: "Invalid URL format" };
@ -1713,3 +1787,92 @@ class CalendarNotesSettingsModal extends Modal {
contentEl.empty();
}
}
// Help modal for calendar URL setup
class CalendarUrlHelpModal extends Modal {
constructor(app: App, private plugin: MemoChron) {
super(app);
}
onOpen() {
const { contentEl } = this;
contentEl.empty();
contentEl.addClass("memochron-help-modal");
contentEl.createEl("h2", { text: "How to Get the Correct Calendar URL" });
// Google Calendar section
const gcalSection = contentEl.createDiv({ cls: "memochron-help-section" });
gcalSection.createEl("h3", { text: "Google Calendar" });
const gcalSteps = gcalSection.createEl("ol");
gcalSteps.createEl("li", { text: "Open Google Calendar in your browser" });
gcalSteps.createEl("li", { text: "Click the gear icon (⚙️) → Settings" });
gcalSteps.createEl("li", { text: "In the left sidebar, click on the calendar you want to add" });
gcalSteps.createEl("li", { text: "Scroll down to \"Integrate calendar\"" });
gcalSteps.createEl("li").innerHTML = "Copy the <strong>Secret address in iCal format</strong>";
const gcalNote = gcalSection.createDiv({ cls: "memochron-help-note" });
gcalNote.createEl("strong", { text: "Correct URL looks like: " });
gcalNote.createEl("code", { text: "https://calendar.google.com/calendar/ical/.../basic.ics" });
gcalSection.createEl("hr");
// Outlook section
const outlookSection = contentEl.createDiv({ cls: "memochron-help-section" });
outlookSection.createEl("h3", { text: "Outlook / Microsoft 365" });
const outlookSteps = outlookSection.createEl("ol");
outlookSteps.createEl("li", { text: "Open Outlook calendar on the web (outlook.office.com)" });
outlookSteps.createEl("li", { text: "Click the gear icon → View all Outlook settings" });
outlookSteps.createEl("li", { text: "Go to Calendar → Shared calendars" });
outlookSteps.createEl("li", { text: "Under \"Publish a calendar\", select your calendar and permissions" });
outlookSteps.createEl("li").innerHTML = "Copy the <strong>ICS link</strong> (not the HTML link)";
outlookSection.createEl("hr");
// iCloud section
const icloudSection = contentEl.createDiv({ cls: "memochron-help-section" });
icloudSection.createEl("h3", { text: "Apple iCloud Calendar" });
const icloudSteps = icloudSection.createEl("ol");
icloudSteps.createEl("li", { text: "Open the Calendar app on your Mac" });
icloudSteps.createEl("li", { text: "Right-click on the calendar → Share Calendar" });
icloudSteps.createEl("li", { text: "Check \"Public Calendar\" to make it shareable" });
icloudSteps.createEl("li", { text: "Click \"Copy Link\" to get the subscription URL" });
contentEl.createEl("hr");
// Common mistakes
const mistakesSection = contentEl.createDiv({ cls: "memochron-help-section" });
mistakesSection.createEl("h3", { text: "Common Mistakes" });
const mistakesList = mistakesSection.createEl("ul");
mistakesList.createEl("li").innerHTML = "<strong>Using the public link</strong> - This opens a webpage, not calendar data";
mistakesList.createEl("li").innerHTML = "<strong>Using the embed link</strong> - This is for embedding in websites";
mistakesList.createEl("li").innerHTML = "<strong>Missing the .ics extension</strong> - The URL should end with .ics";
// Documentation link
const docLink = contentEl.createDiv({ cls: "memochron-help-doc-link" });
docLink.style.marginTop = "1em";
const link = docLink.createEl("a", {
text: "View full documentation on GitHub",
href: "https://github.com/formax68/memoChron#remote-calendars",
});
link.setAttr("target", "_blank");
// Close button
const buttonContainer = contentEl.createDiv({ cls: "memochron-help-buttons" });
buttonContainer.style.marginTop = "1.5em";
buttonContainer.style.textAlign = "right";
new ButtonComponent(buttonContainer)
.setButtonText("Close")
.onClick(() => this.close());
}
onClose() {
const { contentEl } = this;
contentEl.empty();
}
}

View file

@ -1135,4 +1135,124 @@
.memochron-controls.calendar-hidden .memochron-nav {
justify-content: flex-start;
/* Align left when alone */
}
/* Inline color picker styles */
.memochron-inline-color-picker {
display: flex;
gap: var(--size-4-1);
align-items: center;
flex-wrap: wrap;
}
.memochron-inline-color-swatch {
width: 24px;
height: 24px;
border-radius: var(--radius-s);
cursor: pointer;
border: 2px solid var(--background-modifier-border);
transition: transform 0.15s ease, border-color 0.15s ease;
}
.memochron-inline-color-swatch:hover {
transform: scale(1.1);
border-color: var(--interactive-accent);
}
.memochron-inline-color-swatch.selected {
border-color: var(--text-normal);
box-shadow: 0 0 0 2px var(--background-primary), 0 0 0 4px var(--text-normal);
}
.memochron-inline-color-custom-label {
cursor: pointer;
}
.memochron-inline-color-custom-label.selected svg circle {
stroke: var(--text-normal);
stroke-width: 3;
}
/* Help button styling */
.memochron-help-btn {
background: var(--interactive-accent);
color: var(--text-on-accent);
border: none;
border-radius: var(--radius-s);
padding: var(--size-4-1) var(--size-4-2);
cursor: pointer;
transition: background-color 0.15s ease;
}
.memochron-help-btn:hover {
background: var(--interactive-accent-hover);
}
/* Help modal styling */
.memochron-help-modal {
max-width: 600px;
}
.memochron-help-modal h2 {
margin-bottom: var(--size-4-4);
border-bottom: 1px solid var(--background-modifier-border);
padding-bottom: var(--size-4-2);
}
.memochron-help-modal h3 {
margin-top: var(--size-4-4);
margin-bottom: var(--size-4-2);
color: var(--text-normal);
font-size: 1.1em;
}
.memochron-help-section {
margin-bottom: var(--size-4-2);
}
.memochron-help-section ol,
.memochron-help-section ul {
margin-left: var(--size-4-4);
margin-bottom: var(--size-4-2);
}
.memochron-help-section li {
margin-bottom: var(--size-4-1);
line-height: 1.5;
}
.memochron-help-note {
background: var(--background-secondary);
padding: var(--size-4-2) var(--size-4-3);
border-radius: var(--radius-s);
margin-top: var(--size-4-2);
}
.memochron-help-note code {
background: var(--background-modifier-hover);
padding: var(--size-4-1) var(--size-4-2);
border-radius: var(--radius-s);
font-size: 0.85em;
display: inline-block;
margin-top: var(--size-4-1);
word-break: break-all;
}
.memochron-help-doc-link {
text-align: center;
}
.memochron-help-doc-link a {
color: var(--interactive-accent);
}
.memochron-help-doc-link a:hover {
text-decoration: underline;
}
/* Error message styling */
.memochron-error-message {
color: var(--text-error);
font-size: 0.9em;
margin-top: var(--size-4-2);
}