`-per-line style emitted by Google's editors belong here — treating
+ * them as paragraphs would double-space every list.
+ */
+const LINE_TAGS = new Set([
+ "DD",
+ "DIV",
+ "DT",
+ "FIGCAPTION",
+ "LI",
+ "TBODY",
+ "TD",
+ "TFOOT",
+ "TH",
+ "THEAD",
+ "TR",
+]);
+
+/**
+ * Elements whose content is markup rather than prose.
+ */
+const NON_CONTENT_TAGS = new Set(["SCRIPT", "STYLE", "TEMPLATE", "HEAD"]);
+
+/**
+ * A closing tag is strong evidence that a string contains markup. Void tags
+ * cover common standalone elements such as `
`. Requiring one of those
+ * forms preserves ordinary text such as `Contact
` and
+ * placeholders such as ``.
+ */
+const PAIRED_HTML_TAG_PATTERN = /<([a-z][\w:-]*)(?:\s[^>]*)?>[\s\S]*?<\/\1\s*>/i;
+const VOID_HTML_TAG_PATTERN =
+ /<(?:area|base|br|col|embed|hr|img|input|link|meta|param|source|track|wbr)(?:\s[^>]*)?\/?\s*>/i;
+const HTML_COMMENT_PATTERN = //;
+
+/**
+ * Reports whether a description looks like HTML rather than plain text.
+ */
+export function looksLikeHtml(value: string): boolean {
+ return (
+ PAIRED_HTML_TAG_PATTERN.test(value) ||
+ VOID_HTML_TAG_PATTERN.test(value) ||
+ HTML_COMMENT_PATTERN.test(value)
+ );
+}
+
+/**
+ * A flattened fragment: literal text, or a line break whose `weight` is the
+ * number of newlines it requests. Adjacent breaks coalesce to their strongest
+ * weight, so nested block elements never stack up blank lines.
+ */
+type Fragment = string | { weight: number };
+
+function breakWeightOf(tag: string): number {
+ if (PARAGRAPH_TAGS.has(tag)) {
+ return 2;
+ }
+ if (LINE_TAGS.has(tag)) {
+ return 1;
+ }
+ return 0;
+}
+
+function flattenAnchor(element: Element, out: Fragment[]): void {
+ const labelFragments: Fragment[] = [];
+ element.childNodes.forEach((child) => flattenNode(child, labelFragments));
+ const label = tidy(joinFragments(labelFragments));
+ const href = (element.getAttribute("href") ?? "").trim();
+
+ if (!href || href === label) {
+ out.push(label);
+ return;
+ }
+
+ if (!label) {
+ out.push(href);
+ return;
+ }
+
+ // Keep the target reachable once the anchor markup is gone.
+ out.push(`${label} (${href})`);
+}
+
+function flattenNode(node: Node, out: Fragment[]): void {
+ if (node.nodeType === 3 /* TEXT_NODE */) {
+ out.push(node.textContent ?? "");
+ return;
+ }
+
+ if (node.nodeType !== 1 /* ELEMENT_NODE */) {
+ return;
+ }
+
+ const element = node as Element;
+ const tag = element.tagName.toUpperCase();
+
+ if (NON_CONTENT_TAGS.has(tag)) {
+ return;
+ }
+
+ if (tag === "BR") {
+ out.push({ weight: 1 });
+ return;
+ }
+
+ if (tag === "A") {
+ flattenAnchor(element, out);
+ return;
+ }
+
+ const weight = breakWeightOf(tag);
+ if (weight > 0) {
+ out.push({ weight });
+ }
+
+ if (tag === "LI") {
+ out.push("- ");
+ }
+
+ element.childNodes.forEach((child) => flattenNode(child, out));
+
+ if (weight > 0) {
+ out.push({ weight });
+ }
+}
+
+/**
+ * Joins fragments, coalescing runs of breaks into the strongest one and
+ * dropping whitespace that only exists to indent the source markup.
+ */
+function joinFragments(fragments: Fragment[]): string {
+ let result = "";
+ let pendingBreak = 0;
+
+ for (const fragment of fragments) {
+ if (typeof fragment !== "string") {
+ pendingBreak = Math.max(pendingBreak, fragment.weight);
+ continue;
+ }
+
+ if (fragment.length === 0) {
+ continue;
+ }
+
+ // Whitespace between block elements is markup indentation, not content.
+ if (pendingBreak > 0 && fragment.trim().length === 0) {
+ continue;
+ }
+
+ if (result.length > 0 && pendingBreak > 0) {
+ result += "\n".repeat(pendingBreak);
+ }
+ pendingBreak = 0;
+ result += fragment;
+ }
+
+ return result;
+}
+
+/**
+ * Collapses the flattened text into tidy plain text: normal spaces, no trailing
+ * whitespace, and at most one blank line between paragraphs.
+ */
+function tidy(text: string): string {
+ return text
+ .replace(/\u00a0/g, " ")
+ .replace(/\r\n?/g, "\n")
+ .split("\n")
+ .map((line) => line.replace(/[ \t]+/g, " ").trim())
+ .join("\n")
+ .replace(/\n{3,}/g, "\n\n")
+ .trim();
+}
+
+/**
+ * Converts an HTML event description to plain text, preserving paragraph
+ * breaks, list structure, and link targets.
+ *
+ * Obsidian sanitizes the markup into a detached fragment before it is read.
+ */
+export function htmlToPlainText(html: string): string {
+ const fragment = sanitizeHTMLToDom(html);
+ const fragments: Fragment[] = [];
+ fragment.childNodes.forEach((child) => flattenNode(child, fragments));
+ return tidy(joinFragments(fragments));
+}
+
+/**
+ * Normalizes a provider event description to plain text.
+ *
+ * Plain-text descriptions are returned unchanged. Descriptions containing HTML
+ * are flattened, with entities decoded as a side effect of DOM parsing. Returns
+ * `undefined` when there is no usable text left, so existing truthiness checks
+ * on the field continue to skip empty descriptions.
+ */
+export function normalizeCalendarDescription(value: string | undefined | null): string | undefined {
+ if (typeof value !== "string" || value.length === 0) {
+ return undefined;
+ }
+
+ if (!looksLikeHtml(value)) {
+ return value;
+ }
+
+ const plainText = htmlToPlainText(value);
+ return plainText.length > 0 ? plainText : undefined;
+}
diff --git a/tests/__mocks__/obsidian.ts b/tests/__mocks__/obsidian.ts
index 6198e9c4f..91635d785 100644
--- a/tests/__mocks__/obsidian.ts
+++ b/tests/__mocks__/obsidian.ts
@@ -1084,6 +1084,12 @@ export function setTooltip(element: HTMLElement, tooltip: string, options?: { pl
element.classList.add('has-tooltip');
}
+export function sanitizeHTMLToDom(html: string): DocumentFragment {
+ const template = document.createElement('template');
+ template.innerHTML = html;
+ return template.content;
+}
+
// API version check utilities (added in Obsidian 1.11.0)
export function requireApiVersion(version: string): boolean {
// Mock implementation - returns true for testing purposes
@@ -1181,6 +1187,7 @@ export const MockObsidian = {
Notice,
setIcon,
setTooltip,
+ sanitizeHTMLToDom,
};
// Simple debounce mock: return the original function for test determinism
@@ -1232,6 +1239,7 @@ export default {
Events,
setIcon,
setTooltip,
+ sanitizeHTMLToDom,
parseFrontMatterAliases,
parseFrontMatterTags,
parseLinktext,
diff --git a/tests/services/GoogleCalendarService.test.ts b/tests/services/GoogleCalendarService.test.ts
index 5443c0fb1..1f1b9e8d6 100644
--- a/tests/services/GoogleCalendarService.test.ts
+++ b/tests/services/GoogleCalendarService.test.ts
@@ -8,7 +8,12 @@ import { GoogleCalendarError, RateLimitError, EventNotFoundError } from '../../s
jest.mock('obsidian', () => ({
Notice: jest.fn(),
requestUrl: jest.fn(),
- Platform: { isDesktopApp: true }
+ Platform: { isDesktopApp: true },
+ sanitizeHTMLToDom: (html: string) => {
+ const template = document.createElement('template');
+ template.innerHTML = html;
+ return template.content;
+ }
}));
describe('GoogleCalendarService', () => {
@@ -157,6 +162,39 @@ describe('GoogleCalendarService', () => {
expect(events[1].allDay).toBe(true);
});
+ test('should normalize HTML descriptions without changing angle-bracketed text', async () => {
+ mockRequestUrl.mockResolvedValueOnce({
+ status: 200,
+ json: {
+ items: [
+ {
+ id: 'html-description',
+ summary: 'HTML description',
+ description: 'Agenda
',
+ start: { date: '2025-10-22' },
+ end: { date: '2025-10-23' }
+ },
+ {
+ id: 'plain-description',
+ summary: 'Plain description',
+ description: 'Contact ; venue ',
+ start: { date: '2025-10-23' },
+ end: { date: '2025-10-24' }
+ }
+ ],
+ nextSyncToken: 'sync-token-123'
+ },
+ text: '',
+ arrayBuffer: new ArrayBuffer(0),
+ headers: {}
+ });
+
+ const events = await service.getEvents('primary');
+
+ expect(events[0].description).toBe('Agenda\n\n- First item');
+ expect(events[1].description).toBe('Contact ; venue ');
+ });
+
test('should use sync token for incremental updates', async () => {
// Set up sync token
mockPlugin.settings!.googleCalendarSyncTokens = { 'primary': 'old-sync-token' };
diff --git a/tests/unit/utils/calendarDescription.test.ts b/tests/unit/utils/calendarDescription.test.ts
new file mode 100644
index 000000000..b62294200
--- /dev/null
+++ b/tests/unit/utils/calendarDescription.test.ts
@@ -0,0 +1,125 @@
+import {
+ htmlToPlainText,
+ looksLikeHtml,
+ normalizeCalendarDescription,
+} from "../../../src/utils/calendarDescription";
+
+describe("calendarDescription", () => {
+ describe("looksLikeHtml", () => {
+ it("detects markup", () => {
+ expect(looksLikeHtml("Hello
")).toBe(true);
+ expect(looksLikeHtml("Line
Break")).toBe(true);
+ expect(looksLikeHtml('Link')).toBe(true);
+ expect(looksLikeHtml("Text")).toBe(true);
+ });
+
+ it("does not treat comparison operators as markup", () => {
+ expect(looksLikeHtml("Bring < 10 items and > 2 bags")).toBe(false);
+ expect(looksLikeHtml("Budget: 5 < 10")).toBe(false);
+ });
+
+ it("does not treat angle-bracketed plain text as markup", () => {
+ expect(looksLikeHtml("Contact ")).toBe(false);
+ expect(looksLikeHtml("Venue: ")).toBe(false);
+ });
+
+ it("does not treat plain text as markup", () => {
+ expect(looksLikeHtml("Reference: ABC123\n\nGuests: 2")).toBe(false);
+ });
+ });
+
+ describe("normalizeCalendarDescription", () => {
+ it("returns plain-text descriptions unchanged", () => {
+ const plain =
+ "Appointment with the clinic\n\nContact: \nVenue: ";
+ expect(normalizeCalendarDescription(plain)).toBe(plain);
+ });
+
+ it("returns undefined for missing or empty values", () => {
+ expect(normalizeCalendarDescription(undefined)).toBeUndefined();
+ expect(normalizeCalendarDescription(null)).toBeUndefined();
+ expect(normalizeCalendarDescription("")).toBeUndefined();
+ });
+
+ it("returns undefined when markup carries no text", () => {
+ expect(normalizeCalendarDescription("
")).toBeUndefined();
+ });
+
+ it("flattens paragraph markup", () => {
+ const html =
+ "Reservation confirmed on 2024-05-01.
\n" +
+ "Party of 4.\nDuration: 90 minutes.
";
+
+ expect(normalizeCalendarDescription(html)).toBe(
+ "Reservation confirmed on 2024-05-01.\n\nParty of 4.\nDuration: 90 minutes."
+ );
+ });
+
+ it("flattens list markup, as written by the Google Calendar editor", () => {
+ const html =
+ "- Matinee at the Example Cinema, Screen 2
" +
+ "- Reference: ABC123
" +
+ "- Adult (2)
" +
+ "- Seats: A1,A2
";
+
+ expect(normalizeCalendarDescription(html)).toBe(
+ "- Matinee at the Example Cinema, Screen 2\n" +
+ "- Reference: ABC123\n" +
+ "- Adult (2)\n" +
+ "- Seats: A1,A2"
+ );
+ });
+
+ it("decodes entities so escaped punctuation is not shown literally", () => {
+ const html = "Meet at the Queen's Hall, tea & cake after.
";
+ expect(normalizeCalendarDescription(html)).toBe(
+ "Meet at the Queen's Hall, tea & cake after."
+ );
+ });
+ });
+
+ describe("htmlToPlainText", () => {
+ it("turns line breaks into newlines", () => {
+ expect(htmlToPlainText("First
Second
Third")).toBe("First\nSecond\nThird");
+ });
+
+ it("keeps link targets reachable", () => {
+ expect(htmlToPlainText('Booking')).toBe(
+ "Booking (https://example.com/booking)"
+ );
+ });
+
+ it("does not duplicate a link whose label is its target", () => {
+ expect(htmlToPlainText('https://example.com')).toBe(
+ "https://example.com"
+ );
+ });
+
+ it("preserves an Obsidian URI written by task export", () => {
+ const html =
+ "Project: " +
+ 'Note
';
+ expect(htmlToPlainText(html)).toBe(
+ "Project: Note (obsidian://open?vault=Example%20Vault&file=Note.md)"
+ );
+ });
+
+ it("drops script and style content", () => {
+ const html = "Visible
";
+ expect(htmlToPlainText(html)).toBe("Visible");
+ });
+
+ it("drops non-content nested inside links", () => {
+ const html = 'Visible';
+ expect(htmlToPlainText(html)).toBe("Visible (https://example.com)");
+ });
+
+ it("collapses runs of blank lines", () => {
+ expect(htmlToPlainText("One
Two
")).toBe("One\n\nTwo");
+ });
+
+ it("normalizes non-breaking spaces", () => {
+ expect(htmlToPlainText("Room 12
")).toBe("Room 12");
+ });
+ });
+});