feat: Implement the block palette (slash menu) and introduce new block types and related services.

This commit is contained in:
2025-11-19 08:45:15 -05:00
parent 332f586d7b
commit 98f8bd7aa1
16 changed files with 1452 additions and 168 deletions
+141
View File
@@ -708,6 +708,147 @@ app.get('/api/health', (req, res) => {
res.json({ status: 'ok', timestamp: new Date().toISOString() });
});
app.get('/api/url-preview', async (req, res) => {
try {
const raw = String(req.query.url || '').trim();
if (!raw) {
return res.status(400).json({ error: 'missing_url' });
}
let targetUrl;
try {
let normalized = raw;
// If the user did not specify a protocol, default to https:// (or http:// for localhost)
if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(normalized)) {
if (normalized.startsWith('localhost') || normalized.startsWith('127.0.0.1')) {
normalized = 'http://' + normalized;
} else {
normalized = 'https://' + normalized;
}
}
targetUrl = new URL(normalized);
if (targetUrl.protocol !== 'http:' && targetUrl.protocol !== 'https:') {
return res.status(400).json({ error: 'unsupported_protocol' });
}
} catch {
return res.status(400).json({ error: 'invalid_url' });
}
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 8000);
let html = '';
try {
const resp = await fetch(targetUrl.toString(), {
signal: controller.signal,
headers: {
'User-Agent': 'ObsiViewer/1.0 (+https://github.com/)',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8'
}
});
clearTimeout(timeout);
if (!resp.ok) {
return res.status(502).json({ error: 'upstream_error', status: resp.status });
}
html = await resp.text();
} catch (e) {
clearTimeout(timeout);
console.error('url-preview fetch error', e);
return res.status(500).json({ error: 'fetch_failed' });
}
const result = {
url: targetUrl.toString(),
title: null,
description: null,
siteName: null,
imageUrl: null,
faviconUrl: null,
};
const ogTitle = html.match(/<meta[^>]+property=["']og:title["'][^>]*content=["']([^"']+)["'][^>]*>/i);
const titleTag = html.match(/<title[^>]*>([^<]+)<\/title>/i);
if (ogTitle && ogTitle[1]) {
result.title = ogTitle[1].trim();
} else if (titleTag && titleTag[1]) {
result.title = titleTag[1].trim();
}
const ogDesc = html.match(/<meta[^>]+property=["']og:description["'][^>]*content=["']([^"']+)["'][^>]*>/i);
const metaDesc = html.match(/<meta[^>]+name=["']description["'][^>]*content=["']([^"']+)["'][^>]*>/i);
if (ogDesc && ogDesc[1]) {
result.description = ogDesc[1].trim();
} else if (metaDesc && metaDesc[1]) {
result.description = metaDesc[1].trim();
}
const ogSite = html.match(/<meta[^>]+property=["']og:site_name["'][^>]*content=["']([^"']+)["'][^>]*>/i);
if (ogSite && ogSite[1]) {
result.siteName = ogSite[1].trim();
}
const absolutizeImageUrl = (rawUrl) => {
if (!rawUrl) return null;
let img = String(rawUrl).trim();
if (!img) return null;
if (img.startsWith('//')) {
img = targetUrl.protocol + img;
} else if (img.startsWith('/')) {
img = targetUrl.origin + img;
} else if (!/^[a-zA-Z][a-zA-Z0-9+\-.]*:/.test(img)) {
// Relative URL like "images/cover.png"
img = targetUrl.origin.replace(/\/+$/, '') + '/' + img.replace(/^\/+/, '');
}
return img;
};
// Try multiple sources for preview image
const ogImage =
html.match(/<meta[^>]+property=["']og:image["'][^>]*content=["']([^"']+)["'][^>]*>/i) ||
html.match(/<meta[^>]+content=["']([^"']+)["'][^>]*property=["']og:image["'][^>]*>/i);
const ogImageSecure =
html.match(/<meta[^>]+property=["']og:image:secure_url["'][^>]*content=["']([^"']+)["'][^>]*>/i) ||
html.match(/<meta[^>]+content=["']([^"']+)["'][^>]*property=["']og:image:secure_url["'][^>]*>/i);
const twitterImage =
html.match(/<meta[^>]+name=["']twitter:image["'][^>]*content=["']([^"']+)["'][^>]*>/i) ||
html.match(/<meta[^>]+content=["']([^"']+)["'][^>]*name=["']twitter:image["'][^>]*>/i);
const linkImage = html.match(/<link[^>]+rel=["']image_src["'][^>]*href=["']([^"']+)["'][^>]*>/i);
const imageCandidate =
(ogImage && ogImage[1]) ||
(ogImageSecure && ogImageSecure[1]) ||
(twitterImage && twitterImage[1]) ||
(linkImage && linkImage[1]);
const resolvedImage = absolutizeImageUrl(imageCandidate);
if (resolvedImage) {
result.imageUrl = resolvedImage;
}
const faviconMatch = html.match(/<link[^>]+rel=["'](?:shortcut icon|icon)["'][^>]*href=["']([^"']+)["'][^>]*>/i);
if (faviconMatch && faviconMatch[1]) {
let fav = faviconMatch[1].trim();
if (fav.startsWith('//')) {
fav = targetUrl.protocol + fav;
} else if (fav.startsWith('/')) {
fav = targetUrl.origin + fav;
}
result.faviconUrl = fav;
}
// Fallback: if no explicit preview image, reuse favicon as tile image
if (!result.imageUrl && result.faviconUrl) {
result.imageUrl = result.faviconUrl;
}
return res.json(result);
} catch (error) {
console.error('url-preview internal error', error);
return res.status(500).json({ error: 'internal_error' });
}
});
// Gemini Integration endpoints
app.use('/api/integrations/gemini', geminiRoutes);
app.use('/api/integrations/unsplash', unsplashRoutes);