feat: rich command templates with public links + media text-first flow
- Command templates now match notification template style: type icons,
linked filenames via album shared links, location, favorite status
- Media mode sends text message first, then media as reply (was media-only)
- Search/find/person/place resolve asset public URLs from tracked albums'
shared links (share/{key}/photos/{id})
- Albums/summary commands include album public_url in context
- Enriched command template preview sample context with public_url, city,
country, is_favorite
- Extract sanitizePreview to shared lib/sanitize.ts
- Command template preview now renders HTML links (was raw text)
- Global provider filter moved above search in sidebar
- CLAUDE.md: template consistency + context variable sync rules
This commit is contained in:
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Sanitize HTML preview output — allows only safe Telegram-compatible tags.
|
||||
* Used by notification and command template preview renderers.
|
||||
*/
|
||||
|
||||
const ALLOWED_TAGS = new Set(['B', 'I', 'CODE', 'PRE', 'A', 'BR']);
|
||||
|
||||
function walkNodes(parent: Node, target: Node): void {
|
||||
for (const node of Array.from(parent.childNodes)) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
target.appendChild(document.createTextNode(node.textContent || ''));
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as Element;
|
||||
if (ALLOWED_TAGS.has(el.tagName)) {
|
||||
const safe = document.createElement(el.tagName);
|
||||
if (el.tagName === 'A') {
|
||||
const href = el.getAttribute('href') || '';
|
||||
if (/^https?:\/\//i.test(href)) {
|
||||
safe.setAttribute('href', href);
|
||||
safe.setAttribute('target', '_blank');
|
||||
safe.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
}
|
||||
walkNodes(el, safe);
|
||||
target.appendChild(safe);
|
||||
} else {
|
||||
walkNodes(el, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function sanitizePreview(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const fragment = document.createDocumentFragment();
|
||||
walkNodes(doc.body, fragment);
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.appendChild(fragment);
|
||||
return wrapper.innerHTML;
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { sanitizePreview } from '$lib/sanitize';
|
||||
import { commandTemplateConfigsCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
@@ -318,7 +319,7 @@
|
||||
{/if}
|
||||
{#if slotPreview[slot.name] && !slotErrors[slot.name]}
|
||||
<div class="mt-1 p-2 bg-[var(--color-muted)] rounded text-sm">
|
||||
<pre class="whitespace-pre-wrap text-xs">{slotPreview[slot.name]}</pre>
|
||||
<pre class="whitespace-pre-wrap text-xs">{@html sanitizePreview(slotPreview[slot.name])}</pre>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { slide } from 'svelte/transition';
|
||||
import { api } from '$lib/api';
|
||||
import { t } from '$lib/i18n';
|
||||
import { sanitizePreview } from '$lib/sanitize';
|
||||
import { templateConfigsCache, capabilitiesCache } from '$lib/stores/caches.svelte';
|
||||
import PageHeader from '$lib/components/PageHeader.svelte';
|
||||
import Card from '$lib/components/Card.svelte';
|
||||
@@ -213,44 +214,6 @@
|
||||
setTimeout(() => refreshAllPreviews(), 100);
|
||||
}
|
||||
|
||||
function sanitizePreview(html: string): string {
|
||||
// DOM-based sanitizer: parse HTML, walk tree, keep only safe elements
|
||||
const ALLOWED_TAGS = new Set(['B', 'I', 'CODE', 'PRE', 'A', 'BR']);
|
||||
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||
const fragment = document.createDocumentFragment();
|
||||
|
||||
function walkNodes(parent: Node, target: Node) {
|
||||
for (const node of Array.from(parent.childNodes)) {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
target.appendChild(document.createTextNode(node.textContent || ''));
|
||||
} else if (node.nodeType === Node.ELEMENT_NODE) {
|
||||
const el = node as Element;
|
||||
if (ALLOWED_TAGS.has(el.tagName)) {
|
||||
const safe = document.createElement(el.tagName);
|
||||
if (el.tagName === 'A') {
|
||||
const href = el.getAttribute('href') || '';
|
||||
if (/^https?:\/\//i.test(href)) {
|
||||
safe.setAttribute('href', href);
|
||||
safe.setAttribute('target', '_blank');
|
||||
safe.setAttribute('rel', 'noopener noreferrer');
|
||||
}
|
||||
}
|
||||
walkNodes(el, safe);
|
||||
target.appendChild(safe);
|
||||
} else {
|
||||
// Unwrap: keep text content of disallowed tags
|
||||
walkNodes(el, target);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
walkNodes(doc.body, fragment);
|
||||
const wrapper = document.createElement('div');
|
||||
wrapper.appendChild(fragment);
|
||||
return wrapper.innerHTML;
|
||||
}
|
||||
|
||||
function remove(id: number) {
|
||||
confirmDelete = {
|
||||
id,
|
||||
|
||||
Reference in New Issue
Block a user