diff --git a/DOCUMENTATION.md b/DOCUMENTATION.md index c54ded2..ce0ba13 100644 --- a/DOCUMENTATION.md +++ b/DOCUMENTATION.md @@ -9,6 +9,7 @@ - [Working with posts](#working-with-posts) - [Working with pages](#working-with-pages) - [Working with media](#working-with-media) +- [Working with translations](#working-with-translations) - [Using macros](#using-macros) - [Using scripting](#using-scripting) - [Using the AI assistant](#using-the-ai-assistant) @@ -143,18 +144,65 @@ The Media section is where you import, describe, and maintain assets used by pos When importing media, add metadata immediately while context is still fresh. Alt text should describe image meaning for accessibility, captions should support reader understanding, and media tags should help with retrieval and reuse. The goal is to make media usable both now and later, including by teammates who did not import the asset. +You can also insert images directly into the editor by dragging image files from the filesystem onto the post body, or by pasting screenshots from the clipboard. Dropped or pasted images are automatically imported into the media library, linked to the current post, and inserted as markdown images at the cursor position. Supported formats include JPEG, PNG, GIF, WebP, SVG, and BMP. Non-image files are ignored. If the import fails, bDS shows an error toast without inserting anything into the editor. + After placing media in content, run a quick preview pass to confirm placement and context. When possible, commit post changes and their related media changes together. This keeps history coherent and makes future rollback or investigation much easier. ### Key takeaways - Media management includes metadata quality, not only file import. - Add alt text and captions during import, not as a postponed task. +- Drag-and-drop or paste images directly into the post editor for quick insertion. - Commit content and related media in the same change when possible. [↑ Back to In this article](#in-this-article) --- +## Working with translations + +bDS supports translating both posts and media metadata into multiple languages. Translations are stored separately from canonical content so localized variants cannot drift into independent records with their own unrelated metadata. + +### Post translations + +Each post has a canonical language and can have translations for additional languages. Translations store their own title, excerpt, and content, while all other metadata (category, tags, status, slug, dates) stays on the canonical post. This keeps editorial control centralized. + +In the post editor, the **Translations** section shows the current post language as a dropdown and lists existing translations by language with status badges (draft, published, archived). Missing languages are shown so you can see at a glance which translations still need work. Click **Translate to...** to generate a translation using the AI assistant, or manually edit a translation by clicking its language badge. + +The **Do Not Translate** checkbox marks a post as single-language. Posts with this flag are excluded from automatic translation and omitted from alternative language subtrees during blog generation. + +Published translations follow the same filesystem rule as published posts: body content lives in the file, not in the database. Translation files use the naming pattern `slug.lang.md` (for example, `my-post.fr.md` for a French translation of `my-post.md`). + +### Media translations + +Media items can have translated metadata (title, alt text, caption) for each language. The binary asset remains shared — only the descriptive text varies by language. + +In the media editor, the **Translations** section shows the canonical media language as a dropdown. A **Detect Language** button uses AI to identify the language of the existing metadata. Existing translations are listed by language, and you can translate metadata to additional languages using the **Translate to...** button. + +Media translation sidecars use the naming pattern `filename.ext.lang.meta` (for example, `photo.jpg.fr.meta` for the French metadata of `photo.jpg`). + +### Automatic translation cascade + +When blog languages are configured in project settings, bDS can automatically translate content: + +- **On post create/update**: If the project has blog languages beyond the main language, missing translations are generated automatically for each active language (unless the post is marked Do Not Translate). +- **Post-to-media cascade**: After a post is translated, all images linked to that post are automatically translated to the same language if they don't already have a translation. This ensures rendered output never mixes languages. +- **Fill Missing Translations**: A batch tool under the Blog menu scans all published posts and their linked media for missing translations across all configured blog languages and fills them in. Use this after adding a new blog language or after importing content. + +Automatic translation requires AI to be configured and respects offline mode. Failures on individual items are reported via toast but do not block other translations from completing. + +### Key takeaways + +- Post translations store title, excerpt, and content separately from the canonical post. +- Media translations store title, alt text, and caption separately from the canonical media item. +- AI-powered translation is available for both posts and media metadata. +- Automatic cascading ensures posts and their linked media stay in sync across languages. +- Mark posts as Do Not Translate to exclude them from multi-language workflows. + +[↑ Back to In this article](#in-this-article) + +--- + ## Using macros Macros let you insert dynamic content blocks directly inside post/page Markdown by using `[[macro_name ...]]` syntax. bDS expands these macros during preview and generated output using local assets only. @@ -449,7 +497,7 @@ Commit messages should describe intent, not just activity. Messages such as “p ## Configuring settings -Settings define how your project behaves and should be treated as operational controls, not one-time preferences. Project settings establish identity, data location, and public URL context. Editor settings control default working mode and should match how your team writes and reviews content. Content settings support taxonomy consistency and reduce drift across contributors. +Settings define how your project behaves and should be treated as operational controls, not one-time preferences. Project settings establish identity, data location, public URL context, and blog languages for multi-language rendering. Editor settings control default working mode and should match how your team writes and reviews content. Content settings support taxonomy consistency and reduce drift across contributors. AI settings are optional. If configured, they can support drafting and analysis tasks, but core workflows remain fully functional without them. The key principle is that editorial reliability must never depend on optional integrations. @@ -555,6 +603,18 @@ Publishing in bDS is a two-stage process: first you generate the static site loc Full generation is appropriate when you first set up your site, after major template changes, or when you want a clean rebuild. For day-to-day content additions, site validation offers a faster alternative. +### Multi-language generation + +When **Blog Languages** are configured in project settings (for example, English and German), generation produces a fully navigable site in each activated language. The main language keeps the current flat route structure (no prefix). Each additional language gets its own `/{lang}/` prefixed subtree with the same archive routes, category pages, tag pages, and date routes. + +Each language subtree gets its own `rss.xml` and `atom.xml` feeds containing only posts available in that language, with URLs pointing into the language subtree. The root `sitemap.xml` lists all language variants and includes `hreflang` alternate links so search engines can discover the correct version for each user's language. + +Templates receive `blog_languages` and `current_language` in their context. Default templates render a language switcher bar linking to the same page in each available language. Posts marked **Do Not Translate** render only in the main language and are omitted from alternative language subtrees entirely. + +Shared assets (media, CSS, JavaScript) are not duplicated per language — only HTML output varies. This keeps the generated site compact while providing full multi-language navigation. + +The preview server serves the same language-prefixed routes, so you can verify multi-language output before deploying. For example, `/de/2025/03/08/my-post` shows the German translation in preview, matching the generated output exactly. + ### Site validation and incremental publishing After generating a site at least once, you can use **site validation** to detect what changed and re-render only the affected routes — without regenerating the entire site. @@ -586,6 +646,7 @@ The recommended lifecycle is: publish content locally (mark as published), gener ### Key takeaways - Full generation produces a complete static site; use it for initial builds or major changes. +- Multi-language blogs generate language-prefixed subtrees with per-language feeds and hreflang sitemap entries. - Site validation detects missing, extra, and updated pages by comparing the sitemap to generated HTML. - Apply resolves all validation issues by targeted re-rendering — much faster than full generation. - Use validate+apply as the standard incremental publishing workflow after creating or editing posts. diff --git a/README.md b/README.md index 20c8d2a..f5e8378 100644 --- a/README.md +++ b/README.md @@ -8,12 +8,15 @@ Implemented and actively used: - **Offline-first by default** — full local editing, preview, generation, and git-based workflows; all online features are optional - **Multi-project workflow** with active project switching and optional custom data paths -- **Post & page management** (draft/published/archived), pagination, filtering, multi-language full-text search (Snowball stemming), canonical URL handling, inter-post link graph with backlinks/outlinks +- **Post & page management** (draft/published/archived), pagination, filtering, multi-language full-text search (Snowball stemming), canonical URL handling, inter-post link graph with backlinks/outlinks, drag-and-drop and paste image insertion directly into the editor +- **Post translation system** — dedicated translation records per post (title, excerpt, content) stored separately from canonical posts; AI-powered translation via `translatePost`; translation panel in the editor showing existing translations, missing languages, and language badges; `availableLanguages` exposed across all APIs; `doNotTranslate` flag for posts that should remain single-language - **Media pipeline** with import, metadata sidecars, three thumbnail sizes (Sharp), linked-media references, cleanup tooling, and AI-assisted metadata suggestions (title, alt text, caption from image vision) +- **Media translation system** — language-specific metadata (title, alt, caption) stored in separate translation records and `.lang.meta` sidecars; AI-powered metadata translation with language detection; automatic cascade from post translation to linked media; translation panel in the media editor - **Tag management** including merge/rename/sync from content, color support - **Menu editor** for hierarchical site navigation (OPML-based), with page picker and submenu nesting - **WordPress WXR import pipeline** (analysis, import definitions, 4-phase execution with progress tracking, HTML-to-Markdown conversion) - **Blogmark deep links** — `bds://new-post` custom protocol for one-click bookmark saving from any browser, with bookmarklet generation and transform script pipeline +- **Multi-language blog rendering** — configurable `blogLanguages` list in project settings; main language keeps flat routes, additional languages get `/{lang}/` prefixed subtrees with their own feeds; combined `sitemap.xml` with `hreflang` alternates; language switcher in templates; automatic translation on post create/update; "Fill Missing Translations" batch tool; `doNotTranslate` posts excluded from alternative language subtrees; preview server serves language-prefixed routes matching generated output - **Static site generation** — full blog output with single-post pages, paginated category/tag/date archive routes, standalone page routes, plus `sitemap.xml`, `rss.xml`, `atom.xml`, and `calendar.json`; content-hash-based incremental writes - **Site validation** — compares sitemap against generated HTML, detects missing/extra/stale pages, and auto-repairs with targeted re-rendering - **SSH publishing** via `scp` or `rsync` with parallel upload of HTML, thumbnails, and media @@ -26,12 +29,12 @@ Implemented and actively used: - *Utility scripts* — general-purpose Python automation - **Built-in macros** — `youtube`, `vimeo`, `gallery` (lightbox), `photo_archive`, `tag_cloud` (D3 word cloud) - **Liquid template system** for all generated pages with i18n support (English, German, French, Italian, Spanish) and custom Markdown filter with internal link rewriting -- **AI assistant** (OpenCode Zen API, Mistral direct API, Ollama local models) with streaming responses, tool use (search/read/update posts, media analysis including image vision, blog stats), AI-powered taxonomy analysis, and rich interactive responses via the A2UI protocol (charts, tables, forms, cards, metrics, lists, tabs) +- **AI assistant** (OpenCode Zen API, Mistral direct API, Ollama local models) with streaming responses, tool use (search/read/update posts, media analysis including image vision, blog stats, post and media translation), AI-powered taxonomy analysis, and rich interactive responses via the A2UI protocol (charts, tables, forms, cards, metrics, lists, tabs) - **MCP server** — full Model Context Protocol integration exposing the blog to external coding agents; 10 tools, 13 resources, 3 prompts, and interactive review views; supports Streamable HTTP transport (in-app) and stdio transport (standalone CLI); auto-configures into Claude Code, Claude Desktop, GitHub Copilot, Gemini CLI, OpenCode, Mistral Vibe, and OpenAI Codex agent configs - **Model catalog** — fetches and caches model metadata from models.dev with conditional GET refresh; provides pricing, context windows, capabilities, and modalities; works fully offline after first fetch - **Secure key storage** — API keys encrypted via OS keychain (macOS Keychain, Windows DPAPI, Linux libsecret) using Electron safeStorage - **Git integration** (status, diff, commit history with sync status, fetch/pull/push, init, .gitignore management, LFS configure/prune, branch operations) -- **Metadata diff tooling** for comparing filesystem metadata vs database metadata with sync in either direction +- **Metadata diff tooling** for comparing filesystem metadata vs database metadata with sync in either direction, including post and media translation records - **Rich editing** — Milkdown WYSIWYG Markdown editor for posts, Monaco code editor for Python scripts and Liquid templates - **Task system** for background progress reporting across long-running operations with grouping, cancellation, and status bar integration - **In-app documentation** — rendered user guide with navigable table of contents and heading anchors @@ -46,8 +49,8 @@ src/ ├── main/ │ ├── database/ # Drizzle schema, migrations, connection │ ├── engine/ # Core business logic (no UI) -│ │ ├── PostEngine # Post CRUD, FTS indexing, link graph -│ │ ├── MediaEngine # Media files, Sharp thumbnails +│ │ ├── PostEngine # Post CRUD, FTS indexing, link graph, translations +│ │ ├── MediaEngine # Media files, Sharp thumbnails, translations │ │ ├── PostMediaEngine # Post ↔ media linking │ │ ├── ProjectEngine # Multi-project management │ │ ├── MetaEngine # Project metadata, categories @@ -55,15 +58,15 @@ src/ │ │ ├── ScriptEngine # Python script management & Git sync │ │ ├── TemplateEngine # Liquid template CRUD, validation, Git sync │ │ ├── MenuEngine # OPML-based navigation menus -│ │ ├── PreviewServer # Local HTTP preview with Liquid -│ │ ├── BlogGenerationEngine # Static site generation +│ │ ├── PreviewServer # Local HTTP preview with Liquid, language-prefixed routes +│ │ ├── BlogGenerationEngine # Static site generation, multi-language subtrees │ │ ├── PublishEngine # SSH upload (scp / rsync) │ │ ├── SiteValidationDiffService # Sitemap ↔ HTML validation │ │ ├── Import* engines # WXR parsing, analysis, execution │ │ ├── Blogmark* services # Deep link handling, transforms │ │ ├── PythonMacroWorkerRuntime # Pyodide macro execution │ │ ├── GitEngine # Git operations (simple-git) -│ │ ├── MetadataDiffEngine # DB ↔ file metadata comparison +│ │ ├── MetadataDiffEngine # DB ↔ file metadata comparison (posts, media, translations) │ │ ├── ChatEngine # AI chat with streaming & tool use │ │ ├── MCPServer # Model Context Protocol server (HTTP + stdio) │ │ ├── MCPAgentConfigEngine # Auto-configure MCP into coding agents diff --git a/TODO.md b/TODO.md deleted file mode 100644 index e50ffc1..0000000 --- a/TODO.md +++ /dev/null @@ -1,662 +0,0 @@ -# bDS — Remaining Feature Work - - - -This document covers the features described in VISION.md that are not yet -implemented. Each section is a self-contained plan that can be picked up -independently. - ---- - -## 1. Post Translation System - -### Goal - -Posts keep their canonical metadata in the main `posts` table. Translations are -stored separately so translated variants cannot drift into full independent -posts with their own unrelated metadata. The system must expose translation -availability everywhere posts are consumed. - -### Current State - -- No translation storage model yet. -- No translation relationship tracking yet. -- No translation-aware post metadata such as `availableLanguages` yet. -- No language/missing-language filtering in post query APIs yet. -- AI post language detection already exists via `chat:detectPostLanguage`. -- AI post analysis already exists via `chat:analyzePost` and already suggests - title, excerpt, and slug. -- Project-level `mainLanguage` already exists. - -### Implementation Plan - -#### 1.1 Database Schema - -Add a dedicated translations table instead of storing translations as normal -posts. - -Each translation row should contain only: - -- its own ID -- `translationFor` referencing the source post ID -- `language` -- `title` -- `excerpt` -- `content` for draft translations only -- normal timestamps/status fields needed for lifecycle management - -Published translations follow the same rule as published posts: body content is -not stored in the database and is read from the filesystem. - -#### 1.2 YAML Frontmatter - -Store translation files separately but in the same general markdown + YAML -frontmatter style as posts, with only the supported translation fields: - -```yaml -translationFor: -language: fr -title: ... -excerpt: ... -``` - -Draft translation files include markdown body content. Published translation -files keep content only in the file, not in the database. File naming is based -on the source post slug plus the language code, for example: - -- `this-slug.md` for the source post -- `this-slug.fr.md` for the French translation - -#### 1.3 PostEngine Extensions - -Add translation-aware storage and lookup methods instead of treating -translations as regular posts. - -- Create/read/update/publish translation records. -- Resolve translations for a post by source post ID. -- Prevent duplicate translations for the same `(translationFor, language)` pair. -- Keep source post metadata authoritative; translations only override the fields - they actually own. -- Keep `getPost(id)` and `getPostBySlug(slug)` canonical-only. -- Add explicit translation reads such as `getPostTranslation(postId, language)` - and `getPostTranslations(postId)` instead of overloading `getPost()` with an - optional language parameter. -- If callers need "best variant for language X", add a separate higher-level - resolver rather than changing the semantics of the base post APIs. - -Post APIs should expose an `availableLanguages` meta field derived from the -translations table for every source post. - -#### 1.4 AI Translation Tools in OpenCodeManager - -Add translation generation on top of the existing one-shot AI tooling. - -**`translatePost(postId, targetLanguage)`** - -- Read the source post's full content plus title/excerpt. -- Return translated `title`, `excerpt`, and markdown `content`. -- Create or update a translation record/file from the returned data. -- After the post translation is persisted, cascade to linked media: for every - image linked to the source post that does not already have a translation for - `targetLanguage`, call `translateMediaMetadata(mediaId, targetLanguage)` (see - §2.5) to keep the post and its images in the same set of languages. - -Language detection and excerpt suggestion already exist; this step is only -about translation-specific tooling. - -#### 1.5 Import Pipeline Integration - -Integrate with the existing import flow without redefining source posts as -translations. - -1. Call `detectPostLanguage()` to set the `language` field. -2. Optionally queue translation generation for configured target languages. -3. Persist generated results as translation records/files linked via - `translationFor`. - -This is optional and should be configurable per import definition (a checkbox -"Auto-detect language and translate" in `ImportAnalysisView`). - -#### 1.6 API Surface - -Expose translation metadata consistently across all post consumers: - -- Templates and Python scripts can read `post.meta.availableLanguages`. -- Internal AI tools can inspect available translation languages. -- MCP post APIs return the same `availableLanguages` data. -- Python post-query APIs support filtering by `language` and by missing - translation language, so callers can ask for posts available in French or - posts missing Spanish. -- The same language and missing-language filters must be available to internal - AI tools and MCP server queries. - -#### 1.7 UI — Translation Panel - -In the post editor metadata area, add a "Translations" section: - -- Show current post language (dropdown to change). -- List existing translations by language. -- "Translate to..." creates or refreshes the separate translation record. -- Show which configured languages are still missing. - -In the sidebar post list, optionally show a language badge per post. - -#### 1.8 Publishing Pipeline - -In `PageRenderer` and `BlogGenerationEngine`: - -- Resolve alternate-language links from the translations table. -- Add `hreflang` metadata and language switcher data from DB-backed - translation availability. -- Include alternate language entries in sitemap generation. - ---- - -## 2. Media Translation System - -### Goal - -Media files keep their canonical metadata in the main `media` table and main -sidecar. Translations are stored separately so localized metadata cannot drift -into independent media records. The binary asset remains shared; only the -language-specific metadata varies. - -### Current State - -- No media translation storage model yet. -- No translation relationship tracking for media yet. -- No translation-aware media metadata such as `availableLanguages` yet. -- No language/missing-language filtering in media query APIs yet. -- No explicit language tracking on canonical media metadata yet. -- AI media analysis already exists via `chat:analyzeMediaImage` and already - suggests title, alt text, and caption in a requested language. -- Main media metadata already uses DB + sidecar persistence. - -### Implementation Plan - -#### 2.1 Database Schema - -Add a `language` column to the `media` table (optional text, ISO code such as -`'en'`, `'de'`). This records what language the canonical `title`, `alt`, and -`caption` are written in. When null, the project `mainLanguage` is assumed. -Persist the value in the canonical sidecar as well. - -Add a dedicated media translations table instead of storing localized metadata -inside the canonical `media` row. - -Each translation row should contain only: - -- its own ID -- `translationFor` referencing the source media ID -- `language` -- `title` -- `alt` -- `caption` -- normal timestamps needed for lifecycle and sync - -The binary media file remains the canonical original file. Translation rows do -not duplicate the asset itself. - -#### 2.2 Sidecar Format - -Keep the canonical sidecar for canonical metadata, and add language-specific -sidecars for translated metadata only. - -Canonical sidecar stays with the original media file, for example: - -- `image.jpg.meta` - -Translated metadata sidecars use the source filename plus the language code, -for example: - -- `image.jpg.fr.meta` - -Translated sidecars should contain only the supported translation fields: - -```yaml -translationFor: -language: fr -title: ... -alt: ... -caption: ... -``` - -#### 2.3 MediaEngine Extensions - -Add translation-aware storage and lookup methods instead of treating media -translations as separate media items. - -- Create/read/update translation records and translated sidecars. -- Resolve translations for a media item by source media ID. -- Prevent duplicate translations for the same `(translationFor, language)` pair. -- Keep source media metadata authoritative; translations only override - `title`, `alt`, and `caption`. -- Keep `getMedia(id)` canonical-only. -- Add explicit translation reads such as `getMediaTranslation(mediaId, - language)` and `getMediaTranslations(mediaId)` instead of overloading - `getMedia()` with an optional language parameter. -- If callers need "best variant for language X", add a separate higher-level - resolver rather than changing the semantics of the base media APIs. - -Media APIs should expose an `availableLanguages` meta field derived from the -translations table for every canonical media item. - -#### 2.4 AI Translation Tools - -Add media-metadata translation on top of the existing one-shot AI tooling. - -**`translateMediaMetadata(mediaId, targetLanguage)`** - -- Read the source media metadata plus image context needed for a faithful - translation. -- Determine source language from `media.language` (falling back to the - project `mainLanguage`). -- Return translated `title`, `alt`, and `caption`. -- Create or update a translation record/sidecar from the returned data. - -**`detectMediaLanguage(mediaId)`** - -- Read the canonical `title`, `alt`, and `caption` of a media item. -- Use the same lightweight title model and detection pattern as - `detectPostLanguage`. -- Return the detected ISO language code. -- Optionally persist the result to `media.language` if the caller requests it. - -AI media analysis already exists; these steps are only about -language-detection and translation-specific tooling. - -#### 2.5 Post-Triggered Media Translation Cascade - -When a post is translated, all images linked to that post should be translated -automatically so rendered output never mixes languages. - -**Trigger**: After `translatePost(postId, targetLanguage)` successfully -persists a post translation (§1.4), the system resolves all media linked to -the source post via the `postMedia` junction table. - -**For each linked media item**: - -1. Check whether the media already has a translation for `targetLanguage` - (via `getMediaTranslation(mediaId, targetLanguage)`). -2. If a translation already exists, skip — the image is already covered. -3. If no translation exists, call `translateMediaMetadata(mediaId, - targetLanguage)` (§2.4) to generate and persist the translated `title`, - `alt`, and `caption`. - -**Design constraints**: - -- The cascade is additive only — it never overwrites an existing media - translation. Users who independently translate an image via quick action - keep their version. -- Images can still be translated independently at any time through their own - quick action or the media translation panel (§2.7). The cascade merely - ensures coverage; it does not create a hard coupling. -- Failures on individual media translations should be logged but must not - block the post translation from succeeding. Report partial failures to the - UI so the user can retry individual images. -- The cascade runs after the post translation is committed, not inside the - same transaction, so a media-translation failure never rolls back post - work. - -#### 2.6 Import And Sync Integration - -Integrate with the existing media import and metadata sync flow without -creating translated duplicate media records. - -1. Import the binary asset once as the canonical media item. -2. Optionally queue metadata translation generation for configured target - languages. -3. Persist generated results as translation records/sidecars linked via - `translationFor`. -4. Extend metadata diff/sync tooling so canonical and translated sidecars can - both be compared against the database safely. - -#### 2.7 API Surface - -Expose translation metadata consistently across all media consumers: - -- Templates and Python scripts can read `media.meta.availableLanguages`. -- Internal AI tools can inspect available translation languages. -- MCP media APIs return the same `availableLanguages` data. -- Python media-query APIs support filtering by `language` and by missing - translation language, so callers can ask for media with French metadata or - media missing Spanish metadata. -- The same language and missing-language filters must be available to internal - AI tools and MCP server queries. - -#### 2.8 UI — Translation Panel - -In the media editor/details area, add a "Translations" section: - -- Show the canonical media language as a dropdown (same UX as the post - language selector). Changing it updates `media.language`. -- Provide a "Detect Language" button that calls `detectMediaLanguage` and - updates the dropdown. -- List existing metadata translations by language. -- "Translate to..." creates or refreshes the separate translation record. -- Show which configured languages are still missing. - -Media list/detail views can optionally show a language-availability badge. - -#### 2.9 Rendering And Asset Use - -When media metadata is consumed during rendering or editing: - -- Resolve localized `title`, `alt`, and `caption` from the translations table - when a language-specific variant is requested. -- Fall back to canonical metadata when no translation exists. -- Keep URLs and binary asset references stable; only metadata changes by - language. - ---- - -## 3. Drag-and-Drop Image Insertion - -### Goal - -Users can drag image files from the filesystem onto the editor to insert them. -Dropped files are automatically imported into the media library and inserted -as markdown images. - -### Current State - -- Images are inserted only via `InsertModal` (browse media library or enter - URL). -- `MediaEngine.importMedia(sourcePath)` handles file import, thumbnail - generation, and database indexing. -- `imageResolverPlugin` already converts relative media paths to `bds-media://` - protocol URLs for editor display. -- `LinkedMediaPanel` has working drag-drop for reordering (reference pattern). -- `insertImageCommand` from Milkdown inserts image nodes into the editor. - -### Implementation Plan - -#### 3.1 ProseMirror Drop Plugin - -Create a new plugin in `src/renderer/plugins/dropImagePlugin.ts` following the -`imageResolverPlugin` pattern: - -```typescript -// Pseudo-structure -export const dropImagePlugin = $prose(() => { - return new Plugin({ - props: { - handleDOMEvents: { - drop: (view, event) => { - // 1. Check for files in dataTransfer - // 2. Filter to image types - // 3. Get file paths (Electron exposes .path on File objects) - // 4. For each file: import via IPC, insert into editor - // 5. Return true to prevent default - }, - dragover: (view, event) => { - // Show drop indicator if files are images - } - } - } - }); -}); -``` - -#### 3.2 Drop Handler Flow - -For each dropped file: - -1. **Validate** — check file extension against supported image types (jpg, - png, gif, webp, svg, bmp). -2. **Import** — call `window.electronAPI.media.import(file.path)`. This returns - `MediaData` with the media ID and file path. -3. **Insert** — use `insertImageCommand` with `{ src: relativePath, alt: '' }` - where `relativePath` is the media's storage path (e.g., - `media/2025/01/uuid.jpg`). -4. **Link** — call `window.electronAPI.postMedia.link(postId, mediaId)` to - track the relationship. -5. **Resolve** — the existing `imageResolverPlugin` will automatically convert - the relative path to a `bds-media://` URL for display. - -#### 3.3 Visual Feedback - -- On `dragover` with image files: add a CSS class to the editor container - showing a drop zone indicator (border highlight or overlay). -- On `dragleave` / `drop`: remove the indicator. -- During import (for large files): show a small inline spinner or toast. - -#### 3.4 Integration into MilkdownEditor - -In `MilkdownEditor.tsx`, register the new plugin alongside existing plugins: - -```typescript -import { dropImagePlugin } from '../../plugins/dropImagePlugin'; - -// In the editor setup, add to the plugin list -.use(dropImagePlugin) -``` - -Pass `postId` and the import callback to the plugin via the editor context or -a shared ref. - -#### 3.5 Paste Support (Optional Extension) - -The same plugin can handle `paste` events with image files: - -- Check `clipboardData.files` for images. -- Same import → insert → link flow as drop. -- This handles screenshots pasted from the clipboard. - -#### 3.6 Error Handling - -- Non-image files: ignore silently (don't prevent default, let editor handle - text drops normally). -- Import failure: show toast with error message, don't insert anything. -- Multiple files: process sequentially, insert at cursor position for first, - then append after each previous insertion. - -#### 3.7 Testing - -- Unit test the plugin's file validation logic. -- Integration test: mock `electronAPI.media.import`, verify correct calls and - editor state after drop. -- Test edge cases: non-image files, failed imports, multiple simultaneous - drops. - ---- - -## 4. Multi-Language Blog Rendering (Phase 2) - -### Goal - -The generated blog is fully navigable in each activated language. Every -language gets its own route subtree (`/en/`, `/de/`, …), its own feeds, and -its own sitemap entries. Media assets are shared; only HTML differs. The -preview server must serve the same language-prefixed routes so the user can -verify output before uploading. - -### Current State - -- Post and media translation schemas, CRUD, AI translation, and validation - already exist (§1, §2). -- `PageRenderer` already accepts `preferredLanguage` and resolves translations - via `resolveRenderablePost()` and `getMediaTranslation()`. -- `BlogGenerationEngine` builds translation variants with `.lang` slug - suffixes but writes everything to a flat `html/` directory — no - language-prefixed subtrees. -- `PreviewServer` supports a `?lang=` query parameter but has no - language-prefixed routes. -- `ProjectMetadata` has `mainLanguage` but no `blogLanguages` list. -- No `doNotTranslate` flag on posts. -- No automatic translation on post create/update. -- No "Fill missing translations" batch tool. - -### Implementation Plan - -#### 4.0 Extract `SUPPORTED_POST_LANGUAGES` Constant - -The list of supported post languages is currently hardcoded inline in AI -task files (e.g. `['en', 'de', 'fr', 'it', 'es']`). Extract it into a -shared constant in `src/main/shared/` (or similar) so that both AI tasks -and the Blog Languages UI (§4.1) reference a single source of truth. - -#### 4.1 Project Preferences — Blog Languages - -Add `blogLanguages?: string[]` to `ProjectMetadata`. This is the list of -languages the blog is rendered in (e.g. `['en', 'de']`). The `mainLanguage` -is always implicitly included. When `blogLanguages` is empty or absent, the -blog renders in `mainLanguage` only (current behaviour). - -**UI**: Add a "Blog Languages" multi-select in the Project Settings panel, -populated from `SUPPORTED_POST_LANGUAGES`. The main language is shown but -cannot be removed. i18n keys: `settings.project.blogLanguagesLabel`, -`settings.project.blogLanguagesDescription`. - -#### 4.2 Do-Not-Translate Flag - -Add a boolean `doNotTranslate` column to the `posts` table (default false). -Persist in YAML frontmatter as `doNotTranslate: true`. Migration required. - -**UI**: Checkbox in the post editor metadata area, labelled via i18n -(`editor.doNotTranslateLabel`). - -**Validate Translations** must detect posts marked `doNotTranslate` that -still have translations and offer to remove them. - -#### 4.3 Automatic Translation on Post Create/Update - -When a canonical post is created or updated and `blogLanguages` contains -languages beyond `mainLanguage`: - -1. For each active blog language missing a translation (skip if - `doNotTranslate` is set), enqueue a `TaskManager` task calling - `chat:translatePost`. -2. On success, show a toast ("Translated to French"). On failure, show an - error toast. Task progress is visible in the task panel. -3. Only canonical content changes trigger re-translation. Editing a - translation directly does **not** re-trigger anything. -4. After each post translation succeeds, cascade to linked media: for every - media item linked via `postMedia` that lacks a translation for the target - language, enqueue `chat:translateMediaMetadata`. - -#### 4.4 Fill Missing Translations (Blog Menu Tool) - -Add a "Fill Missing Translations" menu item under the Blog menu. - -1. Scan all published posts (excluding `doNotTranslate`) and all linked media - for missing translations across `blogLanguages`. -2. Create one task for post translations and a second task for media metadata - translations. -3. Report progress and partial failures via the task panel and toasts. -4. This is separate from Validate Translations — validate checks consistency, - fill adds missing content. - -#### 4.5 Route Generation — Main Language Flat, Alternatives Prefixed - -The main language keeps the current flat route structure. Only additional -blog languages get a language-prefixed subtree. This means single-language -blogs see zero change from today's output. - -``` -html/ - index.html ← main language (flat, same as today) - page/2/index.html - 2025/03/08/my-post/index.html - category/tech/index.html - tag/rust/index.html - rss.xml - atom.xml - de/ ← additional blog language subtree - index.html - page/2/index.html - 2025/03/08/my-post/index.html - category/tech/index.html - tag/rust/index.html - rss.xml - atom.xml - sitemap.xml ← combined, with hreflang alternates - media/ ← shared, not duplicated - assets/ ← shared, not duplicated -``` - -For the main language pass, generation works exactly as today — no prefix, -no routing changes. For each additional language in `blogLanguages`: - -- Iterate the same route list, writing output under `/{lang}/…`. -- Resolve every post through `resolveRenderablePost(post, engine, lang)`. - If no translation exists, fall back to canonical content. -- Same for media metadata in macros: `getMediaTranslation(id, lang)` with - canonical fallback. -- All internal links within a language subtree stay prefixed (`/de/…` links - to `/de/…`). Main-language links remain unprefixed (`/2025/…`). -- Posts marked `doNotTranslate` render only in the main language output. - They are omitted from alternative language subtrees entirely. - -#### 4.6 Per-Language Feeds - -The main language feeds (`rss.xml`, `atom.xml`) stay at the root as today. -Each alternative language subtree gets its own `rss.xml` and `atom.xml` -under `/{lang}/`, containing only posts available in that language, with -URLs pointing into the language subtree. Feed `` / `xml:lang` is -set to the subtree language. - -#### 4.7 Combined Sitemap with hreflang - -The root `sitemap.xml` lists all language variants of every URL. Each `` -entry includes `` for -every language the post is available in, plus `x-default` pointing to the -main language variant. - -#### 4.8 Language Switcher in Templates - -Add a `blogLanguages` array and `currentLanguage` string to the Liquid -template context. Default templates render a language switcher bar (flag -badges) at the top linking to the same page in each available language. - -The switcher links are absolute paths — unprefixed for the main language -(`/2025/03/08/my-post/`) and prefixed for alternatives -(`/de/2025/03/08/my-post/`) — so they work regardless of route depth. - -#### 4.9 Preview Server — Language-Prefixed Routes - -Extend `PreviewServer` to handle language-prefixed paths for alternative -languages so preview matches the generated output: - -- `GET /2025/03/08/my-post` → render post in main language (unchanged). -- `GET /de/category/tech` → render category list in German. -- For paths starting with a known alternative language prefix, strip it and - pass the language as `preferredLanguage` to `renderRouteForContext()`. -- Unprefixed paths use `mainLanguage` (current behaviour, no change). -- Keep the existing `?lang=` parameter as a fallback for single-post preview - from the editor. -- Language switcher links in preview HTML work because they use the same - prefix scheme as generation. - -This ensures the user sees the exact same route structure and language -switching behaviour in preview as in the generated output. - -#### 4.10 Preview/Generation Parity Checklist - -Both preview and generation must produce identical output for: - -- [ ] Main language routes remain flat/unprefixed. -- [ ] Alternative language routes use `/{lang}/…` prefix. -- [ ] Post content: translated title, excerpt, body with canonical fallback. -- [ ] Media metadata in macros (gallery, photo_album): translated alt/title/ - caption with canonical fallback. -- [ ] Internal links: unprefixed for main language, prefixed for alternatives. -- [ ] Language switcher rendering with correct cross-language links. -- [ ] Per-language feed links in HTML ``. -- [ ] `doNotTranslate` posts omitted from alternative language subtrees. -- [ ] Root `/` renders main language content (unchanged from today). - -Shared implementation: both paths go through `SharedRouteRenderer` → -`PageRenderer`, so language handling logic added there automatically applies -to both preview and generation. The key change is making -`SharedRouteRenderer` language-prefix-aware and ensuring -`BlogGenerationEngine` iterates over `blogLanguages` when building routes. - -#### 4.11 Testing - -- **Unit**: Route prefix stripping, language fallback resolution, feed - language filtering, `doNotTranslate` exclusion, sitemap hreflang building. -- **Integration**: End-to-end generation with two languages produces correct - subtree structure, shared assets, per-language feeds, combined sitemap. -- **Preview parity**: Same route in preview and generation produces identical - HTML (modulo asset URLs). diff --git a/src/main/engine/MediaEngine.ts b/src/main/engine/MediaEngine.ts index 71e960d..0bb9e6f 100644 --- a/src/main/engine/MediaEngine.ts +++ b/src/main/engine/MediaEngine.ts @@ -522,33 +522,31 @@ export class MediaEngine extends EventEmitter { return mimeTypes[ext] || 'application/octet-stream'; } - async importMedia(sourcePath: string, metadata?: Partial): Promise { + private async importMediaBytes(sourceBuffer: Buffer, originalName: string, metadata?: Partial, sourcePath?: string): Promise { const db = getDatabase().getLocal(); const id = uuidv4(); const now = new Date(); - - // Use provided createdAt date or current date + const createdAt = metadata?.createdAt ?? now; const updatedAt = metadata?.updatedAt ?? now; - - const sourceBuffer = await fs.readFile(sourcePath); - const originalName = path.basename(sourcePath); + const ext = path.extname(originalName); const filename = `${id}${ext}`; - - // Use date-based directory structure (media/YYYY/MM/) based on createdAt + const mediaDir = this.getMediaDirForDate(createdAt); await fs.mkdir(mediaDir, { recursive: true }); const destPath = path.join(mediaDir, filename); - // Copy file to media directory - await fs.copyFile(sourcePath, destPath); + if (sourcePath) { + await fs.copyFile(sourcePath, destPath); + } else { + await fs.writeFile(destPath, sourceBuffer); + } const mimeType = metadata?.mimeType || this.getMimeType(originalName); let width = metadata?.width; let height = metadata?.height; - // Get image dimensions using sharp if it's an image if (mimeType.startsWith('image/') && !mimeType.includes('svg')) { try { const sharp = (await import('sharp')).default; @@ -582,7 +580,6 @@ export class MediaEngine extends EventEmitter { const sidecarPath = await this.writeSidecarFile(mediaData, destPath); const checksum = this.calculateChecksum(sourceBuffer); - // Generate thumbnails for images (async, non-blocking) if (mimeType.startsWith('image/') && !mimeType.includes('svg')) { this.generateThumbnails(id, destPath).catch(err => { console.error('Failed to generate thumbnails:', err); @@ -613,7 +610,6 @@ export class MediaEngine extends EventEmitter { await db.insert(media).values(dbMedia); - // Update FTS index await this.updateFTSIndex({ id: mediaData.id, projectId: this.currentProjectId, @@ -629,6 +625,16 @@ export class MediaEngine extends EventEmitter { return mediaData; } + async importMedia(sourcePath: string, metadata?: Partial): Promise { + const sourceBuffer = await fs.readFile(sourcePath); + const originalName = path.basename(sourcePath); + return this.importMediaBytes(sourceBuffer, originalName, metadata, sourcePath); + } + + async importMediaBuffer(sourceBytes: Uint8Array, originalName: string, metadata?: Partial): Promise { + return this.importMediaBytes(Buffer.from(sourceBytes), originalName, metadata); + } + async updateMedia(id: string, data: Partial): Promise { const db = getDatabase().getLocal(); const existing = await this.getMedia(id); diff --git a/src/main/ipc/chatHandlers.ts b/src/main/ipc/chatHandlers.ts index 2570a9a..259b538 100644 --- a/src/main/ipc/chatHandlers.ts +++ b/src/main/ipc/chatHandlers.ts @@ -973,6 +973,18 @@ export async function autoTranslatePost(postId: string, targetLanguage: string, } } +/** + * Analyze a media image for auto-analysis workflows (called from IPC handlers). + */ +export async function autoAnalyzeMediaImage(mediaId: string, language: string): Promise<{ success: boolean; title?: string; alt?: string; caption?: string; error?: string }> { + try { + await ensureInitialized(); + return await retryWithBackoff(() => getOneShotTasks().analyzeMediaImage(mediaId, language)); + } catch (error) { + return { success: false, error: (error as Error).message }; + } +} + /** * Translate media metadata for auto-translation workflows (called from event handlers). */ diff --git a/src/main/ipc/handlers.ts b/src/main/ipc/handlers.ts index cecb230..528167f 100644 --- a/src/main/ipc/handlers.ts +++ b/src/main/ipc/handlers.ts @@ -21,9 +21,12 @@ import { registerEmbeddingHandlers } from './embeddingHandlers'; import { isOfflineModeActive } from './chatHandlers'; import type { EngineBundle } from '../engine/EngineBundle'; import { resolveUiLanguageFromSystemLocale, translateMenu } from '../shared/i18n'; -import { autoTranslatePost, autoTranslateMediaMetadata } from './chatHandlers'; +import { autoTranslatePost, autoTranslateMediaMetadata, autoAnalyzeMediaImage } from './chatHandlers'; import { v4 as uuidv4 } from 'uuid'; +const SUPPORTED_DROP_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.gif', '.webp', '.svg', '.bmp']); +const SUPPORTED_DROP_IMAGE_MIME_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml', 'image/bmp']); + /** * Wrap an IPC handler so that "Database is closing" errors during shutdown * are silently swallowed instead of being logged as scary red error messages. @@ -127,6 +130,88 @@ function runWebContentsMenuAction(sender: any, action: AppMenuAction): boolean { } } +function assertSupportedDropImage(params: { filePath?: string; fileName?: string; mimeType?: string }): void { + const mimeType = params.mimeType?.trim().toLowerCase(); + if (mimeType && SUPPORTED_DROP_IMAGE_MIME_TYPES.has(mimeType)) { + return; + } + + const nameForExtension = params.filePath || params.fileName || ''; + const extension = path.extname(nameForExtension).toLowerCase(); + if (SUPPORTED_DROP_IMAGE_EXTENSIONS.has(extension)) { + return; + } + + throw new Error('Only image files can be imported into the media library from drag-and-drop or paste.'); +} + +async function handleDroppedImageImport( + bundle: EngineBundle, + postId: string, + importMedia: (metadata: Partial) => Promise, +): Promise<{ mediaId: string; alt: string; relativePath: string }> { + const mediaEngine = bundle.mediaEngine; + const postMediaEngine = bundle.postMediaEngine; + const postEngine = bundle.postEngine; + const metaEngine = bundle.metaEngine; + + const projectMetadata = await metaEngine.getProjectMetadata(); + const mainLanguage = projectMetadata?.mainLanguage || 'en'; + const blogLanguages: string[] = projectMetadata?.blogLanguages || []; + + const metadata: Partial = {}; + if (projectMetadata?.defaultAuthor) { + metadata.author = projectMetadata.defaultAuthor; + } + metadata.language = mainLanguage; + + const importedMedia = await importMedia(metadata); + + const db = getDatabase().getLocal(); + const dbMedia = await db.select().from(media).where(eq(media.id, importedMedia.id)).get(); + if (dbMedia?.filePath && importedMedia.mimeType.startsWith('image/') && !importedMedia.mimeType.includes('svg')) { + try { + await mediaEngine.generateThumbnails(importedMedia.id, dbMedia.filePath); + } catch { + // Non-critical: AI analysis will fall back to other thumbnail sizes + } + } + + await postMediaEngine.linkMediaToPost(postId, importedMedia.id); + + let alt = importedMedia.alt || ''; + const analysis = await autoAnalyzeMediaImage(importedMedia.id, mainLanguage); + if (analysis.success) { + await mediaEngine.updateMedia(importedMedia.id, { + title: analysis.title, + alt: analysis.alt, + caption: analysis.caption, + language: mainLanguage, + }); + alt = analysis.alt || alt; + + const otherLanguages = blogLanguages.filter(lang => lang !== mainLanguage); + for (const targetLang of otherLanguages) { + await autoTranslateMediaMetadata(importedMedia.id, targetLang).catch(() => { + // Non-critical: translation failure shouldn't block the drop + }); + } + } + + const post = await postEngine.getPost(postId); + if (post?.status === 'published') { + await postEngine.updatePost(postId, { content: post.content || '', status: 'draft' }); + } + + const relativePath = await mediaEngine.getRelativePath(importedMedia.id); + + return { + mediaId: importedMedia.id, + alt, + relativePath: relativePath || `media/${importedMedia.filename}`, + }; +} + function buildMcpAgentConfigOptions(bundle: EngineBundle): import('../engine/MCPAgentConfigEngine').MCPAgentConfigOptions { const os = require('os') as typeof import('os'); const scriptPath = app.isPackaged @@ -1331,7 +1416,7 @@ export function registerIpcHandlers(bundle: EngineBundle): void { return engine.getProjectMetadata(); }); - safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('../shared/picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record; semanticSimilarityEnabled?: boolean }) => { + safeHandle('meta:updateProjectMetadata', async (_, updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('../shared/picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record; semanticSimilarityEnabled?: boolean; blogLanguages?: string[] }) => { const engine = bundle.metaEngine; await ensureMetaContext(engine); const previousMetadata = await engine.getProjectMetadata(); @@ -1470,6 +1555,19 @@ export function registerIpcHandlers(bundle: EngineBundle): void { return engine.importMediaForPost(postId, filePath); }); + safeHandle('postMedia:dropImport', async (_, postId: string, filePath: string) => { + assertSupportedDropImage({ filePath }); + return handleDroppedImageImport(bundle, postId, (metadata) => bundle.mediaEngine.importMedia(filePath, metadata)); + }); + + safeHandle('postMedia:dropImportBuffer', async (_, postId: string, payload: { fileName: string; mimeType: string; bytes: Uint8Array }) => { + assertSupportedDropImage({ fileName: payload.fileName, mimeType: payload.mimeType }); + return handleDroppedImageImport(bundle, postId, (metadata) => bundle.mediaEngine.importMediaBuffer(payload.bytes, payload.fileName, { + ...metadata, + mimeType: payload.mimeType, + })); + }); + safeHandle('postMedia:rebuild', async () => { const engine = bundle.postMediaEngine; return engine.rebuildFromSidecars(); diff --git a/src/main/preload.ts b/src/main/preload.ts index b15c15e..3fae8eb 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -149,6 +149,8 @@ export const electronAPI: ElectronAPI = { reorder: (postId: string, mediaIds: string[]) => ipcRenderer.invoke('postMedia:reorder', postId, mediaIds), isLinked: (postId: string, mediaId: string) => ipcRenderer.invoke('postMedia:isLinked', postId, mediaId), import: (postId: string, filePath: string) => ipcRenderer.invoke('postMedia:import', postId, filePath), + dropImport: (postId: string, filePath: string) => ipcRenderer.invoke('postMedia:dropImport', postId, filePath), + dropImportBuffer: (postId: string, payload: { fileName: string; mimeType: string; bytes: Uint8Array }) => ipcRenderer.invoke('postMedia:dropImportBuffer', postId, payload), rebuild: () => ipcRenderer.invoke('postMedia:rebuild'), }, @@ -201,7 +203,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; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./shared/picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record; semanticSimilarityEnabled?: boolean }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates), + updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./shared/picoThemes').PicoThemeName; categoryMetadata?: Record; categorySettings?: Record; semanticSimilarityEnabled?: boolean; blogLanguages?: string[] }) => ipcRenderer.invoke('meta:updateProjectMetadata', updates), getPublishingPreferences: () => ipcRenderer.invoke('meta:getPublishingPreferences'), setPublishingPreferences: (prefs: { sshHost: string; sshUser: string; sshRemotePath: string; sshMode: 'scp' | 'rsync' }) => ipcRenderer.invoke('meta:setPublishingPreferences', prefs), clearPublishingPreferences: () => ipcRenderer.invoke('meta:clearPublishingPreferences'), diff --git a/src/main/shared/electronApi.ts b/src/main/shared/electronApi.ts index 0b38412..b774099 100644 --- a/src/main/shared/electronApi.ts +++ b/src/main/shared/electronApi.ts @@ -55,6 +55,13 @@ export interface ProjectMetadata { categoryMetadata?: Record; categorySettings?: Record; semanticSimilarityEnabled?: boolean; + blogLanguages?: string[]; +} + +export interface DropImportBufferPayload { + fileName: string; + mimeType: string; + bytes: Uint8Array; } export interface CategoryRenderSettings { @@ -769,6 +776,8 @@ export interface ElectronAPI { reorder: (postId: string, mediaIds: string[]) => Promise; isLinked: (postId: string, mediaId: string) => Promise; import: (postId: string, filePath: string) => Promise; + dropImport: (postId: string, filePath: string) => Promise<{ mediaId: string; alt: string; relativePath: string }>; + dropImportBuffer: (postId: string, payload: DropImportBufferPayload) => Promise<{ mediaId: string; alt: string; relativePath: string }>; rebuild: () => Promise; }; sync: { diff --git a/src/renderer/components/Editor/Editor.css b/src/renderer/components/Editor/Editor.css index 233260d..d89a487 100644 --- a/src/renderer/components/Editor/Editor.css +++ b/src/renderer/components/Editor/Editor.css @@ -278,6 +278,13 @@ min-height: 300px; } +/* Drop zone visual feedback for markdown editor */ +.editor-body.drop-target-active { + outline: 2px dashed var(--vscode-focusBorder, #007acc); + outline-offset: -2px; + background-color: color-mix(in srgb, var(--vscode-focusBorder, #007acc) 8%, transparent); +} + .editor-body label { font-size: 11px; font-weight: 500; diff --git a/src/renderer/components/Editor/PostEditor.tsx b/src/renderer/components/Editor/PostEditor.tsx index 79b8a90..73fe353 100644 --- a/src/renderer/components/Editor/PostEditor.tsx +++ b/src/renderer/components/Editor/PostEditor.tsx @@ -15,6 +15,7 @@ import { useEntityLoader, useSaveShortcut } from '../../navigation/useEntityEdit import { useI18n } from '../../i18n'; import { SUPPORTED_POST_LANGUAGES, POST_LANGUAGE_FLAGS } from '../../../main/shared/i18n'; import { UI_DATE_LOCALE, getMediaDisplayName } from './editorUtils'; +import { dropImageContext, hasImageFiles, importImageFile } from '../../plugins/dropImagePlugin'; function useDebouncedValue(value: T, delay: number): T { const [debounced, setDebounced] = useState(value); @@ -821,6 +822,125 @@ export const PostEditor: React.FC = ({ postId }) => { }); }, []); + // ── Drag-and-drop image import handler ────────────────────────────── + + const runDropImport = useCallback(async (request: () => Promise<{ mediaId: string; alt: string; relativePath: string } | null | undefined>): Promise<{ mediaId: string; alt: string; relativePath: string } | null> => { + try { + showToast.info(tr('editor.dropImage.importing')); + const result = await request(); + if (result) { + showToast.success(tr('editor.dropImage.success')); + return result; + } + return null; + } catch (error) { + console.error('Drop import failed:', error); + showToast.error(tr('editor.dropImage.failed')); + return null; + } + }, [tr]); + + const refreshPostAfterDropImport = useCallback(async (pid: string) => { + const refreshed = await window.electronAPI?.posts.get(pid); + if (refreshed) { + setPost(refreshed as PostData); + updatePost(pid, refreshed as Partial); + } + }, [updatePost]); + + const handleDropImport = useCallback(async (pid: string, filePath: string): Promise<{ mediaId: string; alt: string; relativePath: string } | null> => { + const result = await runDropImport(() => window.electronAPI?.postMedia.dropImport(pid, filePath)); + if (result) { + await refreshPostAfterDropImport(pid); + } + return result; + }, [refreshPostAfterDropImport, runDropImport]); + + const handleDropImportBuffer = useCallback(async (pid: string, payload: { fileName: string; mimeType: string; bytes: Uint8Array }): Promise<{ mediaId: string; alt: string; relativePath: string } | null> => { + const result = await runDropImport(() => window.electronAPI?.postMedia.dropImportBuffer(pid, payload)); + if (result) { + await refreshPostAfterDropImport(pid); + } + return result; + }, [refreshPostAfterDropImport, runDropImport]); + + const handleDropImportFile = useCallback(async (pid: string, file: File): Promise<{ mediaId: string; alt: string; relativePath: string } | null> => { + return importImageFile(pid, file, { + importFromPath: handleDropImport, + importFromBuffer: handleDropImportBuffer, + }); + }, [handleDropImport, handleDropImportBuffer]); + + // Keep the shared dropImageContext in sync so the Milkdown plugin can access it + useEffect(() => { + dropImageContext.postId = postId; + dropImageContext.onDropImportFile = handleDropImportFile; + return () => { + dropImageContext.postId = null; + dropImageContext.onDropImportFile = null; + }; + }, [postId, handleDropImportFile]); + + // Drag-and-drop handler for the Monaco markdown editor + const handleEditorBodyDragOver = useCallback((e: React.DragEvent) => { + if (editorMode !== 'markdown') return; + if (!e.dataTransfer?.types?.includes('Files')) return; + const hasImages = Array.from(e.dataTransfer.items).some( + (item) => item.kind === 'file' && item.type.startsWith('image/'), + ); + if (hasImages) { + e.preventDefault(); + e.dataTransfer.dropEffect = 'copy'; + editorBodyRef.current?.classList.add('drop-target-active'); + } + }, [editorMode]); + + const handleEditorBodyDragLeave = useCallback((e: React.DragEvent) => { + // Only remove if leaving the editor-body bounds + if (editorBodyRef.current && !editorBodyRef.current.contains(e.relatedTarget as Node)) { + editorBodyRef.current.classList.remove('drop-target-active'); + } + }, []); + + const handleEditorBodyDrop = useCallback(async (e: React.DragEvent) => { + editorBodyRef.current?.classList.remove('drop-target-active'); + if (editorMode !== 'markdown') return; + + const files = e.dataTransfer?.files; + if (!files || files.length === 0) return; + if (!hasImageFiles(files)) { + showToast.info(tr('editor.dropImage.invalidType')); + return; + } + + e.preventDefault(); + e.stopPropagation(); + + for (const file of Array.from(files)) { + const result = await handleDropImportFile(postId, file); + if (result) { + // Insert markdown image reference at cursor position in Monaco + const monacoEditor = editorRef.current as any; + const imageMarkdown = `![${result.alt}](${result.relativePath})`; + + if (monacoEditor?.executeEdits && monacoEditor?.getPosition) { + const position = monacoEditor.getPosition(); + if (position) { + monacoEditor.executeEdits('drop-image', [{ + range: { + startLineNumber: position.lineNumber, + startColumn: position.column, + endLineNumber: position.lineNumber, + endColumn: position.column, + }, + text: `\n${imageMarkdown}\n`, + }]); + } + } + } + } + }, [editorMode, handleDropImportFile, postId, tr]); + // Close quick actions menu when clicking outside useEffect(() => { if (!showPostQuickActions) return; @@ -1442,7 +1562,7 @@ export const PostEditor: React.FC = ({ postId }) => { )} -
+
diff --git a/src/renderer/components/MilkdownEditor/MilkdownEditor.css b/src/renderer/components/MilkdownEditor/MilkdownEditor.css index 951cd06..f7054da 100644 --- a/src/renderer/components/MilkdownEditor/MilkdownEditor.css +++ b/src/renderer/components/MilkdownEditor/MilkdownEditor.css @@ -78,6 +78,13 @@ flex-direction: column; } +/* Drop zone visual feedback */ +.milkdown-content.drop-target-active { + outline: 2px dashed var(--vscode-focusBorder, #007acc); + outline-offset: -2px; + background-color: color-mix(in srgb, var(--vscode-focusBorder, #007acc) 8%, transparent); +} + .milkdown-content .milkdown .ProseMirror { flex: 1; outline: none; diff --git a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx index 847fbe3..89d44d5 100644 --- a/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx +++ b/src/renderer/components/MilkdownEditor/MilkdownEditor.tsx @@ -18,6 +18,7 @@ import type { Plugin } from 'unified'; import { visit } from 'unist-util-visit'; import { macroPlugin } from '../../plugins/macroPlugin'; import { imageResolverPlugin } from '../../plugins/imageResolverPlugin'; +import { dropImagePlugin } from '../../plugins/dropImagePlugin'; // Import macros module to register all macro definitions import '../../macros'; import './MilkdownEditor.css'; @@ -346,6 +347,7 @@ const MilkdownProviderInner: React.FC = ({ .use(commonmark) .use(gfm) .use(imageResolverPlugin) + .use(dropImagePlugin) .use(macroPlugin) .use(history) .use(listener) diff --git a/src/renderer/i18n/locales/de.json b/src/renderer/i18n/locales/de.json index a5c4a42..b15392b 100644 --- a/src/renderer/i18n/locales/de.json +++ b/src/renderer/i18n/locales/de.json @@ -755,6 +755,10 @@ "linkedMediaPanel.toast.unlinkFailed": "Lösen des Mediums fehlgeschlagen", "linkedMediaPanel.toast.linked": "Medium mit Beitrag verknüpft", "linkedMediaPanel.toast.linkFailed": "Verknüpfen des Mediums fehlgeschlagen", + "editor.dropImage.importing": "Bild wird importiert…", + "editor.dropImage.success": "Bild importiert und verknüpft", + "editor.dropImage.failed": "Import des abgelegten Bildes fehlgeschlagen", + "editor.dropImage.invalidType": "Nur Bilddateien können hier abgelegt werden", "styleView.title": "Stil", "styleView.subtitle": "Wähle ein Pico-CSS-Theme und sieh dir vor dem Anwenden eine Vorschau der Top-Beiträge an.", "styleView.themePickerAria": "Pico-Theme-Auswahl", diff --git a/src/renderer/i18n/locales/en.json b/src/renderer/i18n/locales/en.json index 1e1d720..d95cde2 100644 --- a/src/renderer/i18n/locales/en.json +++ b/src/renderer/i18n/locales/en.json @@ -755,6 +755,10 @@ "linkedMediaPanel.toast.unlinkFailed": "Failed to unlink media", "linkedMediaPanel.toast.linked": "Media linked to post", "linkedMediaPanel.toast.linkFailed": "Failed to link media", + "editor.dropImage.importing": "Importing image…", + "editor.dropImage.success": "Image imported and linked", + "editor.dropImage.failed": "Failed to import dropped image", + "editor.dropImage.invalidType": "Only image files can be dropped here", "styleView.title": "Style", "styleView.subtitle": "Select a Pico CSS theme and preview the top posts before applying.", "styleView.themePickerAria": "Pico theme picker", diff --git a/src/renderer/i18n/locales/es.json b/src/renderer/i18n/locales/es.json index 7823fef..888ca26 100644 --- a/src/renderer/i18n/locales/es.json +++ b/src/renderer/i18n/locales/es.json @@ -755,6 +755,10 @@ "linkedMediaPanel.toast.unlinkFailed": "Error al desvincular medio", "linkedMediaPanel.toast.linked": "Medio vinculado a la entrada", "linkedMediaPanel.toast.linkFailed": "Error al vincular medio", + "editor.dropImage.importing": "Importando imagen…", + "editor.dropImage.success": "Imagen importada y vinculada", + "editor.dropImage.failed": "Error al importar la imagen arrastrada", + "editor.dropImage.invalidType": "Solo se pueden soltar archivos de imagen aquí", "styleView.title": "Estilo", "styleView.subtitle": "Selecciona un tema de Pico CSS y previsualiza las entradas principales antes de aplicarlo.", "styleView.themePickerAria": "Selector de tema Pico", diff --git a/src/renderer/i18n/locales/fr.json b/src/renderer/i18n/locales/fr.json index 1de7314..b9bbfc8 100644 --- a/src/renderer/i18n/locales/fr.json +++ b/src/renderer/i18n/locales/fr.json @@ -755,6 +755,10 @@ "linkedMediaPanel.toast.unlinkFailed": "Échec du déliage du média", "linkedMediaPanel.toast.linked": "Média lié à l'article", "linkedMediaPanel.toast.linkFailed": "Échec de la liaison du média", + "editor.dropImage.importing": "Importation de l'image…", + "editor.dropImage.success": "Image importée et liée", + "editor.dropImage.failed": "Échec de l'importation de l'image déposée", + "editor.dropImage.invalidType": "Seuls les fichiers image peuvent être déposés ici", "styleView.title": "Style", "styleView.subtitle": "Sélectionnez un thème Pico CSS et prévisualisez les principaux articles avant application.", "styleView.themePickerAria": "Sélecteur de thème Pico", diff --git a/src/renderer/i18n/locales/it.json b/src/renderer/i18n/locales/it.json index 6ce8d96..29c018b 100644 --- a/src/renderer/i18n/locales/it.json +++ b/src/renderer/i18n/locales/it.json @@ -755,6 +755,10 @@ "linkedMediaPanel.toast.unlinkFailed": "Scollegamento media non riuscito", "linkedMediaPanel.toast.linked": "Media collegato al post", "linkedMediaPanel.toast.linkFailed": "Collegamento media non riuscito", + "editor.dropImage.importing": "Importazione immagine…", + "editor.dropImage.success": "Immagine importata e collegata", + "editor.dropImage.failed": "Importazione dell'immagine trascinata non riuscita", + "editor.dropImage.invalidType": "Solo file immagine possono essere trascinati qui", "styleView.title": "Stile", "styleView.subtitle": "Seleziona un tema Pico CSS e visualizza l'anteprima dei post principali prima di applicarlo.", "styleView.themePickerAria": "Selettore tema Pico", diff --git a/src/renderer/plugins/dropImagePlugin.ts b/src/renderer/plugins/dropImagePlugin.ts new file mode 100644 index 0000000..9d8acfa --- /dev/null +++ b/src/renderer/plugins/dropImagePlugin.ts @@ -0,0 +1,269 @@ +/** + * Milkdown Drop Image Plugin + * + * Handles drag-and-drop of image files from the filesystem into the editor. + * Dropped images are imported into the media library, linked to the current + * post, and inserted as markdown image nodes. AI analysis generates alt text, + * title, and caption automatically. + * + * This plugin also handles paste events with image files (e.g. screenshots). + */ + +import { $prose } from '@milkdown/kit/utils'; +import { Plugin, PluginKey } from '@milkdown/kit/prose/state'; +import type { EditorView } from '@milkdown/kit/prose/view'; + +/** File extensions accepted for image drop/paste. */ +export const SUPPORTED_IMAGE_EXTENSIONS = new Set([ + 'jpg', 'jpeg', 'png', 'gif', 'webp', 'svg', 'bmp', +]); + +const SUPPORTED_IMAGE_MIME_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/svg+xml', + 'image/bmp', +]); + +const MIME_TYPE_TO_EXTENSION: Record = { + 'image/jpeg': 'jpg', + 'image/png': 'png', + 'image/gif': 'gif', + 'image/webp': 'webp', + 'image/svg+xml': 'svg', + 'image/bmp': 'bmp', +}; + +export interface DropImageImportResult { + mediaId: string; + alt: string; + relativePath: string; +} + +export type ImageImportPayload = + | { kind: 'path'; filePath: string } + | { kind: 'buffer'; fileName: string; mimeType: string; bytes: Uint8Array }; + +export interface ImageImportCallbacks { + importFromPath: (postId: string, filePath: string) => Promise; + importFromBuffer: (postId: string, payload: { fileName: string; mimeType: string; bytes: Uint8Array }) => Promise; +} + +function getFileExtension(file: Pick): string { + return file.name.split('.').pop()?.toLowerCase() || ''; +} + +function normalizeSupportedImageMimeType(file: Pick): string | null { + const mimeType = file.type.trim().toLowerCase(); + if (SUPPORTED_IMAGE_MIME_TYPES.has(mimeType)) { + return mimeType; + } + + const extension = getFileExtension(file); + if (extension === 'jpg' || extension === 'jpeg') return 'image/jpeg'; + if (extension === 'png') return 'image/png'; + if (extension === 'gif') return 'image/gif'; + if (extension === 'webp') return 'image/webp'; + if (extension === 'svg') return 'image/svg+xml'; + if (extension === 'bmp') return 'image/bmp'; + return null; +} + +export function isSupportedImageFile(file: Pick): boolean { + return normalizeSupportedImageMimeType(file) !== null; +} + +/** + * Returns true when every file in the list is a supported image type. + * Returns false for empty lists. + */ +export function hasImageFiles(files: FileList | File[]): boolean { + if (!files || files.length === 0) return false; + return Array.from(files).every((file) => isSupportedImageFile(file)); +} + +export async function createImageImportPayload(file: File): Promise { + if (!isSupportedImageFile(file)) { + return null; + } + + const filePath = (file as File & { path?: string }).path; + if (filePath) { + return { kind: 'path', filePath }; + } + + if (typeof file.arrayBuffer !== 'function') { + return null; + } + + const mimeType = normalizeSupportedImageMimeType(file); + if (!mimeType) { + return null; + } + + const bytes = new Uint8Array(await file.arrayBuffer()); + const extension = MIME_TYPE_TO_EXTENSION[mimeType] || getFileExtension(file) || 'png'; + const trimmedName = file.name.trim(); + const fileName = trimmedName.length > 0 ? trimmedName : `pasted-image.${extension}`; + + return { + kind: 'buffer', + fileName, + mimeType, + bytes, + }; +} + +export async function importImageFile( + postId: string, + file: File, + callbacks: ImageImportCallbacks, +): Promise { + const payload = await createImageImportPayload(file); + if (!payload) { + return null; + } + + if (payload.kind === 'path') { + return callbacks.importFromPath(postId, payload.filePath); + } + + return callbacks.importFromBuffer(postId, payload); +} + +/** + * Returns true when the drag event contains external files (from OS). + * Internal editor drags (text, nodes) are ignored. + */ +function isDragWithFiles(event: DragEvent): boolean { + return !!event.dataTransfer?.types?.includes('Files'); +} + +/** + * Shared ref container so the PostEditor can set the postId and callbacks + * that the plugin reads at event time. + */ +export interface DropImageContext { + postId: string | null; + onDropImportFile: ((postId: string, file: File) => Promise) | null; +} + +/** Module-level context ref updated by the PostEditor component. */ +export const dropImageContext: DropImageContext = { + postId: null, + onDropImportFile: null, +}; + +const dropImagePluginKey = new PluginKey('dropImagePlugin'); + +/** + * Insert a markdown image node at the given position (or cursor). + */ +function insertImageAtPos(view: EditorView, pos: number, src: string, alt: string): void { + const { schema } = view.state; + const imageType = schema.nodes.image; + if (!imageType) return; + + const node = imageType.create({ src, alt, title: '' }); + const tr = view.state.tr.insert(pos, node); + view.dispatch(tr); +} + +/** + * Process dropped / pasted image files: import each via IPC and insert into + * the editor at the given document position. + */ +async function processImageFiles(view: EditorView, files: File[], pos: number): Promise { + const { postId, onDropImportFile } = dropImageContext; + if (!postId || !onDropImportFile) return; + + for (const file of files) { + const result = await onDropImportFile(postId, file); + if (result) { + insertImageAtPos(view, pos, result.relativePath, result.alt); + // Shift position for subsequent images so they appear in order + pos += 1; + } + } +} + +/** + * ProseMirror plugin that intercepts drag-and-drop and paste events + * containing image files. + */ +export const dropImagePlugin = $prose(() => { + return new Plugin({ + key: dropImagePluginKey, + props: { + handleDOMEvents: { + dragover: (_view: EditorView, event: Event) => { + const dragEvent = event as DragEvent; + if (!isDragWithFiles(dragEvent)) return false; + + // Check for image files + if (dragEvent.dataTransfer?.items) { + const hasImages = Array.from(dragEvent.dataTransfer.items).some( + (item) => item.kind === 'file' && item.type.startsWith('image/'), + ); + if (hasImages) { + dragEvent.preventDefault(); + // Add visual feedback class + const editorEl = (event.target as HTMLElement).closest?.('.milkdown-content'); + editorEl?.classList.add('drop-target-active'); + return true; + } + } + return false; + }, + + dragleave: (_view: EditorView, event: Event) => { + const editorEl = (event.target as HTMLElement).closest?.('.milkdown-content'); + editorEl?.classList.remove('drop-target-active'); + return false; + }, + + drop: (view: EditorView, event: Event) => { + const dragEvent = event as DragEvent; + + // Remove visual feedback + const editorEl = (event.target as HTMLElement).closest?.('.milkdown-content'); + editorEl?.classList.remove('drop-target-active'); + + if (!isDragWithFiles(dragEvent)) return false; + + const files = dragEvent.dataTransfer?.files; + if (!files || files.length === 0) return false; + if (!hasImageFiles(files)) return false; + + dragEvent.preventDefault(); + dragEvent.stopPropagation(); + + // Determine drop position in the document + const dropPos = view.posAtCoords({ + left: dragEvent.clientX, + top: dragEvent.clientY, + }); + const pos = dropPos ? dropPos.pos : view.state.selection.from; + + void processImageFiles(view, Array.from(files), pos); + return true; + }, + + paste: (view: EditorView, event: Event) => { + const clipboardEvent = event as ClipboardEvent; + const files = clipboardEvent.clipboardData?.files; + if (!files || files.length === 0) return false; + if (!hasImageFiles(files)) return false; + + clipboardEvent.preventDefault(); + + const pos = view.state.selection.from; + void processImageFiles(view, Array.from(files), pos); + return true; + }, + }, + }, + }); +}); diff --git a/tests/ipc/handlers.test.ts b/tests/ipc/handlers.test.ts index 8a80c4b..9e8f0b7 100644 --- a/tests/ipc/handlers.test.ts +++ b/tests/ipc/handlers.test.ts @@ -78,6 +78,7 @@ const mockMediaEngine = { setProjectContext: vi.fn(), setSearchLanguage: vi.fn(), importMedia: vi.fn(), + importMediaBuffer: vi.fn(), updateMedia: vi.fn(), deleteMedia: vi.fn(), getMedia: vi.fn(), @@ -92,6 +93,7 @@ const mockMediaEngine = { getThumbnailDataUrl: vi.fn(), regenerateMissingThumbnails: vi.fn(), getRelativePath: vi.fn(), + generateThumbnails: vi.fn().mockResolvedValue({}), getMediaTranslations: vi.fn(), }; @@ -341,11 +343,13 @@ vi.mock('fs/promises', () => ({ let mockOfflineMode = false; const mockAutoTranslatePost = vi.fn().mockResolvedValue({ success: true }); const mockAutoTranslateMediaMetadata = vi.fn().mockResolvedValue({ success: true }); +const mockAutoAnalyzeMediaImage = vi.fn().mockResolvedValue({ success: true, title: 'AI Title', alt: 'AI alt text', caption: 'AI caption' }); vi.mock('../../src/main/ipc/chatHandlers', () => ({ isOfflineModeActive: vi.fn(() => mockOfflineMode), autoTranslatePost: (...args: any[]) => mockAutoTranslatePost(...args), autoTranslateMediaMetadata: (...args: any[]) => mockAutoTranslateMediaMetadata(...args), + autoAnalyzeMediaImage: (...args: any[]) => mockAutoAnalyzeMediaImage(...args), })); // Helper to invoke a registered handler @@ -1569,6 +1573,21 @@ describe('IPC Handlers', () => { expect(mockMetaEngine.updateProjectMetadata).toHaveBeenCalledWith(updates); expect(result).toEqual(updatedMetadata); }); + + it('should pass blogLanguages through to meta engine', async () => { + const activeProject = createMockProject({ id: 'project-langs', dataPath: '/langs/data' }); + mockProjectEngine.getActiveProject.mockResolvedValue(activeProject); + mockProjectEngine.getDataDir.mockReturnValue('/resolved/langs-data'); + mockMetaEngine.updateProjectMetadata.mockResolvedValue(undefined); + const updatedMetadata = { name: 'Test', blogLanguages: ['en', 'de', 'fr'] }; + mockMetaEngine.getProjectMetadata.mockResolvedValue(updatedMetadata); + + const updates = { blogLanguages: ['en', 'de', 'fr'] }; + const result = await invokeHandler('meta:updateProjectMetadata', updates); + + expect(mockMetaEngine.updateProjectMetadata).toHaveBeenCalledWith(updates); + expect(result).toEqual(updatedMetadata); + }); }); }); @@ -1739,6 +1758,154 @@ describe('IPC Handlers', () => { expect(result).toBe(true); }); }); + + describe('postMedia:dropImport', () => { + it('should import media, run AI analysis, link to post, and return result', async () => { + const mockMedia = createMockMedia({ id: 'drop-media-1', filename: 'drop-media-1.jpg', mimeType: 'image/jpeg' }); + mockMediaEngine.importMedia.mockResolvedValue(mockMedia); + mockMetaEngine.getProjectMetadata.mockResolvedValue({ name: 'Test', mainLanguage: 'en', blogLanguages: ['en', 'de'] }); + mockPostMediaEngine.linkMediaToPost.mockResolvedValue(undefined); + mockMediaEngine.updateMedia.mockResolvedValue(mockMedia); + mockMediaEngine.getRelativePath.mockResolvedValue('media/2026/03/drop-media-1.jpg'); + mockPostEngine.getPost.mockResolvedValue(createMockPost({ id: 'post-1', status: 'draft' })); + mockAutoAnalyzeMediaImage.mockResolvedValue({ success: true, title: 'AI Title', alt: 'AI alt', caption: 'AI caption' }); + + // Mock DB query for filePath used by generateThumbnails + mockDatabase.getLocal.mockReturnValue({ + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + get: vi.fn().mockResolvedValue({ filePath: '/mock/media/drop-media-1.jpg' }), + })), + })), + })), + }); + + const result = await invokeHandler('postMedia:dropImport', 'post-1', '/tmp/photo.jpg'); + + expect(mockMediaEngine.importMedia).toHaveBeenCalledWith('/tmp/photo.jpg', expect.objectContaining({ language: 'en' })); + expect(mockPostMediaEngine.linkMediaToPost).toHaveBeenCalledWith('post-1', 'drop-media-1'); + expect(mockAutoAnalyzeMediaImage).toHaveBeenCalledWith('drop-media-1', 'en'); + expect(mockMediaEngine.updateMedia).toHaveBeenCalledWith('drop-media-1', expect.objectContaining({ + title: 'AI Title', + alt: 'AI alt', + caption: 'AI caption', + })); + expect(mockAutoTranslateMediaMetadata).toHaveBeenCalledWith('drop-media-1', 'de'); + expect(result).toEqual({ + mediaId: 'drop-media-1', + alt: 'AI alt', + relativePath: 'media/2026/03/drop-media-1.jpg', + }); + }); + + it('should transition published post to draft', async () => { + const mockMedia = createMockMedia({ id: 'drop-media-2', filename: 'drop-media-2.png', mimeType: 'image/png' }); + mockMediaEngine.importMedia.mockResolvedValue(mockMedia); + mockMetaEngine.getProjectMetadata.mockResolvedValue({ name: 'Test', mainLanguage: 'en' }); + mockPostMediaEngine.linkMediaToPost.mockResolvedValue(undefined); + mockMediaEngine.updateMedia.mockResolvedValue(mockMedia); + mockMediaEngine.getRelativePath.mockResolvedValue('media/2026/03/drop-media-2.png'); + const publishedPost = createMockPost({ id: 'post-2', status: 'published', content: 'Published content' }); + mockPostEngine.getPost.mockResolvedValue(publishedPost); + mockPostEngine.updatePost.mockResolvedValue({ ...publishedPost, status: 'draft' }); + mockAutoAnalyzeMediaImage.mockResolvedValue({ success: true, title: 'Title', alt: 'Alt', caption: 'Cap' }); + + mockDatabase.getLocal.mockReturnValue({ + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + get: vi.fn().mockResolvedValue({ filePath: '/mock/media/drop-media-2.png' }), + })), + })), + })), + }); + + await invokeHandler('postMedia:dropImport', 'post-2', '/tmp/image.png'); + + expect(mockPostEngine.updatePost).toHaveBeenCalledWith('post-2', expect.objectContaining({ status: 'draft' })); + }); + + it('should handle AI analysis failure gracefully', async () => { + const mockMedia = createMockMedia({ id: 'drop-media-3', filename: 'drop-media-3.jpg', mimeType: 'image/jpeg', alt: undefined }); + mockMediaEngine.importMedia.mockResolvedValue(mockMedia); + mockMetaEngine.getProjectMetadata.mockResolvedValue({ name: 'Test', mainLanguage: 'en' }); + mockPostMediaEngine.linkMediaToPost.mockResolvedValue(undefined); + mockMediaEngine.getRelativePath.mockResolvedValue('media/2026/03/drop-media-3.jpg'); + mockPostEngine.getPost.mockResolvedValue(createMockPost({ id: 'post-3', status: 'draft' })); + mockAutoAnalyzeMediaImage.mockResolvedValue({ success: false, error: 'No API key' }); + + mockDatabase.getLocal.mockReturnValue({ + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + get: vi.fn().mockResolvedValue({ filePath: '/mock/media/drop-media-3.jpg' }), + })), + })), + })), + }); + + const result = await invokeHandler('postMedia:dropImport', 'post-3', '/tmp/photo.jpg'); + + // Should still return a result even without AI metadata + expect(result).toEqual({ + mediaId: 'drop-media-3', + alt: '', + relativePath: 'media/2026/03/drop-media-3.jpg', + }); + // Should NOT call updateMedia or translate when analysis fails + expect(mockMediaEngine.updateMedia).not.toHaveBeenCalled(); + expect(mockAutoTranslateMediaMetadata).not.toHaveBeenCalled(); + }); + + it('should reject non-image files before importing them into the media library', async () => { + await expect(invokeHandler('postMedia:dropImport', 'post-1', '/tmp/document.pdf')).rejects.toThrow(/image/i); + + expect(mockMediaEngine.importMedia).not.toHaveBeenCalled(); + expect(mockPostMediaEngine.linkMediaToPost).not.toHaveBeenCalled(); + }); + }); + + describe('postMedia:dropImportBuffer', () => { + it('should import screenshot-like image buffers without a native file path', async () => { + const mockMedia = createMockMedia({ id: 'drop-media-buffer-1', filename: 'drop-media-buffer-1.png', mimeType: 'image/png' }); + mockMediaEngine.importMediaBuffer.mockResolvedValue(mockMedia); + mockMetaEngine.getProjectMetadata.mockResolvedValue({ name: 'Test', mainLanguage: 'en', blogLanguages: ['en', 'de'] }); + mockPostMediaEngine.linkMediaToPost.mockResolvedValue(undefined); + mockMediaEngine.updateMedia.mockResolvedValue(mockMedia); + mockMediaEngine.getRelativePath.mockResolvedValue('media/2026/03/drop-media-buffer-1.png'); + mockPostEngine.getPost.mockResolvedValue(createMockPost({ id: 'post-4', status: 'draft' })); + mockAutoAnalyzeMediaImage.mockResolvedValue({ success: true, title: 'AI Title', alt: 'AI alt', caption: 'AI caption' }); + + mockDatabase.getLocal.mockReturnValue({ + select: vi.fn(() => ({ + from: vi.fn(() => ({ + where: vi.fn(() => ({ + get: vi.fn().mockResolvedValue({ filePath: '/mock/media/drop-media-buffer-1.png' }), + })), + })), + })), + }); + + const result = await invokeHandler( + 'postMedia:dropImportBuffer', + 'post-4', + { fileName: 'pasted-image.png', mimeType: 'image/png', bytes: new Uint8Array() }, + ); + + expect(mockMediaEngine.importMediaBuffer).toHaveBeenCalledWith( + expect.any(Uint8Array), + 'pasted-image.png', + expect.objectContaining({ language: 'en', mimeType: 'image/png' }), + ); + expect(mockPostMediaEngine.linkMediaToPost).toHaveBeenCalledWith('post-4', 'drop-media-buffer-1'); + expect(result).toEqual({ + mediaId: 'drop-media-buffer-1', + alt: 'AI alt', + relativePath: 'media/2026/03/drop-media-buffer-1.png', + }); + }); + }); }); // ============ App Handlers ============ diff --git a/tests/renderer/plugins/dropImagePlugin.test.ts b/tests/renderer/plugins/dropImagePlugin.test.ts new file mode 100644 index 0000000..ba968de --- /dev/null +++ b/tests/renderer/plugins/dropImagePlugin.test.ts @@ -0,0 +1,83 @@ +/** + * Tests for the drop image plugin validation logic. + */ +import { describe, it, expect, vi } from 'vitest'; +import { createImageImportPayload, hasImageFiles, SUPPORTED_IMAGE_EXTENSIONS } from '../../../src/renderer/plugins/dropImagePlugin'; + +function makeFile(name: string, type: string = 'application/octet-stream'): File { + return new File([''], name, { type }); +} + +describe('dropImagePlugin', () => { + describe('SUPPORTED_IMAGE_EXTENSIONS', () => { + it('should include common image formats', () => { + expect(SUPPORTED_IMAGE_EXTENSIONS.has('jpg')).toBe(true); + expect(SUPPORTED_IMAGE_EXTENSIONS.has('jpeg')).toBe(true); + expect(SUPPORTED_IMAGE_EXTENSIONS.has('png')).toBe(true); + expect(SUPPORTED_IMAGE_EXTENSIONS.has('gif')).toBe(true); + expect(SUPPORTED_IMAGE_EXTENSIONS.has('webp')).toBe(true); + expect(SUPPORTED_IMAGE_EXTENSIONS.has('svg')).toBe(true); + expect(SUPPORTED_IMAGE_EXTENSIONS.has('bmp')).toBe(true); + }); + + it('should not include non-image formats', () => { + expect(SUPPORTED_IMAGE_EXTENSIONS.has('pdf')).toBe(false); + expect(SUPPORTED_IMAGE_EXTENSIONS.has('txt')).toBe(false); + expect(SUPPORTED_IMAGE_EXTENSIONS.has('mp4')).toBe(false); + }); + }); + + describe('hasImageFiles', () => { + it('should return true for a single image file', () => { + expect(hasImageFiles([makeFile('photo.jpg')])).toBe(true); + expect(hasImageFiles([makeFile('image.PNG')])).toBe(true); + expect(hasImageFiles([makeFile('graphic.webp')])).toBe(true); + }); + + it('should return true for multiple image files', () => { + expect(hasImageFiles([makeFile('a.jpg'), makeFile('b.png'), makeFile('c.gif')])).toBe(true); + }); + + it('should return false for non-image files', () => { + expect(hasImageFiles([makeFile('document.pdf')])).toBe(false); + expect(hasImageFiles([makeFile('readme.txt')])).toBe(false); + }); + + it('should return false when mixed with non-image files', () => { + expect(hasImageFiles([makeFile('photo.jpg'), makeFile('doc.pdf')])).toBe(false); + }); + + it('should return true for screenshot-like clipboard files identified only by mime type', () => { + expect(hasImageFiles([makeFile('', 'image/png')])).toBe(true); + }); + + it('should return false for empty list', () => { + expect(hasImageFiles([])).toBe(false); + }); + + it('should return false for null-ish input', () => { + expect(hasImageFiles(null as unknown as File[])).toBe(false); + expect(hasImageFiles(undefined as unknown as File[])).toBe(false); + }); + }); + + describe('createImageImportPayload', () => { + it('should build a buffer payload for screenshot-like clipboard images without a native path', async () => { + const screenshot = makeFile('', 'image/png'); + const arrayBuffer = vi.fn().mockResolvedValue(new ArrayBuffer(0)); + Object.defineProperty(screenshot, 'arrayBuffer', { + value: arrayBuffer, + }); + + const payload = await createImageImportPayload(screenshot); + + expect(arrayBuffer).toHaveBeenCalledOnce(); + expect(payload).toEqual({ + kind: 'buffer', + fileName: 'pasted-image.png', + mimeType: 'image/png', + bytes: new Uint8Array(), + }); + }); + }); +});