feat: bookmarklet to blog stuff easily
This commit is contained in:
@@ -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<string, CategoryMetadata>; // Per-category metadata for UI/rendering
|
||||
categorySettings?: Record<string, CategoryRenderSettings>; // Per-category list rendering preferences
|
||||
@@ -81,12 +82,16 @@ type RawCategoryMetadataInput = Record<string, CategoryMetadata | CategoryRender
|
||||
function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
|
||||
const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage);
|
||||
const publicUrl = sanitizePublicUrl(metadata.publicUrl);
|
||||
const blogmarkCategory = typeof metadata.blogmarkCategory === 'string'
|
||||
? normalizeNonEmptyTaxonomyTerm(metadata.blogmarkCategory) ?? undefined
|
||||
: undefined;
|
||||
const picoTheme = sanitizePicoTheme(metadata.picoTheme);
|
||||
const categoryMetadata = normalizeCategoryMetadata(metadata.categoryMetadata ?? metadata.categorySettings);
|
||||
return {
|
||||
...metadata,
|
||||
publicUrl,
|
||||
maxPostsPerPage,
|
||||
blogmarkCategory,
|
||||
picoTheme,
|
||||
categoryMetadata,
|
||||
categorySettings: undefined,
|
||||
@@ -300,6 +305,7 @@ export class MetaEngine extends EventEmitter {
|
||||
mainLanguage: normalizedUpdates.mainLanguage,
|
||||
defaultAuthor: normalizedUpdates.defaultAuthor,
|
||||
maxPostsPerPage: normalizedUpdates.maxPostsPerPage,
|
||||
blogmarkCategory: normalizedUpdates.blogmarkCategory,
|
||||
picoTheme: normalizedUpdates.picoTheme,
|
||||
categoryMetadata: normalizedUpdates.categoryMetadata,
|
||||
});
|
||||
|
||||
@@ -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 fsPromises from 'fs/promises';
|
||||
import { eq } from 'drizzle-orm';
|
||||
@@ -14,6 +14,7 @@ import { taskManager, TaskProgress } from '../engine/TaskManager';
|
||||
import { getDatabase } from '../database';
|
||||
import { media } from '../database/schema';
|
||||
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 { registerBlogHandlers } from './blogHandlers';
|
||||
|
||||
@@ -775,6 +776,15 @@ export function registerIpcHandlers(): void {
|
||||
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) => {
|
||||
const ownerWindow = BrowserWindow.fromWebContents(event.sender);
|
||||
const buttonPosition = ownerWindow?.getWindowButtonPosition?.();
|
||||
|
||||
113
src/main/main.ts
113
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<void> {
|
||||
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 {
|
||||
const systemLocale = typeof app.getLocale === 'function' ? app.getLocale() : 'en';
|
||||
const uiLanguage = resolveUiLanguageFromSystemLocale(systemLocale);
|
||||
@@ -633,9 +714,35 @@ async function initialize(): Promise<void> {
|
||||
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();
|
||||
|
||||
@@ -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<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)
|
||||
|
||||
125
src/main/shared/blogmark.ts
Normal file
125
src/main/shared/blogmark.ts
Normal 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;})();";
|
||||
}
|
||||
@@ -42,6 +42,7 @@ export interface ProjectMetadata {
|
||||
mainLanguage?: string;
|
||||
defaultAuthor?: string;
|
||||
maxPostsPerPage?: number;
|
||||
blogmarkCategory?: string;
|
||||
picoTheme?: import('./picoThemes').PicoThemeName;
|
||||
categoryMetadata?: Record<string, CategoryMetadata>;
|
||||
categorySettings?: Record<string, CategoryRenderSettings>;
|
||||
@@ -564,6 +565,8 @@ export interface ElectronAPI {
|
||||
selectFolder: (title?: string) => Promise<string | null>;
|
||||
getDefaultProjectPath: (projectId: string) => Promise<string>;
|
||||
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>;
|
||||
triggerMenuAction: (action: string) => Promise<void>;
|
||||
};
|
||||
@@ -577,7 +580,7 @@ export interface ElectronAPI {
|
||||
syncOnStartup: () => Promise<{ tags: string[]; categories: string[]; projectMetadata: ProjectMetadata | null }>;
|
||||
getProjectMetadata: () => 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: {
|
||||
getAll: () => Promise<TagData[]>;
|
||||
|
||||
Reference in New Issue
Block a user