feat: Implement the block palette (slash menu) and introduce new block types and related services.
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user