feat: bookmarklet to blog stuff easily

This commit is contained in:
2026-02-22 17:49:11 +01:00
parent 6e45936fc9
commit 509afa4c85
17 changed files with 613 additions and 5 deletions

View File

@@ -23,6 +23,7 @@ export interface ProjectMetadata {
mainLanguage?: string; // Main language for AI-generated content (ISO code, e.g., 'en', 'de', 'es') mainLanguage?: string; // Main language for AI-generated content (ISO code, e.g., 'en', 'de', 'es')
defaultAuthor?: string; // Default author for new posts and media defaultAuthor?: string; // Default author for new posts and media
maxPostsPerPage?: number; // Preview/server maximum posts per page (default 50) 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 picoTheme?: PicoThemeName; // Selected Pico CSS theme for preview/rendering
categoryMetadata?: Record<string, CategoryMetadata>; // Per-category metadata for UI/rendering categoryMetadata?: Record<string, CategoryMetadata>; // Per-category metadata for UI/rendering
categorySettings?: Record<string, CategoryRenderSettings>; // Per-category list rendering preferences categorySettings?: Record<string, CategoryRenderSettings>; // Per-category list rendering preferences
@@ -81,12 +82,16 @@ type RawCategoryMetadataInput = Record<string, CategoryMetadata | CategoryRender
function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata { function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage); const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage);
const publicUrl = sanitizePublicUrl(metadata.publicUrl); const publicUrl = sanitizePublicUrl(metadata.publicUrl);
const blogmarkCategory = typeof metadata.blogmarkCategory === 'string'
? normalizeNonEmptyTaxonomyTerm(metadata.blogmarkCategory) ?? undefined
: undefined;
const picoTheme = sanitizePicoTheme(metadata.picoTheme); const picoTheme = sanitizePicoTheme(metadata.picoTheme);
const categoryMetadata = normalizeCategoryMetadata(metadata.categoryMetadata ?? metadata.categorySettings); const categoryMetadata = normalizeCategoryMetadata(metadata.categoryMetadata ?? metadata.categorySettings);
return { return {
...metadata, ...metadata,
publicUrl, publicUrl,
maxPostsPerPage, maxPostsPerPage,
blogmarkCategory,
picoTheme, picoTheme,
categoryMetadata, categoryMetadata,
categorySettings: undefined, categorySettings: undefined,
@@ -300,6 +305,7 @@ export class MetaEngine extends EventEmitter {
mainLanguage: normalizedUpdates.mainLanguage, mainLanguage: normalizedUpdates.mainLanguage,
defaultAuthor: normalizedUpdates.defaultAuthor, defaultAuthor: normalizedUpdates.defaultAuthor,
maxPostsPerPage: normalizedUpdates.maxPostsPerPage, maxPostsPerPage: normalizedUpdates.maxPostsPerPage,
blogmarkCategory: normalizedUpdates.blogmarkCategory,
picoTheme: normalizedUpdates.picoTheme, picoTheme: normalizedUpdates.picoTheme,
categoryMetadata: normalizedUpdates.categoryMetadata, categoryMetadata: normalizedUpdates.categoryMetadata,
}); });

View File

@@ -1,4 +1,4 @@
import { app, BrowserWindow, ipcMain, dialog, shell } from 'electron'; import { app, BrowserWindow, ipcMain, dialog, shell, clipboard } from 'electron';
import * as path from 'path'; import * as path from 'path';
import * as fsPromises from 'fs/promises'; import * as fsPromises from 'fs/promises';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
@@ -14,6 +14,7 @@ import { taskManager, TaskProgress } from '../engine/TaskManager';
import { getDatabase } from '../database'; import { getDatabase } from '../database';
import { media } from '../database/schema'; import { media } from '../database/schema';
import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_WEB_CONTENTS_ACTIONS, type AppMenuAction } from '../shared/menuCommands'; import { APP_MENU_ACTION_EVENT_MAP, APP_MENU_WEB_CONTENTS_ACTIONS, type AppMenuAction } from '../shared/menuCommands';
import { generateBlogmarkBookmarkletSource } from '../shared/blogmark';
import { registerMetadataDiffHandlers } from './metadataDiffHandlers'; import { registerMetadataDiffHandlers } from './metadataDiffHandlers';
import { registerBlogHandlers } from './blogHandlers'; import { registerBlogHandlers } from './blogHandlers';
@@ -775,6 +776,15 @@ export function registerIpcHandlers(): void {
return projectEngine.getDefaultProjectBaseDir(projectId); return projectEngine.getDefaultProjectBaseDir(projectId);
}); });
safeHandle('app:getBlogmarkBookmarklet', async () => {
return generateBlogmarkBookmarkletSource();
});
safeHandle('app:copyToClipboard', async (_, text: string) => {
clipboard.writeText(String(text ?? ''));
return true;
});
safeHandle('app:getTitleBarMetrics', async (event) => { safeHandle('app:getTitleBarMetrics', async (event) => {
const ownerWindow = BrowserWindow.fromWebContents(event.sender); const ownerWindow = BrowserWindow.fromWebContents(event.sender);
const buttonPosition = ownerWindow?.getWindowButtonPosition?.(); const buttonPosition = ownerWindow?.getWindowButtonPosition?.();

View File

@@ -7,15 +7,22 @@ import { media } from './database/schema';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { getMediaEngine } from './engine/MediaEngine'; import { getMediaEngine } from './engine/MediaEngine';
import { getPostEngine } from './engine/PostEngine'; import { getPostEngine } from './engine/PostEngine';
import { getMetaEngine } from './engine/MetaEngine';
import { PreviewServer } from './engine/PreviewServer'; 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 { 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 { resolveUiLanguageFromSystemLocale, translateMenu } from './shared/i18n';
import { buildBlogmarkMarkdownLink, extractBlogmarkPayloadFromDeepLink, normalizeBlogmarkCategory } from './shared/blogmark';
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let previewServer: PreviewServer | null = null; let previewServer: PreviewServer | null = null;
let activePreviewPostId: string | null = null; let activePreviewPostId: string | null = null;
let appInitialized = false;
let blogmarkQueue: string[] = [];
let blogmarkQueueProcessing = false;
const PREVIEW_SERVER_PORT = 4123; const PREVIEW_SERVER_PORT = 4123;
const BLOG_PREVIEW_POST_MENU_ID = APP_MENU_ITEM_IDS.previewPost; 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_WIDTH = 800;
const WINDOW_MIN_HEIGHT = 600; const WINDOW_MIN_HEIGHT = 600;
const WINDOW_DEFAULT_WIDTH = 1400; const WINDOW_DEFAULT_WIDTH = 1400;
@@ -330,6 +337,80 @@ async function startPreviewServerOnAppStart(): Promise<void> {
await previewServer.start(PREVIEW_SERVER_PORT); 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<void> {
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<void> {
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 { function createApplicationMenu(): Menu {
const systemLocale = typeof app.getLocale === 'function' ? app.getLocale() : 'en'; const systemLocale = typeof app.getLocale === 'function' ? app.getLocale() : 'en';
const uiLanguage = resolveUiLanguageFromSystemLocale(systemLocale); const uiLanguage = resolveUiLanguageFromSystemLocale(systemLocale);
@@ -633,9 +714,35 @@ async function initialize(): Promise<void> {
registerChatHandlers(); 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 lifecycle
app.whenReady().then(async () => { app.whenReady().then(async () => {
await initialize(); await initialize();
appInitialized = true;
registerBlogmarkProtocolClient();
try { try {
await startPreviewServerOnAppStart(); await startPreviewServerOnAppStart();
} catch (error) { } catch (error) {
@@ -643,6 +750,12 @@ app.whenReady().then(async () => {
} }
createWindow(); createWindow();
const startupDeepLinks = extractBlogmarkDeepLinks(process.argv);
for (const deepLink of startupDeepLinks) {
enqueueBlogmarkDeepLink(deepLink);
}
await processBlogmarkQueue();
app.on('activate', () => { app.on('activate', () => {
if (BrowserWindow.getAllWindows().length === 0) { if (BrowserWindow.getAllWindows().length === 0) {
createWindow(); createWindow();

View File

@@ -145,6 +145,8 @@ export const electronAPI: ElectronAPI = {
selectFolder: (title?: string) => ipcRenderer.invoke('app:selectFolder', title), selectFolder: (title?: string) => ipcRenderer.invoke('app:selectFolder', title),
getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId), getDefaultProjectPath: (projectId: string) => ipcRenderer.invoke('app:getDefaultProjectPath', projectId),
readProjectMetadata: (folderPath: string) => ipcRenderer.invoke('app:readProjectMetadata', folderPath), 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), setPreviewPostTarget: (postId: string | null) => ipcRenderer.invoke('app:setPreviewPostTarget', postId),
triggerMenuAction: (action: string) => ipcRenderer.invoke('app:triggerMenuAction', action), triggerMenuAction: (action: string) => ipcRenderer.invoke('app:triggerMenuAction', action),
}, },
@@ -160,7 +162,7 @@ export const electronAPI: ElectronAPI = {
syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'), syncOnStartup: () => ipcRenderer.invoke('meta:syncOnStartup'),
getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'), getProjectMetadata: () => ipcRenderer.invoke('meta:getProjectMetadata'),
setProjectMetadata: (metadata: { name: string; description?: string }) => ipcRenderer.invoke('meta:setProjectMetadata', metadata), 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<string, { renderInLists: boolean; showTitle: boolean; title: string }>; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }> }) => 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<string, { renderInLists: boolean; showTitle: boolean; title: string }>; categorySettings?: Record<string, { renderInLists: boolean; showTitle: boolean }> }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates),
}, },
// Tag Management (advanced tag operations) // Tag Management (advanced tag operations)

125
src/main/shared/blogmark.ts Normal file
View File

@@ -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;})();";
}

View File

@@ -42,6 +42,7 @@ export interface ProjectMetadata {
mainLanguage?: string; mainLanguage?: string;
defaultAuthor?: string; defaultAuthor?: string;
maxPostsPerPage?: number; maxPostsPerPage?: number;
blogmarkCategory?: string;
picoTheme?: import('./picoThemes').PicoThemeName; picoTheme?: import('./picoThemes').PicoThemeName;
categoryMetadata?: Record<string, CategoryMetadata>; categoryMetadata?: Record<string, CategoryMetadata>;
categorySettings?: Record<string, CategoryRenderSettings>; categorySettings?: Record<string, CategoryRenderSettings>;
@@ -564,6 +565,8 @@ export interface ElectronAPI {
selectFolder: (title?: string) => Promise<string | null>; selectFolder: (title?: string) => Promise<string | null>;
getDefaultProjectPath: (projectId: string) => Promise<string>; getDefaultProjectPath: (projectId: string) => Promise<string>;
readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null>; readProjectMetadata: (folderPath: string) => Promise<{ name?: string; description?: string; publicUrl?: string; mainLanguage?: string } | null>;
getBlogmarkBookmarklet: () => Promise<string>;
copyToClipboard: (text: string) => Promise<boolean>;
setPreviewPostTarget: (postId: string | null) => Promise<void>; setPreviewPostTarget: (postId: string | null) => Promise<void>;
triggerMenuAction: (action: string) => Promise<void>; triggerMenuAction: (action: string) => Promise<void>;
}; };
@@ -577,7 +580,7 @@ export interface ElectronAPI {
syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>; syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>;
getProjectMetadata: () => Promise<ProjectMetadata | null>; getProjectMetadata: () => Promise<ProjectMetadata | null>;
setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>; setProjectMetadata: (metadata: { name: string; description?: string }) => Promise<ProjectMetadata | null>;
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record<string, CategoryMetadata>; categorySettings?: Record<string, CategoryRenderSettings> }) => Promise<ProjectMetadata | null>; updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record<string, CategoryMetadata>; categorySettings?: Record<string, CategoryRenderSettings> }) => Promise<ProjectMetadata | null>;
}; };
tags: { tags: {
getAll: () => Promise<TagData[]>; getAll: () => Promise<TagData[]>;

View File

@@ -2,7 +2,7 @@ import React, { useEffect } from 'react';
import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar } from './components'; import { ActivityBar, Sidebar, Editor, StatusBar, Panel, TabBar, ToastContainer, showToast, ResizablePanel, WindowTitleBar } from './components';
import { useAppStore, PostData, MediaData, TaskProgress } from './store'; import { useAppStore, PostData, MediaData, TaskProgress } from './store';
import { loadTabsForProject, saveTabsForProject } from './utils'; import { loadTabsForProject, saveTabsForProject } from './utils';
import { openSingletonToolTab } from './navigation/tabPolicy'; import { openEntityTab, openSingletonToolTab } from './navigation/tabPolicy';
import { persistSiteValidationReport } from './navigation/siteValidationPersistence'; import { persistSiteValidationReport } from './navigation/siteValidationPersistence';
import { executeActivityClick } from './navigation/activityExecution'; import { executeActivityClick } from './navigation/activityExecution';
import { createAndFocusPost } from './navigation/postCreation'; 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( unsubscribers.push(
window.electronAPI?.on('menu:importMedia', () => { window.electronAPI?.on('menu:importMedia', () => {
window.electronAPI?.media.importDialog(); window.electronAPI?.media.importDialog();

View File

@@ -76,6 +76,15 @@ const DEFAULT_CATEGORY_METADATA: Record<string, CategoryMetadata> = {
// Standard categories that cannot be deleted // Standard categories that cannot be deleted
const PROTECTED_CATEGORIES = ['article', 'aside', 'page', 'picture']; 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) // Individual setting row component (VS Code style)
const SettingRow: React.FC<{ const SettingRow: React.FC<{
id: string; id: string;
@@ -140,6 +149,7 @@ export const SettingsView: React.FC = () => {
const [projectMainLanguage, setProjectMainLanguage] = useState<SupportedLanguage>('en'); const [projectMainLanguage, setProjectMainLanguage] = useState<SupportedLanguage>('en');
const [projectDefaultAuthor, setProjectDefaultAuthor] = useState(''); const [projectDefaultAuthor, setProjectDefaultAuthor] = useState('');
const [projectMaxPostsPerPage, setProjectMaxPostsPerPage] = useState(50); const [projectMaxPostsPerPage, setProjectMaxPostsPerPage] = useState(50);
const [projectBlogmarkCategory, setProjectBlogmarkCategory] = useState('article');
// Post categories management // Post categories management
const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES); const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES);
@@ -195,6 +205,9 @@ export const SettingsView: React.FC = () => {
: 50; : 50;
setProjectMaxPostsPerPage(maxPostsPerPage); setProjectMaxPostsPerPage(maxPostsPerPage);
const incomingBlogmarkCategory = normalizeBlogmarkCategory((metadata as { blogmarkCategory?: unknown } | null)?.blogmarkCategory);
setProjectBlogmarkCategory(incomingBlogmarkCategory || 'article');
const incomingCategoryMetadata = (metadata as any)?.categoryMetadata as Record<string, CategoryMetadata> | undefined; const incomingCategoryMetadata = (metadata as any)?.categoryMetadata as Record<string, CategoryMetadata> | undefined;
const incomingLegacyCategorySettings = (metadata as any)?.categorySettings as Record<string, { renderInLists: boolean; showTitle: boolean }> | undefined; const incomingLegacyCategorySettings = (metadata as any)?.categorySettings as Record<string, { renderInLists: boolean; showTitle: boolean }> | undefined;
setCategoryMetadata((current) => { setCategoryMetadata((current) => {
@@ -232,6 +245,7 @@ export const SettingsView: React.FC = () => {
const categories = await window.electronAPI?.meta.getCategories(); const categories = await window.electronAPI?.meta.getCategories();
if (categories && categories.length > 0) { if (categories && categories.length > 0) {
setPostCategories(categories); setPostCategories(categories);
setProjectBlogmarkCategory((current) => categories.includes(current) ? current : categories[0]);
setCategoryMetadata((current) => { setCategoryMetadata((current) => {
const next = { ...DEFAULT_CATEGORY_METADATA, ...current }; const next = { ...DEFAULT_CATEGORY_METADATA, ...current };
for (const category of categories) { for (const category of categories) {
@@ -244,6 +258,7 @@ export const SettingsView: React.FC = () => {
} else { } else {
// Initialize with defaults if no categories exist // Initialize with defaults if no categories exist
setPostCategories(DEFAULT_POST_CATEGORIES); setPostCategories(DEFAULT_POST_CATEGORIES);
setProjectBlogmarkCategory((current) => DEFAULT_POST_CATEGORIES.includes(current) ? current : DEFAULT_POST_CATEGORIES[0]);
setCategoryMetadata(DEFAULT_CATEGORY_METADATA); setCategoryMetadata(DEFAULT_CATEGORY_METADATA);
} }
@@ -326,6 +341,7 @@ export const SettingsView: React.FC = () => {
mainLanguage: resolveSupportedRenderLanguage(projectMainLanguage), mainLanguage: resolveSupportedRenderLanguage(projectMainLanguage),
defaultAuthor: projectDefaultAuthor.trim() || undefined, defaultAuthor: projectDefaultAuthor.trim() || undefined,
maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))), maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))),
blogmarkCategory: normalizeBlogmarkCategory(projectBlogmarkCategory) || undefined,
categoryMetadata, categoryMetadata,
}); });
} }
@@ -347,8 +363,29 @@ export const SettingsView: React.FC = () => {
setProjectDataPath(''); 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 // 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 editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page']; const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode']; const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode'];
@@ -480,6 +517,32 @@ export const SettingsView: React.FC = () => {
/> />
</SettingRow> </SettingRow>
<SettingRow
id="project-blogmark-category"
label={t('settings.project.blogmarkCategoryLabel')}
description={t('settings.project.blogmarkCategoryDescription')}
>
<select
id="project-blogmark-category"
value={projectBlogmarkCategory}
onChange={(e) => setProjectBlogmarkCategory(e.target.value)}
>
{postCategories.map((category) => (
<option key={category} value={category}>{category}</option>
))}
</select>
</SettingRow>
<SettingRow
id="project-blogmark-bookmarklet"
label={t('settings.project.blogmarkBookmarkletLabel')}
description={t('settings.project.blogmarkBookmarkletDescription')}
>
<button className="secondary" onClick={handleCopyBlogmarkBookmarklet}>
{t('settings.project.blogmarkBookmarkletCopyButton')}
</button>
</SettingRow>
<div className="setting-actions"> <div className="setting-actions">
<button className="primary" onClick={handleSaveProject}> <button className="primary" onClick={handleSaveProject}>
{t('settings.project.saveButton')} {t('settings.project.saveButton')}

View File

@@ -133,6 +133,9 @@
"settings.toast.credentialsCleared": "{type}-Anmeldedaten gelöscht", "settings.toast.credentialsCleared": "{type}-Anmeldedaten gelöscht",
"settings.toast.projectSaved": "Projekteinstellungen gespeichert", "settings.toast.projectSaved": "Projekteinstellungen gespeichert",
"settings.toast.projectSaveFailed": "Projekteinstellungen konnten nicht gespeichert werden", "settings.toast.projectSaveFailed": "Projekteinstellungen konnten nicht gespeichert werden",
"settings.toast.blogmarkBookmarkletCopied": "Blogmark-Bookmarklet in die Zwischenablage kopiert",
"settings.toast.blogmarkBookmarkletCopyFailed": "Blogmark-Bookmarklet konnte nicht kopiert werden",
"settings.toast.blogmarkBookmarkletGenerateFailed": "Blogmark-Bookmarklet konnte nicht erzeugt werden",
"settings.toast.categoryAdded": "Kategorie \"{category}\" hinzugefügt", "settings.toast.categoryAdded": "Kategorie \"{category}\" hinzugefügt",
"settings.toast.categoryAddFailed": "Kategorie konnte nicht hinzugefügt werden", "settings.toast.categoryAddFailed": "Kategorie konnte nicht hinzugefügt werden",
"settings.toast.categoryExists": "Kategorie existiert bereits", "settings.toast.categoryExists": "Kategorie existiert bereits",
@@ -435,6 +438,11 @@
"settings.project.defaultAuthorPlaceholder": "Autorenname", "settings.project.defaultAuthorPlaceholder": "Autorenname",
"settings.project.maxPostsPerPageLabel": "Maximale Beiträge pro Seite", "settings.project.maxPostsPerPageLabel": "Maximale Beiträge pro Seite",
"settings.project.maxPostsPerPageDescription": "Maximale Anzahl von Beiträgen pro Vorschau-Routenseite.", "settings.project.maxPostsPerPageDescription": "Maximale Anzahl von Beiträgen pro Vorschau-Routenseite.",
"settings.project.blogmarkCategoryLabel": "Blogmark-Kategorie",
"settings.project.blogmarkCategoryDescription": "Kategorie für Beiträge, die über Bookmarklet-Deep-Links erstellt werden.",
"settings.project.blogmarkBookmarkletLabel": "Browser-Bookmarklet",
"settings.project.blogmarkBookmarkletDescription": "Kopiere ein Bookmarklet, das du in die Browser-Lesezeichenleiste einfügen kannst, um Seitenlinks an bDS zu senden.",
"settings.project.blogmarkBookmarkletCopyButton": "Blogmark-Bookmarklet kopieren",
"settings.project.saveButton": "Projekteinstellungen speichern", "settings.project.saveButton": "Projekteinstellungen speichern",
"editor.loadingPost": "Beitrag wird geladen...", "editor.loadingPost": "Beitrag wird geladen...",
"editor.unsavedChanges": "Ungespeicherte Änderungen (wird beim Wechsel automatisch gespeichert)", "editor.unsavedChanges": "Ungespeicherte Änderungen (wird beim Wechsel automatisch gespeichert)",

View File

@@ -133,6 +133,9 @@
"settings.toast.credentialsCleared": "{type} credentials cleared", "settings.toast.credentialsCleared": "{type} credentials cleared",
"settings.toast.projectSaved": "Project settings saved", "settings.toast.projectSaved": "Project settings saved",
"settings.toast.projectSaveFailed": "Failed to save project settings", "settings.toast.projectSaveFailed": "Failed to save project settings",
"settings.toast.blogmarkBookmarkletCopied": "Blogmark bookmarklet copied to clipboard",
"settings.toast.blogmarkBookmarkletCopyFailed": "Failed to copy blogmark bookmarklet",
"settings.toast.blogmarkBookmarkletGenerateFailed": "Failed to generate blogmark bookmarklet",
"settings.toast.categoryAdded": "Category \"{category}\" added", "settings.toast.categoryAdded": "Category \"{category}\" added",
"settings.toast.categoryAddFailed": "Failed to add category", "settings.toast.categoryAddFailed": "Failed to add category",
"settings.toast.categoryExists": "Category already exists", "settings.toast.categoryExists": "Category already exists",
@@ -435,6 +438,11 @@
"settings.project.defaultAuthorPlaceholder": "Author Name", "settings.project.defaultAuthorPlaceholder": "Author Name",
"settings.project.maxPostsPerPageLabel": "Max Posts Per Page", "settings.project.maxPostsPerPageLabel": "Max Posts Per Page",
"settings.project.maxPostsPerPageDescription": "Maximum number of posts shown per preview route page.", "settings.project.maxPostsPerPageDescription": "Maximum number of posts shown per preview route page.",
"settings.project.blogmarkCategoryLabel": "Blogmark Category",
"settings.project.blogmarkCategoryDescription": "Category assigned to posts created via bookmarklet deep links.",
"settings.project.blogmarkBookmarkletLabel": "Browser Bookmarklet",
"settings.project.blogmarkBookmarkletDescription": "Copy a bookmarklet you can paste into your browser bookmarks bar to send page links into bDS.",
"settings.project.blogmarkBookmarkletCopyButton": "Copy Blogmark Bookmarklet",
"settings.project.saveButton": "Save Project Settings", "settings.project.saveButton": "Save Project Settings",
"editor.loadingPost": "Loading post...", "editor.loadingPost": "Loading post...",
"editor.unsavedChanges": "Unsaved changes (auto-saves on switch)", "editor.unsavedChanges": "Unsaved changes (auto-saves on switch)",

View File

@@ -133,6 +133,9 @@
"settings.toast.credentialsCleared": "Credenciales de {type} borradas", "settings.toast.credentialsCleared": "Credenciales de {type} borradas",
"settings.toast.projectSaved": "Configuración del proyecto guardada", "settings.toast.projectSaved": "Configuración del proyecto guardada",
"settings.toast.projectSaveFailed": "No se pudo guardar la configuración del proyecto", "settings.toast.projectSaveFailed": "No se pudo guardar la configuración del proyecto",
"settings.toast.blogmarkBookmarkletCopied": "Bookmarklet de blogmark copiado al portapapeles",
"settings.toast.blogmarkBookmarkletCopyFailed": "No se pudo copiar el bookmarklet de blogmark",
"settings.toast.blogmarkBookmarkletGenerateFailed": "No se pudo generar el bookmarklet de blogmark",
"settings.toast.categoryAdded": "Categoría \"{category}\" agregada", "settings.toast.categoryAdded": "Categoría \"{category}\" agregada",
"settings.toast.categoryAddFailed": "No se pudo agregar la categoría", "settings.toast.categoryAddFailed": "No se pudo agregar la categoría",
"settings.toast.categoryExists": "La categoría ya existe", "settings.toast.categoryExists": "La categoría ya existe",
@@ -435,6 +438,11 @@
"settings.project.defaultAuthorPlaceholder": "Nombre del autor", "settings.project.defaultAuthorPlaceholder": "Nombre del autor",
"settings.project.maxPostsPerPageLabel": "Máx. entradas por página", "settings.project.maxPostsPerPageLabel": "Máx. entradas por página",
"settings.project.maxPostsPerPageDescription": "Número máximo de entradas mostradas por página de ruta de vista previa.", "settings.project.maxPostsPerPageDescription": "Número máximo de entradas mostradas por página de ruta de vista previa.",
"settings.project.blogmarkCategoryLabel": "Categoría de blogmark",
"settings.project.blogmarkCategoryDescription": "Categoría asignada a entradas creadas mediante deep links del bookmarklet.",
"settings.project.blogmarkBookmarkletLabel": "Bookmarklet del navegador",
"settings.project.blogmarkBookmarkletDescription": "Copia un bookmarklet para pegarlo en la barra de marcadores del navegador y enviar enlaces de páginas a bDS.",
"settings.project.blogmarkBookmarkletCopyButton": "Copiar bookmarklet de blogmark",
"settings.project.saveButton": "Guardar configuración del proyecto", "settings.project.saveButton": "Guardar configuración del proyecto",
"editor.loadingPost": "Cargando entrada...", "editor.loadingPost": "Cargando entrada...",
"editor.unsavedChanges": "Cambios sin guardar (se guarda automáticamente al cambiar)", "editor.unsavedChanges": "Cambios sin guardar (se guarda automáticamente al cambiar)",

View File

@@ -133,6 +133,9 @@
"settings.toast.credentialsCleared": "Identifiants {type} effacés", "settings.toast.credentialsCleared": "Identifiants {type} effacés",
"settings.toast.projectSaved": "Paramètres du projet enregistrés", "settings.toast.projectSaved": "Paramètres du projet enregistrés",
"settings.toast.projectSaveFailed": "Impossible denregistrer les paramètres du projet", "settings.toast.projectSaveFailed": "Impossible denregistrer les paramètres du projet",
"settings.toast.blogmarkBookmarkletCopied": "Bookmarklet blogmark copié dans le presse-papiers",
"settings.toast.blogmarkBookmarkletCopyFailed": "Impossible de copier le bookmarklet blogmark",
"settings.toast.blogmarkBookmarkletGenerateFailed": "Impossible de générer le bookmarklet blogmark",
"settings.toast.categoryAdded": "Catégorie \"{category}\" ajoutée", "settings.toast.categoryAdded": "Catégorie \"{category}\" ajoutée",
"settings.toast.categoryAddFailed": "Impossible dajouter la catégorie", "settings.toast.categoryAddFailed": "Impossible dajouter la catégorie",
"settings.toast.categoryExists": "La catégorie existe déjà", "settings.toast.categoryExists": "La catégorie existe déjà",
@@ -435,6 +438,11 @@
"settings.project.defaultAuthorPlaceholder": "Nom de lauteur", "settings.project.defaultAuthorPlaceholder": "Nom de lauteur",
"settings.project.maxPostsPerPageLabel": "Nombre max darticles par page", "settings.project.maxPostsPerPageLabel": "Nombre max darticles par page",
"settings.project.maxPostsPerPageDescription": "Nombre maximum darticles affichés par page de route daperçu.", "settings.project.maxPostsPerPageDescription": "Nombre maximum darticles affichés par page de route daperçu.",
"settings.project.blogmarkCategoryLabel": "Catégorie blogmark",
"settings.project.blogmarkCategoryDescription": "Catégorie attribuée aux articles créés via les deep links du bookmarklet.",
"settings.project.blogmarkBookmarkletLabel": "Bookmarklet navigateur",
"settings.project.blogmarkBookmarkletDescription": "Copiez un bookmarklet à coller dans la barre de favoris de votre navigateur pour envoyer les liens de page vers bDS.",
"settings.project.blogmarkBookmarkletCopyButton": "Copier le bookmarklet blogmark",
"settings.project.saveButton": "Enregistrer les paramètres du projet", "settings.project.saveButton": "Enregistrer les paramètres du projet",
"editor.loadingPost": "Chargement de larticle...", "editor.loadingPost": "Chargement de larticle...",
"editor.unsavedChanges": "Modifications non enregistrées (enregistrement auto au changement)", "editor.unsavedChanges": "Modifications non enregistrées (enregistrement auto au changement)",

View File

@@ -133,6 +133,9 @@
"settings.toast.credentialsCleared": "Credenziali {type} cancellate", "settings.toast.credentialsCleared": "Credenziali {type} cancellate",
"settings.toast.projectSaved": "Impostazioni progetto salvate", "settings.toast.projectSaved": "Impostazioni progetto salvate",
"settings.toast.projectSaveFailed": "Impossibile salvare le impostazioni del progetto", "settings.toast.projectSaveFailed": "Impossibile salvare le impostazioni del progetto",
"settings.toast.blogmarkBookmarkletCopied": "Bookmarklet blogmark copiato negli appunti",
"settings.toast.blogmarkBookmarkletCopyFailed": "Impossibile copiare il bookmarklet blogmark",
"settings.toast.blogmarkBookmarkletGenerateFailed": "Impossibile generare il bookmarklet blogmark",
"settings.toast.categoryAdded": "Categoria \"{category}\" aggiunta", "settings.toast.categoryAdded": "Categoria \"{category}\" aggiunta",
"settings.toast.categoryAddFailed": "Impossibile aggiungere la categoria", "settings.toast.categoryAddFailed": "Impossibile aggiungere la categoria",
"settings.toast.categoryExists": "La categoria esiste già", "settings.toast.categoryExists": "La categoria esiste già",
@@ -435,6 +438,11 @@
"settings.project.defaultAuthorPlaceholder": "Nome autore", "settings.project.defaultAuthorPlaceholder": "Nome autore",
"settings.project.maxPostsPerPageLabel": "Max post per pagina", "settings.project.maxPostsPerPageLabel": "Max post per pagina",
"settings.project.maxPostsPerPageDescription": "Numero massimo di post mostrati per pagina di anteprima.", "settings.project.maxPostsPerPageDescription": "Numero massimo di post mostrati per pagina di anteprima.",
"settings.project.blogmarkCategoryLabel": "Categoria blogmark",
"settings.project.blogmarkCategoryDescription": "Categoria assegnata ai post creati tramite deep link del bookmarklet.",
"settings.project.blogmarkBookmarkletLabel": "Bookmarklet del browser",
"settings.project.blogmarkBookmarkletDescription": "Copia un bookmarklet da incollare nella barra dei preferiti del browser per inviare link di pagina a bDS.",
"settings.project.blogmarkBookmarkletCopyButton": "Copia bookmarklet blogmark",
"settings.project.saveButton": "Salva impostazioni progetto", "settings.project.saveButton": "Salva impostazioni progetto",
"editor.loadingPost": "Caricamento post...", "editor.loadingPost": "Caricamento post...",
"editor.unsavedChanges": "Modifiche non salvate (salvataggio automatico al cambio)", "editor.unsavedChanges": "Modifiche non salvate (salvataggio automatico al cambio)",

View File

@@ -0,0 +1,41 @@
import { describe, it, expect } from 'vitest';
import {
buildBlogmarkMarkdownLink,
extractBlogmarkPayloadFromDeepLink,
generateBlogmarkBookmarkletSource,
} from '../../src/main/shared/blogmark';
describe('blogmark deep-link payload', () => {
it('extracts and sanitizes title and URL from deep link', () => {
const payload = extractBlogmarkPayloadFromDeepLink(
'bds://new-post?title=Hello%20%3Cb%3EWorld%3C%2Fb%3E&url=https%3A%2F%2Fexample.com%2Fpost%3Fx%3D1%23frag',
);
expect(payload).toEqual({
title: 'Hello <b>World</b>',
url: 'https://example.com/post?x=1',
});
});
it('rejects non-http URLs', () => {
const payload = extractBlogmarkPayloadFromDeepLink(
'bds://new-post?title=Unsafe&url=javascript%3Aalert(1)',
);
expect(payload).toBeNull();
});
it('builds safe markdown source link', () => {
const markdown = buildBlogmarkMarkdownLink('A [title] (test)', 'https://example.com/x?y=1');
expect(markdown).toBe('[A \\[title\\] \\(test\\)](<https://example.com/x?y=1>)');
});
it('generates bookmarklet that targets bds protocol', () => {
const source = generateBlogmarkBookmarkletSource();
expect(source.startsWith('javascript:')).toBe(true);
expect(source).toContain('bds://new-post?title=');
expect(source).toContain('encodeURIComponent(document.title');
expect(source).toContain('encodeURIComponent(location.href');
});
});

View File

@@ -729,6 +729,30 @@ describe('MetaEngine', () => {
expect(metadata?.maxPostsPerPage).toBe(42); expect(metadata?.maxPostsPerPage).toBe(42);
}); });
it('should set and get blogmarkCategory in project metadata', async () => {
await metaEngine.setProjectMetadata({
name: 'My Blog',
blogmarkCategory: 'Article',
} as any);
const metadata = await metaEngine.getProjectMetadata();
expect((metadata as any)?.blogmarkCategory).toBe('article');
});
it('should persist blogmarkCategory to filesystem', async () => {
await metaEngine.setProjectMetadata({
name: 'Test Project',
blogmarkCategory: 'links',
} as any);
const metaDir = metaEngine.getMetaDir();
const projectPath = normalizePath(`${metaDir}/project.json`);
const content = mockFiles.get(projectPath);
const parsed = JSON.parse(content!);
expect(parsed.blogmarkCategory).toBe('links');
});
it('should set and get publicUrl in project metadata', async () => { it('should set and get publicUrl in project metadata', async () => {
await metaEngine.setProjectMetadata({ await metaEngine.setProjectMetadata({
name: 'My Blog', name: 'My Blog',

View File

@@ -647,4 +647,156 @@ describe('main bootstrap preview behavior', () => {
height: 775, height: 775,
})); }));
}); });
it('handles bds deep-link by creating a blogmark post with preferred category', async () => {
const listeners = new Map<string, (...args: any[]) => void>();
const mockApp = {
name: 'bDS',
whenReady: vi.fn(() => Promise.resolve()),
on: vi.fn((event: string, callback: (...args: any[]) => void) => {
listeners.set(event, callback);
}),
quit: vi.fn(),
requestSingleInstanceLock: vi.fn(() => true),
setAsDefaultProtocolClient: vi.fn(() => true),
};
const windows: Array<{ webContents: { send: ReturnType<typeof vi.fn> } }> = [];
class MockBrowserWindow {
static getAllWindows = vi.fn(() => windows as any);
loadURL = vi.fn();
loadFile = vi.fn();
on = vi.fn();
isDestroyed = vi.fn(() => false);
webContents = {
on: vi.fn(),
send: vi.fn(),
openDevTools: vi.fn(),
toggleDevTools: vi.fn(),
};
constructor() {
windows.push(this as any);
}
}
vi.doMock('electron', () => ({
app: mockApp,
BrowserWindow: MockBrowserWindow,
Menu: {
buildFromTemplate: vi.fn(() => ({})),
setApplicationMenu: vi.fn(),
},
ipcMain: {
on: vi.fn(),
handle: vi.fn(),
removeHandler: vi.fn(),
},
protocol: {
registerSchemesAsPrivileged: vi.fn(),
handle: vi.fn(),
},
net: {
fetch: vi.fn(),
},
shell: {
openExternal: vi.fn(),
openPath: vi.fn(),
},
}));
class MockPreviewServer {
start = vi.fn().mockResolvedValue(4123);
stop = vi.fn().mockResolvedValue(undefined);
getBaseUrl = vi.fn(() => 'http://127.0.0.1:4123');
}
const createPost = vi.fn().mockResolvedValue({
id: 'new-post-id',
title: 'Example title',
content: '[Example title](<https://example.com/>)',
categories: ['article'],
});
vi.doMock('../../src/main/engine/PreviewServer', () => ({
PreviewServer: MockPreviewServer,
}));
vi.doMock('../../src/main/engine/PostEngine', () => ({
getPostEngine: vi.fn(() => ({
getPost: vi.fn().mockResolvedValue(null),
createPost,
})),
}));
vi.doMock('../../src/main/engine/MetaEngine', () => ({
getMetaEngine: vi.fn(() => ({
getProjectMetadata: vi.fn().mockResolvedValue({ blogmarkCategory: 'article' }),
})),
}));
vi.doMock('../../src/main/database', () => ({
getDatabase: vi.fn(() => ({
initializeLocal: vi.fn().mockResolvedValue(undefined),
close: vi.fn().mockResolvedValue(undefined),
getLocal: vi.fn(() => ({
select: vi.fn(() => ({
from: vi.fn(() => ({
where: vi.fn(() => ({
get: vi.fn().mockResolvedValue(null),
})),
})),
})),
})),
getDataPaths: vi.fn(() => ({ database: '/tmp/mock.db' })),
})),
}));
vi.doMock('../../src/main/ipc', () => ({
registerIpcHandlers: vi.fn(),
registerChatHandlers: vi.fn(),
initializeChatHandlers: vi.fn(),
cleanupChatHandlers: vi.fn().mockResolvedValue(undefined),
}));
vi.doMock('../../src/main/database/schema', () => ({
media: {},
}));
vi.doMock('drizzle-orm', () => ({
eq: vi.fn(),
}));
vi.doMock('../../src/main/engine/MediaEngine', () => ({
getMediaEngine: vi.fn(() => ({
getThumbnailPaths: vi.fn().mockResolvedValue({ small: null }),
})),
}));
await import('../../src/main/main');
await new Promise((resolve) => setTimeout(resolve, 0));
const openUrl = listeners.get('open-url');
expect(openUrl).toBeTruthy();
const preventDefault = vi.fn();
openUrl?.({ preventDefault } as any, 'bds://new-post?title=Example%20title&url=https%3A%2F%2Fexample.com%2F');
await new Promise((resolve) => setTimeout(resolve, 0));
expect(preventDefault).toHaveBeenCalled();
expect(createPost).toHaveBeenCalledWith(
expect.objectContaining({
title: 'Example title',
content: '[Example title](<https://example.com/>)',
categories: ['article'],
}),
);
expect(windows[0]?.webContents.send).toHaveBeenCalledWith(
'blogmark:created',
expect.objectContaining({ id: 'new-post-id' }),
);
});
}); });

View File

@@ -128,6 +128,8 @@ Object.defineProperty(globalThis, 'window', {
}, },
app: { app: {
triggerMenuAction: vi.fn(), triggerMenuAction: vi.fn(),
getBlogmarkBookmarklet: vi.fn(),
copyToClipboard: vi.fn(),
}, },
import: { import: {
selectAndAnalyze: vi.fn(), selectAndAnalyze: vi.fn(),