Skip to content
Open
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
4 changes: 4 additions & 0 deletions docs/releases/unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,3 +35,7 @@ When a change has user-facing documentation, include a canonical tasknotes.dev l
## Fixed

- Fixed `PUT /api/tasks/:id` ignoring empty arrays for `contexts` and `blockedBy`: sending `{"contexts": []}` or `{"blockedBy": []}` now clears the corresponding frontmatter field instead of silently leaving the previous value in place. The deletion pass previously fired only on a literal `undefined`, which JSON cannot express, so HTTP clients had no way to clear these fields. Thanks to @tgrosinger for the contribution.
- (#2193) Google Calendar event descriptions written with formatting now read as
plain text in event details, copied text, and generated notes, instead of
showing raw HTML tags. Paragraph breaks, list structure, and link addresses are
kept. Thanks to @martin-forge for the contribution.
3 changes: 2 additions & 1 deletion src/services/GoogleCalendarService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { validateCalendarId, validateEventId, validateRequired } from "./validat
import { CalendarProvider, ProviderCalendar } from "./CalendarProvider";
import { createTaskNotesLogger } from "../utils/tasknotesLogger";
import { publishUserNotice } from "../core/userNotices";
import { normalizeCalendarDescription } from "../utils/calendarDescription";

const tasknotesLogger = createTaskNotesLogger({ tag: "Services/GoogleCalendarService" });

Expand Down Expand Up @@ -499,7 +500,7 @@ export class GoogleCalendarService extends CalendarProvider {
id: `google-${calendarId}-${googleEvent.id}`,
subscriptionId: `google-${calendarId}`,
title: googleEvent.summary || "Untitled Event",
description: googleEvent.description,
description: normalizeCalendarDescription(googleEvent.description),
start: start,
end: end,
allDay: allDay,
Expand Down
259 changes: 259 additions & 0 deletions src/utils/calendarDescription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,259 @@
import { sanitizeHTMLToDom } from "obsidian";

/**
* Plain-text normalization for calendar event descriptions.
*
* The Google Calendar API documents the event `description` field as one that
* "can contain HTML". Google Calendar and third-party integrations therefore
* store markup such as `<p>`, `<br>`, `<ul><li>`, and `<a href>` in it.
*
* TaskNotes consumes that string as plain text everywhere: event tooltips, the
* ICS event info modal, "copy as markdown", the `{{icsEventDescription}}`
* template variable, generated note bodies, and folder templates. Several of
* those write the value into the vault, so raw markup does not merely look
* wrong on screen — it is persisted into notes and folder names.
*
* Normalizing at the Google provider boundary keeps every one of those
* consumers plain-text, rather than making each handle HTML independently.
* ICS subscriptions read descriptions through their own boundary and are not
* normalized here.
*/

/**
* Elements that separate paragraphs, rendered with a blank line between them.
*/
const PARAGRAPH_TAGS = new Set([
"ADDRESS",
"ARTICLE",
"ASIDE",
"BLOCKQUOTE",
"DL",
"FIELDSET",
"FIGURE",
"FOOTER",
"FORM",
"H1",
"H2",
"H3",
"H4",
"H5",
"H6",
"HEADER",
"HR",
"MAIN",
"NAV",
"OL",
"P",
"PRE",
"SECTION",
"TABLE",
"UL",
]);

/**
* Elements that start a new line without a blank line. List items and the
* `<div>`-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 `<br>`. Requiring one of those
* forms preserves ordinary text such as `Contact <user@example.com>` and
* placeholders such as `<TBC>`.
*/
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 = /<!--[\s\S]*?-->/;

/**
* 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;
}
8 changes: 8 additions & 0 deletions tests/__mocks__/obsidian.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1181,6 +1187,7 @@ export const MockObsidian = {
Notice,
setIcon,
setTooltip,
sanitizeHTMLToDom,
};

// Simple debounce mock: return the original function for test determinism
Expand Down Expand Up @@ -1232,6 +1239,7 @@ export default {
Events,
setIcon,
setTooltip,
sanitizeHTMLToDom,
parseFrontMatterAliases,
parseFrontMatterTags,
parseLinktext,
Expand Down
40 changes: 39 additions & 1 deletion tests/services/GoogleCalendarService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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: '<p>Agenda</p><ul><li>First item</li></ul>',
start: { date: '2025-10-22' },
end: { date: '2025-10-23' }
},
{
id: 'plain-description',
summary: 'Plain description',
description: 'Contact <user@example.com>; venue <TBC>',
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 <user@example.com>; venue <TBC>');
});

test('should use sync token for incremental updates', async () => {
// Set up sync token
mockPlugin.settings!.googleCalendarSyncTokens = { 'primary': 'old-sync-token' };
Expand Down
Loading
Loading