From 509afa4c85df757fef73fb9f944336af57f95e7b Mon Sep 17 00:00:00 2001 From: hugo Date: Sun, 22 Feb 2026 17:49:11 +0100 Subject: [PATCH] feat: bookmarklet to blog stuff easily --- src/main/engine/MetaEngine.ts | 6 + src/main/ipc/handlers.ts | 12 +- src/main/main.ts | 113 +++++++++++++ src/main/preload.ts | 4 +- src/main/shared/blogmark.ts | 125 ++++++++++++++ src/main/shared/electronApi.ts | 5 +- src/renderer/App.tsx | 29 +++- .../components/SettingsView/SettingsView.tsx | 65 +++++++- src/renderer/i18n/locales/de.json | 8 + src/renderer/i18n/locales/en.json | 8 + src/renderer/i18n/locales/es.json | 8 + src/renderer/i18n/locales/fr.json | 8 + src/renderer/i18n/locales/it.json | 8 + tests/engine/BlogmarkDeepLink.test.ts | 41 +++++ tests/engine/MetaEngine.test.ts | 24 +++ tests/engine/mainStartup.test.ts | 152 ++++++++++++++++++ tests/setup.ts | 2 + 17 files changed, 613 insertions(+), 5 deletions(-) create mode 100644 src/main/shared/blogmark.ts create mode 100644 tests/engine/BlogmarkDeepLink.test.ts diff --git a/src/main/engine/MetaEngine.ts b/src/main/engine/MetaEngine.ts index 332f79b..0ed48bd 100644 --- a/src/main/engine/MetaEngine.ts +++ b/src/main/engine/MetaEngine.ts @@ -23,6 +23,7 @@ export interface ProjectMetadata { mainLanguage?: string; // Main language for AI-generated content (ISO code, e.g., 'en', 'de', 'es') defaultAuthor?: string; // Default author for new posts and media maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50) + blogmarkCategory?: string; // Category used for externally captured bookmark posts picoTheme?: PicoThemeName; // Selected Pico CSS theme for preview/rendering categoryMetadata?: Record; // Per-category metadata for UI/rendering categorySettings?: Record; // Per-category list rendering preferences @@ -81,12 +82,16 @@ type RawCategoryMetadataInput = Record { + return generateBlogmarkBookmarkletSource(); + }); + + safeHandle('app:copyToClipboard', async (_, text: string) => { + clipboard.writeText(String(text ?? '')); + return true; + }); + safeHandle('app:getTitleBarMetrics', async (event) => { const ownerWindow = BrowserWindow.fromWebContents(event.sender); const buttonPosition = ownerWindow?.getWindowButtonPosition?.(); diff --git a/src/main/main.ts b/src/main/main.ts index 838f83e..acea793 100644 --- a/src/main/main.ts +++ b/src/main/main.ts @@ -7,15 +7,22 @@ import { media } from './database/schema'; import { eq } from 'drizzle-orm'; import { getMediaEngine } from './engine/MediaEngine'; import { getPostEngine } from './engine/PostEngine'; +import { getMetaEngine } from './engine/MetaEngine'; import { PreviewServer } from './engine/PreviewServer'; import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_GROUPS, APP_MENU_ITEM_IDS, type AppMenuAction, type AppMenuItemDefinition } from './shared/menuCommands'; import { resolveUiLanguageFromSystemLocale, translateMenu } from './shared/i18n'; +import { buildBlogmarkMarkdownLink, extractBlogmarkPayloadFromDeepLink, normalizeBlogmarkCategory } from './shared/blogmark'; let mainWindow: BrowserWindow | null = null; let previewServer: PreviewServer | null = null; let activePreviewPostId: string | null = null; +let appInitialized = false; +let blogmarkQueue: string[] = []; +let blogmarkQueueProcessing = false; const PREVIEW_SERVER_PORT = 4123; const BLOG_PREVIEW_POST_MENU_ID = APP_MENU_ITEM_IDS.previewPost; +const BLOGMARK_PROTOCOL = 'bds'; +const BLOGMARK_NEW_POST_PREFIX = `${BLOGMARK_PROTOCOL}://new-post`; const WINDOW_MIN_WIDTH = 800; const WINDOW_MIN_HEIGHT = 600; const WINDOW_DEFAULT_WIDTH = 1400; @@ -330,6 +337,80 @@ async function startPreviewServerOnAppStart(): Promise { await previewServer.start(PREVIEW_SERVER_PORT); } +function extractBlogmarkDeepLinks(argv: string[]): string[] { + return argv.filter((argument) => typeof argument === 'string' && argument.startsWith(BLOGMARK_NEW_POST_PREFIX)); +} + +function enqueueBlogmarkDeepLink(rawDeepLink: string): void { + if (rawDeepLink.startsWith(BLOGMARK_NEW_POST_PREFIX)) { + blogmarkQueue.push(rawDeepLink); + } +} + +function focusMainWindow(): void { + if (!mainWindow || mainWindow.isDestroyed()) { + return; + } + + if (typeof mainWindow.isMinimized === 'function' && mainWindow.isMinimized()) { + mainWindow.restore(); + } + + if (typeof mainWindow.focus === 'function') { + mainWindow.focus(); + } +} + +async function processBlogmarkDeepLink(rawDeepLink: string): Promise { + const payload = extractBlogmarkPayloadFromDeepLink(rawDeepLink); + if (!payload) { + return; + } + + const metadata = await getMetaEngine().getProjectMetadata(); + const preferredCategory = normalizeBlogmarkCategory((metadata as { blogmarkCategory?: unknown } | null)?.blogmarkCategory); + + const createdPost = await getPostEngine().createPost({ + title: payload.title, + content: buildBlogmarkMarkdownLink(payload.title, payload.url), + categories: preferredCategory ? [preferredCategory] : [], + }); + + if (mainWindow && !mainWindow.isDestroyed()) { + mainWindow.webContents.send('blogmark:created', createdPost); + } +} + +async function processBlogmarkQueue(): Promise { + if (!appInitialized || blogmarkQueueProcessing || blogmarkQueue.length === 0) { + return; + } + + blogmarkQueueProcessing = true; + try { + while (blogmarkQueue.length > 0) { + const rawDeepLink = blogmarkQueue.shift(); + if (!rawDeepLink) { + continue; + } + + try { + await processBlogmarkDeepLink(rawDeepLink); + } catch (error) { + console.error('Failed to process blogmark deep link:', error); + } + } + } finally { + blogmarkQueueProcessing = false; + } +} + +function registerBlogmarkProtocolClient(): void { + if (typeof app.setAsDefaultProtocolClient === 'function') { + app.setAsDefaultProtocolClient(BLOGMARK_PROTOCOL); + } +} + function createApplicationMenu(): Menu { const systemLocale = typeof app.getLocale === 'function' ? app.getLocale() : 'en'; const uiLanguage = resolveUiLanguageFromSystemLocale(systemLocale); @@ -633,9 +714,35 @@ async function initialize(): Promise { registerChatHandlers(); } +const hasSingleInstanceLock = typeof app.requestSingleInstanceLock !== 'function' + ? true + : app.requestSingleInstanceLock(); + +if (!hasSingleInstanceLock) { + app.quit(); +} + +app.on('second-instance', (_event, argv) => { + focusMainWindow(); + const deepLinks = extractBlogmarkDeepLinks(argv); + for (const deepLink of deepLinks) { + enqueueBlogmarkDeepLink(deepLink); + } + void processBlogmarkQueue(); +}); + +app.on('open-url', (event, deepLink) => { + event.preventDefault(); + enqueueBlogmarkDeepLink(deepLink); + focusMainWindow(); + void processBlogmarkQueue(); +}); + // App lifecycle app.whenReady().then(async () => { await initialize(); + appInitialized = true; + registerBlogmarkProtocolClient(); try { await startPreviewServerOnAppStart(); } catch (error) { @@ -643,6 +750,12 @@ app.whenReady().then(async () => { } createWindow(); + const startupDeepLinks = extractBlogmarkDeepLinks(process.argv); + for (const deepLink of startupDeepLinks) { + enqueueBlogmarkDeepLink(deepLink); + } + await processBlogmarkQueue(); + app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) { createWindow(); diff --git a/src/main/preload.ts b/src/main/preload.ts index 1a4307f..a25652f 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -145,6 +145,8 @@ export const electronAPI: ElectronAPI = { selectFolder: (title?: string) => ipcRenderer.invoke('app:selectFolder', title), getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId), readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath), + getBlogmarkBookmarklet: () => ipcRenderer.invoke('app:getBlogmarkBookmarklet'), + copyToClipboard: (text: string) => ipcRenderer.invoke('app:copyToClipboard', text), setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId), triggerMenuAction: (action: string) => ipcRenderer.invoke('app:triggerMenuAction', action), }, @@ -160,7 +162,7 @@ export const electronAPI: ElectronAPI = { syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'), getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'), setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata), - updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./shared/picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates), + updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; picoTheme?: import('./shared/picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates), }, // Tag Management (advanced tag operations) diff --git a/src/main/shared/blogmark.ts b/src/main/shared/blogmark.ts new file mode 100644 index 0000000..c040b14 --- /dev/null +++ b/src/main/shared/blogmark.ts @@ -0,0 +1,125 @@ +import { z } from 'zod'; +import { normalizeNonEmptyTaxonomyTerm } from '../engine/taxonomyUtils'; + +const MAX_TITLE_LENGTH = 200; +const MAX_URL_LENGTH = 2048; +const CONTROL_CHARACTERS_REGEX = /[\u0000-\u001F\u007F]/g; + +const blogmarkPayloadSchema = z.object({ + title: z.string().max(MAX_TITLE_LENGTH), + url: z.string().max(MAX_URL_LENGTH), +}); + +export interface BlogmarkPayload { + title: string; + url: string; +} + +function stripControlCharacters(value: string): string { + return value.replace(CONTROL_CHARACTERS_REGEX, ''); +} + +function sanitizeTitle(rawTitle: unknown): string { + if (typeof rawTitle !== 'string') { + return ''; + } + + const trimmed = stripControlCharacters(rawTitle).trim(); + if (!trimmed) { + return ''; + } + + return trimmed.slice(0, MAX_TITLE_LENGTH); +} + +function sanitizeHttpUrl(rawUrl: unknown): string | null { + if (typeof rawUrl !== 'string') { + return null; + } + + const trimmed = stripControlCharacters(rawUrl).trim(); + if (!trimmed) { + return null; + } + + let parsed: URL; + try { + parsed = new URL(trimmed); + } catch { + return null; + } + + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { + return null; + } + + parsed.username = ''; + parsed.password = ''; + parsed.hash = ''; + + const normalized = parsed.toString(); + if (normalized.length > MAX_URL_LENGTH) { + return null; + } + + return normalized; +} + +function escapeMarkdownLinkText(value: string): string { + return value + .replace(/\\/g, '\\\\') + .replace(/\[/g, '\\[') + .replace(/\]/g, '\\]') + .replace(/\(/g, '\\(') + .replace(/\)/g, '\\)') + .replace(/\r?\n/g, ' '); +} + +function getDeepLinkField(url: URL, fieldName: string): string | null { + const value = url.searchParams.get(fieldName); + return typeof value === 'string' ? value : null; +} + +export function normalizeBlogmarkCategory(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + return normalizeNonEmptyTaxonomyTerm(value) ?? undefined; +} + +export function extractBlogmarkPayloadFromDeepLink(rawDeepLink: string): BlogmarkPayload | null { + let parsedDeepLink: URL; + try { + parsedDeepLink = new URL(rawDeepLink); + } catch { + return null; + } + + if (parsedDeepLink.protocol !== 'bds:' || parsedDeepLink.hostname !== 'new-post') { + return null; + } + + const sanitizedUrl = sanitizeHttpUrl(getDeepLinkField(parsedDeepLink, 'url')); + if (!sanitizedUrl) { + return null; + } + + const sanitizedTitle = sanitizeTitle(getDeepLinkField(parsedDeepLink, 'title')) || new URL(sanitizedUrl).hostname; + + const parsedPayload = blogmarkPayloadSchema.safeParse({ + title: sanitizedTitle, + url: sanitizedUrl, + }); + + return parsedPayload.success ? parsedPayload.data : null; +} + +export function buildBlogmarkMarkdownLink(title: string, url: string): string { + const safeTitle = escapeMarkdownLinkText(title.trim()); + return `[${safeTitle}](<${url}>)`; +} + +export function generateBlogmarkBookmarkletSource(): string { + return "javascript:(()=>{const t=encodeURIComponent(document.title||'');const u=encodeURIComponent(location.href||'');location.href='bds://new-post?title='+t+'&url='+u;})();"; +} diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index dfea63f..ddba335 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -42,6 +42,7 @@ export interface ProjectMetadata { mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; + blogmarkCategory?: string; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record; @@ -564,6 +565,8 @@ export interface ElectronAPI { selectFolder: (title?: string) => Promise; getDefaultProjectPath: (projectId: string) => Promise; readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null>; + getBlogmarkBookmarklet: () => Promise; + copyToClipboard: (text: string) => Promise; setPreviewPostTarget: (postId: string | null) => Promise; triggerMenuAction: (action: string) => Promise; }; @@ -577,7 +580,7 @@ export interface ElectronAPI { syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>; getProjectMetadata: () => Promise; setProjectMetadata: (metadata: { name: string; description?: string }) => Promise; - updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => Promise; + updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record }) => Promise; }; tags: { getAll: () => Promise; diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index beb411a..fb3ef5a 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -2,7 +2,7 @@ import React, { useEffect } from 'react'; import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar } from './components'; import { useAppStore, PostData, MediaData, TaskProgress } from './store'; import { loadTabsForProject, saveTabsForProject } from './utils'; -import { openSingletonToolTab } from './navigation/tabPolicy'; +import { openEntityTab, openSingletonToolTab } from './navigation/tabPolicy'; import { persistSiteValidationReport } from './navigation/siteValidationPersistence'; import { executeActivityClick } from './navigation/activityExecution'; import { createAndFocusPost } from './navigation/postCreation'; @@ -214,6 +214,33 @@ const App: React.FC = () => { }) || (() => {}) ); + unsubscribers.push( + window.electronAPI?.on('blogmark:created', (post: unknown) => { + const created = post as { id?: string } | null; + if (!created?.id) { + return; + } + + const state = useAppStore.getState(); + executeActivityClick( + { + activeView: state.activeView, + sidebarVisible: state.sidebarVisible, + tabs: state.tabs, + activeTabId: state.activeTabId, + }, + 'posts', + { + setActiveView: state.setActiveView, + toggleSidebar: state.toggleSidebar, + }, + ); + + state.setSelectedPost(created.id); + openEntityTab(state.openTab, 'post', created.id, 'preview'); + }) || (() => {}) + ); + unsubscribers.push( window.electronAPI?.on('menu:importMedia', () => { window.electronAPI?.media.importDialog(); diff --git a/src/renderer/components/SettingsView/SettingsView.tsx b/src/renderer/components/SettingsView/SettingsView.tsx index 68b1638..9472133 100644 --- a/src/renderer/components/SettingsView/SettingsView.tsx +++ b/src/renderer/components/SettingsView/SettingsView.tsx @@ -76,6 +76,15 @@ const DEFAULT_CATEGORY_METADATA: Record = { // Standard categories that cannot be deleted const PROTECTED_CATEGORIES = ['article', 'aside', 'page', 'picture']; +function normalizeBlogmarkCategory(value: unknown): string | undefined { + if (typeof value !== 'string') { + return undefined; + } + + const normalized = value.trim().toLowerCase(); + return normalized.length > 0 ? normalized : undefined; +} + // Individual setting row component (VS Code style) const SettingRow: React.FC<{ id: string; @@ -140,6 +149,7 @@ export const SettingsView: React.FC = () => { const [projectMainLanguage, setProjectMainLanguage] = useState('en'); const [projectDefaultAuthor, setProjectDefaultAuthor] = useState(''); const [projectMaxPostsPerPage, setProjectMaxPostsPerPage] = useState(50); + const [projectBlogmarkCategory, setProjectBlogmarkCategory] = useState('article'); // Post categories management const [postCategories, setPostCategories] = useState(DEFAULT_POST_CATEGORIES); @@ -195,6 +205,9 @@ export const SettingsView: React.FC = () => { : 50; setProjectMaxPostsPerPage(maxPostsPerPage); + const incomingBlogmarkCategory = normalizeBlogmarkCategory((metadata as { blogmarkCategory?: unknown } | null)?.blogmarkCategory); + setProjectBlogmarkCategory(incomingBlogmarkCategory || 'article'); + const incomingCategoryMetadata = (metadata as any)?.categoryMetadata as Record | undefined; const incomingLegacyCategorySettings = (metadata as any)?.categorySettings as Record | undefined; setCategoryMetadata((current) => { @@ -232,6 +245,7 @@ export const SettingsView: React.FC = () => { const categories = await window.electronAPI?.meta.getCategories(); if (categories && categories.length > 0) { setPostCategories(categories); + setProjectBlogmarkCategory((current) => categories.includes(current) ? current : categories[0]); setCategoryMetadata((current) => { const next = { ...DEFAULT_CATEGORY_METADATA, ...current }; for (const category of categories) { @@ -244,6 +258,7 @@ export const SettingsView: React.FC = () => { } else { // Initialize with defaults if no categories exist setPostCategories(DEFAULT_POST_CATEGORIES); + setProjectBlogmarkCategory((current) => DEFAULT_POST_CATEGORIES.includes(current) ? current : DEFAULT_POST_CATEGORIES[0]); setCategoryMetadata(DEFAULT_CATEGORY_METADATA); } @@ -326,6 +341,7 @@ export const SettingsView: React.FC = () => { mainLanguage: resolveSupportedRenderLanguage(projectMainLanguage), defaultAuthor: projectDefaultAuthor.trim() || undefined, maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))), + blogmarkCategory: normalizeBlogmarkCategory(projectBlogmarkCategory) || undefined, categoryMetadata, }); } @@ -347,8 +363,29 @@ export const SettingsView: React.FC = () => { setProjectDataPath(''); }; + const handleCopyBlogmarkBookmarklet = async () => { + try { + const bookmarkletSource = await window.electronAPI?.app.getBlogmarkBookmarklet(); + if (!bookmarkletSource) { + showToast.error(t('settings.toast.blogmarkBookmarkletGenerateFailed')); + return; + } + + const copied = await window.electronAPI?.app.copyToClipboard(bookmarkletSource); + if (copied) { + showToast.success(t('settings.toast.blogmarkBookmarkletCopied')); + return; + } + + showToast.error(t('settings.toast.blogmarkBookmarkletCopyFailed')); + } catch (error) { + console.error('Failed to copy blogmark bookmarklet:', error); + showToast.error(t('settings.toast.blogmarkBookmarkletCopyFailed')); + } + }; + // Keywords for each section for search filtering - const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'url', 'public', 'path', 'folder', 'location', 'data', 'language', 'author', 'default', 'preview', 'max', 'posts', 'page']; + const projectKeywords = ['project', 'name', 'description', 'blog', 'site', 'url', 'public', 'path', 'folder', 'location', 'data', 'language', 'author', 'default', 'preview', 'max', 'posts', 'page', 'bookmarklet', 'blogmark']; const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual']; const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page']; const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode']; @@ -480,6 +517,32 @@ export const SettingsView: React.FC = () => { /> + + + + + + + +