Feature/post media translations (#42)
* chore: updated todo with translation ideas * feat: first take at the implementation of translations * fix: small addition for the translation feature * feat: support language switching in the editor and preview * feat: better handling of long bodies by not running them through a json envelope * fix: unknown macros have better fallback * feat: api for python to get translations * fix: strip dumb prefix of content in translation * feat: extend meta diff for translations * feat: hook up translations to rebuild-from-disk * feat: generation of the website prefers project language, falling back to canonical language * fix: crashes during rendering * feat: translation validation report * fix: made the translation validation actually work * chore: reorganization of menu * fix: some topics cleanup * chore: updated doc * feat: translations for media * feat: more aligned in UI/UX * feat: edit translations possible * chore: added full multi-language todo * chore: updated todo for clarity * feat: implementation of full multi-linguality * fix: page creation creates pages * fix: flags on every page * fix: better prompt * feat: made MCP server aware of language content * feat: python tools for translations * fix: better fill-in-translations * fix: better prompt for translation. maybe. * fix: losing posts from search due to translation process * fix: translation validation handles in-db content and fill-in of missing translations fixed to flush * fix: faster scanning for infilling of missing translations * chore: updated agent instructions * feat: calendar and tag cloud respect current language now * fix: retries going up * fix: got metadata-diff and rebuild into sync * fix: extended meta-diff for timestamps * fix: made website validation look at translated content, too * fix: multi-lingual search * chore: refactor Editor.tsx into two separate editors * feat: do language detection when no explicit language given --------- Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
@@ -20,7 +20,12 @@ This document provides context and best practices for GitHub Copilot when workin
|
||||
- UI you implement has to be tied to functionality
|
||||
- you must use drizzle to generate migrations and snapshots
|
||||
- we use an sqlite database. use sqlite semantics in snapshots and other artifacts
|
||||
- oh MacOS we use native menus and you have to hook them into the intercept for new menu items
|
||||
- on MacOS we use native menus and you have to hook them into the intercept for new menu items
|
||||
- there are two areas of localization, you sometimes need both (menus for example)
|
||||
- all automatic AI activities must be gated by airplane (offline) mode of the app and either use the local model or inform the user via toast
|
||||
- metadata needs to be flushed to the filesystem and needs to be included in metadata diff tool and in rebuild from filesystem. All three aspects have to be in sync with each other.
|
||||
- if you add new metadata, add them to publishing, metadata-diff and rebuild-from-database
|
||||
- HEREDOCs don't work most of the time. Don't use them. Use editor tools to create proper scripots
|
||||
|
||||
---
|
||||
|
||||
|
||||
394
API.md
394
API.md
@@ -1,6 +1,6 @@
|
||||
# API Documentation
|
||||
|
||||
Contract version: 1.13.0
|
||||
Contract version: 1.15.0
|
||||
|
||||
This reference documents all Python runtime API calls available through `bds_api` in embedded Pyodide.
|
||||
|
||||
@@ -331,6 +331,9 @@ None # or
|
||||
- [posts.getAll](#postsgetall)
|
||||
- [posts.getByStatus](#postsgetbystatus)
|
||||
- [posts.publish](#postspublish)
|
||||
- [posts.getTranslation](#postsgettranslation)
|
||||
- [posts.getTranslations](#postsgettranslations)
|
||||
- [posts.publishTranslation](#postspublishtranslation)
|
||||
- [posts.discard](#postsdiscard)
|
||||
- [posts.hasPublishedVersion](#postshaspublishedversion)
|
||||
- [posts.rebuildFromFiles](#postsrebuildfromfiles)
|
||||
@@ -386,7 +389,8 @@ result = await bds.posts.create(data={})
|
||||
'updatedAt': 'value',
|
||||
'publishedAt': 'value',
|
||||
'tags': 'value',
|
||||
'categories': 'value'
|
||||
'categories': 'value',
|
||||
'availableLanguages': 'value'
|
||||
}
|
||||
```
|
||||
|
||||
@@ -430,7 +434,8 @@ None # or
|
||||
'updatedAt': 'value',
|
||||
'publishedAt': 'value',
|
||||
'tags': 'value',
|
||||
'categories': 'value'
|
||||
'categories': 'value',
|
||||
'availableLanguages': 'value'
|
||||
}
|
||||
```
|
||||
|
||||
@@ -498,7 +503,8 @@ None # or
|
||||
'updatedAt': 'value',
|
||||
'publishedAt': 'value',
|
||||
'tags': 'value',
|
||||
'categories': 'value'
|
||||
'categories': 'value',
|
||||
'availableLanguages': 'value'
|
||||
}
|
||||
```
|
||||
|
||||
@@ -541,13 +547,14 @@ None # or
|
||||
'updatedAt': 'value',
|
||||
'publishedAt': 'value',
|
||||
'tags': 'value',
|
||||
'categories': 'value'
|
||||
'categories': 'value',
|
||||
'availableLanguages': 'value'
|
||||
}
|
||||
```
|
||||
|
||||
### posts.getPreviewUrl
|
||||
|
||||
Get preview URL for post.
|
||||
Get preview URL for post. options may include draft=true and lang=<language-code>.
|
||||
|
||||
**Parameters**
|
||||
|
||||
@@ -635,7 +642,8 @@ result = await bds.posts.get_by_status(status='status')
|
||||
'updatedAt': 'value',
|
||||
'publishedAt': 'value',
|
||||
'tags': 'value',
|
||||
'categories': 'value'
|
||||
'categories': 'value',
|
||||
'availableLanguages': 'value'
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -679,7 +687,133 @@ None # or
|
||||
'updatedAt': 'value',
|
||||
'publishedAt': 'value',
|
||||
'tags': 'value',
|
||||
'categories': 'value'
|
||||
'categories': 'value',
|
||||
'availableLanguages': 'value'
|
||||
}
|
||||
```
|
||||
|
||||
### posts.getTranslation
|
||||
|
||||
Get a single translation for a post by language.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- postId (str, required)
|
||||
- language (str, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `PostTranslationData | null`
|
||||
- Nullability: Returns `None` when no matching value exists.
|
||||
- Data structures: `PostTranslationData`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.posts.get_translation(post_id='post-1', language='language')
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
None # or
|
||||
{
|
||||
'id': 'value',
|
||||
'projectId': 'value',
|
||||
'translationFor': 'value',
|
||||
'language': 'value',
|
||||
'title': 'value',
|
||||
'excerpt': 'value',
|
||||
'content': 'value',
|
||||
'status': 'draft',
|
||||
'createdAt': 'value',
|
||||
'updatedAt': 'value',
|
||||
'publishedAt': 'value',
|
||||
'filePath': 'value'
|
||||
}
|
||||
```
|
||||
|
||||
### posts.getTranslations
|
||||
|
||||
Get all translations for a post.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- postId (str, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `PostTranslationData[]`
|
||||
- Data structures: `PostTranslationData`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.posts.get_translations(post_id='post-1')
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
[
|
||||
{
|
||||
'id': 'value',
|
||||
'projectId': 'value',
|
||||
'translationFor': 'value',
|
||||
'language': 'value',
|
||||
'title': 'value',
|
||||
'excerpt': 'value',
|
||||
'content': 'value',
|
||||
'status': 'draft',
|
||||
'createdAt': 'value',
|
||||
'updatedAt': 'value',
|
||||
'publishedAt': 'value',
|
||||
'filePath': 'value'
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
### posts.publishTranslation
|
||||
|
||||
Publish a specific translation of a post.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- postId (str, required)
|
||||
- language (str, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `PostTranslationData | null`
|
||||
- Nullability: Returns `None` when no matching value exists.
|
||||
- Data structures: `PostTranslationData`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.posts.publish_translation(post_id='post-1', language='language')
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
None # or
|
||||
{
|
||||
'id': 'value',
|
||||
'projectId': 'value',
|
||||
'translationFor': 'value',
|
||||
'language': 'value',
|
||||
'title': 'value',
|
||||
'excerpt': 'value',
|
||||
'content': 'value',
|
||||
'status': 'draft',
|
||||
'createdAt': 'value',
|
||||
'updatedAt': 'value',
|
||||
'publishedAt': 'value',
|
||||
'filePath': 'value'
|
||||
}
|
||||
```
|
||||
|
||||
@@ -722,7 +856,8 @@ None # or
|
||||
'updatedAt': 'value',
|
||||
'publishedAt': 'value',
|
||||
'tags': 'value',
|
||||
'categories': 'value'
|
||||
'categories': 'value',
|
||||
'availableLanguages': 'value'
|
||||
}
|
||||
```
|
||||
|
||||
@@ -828,7 +963,7 @@ result = await bds.posts.search(query='search phrase')
|
||||
|
||||
### posts.filter
|
||||
|
||||
Filter posts by criteria.
|
||||
Filter posts by criteria, including optional language and missingTranslationLanguage filters.
|
||||
|
||||
**Parameters**
|
||||
|
||||
@@ -864,7 +999,8 @@ result = await bds.posts.filter(filter={})
|
||||
'updatedAt': 'value',
|
||||
'publishedAt': 'value',
|
||||
'tags': 'value',
|
||||
'categories': 'value'
|
||||
'categories': 'value',
|
||||
'availableLanguages': 'value'
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -1057,7 +1193,8 @@ result = await bds.posts.get_links_to(id='id-1')
|
||||
'updatedAt': 'value',
|
||||
'publishedAt': 'value',
|
||||
'tags': 'value',
|
||||
'categories': 'value'
|
||||
'categories': 'value',
|
||||
'availableLanguages': 'value'
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -1100,7 +1237,8 @@ result = await bds.posts.get_linked_by(id='id-1')
|
||||
'updatedAt': 'value',
|
||||
'publishedAt': 'value',
|
||||
'tags': 'value',
|
||||
'categories': 'value'
|
||||
'categories': 'value',
|
||||
'availableLanguages': 'value'
|
||||
}
|
||||
]
|
||||
```
|
||||
@@ -1206,6 +1344,10 @@ result = await bds.posts.generate_unique_slug(title='title')
|
||||
- [media.getByYearMonth](#mediagetbyyearmonth)
|
||||
- [media.getTags](#mediagettags)
|
||||
- [media.getTagsWithCounts](#mediagettagswithcounts)
|
||||
- [media.getTranslation](#mediagettranslation)
|
||||
- [media.getTranslations](#mediagettranslations)
|
||||
- [media.upsertTranslation](#mediaupserttranslation)
|
||||
- [media.deleteTranslation](#mediadeletetranslation)
|
||||
|
||||
### media.import
|
||||
|
||||
@@ -1777,6 +1919,111 @@ result = await bds.media.get_tags_with_counts()
|
||||
[]
|
||||
```
|
||||
|
||||
### media.getTranslation
|
||||
|
||||
Get a single translation for a media item by language.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- mediaId (str, required)
|
||||
- language (str, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `MediaTranslationData | null`
|
||||
- Nullability: Returns `None` when no matching value exists.
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.media.get_translation(media_id='media-1', language='language')
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
None # or dict-like object when found
|
||||
```
|
||||
|
||||
### media.getTranslations
|
||||
|
||||
Get all translations for a media item.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- mediaId (str, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `MediaTranslationData[]`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.media.get_translations(media_id='media-1')
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
[]
|
||||
```
|
||||
|
||||
### media.upsertTranslation
|
||||
|
||||
Create or update a media translation for a specific language.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- mediaId (str, required)
|
||||
- language (str, required)
|
||||
- data (dict, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `MediaTranslationData`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.media.upsert_translation(media_id='media-1', language='language', data={})
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
{}
|
||||
```
|
||||
|
||||
### media.deleteTranslation
|
||||
|
||||
Delete a media translation by language.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- mediaId (str, required)
|
||||
- language (str, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `boolean`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.media.delete_translation(media_id='media-1', language='language')
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
True
|
||||
```
|
||||
|
||||
[↑ Back to Table of contents](#table-of-contents)
|
||||
|
||||
## scripts
|
||||
@@ -3514,6 +3761,9 @@ result = await bds.tags.sync_from_posts()
|
||||
- [chat.analyzeMediaImage](#chatanalyzemediaimage)
|
||||
- [chat.detectPostLanguage](#chatdetectpostlanguage)
|
||||
- [chat.analyzePost](#chatanalyzepost)
|
||||
- [chat.translatePost](#chattranslatepost)
|
||||
- [chat.detectMediaLanguage](#chatdetectmedialanguage)
|
||||
- [chat.translateMediaMetadata](#chattranslatemediametadata)
|
||||
|
||||
### chat.analyzeMediaImage
|
||||
|
||||
@@ -3607,6 +3857,90 @@ result = await bds.chat.analyze_post(post_id='post-1')
|
||||
}
|
||||
```
|
||||
|
||||
### chat.translatePost
|
||||
|
||||
Translate a post into a target language and save it as a translation draft.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- postId (str, required)
|
||||
- targetLanguage (str, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `PostTranslationResult`
|
||||
- Data structures: `PostTranslationResult`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.chat.translate_post(post_id='post-1', target_language='target_language')
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
{
|
||||
'success': False,
|
||||
'translation': None,
|
||||
'error': 'value'
|
||||
}
|
||||
```
|
||||
|
||||
### chat.detectMediaLanguage
|
||||
|
||||
Detect the language of media metadata from its title, alt text, and caption.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- title (str, required)
|
||||
- alt (str, required)
|
||||
- caption (str, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `{ success: boolean; language?: string; error?: string }`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.chat.detect_media_language(title='title', alt='alt', caption='caption')
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
{}
|
||||
```
|
||||
|
||||
### chat.translateMediaMetadata
|
||||
|
||||
Translate media metadata (title, alt, caption) into a target language using AI.
|
||||
|
||||
**Parameters**
|
||||
|
||||
- mediaId (str, required)
|
||||
- targetLanguage (str, required)
|
||||
|
||||
**Response specification**
|
||||
|
||||
- Return type: `MediaTranslationResult`
|
||||
|
||||
**Example call**
|
||||
|
||||
```python
|
||||
from bds_api import bds
|
||||
result = await bds.chat.translate_media_metadata(media_id='media-1', target_language='target_language')
|
||||
```
|
||||
|
||||
**Example response**
|
||||
|
||||
```python
|
||||
{}
|
||||
```
|
||||
|
||||
[↑ Back to Table of contents](#table-of-contents)
|
||||
|
||||
## sync
|
||||
@@ -4200,6 +4534,7 @@ Canonical post object used across editor and generation flows.
|
||||
- publishedAt (`string`, optional): Publication timestamp for published posts.
|
||||
- tags (`string[]`, required): List of tag names.
|
||||
- categories (`string[]`, required): List of category names.
|
||||
- availableLanguages (`string[]`, required): Canonical language plus all available translation language codes for this post.
|
||||
|
||||
[↑ Back to Table of contents](#table-of-contents)
|
||||
|
||||
@@ -4423,6 +4758,39 @@ Result from AI post analysis containing suggested title, excerpt, and slug.
|
||||
|
||||
[↑ Back to Table of contents](#table-of-contents)
|
||||
|
||||
### PostTranslationData
|
||||
|
||||
Stored translation draft or published translation for a post.
|
||||
|
||||
**Fields**
|
||||
|
||||
- id (`string`, required): Translation identifier.
|
||||
- projectId (`string`, required): Owning project identifier.
|
||||
- translationFor (`string`, required): Source post identifier this translation belongs to.
|
||||
- language (`string`, required): Target language code for the translation.
|
||||
- title (`string`, required): Translated title.
|
||||
- excerpt (`string`, optional): Translated excerpt.
|
||||
- content (`string`, required): Translated Markdown content.
|
||||
- status (`'draft' | 'published' | 'archived'`, required): Translation lifecycle state.
|
||||
- createdAt (`string`, required): Creation timestamp.
|
||||
- updatedAt (`string`, required): Last update timestamp.
|
||||
- publishedAt (`string`, optional): Publish timestamp when the translation is published.
|
||||
- filePath (`string`, required): Translation file path on disk.
|
||||
|
||||
[↑ Back to Table of contents](#table-of-contents)
|
||||
|
||||
### PostTranslationResult
|
||||
|
||||
Result from AI post translation containing the saved translation draft.
|
||||
|
||||
**Fields**
|
||||
|
||||
- success (`boolean`, required): Whether the translation succeeded.
|
||||
- translation (`PostTranslationData`, optional): Saved translation draft when successful.
|
||||
- error (`string`, optional): Error message when translation failed.
|
||||
|
||||
[↑ Back to Table of contents](#table-of-contents)
|
||||
|
||||
### SimilarPost
|
||||
|
||||
A post with its semantic similarity score relative to a reference post.
|
||||
|
||||
553
TODO.md
553
TODO.md
@@ -1,5 +1,7 @@
|
||||
# bDS — Remaining Feature Work
|
||||
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
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.
|
||||
@@ -10,121 +12,342 @@ independently.
|
||||
|
||||
### Goal
|
||||
|
||||
Posts have a language attribute. The AI importing agent detects post language
|
||||
and can auto-translate posts. Posts link to their translations so the
|
||||
publishing pipeline can generate multilingual output.
|
||||
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
|
||||
|
||||
- Posts have no `language` field.
|
||||
- No translation relationship tracking.
|
||||
- No language detection during import.
|
||||
- No AI translation tools.
|
||||
- The `excerpt` field already exists and can serve as the summary field
|
||||
mentioned in the vision.
|
||||
- `analyzeMediaImage()` in `OpenCodeManager` already demonstrates the pattern
|
||||
for single-shot AI analysis with language parameters.
|
||||
- Project-level `mainLanguage` exists in `MetaEngine`.
|
||||
- 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
|
||||
|
||||
Extend the `posts` table:
|
||||
Add a dedicated translations table instead of storing translations as normal
|
||||
posts.
|
||||
|
||||
| Column | Type | Notes |
|
||||
|-----------------|------|-------------------------------------------------|
|
||||
| language | text | ISO code (`en`, `de`, etc.), defaults to project `mainLanguage` |
|
||||
| translationOfId | text | FK to posts.id — the original post this is a translation of |
|
||||
Each translation row should contain only:
|
||||
|
||||
No separate junction table needed. A translated post is simply a post with
|
||||
`translationOfId` pointing at its source. This keeps the model simple: each
|
||||
post belongs to exactly one language and optionally references one original.
|
||||
- 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
|
||||
|
||||
Extend `postFileUtils.ts` to read/write:
|
||||
Store translation files separately but in the same general markdown + YAML
|
||||
frontmatter style as posts, with only the supported translation fields:
|
||||
|
||||
```yaml
|
||||
language: de
|
||||
translationOf: <original-post-id>
|
||||
translationFor: <original-post-id>
|
||||
language: fr
|
||||
title: ...
|
||||
excerpt: ...
|
||||
```
|
||||
|
||||
On `readPostFile()`, parse these fields. On `writePostFile()`, include them
|
||||
when present.
|
||||
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 methods:
|
||||
Add translation-aware storage and lookup methods instead of treating
|
||||
translations as regular posts.
|
||||
|
||||
- `getTranslations(postId)` — find all posts where `translationOfId === postId`.
|
||||
- `getOriginal(postId)` — if the post has `translationOfId`, return that post.
|
||||
- `createTranslation(originalPostId, targetLanguage, content)` — create a new
|
||||
post linked to the original with the target language set.
|
||||
- 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.
|
||||
|
||||
Modify `createPost()` and `updatePost()` to accept and persist the `language`
|
||||
and `translationOfId` fields.
|
||||
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 three new methods following the `analyzeMediaImage()` pattern:
|
||||
|
||||
**`detectPostLanguage(postId)`**
|
||||
- Read post content.
|
||||
- Send to AI with prompt: "Detect the language of this text. Return a JSON
|
||||
object with `language` (ISO 639-1 code) and `confidence` (0-1)."
|
||||
- Return `{ language: string, confidence: number }`.
|
||||
Add translation generation on top of the existing one-shot AI tooling.
|
||||
|
||||
**`translatePost(postId, targetLanguage)`**
|
||||
- Read full post content + title + excerpt.
|
||||
- Send to AI with prompt: "Translate this blog post to {language}. Return JSON
|
||||
with `title`, `content` (markdown), and `excerpt`."
|
||||
- Return translated fields without creating a post (caller decides).
|
||||
|
||||
**`generatePostSummary(postId)`**
|
||||
- Read post content.
|
||||
- Send to AI: "Write a 2-3 sentence summary of this blog post in
|
||||
{post.language}. Return JSON with `excerpt`."
|
||||
- Return `{ excerpt: string }`.
|
||||
- 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.
|
||||
|
||||
Register these as IPC handlers: `chat:detectPostLanguage`,
|
||||
`chat:translatePost`, `chat:generatePostSummary`.
|
||||
Language detection and excerpt suggestion already exist; this step is only
|
||||
about translation-specific tooling.
|
||||
|
||||
#### 1.5 Import Pipeline Integration
|
||||
|
||||
In `ImportExecutionEngine`, after a post is imported and published:
|
||||
Integrate with the existing import flow without redefining source posts as
|
||||
translations.
|
||||
|
||||
1. Call `detectPostLanguage()` to set the `language` field.
|
||||
2. If the detected language differs from the project's `mainLanguage`, queue
|
||||
a translation task via `TaskManager`.
|
||||
3. The translation task calls `translatePost()`, creates a new post via
|
||||
`createTranslation()`, and publishes it.
|
||||
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 UI — Translation Panel
|
||||
#### 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 with links (open in new tab).
|
||||
- "Translate to..." button that opens a language picker, triggers AI
|
||||
translation, and creates the linked post.
|
||||
- If the post is itself a translation, show "Original: {title}" link.
|
||||
- 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.7 Publishing Pipeline
|
||||
#### 1.8 Publishing Pipeline
|
||||
|
||||
In `PageRenderer` and `BlogGenerationEngine`:
|
||||
|
||||
- Add `hreflang` link tags to generated HTML when translations exist.
|
||||
- Optionally generate a language switcher partial that templates can include.
|
||||
- Sitemap should include `xhtml:link` entries for alternate language versions.
|
||||
- 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: <original-media-id>
|
||||
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
|
||||
@@ -235,3 +458,205 @@ The same plugin can handle `paste` events with image files:
|
||||
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 `<language>` / `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 `<url>`
|
||||
entry includes `<xhtml:link rel="alternate" hreflang="…" href="…"/>` 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 `<head>`.
|
||||
- [ ] `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).
|
||||
|
||||
17
drizzle/0013_swift_devos.sql
Normal file
17
drizzle/0013_swift_devos.sql
Normal file
@@ -0,0 +1,17 @@
|
||||
CREATE TABLE `post_translations` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`translation_for` text NOT NULL,
|
||||
`language` text NOT NULL,
|
||||
`title` text NOT NULL,
|
||||
`excerpt` text,
|
||||
`content` text,
|
||||
`status` text DEFAULT 'draft' NOT NULL,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL,
|
||||
`published_at` integer,
|
||||
`file_path` text DEFAULT '' NOT NULL,
|
||||
`checksum` text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `post_translations_translation_language_idx` ON `post_translations` (`translation_for`,`language`);
|
||||
14
drizzle/0014_media_translations.sql
Normal file
14
drizzle/0014_media_translations.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE `media_translations` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`translation_for` text NOT NULL,
|
||||
`language` text NOT NULL,
|
||||
`title` text,
|
||||
`alt` text,
|
||||
`caption` text,
|
||||
`created_at` integer NOT NULL,
|
||||
`updated_at` integer NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX `media_translations_translation_language_idx` ON `media_translations` (`translation_for`,`language`);--> statement-breakpoint
|
||||
ALTER TABLE `media` ADD `language` text;
|
||||
1
drizzle/0015_melted_zzzax.sql
Normal file
1
drizzle/0015_melted_zzzax.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE `posts` ADD `do_not_translate` integer DEFAULT false NOT NULL;
|
||||
1651
drizzle/meta/0013_snapshot.json
Normal file
1651
drizzle/meta/0013_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1740
drizzle/meta/0014_snapshot.json
Normal file
1740
drizzle/meta/0014_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
1748
drizzle/meta/0015_snapshot.json
Normal file
1748
drizzle/meta/0015_snapshot.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -92,6 +92,27 @@
|
||||
"when": 1772738580546,
|
||||
"tag": "0012_flimsy_meteorite",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 13,
|
||||
"version": "6",
|
||||
"when": 1772882271754,
|
||||
"tag": "0013_swift_devos",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 14,
|
||||
"version": "6",
|
||||
"when": 1772959578621,
|
||||
"tag": "0014_media_translations",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 15,
|
||||
"version": "6",
|
||||
"when": 1772967539676,
|
||||
"tag": "0015_melted_zzzax",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
106
scripts/audit-translations.py
Normal file
106
scripts/audit-translations.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""
|
||||
Audit translation quality — run inside bDS Python runtime.
|
||||
|
||||
Flags posts where translation content is suspicious:
|
||||
- Canonical body is empty/tiny but translation has substantial content (hallucination)
|
||||
- Translation is much longer than canonical (ratio > 3x)
|
||||
- Canonical is macro-only but translation contains prose
|
||||
"""
|
||||
|
||||
from bds_api import bds
|
||||
|
||||
MACRO_PATTERN = r'^\s*(\[\[.*?\]\]\s*)+$'
|
||||
SIZE_RATIO_THRESHOLD = 3.0
|
||||
MIN_HALLUCINATION_SIZE = 200 # translation must be at least this big to flag
|
||||
|
||||
|
||||
async def audit():
|
||||
import re
|
||||
|
||||
# Get all published posts, paginated
|
||||
all_posts = []
|
||||
offset = 0
|
||||
limit = 100
|
||||
while True:
|
||||
result = await bds.posts.get_all(options={'limit': limit, 'offset': offset})
|
||||
items = result.get('items', result) if isinstance(result, dict) else result
|
||||
if isinstance(result, dict):
|
||||
all_posts.extend(result['items'])
|
||||
if not result.get('hasMore', False):
|
||||
break
|
||||
else:
|
||||
all_posts.extend(result)
|
||||
if len(result) < limit:
|
||||
break
|
||||
offset += limit
|
||||
|
||||
print(f"Total posts: {len(all_posts)}")
|
||||
|
||||
suspicious = []
|
||||
|
||||
for post in all_posts:
|
||||
langs = post.get('availableLanguages', [])
|
||||
canonical_lang = post.get('language', '')
|
||||
|
||||
# Only check posts that have translations
|
||||
translation_langs = [l for l in langs if l != canonical_lang]
|
||||
if not translation_langs:
|
||||
continue
|
||||
|
||||
canonical_content = post.get('content', '') or ''
|
||||
canonical_len = len(canonical_content.strip())
|
||||
canonical_is_macro = bool(re.match(MACRO_PATTERN, canonical_content.strip(), re.DOTALL)) if canonical_content.strip() else False
|
||||
canonical_is_empty = canonical_len < 20
|
||||
|
||||
# Get translations for this post
|
||||
translations = await bds.posts.get_translations(post_id=post['id'])
|
||||
if not translations:
|
||||
continue
|
||||
|
||||
for tr in translations:
|
||||
tr_content = tr.get('content', '') or ''
|
||||
tr_len = len(tr_content.strip())
|
||||
tr_lang = tr.get('language', '?')
|
||||
reasons = []
|
||||
|
||||
# Check 1: empty/tiny canonical but substantial translation
|
||||
if canonical_is_empty and tr_len > MIN_HALLUCINATION_SIZE:
|
||||
reasons.append(f"empty canonical ({canonical_len}b) but translation has {tr_len}b")
|
||||
|
||||
# Check 2: macro-only canonical but translation has prose
|
||||
if canonical_is_macro and tr_len > MIN_HALLUCINATION_SIZE:
|
||||
tr_is_macro = bool(re.match(MACRO_PATTERN, tr_content.strip(), re.DOTALL))
|
||||
if not tr_is_macro:
|
||||
reasons.append(f"canonical is macro-only but translation has prose ({tr_len}b)")
|
||||
|
||||
# Check 3: translation is disproportionately longer
|
||||
if canonical_len > 20 and tr_len > 0:
|
||||
ratio = tr_len / canonical_len
|
||||
if ratio > SIZE_RATIO_THRESHOLD:
|
||||
reasons.append(f"translation is {ratio:.1f}x longer ({canonical_len}b → {tr_len}b)")
|
||||
|
||||
if reasons:
|
||||
suspicious.append({
|
||||
'slug': post.get('slug', ''),
|
||||
'title': post.get('title', ''),
|
||||
'canonical_lang': canonical_lang,
|
||||
'translation_lang': tr_lang,
|
||||
'canonical_len': canonical_len,
|
||||
'translation_len': tr_len,
|
||||
'reasons': reasons,
|
||||
})
|
||||
|
||||
# Print results
|
||||
print(f"\nChecked {len(all_posts)} posts")
|
||||
print(f"Found {len(suspicious)} suspicious translations:\n")
|
||||
|
||||
for s in suspicious:
|
||||
print(f" {s['slug']} ({s['canonical_lang']}→{s['translation_lang']})")
|
||||
print(f" title: {s['title']}")
|
||||
print(f" sizes: canonical={s['canonical_len']}b, translation={s['translation_len']}b")
|
||||
for r in s['reasons']:
|
||||
print(f" ⚠ {r}")
|
||||
print()
|
||||
|
||||
|
||||
await audit()
|
||||
@@ -35,6 +35,7 @@ export const posts = sqliteTable('posts', {
|
||||
categories: text('categories'), // JSON array stored as text
|
||||
templateSlug: text('template_slug'), // Optional user template override for this post
|
||||
language: text('language'), // Optional per-post language override (ISO code, e.g. 'en', 'de')
|
||||
doNotTranslate: integer('do_not_translate', { mode: 'boolean' }).notNull().default(false), // Exclude from translation
|
||||
// Legacy columns (kept for migration compatibility, no longer written)
|
||||
publishedTitle: text('published_title'),
|
||||
publishedContent: text('published_content'),
|
||||
@@ -46,6 +47,24 @@ export const posts = sqliteTable('posts', {
|
||||
projectSlugIdx: uniqueIndex('posts_project_slug_idx').on(table.projectId, table.slug),
|
||||
}));
|
||||
|
||||
export const postTranslations = sqliteTable('post_translations', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').notNull(),
|
||||
translationFor: text('translation_for').notNull(),
|
||||
language: text('language').notNull(),
|
||||
title: text('title').notNull(),
|
||||
excerpt: text('excerpt'),
|
||||
content: text('content'),
|
||||
status: text('status', { enum: ['draft', 'published', 'archived'] }).notNull().default('draft'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
publishedAt: integer('published_at', { mode: 'timestamp' }),
|
||||
filePath: text('file_path').notNull().default(''),
|
||||
checksum: text('checksum'),
|
||||
}, (table) => ({
|
||||
translationLanguageIdx: uniqueIndex('post_translations_translation_language_idx').on(table.translationFor, table.language),
|
||||
}));
|
||||
|
||||
// Media table - stores metadata for images and other media
|
||||
export const media = sqliteTable('media', {
|
||||
projectId: text('project_id').notNull(),
|
||||
@@ -66,8 +85,28 @@ export const media = sqliteTable('media', {
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
checksum: text('checksum'),
|
||||
tags: text('tags'), // JSON array stored as text
|
||||
language: text('language'), // Optional per-media language override (ISO code, e.g. 'en', 'de')
|
||||
});
|
||||
|
||||
// Media translations table - stores localized metadata for media items
|
||||
// The binary asset remains shared; only title, alt, and caption vary by language.
|
||||
export const mediaTranslations = sqliteTable('media_translations', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').notNull(),
|
||||
translationFor: text('translation_for').notNull(),
|
||||
language: text('language').notNull(),
|
||||
title: text('title'),
|
||||
alt: text('alt'),
|
||||
caption: text('caption'),
|
||||
createdAt: integer('created_at', { mode: 'timestamp' }).notNull(),
|
||||
updatedAt: integer('updated_at', { mode: 'timestamp' }).notNull(),
|
||||
}, (table) => ({
|
||||
translationLanguageIdx: uniqueIndex('media_translations_translation_language_idx').on(table.translationFor, table.language),
|
||||
}));
|
||||
|
||||
export type MediaTranslation = typeof mediaTranslations.$inferSelect;
|
||||
export type NewMediaTranslation = typeof mediaTranslations.$inferInsert;
|
||||
|
||||
// App settings - stores application configuration
|
||||
export const settings = sqliteTable('settings', {
|
||||
key: text('key').primaryKey(),
|
||||
@@ -294,6 +333,8 @@ export type Project = typeof projects.$inferSelect;
|
||||
export type NewProject = typeof projects.$inferInsert;
|
||||
export type Post = typeof posts.$inferSelect;
|
||||
export type NewPost = typeof posts.$inferInsert;
|
||||
export type PostTranslation = typeof postTranslations.$inferSelect;
|
||||
export type NewPostTranslation = typeof postTranslations.$inferInsert;
|
||||
export type Media = typeof media.$inferSelect;
|
||||
export type NewMedia = typeof media.$inferInsert;
|
||||
export type Setting = typeof settings.$inferSelect;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs/promises';
|
||||
import type { PostEngine, PostData } from './PostEngine';
|
||||
import type { PostData, PostTranslationData } from './PostEngine';
|
||||
import type { MediaEngine, MediaData } from './MediaEngine';
|
||||
import type { PostMediaEngine } from './PostMediaEngine';
|
||||
import {
|
||||
@@ -15,7 +15,7 @@ import { getPicoStylesheetHref, sanitizePicoTheme, type PicoThemeName } from '..
|
||||
import type { MenuDocument } from './MenuEngine';
|
||||
import type { ProjectMetadata } from './MetaEngine';
|
||||
import { loadPublishedGenerationSets } from './GenerationPostSnapshotService';
|
||||
import { buildCalendarArchiveData, buildSitemapAndFeeds, collectSitemapArchiveMetadata } from './GenerationSitemapFeedService';
|
||||
import { buildCalendarArchiveData, buildSitemapAndFeeds, collectSitemapArchiveMetadata, buildMultiLanguageSitemap } from './GenerationSitemapFeedService';
|
||||
import { buildTargetedValidationPlan, planMissingValidationPaths } from './ValidationApplyPlannerService';
|
||||
import { compareSitemapToHtml } from './SiteValidationDiffService';
|
||||
import {
|
||||
@@ -58,6 +58,7 @@ export interface BlogGenerationOptions {
|
||||
baseUrl: string;
|
||||
maxPostsPerPage?: number;
|
||||
language?: string;
|
||||
blogLanguages?: string[];
|
||||
pageTitle?: string;
|
||||
picoTheme?: PicoThemeName;
|
||||
categoryMetadata?: Record<string, CategoryMetadata>;
|
||||
@@ -194,17 +195,79 @@ function resolvePostCreatedAt(post: { createdAt: Date | string }): Date {
|
||||
return Number.isNaN(parsed.getTime()) ? new Date() : parsed;
|
||||
}
|
||||
|
||||
type PublishedTranslationVariant = PostData & {
|
||||
translationSourceSlug: string;
|
||||
translationCanonicalLanguage?: string;
|
||||
translationFilePath: string;
|
||||
};
|
||||
|
||||
interface BlogGenerationPostEngineContract {
|
||||
getPostsFiltered: (filter: { status?: 'draft' | 'published' | 'archived'; excludeCategories?: string[] }) => Promise<PostData[]>;
|
||||
getPublishedVersion: (id: string) => Promise<PostData | null>;
|
||||
getPost: (postId: string) => Promise<PostData | null>;
|
||||
hasPublishedVersion: (postId: string) => Promise<boolean>;
|
||||
getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>;
|
||||
getPostTranslations?: (postId: string) => Promise<PostTranslationData[]>;
|
||||
setProjectContext: (projectId: string, dataDir?: string) => void;
|
||||
}
|
||||
|
||||
export class BlogGenerationEngine {
|
||||
private readonly postEngine: PostEngine;
|
||||
private readonly postEngine: BlogGenerationPostEngineContract;
|
||||
private readonly mediaEngine: MediaEngine;
|
||||
private readonly postMediaEngine: PostMediaEngine;
|
||||
|
||||
constructor(postEngine: PostEngine, mediaEngine: MediaEngine, postMediaEngine: PostMediaEngine) {
|
||||
constructor(postEngine: BlogGenerationPostEngineContract, mediaEngine: MediaEngine, postMediaEngine: PostMediaEngine) {
|
||||
this.postEngine = postEngine;
|
||||
this.mediaEngine = mediaEngine;
|
||||
this.postMediaEngine = postMediaEngine;
|
||||
}
|
||||
|
||||
private buildPublishedTranslationVariant(sourcePost: PostData, translation: PostTranslationData): PublishedTranslationVariant {
|
||||
const canonicalLanguage = typeof sourcePost.language === 'string' ? sourcePost.language.trim() : '';
|
||||
const variantLanguages = Array.from(new Set([
|
||||
canonicalLanguage,
|
||||
...(Array.isArray(sourcePost.availableLanguages) ? sourcePost.availableLanguages : []),
|
||||
translation.language,
|
||||
].filter((language) => typeof language === 'string' && language.trim().length > 0)));
|
||||
|
||||
return {
|
||||
...sourcePost,
|
||||
id: translation.id,
|
||||
slug: `${sourcePost.slug}.${translation.language}`,
|
||||
title: translation.title,
|
||||
excerpt: translation.excerpt,
|
||||
content: translation.content,
|
||||
language: translation.language,
|
||||
updatedAt: translation.updatedAt,
|
||||
publishedAt: translation.publishedAt ?? sourcePost.publishedAt,
|
||||
availableLanguages: variantLanguages,
|
||||
translationSourceSlug: sourcePost.slug,
|
||||
translationCanonicalLanguage: canonicalLanguage || undefined,
|
||||
translationFilePath: translation.filePath,
|
||||
};
|
||||
}
|
||||
|
||||
private async buildPublishedRoutePosts(publishedPosts: PostData[]): Promise<PostData[]> {
|
||||
const routePosts: PostData[] = [...publishedPosts];
|
||||
|
||||
if (typeof this.postEngine.getPostTranslations !== 'function') {
|
||||
return routePosts;
|
||||
}
|
||||
|
||||
for (const post of publishedPosts) {
|
||||
const translations = await this.postEngine.getPostTranslations(post.id);
|
||||
for (const translation of translations) {
|
||||
if (translation.status !== 'published') {
|
||||
continue;
|
||||
}
|
||||
|
||||
routePosts.push(this.buildPublishedTranslationVariant(post, translation));
|
||||
}
|
||||
}
|
||||
|
||||
return routePosts;
|
||||
}
|
||||
|
||||
async generate(options: BlogGenerationOptions, onProgress: (progress: number, message?: string) => void): Promise<BlogGenerationResult> {
|
||||
onProgress(0, 'Loading posts...');
|
||||
|
||||
@@ -226,6 +289,7 @@ export class BlogGenerationEngine {
|
||||
|
||||
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
|
||||
const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories);
|
||||
const publishedRoutePosts = await this.buildPublishedRoutePosts(publishedPosts);
|
||||
|
||||
onProgress(3, `Found ${publishedPosts.length} published posts`);
|
||||
|
||||
@@ -250,7 +314,7 @@ export class BlogGenerationEngine {
|
||||
projectName: options.projectName,
|
||||
projectDescription: options.projectDescription,
|
||||
maxPostsPerPage,
|
||||
publishedPosts,
|
||||
publishedPosts: publishedRoutePosts,
|
||||
publishedListPosts,
|
||||
postIndex: generationPostIndex,
|
||||
includeFeeds: true,
|
||||
@@ -273,7 +337,7 @@ export class BlogGenerationEngine {
|
||||
const archiveMetadata = collectSitemapArchiveMetadata({
|
||||
baseUrl: options.baseUrl,
|
||||
maxPostsPerPage,
|
||||
publishedPosts,
|
||||
publishedPosts: publishedRoutePosts,
|
||||
publishedListPosts,
|
||||
});
|
||||
|
||||
@@ -368,7 +432,7 @@ export class BlogGenerationEngine {
|
||||
const renderRoute = createPreviewBackedGenerationRouteRenderer({
|
||||
options,
|
||||
maxPostsPerPage,
|
||||
publishedPostsForLookup: publishedPosts,
|
||||
publishedPostsForLookup: publishedRoutePosts,
|
||||
engines: {
|
||||
postEngine: this.postEngine,
|
||||
mediaEngine: this.mediaEngine,
|
||||
@@ -400,7 +464,7 @@ export class BlogGenerationEngine {
|
||||
});
|
||||
pagesGenerated += await generatePageRoutes({
|
||||
projectId: options.projectId,
|
||||
posts: publishedPosts,
|
||||
posts: publishedRoutePosts,
|
||||
renderRoute,
|
||||
writePage,
|
||||
onPageGenerated: reportUnitProgress,
|
||||
@@ -411,7 +475,7 @@ export class BlogGenerationEngine {
|
||||
onProgress(35, 'Generating single post pages...');
|
||||
pagesGenerated += await generateSinglePostPages({
|
||||
projectId: options.projectId,
|
||||
posts: publishedPosts,
|
||||
posts: publishedRoutePosts,
|
||||
renderRoute,
|
||||
writePage,
|
||||
onPageGenerated: reportUnitProgress,
|
||||
@@ -464,6 +528,173 @@ export class BlogGenerationEngine {
|
||||
});
|
||||
}
|
||||
|
||||
// --- Alternative language subtree generation ---
|
||||
const mainLanguage = (options.language ?? 'en').trim().toLowerCase();
|
||||
const additionalLanguages = (options.blogLanguages ?? [])
|
||||
.map((lang) => lang.trim().toLowerCase())
|
||||
.filter((lang) => lang.length > 0 && lang !== mainLanguage);
|
||||
|
||||
for (const lang of additionalLanguages) {
|
||||
onProgress(85, `Generating ${lang} language subtree...`);
|
||||
|
||||
// Filter out doNotTranslate posts
|
||||
const langPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
|
||||
const langListPosts = publishedListPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
|
||||
|
||||
const langPostIndex = buildGenerationPostIndex(langListPosts);
|
||||
const langArchiveMetadata = collectSitemapArchiveMetadata({
|
||||
baseUrl: options.baseUrl,
|
||||
maxPostsPerPage,
|
||||
publishedPosts: langPosts,
|
||||
publishedListPosts: langListPosts,
|
||||
});
|
||||
|
||||
// Build per-language feeds
|
||||
if (includeCore) {
|
||||
const langFeedResult = buildSitemapAndFeeds({
|
||||
baseUrl: `${options.baseUrl}/${lang}`,
|
||||
projectName: options.projectName,
|
||||
projectDescription: options.projectDescription,
|
||||
maxPostsPerPage,
|
||||
publishedPosts: langPosts,
|
||||
publishedListPosts: langListPosts,
|
||||
postIndex: langPostIndex,
|
||||
includeFeeds: true,
|
||||
feedLanguage: lang,
|
||||
});
|
||||
|
||||
const langRssPath = path.join(htmlDir, lang, 'rss.xml');
|
||||
const langAtomPath = path.join(htmlDir, lang, 'atom.xml');
|
||||
await fs.mkdir(path.join(htmlDir, lang), { recursive: true });
|
||||
await writeFileIfHashChanged({ projectId: options.projectId, filePath: langRssPath, relativePath: `${lang}/rss.xml`, content: langFeedResult.rssXml });
|
||||
await writeFileIfHashChanged({ projectId: options.projectId, filePath: langAtomPath, relativePath: `${lang}/atom.xml`, content: langFeedResult.atomXml });
|
||||
}
|
||||
|
||||
const langRenderRoute = createPreviewBackedGenerationRouteRenderer({
|
||||
options: { ...options, language: lang },
|
||||
maxPostsPerPage,
|
||||
publishedPostsForLookup: langPosts,
|
||||
languagePrefix: `/${lang}`,
|
||||
engines: {
|
||||
postEngine: this.postEngine,
|
||||
mediaEngine: this.mediaEngine,
|
||||
postMediaEngine: this.postMediaEngine,
|
||||
},
|
||||
});
|
||||
|
||||
const langWritePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({
|
||||
projectId,
|
||||
htmlDir,
|
||||
urlPath: `${lang}/${urlPath}`,
|
||||
content,
|
||||
knownDirectories: knownOutputDirectories,
|
||||
hashCache: generatedHashCache,
|
||||
refreshHashTimestampOnUnchanged: true,
|
||||
});
|
||||
|
||||
const langReportProgress = (message: string) => reportUnitProgress(`[${lang}] ${message}`);
|
||||
|
||||
if (includeCore) {
|
||||
pagesGenerated += await generateRootPages({
|
||||
projectId: options.projectId,
|
||||
posts: langListPosts,
|
||||
maxPostsPerPage,
|
||||
renderRoute: langRenderRoute,
|
||||
writePage: langWritePage,
|
||||
onPageGenerated: langReportProgress,
|
||||
});
|
||||
pagesGenerated += await generatePageRoutes({
|
||||
projectId: options.projectId,
|
||||
posts: langPosts,
|
||||
renderRoute: langRenderRoute,
|
||||
writePage: langWritePage,
|
||||
onPageGenerated: langReportProgress,
|
||||
});
|
||||
}
|
||||
|
||||
if (includeSingle) {
|
||||
pagesGenerated += await generateSinglePostPages({
|
||||
projectId: options.projectId,
|
||||
posts: langPosts,
|
||||
renderRoute: langRenderRoute,
|
||||
writePage: langWritePage,
|
||||
onPageGenerated: langReportProgress,
|
||||
});
|
||||
}
|
||||
|
||||
if (includeCategory) {
|
||||
pagesGenerated += await generateCategoryPages({
|
||||
projectId: options.projectId,
|
||||
posts: langListPosts,
|
||||
allCategories: langArchiveMetadata.allCategories,
|
||||
maxPostsPerPage,
|
||||
renderRoute: langRenderRoute,
|
||||
writePage: langWritePage,
|
||||
onPageGenerated: langReportProgress,
|
||||
postsByCategory: langPostIndex.postsByCategory,
|
||||
});
|
||||
}
|
||||
|
||||
if (includeTag) {
|
||||
pagesGenerated += await generateTagPages({
|
||||
projectId: options.projectId,
|
||||
posts: langListPosts,
|
||||
allTags: langArchiveMetadata.allTags,
|
||||
maxPostsPerPage,
|
||||
renderRoute: langRenderRoute,
|
||||
writePage: langWritePage,
|
||||
onPageGenerated: langReportProgress,
|
||||
postsByTag: langPostIndex.postsByTag,
|
||||
});
|
||||
}
|
||||
|
||||
if (includeDate) {
|
||||
pagesGenerated += await generateDateArchivePages({
|
||||
projectId: options.projectId,
|
||||
posts: langListPosts,
|
||||
yearsMap: langArchiveMetadata.years,
|
||||
yearMonthsMap: langArchiveMetadata.yearMonths,
|
||||
yearMonthDaysMap: langArchiveMetadata.yearMonthDays,
|
||||
maxPostsPerPage,
|
||||
renderRoute: langRenderRoute,
|
||||
writePage: langWritePage,
|
||||
onPageGenerated: langReportProgress,
|
||||
postsByYear: langPostIndex.postsByYear,
|
||||
postsByYearMonth: langPostIndex.postsByYearMonth,
|
||||
postsByYearMonthDay: langPostIndex.postsByYearMonthDay,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// --- Combined sitemap with hreflang (if multiple languages) ---
|
||||
if (includeCore && additionalLanguages.length > 0) {
|
||||
const allLanguages = [mainLanguage, ...additionalLanguages];
|
||||
const langFilteredPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
|
||||
const doNotTranslateIds = new Set(
|
||||
publishedPosts
|
||||
.filter((p) => (p as PostData & { doNotTranslate?: boolean }).doNotTranslate)
|
||||
.map((p) => p.id),
|
||||
);
|
||||
|
||||
const hreflangSitemapXml = buildMultiLanguageSitemap({
|
||||
baseUrl: options.baseUrl,
|
||||
mainLanguage,
|
||||
allLanguages,
|
||||
translatablePosts: langFilteredPosts,
|
||||
doNotTranslatePosts: publishedPosts.filter((p) => doNotTranslateIds.has(p.id)),
|
||||
publishedListPosts,
|
||||
maxPostsPerPage,
|
||||
postIndex: generationPostIndex,
|
||||
});
|
||||
|
||||
sitemapWritten = await writeFileIfHashChanged({
|
||||
projectId: options.projectId,
|
||||
filePath: sitemapPath,
|
||||
relativePath: 'sitemap.xml',
|
||||
content: hreflangSitemapXml,
|
||||
});
|
||||
}
|
||||
|
||||
onProgress(100, `Site generated (${publishedPosts.length} posts, ${pagesGenerated} pages)`);
|
||||
|
||||
return {
|
||||
@@ -535,6 +766,7 @@ export class BlogGenerationEngine {
|
||||
.map(([category]) => category);
|
||||
|
||||
const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories);
|
||||
const publishedRoutePosts = await this.buildPublishedRoutePosts(publishedPosts);
|
||||
const generationPostIndex = buildGenerationPostIndex(publishedListPosts);
|
||||
|
||||
const { sitemapXml } = buildSitemapAndFeeds({
|
||||
@@ -542,7 +774,7 @@ export class BlogGenerationEngine {
|
||||
projectName: options.projectName,
|
||||
projectDescription: options.projectDescription,
|
||||
maxPostsPerPage,
|
||||
publishedPosts,
|
||||
publishedPosts: publishedRoutePosts,
|
||||
publishedListPosts,
|
||||
postIndex: generationPostIndex,
|
||||
includeFeeds: false,
|
||||
@@ -551,20 +783,111 @@ export class BlogGenerationEngine {
|
||||
const htmlDir = path.join(options.dataDir, 'html');
|
||||
await fs.mkdir(htmlDir, { recursive: true });
|
||||
const sitemapPath = path.join(htmlDir, 'sitemap.xml');
|
||||
|
||||
// --- Build per-language expected paths ---
|
||||
const mainLanguage = (options.language ?? 'en').trim().toLowerCase();
|
||||
const additionalLanguages = (options.blogLanguages ?? [])
|
||||
.map((lang) => lang.trim().toLowerCase())
|
||||
.filter((lang) => lang.length > 0 && lang !== mainLanguage);
|
||||
|
||||
let sitemapToWrite = sitemapXml;
|
||||
const additionalExpectedPaths: string[] = [];
|
||||
const additionalPostTimestampChecks: Array<{
|
||||
postUrlPath: string;
|
||||
postFilePath: string;
|
||||
generatedUpdatedAtMs?: number;
|
||||
}> = [];
|
||||
|
||||
if (additionalLanguages.length > 0) {
|
||||
const langPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
|
||||
const langListPosts = publishedListPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
|
||||
|
||||
for (const lang of additionalLanguages) {
|
||||
const langPostIndex = buildGenerationPostIndex(langListPosts);
|
||||
const langSitemapResult = buildSitemapAndFeeds({
|
||||
baseUrl: `${options.baseUrl}/${lang}`,
|
||||
projectName: options.projectName,
|
||||
projectDescription: options.projectDescription,
|
||||
maxPostsPerPage,
|
||||
publishedPosts: langPosts,
|
||||
publishedListPosts: langListPosts,
|
||||
postIndex: langPostIndex,
|
||||
includeFeeds: false,
|
||||
});
|
||||
|
||||
// Extract expected paths from the per-language sitemap, stripping base URL
|
||||
const langLocMatches = langSitemapResult.sitemapXml.matchAll(/<loc>(.*?)<\/loc>/g);
|
||||
for (const match of langLocMatches) {
|
||||
const loc = match[1]?.trim();
|
||||
if (!loc) continue;
|
||||
try {
|
||||
const locUrl = new URL(loc);
|
||||
const base = new URL(options.baseUrl);
|
||||
let locPath = locUrl.pathname.replace(/\/+$/, '');
|
||||
const basePath = base.pathname.replace(/\/+$/, '');
|
||||
if (basePath && locPath.startsWith(basePath)) {
|
||||
locPath = locPath.slice(basePath.length);
|
||||
}
|
||||
additionalExpectedPaths.push(locPath || '/');
|
||||
} catch {
|
||||
additionalExpectedPaths.push(loc);
|
||||
}
|
||||
}
|
||||
|
||||
// Build per-language post timestamp checks
|
||||
for (const post of langPosts) {
|
||||
const createdAt = resolvePostCreatedAt(post);
|
||||
const year = String(createdAt.getFullYear());
|
||||
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||
const postFilePath = path.join(options.dataDir, 'posts', year, month, `${post.slug}.md`);
|
||||
const postUrlPath = `/${lang}${buildCanonicalPostPath(post)}`;
|
||||
const relativePath = `${postUrlPath.replace(/^\//, '')}/index.html`;
|
||||
const generatedRecord = await getGeneratedFileHashRecord(options.projectId, relativePath);
|
||||
|
||||
additionalPostTimestampChecks.push({
|
||||
postUrlPath,
|
||||
postFilePath,
|
||||
generatedUpdatedAtMs: generatedRecord?.updatedAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Write multi-language sitemap
|
||||
const allLanguages = [mainLanguage, ...additionalLanguages];
|
||||
const langFilteredPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
|
||||
const doNotTranslateIds = new Set(
|
||||
publishedPosts
|
||||
.filter((p) => (p as PostData & { doNotTranslate?: boolean }).doNotTranslate)
|
||||
.map((p) => p.id),
|
||||
);
|
||||
|
||||
sitemapToWrite = buildMultiLanguageSitemap({
|
||||
baseUrl: options.baseUrl,
|
||||
mainLanguage,
|
||||
allLanguages,
|
||||
translatablePosts: langFilteredPosts,
|
||||
doNotTranslatePosts: publishedPosts.filter((p) => doNotTranslateIds.has(p.id)),
|
||||
publishedListPosts,
|
||||
maxPostsPerPage,
|
||||
postIndex: generationPostIndex,
|
||||
});
|
||||
}
|
||||
|
||||
const sitemapChanged = await writeFileIfHashChanged({
|
||||
projectId: options.projectId,
|
||||
filePath: sitemapPath,
|
||||
relativePath: 'sitemap.xml',
|
||||
content: sitemapXml,
|
||||
content: sitemapToWrite,
|
||||
});
|
||||
|
||||
onProgress(50, 'Comparing sitemap to html pages...');
|
||||
|
||||
const postTimestampChecks = await Promise.all(publishedPosts.map(async (post) => {
|
||||
const postTimestampChecks = await Promise.all(publishedRoutePosts.map(async (post) => {
|
||||
const createdAt = resolvePostCreatedAt(post);
|
||||
const year = String(createdAt.getFullYear());
|
||||
const month = String(createdAt.getMonth() + 1).padStart(2, '0');
|
||||
const postFilePath = path.join(options.dataDir, 'posts', year, month, `${post.slug}.md`);
|
||||
const postFilePath = (post as PublishedTranslationVariant).translationFilePath
|
||||
?? path.join(options.dataDir, 'posts', year, month, `${post.slug}.md`);
|
||||
const postUrlPath = buildCanonicalPostPath(post);
|
||||
const relativePath = `${postUrlPath.replace(/^\//, '')}/index.html`;
|
||||
const generatedRecord = await getGeneratedFileHashRecord(options.projectId, relativePath);
|
||||
@@ -580,7 +903,8 @@ export class BlogGenerationEngine {
|
||||
sitemapXml,
|
||||
baseUrl: options.baseUrl,
|
||||
htmlDir,
|
||||
postTimestampChecks,
|
||||
postTimestampChecks: [...postTimestampChecks, ...additionalPostTimestampChecks],
|
||||
additionalExpectedPaths,
|
||||
});
|
||||
|
||||
onProgress(
|
||||
@@ -613,7 +937,12 @@ export class BlogGenerationEngine {
|
||||
|
||||
onProgress(10, 'Planning validation apply steps...');
|
||||
|
||||
const missingPathPlan = planMissingValidationPaths(rerenderPaths);
|
||||
const mainLanguage = (options.language ?? 'en').trim().toLowerCase();
|
||||
const additionalLanguages = (options.blogLanguages ?? [])
|
||||
.map((lang) => lang.trim().toLowerCase())
|
||||
.filter((lang) => lang.length > 0 && lang !== mainLanguage);
|
||||
|
||||
const missingPathPlan = planMissingValidationPaths(rerenderPaths, additionalLanguages);
|
||||
|
||||
onProgress(20, 'Deleting extra URLs...');
|
||||
|
||||
@@ -687,13 +1016,14 @@ export class BlogGenerationEngine {
|
||||
|
||||
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage);
|
||||
const { publishedPosts, publishedListPosts } = await loadPublishedGenerationSets(this.postEngine, listExcludedCategories);
|
||||
const publishedRoutePosts = await this.buildPublishedRoutePosts(publishedPosts);
|
||||
const generationPostIndex = buildGenerationPostIndex(publishedListPosts);
|
||||
|
||||
const { allCategories, allTags, years, yearMonths, yearMonthDays } = buildApplyValidationArchives(publishedListPosts);
|
||||
|
||||
const targetedPlan = buildTargetedValidationPlan({
|
||||
initialPlan: missingPathPlan,
|
||||
publishedPosts,
|
||||
publishedPosts: publishedRoutePosts,
|
||||
allCategories,
|
||||
allTags,
|
||||
availableYearMonths: yearMonths.keys(),
|
||||
@@ -706,7 +1036,7 @@ export class BlogGenerationEngine {
|
||||
const renderRoute = createPreviewBackedGenerationRouteRenderer({
|
||||
options,
|
||||
maxPostsPerPage,
|
||||
publishedPostsForLookup: publishedPosts,
|
||||
publishedPostsForLookup: publishedRoutePosts,
|
||||
engines: {
|
||||
postEngine: this.postEngine,
|
||||
mediaEngine: this.mediaEngine,
|
||||
@@ -725,7 +1055,7 @@ export class BlogGenerationEngine {
|
||||
};
|
||||
|
||||
const { requestedSinglePosts, requestedPagePosts } = selectRequestedPosts({
|
||||
publishedPosts,
|
||||
publishedPosts: publishedRoutePosts,
|
||||
requestedPostIds: targetedPlan.requestedPostIds,
|
||||
requestedPageSlugs: targetedPlan.requestedPageSlugs,
|
||||
});
|
||||
@@ -819,6 +1149,136 @@ export class BlogGenerationEngine {
|
||||
postsByYearMonthDay: generationPostIndex.postsByYearMonthDay,
|
||||
});
|
||||
}
|
||||
|
||||
// --- Render missing per-language subtree pages ---
|
||||
for (const [lang, langMissingPlan] of missingPathPlan.languagePlans) {
|
||||
const langPosts = publishedPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
|
||||
const langListPosts = publishedListPosts.filter((p) => !(p as PostData & { doNotTranslate?: boolean }).doNotTranslate);
|
||||
const langPostIndex = buildGenerationPostIndex(langListPosts);
|
||||
const langArchives = buildApplyValidationArchives(langListPosts);
|
||||
|
||||
const langTargetedPlan = buildTargetedValidationPlan({
|
||||
initialPlan: langMissingPlan,
|
||||
publishedPosts: langPosts,
|
||||
allCategories: langArchives.allCategories,
|
||||
allTags: langArchives.allTags,
|
||||
availableYearMonths: langArchives.yearMonths.keys(),
|
||||
availableYearMonthDays: langArchives.yearMonthDays.keys(),
|
||||
});
|
||||
|
||||
const langRenderRoute = createPreviewBackedGenerationRouteRenderer({
|
||||
options: { ...options, language: lang },
|
||||
maxPostsPerPage,
|
||||
publishedPostsForLookup: langPosts,
|
||||
languagePrefix: `/${lang}`,
|
||||
engines: {
|
||||
postEngine: this.postEngine,
|
||||
mediaEngine: this.mediaEngine,
|
||||
postMediaEngine: this.postMediaEngine,
|
||||
},
|
||||
});
|
||||
|
||||
const langWritePage = (projectId: string, urlPath: string, content: string) => writeHtmlPage({
|
||||
projectId,
|
||||
htmlDir,
|
||||
urlPath: `${lang}/${urlPath}`,
|
||||
content,
|
||||
refreshHashTimestampOnUnchanged: true,
|
||||
});
|
||||
|
||||
if (langTargetedPlan.requestRootRoutes) {
|
||||
renderedUrlCount += await generateRootPages({
|
||||
projectId: options.projectId,
|
||||
posts: langListPosts,
|
||||
maxPostsPerPage,
|
||||
renderRoute: langRenderRoute,
|
||||
writePage: langWritePage,
|
||||
onPageGenerated,
|
||||
});
|
||||
const langRequestedPagePosts = selectRequestedPosts({
|
||||
publishedPosts: langPosts,
|
||||
requestedPostIds: new Set(),
|
||||
requestedPageSlugs: langTargetedPlan.requestedPageSlugs,
|
||||
}).requestedPagePosts;
|
||||
if (langRequestedPagePosts.length > 0) {
|
||||
renderedUrlCount += await generatePageRoutes({
|
||||
projectId: options.projectId,
|
||||
posts: langRequestedPagePosts,
|
||||
renderRoute: langRenderRoute,
|
||||
writePage: langWritePage,
|
||||
onPageGenerated,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (langTargetedPlan.requestedCategorySet.size > 0) {
|
||||
renderedUrlCount += await generateCategoryPages({
|
||||
projectId: options.projectId,
|
||||
posts: langListPosts,
|
||||
allCategories: langTargetedPlan.requestedCategorySet,
|
||||
maxPostsPerPage,
|
||||
renderRoute: langRenderRoute,
|
||||
writePage: langWritePage,
|
||||
onPageGenerated,
|
||||
postsByCategory: langPostIndex.postsByCategory,
|
||||
});
|
||||
}
|
||||
|
||||
if (langTargetedPlan.requestedTagSet.size > 0) {
|
||||
renderedUrlCount += await generateTagPages({
|
||||
projectId: options.projectId,
|
||||
posts: langListPosts,
|
||||
allTags: langTargetedPlan.requestedTagSet,
|
||||
maxPostsPerPage,
|
||||
renderRoute: langRenderRoute,
|
||||
writePage: langWritePage,
|
||||
onPageGenerated,
|
||||
postsByTag: langPostIndex.postsByTag,
|
||||
});
|
||||
}
|
||||
|
||||
const langRequestedSinglePosts = selectRequestedPosts({
|
||||
publishedPosts: langPosts,
|
||||
requestedPostIds: langTargetedPlan.requestedPostIds,
|
||||
requestedPageSlugs: new Set(),
|
||||
}).requestedSinglePosts;
|
||||
|
||||
if (langRequestedSinglePosts.length > 0) {
|
||||
renderedUrlCount += await generateSinglePostPages({
|
||||
projectId: options.projectId,
|
||||
posts: langRequestedSinglePosts,
|
||||
renderRoute: langRenderRoute,
|
||||
writePage: langWritePage,
|
||||
onPageGenerated,
|
||||
});
|
||||
}
|
||||
|
||||
const langRequestedArchives = buildRequestedArchiveMaps({
|
||||
requestedYears: langTargetedPlan.requestedYears,
|
||||
requestedYearMonths: langTargetedPlan.requestedYearMonths,
|
||||
requestedYearMonthDays: langTargetedPlan.requestedYearMonthDays,
|
||||
years: langArchives.years,
|
||||
yearMonths: langArchives.yearMonths,
|
||||
yearMonthDays: langArchives.yearMonthDays,
|
||||
});
|
||||
|
||||
if (langRequestedArchives.requestedYearsMap.size > 0 || langRequestedArchives.requestedYearMonthsMap.size > 0 || langRequestedArchives.requestedYearMonthDaysMap.size > 0) {
|
||||
renderedUrlCount += await generateDateArchivePages({
|
||||
projectId: options.projectId,
|
||||
posts: langListPosts,
|
||||
yearsMap: langRequestedArchives.requestedYearsMap,
|
||||
yearMonthsMap: langRequestedArchives.requestedYearMonthsMap,
|
||||
yearMonthDaysMap: langRequestedArchives.requestedYearMonthDaysMap,
|
||||
maxPostsPerPage,
|
||||
renderRoute: langRenderRoute,
|
||||
writePage: langWritePage,
|
||||
onPageGenerated,
|
||||
postsByYear: langPostIndex.postsByYear,
|
||||
postsByYearMonth: langPostIndex.postsByYearMonth,
|
||||
postsByYearMonthDay: langPostIndex.postsByYearMonthDay,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (renderedUrlCount > 0 || deletedUrlCount > 0) {
|
||||
|
||||
@@ -7,6 +7,7 @@ import type { PostData } from './PostEngine';
|
||||
import type { PicoThemeName } from '../shared/picoThemes';
|
||||
import type { CategoryMetadata } from './BlogGenerationEngine';
|
||||
import { PreviewServer } from './PreviewServer';
|
||||
import type { PostTranslationData } from './PostEngine';
|
||||
|
||||
interface RenderContext {
|
||||
projectContext: {
|
||||
@@ -55,12 +56,14 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
|
||||
};
|
||||
maxPostsPerPage: number;
|
||||
publishedPostsForLookup: PostData[];
|
||||
languagePrefix?: string;
|
||||
engines: {
|
||||
postEngine: {
|
||||
getPostsFiltered: (filter: Parameters<PreviewServer['renderRouteForContext']>[1] extends never ? never : any) => Promise<PostData[]>;
|
||||
getPublishedVersion: (postId: string) => Promise<PostData | null>;
|
||||
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
|
||||
getPost: (postId: string) => Promise<PostData | null>;
|
||||
getPostTranslation?: (postId: string, language: string) => Promise<PostTranslationData | null>;
|
||||
hasPublishedVersion: (postId: string) => Promise<boolean>;
|
||||
getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>;
|
||||
setProjectContext: (projectId: string, dataDir?: string) => void;
|
||||
@@ -176,6 +179,9 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
|
||||
return match ?? null;
|
||||
},
|
||||
getPost: (postId: string) => params.engines.postEngine.getPost(postId),
|
||||
getPostTranslation: params.engines.postEngine.getPostTranslation
|
||||
? (postId: string, language: string) => params.engines.postEngine.getPostTranslation!(postId, language)
|
||||
: undefined,
|
||||
hasPublishedVersion: (postId: string) => params.engines.postEngine.hasPublishedVersion(postId),
|
||||
getLinkedBy: params.engines.postEngine.getLinkedBy
|
||||
? (postId: string) => params.engines.postEngine.getLinkedBy!(postId)
|
||||
@@ -218,7 +224,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
|
||||
userTemplatesDir: path.join(params.options.dataDir, 'templates'),
|
||||
});
|
||||
|
||||
const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map<string, string>; canonicalMediaPathBySourcePath: Map<string, string> }> = (async () => {
|
||||
const htmlRewriteContextPromise: Promise<{ canonicalPostPathBySlug: Map<string, string>; canonicalMediaPathBySourcePath: Map<string, string>; languagePrefix?: string }> = (async () => {
|
||||
const canonicalPostPathBySlug = new Map<string, string>();
|
||||
for (const post of params.publishedPostsForLookup) {
|
||||
canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post));
|
||||
@@ -241,6 +247,7 @@ export function createPreviewBackedGenerationRouteRenderer(params: {
|
||||
return {
|
||||
canonicalPostPathBySlug,
|
||||
canonicalMediaPathBySourcePath,
|
||||
languagePrefix: params.languagePrefix,
|
||||
};
|
||||
})();
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ interface BuildSitemapAndFeedsParams {
|
||||
publishedListPosts: PostData[];
|
||||
postIndex: GenerationPostIndexLike;
|
||||
includeFeeds: boolean;
|
||||
feedLanguage?: string;
|
||||
}
|
||||
|
||||
export interface SitemapFeedBuildResult {
|
||||
@@ -419,13 +420,14 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
|
||||
` <title>${escapeXml(feedTitle)}</title>`,
|
||||
` <link>${escapeXml(baseLink)}</link>`,
|
||||
` <description>${escapeXml(feedDescription)}</description>`,
|
||||
params.feedLanguage ? ` <language>${escapeXml(params.feedLanguage)}</language>` : null,
|
||||
` <lastBuildDate>${feedUpdatedAt.toUTCString()}</lastBuildDate>`,
|
||||
' <generator>bDS</generator>',
|
||||
...rssItems,
|
||||
' </channel>',
|
||||
'</rss>',
|
||||
'',
|
||||
].join('\n');
|
||||
].filter(Boolean).join('\n');
|
||||
|
||||
const atomEntries = feedPosts.map((post) => {
|
||||
const createdAt = resolvePostCreatedAt(post);
|
||||
@@ -455,9 +457,11 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
|
||||
].filter(Boolean).join('\n');
|
||||
});
|
||||
|
||||
const atomFeedLangAttr = params.feedLanguage ? ` xml:lang="${escapeXml(params.feedLanguage)}"` : '';
|
||||
|
||||
const atomXml = [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<feed xmlns="http://www.w3.org/2005/Atom">',
|
||||
`<feed xmlns="http://www.w3.org/2005/Atom"${atomFeedLangAttr}>`,
|
||||
` <title>${escapeXml(feedTitle)}</title>`,
|
||||
` <subtitle>${escapeXml(feedDescription)}</subtitle>`,
|
||||
` <id>${escapeXml(baseLink)}</id>`,
|
||||
@@ -482,3 +486,170 @@ export function buildSitemapAndFeeds(params: BuildSitemapAndFeedsParams): Sitema
|
||||
feedPosts,
|
||||
};
|
||||
}
|
||||
|
||||
interface MultiLanguageSitemapParams {
|
||||
baseUrl: string;
|
||||
mainLanguage: string;
|
||||
allLanguages: string[];
|
||||
translatablePosts: PostData[];
|
||||
doNotTranslatePosts: PostData[];
|
||||
publishedListPosts: PostData[];
|
||||
maxPostsPerPage: number;
|
||||
postIndex: GenerationPostIndexLike;
|
||||
}
|
||||
|
||||
function buildHreflangLinks(baseUrl: string, urlPath: string, mainLanguage: string, languages: string[]): string[] {
|
||||
const links: string[] = [];
|
||||
for (const lang of languages) {
|
||||
const prefix = lang === mainLanguage ? '' : `/${lang}`;
|
||||
const href = `${baseUrl}${prefix}${urlPath}`;
|
||||
const canonicalHref = href.endsWith('/') ? href : `${href}/`;
|
||||
links.push(` <xhtml:link rel="alternate" hreflang="${escapeXml(lang)}" href="${escapeXml(canonicalHref)}" />`);
|
||||
}
|
||||
const xDefaultHref = `${baseUrl}${urlPath}`;
|
||||
const canonicalXDefault = xDefaultHref.endsWith('/') ? xDefaultHref : `${xDefaultHref}/`;
|
||||
links.push(` <xhtml:link rel="alternate" hreflang="x-default" href="${escapeXml(canonicalXDefault)}" />`);
|
||||
return links;
|
||||
}
|
||||
|
||||
function buildMultiLanguageSitemapUrl(
|
||||
loc: string,
|
||||
lastmod: string,
|
||||
changefreq: string,
|
||||
priority: string,
|
||||
hreflangLinks: string[],
|
||||
): string {
|
||||
const canonicalLoc = (() => {
|
||||
try {
|
||||
const parsed = new URL(loc);
|
||||
if (!parsed.pathname.endsWith('/')) {
|
||||
parsed.pathname = `${parsed.pathname}/`;
|
||||
}
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return loc.endsWith('/') ? loc : `${loc}/`;
|
||||
}
|
||||
})();
|
||||
|
||||
return [
|
||||
' <url>',
|
||||
` <loc>${escapeXml(canonicalLoc)}</loc>`,
|
||||
` <lastmod>${escapeXml(lastmod)}</lastmod>`,
|
||||
` <changefreq>${changefreq}</changefreq>`,
|
||||
` <priority>${priority}</priority>`,
|
||||
...hreflangLinks,
|
||||
' </url>',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
export function buildMultiLanguageSitemap(params: MultiLanguageSitemapParams): string {
|
||||
const {
|
||||
baseUrl,
|
||||
mainLanguage,
|
||||
allLanguages,
|
||||
translatablePosts,
|
||||
doNotTranslatePosts,
|
||||
publishedListPosts,
|
||||
maxPostsPerPage,
|
||||
postIndex,
|
||||
} = params;
|
||||
|
||||
const now = new Date().toISOString();
|
||||
const latestPostUpdatedAt = publishedListPosts[0]?.updatedAt.toISOString() || now;
|
||||
const urls: string[] = [];
|
||||
|
||||
// Root page — all languages
|
||||
urls.push(buildMultiLanguageSitemapUrl(
|
||||
`${baseUrl}/`, latestPostUpdatedAt, 'daily', '1.0',
|
||||
buildHreflangLinks(baseUrl, '/', mainLanguage, allLanguages),
|
||||
));
|
||||
|
||||
// Root pagination
|
||||
const totalListPages = Math.max(1, Math.ceil(publishedListPosts.length / maxPostsPerPage));
|
||||
for (let page = 2; page <= totalListPages; page++) {
|
||||
urls.push(buildMultiLanguageSitemapUrl(
|
||||
`${baseUrl}/page/${page}`, latestPostUpdatedAt, 'daily', '0.9',
|
||||
buildHreflangLinks(baseUrl, `/page/${page}`, mainLanguage, allLanguages),
|
||||
));
|
||||
}
|
||||
|
||||
// Translatable posts — all languages
|
||||
for (const post of translatablePosts) {
|
||||
const createdAt = resolvePostCreatedAt(post);
|
||||
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
|
||||
urls.push(buildMultiLanguageSitemapUrl(
|
||||
`${baseUrl}${canonicalPath}`, post.updatedAt.toISOString(), 'monthly', '0.8',
|
||||
buildHreflangLinks(baseUrl, canonicalPath, mainLanguage, allLanguages),
|
||||
));
|
||||
}
|
||||
|
||||
// Do-not-translate posts — main language only
|
||||
for (const post of doNotTranslatePosts) {
|
||||
const createdAt = resolvePostCreatedAt(post);
|
||||
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
|
||||
urls.push(buildMultiLanguageSitemapUrl(
|
||||
`${baseUrl}${canonicalPath}`, post.updatedAt.toISOString(), 'monthly', '0.8',
|
||||
buildHreflangLinks(baseUrl, canonicalPath, mainLanguage, [mainLanguage]),
|
||||
));
|
||||
}
|
||||
|
||||
// Page posts (category 'page') — respecting doNotTranslate
|
||||
const allPublishedPosts = [...translatablePosts, ...doNotTranslatePosts];
|
||||
for (const post of allPublishedPosts) {
|
||||
const categories = Array.isArray(post.categories) ? post.categories : [];
|
||||
if (!categories.includes('page')) continue;
|
||||
const trimmedSlug = (post.slug || '').replace(/^\/+|\/+$/g, '');
|
||||
if (trimmedSlug.length === 0) continue;
|
||||
const isTranslatable = !(post as PostData & { doNotTranslate?: boolean }).doNotTranslate;
|
||||
const langs = isTranslatable ? allLanguages : [mainLanguage];
|
||||
urls.push(buildMultiLanguageSitemapUrl(
|
||||
`${baseUrl}/${trimmedSlug}`, post.updatedAt.toISOString(), 'weekly', '0.7',
|
||||
buildHreflangLinks(baseUrl, `/${trimmedSlug}`, mainLanguage, langs),
|
||||
));
|
||||
}
|
||||
|
||||
// Archives — all languages
|
||||
for (const [year, lastmod] of Array.from(postIndex.postsByYear.entries()).sort((a, b) => b[0] - a[0])) {
|
||||
const lastmodStr = lastmod instanceof Date ? lastmod.toISOString() : latestPostUpdatedAt;
|
||||
urls.push(buildMultiLanguageSitemapUrl(
|
||||
`${baseUrl}/${year}`, lastmodStr, 'monthly', '0.5',
|
||||
buildHreflangLinks(baseUrl, `/${year}`, mainLanguage, allLanguages),
|
||||
));
|
||||
}
|
||||
for (const [ym] of Array.from(postIndex.postsByYearMonth.entries()).sort().reverse()) {
|
||||
urls.push(buildMultiLanguageSitemapUrl(
|
||||
`${baseUrl}/${ym}`, latestPostUpdatedAt, 'monthly', '0.5',
|
||||
buildHreflangLinks(baseUrl, `/${ym}`, mainLanguage, allLanguages),
|
||||
));
|
||||
}
|
||||
for (const [ymd] of Array.from(postIndex.postsByYearMonthDay.entries()).sort().reverse()) {
|
||||
urls.push(buildMultiLanguageSitemapUrl(
|
||||
`${baseUrl}/${ymd}`, latestPostUpdatedAt, 'monthly', '0.4',
|
||||
buildHreflangLinks(baseUrl, `/${ymd}`, mainLanguage, allLanguages),
|
||||
));
|
||||
}
|
||||
|
||||
// Categories — all languages
|
||||
for (const category of Array.from(postIndex.postsByCategory.keys()).sort()) {
|
||||
urls.push(buildMultiLanguageSitemapUrl(
|
||||
`${baseUrl}/category/${encodeURIComponent(category)}`, latestPostUpdatedAt, 'weekly', '0.6',
|
||||
buildHreflangLinks(baseUrl, `/category/${encodeURIComponent(category)}`, mainLanguage, allLanguages),
|
||||
));
|
||||
}
|
||||
|
||||
// Tags — all languages
|
||||
for (const tag of Array.from(postIndex.postsByTag.keys()).sort()) {
|
||||
urls.push(buildMultiLanguageSitemapUrl(
|
||||
`${baseUrl}/tag/${encodeURIComponent(tag)}`, latestPostUpdatedAt, 'weekly', '0.6',
|
||||
buildHreflangLinks(baseUrl, `/tag/${encodeURIComponent(tag)}`, mainLanguage, allLanguages),
|
||||
));
|
||||
}
|
||||
|
||||
return [
|
||||
'<?xml version="1.0" encoding="UTF-8"?>',
|
||||
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml">',
|
||||
...urls,
|
||||
'</urlset>',
|
||||
'',
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
@@ -622,6 +622,7 @@ export class ImportExecutionEngine extends EventEmitter {
|
||||
publishedAt,
|
||||
tags: resolvedTags,
|
||||
categories: resolvedCategories,
|
||||
availableLanguages: [],
|
||||
};
|
||||
|
||||
// Write to filesystem first (for published posts)
|
||||
|
||||
@@ -22,6 +22,7 @@ import type {
|
||||
SearchResult,
|
||||
PaginatedResult,
|
||||
PaginationOptions,
|
||||
PostTranslationData,
|
||||
} from './PostEngine';
|
||||
import type { MediaData } from './MediaEngine';
|
||||
import type { CreateScriptInput, ScriptData, ScriptValidationResult } from './ScriptEngine';
|
||||
@@ -76,6 +77,8 @@ interface PostEngineContract {
|
||||
getLinksTo: (postId: string) => Promise<Array<{ id: string; title: string; slug: string }>>;
|
||||
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
|
||||
getPostCounts: (groupBy: Array<'year' | 'month' | 'tag' | 'category' | 'status'>, filter?: { year?: number; month?: number; status?: string; category?: string; tags?: string[] }) => Promise<{ groups: Record<string, string | number>[]; totalPosts: number }>;
|
||||
getPostTranslation: (postId: string, language: string) => Promise<PostTranslationData | null>;
|
||||
getPostTranslations: (postId: string) => Promise<PostTranslationData[]>;
|
||||
}
|
||||
|
||||
interface MediaEngineContract {
|
||||
@@ -174,6 +177,7 @@ export class MCPServer {
|
||||
this.registerResources(server);
|
||||
this.registerResourceTemplates(server);
|
||||
this.registerReadTools(server);
|
||||
this.registerMediaTranslationTools(server);
|
||||
this.registerProposalTools(server);
|
||||
this.registerAcceptDiscardTools(server);
|
||||
this.registerPrompts(server);
|
||||
@@ -512,6 +516,8 @@ export class MCPServer {
|
||||
query: z.string().optional().describe('Full-text search query'),
|
||||
category: z.string().optional().describe('Filter by category'),
|
||||
tags: z.array(z.string()).optional().describe('Filter by tags (all must match)'),
|
||||
language: z.string().optional().describe('Require posts that are available in this language'),
|
||||
missingTranslationLanguage: z.string().optional().describe('Require posts missing this translation language'),
|
||||
year: z.number().optional().describe('Filter by year'),
|
||||
month: z.number().optional().describe('Filter by month (1-12). Requires year.'),
|
||||
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by status'),
|
||||
@@ -527,7 +533,7 @@ export class MCPServer {
|
||||
};
|
||||
}
|
||||
|
||||
const hasFilters = args.category || args.tags || args.year || args.month || args.status;
|
||||
const hasFilters = args.category || args.tags || args.language || args.missingTranslationLanguage || args.year || args.month || args.status;
|
||||
const offset = args.offset ?? 0;
|
||||
const limit = args.limit ?? 50;
|
||||
|
||||
@@ -543,6 +549,8 @@ export class MCPServer {
|
||||
const filter: PostFilter = {};
|
||||
if (args.category) filter.categories = [args.category];
|
||||
if (args.tags) filter.tags = args.tags;
|
||||
if (args.language) filter.language = args.language;
|
||||
if (args.missingTranslationLanguage) filter.missingTranslationLanguage = args.missingTranslationLanguage;
|
||||
if (args.year) filter.year = args.year;
|
||||
if (args.month) filter.month = args.month;
|
||||
if (args.status) filter.status = args.status;
|
||||
@@ -611,9 +619,10 @@ export class MCPServer {
|
||||
// ── read_post_by_slug ──
|
||||
server.registerTool('read_post_by_slug', {
|
||||
title: 'Read Post by Slug',
|
||||
description: 'Read the full content and metadata of a specific blog post by its slug. Includes backlinks and outlinks. Useful when you know the slug but not the ID.',
|
||||
description: 'Read the full content and metadata of a specific blog post by its slug. Includes backlinks and outlinks. Optionally specify a language to read a translation instead of the canonical post.',
|
||||
inputSchema: {
|
||||
slug: z.string().describe('The slug of the post to read'),
|
||||
language: z.string().optional().describe('Language code to read a specific translation (e.g., "en", "fr"). Omit to read the canonical post.'),
|
||||
},
|
||||
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||
}, async (args) => {
|
||||
@@ -624,6 +633,38 @@ export class MCPServer {
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
|
||||
// If a language is requested and it differs from the canonical language, fetch translation
|
||||
if (args.language && args.language !== post.language) {
|
||||
const translation = await this.deps.postEngine.getPostTranslation(post.id, args.language);
|
||||
if (!translation) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `No ${args.language} translation found for "${args.slug}"` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const [backlinks, linksTo] = await Promise.all([
|
||||
this.deps.postEngine.getLinkedBy(post.id),
|
||||
this.deps.postEngine.getLinksTo(post.id),
|
||||
]);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({
|
||||
post: {
|
||||
id: post.id, title: translation.title, slug: post.slug,
|
||||
content: translation.content, excerpt: translation.excerpt,
|
||||
status: post.status, author: post.author,
|
||||
language: translation.language,
|
||||
canonicalLanguage: post.language,
|
||||
categories: post.categories, tags: post.tags, availableLanguages: post.availableLanguages,
|
||||
createdAt: post.createdAt, updatedAt: post.updatedAt,
|
||||
publishedAt: post.publishedAt,
|
||||
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
|
||||
linksTo: linksTo.map(l => ({ id: l.id, title: l.title, slug: l.slug })),
|
||||
},
|
||||
}) }],
|
||||
};
|
||||
}
|
||||
|
||||
const [backlinks, linksTo] = await Promise.all([
|
||||
this.deps.postEngine.getLinkedBy(post.id),
|
||||
this.deps.postEngine.getLinksTo(post.id),
|
||||
@@ -634,7 +675,7 @@ export class MCPServer {
|
||||
id: post.id, title: post.title, slug: post.slug,
|
||||
content: post.content, excerpt: post.excerpt,
|
||||
status: post.status, author: post.author,
|
||||
categories: post.categories, tags: post.tags,
|
||||
categories: post.categories, tags: post.tags, availableLanguages: post.availableLanguages,
|
||||
createdAt: post.createdAt, updatedAt: post.updatedAt,
|
||||
publishedAt: post.publishedAt,
|
||||
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
|
||||
@@ -645,6 +686,87 @@ export class MCPServer {
|
||||
});
|
||||
}
|
||||
|
||||
private registerMediaTranslationTools(server: McpServer): void {
|
||||
// ── get_post_translations ──
|
||||
server.registerTool('get_post_translations', {
|
||||
title: 'Get Post Translations',
|
||||
description: 'List all available translations for a blog post. Returns translation records with language, title, content, excerpt, and status.',
|
||||
inputSchema: {
|
||||
slug: z.string().describe('The slug of the canonical post'),
|
||||
},
|
||||
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||
}, async (args) => {
|
||||
const post = await this.deps.postEngine.getPostBySlug(args.slug);
|
||||
if (!post) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Post with slug "${args.slug}" not found` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
const translations = await this.deps.postEngine.getPostTranslations(post.id);
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({
|
||||
translations: translations.map(t => ({
|
||||
id: t.id,
|
||||
language: t.language,
|
||||
title: t.title,
|
||||
excerpt: t.excerpt,
|
||||
content: t.content,
|
||||
status: t.status,
|
||||
createdAt: t.createdAt,
|
||||
updatedAt: t.updatedAt,
|
||||
})),
|
||||
}) }],
|
||||
};
|
||||
});
|
||||
|
||||
// ── get_media_translations ──
|
||||
server.registerTool('get_media_translations', {
|
||||
title: 'Get Media Translations',
|
||||
description: 'List all available translations for a media item. Returns translation records with language, title, alt, and caption.',
|
||||
inputSchema: {
|
||||
mediaId: z.string().describe('The ID of the media item'),
|
||||
},
|
||||
annotations: { readOnlyHint: true, openWorldHint: false },
|
||||
}, async (args) => {
|
||||
const mediaEngine = this.deps.mediaEngine as import('./MediaEngine').MediaEngine;
|
||||
const translations = await mediaEngine.getMediaTranslations(args.mediaId);
|
||||
return { content: [{ type: 'text' as const, text: JSON.stringify({ translations }) }] };
|
||||
});
|
||||
|
||||
// ── upsert_media_translation ──
|
||||
registerAppTool(server, 'upsert_media_translation', {
|
||||
title: 'Upsert Media Translation',
|
||||
description: 'Create or update a translation of media metadata (title, alt text, caption) for a specific language.',
|
||||
inputSchema: {
|
||||
mediaId: z.string().describe('The ID of the media item to translate'),
|
||||
language: z.string().describe('Target language code (e.g., "fr", "de", "es")'),
|
||||
title: z.string().optional().describe('Translated title'),
|
||||
alt: z.string().optional().describe('Translated alt text'),
|
||||
caption: z.string().optional().describe('Translated caption'),
|
||||
},
|
||||
annotations: { readOnlyHint: false, destructiveHint: false },
|
||||
_meta: { ui: { resourceUri: 'ui://bds/review-media-translation' } },
|
||||
}, async (args: { mediaId: string; language: string; title?: string; alt?: string; caption?: string }) => {
|
||||
try {
|
||||
const mediaEngine = this.deps.mediaEngine as import('./MediaEngine').MediaEngine;
|
||||
const translation = await mediaEngine.upsertMediaTranslation(args.mediaId, args.language, {
|
||||
title: args.title,
|
||||
alt: args.alt,
|
||||
caption: args.caption,
|
||||
});
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ translation }) }],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
content: [{ type: 'text' as const, text: JSON.stringify({ error: `Failed to upsert media translation: ${error instanceof Error ? error.message : String(error)}` }) }],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private registerProposalTools(server: McpServer): void {
|
||||
// ── draft_post ──
|
||||
registerAppTool(server, 'draft_post', {
|
||||
|
||||
@@ -6,7 +6,7 @@ import * as crypto from 'crypto';
|
||||
import { eq, and, gte, lte, lt, desc } from 'drizzle-orm';
|
||||
import { app } from 'electron';
|
||||
import { getDatabase } from '../database';
|
||||
import { media, Media, NewMedia, postMedia } from '../database/schema';
|
||||
import { media, Media, NewMedia, postMedia, mediaTranslations } from '../database/schema';
|
||||
import { stemText, stemQuery, SupportedLanguage } from './stemmer';
|
||||
import { CliNotifier, NoopNotifier } from './CliNotifier';
|
||||
|
||||
@@ -33,10 +33,24 @@ export interface MediaData {
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
author?: string;
|
||||
language?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
tags: string[];
|
||||
linkedPostIds?: string[]; // Posts this media is linked to
|
||||
availableLanguages: string[];
|
||||
}
|
||||
|
||||
export interface MediaTranslationData {
|
||||
id: string;
|
||||
projectId: string;
|
||||
translationFor: string;
|
||||
language: string;
|
||||
title?: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
}
|
||||
|
||||
export interface MediaMetadata {
|
||||
@@ -50,6 +64,7 @@ export interface MediaMetadata {
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
author?: string;
|
||||
language?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags: string[];
|
||||
@@ -62,6 +77,8 @@ export interface MediaFilter {
|
||||
endDate?: Date;
|
||||
year?: number;
|
||||
month?: number;
|
||||
language?: string;
|
||||
missingTranslationLanguage?: string;
|
||||
}
|
||||
|
||||
export interface MediaSearchResult {
|
||||
@@ -348,6 +365,7 @@ export class MediaEngine extends EventEmitter {
|
||||
alt: mediaData.alt,
|
||||
caption: mediaData.caption,
|
||||
author: mediaData.author,
|
||||
language: mediaData.language,
|
||||
createdAt: mediaData.createdAt.toISOString(),
|
||||
updatedAt: mediaData.updatedAt.toISOString(),
|
||||
tags: mediaData.tags,
|
||||
@@ -369,6 +387,7 @@ export class MediaEngine extends EventEmitter {
|
||||
if (metadata.alt) lines.push(`alt: "${metadata.alt}"`);
|
||||
if (metadata.caption) lines.push(`caption: "${metadata.caption}"`);
|
||||
if (metadata.author) lines.push(`author: "${metadata.author}"`);
|
||||
if (metadata.language) lines.push(`language: ${metadata.language}`);
|
||||
|
||||
lines.push(`createdAt: ${metadata.createdAt}`);
|
||||
lines.push(`updatedAt: ${metadata.updatedAt}`);
|
||||
@@ -445,6 +464,9 @@ export class MediaEngine extends EventEmitter {
|
||||
case 'author':
|
||||
metadata.author = value;
|
||||
break;
|
||||
case 'language':
|
||||
metadata.language = value;
|
||||
break;
|
||||
case 'createdAt':
|
||||
metadata.createdAt = value;
|
||||
break;
|
||||
@@ -550,9 +572,11 @@ export class MediaEngine extends EventEmitter {
|
||||
alt: metadata?.alt,
|
||||
caption: metadata?.caption,
|
||||
author: metadata?.author,
|
||||
language: metadata?.language,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
tags: metadata?.tags || [],
|
||||
availableLanguages: metadata?.language ? [metadata.language] : [],
|
||||
};
|
||||
|
||||
const sidecarPath = await this.writeSidecarFile(mediaData, destPath);
|
||||
@@ -578,6 +602,7 @@ export class MediaEngine extends EventEmitter {
|
||||
alt: mediaData.alt,
|
||||
caption: mediaData.caption,
|
||||
author: mediaData.author,
|
||||
language: mediaData.language,
|
||||
filePath: destPath,
|
||||
sidecarPath,
|
||||
createdAt: mediaData.createdAt,
|
||||
@@ -643,6 +668,7 @@ export class MediaEngine extends EventEmitter {
|
||||
alt: updated.alt,
|
||||
caption: updated.caption,
|
||||
author: updated.author,
|
||||
language: updated.language,
|
||||
updatedAt: updated.updatedAt,
|
||||
tags: JSON.stringify(updated.tags),
|
||||
})
|
||||
@@ -765,6 +791,22 @@ export class MediaEngine extends EventEmitter {
|
||||
const { postMedia } = await import('../database/schema');
|
||||
await db.delete(postMedia).where(eq(postMedia.mediaId, id));
|
||||
|
||||
// Delete media translations (cascade cleanup)
|
||||
await db.delete(mediaTranslations).where(eq(mediaTranslations.translationFor, id));
|
||||
|
||||
// Delete translated sidecar files
|
||||
try {
|
||||
const mediaDir = path.dirname(existing.filePath);
|
||||
const entries = await fs.readdir(mediaDir);
|
||||
const basename = path.basename(existing.filePath);
|
||||
for (const entry of entries) {
|
||||
const translatedSidecarPattern = new RegExp(`^${basename.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\.[a-z]{2}\\.meta$`);
|
||||
if (translatedSidecarPattern.test(entry)) {
|
||||
try { await fs.unlink(path.join(mediaDir, entry)); } catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
} catch { /* directory may not exist */ }
|
||||
|
||||
await db.delete(media).where(eq(media.id, id));
|
||||
|
||||
// Delete from FTS index
|
||||
@@ -783,6 +825,13 @@ export class MediaEngine extends EventEmitter {
|
||||
return null;
|
||||
}
|
||||
|
||||
const translations = await db.select().from(mediaTranslations).where(eq(mediaTranslations.translationFor, id)).all();
|
||||
const canonicalLang = dbMedia.language || undefined;
|
||||
const translationLangs = translations.map(t => t.language);
|
||||
const availableLanguages = canonicalLang
|
||||
? [canonicalLang, ...translationLangs]
|
||||
: translationLangs.length > 0 ? translationLangs : [];
|
||||
|
||||
return {
|
||||
id: dbMedia.id,
|
||||
filename: dbMedia.filename,
|
||||
@@ -795,9 +844,11 @@ export class MediaEngine extends EventEmitter {
|
||||
alt: dbMedia.alt || undefined,
|
||||
caption: dbMedia.caption || undefined,
|
||||
author: dbMedia.author || undefined,
|
||||
language: canonicalLang,
|
||||
createdAt: dbMedia.createdAt,
|
||||
updatedAt: dbMedia.updatedAt,
|
||||
tags: JSON.parse(dbMedia.tags || '[]'),
|
||||
availableLanguages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -822,9 +873,11 @@ export class MediaEngine extends EventEmitter {
|
||||
alt: dbMedia.alt || undefined,
|
||||
caption: dbMedia.caption || undefined,
|
||||
author: dbMedia.author || undefined,
|
||||
language: dbMedia.language || undefined,
|
||||
createdAt: dbMedia.createdAt,
|
||||
updatedAt: dbMedia.updatedAt,
|
||||
tags: JSON.parse(dbMedia.tags || '[]'),
|
||||
availableLanguages: [] as string[],
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -884,9 +937,11 @@ export class MediaEngine extends EventEmitter {
|
||||
alt: dbMedia.alt || undefined,
|
||||
caption: dbMedia.caption || undefined,
|
||||
author: dbMedia.author || undefined,
|
||||
language: dbMedia.language || undefined,
|
||||
createdAt: dbMedia.createdAt,
|
||||
updatedAt: dbMedia.updatedAt,
|
||||
tags: JSON.parse(dbMedia.tags || '[]'),
|
||||
availableLanguages: [] as string[],
|
||||
};
|
||||
|
||||
// Client-side filtering for tags (JSON array)
|
||||
@@ -1033,6 +1088,10 @@ export class MediaEngine extends EventEmitter {
|
||||
await db.delete(postMedia).where(eq(postMedia.projectId, this.currentProjectId));
|
||||
console.log(`Deleted post-media links for project ${this.currentProjectId}`);
|
||||
|
||||
// Delete all media translations for the current project
|
||||
await db.delete(mediaTranslations).where(eq(mediaTranslations.projectId, this.currentProjectId));
|
||||
console.log(`Deleted media translations for project ${this.currentProjectId}`);
|
||||
|
||||
// Delete all FTS entries for the current project
|
||||
const client = getDatabase().getLocalClient();
|
||||
if (client) {
|
||||
@@ -1046,7 +1105,14 @@ export class MediaEngine extends EventEmitter {
|
||||
onProgress(5, 'Scanning media directory...');
|
||||
|
||||
// Recursively find all .meta files in the media directory tree
|
||||
// Canonical sidecars: <file>.meta Translation sidecars: <file>.<lang>.meta
|
||||
const metaFiles: string[] = [];
|
||||
const translationMetaFiles: string[] = [];
|
||||
const isTranslationSidecar = (name: string) => {
|
||||
// e.g. photo.jpg.fr.meta → parts = ['photo', 'jpg', 'fr', 'meta']
|
||||
const parts = name.split('.');
|
||||
return parts.length >= 4 && parts[parts.length - 1] === 'meta' && parts[parts.length - 2].length <= 5;
|
||||
};
|
||||
const scanDir = async (dir: string) => {
|
||||
try {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
@@ -1055,7 +1121,11 @@ export class MediaEngine extends EventEmitter {
|
||||
if (entry.isDirectory()) {
|
||||
await scanDir(fullPath);
|
||||
} else if (entry.name.endsWith('.meta')) {
|
||||
metaFiles.push(fullPath);
|
||||
if (isTranslationSidecar(entry.name)) {
|
||||
translationMetaFiles.push(fullPath);
|
||||
} else {
|
||||
metaFiles.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -1101,6 +1171,8 @@ export class MediaEngine extends EventEmitter {
|
||||
title: metadata.title,
|
||||
alt: metadata.alt,
|
||||
caption: metadata.caption,
|
||||
author: metadata.author || null,
|
||||
language: metadata.language || null,
|
||||
filePath: mediaFilePath,
|
||||
sidecarPath,
|
||||
createdAt: new Date(metadata.createdAt),
|
||||
@@ -1144,6 +1216,33 @@ export class MediaEngine extends EventEmitter {
|
||||
}
|
||||
}
|
||||
|
||||
// Import media translation sidecars
|
||||
if (translationMetaFiles.length > 0) {
|
||||
onProgress(92, `Importing ${translationMetaFiles.length} media translation(s)...`);
|
||||
for (const tPath of translationMetaFiles) {
|
||||
const tData = await this.readTranslatedSidecarFile(tPath);
|
||||
if (tData?.translationFor && tData?.language) {
|
||||
// Find the canonical media by id
|
||||
const canonical = await db.select({ id: media.id }).from(media)
|
||||
.where(eq(media.id, tData.translationFor))
|
||||
.get();
|
||||
if (canonical) {
|
||||
await db.insert(mediaTranslations).values({
|
||||
id: uuidv4(),
|
||||
projectId: this.currentProjectId,
|
||||
translationFor: tData.translationFor,
|
||||
language: tData.language,
|
||||
title: tData.title || null,
|
||||
alt: tData.alt || null,
|
||||
caption: tData.caption || null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onProgress(100, 'Database rebuild complete');
|
||||
this.emit('databaseRebuilt');
|
||||
},
|
||||
@@ -1307,6 +1406,198 @@ export class MediaEngine extends EventEmitter {
|
||||
|
||||
await taskManager.runTask(task);
|
||||
}
|
||||
|
||||
// ─── Media Translation CRUD ─────────────────────────────────────────
|
||||
|
||||
async getMediaTranslation(mediaId: string, language: string): Promise<MediaTranslationData | null> {
|
||||
const db = getDatabase().getLocal();
|
||||
const rows = await db.select().from(mediaTranslations)
|
||||
.where(eq(mediaTranslations.translationFor, mediaId))
|
||||
.all();
|
||||
const row = rows.find(r => r.language === language.toLowerCase());
|
||||
if (!row) return null;
|
||||
return this.toMediaTranslationData(row);
|
||||
}
|
||||
|
||||
async getMediaTranslations(mediaId: string): Promise<MediaTranslationData[]> {
|
||||
const db = getDatabase().getLocal();
|
||||
const rows = await db.select().from(mediaTranslations)
|
||||
.where(eq(mediaTranslations.translationFor, mediaId))
|
||||
.all();
|
||||
return rows.map(r => this.toMediaTranslationData(r));
|
||||
}
|
||||
|
||||
async upsertMediaTranslation(
|
||||
mediaId: string,
|
||||
language: string,
|
||||
data: { title?: string; alt?: string; caption?: string },
|
||||
): Promise<MediaTranslationData> {
|
||||
const db = getDatabase().getLocal();
|
||||
const normalizedLang = language.toLowerCase();
|
||||
|
||||
// Verify media exists
|
||||
const mediaItem = await db.select().from(media).where(eq(media.id, mediaId)).get();
|
||||
if (!mediaItem) {
|
||||
throw new Error('Media item not found');
|
||||
}
|
||||
|
||||
// Reject if language matches canonical
|
||||
const canonicalLang = mediaItem.language?.toLowerCase();
|
||||
if (canonicalLang && canonicalLang === normalizedLang) {
|
||||
throw new Error('Translation language must differ from canonical media language');
|
||||
}
|
||||
|
||||
const now = new Date();
|
||||
|
||||
// Check for existing translation
|
||||
const existing = await this.getMediaTranslation(mediaId, normalizedLang);
|
||||
|
||||
if (existing) {
|
||||
// Update existing
|
||||
await db.update(mediaTranslations)
|
||||
.set({
|
||||
title: data.title ?? existing.title,
|
||||
alt: data.alt ?? existing.alt,
|
||||
caption: data.caption ?? existing.caption,
|
||||
updatedAt: now,
|
||||
})
|
||||
.where(eq(mediaTranslations.id, existing.id));
|
||||
|
||||
const updated: MediaTranslationData = {
|
||||
...existing,
|
||||
title: data.title ?? existing.title,
|
||||
alt: data.alt ?? existing.alt,
|
||||
caption: data.caption ?? existing.caption,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
// Write translated sidecar
|
||||
await this.writeTranslatedSidecarFile(mediaItem.filePath, updated);
|
||||
|
||||
this.emit('mediaTranslationUpdated', updated);
|
||||
return updated;
|
||||
}
|
||||
|
||||
// Create new
|
||||
const id = uuidv4();
|
||||
const newTranslation: MediaTranslationData = {
|
||||
id,
|
||||
projectId: this.currentProjectId,
|
||||
translationFor: mediaId,
|
||||
language: normalizedLang,
|
||||
title: data.title,
|
||||
alt: data.alt,
|
||||
caption: data.caption,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
await db.insert(mediaTranslations).values({
|
||||
id,
|
||||
projectId: this.currentProjectId,
|
||||
translationFor: mediaId,
|
||||
language: normalizedLang,
|
||||
title: data.title,
|
||||
alt: data.alt,
|
||||
caption: data.caption,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
|
||||
// Write translated sidecar
|
||||
await this.writeTranslatedSidecarFile(mediaItem.filePath, newTranslation);
|
||||
|
||||
this.emit('mediaTranslationCreated', newTranslation);
|
||||
return newTranslation;
|
||||
}
|
||||
|
||||
async deleteMediaTranslation(mediaId: string, language: string): Promise<boolean> {
|
||||
const normalizedLang = language.toLowerCase();
|
||||
const existing = await this.getMediaTranslation(mediaId, normalizedLang);
|
||||
if (!existing) return false;
|
||||
|
||||
const db = getDatabase().getLocal();
|
||||
await db.delete(mediaTranslations).where(eq(mediaTranslations.id, existing.id));
|
||||
|
||||
// Delete translated sidecar file
|
||||
const mediaItem = await db.select().from(media).where(eq(media.id, mediaId)).get();
|
||||
if (mediaItem) {
|
||||
const sidecarPath = `${mediaItem.filePath}.${normalizedLang}.meta`;
|
||||
try { await fs.unlink(sidecarPath); } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
this.emit('mediaTranslationDeleted', { mediaId, language: normalizedLang });
|
||||
return true;
|
||||
}
|
||||
|
||||
private toMediaTranslationData(row: typeof mediaTranslations.$inferSelect): MediaTranslationData {
|
||||
return {
|
||||
id: row.id,
|
||||
projectId: row.projectId,
|
||||
translationFor: row.translationFor,
|
||||
language: row.language,
|
||||
title: row.title || undefined,
|
||||
alt: row.alt || undefined,
|
||||
caption: row.caption || undefined,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Translated Sidecar I/O ─────────────────────────────────────────
|
||||
|
||||
private async writeTranslatedSidecarFile(
|
||||
mediaFilePath: string,
|
||||
translation: MediaTranslationData,
|
||||
): Promise<string> {
|
||||
const sidecarPath = `${mediaFilePath}.${translation.language}.meta`;
|
||||
|
||||
const lines = [
|
||||
'---',
|
||||
`translationFor: ${translation.translationFor}`,
|
||||
`language: ${translation.language}`,
|
||||
];
|
||||
if (translation.title) lines.push(`title: "${translation.title}"`);
|
||||
if (translation.alt) lines.push(`alt: "${translation.alt}"`);
|
||||
if (translation.caption) lines.push(`caption: "${translation.caption}"`);
|
||||
lines.push('---');
|
||||
|
||||
await fs.writeFile(sidecarPath, lines.join('\n'), 'utf-8');
|
||||
return sidecarPath;
|
||||
}
|
||||
|
||||
async readTranslatedSidecarFile(sidecarPath: string): Promise<{ translationFor?: string; language?: string; title?: string; alt?: string; caption?: string } | null> {
|
||||
try {
|
||||
try { await fs.access(sidecarPath); } catch { return null; }
|
||||
|
||||
const content = await fs.readFile(sidecarPath, 'utf-8');
|
||||
const result: { translationFor?: string; language?: string; title?: string; alt?: string; caption?: string } = {};
|
||||
|
||||
for (const line of content.split('\n')) {
|
||||
if (line === '---') continue;
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex === -1) continue;
|
||||
|
||||
const key = line.substring(0, colonIndex).trim();
|
||||
let value = line.substring(colonIndex + 1).trim();
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
switch (key) {
|
||||
case 'translationFor': result.translationFor = value; break;
|
||||
case 'language': result.language = value; break;
|
||||
case 'title': result.title = value; break;
|
||||
case 'alt': result.alt = value; break;
|
||||
case 'caption': result.caption = value; break;
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ import { eq } from 'drizzle-orm';
|
||||
import { getDatabase } from '../database';
|
||||
import { posts, projects } from '../database/schema';
|
||||
import { sanitizePicoTheme, type PicoThemeName } from '../shared/picoThemes';
|
||||
import { SUPPORTED_RENDER_LANGUAGES, type SupportedLanguage } from '../shared/i18n';
|
||||
import {
|
||||
normalizeTaxonomyTerm,
|
||||
normalizeNonEmptyTaxonomyTerm,
|
||||
@@ -29,6 +30,7 @@ export interface ProjectMetadata {
|
||||
categoryMetadata?: Record<string, CategoryMetadata>; // Per-category metadata for UI/rendering
|
||||
categorySettings?: Record<string, CategoryRenderSettings>; // Per-category list rendering preferences
|
||||
semanticSimilarityEnabled?: boolean; // Enable local ONNX embedding-based semantic similarity
|
||||
blogLanguages?: string[]; // Languages the blog is rendered in (mainLanguage is always included)
|
||||
}
|
||||
|
||||
export interface CategoryRenderSettings {
|
||||
@@ -103,6 +105,19 @@ function sanitizeCategoryTitle(value: unknown, fallback: string): string {
|
||||
|
||||
type RawCategoryMetadataInput = Record<string, CategoryMetadata | CategoryRenderSettings>;
|
||||
|
||||
const supportedLanguageSet = new Set<string>(SUPPORTED_RENDER_LANGUAGES);
|
||||
|
||||
function sanitizeBlogLanguages(value: unknown): string[] | undefined {
|
||||
if (!Array.isArray(value)) {
|
||||
return undefined;
|
||||
}
|
||||
const filtered = value
|
||||
.filter((item): item is string => typeof item === 'string')
|
||||
.map((item) => item.trim().toLowerCase())
|
||||
.filter((item) => item.length > 0 && supportedLanguageSet.has(item));
|
||||
return filtered.length > 0 ? [...new Set(filtered)] : undefined;
|
||||
}
|
||||
|
||||
function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
|
||||
const maxPostsPerPage = sanitizeMaxPostsPerPage(metadata.maxPostsPerPage);
|
||||
const publicUrl = sanitizePublicUrl(metadata.publicUrl);
|
||||
@@ -112,6 +127,7 @@ function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
|
||||
const pythonRuntimeMode = metadata.pythonRuntimeMode === 'main-thread' ? 'main-thread' : 'webworker';
|
||||
const picoTheme = sanitizePicoTheme(metadata.picoTheme);
|
||||
const categoryMetadata = normalizeCategoryMetadata(metadata.categoryMetadata ?? metadata.categorySettings);
|
||||
const blogLanguages = sanitizeBlogLanguages(metadata.blogLanguages);
|
||||
return {
|
||||
...metadata,
|
||||
publicUrl,
|
||||
@@ -121,6 +137,7 @@ function normalizeProjectMetadata(metadata: ProjectMetadata): ProjectMetadata {
|
||||
picoTheme,
|
||||
categoryMetadata,
|
||||
categorySettings: undefined,
|
||||
blogLanguages,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -349,6 +366,7 @@ export class MetaEngine extends EventEmitter {
|
||||
picoTheme: normalizedUpdates.picoTheme,
|
||||
categoryMetadata: normalizedUpdates.categoryMetadata,
|
||||
semanticSimilarityEnabled: normalizedUpdates.semanticSimilarityEnabled,
|
||||
blogLanguages: normalizedUpdates.blogLanguages,
|
||||
});
|
||||
} else {
|
||||
this.projectMetadata = normalizeProjectMetadata({
|
||||
|
||||
@@ -11,8 +11,9 @@ import * as fs from 'fs/promises';
|
||||
import * as path from 'path';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { getDatabase } from '../database';
|
||||
import { posts, media, scripts, templates } from '../database/schema';
|
||||
import { posts, postTranslations, media, scripts, templates } from '../database/schema';
|
||||
import { readPostFile, PostFileData } from './postFileUtils';
|
||||
import { readPostTranslationFile } from './postTranslationFileUtils';
|
||||
import { taskManager } from './TaskManager';
|
||||
import type { PostEngine } from './PostEngine';
|
||||
import type { MediaEngine } from './MediaEngine';
|
||||
@@ -32,7 +33,7 @@ export interface FieldDifference<T = unknown> {
|
||||
/**
|
||||
* The fields that can have differences for posts
|
||||
*/
|
||||
export type DiffField = 'tags' | 'categories' | 'title' | 'excerpt' | 'author' | 'language';
|
||||
export type DiffField = 'tags' | 'categories' | 'title' | 'excerpt' | 'author' | 'language' | 'translationFor' | 'doNotTranslate' | 'status' | 'templateSlug' | 'createdAt' | 'updatedAt' | 'publishedAt';
|
||||
|
||||
/**
|
||||
* Metadata differences for a single post
|
||||
@@ -41,6 +42,8 @@ export interface PostMetadataDiff {
|
||||
postId: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
variant?: 'post' | 'translation';
|
||||
translationLanguage?: string;
|
||||
filePath?: string;
|
||||
hasDifferences: boolean;
|
||||
fileMissing?: boolean;
|
||||
@@ -70,6 +73,9 @@ export interface OrphanFile {
|
||||
slug: string;
|
||||
title?: string;
|
||||
id?: string;
|
||||
variant?: 'post' | 'translation';
|
||||
translationFor?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -85,7 +91,7 @@ export interface ScanResult {
|
||||
|
||||
// ── Media diff types ──
|
||||
|
||||
export type MediaDiffField = 'title' | 'alt' | 'caption' | 'author' | 'tags';
|
||||
export type MediaDiffField = 'title' | 'alt' | 'caption' | 'author' | 'tags' | 'language';
|
||||
|
||||
export interface MediaMetadataDiff {
|
||||
mediaId: string;
|
||||
@@ -354,7 +360,80 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
.get();
|
||||
|
||||
if (!dbPost) {
|
||||
return null;
|
||||
const dbTranslation = await db
|
||||
.select()
|
||||
.from(postTranslations)
|
||||
.where(and(eq(postTranslations.id, postId), eq(postTranslations.projectId, this.currentProjectId)))
|
||||
.get();
|
||||
|
||||
if (!dbTranslation) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!dbTranslation.filePath || dbTranslation.status === 'draft') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const sourcePost = await db
|
||||
.select()
|
||||
.from(posts)
|
||||
.where(and(eq(posts.id, dbTranslation.translationFor), eq(posts.projectId, this.currentProjectId)))
|
||||
.get();
|
||||
|
||||
const translationSlug = sourcePost?.slug
|
||||
? `${sourcePost.slug}.${dbTranslation.language}`
|
||||
: path.basename(dbTranslation.filePath, path.extname(dbTranslation.filePath));
|
||||
const translationTitle = `${dbTranslation.title} [${dbTranslation.language}]`;
|
||||
const translationFileData = await readPostTranslationFile(dbTranslation.filePath);
|
||||
|
||||
if (!translationFileData) {
|
||||
const missingDiffs: Partial<Record<DiffField, FieldDifference>> = {
|
||||
translationFor: { dbValue: dbTranslation.translationFor, fileValue: null },
|
||||
language: { dbValue: dbTranslation.language, fileValue: null },
|
||||
title: { dbValue: dbTranslation.title, fileValue: null },
|
||||
};
|
||||
if (dbTranslation.excerpt) {
|
||||
missingDiffs.excerpt = { dbValue: dbTranslation.excerpt, fileValue: null };
|
||||
}
|
||||
|
||||
return {
|
||||
postId: dbTranslation.id,
|
||||
title: translationTitle,
|
||||
slug: translationSlug,
|
||||
variant: 'translation',
|
||||
translationLanguage: dbTranslation.language,
|
||||
filePath: dbTranslation.filePath,
|
||||
hasDifferences: true,
|
||||
fileMissing: true,
|
||||
differences: missingDiffs,
|
||||
};
|
||||
}
|
||||
|
||||
const translationDiffs: Partial<Record<DiffField, FieldDifference>> = {};
|
||||
|
||||
if (dbTranslation.translationFor !== translationFileData.translationFor) {
|
||||
translationDiffs.translationFor = { dbValue: dbTranslation.translationFor, fileValue: translationFileData.translationFor };
|
||||
}
|
||||
if (dbTranslation.language !== translationFileData.language) {
|
||||
translationDiffs.language = { dbValue: dbTranslation.language, fileValue: translationFileData.language };
|
||||
}
|
||||
if (dbTranslation.title !== translationFileData.title) {
|
||||
translationDiffs.title = { dbValue: dbTranslation.title, fileValue: translationFileData.title };
|
||||
}
|
||||
if ((dbTranslation.excerpt || '') !== (translationFileData.excerpt || '')) {
|
||||
translationDiffs.excerpt = { dbValue: dbTranslation.excerpt || '', fileValue: translationFileData.excerpt || '' };
|
||||
}
|
||||
|
||||
return {
|
||||
postId: dbTranslation.id,
|
||||
title: translationTitle,
|
||||
slug: translationSlug,
|
||||
variant: 'translation',
|
||||
translationLanguage: dbTranslation.language,
|
||||
filePath: dbTranslation.filePath,
|
||||
hasDifferences: Object.keys(translationDiffs).length > 0,
|
||||
differences: translationDiffs,
|
||||
};
|
||||
}
|
||||
|
||||
// Skip drafts - they don't have files
|
||||
@@ -375,6 +454,8 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
if (dbPost.excerpt) missingDiffs.excerpt = { dbValue: dbPost.excerpt, fileValue: null };
|
||||
if (dbPost.author) missingDiffs.author = { dbValue: dbPost.author, fileValue: null };
|
||||
if (dbPost.language) missingDiffs.language = { dbValue: dbPost.language, fileValue: null };
|
||||
if (dbPost.doNotTranslate) missingDiffs.doNotTranslate = { dbValue: true, fileValue: null };
|
||||
if (dbPost.templateSlug) missingDiffs.templateSlug = { dbValue: dbPost.templateSlug, fileValue: null };
|
||||
return {
|
||||
postId: dbPost.id,
|
||||
title: dbPost.title,
|
||||
@@ -425,6 +506,35 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
differences.language = { dbValue: dbPost.language || '', fileValue: fileData.language || '' };
|
||||
}
|
||||
|
||||
// Compare doNotTranslate
|
||||
const dbDoNotTranslate = Boolean(dbPost.doNotTranslate);
|
||||
const fileDoNotTranslate = Boolean(fileData.doNotTranslate);
|
||||
if (dbDoNotTranslate !== fileDoNotTranslate) {
|
||||
differences.doNotTranslate = { dbValue: dbDoNotTranslate, fileValue: fileDoNotTranslate };
|
||||
}
|
||||
|
||||
// Compare status
|
||||
const fileStatus = fileData.status || 'published';
|
||||
if (dbPost.status !== fileStatus) {
|
||||
differences.status = { dbValue: dbPost.status, fileValue: fileStatus };
|
||||
}
|
||||
|
||||
// Compare templateSlug
|
||||
if ((dbPost.templateSlug || '') !== (fileData.templateSlug || '')) {
|
||||
differences.templateSlug = { dbValue: dbPost.templateSlug || '', fileValue: fileData.templateSlug || '' };
|
||||
}
|
||||
|
||||
// Compare timestamps (second precision — DB stores integer seconds)
|
||||
if (!this.datesEqualSeconds(dbPost.createdAt, fileData.createdAt)) {
|
||||
differences.createdAt = { dbValue: dbPost.createdAt?.toISOString() || '', fileValue: fileData.createdAt?.toISOString() || '' };
|
||||
}
|
||||
if (!this.datesEqualSeconds(dbPost.updatedAt, fileData.updatedAt)) {
|
||||
differences.updatedAt = { dbValue: dbPost.updatedAt?.toISOString() || '', fileValue: fileData.updatedAt?.toISOString() || '' };
|
||||
}
|
||||
if (!this.datesEqualSeconds(dbPost.publishedAt, fileData.publishedAt)) {
|
||||
differences.publishedAt = { dbValue: dbPost.publishedAt?.toISOString() || '', fileValue: fileData.publishedAt?.toISOString() || '' };
|
||||
}
|
||||
|
||||
return {
|
||||
postId: dbPost.id,
|
||||
title: dbPost.title,
|
||||
@@ -445,6 +555,13 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
return sortedA.every((val, idx) => val === sortedB[idx]);
|
||||
}
|
||||
|
||||
/** Compare two dates at second precision (SQLite stores integer seconds). */
|
||||
private datesEqualSeconds(a: Date | null | undefined, b: Date | null | undefined): boolean {
|
||||
if (!a && !b) return true;
|
||||
if (!a || !b) return false;
|
||||
return Math.floor(a.getTime() / 1000) === Math.floor(b.getTime() / 1000);
|
||||
}
|
||||
|
||||
/**
|
||||
* Scan all published posts and find metadata differences.
|
||||
* When postsBaseDir is provided, also scans the filesystem to detect
|
||||
@@ -468,8 +585,19 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
|
||||
const translationResult = await client.execute({
|
||||
sql: `SELECT id, translation_for, language, title, excerpt, file_path
|
||||
FROM post_translations
|
||||
WHERE project_id = ?
|
||||
AND status = 'published'
|
||||
AND file_path IS NOT NULL
|
||||
AND file_path != ''`,
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
|
||||
const publishedPosts = result.rows;
|
||||
const total = publishedPosts.length;
|
||||
const publishedTranslations = translationResult.rows;
|
||||
const total = publishedPosts.length + publishedTranslations.length;
|
||||
const differences: PostMetadataDiff[] = [];
|
||||
|
||||
onProgress(0, total, `Scanning ${total} published posts...`);
|
||||
@@ -488,11 +616,28 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
differences.push(diff);
|
||||
}
|
||||
|
||||
if ((i + 1) % 10 === 0 || i === total - 1) {
|
||||
if ((i + 1) % 10 === 0 || i === publishedPosts.length - 1) {
|
||||
onProgress(i + 1, total, `Scanned ${i + 1}/${total} posts, found ${differences.length} with differences`);
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = 0; i < publishedTranslations.length; i++) {
|
||||
const row = publishedTranslations[i];
|
||||
const translationId = row.id as string;
|
||||
const filePath = row.file_path as string;
|
||||
if (filePath) knownFilePaths.add(filePath);
|
||||
|
||||
const diff = await this.comparePostMetadata(translationId);
|
||||
if (diff && diff.hasDifferences) {
|
||||
differences.push(diff);
|
||||
}
|
||||
|
||||
const processed = publishedPosts.length + i + 1;
|
||||
if (processed % 10 === 0 || processed === total) {
|
||||
onProgress(processed, total, `Scanned ${processed}/${total} posts, found ${differences.length} with differences`);
|
||||
}
|
||||
}
|
||||
|
||||
// Also include file_paths from non-published posts so we don't flag them as orphans
|
||||
const allPostsResult = await client.execute({
|
||||
sql: `SELECT file_path FROM posts WHERE project_id = ? AND file_path IS NOT NULL AND file_path != ''`,
|
||||
@@ -502,6 +647,14 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
knownFilePaths.add(row.file_path as string);
|
||||
}
|
||||
|
||||
const allTranslationsResult = await client.execute({
|
||||
sql: `SELECT file_path FROM post_translations WHERE project_id = ? AND file_path IS NOT NULL AND file_path != ''`,
|
||||
args: [this.currentProjectId],
|
||||
});
|
||||
for (const row of allTranslationsResult.rows) {
|
||||
knownFilePaths.add(row.file_path as string);
|
||||
}
|
||||
|
||||
// Scan filesystem for orphan files
|
||||
const orphanFiles = await this.findOrphanFiles(postsBaseDir, knownFilePaths, onProgress, total);
|
||||
|
||||
@@ -564,19 +717,31 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
const slug = path.basename(filePath, path.extname(filePath));
|
||||
let title: string | undefined;
|
||||
let id: string | undefined;
|
||||
let variant: 'post' | 'translation' | undefined;
|
||||
let translationFor: string | undefined;
|
||||
let language: string | undefined;
|
||||
|
||||
// Try to read frontmatter for metadata
|
||||
try {
|
||||
const fileData = await readPostFile(filePath);
|
||||
if (fileData) {
|
||||
title = fileData.title;
|
||||
id = fileData.id;
|
||||
const translationData = await readPostTranslationFile(filePath);
|
||||
if (translationData) {
|
||||
title = translationData.title;
|
||||
variant = 'translation';
|
||||
translationFor = translationData.translationFor;
|
||||
language = translationData.language;
|
||||
} else {
|
||||
const fileData = await readPostFile(filePath);
|
||||
if (fileData) {
|
||||
title = fileData.title;
|
||||
id = fileData.id;
|
||||
variant = 'post';
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Couldn't parse file, still report it as orphan
|
||||
}
|
||||
|
||||
orphanFiles.push({ filePath, slug, title, id });
|
||||
orphanFiles.push({ filePath, slug, title, id, variant, translationFor, language });
|
||||
|
||||
if ((i + 1) % 10 === 0 || i === orphanPaths.length - 1) {
|
||||
onProgress(scannedSoFar + i + 1, scannedSoFar + orphanPaths.length,
|
||||
@@ -600,6 +765,13 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
excerpt: 'Excerpt',
|
||||
author: 'Author',
|
||||
language: 'Language',
|
||||
translationFor: 'Translation Source',
|
||||
doNotTranslate: 'Do Not Translate',
|
||||
status: 'Status',
|
||||
templateSlug: 'Template',
|
||||
createdAt: 'Created At',
|
||||
updatedAt: 'Updated At',
|
||||
publishedAt: 'Published At',
|
||||
};
|
||||
|
||||
for (const diff of diffs) {
|
||||
@@ -641,7 +813,16 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
return this.runSyncLoop(
|
||||
postIds,
|
||||
onProgress,
|
||||
async (postId) => postEngine.syncPublishedPostFile(postId),
|
||||
async (postId) => {
|
||||
const syncedPost = await postEngine.syncPublishedPostFile(postId);
|
||||
if (syncedPost) {
|
||||
return true;
|
||||
}
|
||||
if (typeof postEngine.syncPublishedPostTranslationFile === 'function') {
|
||||
return postEngine.syncPublishedPostTranslationFile(postId);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
(postId) => `[MetadataDiffEngine] Failed to sync post ${postId} to file:`
|
||||
);
|
||||
}
|
||||
@@ -667,45 +848,99 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
.where(and(eq(posts.id, postId), eq(posts.projectId, this.currentProjectId)))
|
||||
.get();
|
||||
|
||||
if (!dbPost || !dbPost.filePath) {
|
||||
if (dbPost?.filePath) {
|
||||
const fileData = await readPostFile(dbPost.filePath);
|
||||
if (!fileData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const updateData: Record<string, unknown> = {};
|
||||
|
||||
if (!field || field === 'tags') {
|
||||
updateData.tags = JSON.stringify(fileData.tags || []);
|
||||
}
|
||||
if (!field || field === 'categories') {
|
||||
updateData.categories = JSON.stringify(fileData.categories || []);
|
||||
}
|
||||
if (!field || field === 'title') {
|
||||
updateData.title = fileData.title;
|
||||
}
|
||||
if (!field || field === 'excerpt') {
|
||||
updateData.excerpt = fileData.excerpt || null;
|
||||
}
|
||||
if (!field || field === 'author') {
|
||||
updateData.author = fileData.author || null;
|
||||
}
|
||||
if (!field || field === 'language') {
|
||||
updateData.language = fileData.language || null;
|
||||
}
|
||||
if (!field || field === 'doNotTranslate') {
|
||||
updateData.doNotTranslate = fileData.doNotTranslate === true;
|
||||
}
|
||||
if (!field || field === 'status') {
|
||||
updateData.status = fileData.status || 'published';
|
||||
}
|
||||
if (!field || field === 'templateSlug') {
|
||||
updateData.templateSlug = fileData.templateSlug || null;
|
||||
}
|
||||
if (!field || field === 'createdAt') {
|
||||
updateData.createdAt = fileData.createdAt;
|
||||
}
|
||||
if (!field || field === 'updatedAt') {
|
||||
updateData.updatedAt = fileData.updatedAt;
|
||||
}
|
||||
if (!field || field === 'publishedAt') {
|
||||
updateData.publishedAt = fileData.publishedAt || null;
|
||||
}
|
||||
// For single-field syncs of non-timestamp fields, mark record as recently modified
|
||||
if (field && field !== 'createdAt' && field !== 'updatedAt' && field !== 'publishedAt') {
|
||||
updateData.updatedAt = new Date();
|
||||
}
|
||||
|
||||
await db
|
||||
.update(posts)
|
||||
.set(updateData)
|
||||
.where(eq(posts.id, postId));
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
const dbTranslation = await db
|
||||
.select()
|
||||
.from(postTranslations)
|
||||
.where(and(eq(postTranslations.id, postId), eq(postTranslations.projectId, this.currentProjectId)))
|
||||
.get();
|
||||
|
||||
if (!dbTranslation?.filePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Read file metadata
|
||||
const fileData = await readPostFile(dbPost.filePath);
|
||||
if (!fileData) {
|
||||
const translationFileData = await readPostTranslationFile(dbTranslation.filePath);
|
||||
if (!translationFileData) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Build update object based on field or all fields
|
||||
const updateData: Record<string, unknown> = {
|
||||
const translationUpdateData: Record<string, unknown> = {
|
||||
updatedAt: new Date(),
|
||||
};
|
||||
|
||||
if (!field || field === 'tags') {
|
||||
updateData.tags = JSON.stringify(fileData.tags || []);
|
||||
}
|
||||
if (!field || field === 'categories') {
|
||||
updateData.categories = JSON.stringify(fileData.categories || []);
|
||||
}
|
||||
if (!field || field === 'title') {
|
||||
updateData.title = fileData.title;
|
||||
}
|
||||
if (!field || field === 'excerpt') {
|
||||
updateData.excerpt = fileData.excerpt || null;
|
||||
}
|
||||
if (!field || field === 'author') {
|
||||
updateData.author = fileData.author || null;
|
||||
if (!field || field === 'translationFor') {
|
||||
translationUpdateData.translationFor = translationFileData.translationFor;
|
||||
}
|
||||
if (!field || field === 'language') {
|
||||
updateData.language = fileData.language || null;
|
||||
translationUpdateData.language = translationFileData.language || null;
|
||||
}
|
||||
if (!field || field === 'title') {
|
||||
translationUpdateData.title = translationFileData.title;
|
||||
}
|
||||
if (!field || field === 'excerpt') {
|
||||
translationUpdateData.excerpt = translationFileData.excerpt || null;
|
||||
}
|
||||
|
||||
// Update database
|
||||
await db
|
||||
.update(posts)
|
||||
.set(updateData)
|
||||
.where(eq(posts.id, postId));
|
||||
.update(postTranslations)
|
||||
.set(translationUpdateData)
|
||||
.where(eq(postTranslations.id, postId));
|
||||
|
||||
return true;
|
||||
},
|
||||
@@ -738,6 +973,7 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
if (dbMedia.alt) missingDiffs.alt = { dbValue: dbMedia.alt, fileValue: null };
|
||||
if (dbMedia.caption) missingDiffs.caption = { dbValue: dbMedia.caption, fileValue: null };
|
||||
if (dbMedia.author) missingDiffs.author = { dbValue: dbMedia.author, fileValue: null };
|
||||
if (dbMedia.language) missingDiffs.language = { dbValue: dbMedia.language, fileValue: null };
|
||||
const dbTags: string[] = JSON.parse(dbMedia.tags || '[]');
|
||||
if (dbTags.length > 0) missingDiffs.tags = { dbValue: dbTags, fileValue: null };
|
||||
return {
|
||||
@@ -764,6 +1000,9 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
if ((dbMedia.author || '') !== (sidecar.author || '')) {
|
||||
differences.author = { dbValue: dbMedia.author || '', fileValue: sidecar.author || '' };
|
||||
}
|
||||
if ((dbMedia.language || '') !== (sidecar.language || '')) {
|
||||
differences.language = { dbValue: dbMedia.language || '', fileValue: sidecar.language || '' };
|
||||
}
|
||||
|
||||
const dbTags: string[] = JSON.parse(dbMedia.tags || '[]');
|
||||
const sidecarTags = sidecar.tags || [];
|
||||
@@ -829,6 +1068,7 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
caption: 'Caption',
|
||||
author: 'Author',
|
||||
tags: 'Tags',
|
||||
language: 'Language',
|
||||
};
|
||||
|
||||
for (const diff of diffs) {
|
||||
@@ -910,6 +1150,7 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
if (!field || field === 'alt') updateData.alt = sidecar.alt || null;
|
||||
if (!field || field === 'caption') updateData.caption = sidecar.caption || null;
|
||||
if (!field || field === 'author') updateData.author = sidecar.author || null;
|
||||
if (!field || field === 'language') updateData.language = sidecar.language || null;
|
||||
if (!field || field === 'tags') updateData.tags = JSON.stringify(sidecar.tags || []);
|
||||
|
||||
await db.update(media).set(updateData).where(eq(media.id, mediaId));
|
||||
@@ -1506,7 +1747,10 @@ export class MetadataDiffEngine extends EventEmitter {
|
||||
|
||||
for (let i = 0; i < filePaths.length; i++) {
|
||||
try {
|
||||
const imported = await postEngine.importOrphanFile(filePaths[i]);
|
||||
const translationData = await readPostTranslationFile(filePaths[i]);
|
||||
const imported = translationData && typeof postEngine.importOrphanTranslationFile === 'function'
|
||||
? await postEngine.importOrphanTranslationFile(filePaths[i])
|
||||
: await postEngine.importOrphanFile(filePaths[i]);
|
||||
if (imported) {
|
||||
success++;
|
||||
} else {
|
||||
|
||||
@@ -3,13 +3,14 @@ import fs from 'node:fs';
|
||||
import { marked } from 'marked';
|
||||
import { Liquid } from 'liquidjs';
|
||||
import type { MediaData } from './MediaEngine';
|
||||
import type { PostTranslationData } from './PostEngine';
|
||||
import type { PostData } from './PostEngine';
|
||||
import type { MenuDocument, MenuItemData } from './MenuEngine';
|
||||
import { PICO_THEME_NAMES } from '../shared/picoThemes';
|
||||
import { CODE_ENHANCEMENTS_RUNTIME_JS } from './assets/codeEnhancementsRuntime';
|
||||
import { CALENDAR_RUNTIME_JS } from './assets/calendarRuntime';
|
||||
import { TAG_CLOUD_RUNTIME_JS } from './assets/tagCloudRuntime';
|
||||
import { resolveRenderLanguageFromProjectPreferences, translateRender } from '../shared/i18n';
|
||||
import { resolveRenderLanguageFromProjectPreferences, translateRender, getRenderTranslations } from '../shared/i18n';
|
||||
|
||||
function readLocalAsset(filename: string): string {
|
||||
const candidates = [
|
||||
@@ -54,6 +55,7 @@ export interface PythonMacroRendererContract {
|
||||
export interface HtmlRewriteContext {
|
||||
canonicalPostPathBySlug: Map<string, string>;
|
||||
canonicalMediaPathBySourcePath: Map<string, string>;
|
||||
languagePrefix?: string;
|
||||
}
|
||||
|
||||
export interface TemplatePostEntry {
|
||||
@@ -98,6 +100,9 @@ export type DateArchiveContext = {
|
||||
export interface PostListTemplateContext {
|
||||
page_title: string;
|
||||
language: string;
|
||||
blog_languages: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
|
||||
current_language: string;
|
||||
language_prefix: string;
|
||||
menu_items: TemplateMenuItem[];
|
||||
pico_stylesheet_href?: string;
|
||||
html_theme_attribute?: string;
|
||||
@@ -133,9 +138,17 @@ export interface BacklinkEntry {
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface AlternateLinkEntry {
|
||||
href: string;
|
||||
hreflang: string;
|
||||
}
|
||||
|
||||
export interface SinglePostTemplateContext {
|
||||
page_title: string;
|
||||
language: string;
|
||||
blog_languages: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
|
||||
current_language: string;
|
||||
language_prefix: string;
|
||||
menu_items: TemplateMenuItem[];
|
||||
pico_stylesheet_href?: string;
|
||||
html_theme_attribute?: string;
|
||||
@@ -149,6 +162,7 @@ export interface SinglePostTemplateContext {
|
||||
canonical_media_path_by_source_path: Record<string, string>;
|
||||
post_data_json_by_id: Record<string, string>;
|
||||
backlinks: BacklinkEntry[];
|
||||
alternate_links: AlternateLinkEntry[];
|
||||
}
|
||||
|
||||
export interface NotFoundTemplateContext {
|
||||
@@ -175,6 +189,7 @@ export interface RoutePagination {
|
||||
|
||||
export interface MediaEngineContract {
|
||||
getAllMedia: () => Promise<MediaData[]>;
|
||||
getMediaTranslation?: (mediaId: string, language: string) => Promise<{ title?: string; alt?: string; caption?: string } | null>;
|
||||
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
|
||||
}
|
||||
|
||||
@@ -185,6 +200,7 @@ export interface PostMediaEngineContract {
|
||||
|
||||
export interface PostEngineContract {
|
||||
getPost: (id: string) => Promise<PostData | null>;
|
||||
getPostTranslation?: (postId: string, language: string) => Promise<PostTranslationData | null>;
|
||||
getPostsFiltered?: (filter: { status?: 'draft' | 'published' | 'archived' }) => Promise<PostData[]>;
|
||||
}
|
||||
|
||||
@@ -815,6 +831,14 @@ export function rewriteRenderedHtmlUrls(html: string, rewriteContext: HtmlRewrit
|
||||
});
|
||||
}
|
||||
|
||||
export function applyLanguagePrefixToHtml(html: string, languagePrefix: string): string {
|
||||
if (!languagePrefix) return html;
|
||||
return html.replace(/\bhref=(['"])(\/(?!media\/|assets\/).*?)\1/gi, (_fullMatch, quote: string, href: string) => {
|
||||
if (href.startsWith(languagePrefix + '/') || href === languagePrefix) return `href=${quote}${href}${quote}`;
|
||||
return `href=${quote}${languagePrefix}${href}${quote}`;
|
||||
});
|
||||
}
|
||||
|
||||
export function renderMacro(
|
||||
name: string,
|
||||
params: Record<string, string>,
|
||||
@@ -890,6 +914,7 @@ export async function replaceAllMacrosAsync(
|
||||
renderLanguage: string,
|
||||
pythonMacroRenderer?: PythonMacroRendererContract | null,
|
||||
postDataJson?: string | null,
|
||||
languagePrefix?: string,
|
||||
): Promise<string> {
|
||||
const macroRegex = /\[\[(\w+)(?:\s+([^\]]+))?\]\]/g;
|
||||
const matches: Array<{ fullMatch: string; name: string; rawParams: string | undefined; start: number; end: number }> = [];
|
||||
@@ -942,12 +967,15 @@ export async function replaceAllMacrosAsync(
|
||||
const pythonScript = scriptsBySlug.get(normalizeMacroName(m.name));
|
||||
if (pythonScript && pythonMacroRenderer) {
|
||||
try {
|
||||
const resolvedLang = resolveRenderLanguageFromProjectPreferences(renderLanguage);
|
||||
const context = {
|
||||
env: {
|
||||
isPreview: false,
|
||||
mainLanguage: renderLanguage,
|
||||
languagePrefix: languagePrefix ?? '',
|
||||
hook: m.name,
|
||||
source: { kind: 'macro', id: pythonScript.id },
|
||||
translations: getRenderTranslations(resolvedLang),
|
||||
},
|
||||
params: params,
|
||||
};
|
||||
@@ -967,7 +995,7 @@ export async function replaceAllMacrosAsync(
|
||||
rendered.push('');
|
||||
}
|
||||
} else {
|
||||
rendered.push('');
|
||||
rendered.push(m.fullMatch);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1172,10 +1200,11 @@ export class PageRenderer {
|
||||
return translateRender(resolved, key);
|
||||
});
|
||||
|
||||
this.liquid.registerFilter('markdown', async (value: unknown, postIdArg: unknown, postDataJsonByIdArg: unknown, canonicalPostsArg: unknown, canonicalMediaArg: unknown, renderLanguageArg: unknown) => {
|
||||
this.liquid.registerFilter('markdown', async (value: unknown, postIdArg: unknown, postDataJsonByIdArg: unknown, canonicalPostsArg: unknown, canonicalMediaArg: unknown, renderLanguageArg: unknown, languagePrefixArg: unknown) => {
|
||||
const content = typeof value === 'string' ? value : '';
|
||||
const postId = typeof postIdArg === 'string' ? postIdArg : '';
|
||||
const renderLanguage = typeof renderLanguageArg === 'string' ? renderLanguageArg : 'en';
|
||||
const langPrefix = typeof languagePrefixArg === 'string' ? languagePrefixArg : '';
|
||||
const postDataJsonById = (postDataJsonByIdArg && typeof postDataJsonByIdArg === 'object' && !Array.isArray(postDataJsonByIdArg))
|
||||
? postDataJsonByIdArg as Record<string, string>
|
||||
: {};
|
||||
@@ -1202,7 +1231,7 @@ export class PageRenderer {
|
||||
: null;
|
||||
|
||||
const withMacros = await replaceAllMacrosAsync(
|
||||
content, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage, this.pythonMacroRenderer, postDataJson,
|
||||
content, postId, mediaItems, linkedMediaIds, tagUsage, renderLanguage, this.pythonMacroRenderer, postDataJson, langPrefix,
|
||||
);
|
||||
|
||||
const markdownHtml = await marked.parse(withMacros, { async: true, gfm: true, breaks: false });
|
||||
@@ -1247,6 +1276,9 @@ export class PageRenderer {
|
||||
basePathname: string;
|
||||
page_title: string;
|
||||
language: string;
|
||||
blog_languages?: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
|
||||
current_language?: string;
|
||||
language_prefix?: string;
|
||||
menu_items?: TemplateMenuItem[];
|
||||
pico_stylesheet_href?: string;
|
||||
html_theme_attribute?: string;
|
||||
@@ -1377,6 +1409,9 @@ export class PageRenderer {
|
||||
return {
|
||||
page_title: options.page_title,
|
||||
language: options.language,
|
||||
blog_languages: options.blog_languages ?? [],
|
||||
current_language: options.current_language ?? options.language,
|
||||
language_prefix: options.language_prefix ?? '',
|
||||
menu_items: options.menu_items ?? [],
|
||||
pico_stylesheet_href: options.pico_stylesheet_href,
|
||||
html_theme_attribute: options.html_theme_attribute,
|
||||
@@ -1419,13 +1454,47 @@ export class PageRenderer {
|
||||
};
|
||||
}
|
||||
|
||||
async resolveRenderablePost(post: PostData, postEngine: PostEngineContract): Promise<PostData> {
|
||||
if (post.status === 'published' && !post.content) {
|
||||
const fullPost = await postEngine.getPost(post.id);
|
||||
return fullPost ?? post;
|
||||
async resolveRenderablePost(post: PostData, postEngine: PostEngineContract, preferredLanguage?: string): Promise<PostData> {
|
||||
// Pre-built translation variants (from blog generation) already have content and
|
||||
// translationSourceSlug set — skip hydration and language resolution entirely.
|
||||
const variantPost = post as PostData & { translationSourceSlug?: string };
|
||||
if (variantPost.translationSourceSlug) {
|
||||
return post;
|
||||
}
|
||||
|
||||
return post;
|
||||
const hydratedPost = post.status === 'published' && !post.content
|
||||
? (await postEngine.getPost(post.id)) ?? post
|
||||
: post;
|
||||
|
||||
const requestedLanguage = preferredLanguage?.trim().toLowerCase();
|
||||
const canonicalLanguage = hydratedPost.language?.trim().toLowerCase();
|
||||
if (!requestedLanguage || requestedLanguage === canonicalLanguage || !postEngine.getPostTranslation) {
|
||||
return hydratedPost;
|
||||
}
|
||||
|
||||
const translation = await postEngine.getPostTranslation(hydratedPost.id, requestedLanguage);
|
||||
if (!translation || !translation.content) {
|
||||
return hydratedPost;
|
||||
}
|
||||
|
||||
const availableLanguages = Array.from(new Set([
|
||||
...(Array.isArray(hydratedPost.availableLanguages) ? hydratedPost.availableLanguages : []),
|
||||
requestedLanguage,
|
||||
canonicalLanguage,
|
||||
].filter((language): language is string => typeof language === 'string' && language.length > 0)));
|
||||
|
||||
return {
|
||||
...hydratedPost,
|
||||
title: translation.title,
|
||||
excerpt: translation.excerpt,
|
||||
content: translation.content,
|
||||
language: translation.language,
|
||||
updatedAt: translation.updatedAt,
|
||||
publishedAt: translation.publishedAt ?? hydratedPost.publishedAt,
|
||||
availableLanguages,
|
||||
translationSourceSlug: hydratedPost.slug,
|
||||
translationCanonicalLanguage: canonicalLanguage || undefined,
|
||||
} as PostData;
|
||||
}
|
||||
|
||||
async renderPostList(
|
||||
@@ -1438,6 +1507,9 @@ export class PageRenderer {
|
||||
basePathname: string;
|
||||
page_title: string;
|
||||
language: string;
|
||||
blog_languages?: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
|
||||
current_language?: string;
|
||||
language_prefix?: string;
|
||||
menu_items?: TemplateMenuItem[];
|
||||
pico_stylesheet_href?: string;
|
||||
html_theme_attribute?: string;
|
||||
@@ -1452,7 +1524,7 @@ export class PageRenderer {
|
||||
}
|
||||
|
||||
const renderablePosts = postEngine
|
||||
? await Promise.all(posts.map(async (post) => this.resolveRenderablePost(post, postEngine)))
|
||||
? await Promise.all(posts.map(async (post) => this.resolveRenderablePost(post, postEngine, options.language)))
|
||||
: posts;
|
||||
const templateContext = this.buildListTemplateContext(
|
||||
renderablePosts,
|
||||
@@ -1465,7 +1537,8 @@ export class PageRenderer {
|
||||
routeCategory ?? undefined,
|
||||
options.categorySettings as Record<string, { listTemplateSlug?: string | null }> | undefined,
|
||||
);
|
||||
return this.liquid.renderFile(listTemplateName, templateContext);
|
||||
const html = await this.liquid.renderFile(listTemplateName, templateContext);
|
||||
return rewriteContext.languagePrefix ? applyLanguagePrefixToHtml(html, rewriteContext.languagePrefix) : html;
|
||||
}
|
||||
|
||||
async renderSinglePost(
|
||||
@@ -1474,6 +1547,9 @@ export class PageRenderer {
|
||||
pageContext: {
|
||||
page_title: string;
|
||||
language: string;
|
||||
blog_languages?: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
|
||||
current_language?: string;
|
||||
language_prefix?: string;
|
||||
menu_items?: TemplateMenuItem[];
|
||||
pico_stylesheet_href?: string;
|
||||
html_theme_attribute?: string;
|
||||
@@ -1481,11 +1557,12 @@ export class PageRenderer {
|
||||
tagSettings?: Record<string, { postTemplateSlug?: string | null }>;
|
||||
categorySettings?: Record<string, { postTemplateSlug?: string | null }>;
|
||||
backlinks?: BacklinkEntry[];
|
||||
alternate_links?: AlternateLinkEntry[];
|
||||
},
|
||||
postEngine?: PostEngineContract,
|
||||
): Promise<string> {
|
||||
const renderablePost = postEngine
|
||||
? await this.resolveRenderablePost(post, postEngine)
|
||||
? await this.resolveRenderablePost(post, postEngine, pageContext.language)
|
||||
: post;
|
||||
|
||||
const postCategories = Array.isArray(renderablePost.categories)
|
||||
@@ -1503,6 +1580,9 @@ export class PageRenderer {
|
||||
const context: SinglePostTemplateContext = {
|
||||
...pageContext,
|
||||
language: postLanguage || pageContext.language,
|
||||
blog_languages: pageContext.blog_languages ?? [],
|
||||
current_language: pageContext.current_language ?? pageContext.language,
|
||||
language_prefix: pageContext.language_prefix ?? '',
|
||||
menu_items: pageContext.menu_items ?? [],
|
||||
post: {
|
||||
id: renderablePost.id,
|
||||
@@ -1523,6 +1603,7 @@ export class PageRenderer {
|
||||
[renderablePost.id]: JSON.stringify(serializePostDataForMacro(renderablePost)),
|
||||
},
|
||||
backlinks: pageContext.backlinks ?? [],
|
||||
alternate_links: pageContext.alternate_links ?? [],
|
||||
};
|
||||
|
||||
const postTemplateName = resolvePostTemplateName(
|
||||
@@ -1530,7 +1611,8 @@ export class PageRenderer {
|
||||
pageContext.tagSettings,
|
||||
pageContext.categorySettings,
|
||||
);
|
||||
return this.liquid.renderFile(postTemplateName, context);
|
||||
const html = await this.liquid.renderFile(postTemplateName, context);
|
||||
return rewriteContext.languagePrefix ? applyLanguagePrefixToHtml(html, rewriteContext.languagePrefix) : html;
|
||||
}
|
||||
|
||||
async renderNotFound(context: NotFoundTemplateContext): Promise<string> {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ import path from 'node:path';
|
||||
import { type CategoryMetadata, type ProjectMetadata } from './MetaEngine';
|
||||
import { type MediaData } from './MediaEngine';
|
||||
import { type MenuDocument } from './MenuEngine';
|
||||
import { type PostData, type PostFilter } from './PostEngine';
|
||||
import { type PostData, type PostFilter, type PostTranslationData } from './PostEngine';
|
||||
import {
|
||||
PageRenderer,
|
||||
PREVIEW_ASSETS,
|
||||
@@ -20,8 +20,10 @@ import {
|
||||
type PythonMacroRendererContract,
|
||||
} from './PageRenderer';
|
||||
import { getPicoStylesheetHref, sanitizePicoTheme, sanitizePicoThemeMode } from '../shared/picoThemes';
|
||||
import { POST_LANGUAGE_FLAGS, type SupportedLanguage } from '../shared/i18n';
|
||||
import { renderRouteWithSharedContext } from './SharedRouteRenderer';
|
||||
import {
|
||||
findPublishedPostBySlug,
|
||||
findSinglePostBySlug,
|
||||
loadPostsForDayPage,
|
||||
loadPublishedSnapshots,
|
||||
@@ -40,6 +42,9 @@ interface ActiveProjectContext {
|
||||
interface PostEngineContract {
|
||||
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
|
||||
getPost: (id: string) => Promise<PostData | null>;
|
||||
getPostTranslation?: (postId: string, language: string) => Promise<PostTranslationData | null>;
|
||||
getPostTranslations?: (postId: string) => Promise<PostTranslationData[]>;
|
||||
getPublishedTranslationLanguagesByPost?: () => Promise<Map<string, string[]>>;
|
||||
hasPublishedVersion: (id: string) => Promise<boolean>;
|
||||
getPublishedVersion: (id: string) => Promise<PostData | null>;
|
||||
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
|
||||
@@ -86,6 +91,9 @@ export class PreviewServer {
|
||||
private readonly tagColorByNameCache = new Map<string, Promise<Record<string, string>>>();
|
||||
private server: Server | null = null;
|
||||
private port: number | null = null;
|
||||
private isStopping = false;
|
||||
private inflightRequests = 0;
|
||||
private drainResolve: (() => void) | null = null;
|
||||
|
||||
constructor(dependencies?: Partial<PreviewServerDependencies>) {
|
||||
if (!dependencies?.postEngine) throw new Error('PreviewServer: postEngine not provided');
|
||||
@@ -141,6 +149,13 @@ export class PreviewServer {
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
this.isStopping = true;
|
||||
|
||||
// Wait for in-flight requests to finish before closing the server.
|
||||
if (this.inflightRequests > 0) {
|
||||
await new Promise<void>((resolve) => { this.drainResolve = resolve; });
|
||||
}
|
||||
|
||||
if (!this.server) {
|
||||
this.port = null;
|
||||
return;
|
||||
@@ -183,7 +198,8 @@ export class PreviewServer {
|
||||
requestTheme?: string | null;
|
||||
htmlThemeAttribute?: string;
|
||||
allowEmptyArchiveRender?: boolean;
|
||||
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string };
|
||||
preferredLanguage?: string;
|
||||
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string; lang?: string; preferredLanguage?: string };
|
||||
},
|
||||
): Promise<string | null> {
|
||||
return renderRouteWithSharedContext(pathname, options, {
|
||||
@@ -203,12 +219,18 @@ export class PreviewServer {
|
||||
loadPublishedSnapshotsPage: (filter, pagination) => loadPublishedSnapshotsPage(this.postEngine, filter, pagination),
|
||||
loadPublishedSnapshots: (filter, pagination) => loadPublishedSnapshots(this.postEngine, filter, pagination),
|
||||
loadPostsForDayPage: (year, month, day, pagination) => loadPostsForDayPage(this.postEngine, year, month, day, pagination),
|
||||
findPublishedPostBySlug: (slug, dateFilter) => findPublishedPostBySlug(this.postEngine, slug, dateFilter),
|
||||
findSinglePostBySlug: (slug, singlePostOptions, dateFilter) => findSinglePostBySlug(this.postEngine, slug, singlePostOptions, dateFilter),
|
||||
getLinkedBy: this.postEngine.getLinkedBy ? (postId) => this.postEngine.getLinkedBy!(postId) : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
private async handleRequest(req: IncomingMessage, res: ServerResponse): Promise<void> {
|
||||
if (this.isStopping) {
|
||||
this.respond(res, 503, 'Service Unavailable');
|
||||
return;
|
||||
}
|
||||
|
||||
const remoteAddress = req.socket.remoteAddress;
|
||||
const isLocal = remoteAddress === '127.0.0.1'
|
||||
|| remoteAddress === '::1'
|
||||
@@ -224,6 +246,7 @@ export class PreviewServer {
|
||||
return;
|
||||
}
|
||||
|
||||
this.inflightRequests++;
|
||||
try {
|
||||
const requestUrl = new URL(req.url || '/', 'http://127.0.0.1');
|
||||
const pathname = decodeURIComponent(requestUrl.pathname.replace(/\/+$/, '') || '/');
|
||||
@@ -251,7 +274,8 @@ export class PreviewServer {
|
||||
const menuItems = buildTemplateMenuItems(menu, categoryMetadata);
|
||||
const categorySettings = this.resolveCategorySettings(metadata);
|
||||
const listExcludedCategories = this.resolveListExcludedCategories(categorySettings);
|
||||
const language = metadata?.mainLanguage?.trim() || 'en';
|
||||
const requestLanguage = requestUrl.searchParams.get('lang')?.trim().toLowerCase() || undefined;
|
||||
const language = requestLanguage || metadata?.mainLanguage?.trim() || 'en';
|
||||
const pageTitle = resolvePageTitle(metadata, context.projectName, context.projectDescription);
|
||||
const maxPostsPerPage = clampMaxPostsPerPage(metadata?.maxPostsPerPage);
|
||||
const requestTheme = sanitizePicoTheme(requestUrl.searchParams.get('theme'));
|
||||
@@ -262,18 +286,52 @@ export class PreviewServer {
|
||||
const picoStylesheetHref = getPicoStylesheetHref(appliedTheme);
|
||||
const htmlRewriteContext = await this.buildHtmlRewriteContext();
|
||||
|
||||
if (pathname === '/calendar.json') {
|
||||
// Detect language-prefixed paths for alternative language previews
|
||||
const mainLanguage = metadata?.mainLanguage?.trim().toLowerCase() || 'en';
|
||||
const blogLanguages: string[] = Array.isArray((metadata as { blogLanguages?: unknown })?.blogLanguages)
|
||||
? (metadata as { blogLanguages: string[] }).blogLanguages
|
||||
: [];
|
||||
const alternativeLanguages = blogLanguages
|
||||
.map((lang) => lang.trim().toLowerCase())
|
||||
.filter((lang) => lang.length > 0 && lang !== mainLanguage);
|
||||
let routePathname = pathname;
|
||||
let languagePrefix = '';
|
||||
let routeLanguage = requestLanguage;
|
||||
const langPrefixMatch = pathname.match(/^\/([a-z]{2})(\/.*|$)/);
|
||||
if (langPrefixMatch && alternativeLanguages.includes(langPrefixMatch[1])) {
|
||||
languagePrefix = `/${langPrefixMatch[1]}`;
|
||||
routeLanguage = langPrefixMatch[1];
|
||||
routePathname = langPrefixMatch[2] || '/';
|
||||
htmlRewriteContext.languagePrefix = languagePrefix;
|
||||
}
|
||||
|
||||
if (pathname === '/calendar.json' || routePathname === '/calendar.json') {
|
||||
const calendarJson = await this.resolveCalendarJson(context.dataDir, listExcludedCategories);
|
||||
this.respondAsset(res, 'application/json; charset=utf-8', calendarJson);
|
||||
return;
|
||||
}
|
||||
|
||||
if (pathname === '/__style-preview') {
|
||||
const rawBlogLanguages: string[] = Array.isArray((metadata as { blogLanguages?: unknown })?.blogLanguages)
|
||||
? (metadata as { blogLanguages: string[] }).blogLanguages
|
||||
: [];
|
||||
const allBlogLanguages = rawBlogLanguages.length > 0
|
||||
? (rawBlogLanguages.includes(mainLanguage) ? rawBlogLanguages : [mainLanguage, ...rawBlogLanguages])
|
||||
: [];
|
||||
const stylePreviewBlogLanguages = allBlogLanguages.length > 0
|
||||
? allBlogLanguages.map((lang) => ({
|
||||
code: lang,
|
||||
flag: POST_LANGUAGE_FLAGS[lang as SupportedLanguage] ?? '',
|
||||
href_prefix: lang === mainLanguage ? '' : `/${lang}`,
|
||||
is_current: lang === mainLanguage,
|
||||
}))
|
||||
: [];
|
||||
const stylePreviewHtml = await this.renderStylePreview(htmlRewriteContext, {
|
||||
pageTitle,
|
||||
language,
|
||||
menuItems,
|
||||
picoStylesheetHref,
|
||||
blogLanguages: stylePreviewBlogLanguages,
|
||||
htmlThemeAttribute: previewThemeMode && previewThemeMode !== 'auto' ? `data-theme="${previewThemeMode}"` : undefined,
|
||||
}, categorySettings, listExcludedCategories);
|
||||
this.respond(res, 200, stylePreviewHtml);
|
||||
@@ -292,16 +350,20 @@ export class PreviewServer {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await this.renderRouteForContext(pathname, {
|
||||
const result = await this.renderRouteForContext(routePathname, {
|
||||
projectContext: context,
|
||||
metadata,
|
||||
menu,
|
||||
htmlRewriteContext,
|
||||
maxPostsPerPage,
|
||||
requestTheme,
|
||||
htmlThemeAttribute: undefined,
|
||||
preferredLanguage: routeLanguage,
|
||||
singlePostOptions: {
|
||||
useDraftContent,
|
||||
draftPostId,
|
||||
lang: requestLanguage,
|
||||
preferredLanguage: routeLanguage,
|
||||
},
|
||||
});
|
||||
if (!result) {
|
||||
@@ -320,12 +382,18 @@ export class PreviewServer {
|
||||
} catch (error) {
|
||||
console.error('[PreviewServer] Request failed:', error);
|
||||
this.respond(res, 500, 'Internal Server Error');
|
||||
} finally {
|
||||
this.inflightRequests--;
|
||||
if (this.inflightRequests === 0 && this.drainResolve) {
|
||||
this.drainResolve();
|
||||
this.drainResolve = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async renderStylePreview(
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; htmlThemeAttribute?: string },
|
||||
pageContext: { pageTitle: string; language: string; menuItems: ReturnType<typeof buildTemplateMenuItems>; picoStylesheetHref: string; blogLanguages?: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>; htmlThemeAttribute?: string },
|
||||
categorySettings: Record<string, CategoryRenderSettings>,
|
||||
listExcludedCategories: string[],
|
||||
): Promise<string> {
|
||||
@@ -353,6 +421,7 @@ export class PreviewServer {
|
||||
categorySettings,
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
blog_languages: pageContext.blogLanguages,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
@@ -363,8 +432,20 @@ export class PreviewServer {
|
||||
const publishedPosts = await loadPublishedSnapshots(this.postEngine, { status: 'published' });
|
||||
const canonicalPostPathBySlug = new Map<string, string>();
|
||||
|
||||
const translationMap = this.postEngine.getPublishedTranslationLanguagesByPost
|
||||
? await this.postEngine.getPublishedTranslationLanguagesByPost()
|
||||
: new Map<string, string[]>();
|
||||
|
||||
for (const post of publishedPosts) {
|
||||
canonicalPostPathBySlug.set(post.slug, buildCanonicalPostPath(post));
|
||||
|
||||
const languages = translationMap.get(post.id);
|
||||
if (languages) {
|
||||
for (const lang of languages) {
|
||||
const variantSlug = `${post.slug}.${lang}`;
|
||||
canonicalPostPathBySlug.set(variantSlug, buildCanonicalPostPath({ ...post, slug: variantSlug }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const canonicalMediaPathBySourcePath = new Map<string, string>();
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import type { MenuDocument } from './MenuEngine';
|
||||
import type { ProjectMetadata } from './MetaEngine';
|
||||
import { getPicoStylesheetHref, sanitizePicoTheme } from '../shared/picoThemes';
|
||||
import { POST_LANGUAGE_FLAGS, type SupportedLanguage } from '../shared/i18n';
|
||||
import {
|
||||
buildTemplateMenuItems,
|
||||
clampMaxPostsPerPage,
|
||||
parseRoutePagination,
|
||||
resolvePageTitle,
|
||||
type AlternateLinkEntry,
|
||||
type BacklinkEntry,
|
||||
type PostEngineContract,
|
||||
type CategoryRenderSettings,
|
||||
@@ -32,7 +34,8 @@ export interface SharedRouteRenderOptions {
|
||||
requestTheme?: string | null;
|
||||
htmlThemeAttribute?: string;
|
||||
allowEmptyArchiveRender?: boolean;
|
||||
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string };
|
||||
preferredLanguage?: string;
|
||||
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string; lang?: string; preferredLanguage?: string };
|
||||
}
|
||||
|
||||
export interface SharedRouteRenderServices<TCategoryMetadata> {
|
||||
@@ -77,9 +80,13 @@ export interface SharedRouteRenderServices<TCategoryMetadata> {
|
||||
day: number,
|
||||
pagination?: { maxPostsPerPage: number; page?: number; excludeCategories?: string[] },
|
||||
) => Promise<{ posts: PostData[]; totalPosts: number }>;
|
||||
findPublishedPostBySlug: (
|
||||
slug: string,
|
||||
dateFilter?: { year: number; month: number },
|
||||
) => Promise<PostData | null>;
|
||||
findSinglePostBySlug: (
|
||||
slug: string,
|
||||
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
|
||||
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string; lang?: string; preferredLanguage?: string },
|
||||
dateFilter?: { year: number; month: number; day?: number },
|
||||
) => Promise<PostData | null>;
|
||||
getLinkedBy?: (postId: string) => Promise<{ id: string; title: string; slug: string }[]>;
|
||||
@@ -114,6 +121,48 @@ async function resolveBacklinks(
|
||||
.filter((entry): entry is BacklinkEntry => entry !== null);
|
||||
}
|
||||
|
||||
function resolveAlternateLinks(
|
||||
post: PostData,
|
||||
rewriteContext: HtmlRewriteContext,
|
||||
): AlternateLinkEntry[] {
|
||||
const variantPost = post as PostData & {
|
||||
translationSourceSlug?: string;
|
||||
translationCanonicalLanguage?: string;
|
||||
};
|
||||
const sourceSlug = typeof variantPost.translationSourceSlug === 'string' && variantPost.translationSourceSlug.trim().length > 0
|
||||
? variantPost.translationSourceSlug.trim()
|
||||
: post.slug;
|
||||
const canonicalLanguage = typeof variantPost.translationCanonicalLanguage === 'string' && variantPost.translationCanonicalLanguage.trim().length > 0
|
||||
? variantPost.translationCanonicalLanguage.trim()
|
||||
: (post.language || '').trim();
|
||||
const linkByLanguage = new Map<string, string>();
|
||||
const currentLanguage = (post.language || canonicalLanguage).trim();
|
||||
const currentHref = rewriteContext.canonicalPostPathBySlug.get(post.slug);
|
||||
if (currentLanguage && currentHref) {
|
||||
linkByLanguage.set(currentLanguage, currentHref);
|
||||
}
|
||||
|
||||
const canonicalHref = rewriteContext.canonicalPostPathBySlug.get(sourceSlug);
|
||||
if (canonicalLanguage && canonicalHref) {
|
||||
linkByLanguage.set(canonicalLanguage, canonicalHref);
|
||||
}
|
||||
|
||||
const languages = Array.from(new Set((Array.isArray(post.availableLanguages) ? post.availableLanguages : [])
|
||||
.filter((language) => typeof language === 'string' && language.trim().length > 0)));
|
||||
for (const language of languages) {
|
||||
if (linkByLanguage.has(language)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const href = rewriteContext.canonicalPostPathBySlug.get(`${sourceSlug}.${language}`);
|
||||
if (href) {
|
||||
linkByLanguage.set(language, href);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(linkByLanguage.entries()).map(([hreflang, href]) => ({ hreflang, href }));
|
||||
}
|
||||
|
||||
async function resolveRouteWithSharedServices(
|
||||
pathname: string,
|
||||
maxPostsPerPage: number,
|
||||
@@ -121,6 +170,9 @@ async function resolveRouteWithSharedServices(
|
||||
pageContext: {
|
||||
pageTitle: string;
|
||||
language: string;
|
||||
blogLanguages: Array<{ code: string; flag: string; href_prefix: string; is_current: boolean }>;
|
||||
currentLanguage: string;
|
||||
languagePrefix: string;
|
||||
menuItems: ReturnType<typeof buildTemplateMenuItems>;
|
||||
picoStylesheetHref: string;
|
||||
htmlThemeAttribute?: string;
|
||||
@@ -132,7 +184,7 @@ async function resolveRouteWithSharedServices(
|
||||
listExcludedCategories: string[],
|
||||
services: SharedRouteRenderServices<CategoryMetadata>,
|
||||
allowEmptyArchiveRender: boolean,
|
||||
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
|
||||
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string; lang?: string; preferredLanguage?: string },
|
||||
): Promise<string | null> {
|
||||
const routePagination = parseRoutePagination(pathname);
|
||||
if (!routePagination) {
|
||||
@@ -157,6 +209,9 @@ async function resolveRouteWithSharedServices(
|
||||
categorySettings,
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
blog_languages: pageContext.blogLanguages,
|
||||
current_language: pageContext.currentLanguage,
|
||||
language_prefix: pageContext.languagePrefix,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
@@ -177,6 +232,9 @@ async function resolveRouteWithSharedServices(
|
||||
categorySettings,
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
blog_languages: pageContext.blogLanguages,
|
||||
current_language: pageContext.currentLanguage,
|
||||
language_prefix: pageContext.languagePrefix,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
@@ -198,6 +256,9 @@ async function resolveRouteWithSharedServices(
|
||||
categorySettings,
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
blog_languages: pageContext.blogLanguages,
|
||||
current_language: pageContext.currentLanguage,
|
||||
language_prefix: pageContext.languagePrefix,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
@@ -211,12 +272,18 @@ async function resolveRouteWithSharedServices(
|
||||
const month = Number(daySlugMatch[2]);
|
||||
const day = Number(daySlugMatch[3]);
|
||||
const slug = daySlugMatch[4];
|
||||
const post = await services.findSinglePostBySlug(slug, singlePostOptions, { year, month, day });
|
||||
const post = await services.findSinglePostBySlug(slug, {
|
||||
...singlePostOptions,
|
||||
preferredLanguage: singlePostOptions?.preferredLanguage ?? pageContext.language,
|
||||
}, { year, month, day });
|
||||
if (!post) return null;
|
||||
const backlinks = await resolveBacklinks(post.id, rewriteContext, services.getLinkedBy);
|
||||
return services.pageRenderer.renderSinglePost(post, rewriteContext, {
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
blog_languages: pageContext.blogLanguages,
|
||||
current_language: pageContext.currentLanguage,
|
||||
language_prefix: pageContext.languagePrefix,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
@@ -224,6 +291,7 @@ async function resolveRouteWithSharedServices(
|
||||
tagSettings: tagTemplateSettings,
|
||||
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
|
||||
backlinks,
|
||||
alternate_links: resolveAlternateLinks(post, rewriteContext),
|
||||
}, services.postEngineForMacros);
|
||||
}
|
||||
|
||||
@@ -245,6 +313,9 @@ async function resolveRouteWithSharedServices(
|
||||
categorySettings,
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
blog_languages: pageContext.blogLanguages,
|
||||
current_language: pageContext.currentLanguage,
|
||||
language_prefix: pageContext.languagePrefix,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
@@ -267,6 +338,9 @@ async function resolveRouteWithSharedServices(
|
||||
categorySettings,
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
blog_languages: pageContext.blogLanguages,
|
||||
current_language: pageContext.currentLanguage,
|
||||
language_prefix: pageContext.languagePrefix,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
@@ -287,6 +361,9 @@ async function resolveRouteWithSharedServices(
|
||||
categorySettings,
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
blog_languages: pageContext.blogLanguages,
|
||||
current_language: pageContext.currentLanguage,
|
||||
language_prefix: pageContext.languagePrefix,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
@@ -297,13 +374,17 @@ async function resolveRouteWithSharedServices(
|
||||
const pageSlugMatch = pagedPathname.match(/^\/([^/]+)$/);
|
||||
if (pageSlugMatch) {
|
||||
const slug = pageSlugMatch[1];
|
||||
const pages = await services.loadPublishedSnapshots({ status: 'published', categories: ['page'] }, { maxPostsPerPage });
|
||||
const pagePost = pages.find((candidate) => candidate.slug === slug) || null;
|
||||
if (!pagePost) return null;
|
||||
const pagePost = await services.findPublishedPostBySlug(slug);
|
||||
if (!pagePost || !(Array.isArray(pagePost.categories) && pagePost.categories.includes('page'))) {
|
||||
return null;
|
||||
}
|
||||
const backlinks = await resolveBacklinks(pagePost.id, rewriteContext, services.getLinkedBy);
|
||||
return services.pageRenderer.renderSinglePost(pagePost, rewriteContext, {
|
||||
page_title: pageContext.pageTitle,
|
||||
language: pageContext.language,
|
||||
blog_languages: pageContext.blogLanguages,
|
||||
current_language: pageContext.currentLanguage,
|
||||
language_prefix: pageContext.languagePrefix,
|
||||
menu_items: pageContext.menuItems,
|
||||
pico_stylesheet_href: pageContext.picoStylesheetHref,
|
||||
html_theme_attribute: pageContext.htmlThemeAttribute,
|
||||
@@ -311,6 +392,7 @@ async function resolveRouteWithSharedServices(
|
||||
tagSettings: tagTemplateSettings,
|
||||
categorySettings: categorySettings as Record<string, { postTemplateSlug?: string | null }>,
|
||||
backlinks,
|
||||
alternate_links: resolveAlternateLinks(pagePost, rewriteContext),
|
||||
}, services.postEngineForMacros);
|
||||
}
|
||||
|
||||
@@ -343,7 +425,7 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
|
||||
const menuItems = buildTemplateMenuItems(menu, categoryMetadata as Record<string, { title?: string }>);
|
||||
const categorySettings = services.resolveCategorySettings(metadata ?? null);
|
||||
const listExcludedCategories = services.resolveListExcludedCategories(categorySettings);
|
||||
const language = metadata?.mainLanguage?.trim() || 'en';
|
||||
const language = options.preferredLanguage?.trim().toLowerCase() || metadata?.mainLanguage?.trim() || 'en';
|
||||
const pageTitle = resolvePageTitle(metadata ?? null, options.projectContext.projectName, options.projectContext.projectDescription);
|
||||
const maxPostsPerPage = clampMaxPostsPerPage(options.maxPostsPerPage ?? metadata?.maxPostsPerPage);
|
||||
const appliedTheme = sanitizePicoTheme(options.requestTheme)
|
||||
@@ -354,9 +436,32 @@ export async function renderRouteWithSharedContext<TCategoryMetadata>(
|
||||
const tagTemplateSettings = await services.resolveTagTemplateSettings?.(options.projectContext) ?? {};
|
||||
const normalizedPathname = decodeURIComponent(pathname.replace(/\/+$/, '') || '/');
|
||||
|
||||
const languagePrefix = htmlRewriteContext.languagePrefix ?? '';
|
||||
const currentLanguage = languagePrefix
|
||||
? languagePrefix.replace(/^\//, '')
|
||||
: language;
|
||||
const mainLang = metadata?.mainLanguage?.trim().toLowerCase() || 'en';
|
||||
const rawBlogLanguages: string[] = Array.isArray((metadata as { blogLanguages?: unknown })?.blogLanguages)
|
||||
? (metadata as { blogLanguages: string[] }).blogLanguages
|
||||
: [];
|
||||
const allBlogLanguages = rawBlogLanguages.length > 0
|
||||
? (rawBlogLanguages.includes(mainLang) ? rawBlogLanguages : [mainLang, ...rawBlogLanguages])
|
||||
: [];
|
||||
const blogLanguages = allBlogLanguages.length > 0
|
||||
? allBlogLanguages.map((lang) => ({
|
||||
code: lang,
|
||||
flag: POST_LANGUAGE_FLAGS[lang as SupportedLanguage] ?? '',
|
||||
href_prefix: lang === mainLang ? '' : `/${lang}`,
|
||||
is_current: lang === currentLanguage,
|
||||
}))
|
||||
: [];
|
||||
|
||||
return resolveRouteWithSharedServices(normalizedPathname, maxPostsPerPage, htmlRewriteContext, {
|
||||
pageTitle,
|
||||
language,
|
||||
blogLanguages,
|
||||
currentLanguage,
|
||||
languagePrefix,
|
||||
menuItems,
|
||||
picoStylesheetHref,
|
||||
htmlThemeAttribute: options.htmlThemeAttribute,
|
||||
|
||||
@@ -1,12 +1,20 @@
|
||||
import type { PostData, PostFilter } from './PostEngine';
|
||||
import type { PostData, PostFilter, PostTranslationData } from './PostEngine';
|
||||
|
||||
export interface SharedSnapshotPostEngine {
|
||||
getPostsFiltered: (filter: PostFilter) => Promise<PostData[]>;
|
||||
getPost: (id: string) => Promise<PostData | null>;
|
||||
getPublishedVersion: (id: string) => Promise<PostData | null>;
|
||||
getPostTranslation?: (postId: string, language: string) => Promise<PostTranslationData | null>;
|
||||
findPublishedBySlug?: (slug: string, dateFilter?: { year: number; month: number }) => Promise<PostData | null>;
|
||||
}
|
||||
|
||||
interface SinglePostPreviewOptions {
|
||||
useDraftContent?: boolean;
|
||||
draftPostId?: string;
|
||||
lang?: string;
|
||||
preferredLanguage?: string;
|
||||
}
|
||||
|
||||
function buildSnapshotBaseFilter(filter: PostFilter): PostFilter {
|
||||
const baseFilter: PostFilter = {};
|
||||
|
||||
@@ -166,25 +174,64 @@ export async function findPublishedPostBySlug(
|
||||
export async function findSinglePostBySlug(
|
||||
postEngine: SharedSnapshotPostEngine,
|
||||
slug: string,
|
||||
singlePostOptions?: { useDraftContent?: boolean; draftPostId?: string },
|
||||
singlePostOptions?: SinglePostPreviewOptions,
|
||||
dateFilter?: { year: number; month: number; day?: number },
|
||||
): Promise<PostData | null> {
|
||||
let resolvedPost: PostData | null = null;
|
||||
|
||||
if (singlePostOptions?.useDraftContent && singlePostOptions.draftPostId) {
|
||||
const draftCandidate = await postEngine.getPost(singlePostOptions.draftPostId);
|
||||
if (draftCandidate && draftCandidate.status === 'draft' && draftCandidate.slug === slug) {
|
||||
if (!dateFilter) {
|
||||
return draftCandidate;
|
||||
}
|
||||
|
||||
const sameYear = draftCandidate.createdAt.getFullYear() === dateFilter.year;
|
||||
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month - 1;
|
||||
const sameDay = dateFilter.day === undefined || draftCandidate.createdAt.getDate() === dateFilter.day;
|
||||
if (sameYear && sameMonth && sameDay) {
|
||||
return draftCandidate;
|
||||
resolvedPost = draftCandidate;
|
||||
} else {
|
||||
const sameYear = draftCandidate.createdAt.getFullYear() === dateFilter.year;
|
||||
const sameMonth = draftCandidate.createdAt.getMonth() === dateFilter.month - 1;
|
||||
const sameDay = dateFilter.day === undefined || draftCandidate.createdAt.getDate() === dateFilter.day;
|
||||
if (sameYear && sameMonth && sameDay) {
|
||||
resolvedPost = draftCandidate;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fallbackDateFilter = dateFilter ? { year: dateFilter.year, month: dateFilter.month } : undefined;
|
||||
return findPublishedPostBySlug(postEngine, slug, fallbackDateFilter);
|
||||
if (!resolvedPost) {
|
||||
const fallbackDateFilter = dateFilter ? { year: dateFilter.year, month: dateFilter.month } : undefined;
|
||||
resolvedPost = await findPublishedPostBySlug(postEngine, slug, fallbackDateFilter);
|
||||
}
|
||||
|
||||
if (!resolvedPost) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const requestedLanguage = (singlePostOptions?.lang ?? singlePostOptions?.preferredLanguage)?.trim().toLowerCase();
|
||||
if (!requestedLanguage || requestedLanguage === (resolvedPost.language || '').trim().toLowerCase() || !postEngine.getPostTranslation) {
|
||||
return resolvedPost;
|
||||
}
|
||||
|
||||
const translation = await postEngine.getPostTranslation(resolvedPost.id, requestedLanguage);
|
||||
if (!translation) {
|
||||
return resolvedPost;
|
||||
}
|
||||
|
||||
const availableLanguages = Array.from(new Set([
|
||||
...((Array.isArray(resolvedPost.availableLanguages) ? resolvedPost.availableLanguages : [])),
|
||||
requestedLanguage,
|
||||
(resolvedPost.language || '').trim(),
|
||||
].filter((language): language is string => typeof language === 'string' && language.length > 0)));
|
||||
|
||||
return {
|
||||
...resolvedPost,
|
||||
id: translation.id,
|
||||
slug: `${resolvedPost.slug}.${translation.language}`,
|
||||
title: translation.title,
|
||||
excerpt: translation.excerpt,
|
||||
content: translation.content,
|
||||
language: translation.language,
|
||||
updatedAt: translation.updatedAt,
|
||||
publishedAt: translation.publishedAt ?? resolvedPost.publishedAt,
|
||||
availableLanguages,
|
||||
translationSourceSlug: resolvedPost.slug,
|
||||
translationCanonicalLanguage: resolvedPost.language,
|
||||
} as PostData;
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ interface CompareSitemapToHtmlParams {
|
||||
baseUrl: string;
|
||||
htmlDir: string;
|
||||
postTimestampChecks?: PostTimestampCheck[];
|
||||
additionalExpectedPaths?: string[];
|
||||
}
|
||||
|
||||
function normalizeUrlPath(urlPath: string): string {
|
||||
@@ -123,6 +124,12 @@ export async function compareSitemapToHtml(params: CompareSitemapToHtmlParams):
|
||||
.map((value) => normalizeUrlPath(value)),
|
||||
);
|
||||
|
||||
if (Array.isArray(params.additionalExpectedPaths)) {
|
||||
for (const p of params.additionalExpectedPaths) {
|
||||
expectedPathSet.add(normalizeUrlPath(p));
|
||||
}
|
||||
}
|
||||
|
||||
const { existingHtmlPathSet, zeroByteHtmlPathSet } = await collectHtmlIndexPaths(params.htmlDir);
|
||||
|
||||
const missingUrlPaths = Array.from(expectedPathSet)
|
||||
|
||||
@@ -17,6 +17,7 @@ export interface MissingPathPlan {
|
||||
requestedPageSlugs: Set<string>;
|
||||
requestRootRoutes: boolean;
|
||||
requiresFallbackSectionRender: boolean;
|
||||
languagePlans: Map<string, MissingPathPlan>;
|
||||
}
|
||||
|
||||
export interface TargetedValidationPlan {
|
||||
@@ -58,35 +59,32 @@ function decodePathSegment(value: string): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function planMissingValidationPaths(missingPaths: string[]): MissingPathPlan {
|
||||
const requestedCategories = new Set<string>();
|
||||
const requestedTags = new Set<string>();
|
||||
const requestedYears = new Set<number>();
|
||||
const requestedYearMonths = new Set<string>();
|
||||
const requestedYearMonthDays = new Set<string>();
|
||||
const requestedPostRoutes: RequestedPostRoute[] = [];
|
||||
const requestedPageSlugs = new Set<string>();
|
||||
let requestRootRoutes = false;
|
||||
let requiresFallbackSectionRender = false;
|
||||
|
||||
for (const missingPath of missingPaths) {
|
||||
const normalizedPath = normalizeUrlPath(missingPath);
|
||||
|
||||
if (normalizedPath === '/' || /^\/page\/\d+$/.test(normalizedPath)) {
|
||||
requestRootRoutes = true;
|
||||
continue;
|
||||
function classifyPath(
|
||||
normalizedPath: string,
|
||||
requestedCategories: Set<string>,
|
||||
requestedTags: Set<string>,
|
||||
requestedYears: Set<number>,
|
||||
requestedYearMonths: Set<string>,
|
||||
requestedYearMonthDays: Set<string>,
|
||||
requestedPostRoutes: RequestedPostRoute[],
|
||||
requestedPageSlugs: Set<string>,
|
||||
state: { requestRootRoutes: boolean; requiresFallbackSectionRender: boolean },
|
||||
): void {
|
||||
if (normalizedPath === '/' || /^\/(page\/\d+)$/.test(normalizedPath)) {
|
||||
state.requestRootRoutes = true;
|
||||
return;
|
||||
}
|
||||
|
||||
const categoryMatch = normalizedPath.match(/^\/category\/([^/]+)(?:\/page\/\d+)?$/);
|
||||
if (categoryMatch) {
|
||||
requestedCategories.add(decodePathSegment(categoryMatch[1]));
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
const tagMatch = normalizedPath.match(/^\/tag\/([^/]+)(?:\/page\/\d+)?$/);
|
||||
if (tagMatch) {
|
||||
requestedTags.add(decodePathSegment(tagMatch[1]));
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
const singleMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})\/([^/]+)$/);
|
||||
@@ -97,48 +95,131 @@ export function planMissingValidationPaths(missingPaths: string[]): MissingPathP
|
||||
day: Number(singleMatch[3]),
|
||||
slug: decodePathSegment(singleMatch[4]),
|
||||
});
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
const yearMatch = normalizedPath.match(/^\/(\d{4})(?:\/page\/\d+)?$/);
|
||||
if (yearMatch) {
|
||||
requestedYears.add(Number(yearMatch[1]));
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
const monthMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})(?:\/page\/\d+)?$/);
|
||||
if (monthMatch) {
|
||||
requestedYearMonths.add(`${monthMatch[1]}/${monthMatch[2]}`);
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
const dayMatch = normalizedPath.match(/^\/(\d{4})\/(\d{2})\/(\d{2})(?:\/page\/\d+)?$/);
|
||||
if (dayMatch) {
|
||||
requestedYearMonthDays.add(`${dayMatch[1]}/${dayMatch[2]}/${dayMatch[3]}`);
|
||||
continue;
|
||||
return;
|
||||
}
|
||||
|
||||
const pageMatch = normalizedPath.match(/^\/([^/]+)$/);
|
||||
if (pageMatch) {
|
||||
requestedPageSlugs.add(decodePathSegment(pageMatch[1]));
|
||||
return;
|
||||
}
|
||||
|
||||
state.requiresFallbackSectionRender = true;
|
||||
}
|
||||
|
||||
function createEmptyPlan(): {
|
||||
requestedCategories: Set<string>;
|
||||
requestedTags: Set<string>;
|
||||
requestedYears: Set<number>;
|
||||
requestedYearMonths: Set<string>;
|
||||
requestedYearMonthDays: Set<string>;
|
||||
requestedPostRoutes: RequestedPostRoute[];
|
||||
requestedPageSlugs: Set<string>;
|
||||
state: { requestRootRoutes: boolean; requiresFallbackSectionRender: boolean };
|
||||
} {
|
||||
return {
|
||||
requestedCategories: new Set<string>(),
|
||||
requestedTags: new Set<string>(),
|
||||
requestedYears: new Set<number>(),
|
||||
requestedYearMonths: new Set<string>(),
|
||||
requestedYearMonthDays: new Set<string>(),
|
||||
requestedPostRoutes: [],
|
||||
requestedPageSlugs: new Set<string>(),
|
||||
state: { requestRootRoutes: false, requiresFallbackSectionRender: false },
|
||||
};
|
||||
}
|
||||
|
||||
function finalizePlan(plan: ReturnType<typeof createEmptyPlan>, languagePlans: Map<string, MissingPathPlan>): MissingPathPlan {
|
||||
return {
|
||||
requestedCategories: plan.requestedCategories,
|
||||
requestedTags: plan.requestedTags,
|
||||
requestedYears: plan.requestedYears,
|
||||
requestedYearMonths: plan.requestedYearMonths,
|
||||
requestedYearMonthDays: plan.requestedYearMonthDays,
|
||||
requestedPostRoutes: plan.requestedPostRoutes,
|
||||
requestedPageSlugs: plan.requestedPageSlugs,
|
||||
requestRootRoutes: plan.state.requestRootRoutes,
|
||||
requiresFallbackSectionRender: plan.state.requiresFallbackSectionRender,
|
||||
languagePlans,
|
||||
};
|
||||
}
|
||||
|
||||
export function planMissingValidationPaths(missingPaths: string[], additionalLanguages?: string[]): MissingPathPlan {
|
||||
const mainPlan = createEmptyPlan();
|
||||
const langPlanMap = new Map<string, ReturnType<typeof createEmptyPlan>>();
|
||||
const knownLanguages = new Set((additionalLanguages ?? []).map((l) => l.trim().toLowerCase()).filter((l) => l.length > 0));
|
||||
|
||||
for (const missingPath of missingPaths) {
|
||||
const normalizedPath = normalizeUrlPath(missingPath);
|
||||
|
||||
// Check for language prefix
|
||||
const langPrefixMatch = normalizedPath.match(/^\/([a-z]{2,3})(?:(\/.+))?$/);
|
||||
if (langPrefixMatch && knownLanguages.has(langPrefixMatch[1])) {
|
||||
const lang = langPrefixMatch[1];
|
||||
const strippedPath = langPrefixMatch[2] ? normalizeUrlPath(langPrefixMatch[2]) : '/';
|
||||
|
||||
if (!langPlanMap.has(lang)) {
|
||||
langPlanMap.set(lang, createEmptyPlan());
|
||||
}
|
||||
const lp = langPlanMap.get(lang)!;
|
||||
classifyPath(
|
||||
strippedPath,
|
||||
lp.requestedCategories,
|
||||
lp.requestedTags,
|
||||
lp.requestedYears,
|
||||
lp.requestedYearMonths,
|
||||
lp.requestedYearMonthDays,
|
||||
lp.requestedPostRoutes,
|
||||
lp.requestedPageSlugs,
|
||||
lp.state,
|
||||
);
|
||||
if (lp.state.requiresFallbackSectionRender) {
|
||||
mainPlan.state.requiresFallbackSectionRender = true;
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
requiresFallbackSectionRender = true;
|
||||
break;
|
||||
classifyPath(
|
||||
normalizedPath,
|
||||
mainPlan.requestedCategories,
|
||||
mainPlan.requestedTags,
|
||||
mainPlan.requestedYears,
|
||||
mainPlan.requestedYearMonths,
|
||||
mainPlan.requestedYearMonthDays,
|
||||
mainPlan.requestedPostRoutes,
|
||||
mainPlan.requestedPageSlugs,
|
||||
mainPlan.state,
|
||||
);
|
||||
if (mainPlan.state.requiresFallbackSectionRender) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
requestedCategories,
|
||||
requestedTags,
|
||||
requestedYears,
|
||||
requestedYearMonths,
|
||||
requestedYearMonthDays,
|
||||
requestedPostRoutes,
|
||||
requestedPageSlugs,
|
||||
requestRootRoutes,
|
||||
requiresFallbackSectionRender,
|
||||
};
|
||||
const languagePlans = new Map<string, MissingPathPlan>();
|
||||
for (const [lang, lp] of langPlanMap) {
|
||||
languagePlans.set(lang, finalizePlan(lp, new Map()));
|
||||
}
|
||||
|
||||
return finalizePlan(mainPlan, languagePlans);
|
||||
}
|
||||
|
||||
interface BuildTargetedValidationPlanParams {
|
||||
|
||||
@@ -167,12 +167,14 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
query: z.string().describe('The search query text to find in posts'),
|
||||
category: z.string().optional().describe('Optional category to filter by'),
|
||||
tags: z.array(z.string()).optional().describe('Optional array of tags to filter by (all must match)'),
|
||||
language: z.string().optional().describe('Require posts that are available in this language'),
|
||||
missingTranslationLanguage: z.string().optional().describe('Require posts that are missing this translation language'),
|
||||
year: z.number().optional().describe('Filter to posts created in this year'),
|
||||
month: z.number().optional().describe('Filter to posts created in this month (1-12). Requires year.'),
|
||||
limit: z.number().optional().describe('Maximum number of results to return (default: 10)'),
|
||||
offset: z.number().optional().describe('Offset for pagination (default: 0)'),
|
||||
}),
|
||||
execute: async ({ query, category, tags, year, month, limit: lim, offset: off }) => {
|
||||
execute: async ({ query, category, tags, language, missingTranslationLanguage, year, month, limit: lim, offset: off }) => {
|
||||
if (month !== undefined && year === undefined) {
|
||||
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
|
||||
}
|
||||
@@ -180,6 +182,8 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
const filter: PostFilter = {};
|
||||
if (category) filter.categories = [category];
|
||||
if (tags && tags.length > 0) filter.tags = tags;
|
||||
if (language) filter.language = language;
|
||||
if (missingTranslationLanguage) filter.missingTranslationLanguage = missingTranslationLanguage;
|
||||
if (year !== undefined) filter.year = year;
|
||||
if (month !== undefined && year !== undefined) filter.month = month;
|
||||
|
||||
@@ -194,6 +198,7 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
id: p.id, title: p.title, slug: p.slug,
|
||||
excerpt: p.excerpt, status: p.status,
|
||||
categories: p.categories, tags: p.tags,
|
||||
availableLanguages: p.availableLanguages,
|
||||
createdAt: p.createdAt, updatedAt: p.updatedAt,
|
||||
})),
|
||||
postEngine,
|
||||
@@ -232,6 +237,7 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
content: post.content, excerpt: post.excerpt,
|
||||
status: post.status, author: post.author,
|
||||
categories: post.categories, tags: post.tags,
|
||||
availableLanguages: post.availableLanguages,
|
||||
createdAt: post.createdAt, updatedAt: post.updatedAt,
|
||||
publishedAt: post.publishedAt,
|
||||
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
|
||||
@@ -260,6 +266,7 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
content: post.content, excerpt: post.excerpt,
|
||||
status: post.status, author: post.author,
|
||||
categories: post.categories, tags: post.tags,
|
||||
availableLanguages: post.availableLanguages,
|
||||
createdAt: post.createdAt, updatedAt: post.updatedAt,
|
||||
publishedAt: post.publishedAt,
|
||||
backlinks: backlinks.map(b => ({ id: b.id, title: b.title, slug: b.slug })),
|
||||
@@ -275,12 +282,14 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
status: z.enum(['draft', 'published', 'archived']).optional().describe('Filter by post status'),
|
||||
category: z.string().optional().describe('Filter by category'),
|
||||
tags: z.array(z.string()).optional().describe('Filter by tags (posts must have all specified tags)'),
|
||||
language: z.string().optional().describe('Require posts that are available in this language'),
|
||||
missingTranslationLanguage: z.string().optional().describe('Require posts that are missing this translation language'),
|
||||
year: z.number().optional().describe('Filter to posts created in this year'),
|
||||
month: z.number().optional().describe('Filter to posts created in this month (1-12). Requires year.'),
|
||||
limit: z.number().optional().describe('Maximum number of results (default: 20)'),
|
||||
offset: z.number().optional().describe('Offset for pagination (default: 0)'),
|
||||
}),
|
||||
execute: async ({ status, category, tags, year, month, limit: lim, offset: off }) => {
|
||||
execute: async ({ status, category, tags, language, missingTranslationLanguage, year, month, limit: lim, offset: off }) => {
|
||||
if (month !== undefined && year === undefined) {
|
||||
return { success: false, error: 'month requires year. Example: year: 2025, month: 3' };
|
||||
}
|
||||
@@ -289,6 +298,8 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
if (status) filter.status = status;
|
||||
if (tags) filter.tags = tags;
|
||||
if (category) filter.categories = [category];
|
||||
if (language) filter.language = language;
|
||||
if (missingTranslationLanguage) filter.missingTranslationLanguage = missingTranslationLanguage;
|
||||
if (year !== undefined) filter.year = year;
|
||||
if (month !== undefined && year !== undefined) filter.month = month;
|
||||
|
||||
@@ -317,7 +328,7 @@ export function createBlogTools(deps: BlogToolDeps) {
|
||||
pageItems.map(p => ({
|
||||
id: p.id, title: p.title, slug: p.slug,
|
||||
status: p.status, categories: p.categories,
|
||||
tags: p.tags, createdAt: p.createdAt, updatedAt: p.updatedAt,
|
||||
tags: p.tags, availableLanguages: p.availableLanguages, createdAt: p.createdAt, updatedAt: p.updatedAt,
|
||||
})),
|
||||
postEngine,
|
||||
);
|
||||
|
||||
32
src/main/engine/ai/retry.ts
Normal file
32
src/main/engine/ai/retry.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Retry helper with exponential backoff for AI API calls.
|
||||
*
|
||||
* Delays: baseDelayMs * 2^attempt (e.g. 5s, 10s, 20s for base=5000).
|
||||
*/
|
||||
|
||||
export interface RetryOptions {
|
||||
/** Maximum number of retries after the initial attempt (default: 3). */
|
||||
maxRetries?: number;
|
||||
/** Base delay in ms before the first retry (default: 5000). Doubles each retry. */
|
||||
baseDelayMs?: number;
|
||||
}
|
||||
|
||||
const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
export async function retryWithBackoff<T extends { success: boolean }>(
|
||||
fn: () => Promise<T>,
|
||||
options?: RetryOptions,
|
||||
): Promise<T> {
|
||||
const maxRetries = options?.maxRetries ?? 3;
|
||||
const baseDelayMs = options?.baseDelayMs ?? 5000;
|
||||
|
||||
let result = await fn();
|
||||
|
||||
for (let attempt = 0; attempt < maxRetries && !result.success; attempt++) {
|
||||
const delay = baseDelayMs * Math.pow(2, attempt);
|
||||
await sleep(delay);
|
||||
result = await fn();
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
@@ -46,6 +46,33 @@ export interface PostAnalysisResult {
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface PostTranslationResult {
|
||||
success: boolean;
|
||||
translation?: Awaited<ReturnType<PostEngine['upsertPostTranslation']>>;
|
||||
error?: string;
|
||||
warning?: string;
|
||||
}
|
||||
|
||||
export interface MediaTranslationResult {
|
||||
success: boolean;
|
||||
translation?: Awaited<ReturnType<MediaEngine['upsertMediaTranslation']>>;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function normalizeTranslatedMarkdownBody(content: string, sourceContent: string): string {
|
||||
const normalizedContent = content.trim();
|
||||
if (!normalizedContent) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const leadingLabelPattern = /^(content|inhalt|contenu|contenuto|contenido):\s*\n\s*\n/i;
|
||||
if (!leadingLabelPattern.test(normalizedContent) || leadingLabelPattern.test(sourceContent.trim())) {
|
||||
return normalizedContent;
|
||||
}
|
||||
|
||||
return normalizedContent.replace(leadingLabelPattern, '');
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OneShotTasks
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -442,4 +469,271 @@ Remember: Only suggest mappings from NEW items to EXISTING items. Consider langu
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
async translatePost(postId: string, targetLanguage: string, options?: { autoPublish?: boolean }): Promise<PostTranslationResult> {
|
||||
if (!this.postEngine) {
|
||||
return { success: false, error: 'Post engine not available' };
|
||||
}
|
||||
|
||||
const post = await this.postEngine.getPost(postId);
|
||||
if (!post) {
|
||||
return { success: false, error: 'Post not found' };
|
||||
}
|
||||
if (!post.content || post.content.trim().length === 0) {
|
||||
return { success: false, error: 'Post has no content to translate' };
|
||||
}
|
||||
|
||||
let modelId = await this.chatEngine.getSetting('chat_title_model');
|
||||
if (!modelId || !this.providers.isProviderKeySet(this.providers.detectModelProvider(modelId))) {
|
||||
modelId = this.providers.getOpencodeKey()
|
||||
? 'claude-sonnet-4-5'
|
||||
: this.providers.getMistralKey()
|
||||
? 'mistral-large-latest'
|
||||
: null;
|
||||
}
|
||||
|
||||
if (this.providers.isOfflineMode()) {
|
||||
const offlineModel = await this.chatEngine.getSetting('offline_title_model')
|
||||
|| this.providers.getFirstKnownLocalModelId();
|
||||
if (offlineModel) {
|
||||
modelId = offlineModel;
|
||||
} else if (!modelId || (!this.providers.isOllamaModel(modelId) && !this.providers.isLmstudioModel(modelId))) {
|
||||
return { success: false, error: 'No offline model configured. Set one in Settings → AI → Airplane Mode.' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!modelId) {
|
||||
return { success: false, error: 'API key not configured. Please set an API key in Settings.' };
|
||||
}
|
||||
|
||||
// Auto-detect source language if not explicitly set on the post
|
||||
let sourceLanguage = post.language || '';
|
||||
if (!sourceLanguage) {
|
||||
const detection = await this.detectPostLanguage(post.title, post.content || '');
|
||||
if (detection.success && detection.language) {
|
||||
sourceLanguage = detection.language;
|
||||
await this.postEngine.updatePost(postId, { language: sourceLanguage });
|
||||
} else {
|
||||
sourceLanguage = 'en';
|
||||
}
|
||||
}
|
||||
const metadataSystemPrompt = `You translate blog post metadata. Return ONLY valid JSON with keys title and excerpt. Do not add commentary. Do not invent or add any text that is not present in the source. Only translate the given text. Translate from ${sourceLanguage} to ${targetLanguage}.`;
|
||||
const metadataUserPrompt = [
|
||||
`Title: ${post.title}`,
|
||||
`Excerpt: ${post.excerpt || ''}`,
|
||||
].join('\n\n');
|
||||
const contentSystemPrompt = `You translate blog post Markdown bodies. Return ONLY the translated Markdown body, with no JSON envelope and no commentary. Preserve Markdown structure. Leave text inside fenced code blocks untranslated. Keep markdown link text and URLs unchanged (e.g. [link text](URL) stays as-is). The body may contain mixed languages — always translate the surrounding prose from ${sourceLanguage} to ${targetLanguage}, even if some parts (like link text) are already in the target language. Do not invent, add, or generate any text that is not present in the source. Only translate the exact text provided. If the content contains only macro calls, shortcodes, or other non-translatable tokens, return them unchanged. If the source body is empty, return an empty string.`;
|
||||
const contentUserPrompt = post.content;
|
||||
|
||||
try {
|
||||
const model = this.providers.resolveModel(modelId);
|
||||
const { text: metadataText } = await generateText({
|
||||
model,
|
||||
system: metadataSystemPrompt,
|
||||
prompt: metadataUserPrompt,
|
||||
maxOutputTokens: 500,
|
||||
maxRetries: 2,
|
||||
});
|
||||
|
||||
const { text: translatedContent } = await generateText({
|
||||
model,
|
||||
system: contentSystemPrompt,
|
||||
prompt: contentUserPrompt,
|
||||
maxOutputTokens: 4000,
|
||||
maxRetries: 2,
|
||||
});
|
||||
|
||||
let parsed: { title?: string; excerpt?: string } | null = null;
|
||||
const jsonMatch = metadataText.match(/\{[\s\S]*\}/);
|
||||
if (jsonMatch) {
|
||||
try {
|
||||
parsed = JSON.parse(jsonMatch[0]);
|
||||
} catch {
|
||||
parsed = null;
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedTranslatedContent = normalizeTranslatedMarkdownBody(translatedContent || '', post.content);
|
||||
|
||||
// Collect warnings for partial failures
|
||||
const warnings: string[] = [];
|
||||
if (!parsed) {
|
||||
warnings.push('metadata JSON parsing failed, title/excerpt kept as original');
|
||||
}
|
||||
if (normalizedTranslatedContent.trim() === post.content.trim()) {
|
||||
warnings.push('translated content is identical to source');
|
||||
}
|
||||
|
||||
const translation = await this.postEngine.upsertPostTranslation(postId, targetLanguage, {
|
||||
title: parsed?.title || post.title,
|
||||
excerpt: parsed?.excerpt || post.excerpt || undefined,
|
||||
content: normalizedTranslatedContent,
|
||||
status: options?.autoPublish ? 'published' : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
translation,
|
||||
warning: warnings.length > 0 ? warnings.join('; ') : undefined,
|
||||
};
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the language of media metadata (title, alt, caption).
|
||||
* Uses the configured title model (lightweight, text-only).
|
||||
*/
|
||||
async detectMediaLanguage(
|
||||
title: string,
|
||||
alt: string,
|
||||
caption: string,
|
||||
): Promise<LanguageDetectionResult> {
|
||||
const combined = [title, alt, caption].filter(Boolean).join('\n');
|
||||
if (!combined.trim()) {
|
||||
return { success: false, error: 'No metadata text provided for language detection' };
|
||||
}
|
||||
|
||||
let modelId = await this.chatEngine.getSetting('chat_title_model');
|
||||
if (!modelId || !this.providers.isProviderKeySet(this.providers.detectModelProvider(modelId))) {
|
||||
modelId = this.providers.getOpencodeKey()
|
||||
? 'claude-sonnet-4-5'
|
||||
: this.providers.getMistralKey()
|
||||
? 'mistral-large-latest'
|
||||
: null;
|
||||
}
|
||||
|
||||
if (this.providers.isOfflineMode()) {
|
||||
const offlineModel = await this.chatEngine.getSetting('offline_title_model')
|
||||
|| this.providers.getFirstKnownLocalModelId();
|
||||
if (offlineModel) {
|
||||
modelId = offlineModel;
|
||||
} else if (!modelId || (!this.providers.isOllamaModel(modelId) && !this.providers.isLmstudioModel(modelId))) {
|
||||
return { success: false, error: 'No offline model configured. Set one in Settings → AI → Airplane Mode.' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!modelId) {
|
||||
return { success: false, error: 'API key not configured. Please set an API key in Settings.' };
|
||||
}
|
||||
|
||||
const supportedLanguages = ['en', 'de', 'fr', 'it', 'es'];
|
||||
const systemPrompt = `You are a language detection assistant. Given image metadata (title, alt text, caption), determine the language. Respond with ONLY a JSON object: { "language": "<code>" } where <code> is one of: ${supportedLanguages.join(', ')}. If the language is not in the list, pick the closest match. No other text.`;
|
||||
const userPrompt = `Title: ${title}\nAlt: ${alt}\nCaption: ${caption}`;
|
||||
|
||||
try {
|
||||
const model = this.providers.resolveModel(modelId);
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
maxOutputTokens: 50,
|
||||
maxRetries: 2,
|
||||
});
|
||||
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) return { success: false, error: 'Invalid response format from AI' };
|
||||
|
||||
const result = JSON.parse(jsonMatch[0]);
|
||||
const detected = (result.language || '').toLowerCase().trim();
|
||||
if (!supportedLanguages.includes(detected)) {
|
||||
return { success: false, error: `Unsupported language detected: ${detected}` };
|
||||
}
|
||||
|
||||
return { success: true, language: detected };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate media metadata (title, alt, caption) into a target language.
|
||||
* Persists the result as a media translation via MediaEngine.
|
||||
*/
|
||||
async translateMediaMetadata(
|
||||
mediaId: string,
|
||||
targetLanguage: string,
|
||||
): Promise<MediaTranslationResult> {
|
||||
const mediaItem = await this.mediaEngine.getMedia(mediaId);
|
||||
if (!mediaItem) {
|
||||
return { success: false, error: 'Media item not found' };
|
||||
}
|
||||
|
||||
const hasMetadata = mediaItem.title || mediaItem.alt || mediaItem.caption;
|
||||
if (!hasMetadata) {
|
||||
return { success: false, error: 'Media item has no metadata to translate' };
|
||||
}
|
||||
|
||||
let modelId = await this.chatEngine.getSetting('chat_title_model');
|
||||
if (!modelId || !this.providers.isProviderKeySet(this.providers.detectModelProvider(modelId))) {
|
||||
modelId = this.providers.getOpencodeKey()
|
||||
? 'claude-sonnet-4-5'
|
||||
: this.providers.getMistralKey()
|
||||
? 'mistral-large-latest'
|
||||
: null;
|
||||
}
|
||||
|
||||
if (this.providers.isOfflineMode()) {
|
||||
const offlineModel = await this.chatEngine.getSetting('offline_title_model')
|
||||
|| this.providers.getFirstKnownLocalModelId();
|
||||
if (offlineModel) {
|
||||
modelId = offlineModel;
|
||||
} else if (!modelId || (!this.providers.isOllamaModel(modelId) && !this.providers.isLmstudioModel(modelId))) {
|
||||
return { success: false, error: 'No offline model configured. Set one in Settings → AI → Airplane Mode.' };
|
||||
}
|
||||
}
|
||||
|
||||
if (!modelId) {
|
||||
return { success: false, error: 'API key not configured. Please set an API key in Settings.' };
|
||||
}
|
||||
|
||||
// Auto-detect source language if not explicitly set on the media
|
||||
let sourceLanguage = mediaItem.language || '';
|
||||
if (!sourceLanguage) {
|
||||
const detection = await this.detectMediaLanguage(
|
||||
mediaItem.title || '',
|
||||
mediaItem.alt || '',
|
||||
mediaItem.caption || '',
|
||||
);
|
||||
if (detection.success && detection.language) {
|
||||
sourceLanguage = detection.language;
|
||||
await this.mediaEngine.updateMedia(mediaId, { language: sourceLanguage });
|
||||
} else {
|
||||
sourceLanguage = 'en';
|
||||
}
|
||||
}
|
||||
const systemPrompt = `You translate image metadata. Return ONLY valid JSON with keys title, alt, caption. Do not add commentary. Translate from ${sourceLanguage} to ${targetLanguage}. If a field is null or empty, return it as null.`;
|
||||
const userPrompt = [
|
||||
`Title: ${mediaItem.title || ''}`,
|
||||
`Alt: ${mediaItem.alt || ''}`,
|
||||
`Caption: ${mediaItem.caption || ''}`,
|
||||
].join('\n');
|
||||
|
||||
try {
|
||||
const model = this.providers.resolveModel(modelId);
|
||||
const { text } = await generateText({
|
||||
model,
|
||||
system: systemPrompt,
|
||||
prompt: userPrompt,
|
||||
maxOutputTokens: 300,
|
||||
maxRetries: 2,
|
||||
});
|
||||
|
||||
const jsonMatch = text.match(/\{[\s\S]*\}/);
|
||||
if (!jsonMatch) return { success: false, error: 'Invalid response format from AI' };
|
||||
|
||||
const parsed = JSON.parse(jsonMatch[0]);
|
||||
|
||||
const translation = await this.mediaEngine.upsertMediaTranslation(mediaId, targetLanguage, {
|
||||
title: parsed.title || undefined,
|
||||
alt: parsed.alt || undefined,
|
||||
caption: parsed.caption || undefined,
|
||||
});
|
||||
|
||||
return { success: true, translation };
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -143,3 +143,8 @@ main { display: grid; gap: 1rem; }
|
||||
.preview-pagination .spacer { flex: 1; }
|
||||
.not-found { display: grid; place-items: center; min-height: 48vh; }
|
||||
.not-found article { max-width: 32rem; text-align: center; }
|
||||
.language-switcher { position: fixed; right: .75rem; top: 1.5rem; display: flex; flex-direction: column; gap: .1rem; z-index: 100; }
|
||||
.language-switcher-badge { display: block; padding: .05rem .1rem; font-size: .85rem; line-height: 1.1; text-decoration: none; border: 1px solid transparent; border-radius: .15rem; cursor: pointer; opacity: .7; transition: opacity .15s ease-in-out; }
|
||||
.language-switcher-badge:hover,
|
||||
.language-switcher-badge:focus-visible { opacity: 1; border-color: var(--pico-color, var(--color)); }
|
||||
.language-switcher-badge-current { opacity: 1; border-color: var(--pico-primary, var(--primary)); }
|
||||
|
||||
@@ -9,6 +9,8 @@ export const CALENDAR_RUNTIME_JS = String.raw`(() => {
|
||||
return;
|
||||
}
|
||||
|
||||
const languagePrefix = document.documentElement.getAttribute('data-language-prefix') || '';
|
||||
|
||||
const labels = {
|
||||
loading: panel.getAttribute('data-i18n-loading') || 'Loading calendar…',
|
||||
error: panel.getAttribute('data-i18n-error') || 'Calendar data could not be loaded.',
|
||||
@@ -70,7 +72,7 @@ export const CALENDAR_RUNTIME_JS = String.raw`(() => {
|
||||
if (!pathname) {
|
||||
return;
|
||||
}
|
||||
window.location.assign(pathname);
|
||||
window.location.assign(languagePrefix + pathname);
|
||||
}
|
||||
|
||||
function parseInitialYearMonth() {
|
||||
@@ -86,7 +88,10 @@ export const CALENDAR_RUNTIME_JS = String.raw`(() => {
|
||||
: null;
|
||||
|
||||
if (!Number.isInteger(selectedYear) || !Number.isInteger(selectedMonth)) {
|
||||
const pathname = window.location.pathname || '';
|
||||
const rawPathname = window.location.pathname || '';
|
||||
const pathname = languagePrefix && rawPathname.startsWith(languagePrefix + '/')
|
||||
? rawPathname.slice(languagePrefix.length)
|
||||
: rawPathname;
|
||||
const parts = pathname.split('/').filter(Boolean);
|
||||
const pathYear = Number(parts[0]);
|
||||
const pathMonth = Number(parts[1]);
|
||||
@@ -104,7 +109,7 @@ export const CALENDAR_RUNTIME_JS = String.raw`(() => {
|
||||
}
|
||||
|
||||
async function loadCalendarData() {
|
||||
const response = await fetch('/calendar.json', { cache: 'no-store' });
|
||||
const response = await fetch(languagePrefix + '/calendar.json', { cache: 'no-store' });
|
||||
if (!response.ok) {
|
||||
throw new Error('calendar.json request failed');
|
||||
}
|
||||
|
||||
@@ -174,6 +174,7 @@ export const TAG_CLOUD_RUNTIME_JS = String.raw`(function () {
|
||||
return;
|
||||
}
|
||||
|
||||
const langPrefix = document.documentElement.getAttribute('data-language-prefix') || '';
|
||||
const rawWords = container.getAttribute('data-tag-cloud-words');
|
||||
const words = parseWords(rawWords);
|
||||
if (words.length === 0) {
|
||||
@@ -246,7 +247,7 @@ export const TAG_CLOUD_RUNTIME_JS = String.raw`(function () {
|
||||
|
||||
textNode.addEventListener('click', () => {
|
||||
if (word && typeof word.url === 'string' && word.url.length > 0) {
|
||||
window.location.assign(word.url);
|
||||
window.location.assign(langPrefix + word.url);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@ export {
|
||||
stemQuery,
|
||||
prepareForFTS,
|
||||
getSupportedLanguages,
|
||||
isoToStemmerLanguage,
|
||||
type SupportedLanguage,
|
||||
} from './stemmer';
|
||||
export {
|
||||
|
||||
@@ -105,6 +105,9 @@ const METHOD_NAME_MAP: Record<string, string> = {
|
||||
'posts.getAll': 'getAllPosts',
|
||||
'posts.getByStatus': 'getPostsByStatus',
|
||||
'posts.publish': 'publishPost',
|
||||
'posts.getTranslation': 'getPostTranslation',
|
||||
'posts.getTranslations': 'getPostTranslations',
|
||||
'posts.publishTranslation': 'publishPostTranslation',
|
||||
'posts.discard': 'discardChanges',
|
||||
'posts.hasPublishedVersion': 'hasPublishedVersion',
|
||||
'posts.rebuildFromFiles': 'rebuildDatabaseFromFiles',
|
||||
|
||||
@@ -15,6 +15,8 @@ export interface PostFileData {
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
author?: string;
|
||||
language?: string;
|
||||
doNotTranslate?: boolean;
|
||||
templateSlug?: string;
|
||||
createdAt: Date;
|
||||
updatedAt: Date;
|
||||
publishedAt?: Date;
|
||||
@@ -30,6 +32,8 @@ interface PostFileMetadata {
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
author?: string;
|
||||
language?: string;
|
||||
doNotTranslate?: boolean;
|
||||
templateSlug?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
publishedAt?: string;
|
||||
@@ -65,6 +69,8 @@ export async function readPostFile(filePath: string): Promise<PostFileData | nul
|
||||
status: metadata.status,
|
||||
author: metadata.author,
|
||||
language: metadata.language || undefined,
|
||||
doNotTranslate: metadata.doNotTranslate === true,
|
||||
templateSlug: metadata.templateSlug || undefined,
|
||||
createdAt: new Date(metadata.createdAt),
|
||||
updatedAt: new Date(metadata.updatedAt),
|
||||
publishedAt: metadata.publishedAt ? new Date(metadata.publishedAt) : undefined,
|
||||
|
||||
46
src/main/engine/postTranslationFileUtils.ts
Normal file
46
src/main/engine/postTranslationFileUtils.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import * as fs from 'fs/promises';
|
||||
import matter from 'gray-matter';
|
||||
|
||||
export interface PostTranslationFileData {
|
||||
translationFor: string;
|
||||
language: string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface PostTranslationFileMetadata {
|
||||
translationFor?: string;
|
||||
language?: string;
|
||||
title?: string;
|
||||
excerpt?: string;
|
||||
}
|
||||
|
||||
export async function readPostTranslationFile(filePath: string): Promise<PostTranslationFileData | null> {
|
||||
try {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileContent = await fs.readFile(filePath, 'utf-8');
|
||||
const { data, content } = matter(fileContent);
|
||||
const metadata = data as PostTranslationFileMetadata;
|
||||
|
||||
if (!metadata.translationFor || !metadata.language || !metadata.title) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
translationFor: metadata.translationFor,
|
||||
language: metadata.language,
|
||||
title: metadata.title,
|
||||
excerpt: metadata.excerpt || undefined,
|
||||
content,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error(`Failed to parse post translation file: ${filePath}`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -195,6 +195,13 @@ import json as _json
|
||||
import inspect as _inspect
|
||||
|
||||
_macro_ctx = _json.loads(__bds_macro_context_json)
|
||||
_bds_translations = _macro_ctx.get('env', {}).get('translations', {})
|
||||
def T(key, **kwargs):
|
||||
val = _bds_translations.get(key, key)
|
||||
if kwargs:
|
||||
for k, v in kwargs.items():
|
||||
val = val.replace('{' + k + '}', str(v))
|
||||
return val
|
||||
_macro_ep = __bds_macro_entrypoint
|
||||
_macro_fn = globals().get(_macro_ep)
|
||||
if _macro_fn is None or not callable(_macro_fn):
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
|
||||
<body>
|
||||
<main>
|
||||
|
||||
@@ -8,8 +8,12 @@
|
||||
<link rel="stylesheet" href="/assets/highlight.min.css" />
|
||||
<link rel="stylesheet" href="/assets/vanilla-calendar.min.css" />
|
||||
<link rel="stylesheet" href="/assets/bds.css" />
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS" href="/rss.xml" />
|
||||
<link rel="alternate" type="application/atom+xml" title="Atom" href="/atom.xml" />
|
||||
{% assign feed_prefix = language_prefix | default: '' %}
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS" href="{{ feed_prefix }}/rss.xml" />
|
||||
<link rel="alternate" type="application/atom+xml" title="Atom" href="{{ feed_prefix }}/atom.xml" />
|
||||
{% for alternate_link in alternate_links %}
|
||||
<link rel="alternate" hreflang="{{ alternate_link.hreflang | escape }}" href="{{ alternate_link.href | escape }}" />
|
||||
{% endfor %}
|
||||
<script defer src="/assets/highlight.min.js"></script>
|
||||
<script defer src="/assets/code-enhancements.js"></script>
|
||||
<script defer src="/assets/d3.layout.cloud.js"></script>
|
||||
|
||||
18
src/main/engine/templates/partials/language-switcher.liquid
Normal file
18
src/main/engine/templates/partials/language-switcher.liquid
Normal file
@@ -0,0 +1,18 @@
|
||||
{% if blog_languages.size > 1 %}
|
||||
<nav class="language-switcher" aria-label="{{ 'render.languageSwitcher.ariaLabel' | i18n: language }}">
|
||||
{% for lang in blog_languages %}
|
||||
{% if lang.is_current %}
|
||||
<span class="language-switcher-badge language-switcher-badge-current" aria-current="true" title="{{ lang.code }}">{{ lang.flag }}</span>
|
||||
{% else %}
|
||||
<a class="language-switcher-badge" href="{{ lang.href_prefix | default: '/' }}" data-lang-prefix="{{ lang.href_prefix }}" title="{{ lang.code }}">{{ lang.flag }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</nav>
|
||||
<script>
|
||||
(function(){
|
||||
var links=document.querySelectorAll('.language-switcher-badge[data-lang-prefix]');
|
||||
var path=location.pathname.replace(/^\/[a-z]{2}(?=\/|$)/,'') || '/';
|
||||
links.forEach(function(a){a.href=(a.dataset.langPrefix||'')+path;});
|
||||
}());
|
||||
</script>
|
||||
{% endif %}
|
||||
@@ -1,8 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
|
||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix %}
|
||||
<body>
|
||||
<main>
|
||||
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language %}
|
||||
{% if archive_context %}
|
||||
{% if show_archive_range_heading and min_date and max_date %}
|
||||
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
|
||||
@@ -44,7 +45,7 @@
|
||||
{% endif %}
|
||||
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
|
||||
{% endif %}
|
||||
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
|
||||
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
@@ -59,7 +60,7 @@
|
||||
{% endif %}
|
||||
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
|
||||
{% endif %}
|
||||
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}
|
||||
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
|
||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, alternate_links: alternate_links, language_prefix: language_prefix %}
|
||||
<body>
|
||||
<main>
|
||||
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language %}
|
||||
<h1>{{ post.title }}</h1>
|
||||
{% render 'partials/menu', menu_items: menu_items, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %}
|
||||
{% if post_categories.size > 0 or post_tags.size > 0 %}
|
||||
@@ -17,7 +18,7 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
<article class="single-post" data-template="single-post">
|
||||
<div class="post">{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language }}</div>
|
||||
<div class="post">{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}</div>
|
||||
</article>
|
||||
{% if backlinks.size > 0 %}
|
||||
<div class="single-post-backlinks" aria-label="{{ 'render.backlinks.ariaLabel' | i18n: language }}">
|
||||
|
||||
@@ -8,11 +8,18 @@ import {
|
||||
} from '../engine/BlogGenerationEngine';
|
||||
import { resolvePageTitle } from '../engine/PageRenderer';
|
||||
import type { EngineBundle } from '../engine/EngineBundle';
|
||||
import type { TranslationValidationReport } from '../shared/electronApi';
|
||||
import { autoTranslatePost, autoTranslateMediaMetadata } from './chatHandlers';
|
||||
import { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
type SafeHandle = (channel: string, handler: (...args: any[]) => Promise<any>) => void;
|
||||
|
||||
export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundle): void {
|
||||
const resolveBlogGenerationBaseOptions = async (): Promise<BlogGenerationOptions> => {
|
||||
const resolveActiveProjectContext = async (): Promise<{
|
||||
project: NonNullable<Awaited<ReturnType<EngineBundle['projectEngine']['getActiveProject']>>>;
|
||||
dataDir: string;
|
||||
metadata: Awaited<ReturnType<EngineBundle['metaEngine']['getProjectMetadata']>>;
|
||||
}> => {
|
||||
const projectEngine = bundle.projectEngine;
|
||||
const postEngine = bundle.postEngine;
|
||||
const metaEngine = bundle.metaEngine;
|
||||
@@ -37,6 +44,17 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl
|
||||
}
|
||||
|
||||
const metadata = await metaEngine.getProjectMetadata();
|
||||
|
||||
return {
|
||||
project,
|
||||
dataDir,
|
||||
metadata,
|
||||
};
|
||||
};
|
||||
|
||||
const resolveBlogGenerationBaseOptions = async (): Promise<BlogGenerationOptions> => {
|
||||
const menuEngine = bundle.menuEngine;
|
||||
const { project, dataDir, metadata } = await resolveActiveProjectContext();
|
||||
const menu = await menuEngine.getMenu();
|
||||
const baseUrl = resolvePublicBaseUrl(metadata?.publicUrl);
|
||||
if (!baseUrl) {
|
||||
@@ -60,6 +78,7 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl
|
||||
baseUrl,
|
||||
maxPostsPerPage: metadata?.maxPostsPerPage,
|
||||
language,
|
||||
blogLanguages: Array.isArray(metadata?.blogLanguages) ? metadata.blogLanguages : [],
|
||||
pageTitle,
|
||||
picoTheme: metadata?.picoTheme,
|
||||
categoryMetadata: (metadata as any)?.categoryMetadata,
|
||||
@@ -145,6 +164,142 @@ export function registerBlogHandlers(safeHandle: SafeHandle, bundle: EngineBundl
|
||||
});
|
||||
});
|
||||
|
||||
safeHandle('blog:validateTranslations', async () => {
|
||||
await resolveActiveProjectContext();
|
||||
|
||||
const taskTimestamp = Date.now();
|
||||
return bundle.taskManager.runTask({
|
||||
id: `translation-validate-${taskTimestamp}`,
|
||||
name: 'Validate Translations',
|
||||
execute: async (onProgress) => {
|
||||
onProgress(0, 'Validating translations...');
|
||||
const result = await bundle.postEngine.validateTranslations();
|
||||
onProgress(100, 'Translation validation complete');
|
||||
return result;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
safeHandle('blog:fixInvalidTranslations', async (_event, report: TranslationValidationReport) => {
|
||||
await resolveActiveProjectContext();
|
||||
|
||||
const taskTimestamp = Date.now();
|
||||
return bundle.taskManager.runTask({
|
||||
id: `translation-fix-${taskTimestamp}`,
|
||||
name: 'Fix Invalid Translations',
|
||||
execute: async (onProgress) => {
|
||||
onProgress(0, 'Fixing invalid translations...');
|
||||
const result = await bundle.postEngine.fixInvalidTranslations(report);
|
||||
onProgress(100, 'Invalid translations fixed');
|
||||
return result;
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
safeHandle('blog:fillMissingTranslations', async () => {
|
||||
const { metadata } = await resolveActiveProjectContext();
|
||||
const blogLanguages = metadata?.blogLanguages || [];
|
||||
const mainLang = metadata?.mainLanguage || 'en';
|
||||
if (blogLanguages.length === 0 || (blogLanguages.length === 1 && blogLanguages[0] === mainLang)) {
|
||||
return { taskStarted: false };
|
||||
}
|
||||
|
||||
// Start the task immediately — scanning happens inside with progress
|
||||
bundle.taskManager.runTask({
|
||||
id: uuidv4(),
|
||||
name: 'Fill missing translations',
|
||||
execute: async (onProgress) => {
|
||||
onProgress(0, 'Scanning posts…');
|
||||
|
||||
// Use missingTranslationLanguage filter per language instead of N+1 queries
|
||||
const postItems: Array<{ postId: string; postTitle: string; targetLang: string }> = [];
|
||||
for (let i = 0; i < blogLanguages.length; i++) {
|
||||
const lang = blogLanguages[i];
|
||||
const postsNeedingLang = await bundle.postEngine.getPostsFiltered({
|
||||
status: 'published',
|
||||
missingTranslationLanguage: lang,
|
||||
});
|
||||
for (const post of postsNeedingLang) {
|
||||
if (!post.doNotTranslate) {
|
||||
postItems.push({ postId: post.id, postTitle: post.title, targetLang: lang });
|
||||
}
|
||||
}
|
||||
onProgress(
|
||||
Math.round(((i + 1) / blogLanguages.length) * 10),
|
||||
`Scanning posts… (${i + 1}/${blogLanguages.length} languages)`,
|
||||
);
|
||||
}
|
||||
|
||||
onProgress(10, 'Scanning media…');
|
||||
|
||||
// Collect missing media translations
|
||||
const allPublished = await bundle.postEngine.getPostsFiltered({ status: 'published' });
|
||||
const publishedPosts = allPublished.filter((p) => !p.doNotTranslate);
|
||||
const mediaItems: Array<{ mediaId: string; targetLang: string }> = [];
|
||||
const seenMediaLang = new Set<string>();
|
||||
for (let i = 0; i < publishedPosts.length; i++) {
|
||||
const post = publishedPosts[i];
|
||||
const postLang = post.language || mainLang;
|
||||
const links = await bundle.postMediaEngine.getLinkedMediaForPost(post.id);
|
||||
for (const link of links) {
|
||||
const mediaTranslations = await bundle.mediaEngine.getMediaTranslations(link.mediaId);
|
||||
const existingLangs = new Set(mediaTranslations.map((t) => t.language));
|
||||
for (const lang of blogLanguages) {
|
||||
const key = `${link.mediaId}:${lang}`;
|
||||
if (lang !== postLang && !existingLangs.has(lang) && !seenMediaLang.has(key)) {
|
||||
seenMediaLang.add(key);
|
||||
mediaItems.push({ mediaId: link.mediaId, targetLang: lang });
|
||||
}
|
||||
}
|
||||
}
|
||||
onProgress(10 + Math.round(((i + 1) / publishedPosts.length) * 5), `Scanning media… (${i + 1}/${publishedPosts.length})`);
|
||||
}
|
||||
|
||||
const totalItems = postItems.length + mediaItems.length;
|
||||
if (totalItems === 0) {
|
||||
onProgress(100, 'All translations are up to date');
|
||||
return;
|
||||
}
|
||||
|
||||
onProgress(15, `Found ${postItems.length} posts and ${mediaItems.length} media to translate`);
|
||||
|
||||
let completed = 0;
|
||||
let failed = 0;
|
||||
let warned = 0;
|
||||
|
||||
for (const item of postItems) {
|
||||
onProgress(15 + Math.round((completed / totalItems) * 85), `Translating "${item.postTitle}" → ${item.targetLang}…`);
|
||||
const result = await autoTranslatePost(item.postId, item.targetLang, { autoPublish: true });
|
||||
if (!result.success) {
|
||||
failed++;
|
||||
console.error(`[FillMissing] post "${item.postTitle}" → ${item.targetLang} failed:`, result.error);
|
||||
} else if (result.warning) {
|
||||
warned++;
|
||||
console.warn(`[FillMissing] post "${item.postTitle}" → ${item.targetLang}: ${result.warning}`);
|
||||
}
|
||||
completed++;
|
||||
}
|
||||
|
||||
for (const item of mediaItems) {
|
||||
onProgress(15 + Math.round((completed / totalItems) * 85), `Translating media ${item.mediaId.slice(0, 8)}… → ${item.targetLang}…`);
|
||||
const result = await autoTranslateMediaMetadata(item.mediaId, item.targetLang);
|
||||
if (!result.success) {
|
||||
failed++;
|
||||
console.error(`[FillMissing] media ${item.mediaId.slice(0, 8)}… → ${item.targetLang} failed:`, result.error);
|
||||
}
|
||||
completed++;
|
||||
}
|
||||
|
||||
const parts: string[] = ['Done'];
|
||||
if (failed > 0) parts.push(`${failed} failed`);
|
||||
if (warned > 0) parts.push(`${warned} warnings`);
|
||||
onProgress(100, parts.length > 1 ? `${parts[0]} (${parts.slice(1).join(', ')})` : parts[0]);
|
||||
},
|
||||
}).catch(() => { /* errors tracked via task panel */ });
|
||||
|
||||
return { taskStarted: true };
|
||||
});
|
||||
|
||||
safeHandle('blog:regenerateCalendar', async () => {
|
||||
const blogGenerationEngine = bundle.blogGenerationEngine;
|
||||
const baseOptions = await resolveBlogGenerationBaseOptions();
|
||||
|
||||
@@ -10,6 +10,7 @@ import { SecureKeyStore } from '../engine/SecureKeyStore';
|
||||
import { ProviderRegistry } from '../engine/ai/providers';
|
||||
import { ChatService } from '../engine/ai/chat';
|
||||
import { OneShotTasks } from '../engine/ai/tasks';
|
||||
import { retryWithBackoff } from '../engine/ai/retry';
|
||||
import { getDatabase } from '../database';
|
||||
import type { EngineBundle } from '../engine/EngineBundle';
|
||||
import type { BlogToolDeps } from '../engine/ai/blog-tools';
|
||||
@@ -907,6 +908,43 @@ export function registerChatHandlers(): void {
|
||||
}
|
||||
});
|
||||
|
||||
// Translate a post and persist/update its translation record
|
||||
ipcMain.handle('chat:translatePost', async (_, postId: string, targetLanguage: string) => {
|
||||
try {
|
||||
await ensureInitialized();
|
||||
return await getOneShotTasks().translatePost(postId, targetLanguage || 'en');
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error translating post:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Media Language Detection ============
|
||||
|
||||
// Detect the language of media metadata (title, alt, caption)
|
||||
ipcMain.handle('chat:detectMediaLanguage', async (_, title: string, alt: string, caption: string) => {
|
||||
try {
|
||||
await ensureInitialized();
|
||||
return await getOneShotTasks().detectMediaLanguage(title, alt, caption);
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error detecting media language:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ Media Metadata Translation ============
|
||||
|
||||
// Translate media metadata (title, alt, caption) into a target language
|
||||
ipcMain.handle('chat:translateMediaMetadata', async (_, mediaId: string, targetLanguage: string) => {
|
||||
try {
|
||||
await ensureInitialized();
|
||||
return await getOneShotTasks().translateMediaMetadata(mediaId, targetLanguage);
|
||||
} catch (error) {
|
||||
console.error('[Chat IPC] Error translating media metadata:', error);
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
// ============ A2UI Actions ============
|
||||
|
||||
ipcMain.handle('a2ui:dispatch', async (_, action: { surfaceId: string; componentId: string; action: string; payload?: Record<string, unknown> }) => {
|
||||
@@ -922,6 +960,31 @@ export function registerChatHandlers(): void {
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate a post for auto-translation workflows (called from event handlers).
|
||||
* Returns the result of translatePost or an error object if AI is not initialized.
|
||||
*/
|
||||
export async function autoTranslatePost(postId: string, targetLanguage: string, options?: { autoPublish?: boolean }): Promise<{ success: boolean; error?: string; warning?: string }> {
|
||||
try {
|
||||
await ensureInitialized();
|
||||
return await retryWithBackoff(() => getOneShotTasks().translatePost(postId, targetLanguage, options));
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Translate media metadata for auto-translation workflows (called from event handlers).
|
||||
*/
|
||||
export async function autoTranslateMediaMetadata(mediaId: string, targetLanguage: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await ensureInitialized();
|
||||
return await retryWithBackoff(() => getOneShotTasks().translateMediaMetadata(mediaId, targetLanguage));
|
||||
} catch (error) {
|
||||
return { success: false, error: (error as Error).message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleanup chat resources
|
||||
*/
|
||||
|
||||
@@ -21,6 +21,8 @@ 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 { v4 as uuidv4 } from 'uuid';
|
||||
|
||||
/**
|
||||
* Wrap an IPC handler so that "Database is closing" errors during shutdown
|
||||
@@ -433,6 +435,7 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
||||
const stemmerLang = isoToStemmerLanguage(metadata.mainLanguage);
|
||||
postEngine.setSearchLanguage(stemmerLang);
|
||||
mediaEngine.setSearchLanguage(stemmerLang);
|
||||
postEngine.setMainLanguage(metadata.mainLanguage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -475,6 +478,7 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
||||
const stemmerLang = isoToStemmerLanguage(metadata.mainLanguage);
|
||||
postEngine.setSearchLanguage(stemmerLang);
|
||||
mediaEngine.setSearchLanguage(stemmerLang);
|
||||
postEngine.setMainLanguage(metadata.mainLanguage);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -537,7 +541,27 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
||||
return engine.getPostBySlug(slug);
|
||||
});
|
||||
|
||||
safeHandle('posts:getPreviewUrl', async (_, id: string, options?: { draft?: boolean }) => {
|
||||
safeHandle('posts:getTranslation', async (_, postId: string, language: string) => {
|
||||
const engine = bundle.postEngine;
|
||||
return engine.getPostTranslation(postId, language);
|
||||
});
|
||||
|
||||
safeHandle('posts:getTranslations', async (_, postId: string) => {
|
||||
const engine = bundle.postEngine;
|
||||
return engine.getPostTranslations(postId);
|
||||
});
|
||||
|
||||
safeHandle('posts:upsertTranslation', async (_, postId: string, language: string, data: Record<string, unknown>) => {
|
||||
const engine = bundle.postEngine;
|
||||
return engine.upsertPostTranslation(postId, language, data as never);
|
||||
});
|
||||
|
||||
safeHandle('posts:publishTranslation', async (_, postId: string, language: string) => {
|
||||
const engine = bundle.postEngine;
|
||||
return engine.publishPostTranslation(postId, language);
|
||||
});
|
||||
|
||||
safeHandle('posts:getPreviewUrl', async (_, id: string, options?: { draft?: boolean; lang?: string }) => {
|
||||
const engine = bundle.postEngine;
|
||||
const post = await engine.getPost(id);
|
||||
|
||||
@@ -548,7 +572,15 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
||||
const createdAt = resolvePostCreatedAt(post);
|
||||
const canonicalPath = buildCanonicalPreviewPath(createdAt, post.slug);
|
||||
if (options?.draft) {
|
||||
return `http://127.0.0.1:4123${canonicalPath}?draft=true&postId=${encodeURIComponent(id)}`;
|
||||
const params = new URLSearchParams({ draft: 'true', postId: id });
|
||||
if (options.lang?.trim()) {
|
||||
params.set('lang', options.lang.trim().toLowerCase());
|
||||
}
|
||||
return `http://127.0.0.1:4123${canonicalPath}?${params.toString()}`;
|
||||
}
|
||||
|
||||
if (options?.lang?.trim()) {
|
||||
return `http://127.0.0.1:4123${canonicalPath}?lang=${encodeURIComponent(options.lang.trim().toLowerCase())}`;
|
||||
}
|
||||
|
||||
return `http://127.0.0.1:4123${canonicalPath}`;
|
||||
@@ -872,6 +904,28 @@ export function registerIpcHandlers(bundle: EngineBundle): void {
|
||||
return engine.regenerateMissingThumbnails();
|
||||
});
|
||||
|
||||
// ============ Media Translation Handlers ============
|
||||
|
||||
safeHandle('media:getTranslation', async (_, mediaId: string, language: string) => {
|
||||
const engine = bundle.mediaEngine;
|
||||
return engine.getMediaTranslation(mediaId, language);
|
||||
});
|
||||
|
||||
safeHandle('media:getTranslations', async (_, mediaId: string) => {
|
||||
const engine = bundle.mediaEngine;
|
||||
return engine.getMediaTranslations(mediaId);
|
||||
});
|
||||
|
||||
safeHandle('media:upsertTranslation', async (_, mediaId: string, language: string, data: { title?: string; alt?: string; caption?: string }) => {
|
||||
const engine = bundle.mediaEngine;
|
||||
return engine.upsertMediaTranslation(mediaId, language, data);
|
||||
});
|
||||
|
||||
safeHandle('media:deleteTranslation', async (_, mediaId: string, language: string) => {
|
||||
const engine = bundle.mediaEngine;
|
||||
return engine.deleteMediaTranslation(mediaId, language);
|
||||
});
|
||||
|
||||
// ============ Script Handlers ============
|
||||
|
||||
safeHandle('scripts:create', async (_, data: CreateScriptInput) => {
|
||||
@@ -1810,10 +1864,65 @@ export function registerEventForwarding(bundle: EngineBundle): void {
|
||||
postEngine.on('rebuildStarted', forwardEvent('posts:rebuildStarted'));
|
||||
postEngine.on('databaseRebuilt', forwardEvent('posts:databaseRebuilt'));
|
||||
|
||||
// Auto-translate: when a canonical post is created or updated, enqueue
|
||||
// translation tasks for each blog language that does not yet have a translation.
|
||||
const enqueueAutoTranslations = (post: PostData) => {
|
||||
if (post.doNotTranslate) return;
|
||||
metaEngine.getProjectMetadata().then(async (metadata) => {
|
||||
if (!metadata) return;
|
||||
const blogLanguages = metadata.blogLanguages || [];
|
||||
const mainLang = metadata.mainLanguage || 'en';
|
||||
const postLang = post.language || mainLang;
|
||||
const targetLanguages = blogLanguages.filter((lang) => lang !== postLang);
|
||||
if (targetLanguages.length === 0) return;
|
||||
|
||||
const existingTranslations = await postEngine.getPostTranslations(post.id);
|
||||
const existingLangs = new Set(existingTranslations.map((t) => t.language));
|
||||
const missingLanguages = targetLanguages.filter((lang) => !existingLangs.has(lang));
|
||||
if (missingLanguages.length === 0) return;
|
||||
|
||||
const groupId = uuidv4();
|
||||
for (const targetLang of missingLanguages) {
|
||||
bundle.taskManager.runTask({
|
||||
id: uuidv4(),
|
||||
name: `Translate "${post.title}" → ${targetLang}`,
|
||||
groupId,
|
||||
groupName: `Auto-translate: ${post.title}`,
|
||||
execute: async (onProgress) => {
|
||||
onProgress(10, `Translating to ${targetLang}...`);
|
||||
const result = await autoTranslatePost(post.id, targetLang);
|
||||
if (!result.success) {
|
||||
throw new Error(result.error || `Translation to ${targetLang} failed`);
|
||||
}
|
||||
onProgress(70, `Translating linked media...`);
|
||||
// Cascade: translate linked media metadata
|
||||
const links = await bundle.postMediaEngine.getLinkedMediaForPost(post.id);
|
||||
for (const link of links) {
|
||||
const mediaTranslations = await bundle.mediaEngine.getMediaTranslations(link.mediaId);
|
||||
const hasLang = mediaTranslations.some((t) => t.language === targetLang);
|
||||
if (!hasLang) {
|
||||
await autoTranslateMediaMetadata(link.mediaId, targetLang).catch(() => {});
|
||||
}
|
||||
}
|
||||
onProgress(100, 'Done');
|
||||
},
|
||||
}).catch((error) => {
|
||||
console.error(`[Auto-translate] Failed for ${post.id} → ${targetLang}:`, error);
|
||||
});
|
||||
}
|
||||
}).catch(() => {});
|
||||
};
|
||||
|
||||
postEngine.on('postCreated', (post: PostData) => enqueueAutoTranslations(post));
|
||||
postEngine.on('postUpdated', (post: PostData) => enqueueAutoTranslations(post));
|
||||
|
||||
mediaEngine.on('mediaImported', forwardEvent('media:imported'));
|
||||
mediaEngine.on('mediaUpdated', forwardEvent('media:updated'));
|
||||
mediaEngine.on('mediaDeleted', forwardEvent('media:deleted'));
|
||||
mediaEngine.on('mediaFileReplaced', forwardEvent('media:fileReplaced'));
|
||||
mediaEngine.on('mediaTranslationCreated', forwardEvent('media:translationCreated'));
|
||||
mediaEngine.on('mediaTranslationUpdated', forwardEvent('media:translationUpdated'));
|
||||
mediaEngine.on('mediaTranslationDeleted', forwardEvent('media:translationDeleted'));
|
||||
mediaEngine.on('rebuildStarted', forwardEvent('media:rebuildStarted'));
|
||||
mediaEngine.on('databaseRebuilt', forwardEvent('media:databaseRebuilt'));
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ export function registerMetadataDiffHandlers(safeHandle: SafeHandle, bundle: Eng
|
||||
|
||||
safeHandle('metadataDiff:syncFileToDb', async (_, postIds: string[], field: string, groupLabel: string) => {
|
||||
await withProjectContext(bundle);
|
||||
return engine().runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author', groupLabel);
|
||||
return engine().runSyncFileToDbTask(postIds, field as 'tags' | 'categories' | 'title' | 'excerpt' | 'author' | 'language' | 'translationFor', groupLabel);
|
||||
});
|
||||
|
||||
// ── Media ──
|
||||
|
||||
@@ -522,6 +522,7 @@ async function initializeActiveProjectContext(): Promise<void> {
|
||||
const postEngine = bundle!.postEngine as {
|
||||
setProjectContext?: (projectId: string, dataDir?: string) => void;
|
||||
setSearchLanguage?: (language: string) => void;
|
||||
setMainLanguage?: (language: string) => void;
|
||||
};
|
||||
const mediaEngine = bundle!.mediaEngine as {
|
||||
setProjectContext?: (projectId: string, dataDir?: string, internalDir?: string) => void;
|
||||
@@ -553,6 +554,7 @@ async function initializeActiveProjectContext(): Promise<void> {
|
||||
const stemmerLang = isoToStemmerLanguage(metadata.mainLanguage);
|
||||
postEngine.setSearchLanguage?.(stemmerLang);
|
||||
mediaEngine.setSearchLanguage?.(stemmerLang);
|
||||
postEngine.setMainLanguage?.(metadata.mainLanguage);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize active project context:', error);
|
||||
@@ -684,15 +686,16 @@ function createApplicationMenu(): Menu {
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
const item: MenuItemConstructorOptions = {
|
||||
label: translatedLabel,
|
||||
accelerator: definition.accelerator,
|
||||
id: definition.id,
|
||||
enabled: definition.enabled,
|
||||
click: async () => {
|
||||
await triggerMenuAction(action);
|
||||
},
|
||||
};
|
||||
if (definition.accelerator) item.accelerator = definition.accelerator;
|
||||
if (definition.id) item.id = definition.id;
|
||||
if (definition.enabled !== undefined) item.enabled = definition.enabled;
|
||||
return item;
|
||||
};
|
||||
|
||||
const buildSharedGroupMenuItems = (groupLabel: string): MenuItemConstructorOptions[] => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import { contextBridge, ipcRenderer } from 'electron';
|
||||
import type { ElectronAPI } from './shared/electronApi';
|
||||
import type { GitInitProgress } from './shared/electronApi';
|
||||
import type { SiteValidationReport } from './shared/electronApi';
|
||||
import type { TranslationValidationReport } from './shared/electronApi';
|
||||
|
||||
// Expose protected methods that allow the renderer process to use
|
||||
// ipcRenderer without exposing the entire object
|
||||
@@ -55,7 +56,11 @@ export const electronAPI: ElectronAPI = {
|
||||
delete: (id: string) => ipcRenderer.invoke('posts:delete', id),
|
||||
get: (id: string) => ipcRenderer.invoke('posts:get', id),
|
||||
getBySlug: (slug: string) => ipcRenderer.invoke('posts:getBySlug', slug),
|
||||
getPreviewUrl: (id: string, options?: { draft?: boolean }) => ipcRenderer.invoke('posts:getPreviewUrl', id, options),
|
||||
getTranslation: (postId: string, language: string) => ipcRenderer.invoke('posts:getTranslation', postId, language),
|
||||
getTranslations: (postId: string) => ipcRenderer.invoke('posts:getTranslations', postId),
|
||||
upsertTranslation: (postId: string, language: string, data: unknown) => ipcRenderer.invoke('posts:upsertTranslation', postId, language, data),
|
||||
publishTranslation: (postId: string, language: string) => ipcRenderer.invoke('posts:publishTranslation', postId, language),
|
||||
getPreviewUrl: (id: string, options?: { draft?: boolean; lang?: string }) => ipcRenderer.invoke('posts:getPreviewUrl', id, options),
|
||||
getAll: (options?: { limit?: number; offset?: number }) => ipcRenderer.invoke('posts:getAll', options),
|
||||
getByStatus: (status: string) => ipcRenderer.invoke('posts:getByStatus', status),
|
||||
publish: (id: string) => ipcRenderer.invoke('posts:publish', id),
|
||||
@@ -100,6 +105,10 @@ export const electronAPI: ElectronAPI = {
|
||||
getThumbnail: (id: string, size?: 'small' | 'medium' | 'large') => ipcRenderer.invoke('media:getThumbnail', id, size),
|
||||
regenerateThumbnails: (id: string) => ipcRenderer.invoke('media:regenerateThumbnails', id),
|
||||
regenerateMissingThumbnails: () => ipcRenderer.invoke('media:regenerateMissingThumbnails'),
|
||||
getTranslation: (mediaId: string, language: string) => ipcRenderer.invoke('media:getTranslation', mediaId, language),
|
||||
getTranslations: (mediaId: string) => ipcRenderer.invoke('media:getTranslations', mediaId),
|
||||
upsertTranslation: (mediaId: string, language: string, data: unknown) => ipcRenderer.invoke('media:upsertTranslation', mediaId, language, data),
|
||||
deleteTranslation: (mediaId: string, language: string) => ipcRenderer.invoke('media:deleteTranslation', mediaId, language),
|
||||
},
|
||||
|
||||
// Scripts
|
||||
@@ -300,6 +309,9 @@ export const electronAPI: ElectronAPI = {
|
||||
blog: {
|
||||
generateSitemap: () => ipcRenderer.invoke('blog:generateSitemap'),
|
||||
validateSite: () => ipcRenderer.invoke('blog:validateSite'),
|
||||
validateTranslations: () => ipcRenderer.invoke('blog:validateTranslations'),
|
||||
fixInvalidTranslations: (report: TranslationValidationReport) => ipcRenderer.invoke('blog:fixInvalidTranslations', report),
|
||||
fillMissingTranslations: () => ipcRenderer.invoke('blog:fillMissingTranslations'),
|
||||
applyValidation: (report: SiteValidationReport) => ipcRenderer.invoke('blog:applyValidation', report),
|
||||
regenerateCalendar: () => ipcRenderer.invoke('blog:regenerateCalendar'),
|
||||
},
|
||||
@@ -396,6 +408,15 @@ export const electronAPI: ElectronAPI = {
|
||||
// Post Analysis (title, excerpt, slug suggestions)
|
||||
analyzePost: (postId: string, language?: string) => ipcRenderer.invoke('chat:analyzePost', postId, language),
|
||||
|
||||
// Post Translation
|
||||
translatePost: (postId: string, targetLanguage: string) => ipcRenderer.invoke('chat:translatePost', postId, targetLanguage),
|
||||
|
||||
// Media Language Detection
|
||||
detectMediaLanguage: (mediaId: string) => ipcRenderer.invoke('chat:detectMediaLanguage', mediaId),
|
||||
|
||||
// Media Metadata Translation
|
||||
translateMediaMetadata: (mediaId: string, targetLanguage: string) => ipcRenderer.invoke('chat:translateMediaMetadata', mediaId, targetLanguage),
|
||||
|
||||
// Event listeners for streaming/progress
|
||||
onStreamDelta: (callback: (data: { conversationId: string; delta: string }) => void) => {
|
||||
const subscription = (_event: Electron.IpcRendererEvent, data: { conversationId: string; delta: string }) => callback(data);
|
||||
|
||||
@@ -96,18 +96,37 @@ export interface PostData {
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
author?: string;
|
||||
language?: string;
|
||||
doNotTranslate?: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
publishedAt?: string;
|
||||
tags: string[];
|
||||
categories: string[];
|
||||
availableLanguages: string[];
|
||||
templateSlug?: string;
|
||||
}
|
||||
|
||||
export interface PostTranslationData {
|
||||
id: string;
|
||||
projectId: string;
|
||||
translationFor: string;
|
||||
language: string;
|
||||
title: string;
|
||||
excerpt?: string;
|
||||
content: string;
|
||||
status: 'draft' | 'published' | 'archived';
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
publishedAt?: string;
|
||||
filePath: string;
|
||||
}
|
||||
|
||||
export interface PostFilter {
|
||||
status?: 'draft' | 'published' | 'archived';
|
||||
tags?: string[];
|
||||
categories?: string[];
|
||||
language?: string;
|
||||
missingTranslationLanguage?: string;
|
||||
year?: number;
|
||||
month?: number;
|
||||
from?: string;
|
||||
@@ -134,15 +153,31 @@ export interface MediaData {
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
author?: string;
|
||||
language?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
tags: string[];
|
||||
availableLanguages: string[];
|
||||
}
|
||||
|
||||
export interface MediaTranslationData {
|
||||
id: string;
|
||||
projectId: string;
|
||||
translationFor: string;
|
||||
language: string;
|
||||
title?: string;
|
||||
alt?: string;
|
||||
caption?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface MediaFilter {
|
||||
tags?: string[];
|
||||
year?: number;
|
||||
month?: number;
|
||||
language?: string;
|
||||
missingTranslationLanguage?: string;
|
||||
}
|
||||
|
||||
export interface MediaSearchResult {
|
||||
@@ -533,6 +568,29 @@ export interface SiteValidationReport {
|
||||
existingHtmlUrlCount: number;
|
||||
}
|
||||
|
||||
export interface TranslationValidationIssue {
|
||||
issue: 'same-language-as-canonical' | 'missing-source-post' | 'do-not-translate-has-translations' | 'content-in-database';
|
||||
translationId?: string;
|
||||
translationFor: string;
|
||||
canonicalLanguage?: string;
|
||||
translationLanguage: string;
|
||||
title?: string;
|
||||
filePath?: string;
|
||||
}
|
||||
|
||||
export interface TranslationValidationReport {
|
||||
checkedDatabaseRowCount: number;
|
||||
checkedFilesystemFileCount: number;
|
||||
invalidDatabaseRows: TranslationValidationIssue[];
|
||||
invalidFilesystemFiles: TranslationValidationIssue[];
|
||||
}
|
||||
|
||||
export interface TranslationValidationFixResult {
|
||||
deletedDatabaseRows: number;
|
||||
deletedFiles: number;
|
||||
flushedTranslations: number;
|
||||
}
|
||||
|
||||
export interface SiteValidationApplyResult {
|
||||
renderedUrlCount: number;
|
||||
deletedUrlCount: number;
|
||||
@@ -596,7 +654,11 @@ export interface ElectronAPI {
|
||||
delete: (id: string) => Promise<boolean>;
|
||||
get: (id: string) => Promise<PostData | null>;
|
||||
getBySlug: (slug: string) => Promise<PostData | null>;
|
||||
getPreviewUrl: (id: string, options?: { draft?: boolean }) => Promise<string | null>;
|
||||
getTranslation: (postId: string, language: string) => Promise<PostTranslationData | null>;
|
||||
getTranslations: (postId: string) => Promise<PostTranslationData[]>;
|
||||
upsertTranslation: (postId: string, language: string, data: Partial<PostTranslationData>) => Promise<PostTranslationData>;
|
||||
publishTranslation: (postId: string, language: string) => Promise<PostTranslationData | null>;
|
||||
getPreviewUrl: (id: string, options?: { draft?: boolean; lang?: string }) => Promise<string | null>;
|
||||
getAll: (options?: { limit?: number; offset?: number }) => Promise<PaginatedPostsResult>;
|
||||
getByStatus: (status: string) => Promise<PostData[]>;
|
||||
publish: (id: string) => Promise<PostData | null>;
|
||||
@@ -639,6 +701,10 @@ export interface ElectronAPI {
|
||||
getByYearMonth: () => Promise<{ year: number; month: number; count: number }[]>;
|
||||
getTags: () => Promise<string[]>;
|
||||
getTagsWithCounts: () => Promise<TagCount[]>;
|
||||
getTranslation: (mediaId: string, language: string) => Promise<MediaTranslationData | null>;
|
||||
getTranslations: (mediaId: string) => Promise<MediaTranslationData[]>;
|
||||
upsertTranslation: (mediaId: string, language: string, data: Partial<MediaTranslationData>) => Promise<MediaTranslationData>;
|
||||
deleteTranslation: (mediaId: string, language: string) => Promise<boolean>;
|
||||
};
|
||||
scripts: {
|
||||
create: (data: {
|
||||
@@ -747,7 +813,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; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record<string, CategoryMetadata>; categorySettings?: Record<string, CategoryRenderSettings>; semanticSimilarityEnabled?: boolean }) => Promise<ProjectMetadata | null>;
|
||||
updateProjectMetadata: (updates: { name?: string; description?: string; dataPath?: string; publicUrl?: string; mainLanguage?: string; defaultAuthor?: string; maxPostsPerPage?: number; blogmarkCategory?: string; pythonRuntimeMode?: 'webworker' | 'main-thread'; picoTheme?: import('./picoThemes').PicoThemeName; categoryMetadata?: Record<string, CategoryMetadata>; categorySettings?: Record<string, CategoryRenderSettings>; semanticSimilarityEnabled?: boolean; blogLanguages?: string[] }) => Promise<ProjectMetadata | null>;
|
||||
getPublishingPreferences: () => Promise<PublishingPreferences | null>;
|
||||
setPublishingPreferences: (prefs: PublishingPreferences) => Promise<void>;
|
||||
clearPublishingPreferences: () => Promise<void>;
|
||||
@@ -897,6 +963,9 @@ export interface ElectronAPI {
|
||||
pagesGenerated: number;
|
||||
}>;
|
||||
validateSite: () => Promise<SiteValidationReport>;
|
||||
validateTranslations: () => Promise<TranslationValidationReport>;
|
||||
fixInvalidTranslations: (report: TranslationValidationReport) => Promise<TranslationValidationFixResult>;
|
||||
fillMissingTranslations: () => Promise<{ taskStarted: boolean }>;
|
||||
applyValidation: (report: SiteValidationReport) => Promise<SiteValidationApplyResult>;
|
||||
regenerateCalendar: () => Promise<CalendarRegenerationResult>;
|
||||
};
|
||||
@@ -997,6 +1066,15 @@ export interface ElectronAPI {
|
||||
// Post Analysis (title, excerpt, slug suggestions)
|
||||
analyzePost: (postId: string, language?: string) => Promise<{ success: boolean; title?: string; excerpt?: string; slug?: string; error?: string }>;
|
||||
|
||||
// Post Translation
|
||||
translatePost: (postId: string, targetLanguage: string) => Promise<{ success: boolean; translation?: PostTranslationData; error?: string }>;
|
||||
|
||||
// Media Language Detection
|
||||
detectMediaLanguage: (mediaId: string) => Promise<{ success: boolean; language?: string; error?: string }>;
|
||||
|
||||
// Media Metadata Translation
|
||||
translateMediaMetadata: (mediaId: string, targetLanguage: string) => Promise<{ success: boolean; translation?: MediaTranslationData; error?: string }>;
|
||||
|
||||
// Event listeners for streaming/progress
|
||||
onStreamDelta: (callback: (data: ChatStreamDelta) => void) => () => void;
|
||||
onToolCall: (callback: (data: ChatToolCall) => void) => () => void;
|
||||
|
||||
@@ -7,6 +7,20 @@ import esJson from './i18n/locales/es.json';
|
||||
export type SupportedLanguage = 'en' | 'de' | 'fr' | 'it' | 'es';
|
||||
export const SUPPORTED_RENDER_LANGUAGES: SupportedLanguage[] = ['en', 'de', 'fr', 'it', 'es'];
|
||||
|
||||
/**
|
||||
* Canonical list of languages supported for post and media translations.
|
||||
* Used by both AI tasks and the Blog Languages UI.
|
||||
*/
|
||||
export const SUPPORTED_POST_LANGUAGES: readonly SupportedLanguage[] = SUPPORTED_RENDER_LANGUAGES;
|
||||
|
||||
export const POST_LANGUAGE_FLAGS: Record<SupportedLanguage, string> = {
|
||||
en: '🇬🇧',
|
||||
de: '🇩🇪',
|
||||
fr: '🇫🇷',
|
||||
it: '🇮🇹',
|
||||
es: '🇪🇸',
|
||||
};
|
||||
|
||||
type TranslationMap = Record<string, string>;
|
||||
|
||||
const en = enJson as TranslationMap;
|
||||
@@ -47,6 +61,10 @@ export function resolveUiLanguageFromSystemLocale(systemLocale: string | undefin
|
||||
return normalizeLanguage(systemLocale);
|
||||
}
|
||||
|
||||
export function getRenderTranslations(language: SupportedLanguage): Record<string, string> {
|
||||
return catalog[language] ?? catalog.en;
|
||||
}
|
||||
|
||||
export function translateRender(language: SupportedLanguage, key: string): string {
|
||||
return catalog[language]?.[key] ?? key;
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"menu.item.generateSitemap": "Site rendern",
|
||||
"menu.item.regenerateCalendar": "Kalender neu erzeugen",
|
||||
"menu.item.validateSite": "Website validieren",
|
||||
"menu.item.validateTranslations": "Übersetzungen validieren",
|
||||
"menu.item.findDuplicates": "Doppelte Beiträge finden",
|
||||
"menu.item.uploadSite": "Website hochladen",
|
||||
"menu.item.about": "Über Blogging Desktop Server",
|
||||
@@ -67,6 +68,7 @@
|
||||
"render.taxonomy.ariaLabel": "Taxonomie",
|
||||
"render.backlinks.label": "Verlinkt von",
|
||||
"render.backlinks.ariaLabel": "Rückverweise",
|
||||
"render.languageSwitcher.ariaLabel": "Sprache",
|
||||
"render.video.youtubeTitle": "YouTube-Video",
|
||||
"render.video.vimeoTitle": "Vimeo-Video",
|
||||
"render.month.1": "Januar",
|
||||
@@ -91,5 +93,6 @@
|
||||
"task.rebuildEmbeddingIndex.name": "Embedding-Index neu aufbauen",
|
||||
"task.rebuildEmbeddingIndex.clearing": "Index wird geleert…",
|
||||
"task.duplicateSearch.name": "Doppelte Beiträge finden",
|
||||
"task.duplicateSearch.searching": "Prüfe: {checked}/{total}"
|
||||
"task.duplicateSearch.searching": "Prüfe: {checked}/{total}",
|
||||
"menu.item.fillMissingTranslations": "Fehlende Übersetzungen ausfüllen"
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"menu.item.generateSitemap": "Render Site",
|
||||
"menu.item.regenerateCalendar": "Regenerate Calendar",
|
||||
"menu.item.validateSite": "Validate Site",
|
||||
"menu.item.validateTranslations": "Validate Translations",
|
||||
"menu.item.findDuplicates": "Find Duplicate Posts",
|
||||
"menu.item.uploadSite": "Upload Site",
|
||||
"menu.item.about": "About Blogging Desktop Server",
|
||||
@@ -67,6 +68,7 @@
|
||||
"render.taxonomy.ariaLabel": "Taxonomy",
|
||||
"render.backlinks.label": "Linked from",
|
||||
"render.backlinks.ariaLabel": "Backlinks",
|
||||
"render.languageSwitcher.ariaLabel": "Language",
|
||||
"render.video.youtubeTitle": "YouTube video",
|
||||
"render.video.vimeoTitle": "Vimeo video",
|
||||
"render.month.1": "January",
|
||||
@@ -91,5 +93,6 @@
|
||||
"task.rebuildEmbeddingIndex.name": "Rebuild Embedding Index",
|
||||
"task.rebuildEmbeddingIndex.clearing": "Clearing index…",
|
||||
"task.duplicateSearch.name": "Find Duplicate Posts",
|
||||
"task.duplicateSearch.searching": "Checking: {checked}/{total}"
|
||||
"task.duplicateSearch.searching": "Checking: {checked}/{total}",
|
||||
"menu.item.fillMissingTranslations": "Fill Missing Translations"
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"menu.item.generateSitemap": "Renderizar sitio",
|
||||
"menu.item.regenerateCalendar": "Regenerar calendario",
|
||||
"menu.item.validateSite": "Validar sitio",
|
||||
"menu.item.validateTranslations": "Validar traducciones",
|
||||
"menu.item.findDuplicates": "Buscar entradas duplicadas",
|
||||
"menu.item.uploadSite": "Subir sitio",
|
||||
"menu.item.about": "Acerca de Blogging Desktop Server",
|
||||
@@ -67,6 +68,7 @@
|
||||
"render.taxonomy.ariaLabel": "Taxonomía",
|
||||
"render.backlinks.label": "Enlazado desde",
|
||||
"render.backlinks.ariaLabel": "Retroenlaces",
|
||||
"render.languageSwitcher.ariaLabel": "Idioma",
|
||||
"render.video.youtubeTitle": "Vídeo de YouTube",
|
||||
"render.video.vimeoTitle": "Vídeo de Vimeo",
|
||||
"render.month.1": "enero",
|
||||
@@ -91,5 +93,6 @@
|
||||
"task.rebuildEmbeddingIndex.name": "Reconstruir índice de embeddings",
|
||||
"task.rebuildEmbeddingIndex.clearing": "Vaciando índice…",
|
||||
"task.duplicateSearch.name": "Buscar entradas duplicadas",
|
||||
"task.duplicateSearch.searching": "Comprobando: {checked}/{total}"
|
||||
"task.duplicateSearch.searching": "Comprobando: {checked}/{total}",
|
||||
"menu.item.fillMissingTranslations": "Completar traducciones faltantes"
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"menu.item.generateSitemap": "Rendre le site",
|
||||
"menu.item.regenerateCalendar": "Régénérer le calendrier",
|
||||
"menu.item.validateSite": "Valider le site",
|
||||
"menu.item.validateTranslations": "Valider les traductions",
|
||||
"menu.item.findDuplicates": "Trouver les articles en double",
|
||||
"menu.item.uploadSite": "Publier le site",
|
||||
"menu.item.about": "À propos de Blogging Desktop Server",
|
||||
@@ -67,6 +68,7 @@
|
||||
"render.taxonomy.ariaLabel": "Taxonomie",
|
||||
"render.backlinks.label": "Lié depuis",
|
||||
"render.backlinks.ariaLabel": "Rétroliens",
|
||||
"render.languageSwitcher.ariaLabel": "Langue",
|
||||
"render.video.youtubeTitle": "Vidéo YouTube",
|
||||
"render.video.vimeoTitle": "Vidéo Vimeo",
|
||||
"render.month.1": "janvier",
|
||||
@@ -91,5 +93,6 @@
|
||||
"task.rebuildEmbeddingIndex.name": "Reconstruire l'index d'embeddings",
|
||||
"task.rebuildEmbeddingIndex.clearing": "Vidage de l'index…",
|
||||
"task.duplicateSearch.name": "Trouver les articles en double",
|
||||
"task.duplicateSearch.searching": "Vérification : {checked}/{total}"
|
||||
"task.duplicateSearch.searching": "Vérification : {checked}/{total}",
|
||||
"menu.item.fillMissingTranslations": "Compléter les traductions manquantes"
|
||||
}
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"menu.item.generateSitemap": "Renderizza sito",
|
||||
"menu.item.regenerateCalendar": "Rigenera calendario",
|
||||
"menu.item.validateSite": "Valida sito",
|
||||
"menu.item.validateTranslations": "Valida traduzioni",
|
||||
"menu.item.findDuplicates": "Trova post duplicati",
|
||||
"menu.item.uploadSite": "Carica sito",
|
||||
"menu.item.about": "Informazioni su Blogging Desktop Server",
|
||||
@@ -67,6 +68,7 @@
|
||||
"render.taxonomy.ariaLabel": "Tassonomia",
|
||||
"render.backlinks.label": "Collegato da",
|
||||
"render.backlinks.ariaLabel": "Retrocollegamenti",
|
||||
"render.languageSwitcher.ariaLabel": "Lingua",
|
||||
"render.video.youtubeTitle": "Video YouTube",
|
||||
"render.video.vimeoTitle": "Video Vimeo",
|
||||
"render.month.1": "gennaio",
|
||||
@@ -91,5 +93,6 @@
|
||||
"task.rebuildEmbeddingIndex.name": "Ricostruisci indice embeddings",
|
||||
"task.rebuildEmbeddingIndex.clearing": "Svuotamento indice…",
|
||||
"task.duplicateSearch.name": "Trova post duplicati",
|
||||
"task.duplicateSearch.searching": "Controllo: {checked}/{total}"
|
||||
"task.duplicateSearch.searching": "Controllo: {checked}/{total}",
|
||||
"menu.item.fillMissingTranslations": "Completa le traduzioni mancanti"
|
||||
}
|
||||
|
||||
@@ -37,9 +37,11 @@ export type AppMenuAction =
|
||||
| 'generateSitemap'
|
||||
| 'regenerateCalendar'
|
||||
| 'validateSite'
|
||||
| 'validateTranslations'
|
||||
| 'uploadSite'
|
||||
| 'openDocumentation'
|
||||
| 'openApiDocumentation'
|
||||
| 'fillMissingTranslations'
|
||||
| 'findDuplicates'
|
||||
| 'about'
|
||||
| 'viewOnGitHub'
|
||||
@@ -126,18 +128,20 @@ export const APP_MENU_GROUPS: AppMenuGroupDefinition[] = [
|
||||
{ label: 'menu.item.publishSelected', action: 'publishSelected', accelerator: 'CmdOrCtrl+Shift+P' },
|
||||
{ label: '', action: 'blog-separator-1', separator: true },
|
||||
{ label: 'menu.item.previewPost', action: 'previewPost', id: APP_MENU_ITEM_IDS.previewPost, enabled: false, accelerator: 'CmdOrCtrl+Shift+V' },
|
||||
{ label: 'menu.item.editMenu', action: 'editMenu' },
|
||||
{ label: '', action: 'blog-separator-2', separator: true },
|
||||
{ label: 'menu.item.rebuildDatabase', action: 'rebuildDatabase' },
|
||||
{ label: 'menu.item.reindexText', action: 'reindexText' },
|
||||
{ label: 'menu.item.rebuildEmbeddingIndex', action: 'rebuildEmbeddingIndex' },
|
||||
{ label: '', action: 'blog-separator-3', separator: true },
|
||||
{ label: 'menu.item.metadataDiff', action: 'metadataDiff' },
|
||||
{ label: 'menu.item.editMenu', action: 'editMenu' },
|
||||
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
|
||||
{ label: 'menu.item.regenerateCalendar', action: 'regenerateCalendar' },
|
||||
{ label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' },
|
||||
{ label: 'menu.item.validateTranslations', action: 'validateTranslations' },
|
||||
{ label: 'menu.item.fillMissingTranslations', action: 'fillMissingTranslations' },
|
||||
{ label: 'menu.item.findDuplicates', action: 'findDuplicates' },
|
||||
{ label: '', action: 'blog-separator-4', separator: true },
|
||||
{ label: 'menu.item.generateSitemap', action: 'generateSitemap', accelerator: 'CmdOrCtrl+R' },
|
||||
{ label: 'menu.item.validateSite', action: 'validateSite', accelerator: 'CmdOrCtrl+Shift+L' },
|
||||
{ label: 'menu.item.uploadSite', action: 'uploadSite', accelerator: 'CmdOrCtrl+Shift+U' },
|
||||
],
|
||||
},
|
||||
@@ -177,6 +181,8 @@ export const APP_MENU_ACTION_EVENT_MAP: Partial<Record<AppMenuAction, string>> =
|
||||
generateSitemap: 'menu:generateSitemap',
|
||||
regenerateCalendar: 'menu:regenerateCalendar',
|
||||
validateSite: 'menu:validateSite',
|
||||
validateTranslations: 'menu:validateTranslations',
|
||||
fillMissingTranslations: 'menu:fillMissingTranslations',
|
||||
findDuplicates: 'menu:findDuplicates',
|
||||
uploadSite: 'menu:uploadSite',
|
||||
openDocumentation: 'menu:openDocumentation',
|
||||
|
||||
@@ -82,16 +82,19 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
|
||||
method('posts.delete', 'Delete a post by id.', [requiredString('id')], 'boolean'),
|
||||
method('posts.get', 'Fetch one post by id.', [requiredString('postId')], 'PostData | null'),
|
||||
method('posts.getBySlug', 'Fetch one post by slug.', [requiredString('slug')], 'PostData | null'),
|
||||
method('posts.getPreviewUrl', 'Get preview URL for post.', [requiredString('id'), optionalObject('options')], 'string | null'),
|
||||
method('posts.getPreviewUrl', 'Get preview URL for post. options may include draft=true and lang=<language-code>.', [requiredString('id'), optionalObject('options')], 'string | null'),
|
||||
method('posts.getAll', 'Fetch posts with pagination.', [optionalObject('options')], 'PaginatedPostsResult'),
|
||||
method('posts.getByStatus', 'Fetch posts by status.', [requiredString('status')], 'PostData[]'),
|
||||
method('posts.publish', 'Publish a post by id.', [requiredString('id')], 'PostData | null'),
|
||||
method('posts.getTranslation', 'Get a single translation for a post by language.', [requiredString('postId'), requiredString('language')], 'PostTranslationData | null'),
|
||||
method('posts.getTranslations', 'Get all translations for a post.', [requiredString('postId')], 'PostTranslationData[]'),
|
||||
method('posts.publishTranslation', 'Publish a specific translation of a post.', [requiredString('postId'), requiredString('language')], 'PostTranslationData | null'),
|
||||
method('posts.discard', 'Discard draft changes for post.', [requiredString('id')], 'PostData | null'),
|
||||
method('posts.hasPublishedVersion', 'Check if post has published version.', [requiredString('id')], 'boolean'),
|
||||
method('posts.rebuildFromFiles', 'Rebuild posts database from files.', [], 'void'),
|
||||
method('posts.reindexText', 'Reindex post search text.', [], 'void'),
|
||||
method('posts.search', 'Search posts by free-text query.', [requiredString('query')], 'SearchResult[]'),
|
||||
method('posts.filter', 'Filter posts by criteria.', [requiredObject('filter')], 'PostData[]'),
|
||||
method('posts.filter', 'Filter posts by criteria, including optional language and missingTranslationLanguage filters.', [requiredObject('filter')], 'PostData[]'),
|
||||
method('posts.getTags', 'Get all post tags.', [], 'string[]'),
|
||||
method('posts.getCategories', 'Get all post categories.', [], 'string[]'),
|
||||
method('posts.getByYearMonth', 'Get post counts grouped by year/month.', [], 'Array<{ year: number; month: number; count: number } >'),
|
||||
@@ -123,6 +126,11 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
|
||||
method('media.getTags', 'Get all media tags.', [], 'string[]'),
|
||||
method('media.getTagsWithCounts', 'Get media tags with counts.', [], 'TagCount[]'),
|
||||
|
||||
method('media.getTranslation', 'Get a single translation for a media item by language.', [requiredString('mediaId'), requiredString('language')], 'MediaTranslationData | null'),
|
||||
method('media.getTranslations', 'Get all translations for a media item.', [requiredString('mediaId')], 'MediaTranslationData[]'),
|
||||
method('media.upsertTranslation', 'Create or update a media translation for a specific language.', [requiredString('mediaId'), requiredString('language'), requiredObject('data')], 'MediaTranslationData'),
|
||||
method('media.deleteTranslation', 'Delete a media translation by language.', [requiredString('mediaId'), requiredString('language')], 'boolean'),
|
||||
|
||||
method('scripts.create', 'Create script. data must include: title (str), kind ("macro"|"utility"|"transform"), content (str). Optional: slug (str), entrypoint (str, defaults to "render"), enabled (bool).', [requiredObject('data')], 'ScriptData'),
|
||||
method('scripts.update', 'Update script by id. data may include any of: title, kind, content, slug, entrypoint, enabled.', [requiredString('id'), requiredObject('data')], 'ScriptData | null'),
|
||||
method('scripts.delete', 'Delete script by id.', [requiredString('id')], 'boolean'),
|
||||
@@ -192,6 +200,9 @@ const METHODS_V1: PythonApiMethodContractV1[] = [
|
||||
method('chat.analyzeMediaImage', 'Analyze an image and generate title, alt text, and caption using AI.', [requiredString('mediaId'), optionalString('language')], 'ImageAnalysisResult'),
|
||||
method('chat.detectPostLanguage', 'Detect the language of a post from its title and content.', [requiredString('title'), requiredString('content')], '{ success: boolean; language?: string; error?: string }'),
|
||||
method('chat.analyzePost', 'Analyze a post and generate suggested title, excerpt, and slug using AI.', [requiredString('postId'), optionalString('language')], 'PostAnalysisResult'),
|
||||
method('chat.translatePost', 'Translate a post into a target language and save it as a translation draft.', [requiredString('postId'), requiredString('targetLanguage')], 'PostTranslationResult'),
|
||||
method('chat.detectMediaLanguage', 'Detect the language of media metadata from its title, alt text, and caption.', [requiredString('title'), requiredString('alt'), requiredString('caption')], '{ success: boolean; language?: string; error?: string }'),
|
||||
method('chat.translateMediaMetadata', 'Translate media metadata (title, alt, caption) into a target language using AI.', [requiredString('mediaId'), requiredString('targetLanguage')], 'MediaTranslationResult'),
|
||||
|
||||
method('sync.checkAvailability', 'Check if git is available.', [], 'GitAvailability'),
|
||||
method('sync.getRepoState', 'Get repository state for active project.', [], 'RepoState'),
|
||||
@@ -257,6 +268,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
||||
{ name: 'publishedAt', type: 'string', required: false, description: 'Publication timestamp for published posts.' },
|
||||
{ name: 'tags', type: 'string[]', required: true, description: 'List of tag names.' },
|
||||
{ name: 'categories', type: 'string[]', required: true, description: 'List of category names.' },
|
||||
{ name: 'availableLanguages', type: 'string[]', required: true, description: 'Canonical language plus all available translation language codes for this post.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -437,6 +449,33 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
||||
{ name: 'error', type: 'string', required: false, description: 'Error message when analysis failed.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'PostTranslationData',
|
||||
description: 'Stored translation draft or published translation for a post.',
|
||||
fields: [
|
||||
{ name: 'id', type: 'string', required: true, description: 'Translation identifier.' },
|
||||
{ name: 'projectId', type: 'string', required: true, description: 'Owning project identifier.' },
|
||||
{ name: 'translationFor', type: 'string', required: true, description: 'Source post identifier this translation belongs to.' },
|
||||
{ name: 'language', type: 'string', required: true, description: 'Target language code for the translation.' },
|
||||
{ name: 'title', type: 'string', required: true, description: 'Translated title.' },
|
||||
{ name: 'excerpt', type: 'string', required: false, description: 'Translated excerpt.' },
|
||||
{ name: 'content', type: 'string', required: true, description: 'Translated Markdown content.' },
|
||||
{ name: 'status', type: "'draft' | 'published' | 'archived'", required: true, description: 'Translation lifecycle state.' },
|
||||
{ name: 'createdAt', type: 'string', required: true, description: 'Creation timestamp.' },
|
||||
{ name: 'updatedAt', type: 'string', required: true, description: 'Last update timestamp.' },
|
||||
{ name: 'publishedAt', type: 'string', required: false, description: 'Publish timestamp when the translation is published.' },
|
||||
{ name: 'filePath', type: 'string', required: true, description: 'Translation file path on disk.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'PostTranslationResult',
|
||||
description: 'Result from AI post translation containing the saved translation draft.',
|
||||
fields: [
|
||||
{ name: 'success', type: 'boolean', required: true, description: 'Whether the translation succeeded.' },
|
||||
{ name: 'translation', type: 'PostTranslationData', required: false, description: 'Saved translation draft when successful.' },
|
||||
{ name: 'error', type: 'string', required: false, description: 'Error message when translation failed.' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'SimilarPost',
|
||||
description: 'A post with its semantic similarity score relative to a reference post.',
|
||||
@@ -465,7 +504,7 @@ const DATA_STRUCTURES_V1: PythonApiDataStructureContractV1[] = [
|
||||
];
|
||||
|
||||
export const BDS_PYTHON_API_CONTRACT_V1: PythonApiContractV1 = {
|
||||
version: '1.13.0',
|
||||
version: '1.15.0',
|
||||
generatedAt: '2026-03-07T00:00:00.000Z',
|
||||
methods: METHODS_V1,
|
||||
dataStructures: DATA_STRUCTURES_V1,
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useAppStore, PostData, MediaData, TaskProgress } from './store';
|
||||
import { loadTabsForProject, saveTabsForProject } from './utils';
|
||||
import { openSingletonToolTab } from './navigation/tabPolicy';
|
||||
import { persistSiteValidationReport } from './navigation/siteValidationPersistence';
|
||||
import { persistTranslationValidationReport } from './navigation/translationValidationPersistence';
|
||||
import { persistDuplicatesResult } from './navigation/duplicatesPersistence';
|
||||
import { executeActivityClick } from './navigation/activityExecution';
|
||||
import { handleBlogmarkCreatedEvent } from './navigation/blogmarkHandling';
|
||||
@@ -416,7 +417,9 @@ const App: React.FC = () => {
|
||||
window.electronAPI?.posts.rebuildFromFiles(),
|
||||
window.electronAPI?.media.rebuildFromFiles(),
|
||||
window.electronAPI?.scripts.rebuildFromFiles(),
|
||||
window.electronAPI?.templates.rebuildFromFiles(),
|
||||
]);
|
||||
await window.electronAPI?.posts.rebuildLinks();
|
||||
await window.electronAPI?.media.regenerateMissingThumbnails();
|
||||
} catch (error) {
|
||||
console.error('Database rebuild failed:', error);
|
||||
@@ -508,6 +511,49 @@ const App: React.FC = () => {
|
||||
}) || (() => {})
|
||||
);
|
||||
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('menu:validateTranslations', () => {
|
||||
const validateAndOpen = async () => {
|
||||
try {
|
||||
const report = await window.electronAPI?.blog.validateTranslations();
|
||||
const projectId = useAppStore.getState().activeProject?.id;
|
||||
if (projectId && report) {
|
||||
persistTranslationValidationReport(projectId, report);
|
||||
window.dispatchEvent(new CustomEvent('bds:translation-validation-updated', {
|
||||
detail: { projectId },
|
||||
}));
|
||||
}
|
||||
openSingletonToolTab(openTab, 'translation-validation');
|
||||
} catch (error) {
|
||||
console.error('Translation validation failed:', error);
|
||||
showToast.error(tr('translationValidation.error.validate'));
|
||||
}
|
||||
};
|
||||
void validateAndOpen();
|
||||
}) || (() => {})
|
||||
);
|
||||
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('menu:fillMissingTranslations', () => {
|
||||
const fillMissing = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI?.blog.fillMissingTranslations();
|
||||
if (result) {
|
||||
if (!result.taskStarted) {
|
||||
showToast.info(tr('blog.fillMissing.nothingToDo'));
|
||||
} else {
|
||||
showToast.success(tr('blog.fillMissing.started'));
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Fill missing translations failed:', error);
|
||||
showToast.error(tr('blog.fillMissing.error'));
|
||||
}
|
||||
};
|
||||
void fillMissing();
|
||||
}) || (() => {})
|
||||
);
|
||||
|
||||
unsubscribers.push(
|
||||
window.electronAPI?.on('menu:previewPost', async () => {
|
||||
try {
|
||||
|
||||
@@ -115,11 +115,16 @@
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.metadata-toggle-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.metadata-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 6px 4px;
|
||||
background: none;
|
||||
border: none;
|
||||
@@ -130,6 +135,7 @@
|
||||
letter-spacing: 0.5px;
|
||||
cursor: pointer;
|
||||
transition: color 0.15s;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.metadata-toggle:hover {
|
||||
@@ -202,10 +208,12 @@
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
}
|
||||
|
||||
.editor-language-row select {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.editor-language-row button.compact {
|
||||
@@ -215,6 +223,53 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.editor-translations-flags {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.editor-translation-flag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
font-size: 14px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.editor-translation-flag.status-published {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.editor-translation-flag.status-draft {
|
||||
opacity: 0.82;
|
||||
}
|
||||
|
||||
.editor-translation-flag.status-archived {
|
||||
opacity: 0.45;
|
||||
filter: grayscale(0.35);
|
||||
}
|
||||
|
||||
.editor-translation-flag.active {
|
||||
border-color: var(--vscode-testing-iconQueued, #cca700);
|
||||
background: color-mix(in srgb, var(--vscode-testing-iconQueued, #cca700) 14%, transparent);
|
||||
}
|
||||
|
||||
.editor-translation-flag:hover {
|
||||
background: color-mix(in srgb, var(--vscode-list-hoverBackground) 75%, transparent);
|
||||
}
|
||||
|
||||
.editor-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@@ -1037,6 +1092,11 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.quick-actions-divider {
|
||||
height: 1px;
|
||||
background: var(--vscode-dropdown-border, #454545);
|
||||
}
|
||||
|
||||
.quick-action-item {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -1081,3 +1141,115 @@
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.translation-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: rgba(0, 0, 0, 0.6);
|
||||
z-index: 10000;
|
||||
}
|
||||
|
||||
.translation-modal {
|
||||
width: min(460px, calc(100vw - 32px));
|
||||
background: var(--vscode-editor-background);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
|
||||
.translation-modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.translation-modal-header h2 {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.translation-modal-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
cursor: pointer;
|
||||
font-size: 20px;
|
||||
line-height: 1;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
|
||||
.translation-modal-close:hover {
|
||||
color: var(--vscode-foreground);
|
||||
}
|
||||
|
||||
.translation-modal-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 18px;
|
||||
}
|
||||
|
||||
.translation-modal-copy {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.translation-modal-label {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.translation-modal-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.translation-modal-status-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border-radius: 6px;
|
||||
background: color-mix(in srgb, var(--vscode-editorWidget-background) 88%, transparent);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
.translation-modal-flag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 999px;
|
||||
background: var(--vscode-input-background);
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.translation-modal-status-copy {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.translation-modal-status-copy strong {
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.translation-modal-status-copy small {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.translation-modal-footer {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
padding: 16px 18px 18px;
|
||||
border-top: 1px solid var(--vscode-panel-border);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
863
src/renderer/components/Editor/MediaEditor.tsx
Normal file
863
src/renderer/components/Editor/MediaEditor.tsx
Normal file
@@ -0,0 +1,863 @@
|
||||
import React, { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useAppStore } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import { AISuggestionsModal } from '../AISuggestionsModal/AISuggestionsModal';
|
||||
import { openEntityTab } from '../../navigation/tabPolicy';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { SUPPORTED_POST_LANGUAGES, POST_LANGUAGE_FLAGS } from '../../../main/shared/i18n';
|
||||
import { getMediaDisplayName } from './editorUtils';
|
||||
|
||||
export const MediaEditor: React.FC<{ mediaId: string }> = ({ mediaId }) => {
|
||||
const { t: tr } = useI18n();
|
||||
const { media, updateMedia, showErrorModal, showConfirmDeleteModal, openTab } = useAppStore();
|
||||
const activeProjectId = useAppStore((s) => s.activeProject?.id ?? null);
|
||||
const item = media.find(m => m.id === mediaId);
|
||||
|
||||
const [title, setTitle] = useState(item?.title || '');
|
||||
const [alt, setAlt] = useState(item?.alt || '');
|
||||
const [caption, setCaption] = useState(item?.caption || '');
|
||||
const [author, setAuthor] = useState(item?.author || '');
|
||||
const [tags, setTags] = useState(item?.tags.join(', ') || '');
|
||||
const [linkedPosts, setLinkedPosts] = useState<{ postId: string; sortOrder: number }[]>([]);
|
||||
const [postTitles, setPostTitles] = useState<Map<string, string>>(new Map());
|
||||
const [showPostPicker, setShowPostPicker] = useState(false);
|
||||
const [postSearchQuery, setPostSearchQuery] = useState('');
|
||||
const [pickerPosts, setPickerPosts] = useState<{ id: string; title: string }[]>([]);
|
||||
|
||||
// Quick action menu state
|
||||
const [showQuickActions, setShowQuickActions] = useState(false);
|
||||
const [projectLanguage, setProjectLanguage] = useState('en');
|
||||
const quickActionsRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// AI suggestions modal state
|
||||
const [showAISuggestionsModal, setShowAISuggestionsModal] = useState(false);
|
||||
const [isAnalyzing, setIsAnalyzing] = useState(false);
|
||||
const [aiSuggestionFields, setAISuggestionFields] = useState<Array<{ key: string; label: string; currentValue: string; suggestedValue?: string }>>([]);
|
||||
const [aiError, setAIError] = useState<string | undefined>(undefined);
|
||||
|
||||
// Translation state
|
||||
const [mediaLanguage, setMediaLanguage] = useState(item?.language || '');
|
||||
const [mediaTranslations, setMediaTranslations] = useState<import('../../../main/shared/electronApi').MediaTranslationData[]>([]);
|
||||
const [isTranslating, setIsTranslating] = useState(false);
|
||||
const [isDetectingLanguage, setIsDetectingLanguage] = useState(false);
|
||||
const [showMediaTranslationModal, setShowMediaTranslationModal] = useState(false);
|
||||
const [translationTargetLanguage, setTranslationTargetLanguage] = useState('');
|
||||
const [editingTranslation, setEditingTranslation] = useState<{ language: string; title: string; alt: string; caption: string } | null>(null);
|
||||
|
||||
// Load project language setting
|
||||
useEffect(() => {
|
||||
if (!activeProjectId) return;
|
||||
window.electronAPI?.meta.getProjectMetadata().then(metadata => {
|
||||
if (metadata?.mainLanguage) {
|
||||
setProjectLanguage(metadata.mainLanguage);
|
||||
}
|
||||
});
|
||||
}, [activeProjectId]);
|
||||
|
||||
// Load media translations
|
||||
const loadMediaTranslations = useCallback(async () => {
|
||||
if (!mediaId) return;
|
||||
const result = await window.electronAPI?.media.getTranslations?.(mediaId);
|
||||
setMediaTranslations(result || []);
|
||||
}, [mediaId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMediaTranslations();
|
||||
}, [loadMediaTranslations]);
|
||||
|
||||
// Handle language change on canonical media
|
||||
const handleLanguageChange = async (newLanguage: string) => {
|
||||
setMediaLanguage(newLanguage);
|
||||
try {
|
||||
const updated = await window.electronAPI?.media.update(item!.id, { language: newLanguage || undefined });
|
||||
if (updated) {
|
||||
updateMedia(item!.id, updated as Partial<typeof item>);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update media language:', error);
|
||||
}
|
||||
};
|
||||
|
||||
// Detect media language from metadata
|
||||
const handleDetectLanguage = async () => {
|
||||
if (!item || isDetectingLanguage) return;
|
||||
setIsDetectingLanguage(true);
|
||||
try {
|
||||
const result = await window.electronAPI?.chat.detectMediaLanguage(
|
||||
title || item.title || '',
|
||||
alt || item.alt || '',
|
||||
caption || item.caption || '',
|
||||
);
|
||||
if (result?.success && result.language) {
|
||||
setMediaLanguage(result.language);
|
||||
const updated = await window.electronAPI?.media.update(item.id, { language: result.language });
|
||||
if (updated) {
|
||||
updateMedia(item.id, updated as Partial<typeof item>);
|
||||
}
|
||||
showToast.success(tr('editor.media.toast.languageDetected', { language: tr(`language.${result.language}`) }));
|
||||
} else {
|
||||
showToast.error(result?.error || tr('editor.media.error.detectLanguage'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to detect media language:', error);
|
||||
showToast.error(tr('editor.media.error.detectLanguage'));
|
||||
} finally {
|
||||
setIsDetectingLanguage(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Translate media metadata with AI
|
||||
const handleTranslateMedia = async (targetLanguage: string) => {
|
||||
if (!item || isTranslating) return;
|
||||
setIsTranslating(true);
|
||||
try {
|
||||
const result = await window.electronAPI?.chat.translateMediaMetadata(item.id, targetLanguage);
|
||||
if (result?.success) {
|
||||
await loadMediaTranslations();
|
||||
showToast.success(tr('editor.media.translations.translateSuccess', { language: tr(`language.${targetLanguage}`) }));
|
||||
} else {
|
||||
showToast.error(result?.error || tr('editor.media.translations.translateFailed'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to translate media metadata:', error);
|
||||
showToast.error(tr('editor.media.translations.translateFailed'));
|
||||
} finally {
|
||||
setIsTranslating(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Open translation modal (like posts)
|
||||
const handleOpenMediaTranslationModal = () => {
|
||||
const preferred = translationTargetLanguage
|
||||
|| availableTranslationLanguages[0]
|
||||
|| '';
|
||||
setShowQuickActions(false);
|
||||
setTranslationTargetLanguage(preferred);
|
||||
setShowMediaTranslationModal(true);
|
||||
};
|
||||
|
||||
const handleCloseMediaTranslationModal = () => {
|
||||
setShowMediaTranslationModal(false);
|
||||
};
|
||||
|
||||
const handleConfirmMediaTranslation = () => {
|
||||
if (!translationTargetLanguage) return;
|
||||
setShowMediaTranslationModal(false);
|
||||
void handleTranslateMedia(translationTargetLanguage);
|
||||
};
|
||||
|
||||
// Open edit modal for an existing translation
|
||||
const handleOpenEditTranslation = (translation: import('../../../main/shared/electronApi').MediaTranslationData) => {
|
||||
setEditingTranslation({
|
||||
language: translation.language,
|
||||
title: translation.title || '',
|
||||
alt: translation.alt || '',
|
||||
caption: translation.caption || '',
|
||||
});
|
||||
};
|
||||
|
||||
// Save edits to a translation
|
||||
const handleSaveEditTranslation = async () => {
|
||||
if (!item || !editingTranslation) return;
|
||||
try {
|
||||
await window.electronAPI?.media.upsertTranslation(item.id, editingTranslation.language, {
|
||||
title: editingTranslation.title || undefined,
|
||||
alt: editingTranslation.alt || undefined,
|
||||
caption: editingTranslation.caption || undefined,
|
||||
});
|
||||
await loadMediaTranslations();
|
||||
setEditingTranslation(null);
|
||||
showToast.success(tr('editor.media.translations.saved', { language: tr(`language.${editingTranslation.language}`) }));
|
||||
} catch (error) {
|
||||
console.error('Failed to save media translation:', error);
|
||||
showToast.error(tr('editor.media.translations.saveFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// Delete a media translation
|
||||
const handleDeleteTranslation = async (language: string) => {
|
||||
if (!item) return;
|
||||
try {
|
||||
await window.electronAPI?.media.deleteTranslation?.(item.id, language);
|
||||
await loadMediaTranslations();
|
||||
showToast.success(tr('editor.media.translations.deleted', { language: tr(`language.${language}`) }));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete media translation:', error);
|
||||
showToast.error(tr('editor.media.translations.deleteFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// Available languages for translation (exclude canonical)
|
||||
const availableTranslationLanguages = SUPPORTED_POST_LANGUAGES.filter(
|
||||
lang => lang !== mediaLanguage && !mediaTranslations.find(t => t.language === lang)
|
||||
);
|
||||
|
||||
// Close quick actions menu when clicking outside
|
||||
useEffect(() => {
|
||||
const handleClickOutside = (event: MouseEvent) => {
|
||||
if (quickActionsRef.current && !quickActionsRef.current.contains(event.target as Node)) {
|
||||
setShowQuickActions(false);
|
||||
}
|
||||
};
|
||||
if (showQuickActions) {
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
return () => document.removeEventListener('mousedown', handleClickOutside);
|
||||
}
|
||||
}, [showQuickActions]);
|
||||
|
||||
// Handle AI image analysis for alt text and caption
|
||||
const handleAIAnalysis = async () => {
|
||||
if (!item || isAnalyzing) return;
|
||||
|
||||
setShowQuickActions(false);
|
||||
setShowAISuggestionsModal(true);
|
||||
setIsAnalyzing(true);
|
||||
setAISuggestionFields([]);
|
||||
setAIError(undefined);
|
||||
|
||||
try {
|
||||
const result = await window.electronAPI?.chat.analyzeMediaImage(item.id, projectLanguage);
|
||||
|
||||
if (result?.success) {
|
||||
setAISuggestionFields([
|
||||
{ key: 'title', label: tr('aiSuggestions.titleField'), currentValue: title, suggestedValue: result.title },
|
||||
{ key: 'alt', label: tr('aiSuggestions.altField'), currentValue: alt, suggestedValue: result.alt },
|
||||
{ key: 'caption', label: tr('aiSuggestions.captionField'), currentValue: caption, suggestedValue: result.caption },
|
||||
]);
|
||||
} else {
|
||||
setAIError(result?.error || tr('editor.media.error.analyzeImage'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to analyze image:', error);
|
||||
setAIError((error as Error).message || tr('editor.media.error.analyzeImage'));
|
||||
} finally {
|
||||
setIsAnalyzing(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle applying AI suggestions
|
||||
const handleApplyAISuggestions = (values: Record<string, string>) => {
|
||||
if (values.title) setTitle(values.title);
|
||||
if (values.alt) setAlt(values.alt);
|
||||
if (values.caption) setCaption(values.caption);
|
||||
setShowAISuggestionsModal(false);
|
||||
if (Object.keys(values).length > 0) {
|
||||
showToast.success(tr('editor.media.toast.aiApplied'));
|
||||
}
|
||||
};
|
||||
|
||||
// Close AI suggestions modal
|
||||
const handleCloseAISuggestionsModal = () => {
|
||||
setShowAISuggestionsModal(false);
|
||||
setAISuggestions(null);
|
||||
setAIError(undefined);
|
||||
};
|
||||
|
||||
// Load linked posts for this media and fetch their titles
|
||||
useEffect(() => {
|
||||
const loadLinkedPosts = async () => {
|
||||
if (!mediaId || !activeProjectId) return;
|
||||
try {
|
||||
const links = await window.electronAPI?.postMedia.getForMedia(mediaId);
|
||||
if (links) {
|
||||
setLinkedPosts(links.map(l => ({ postId: l.postId, sortOrder: l.sortOrder })));
|
||||
// Fetch titles for linked posts
|
||||
const titles = new Map<string, string>();
|
||||
for (const link of links) {
|
||||
const post = await window.electronAPI?.posts.get(link.postId);
|
||||
if (post) {
|
||||
titles.set(link.postId, post.title || tr('editor.untitled'));
|
||||
}
|
||||
}
|
||||
setPostTitles(titles);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load linked posts:', error);
|
||||
}
|
||||
};
|
||||
loadLinkedPosts();
|
||||
}, [mediaId, activeProjectId]);
|
||||
|
||||
// Fetch posts for the picker when it opens
|
||||
useEffect(() => {
|
||||
if (!showPostPicker) return;
|
||||
const loadPickerPosts = async () => {
|
||||
try {
|
||||
const result = await window.electronAPI?.posts.getAll({ limit: 100, offset: 0 });
|
||||
if (result?.items) {
|
||||
setPickerPosts(result.items.map(p => ({ id: p.id, title: p.title || tr('editor.untitled') })));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load posts for picker:', error);
|
||||
}
|
||||
};
|
||||
loadPickerPosts();
|
||||
}, [showPostPicker]);
|
||||
|
||||
// Get post titles for display
|
||||
const getPostTitle = (postId: string): string => {
|
||||
return postTitles.get(postId) || tr('sidebar.loading');
|
||||
};
|
||||
|
||||
// Handle linking to a new post
|
||||
const handleLinkToPost = async (postId: string, postTitle: string) => {
|
||||
try {
|
||||
await window.electronAPI?.postMedia.link(postId, mediaId);
|
||||
setLinkedPosts([...linkedPosts, { postId, sortOrder: linkedPosts.length }]);
|
||||
setPostTitles(prev => new Map(prev).set(postId, postTitle));
|
||||
setShowPostPicker(false);
|
||||
setPostSearchQuery('');
|
||||
showToast.success(tr('editor.media.toast.linkedToPost'));
|
||||
} catch (error) {
|
||||
console.error('Failed to link to post:', error);
|
||||
showToast.error(tr('editor.media.toast.linkFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle unlinking from a post
|
||||
const handleUnlinkFromPost = async (postId: string) => {
|
||||
try {
|
||||
await window.electronAPI?.postMedia.unlink(postId, mediaId);
|
||||
setLinkedPosts(linkedPosts.filter(l => l.postId !== postId));
|
||||
showToast.success(tr('editor.media.toast.unlinkedFromPost'));
|
||||
} catch (error) {
|
||||
console.error('Failed to unlink from post:', error);
|
||||
showToast.error(tr('editor.media.toast.unlinkFailed'));
|
||||
}
|
||||
};
|
||||
|
||||
// Handle click on a post to navigate to it
|
||||
const handlePostClick = (postId: string) => {
|
||||
openEntityTab(openTab, 'post', postId, 'preview');
|
||||
};
|
||||
|
||||
// Get unlinked posts for picker, filtered by search
|
||||
const unlinkedPosts = pickerPosts.filter(
|
||||
p => !linkedPosts.find(l => l.postId === p.id)
|
||||
).filter(
|
||||
p => !postSearchQuery || p.title.toLowerCase().includes(postSearchQuery.toLowerCase())
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (item) {
|
||||
setTitle(item.title || '');
|
||||
setAlt(item.alt || '');
|
||||
setCaption(item.caption || '');
|
||||
setAuthor(item.author || '');
|
||||
setTags(item.tags.join(', '));
|
||||
setMediaLanguage(item.language || '');
|
||||
}
|
||||
}, [item?.id]);
|
||||
|
||||
if (!item) {
|
||||
return <div className="editor-empty">{tr('editor.media.notFound')}</div>;
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
const updated = await window.electronAPI?.media.update(item.id, {
|
||||
title,
|
||||
alt,
|
||||
caption,
|
||||
author: author || undefined,
|
||||
language: mediaLanguage || undefined,
|
||||
tags: tags.split(',').map(t => t.trim()).filter(t => t.length > 0),
|
||||
});
|
||||
if (updated) {
|
||||
updateMedia(item.id, updated as Partial<typeof item>);
|
||||
showToast.success(tr('editor.media.toast.updated'));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to update media:', error);
|
||||
const err = error as Error;
|
||||
showErrorModal({
|
||||
title: tr('editor.media.error.updateTitle'),
|
||||
message: err.message || tr('editor.media.error.updateMessage'),
|
||||
stack: err.stack,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplaceFile = async () => {
|
||||
try {
|
||||
const updated = await window.electronAPI?.media.replaceFileDialog(item.id);
|
||||
if (updated) {
|
||||
updateMedia(item.id, updated as Partial<typeof item>);
|
||||
showToast.success(tr('editor.media.toast.fileReplaced'));
|
||||
}
|
||||
// null means user cancelled or file unchanged - no action needed
|
||||
} catch (error) {
|
||||
console.error('Failed to replace media file:', error);
|
||||
const err = error as Error;
|
||||
showErrorModal({
|
||||
title: tr('editor.media.error.replaceTitle'),
|
||||
message: err.message || tr('editor.media.error.replaceMessage'),
|
||||
stack: err.stack,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
// Fetch posts that link to this media
|
||||
const linkedPostsList = await window.electronAPI?.postMedia.getForMedia(mediaId);
|
||||
|
||||
// Build references array
|
||||
const references: Array<{ id: string; title: string; type: 'post' | 'media' | 'link' }> = [];
|
||||
|
||||
// Add posts that use this media - fetch titles from database
|
||||
if (linkedPostsList && linkedPostsList.length > 0) {
|
||||
for (const link of linkedPostsList) {
|
||||
const post = await window.electronAPI?.posts.get(link.postId);
|
||||
if (post) {
|
||||
references.push({
|
||||
id: post.id,
|
||||
title: post.title || tr('editor.untitled'),
|
||||
type: 'post',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Show confirmation modal
|
||||
showConfirmDeleteModal({
|
||||
itemType: 'media',
|
||||
itemTitle: getMediaDisplayName(item),
|
||||
references,
|
||||
onConfirm: async () => {
|
||||
try {
|
||||
await window.electronAPI?.media.delete(item.id);
|
||||
useAppStore.getState().removeMedia(item.id);
|
||||
showToast.success(tr('editor.media.toast.deleted'));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete media:', error);
|
||||
const err = error as Error;
|
||||
showErrorModal({
|
||||
title: tr('editor.error.deleteTitle'),
|
||||
message: err.message || tr('editor.media.error.deleteMessage'),
|
||||
stack: err.stack,
|
||||
});
|
||||
}
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch media references:', error);
|
||||
const err = error as Error;
|
||||
showErrorModal({
|
||||
title: tr('errorModal.error'),
|
||||
message: err.message || tr('editor.media.error.fetchReferencesMessage'),
|
||||
stack: err.stack,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="editor">
|
||||
<div className="editor-header">
|
||||
<div className="editor-tabs">
|
||||
<div className="editor-tab active">
|
||||
<span className="editor-tab-title">{getMediaDisplayName(item)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="editor-actions">
|
||||
{/* Quick Actions Dropdown */}
|
||||
<div className="quick-actions-wrapper" ref={quickActionsRef}>
|
||||
<button
|
||||
className="secondary quick-actions-btn"
|
||||
onClick={() => setShowQuickActions(!showQuickActions)}
|
||||
disabled={isAnalyzing || isDetectingLanguage || isTranslating}
|
||||
title={tr('editor.media.quickActions.title')}
|
||||
>
|
||||
{(isAnalyzing || isDetectingLanguage || isTranslating) ? tr('editor.media.quickActions.analyzing') : tr('editor.media.quickActions.button')}
|
||||
</button>
|
||||
{showQuickActions && (
|
||||
<div className="quick-actions-menu">
|
||||
{item.mimeType.startsWith('image/') && (
|
||||
<button
|
||||
className="quick-action-item"
|
||||
onClick={handleAIAnalysis}
|
||||
disabled={isAnalyzing}
|
||||
>
|
||||
<span className="quick-action-icon">🤖</span>
|
||||
<span className="quick-action-text">
|
||||
<strong>{tr('editor.media.quickActions.aiTitle')}</strong>
|
||||
<small>{tr('editor.media.quickActions.aiDescription')}</small>
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
{item.mimeType.startsWith('image/') && <div className="quick-actions-divider" />}
|
||||
<button
|
||||
className="quick-action-item"
|
||||
onClick={() => { setShowQuickActions(false); void handleDetectLanguage(); }}
|
||||
disabled={isDetectingLanguage || (!title && !alt && !caption)}
|
||||
>
|
||||
<span className="quick-action-icon">🔍</span>
|
||||
<span className="quick-action-text">
|
||||
<strong>{tr('editor.media.quickActions.detectLanguageTitle')}</strong>
|
||||
<small>{tr('editor.media.quickActions.detectLanguageDescription')}</small>
|
||||
</span>
|
||||
</button>
|
||||
<div className="quick-actions-divider" />
|
||||
<button
|
||||
className="quick-action-item"
|
||||
onClick={handleOpenMediaTranslationModal}
|
||||
disabled={isTranslating || !mediaLanguage || availableTranslationLanguages.length === 0}
|
||||
>
|
||||
<span className="quick-action-icon">🌍</span>
|
||||
<span className="quick-action-text">
|
||||
<strong>{tr('editor.media.quickActions.translateTitle')}</strong>
|
||||
<small>{tr('editor.media.quickActions.translateDescription')}</small>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={handleReplaceFile} className="secondary">{tr('editor.media.replaceFile')}</button>
|
||||
<button onClick={handleSave}>{tr('common.save')}</button>
|
||||
<button onClick={handleDelete} className="secondary danger">{tr('editor.delete')}</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="editor-content media-editor">
|
||||
<div className="media-preview">
|
||||
{item.mimeType.startsWith('image/') ? (
|
||||
<div className="media-preview-image">
|
||||
<img
|
||||
src={`bds-media://${item.id}?t=${item.updatedAt instanceof Date ? item.updatedAt.getTime() : item.updatedAt}`}
|
||||
alt={item.alt || item.originalName}
|
||||
onError={(e) => {
|
||||
// Fallback to placeholder if image fails to load
|
||||
const target = e.target as HTMLImageElement;
|
||||
target.style.display = 'none';
|
||||
target.parentElement?.classList.add('has-error');
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="media-preview-placeholder">
|
||||
<svg width="64" height="64" viewBox="0 0 24 24" fill="currentColor" opacity="0.3">
|
||||
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8l-6-6z"/>
|
||||
</svg>
|
||||
<span>{item.originalName}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="media-details">
|
||||
<div className="editor-field">
|
||||
<label>{tr('editor.media.field.fileName')}</label>
|
||||
<input type="text" value={item.originalName} disabled className="disabled" />
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>{tr('editor.media.field.type')}</label>
|
||||
<input type="text" value={item.mimeType} disabled className="disabled" />
|
||||
</div>
|
||||
<div className="editor-field-row">
|
||||
<div className="editor-field">
|
||||
<label>{tr('editor.media.field.size')}</label>
|
||||
<input type="text" value={`${(item.size / 1024).toFixed(1)} KB`} disabled className="disabled" />
|
||||
</div>
|
||||
{item.width && item.height && (
|
||||
<div className="editor-field">
|
||||
<label>{tr('editor.media.field.dimensions')}</label>
|
||||
<input type="text" value={`${item.width} × ${item.height}`} disabled className="disabled" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>{tr('editor.media.field.title')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
placeholder={tr('editor.media.placeholder.title')}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>{tr('editor.media.field.altText')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={alt}
|
||||
onChange={(e) => setAlt(e.target.value)}
|
||||
placeholder={tr('editor.media.placeholder.altText')}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>{tr('editor.media.field.caption')}</label>
|
||||
<textarea
|
||||
value={caption}
|
||||
onChange={(e) => setCaption(e.target.value)}
|
||||
placeholder={tr('editor.media.placeholder.caption')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>{tr('editor.media.field.tags')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={tags}
|
||||
onChange={(e) => setTags(e.target.value)}
|
||||
placeholder={tr('editor.media.placeholder.tags')}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label>{tr('editor.media.field.author')}</label>
|
||||
<input
|
||||
type="text"
|
||||
value={author}
|
||||
onChange={(e) => setAuthor(e.target.value)}
|
||||
placeholder={tr('editor.media.placeholder.author')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Language & Translations Section */}
|
||||
<div className="editor-field">
|
||||
<label>{tr('editor.media.field.language')}</label>
|
||||
<select
|
||||
value={mediaLanguage}
|
||||
onChange={(e) => handleLanguageChange(e.target.value)}
|
||||
>
|
||||
<option value="">{tr('editor.media.field.languageNone')}</option>
|
||||
{SUPPORTED_POST_LANGUAGES.map((lang) => (
|
||||
<option key={lang} value={lang}>{tr(`language.${lang}`)}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{mediaLanguage && (
|
||||
<div className="editor-field media-translations-section">
|
||||
<label>{tr('editor.media.translations.title')}</label>
|
||||
|
||||
{mediaTranslations.length === 0 ? (
|
||||
<div className="no-linked-posts">{tr('editor.media.translations.none')}</div>
|
||||
) : (
|
||||
<div className="linked-posts-list">
|
||||
{mediaTranslations.map((translation) => (
|
||||
<div key={translation.language} className="linked-post-item">
|
||||
<span
|
||||
className="linked-post-title"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handleOpenEditTranslation(translation)}
|
||||
title={tr('editor.media.translations.editTitle', { language: tr(`language.${translation.language}`) })}
|
||||
>
|
||||
{POST_LANGUAGE_FLAGS[translation.language as keyof typeof POST_LANGUAGE_FLAGS] || '🏳️'}{' '}
|
||||
{tr(`language.${translation.language}`)}
|
||||
{translation.title && ` — ${translation.title}`}
|
||||
</span>
|
||||
<button
|
||||
className="secondary"
|
||||
onClick={() => handleTranslateMedia(translation.language)}
|
||||
disabled={isTranslating}
|
||||
title={tr('editor.media.translations.refreshTitle')}
|
||||
style={{ marginRight: '4px', fontSize: '0.8em', padding: '2px 6px' }}
|
||||
>
|
||||
{tr('editor.media.translations.refresh')}
|
||||
</button>
|
||||
<button
|
||||
className="unlink-btn"
|
||||
onClick={() => handleDeleteTranslation(translation.language)}
|
||||
title={tr('editor.media.translations.deleteTitle')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Linked Posts Section */}
|
||||
<div className="editor-field linked-posts-section">
|
||||
<label>
|
||||
{tr('editor.media.linkedPosts')}
|
||||
<button
|
||||
className="add-link-btn"
|
||||
onClick={() => setShowPostPicker(!showPostPicker)}
|
||||
title={tr('editor.media.linkToPostTitle')}
|
||||
>
|
||||
{tr('editor.media.linkAction')}
|
||||
</button>
|
||||
</label>
|
||||
|
||||
{showPostPicker && (
|
||||
<div className="post-picker">
|
||||
<div className="post-picker-search">
|
||||
<input
|
||||
type="text"
|
||||
placeholder={tr('editor.media.searchPosts')}
|
||||
value={postSearchQuery}
|
||||
onChange={(e) => setPostSearchQuery(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
{unlinkedPosts.length === 0 ? (
|
||||
<div className="no-posts">{postSearchQuery ? tr('editor.media.noMatchingPosts') : tr('editor.media.noPostsToLink')}</div>
|
||||
) : (
|
||||
<div className="post-picker-list">
|
||||
{unlinkedPosts.slice(0, 10).map(post => (
|
||||
<div
|
||||
key={post.id}
|
||||
className="post-picker-item"
|
||||
onClick={() => handleLinkToPost(post.id, post.title)}
|
||||
>
|
||||
{post.title}
|
||||
</div>
|
||||
))}
|
||||
{unlinkedPosts.length > 10 && (
|
||||
<div className="post-picker-more">
|
||||
{tr('editor.media.morePosts', { count: unlinkedPosts.length - 10 })}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{linkedPosts.length === 0 ? (
|
||||
<div className="no-linked-posts">{tr('editor.media.notLinked')}</div>
|
||||
) : (
|
||||
<div className="linked-posts-list">
|
||||
{linkedPosts.map(({ postId }) => (
|
||||
<div key={postId} className="linked-post-item">
|
||||
<span
|
||||
className="linked-post-title"
|
||||
onClick={() => handlePostClick(postId)}
|
||||
title={tr('editor.media.openPost')}
|
||||
>
|
||||
📄 {getPostTitle(postId)}
|
||||
</span>
|
||||
<button
|
||||
className="unlink-btn"
|
||||
onClick={() => handleUnlinkFromPost(postId)}
|
||||
title={tr('editor.media.unlinkFromPost')}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* AI Suggestions Modal */}
|
||||
<AISuggestionsModal
|
||||
isOpen={showAISuggestionsModal}
|
||||
isLoading={isAnalyzing}
|
||||
fields={aiSuggestionFields}
|
||||
modalTitle={tr('aiSuggestions.title')}
|
||||
loadingText={tr('aiSuggestions.analyzing')}
|
||||
emptyText={tr('aiSuggestions.empty')}
|
||||
error={aiError}
|
||||
onConfirm={handleApplyAISuggestions}
|
||||
onClose={handleCloseAISuggestionsModal}
|
||||
/>
|
||||
|
||||
{/* Translation Modal */}
|
||||
{showMediaTranslationModal && (
|
||||
<div className="translation-modal-backdrop" onClick={handleCloseMediaTranslationModal}>
|
||||
<div className="translation-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="translation-modal-header">
|
||||
<h2>{tr('editor.media.translations.title')}</h2>
|
||||
<button className="translation-modal-close" onClick={handleCloseMediaTranslationModal} title={tr('common.cancel')}>×</button>
|
||||
</div>
|
||||
<div className="translation-modal-body">
|
||||
<label className="translation-modal-label" htmlFor="media-translation-target-language">{tr('editor.media.translations.selectTarget')}</label>
|
||||
<p className="translation-modal-copy">{tr('editor.media.translations.currentLanguage', { language: tr(`language.${mediaLanguage}`) })}</p>
|
||||
<select
|
||||
id="media-translation-target-language"
|
||||
className="translation-modal-select"
|
||||
value={translationTargetLanguage}
|
||||
onChange={(e) => setTranslationTargetLanguage(e.target.value)}
|
||||
>
|
||||
{SUPPORTED_POST_LANGUAGES
|
||||
.filter(lang => lang !== mediaLanguage)
|
||||
.map((lang) => {
|
||||
const existing = mediaTranslations.find(t => t.language === lang);
|
||||
return (
|
||||
<option key={lang} value={lang}>
|
||||
{tr(`language.${lang}`)}{existing ? ` (${tr('editor.media.translations.refresh')})` : ''}
|
||||
</option>
|
||||
);
|
||||
})}
|
||||
</select>
|
||||
{translationTargetLanguage && (
|
||||
<div className="translation-modal-status-row">
|
||||
<span className="translation-modal-flag" aria-hidden="true">{POST_LANGUAGE_FLAGS[translationTargetLanguage as keyof typeof POST_LANGUAGE_FLAGS] || '🏳️'}</span>
|
||||
<span className="translation-modal-status-copy">
|
||||
<strong>{tr(`language.${translationTargetLanguage}`)}</strong>
|
||||
<small>
|
||||
{mediaTranslations.find(t => t.language === translationTargetLanguage)
|
||||
? tr('editor.media.translations.refresh')
|
||||
: tr('editor.media.translations.none')}
|
||||
</small>
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="translation-modal-footer">
|
||||
<button className="secondary" onClick={handleCloseMediaTranslationModal}>{tr('common.cancel')}</button>
|
||||
<button
|
||||
onClick={handleConfirmMediaTranslation}
|
||||
disabled={!translationTargetLanguage || isTranslating}
|
||||
title={tr('editor.media.quickActions.translateDescription')}
|
||||
>
|
||||
{isTranslating ? tr('editor.media.translations.translating') : tr('editor.media.translations.translateButton')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Translation Modal */}
|
||||
{editingTranslation && (
|
||||
<div className="translation-modal-backdrop" onClick={() => setEditingTranslation(null)}>
|
||||
<div className="translation-modal" onClick={(event) => event.stopPropagation()}>
|
||||
<div className="translation-modal-header">
|
||||
<h2>{tr('editor.media.translations.editTitle', { language: tr(`language.${editingTranslation.language}`) })}</h2>
|
||||
<button className="translation-modal-close" onClick={() => setEditingTranslation(null)} title={tr('common.cancel')}>×</button>
|
||||
</div>
|
||||
<div className="translation-modal-body">
|
||||
<div className="editor-field">
|
||||
<label htmlFor="edit-translation-title">{tr('editor.media.field.title')}</label>
|
||||
<input
|
||||
id="edit-translation-title"
|
||||
type="text"
|
||||
value={editingTranslation.title}
|
||||
onChange={(e) => setEditingTranslation({ ...editingTranslation, title: e.target.value })}
|
||||
placeholder={tr('editor.media.placeholder.title')}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label htmlFor="edit-translation-alt">{tr('editor.media.field.altText')}</label>
|
||||
<input
|
||||
id="edit-translation-alt"
|
||||
type="text"
|
||||
value={editingTranslation.alt}
|
||||
onChange={(e) => setEditingTranslation({ ...editingTranslation, alt: e.target.value })}
|
||||
placeholder={tr('editor.media.placeholder.altText')}
|
||||
/>
|
||||
</div>
|
||||
<div className="editor-field">
|
||||
<label htmlFor="edit-translation-caption">{tr('editor.media.field.caption')}</label>
|
||||
<textarea
|
||||
id="edit-translation-caption"
|
||||
value={editingTranslation.caption}
|
||||
onChange={(e) => setEditingTranslation({ ...editingTranslation, caption: e.target.value })}
|
||||
placeholder={tr('editor.media.placeholder.caption')}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="translation-modal-footer">
|
||||
<button className="secondary" onClick={() => setEditingTranslation(null)}>{tr('common.cancel')}</button>
|
||||
<button onClick={() => void handleSaveEditTranslation()}>{tr('common.save')}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
1668
src/renderer/components/Editor/PostEditor.tsx
Normal file
1668
src/renderer/components/Editor/PostEditor.tsx
Normal file
File diff suppressed because it is too large
Load Diff
12
src/renderer/components/Editor/editorUtils.ts
Normal file
12
src/renderer/components/Editor/editorUtils.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
export const UI_DATE_LOCALE: Record<string, string> = {
|
||||
en: 'en-US',
|
||||
de: 'de-DE',
|
||||
fr: 'fr-FR',
|
||||
it: 'it-IT',
|
||||
es: 'es-ES',
|
||||
};
|
||||
|
||||
/** Get display name for media: prefer title over originalName */
|
||||
export function getMediaDisplayName(media: { title?: string; originalName: string }): string {
|
||||
return media.title || media.originalName;
|
||||
}
|
||||
@@ -223,6 +223,7 @@ export const SettingsView: React.FC = () => {
|
||||
const [defaultProjectPath, setDefaultProjectPath] = useState('');
|
||||
const [projectMainLanguage, setProjectMainLanguage] = useState<SupportedLanguage>('en');
|
||||
const [projectDefaultAuthor, setProjectDefaultAuthor] = useState('');
|
||||
const [projectBlogLanguages, setProjectBlogLanguages] = useState<string[]>([]);
|
||||
const [projectMaxPostsPerPage, setProjectMaxPostsPerPage] = useState(50);
|
||||
const [projectBlogmarkCategory, setProjectBlogmarkCategory] = useState('article');
|
||||
const [projectPythonRuntimeMode, setProjectPythonRuntimeMode] = useState<'webworker' | 'main-thread'>('webworker');
|
||||
@@ -318,6 +319,11 @@ export const SettingsView: React.FC = () => {
|
||||
const incomingSemanticSimilarity = (metadata as { semanticSimilarityEnabled?: unknown } | null)?.semanticSimilarityEnabled;
|
||||
setSemanticSimilarityEnabled(incomingSemanticSimilarity === true);
|
||||
|
||||
const incomingBlogLanguages = (metadata as { blogLanguages?: unknown } | null)?.blogLanguages;
|
||||
setProjectBlogLanguages(Array.isArray(incomingBlogLanguages)
|
||||
? incomingBlogLanguages.filter((l): l is string => typeof l === 'string')
|
||||
: []);
|
||||
|
||||
const incomingCategoryMetadata = (metadata as any)?.categoryMetadata as Record<string, CategoryMetadata> | undefined;
|
||||
const incomingLegacyCategorySettings = (metadata as any)?.categorySettings as Record<string, { renderInLists: boolean; showTitle: boolean }> | undefined;
|
||||
setCategoryMetadata((current) => {
|
||||
@@ -550,6 +556,7 @@ export const SettingsView: React.FC = () => {
|
||||
blogmarkCategory: normalizeBlogmarkCategory(projectBlogmarkCategory) || undefined,
|
||||
pythonRuntimeMode: projectPythonRuntimeMode,
|
||||
semanticSimilarityEnabled,
|
||||
blogLanguages: projectBlogLanguages.length > 0 ? projectBlogLanguages : undefined,
|
||||
categoryMetadata,
|
||||
});
|
||||
}
|
||||
@@ -691,6 +698,37 @@ export const SettingsView: React.FC = () => {
|
||||
</select>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
id="project-blog-languages"
|
||||
label={t('settings.project.blogLanguagesLabel')}
|
||||
description={t('settings.project.blogLanguagesDescription')}
|
||||
>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '0.5rem' }}>
|
||||
{SUPPORTED_RENDER_LANGUAGES.map((language) => {
|
||||
const isMain = language === projectMainLanguage;
|
||||
const isChecked = isMain || projectBlogLanguages.includes(language);
|
||||
return (
|
||||
<label key={language} style={{ display: 'flex', alignItems: 'center', gap: '0.25rem', opacity: isMain ? 0.7 : 1 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isChecked}
|
||||
disabled={isMain}
|
||||
onChange={(e) => {
|
||||
if (isMain) return;
|
||||
setProjectBlogLanguages((prev) =>
|
||||
e.target.checked
|
||||
? [...prev.filter((l) => l !== language), language]
|
||||
: prev.filter((l) => l !== language),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
{t(RENDER_LANGUAGE_LABEL_KEY[language])}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
id="project-author"
|
||||
label={t('settings.project.defaultAuthorLabel')}
|
||||
|
||||
@@ -125,6 +125,24 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.sidebar-item-title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.sidebar-item-language-badge {
|
||||
flex-shrink: 0;
|
||||
min-width: 18px;
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--vscode-badge-background) 82%, transparent);
|
||||
color: var(--vscode-badge-foreground);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.sidebar-item-meta {
|
||||
font-size: 11px;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
|
||||
@@ -736,6 +736,7 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
await createAndFocusPost({
|
||||
createPost: async (input) => (await window.electronAPI?.posts.create(input)) as { id: string } | null | undefined,
|
||||
setSelectedPost: selectPost,
|
||||
categories: isPagesMode ? [PAGE_CATEGORY] : [],
|
||||
onError: (error) => {
|
||||
console.error('Failed to create post:', error);
|
||||
},
|
||||
@@ -876,7 +877,14 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
>
|
||||
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||
<div className="sidebar-item-content">
|
||||
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
|
||||
<div className="sidebar-item-title-row">
|
||||
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
|
||||
{post.availableLanguages?.length > 1 && (
|
||||
<span className="sidebar-item-language-badge" title={t('sidebar.languagesAvailable', { count: post.availableLanguages.length })}>
|
||||
{post.availableLanguages.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="sidebar-item-meta">{formatDate(post.updatedAt, uiLocale)}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -904,7 +912,14 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
>
|
||||
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||
<div className="sidebar-item-content">
|
||||
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
|
||||
<div className="sidebar-item-title-row">
|
||||
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
|
||||
{post.availableLanguages?.length > 1 && (
|
||||
<span className="sidebar-item-language-badge" title={t('sidebar.languagesAvailable', { count: post.availableLanguages.length })}>
|
||||
{post.availableLanguages.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="sidebar-item-meta">{formatDate(post.publishedAt || post.updatedAt, uiLocale)}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -932,7 +947,14 @@ const PostsList: React.FC<PostsListProps> = ({ mode, isActive }) => {
|
||||
>
|
||||
<span className="post-type-icon" title={postType.type}>{postType.icon}</span>
|
||||
<div className="sidebar-item-content">
|
||||
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
|
||||
<div className="sidebar-item-title-row">
|
||||
<div className="sidebar-item-title">{post.title || t('sidebar.untitled')}</div>
|
||||
{post.availableLanguages?.length > 1 && (
|
||||
<span className="sidebar-item-language-badge" title={t('sidebar.languagesAvailable', { count: post.availableLanguages.length })}>
|
||||
{post.availableLanguages.length}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="sidebar-item-meta">{formatDate(post.updatedAt, uiLocale)}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -87,6 +87,10 @@ const getTabTitle = (
|
||||
return tr('siteValidation.tabTitle');
|
||||
}
|
||||
|
||||
if (tab.type === 'translation-validation') {
|
||||
return tr('translationValidation.tabTitle');
|
||||
}
|
||||
|
||||
if (tab.type === 'find-duplicates') {
|
||||
return tr('duplicatesView.tabTitle');
|
||||
}
|
||||
@@ -184,6 +188,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
|
||||
<path d="M8 1.5a6.5 6.5 0 1 0 6.5 6.5A6.5 6.5 0 0 0 8 1.5zm0 1a5.5 5.5 0 0 1 4.39 8.82l-.88-.88a.5.5 0 0 0-.7.7l.8.8A5.5 5.5 0 1 1 8 2.5zm2.35 3.15L7 9 5.65 7.65a.5.5 0 1 0-.7.7l1.7 1.7a.5.5 0 0 0 .7 0l3.7-3.7a.5.5 0 1 0-.7-.7z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'translation-validation':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
<path d="M2 2.5A1.5 1.5 0 0 1 3.5 1h5A1.5 1.5 0 0 1 10 2.5v1h2.5A1.5 1.5 0 0 1 14 5v7.5a1.5 1.5 0 0 1-1.5 1.5h-9A1.5 1.5 0 0 1 2 12.5v-10zM3.5 2a.5.5 0 0 0-.5.5v10a.5.5 0 0 0 .5.5h9a.5.5 0 0 0 .5-.5V5a.5.5 0 0 0-.5-.5H10v1.15a.5.5 0 0 1-.85.35L7.5 4.35 5.85 6A.5.5 0 0 1 5 5.65V2.5a.5.5 0 0 0-.5-.5h-1zm2.5 1.71v.73l1.15-1.14a.5.5 0 0 1 .7 0L9 4.44v-.73a.5.5 0 0 0-.5-.5h-2a.5.5 0 0 0-.5.5zM5.5 8h5v1h-5V8zm0 2h5v1h-5v-1z"/>
|
||||
</svg>
|
||||
);
|
||||
case 'find-duplicates':
|
||||
return (
|
||||
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
.translation-validation-view {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
padding: 16px;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background: var(--vscode-editor-background);
|
||||
color: var(--vscode-editor-foreground);
|
||||
}
|
||||
|
||||
.translation-validation-summary h2 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.translation-validation-summary p {
|
||||
margin: 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.translation-validation-section h3 {
|
||||
margin: 0 0 8px 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.translation-validation-empty,
|
||||
.translation-validation-status {
|
||||
margin: 0;
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.translation-validation-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.translation-validation-card {
|
||||
border: 1px solid var(--vscode-panel-border);
|
||||
border-radius: 6px;
|
||||
padding: 12px;
|
||||
background: color-mix(in srgb, var(--vscode-editor-background) 82%, var(--vscode-editorWidget-background) 18%);
|
||||
}
|
||||
|
||||
.translation-validation-card.translation-validation-card-db {
|
||||
border-left: 4px solid var(--vscode-notificationsWarningIcon-foreground);
|
||||
}
|
||||
|
||||
.translation-validation-card.translation-validation-card-file {
|
||||
border-left: 4px solid var(--vscode-notificationsErrorIcon-foreground);
|
||||
}
|
||||
|
||||
.translation-validation-card-title {
|
||||
margin: 0 0 6px 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.translation-validation-card-meta {
|
||||
margin: 0;
|
||||
display: grid;
|
||||
grid-template-columns: max-content 1fr;
|
||||
gap: 4px 10px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.translation-validation-actions {
|
||||
margin-top: auto;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.translation-validation-revalidate,
|
||||
.translation-validation-fix {
|
||||
background-color: var(--vscode-button-background);
|
||||
color: var(--vscode-button-foreground);
|
||||
border: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.translation-validation-revalidate:hover:not(:disabled),
|
||||
.translation-validation-fix:hover:not(:disabled) {
|
||||
background-color: var(--vscode-button-hoverBackground);
|
||||
}
|
||||
|
||||
.translation-validation-revalidate:disabled,
|
||||
.translation-validation-fix:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.translation-validation-card-meta dt {
|
||||
color: var(--vscode-descriptionForeground);
|
||||
}
|
||||
|
||||
.translation-validation-card-meta dd {
|
||||
margin: 0;
|
||||
word-break: break-word;
|
||||
font-family: var(--vscode-editor-font-family);
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import type { TranslationValidationIssue, TranslationValidationReport, TranslationValidationFixResult } from '../../../main/shared/electronApi';
|
||||
import { useAppStore } from '../../store';
|
||||
import { showToast } from '../Toast';
|
||||
import { useI18n } from '../../i18n';
|
||||
import { getPersistedTranslationValidationReport, persistTranslationValidationReport } from '../../navigation/translationValidationPersistence';
|
||||
import './TranslationValidationView.css';
|
||||
|
||||
function getIssueLabel(
|
||||
issue: TranslationValidationIssue['issue'],
|
||||
tr: (key: string, vars?: Record<string, string | number>) => string,
|
||||
): string {
|
||||
if (issue === 'same-language-as-canonical') {
|
||||
return tr('translationValidation.issue.sameLanguage');
|
||||
}
|
||||
if (issue === 'do-not-translate-has-translations') {
|
||||
return tr('translationValidation.issue.doNotTranslate');
|
||||
}
|
||||
if (issue === 'content-in-database') {
|
||||
return tr('translationValidation.issue.contentInDatabase');
|
||||
}
|
||||
|
||||
return tr('translationValidation.issue.missingSource');
|
||||
}
|
||||
|
||||
function ValidationIssueCard({
|
||||
issue,
|
||||
kind,
|
||||
}: {
|
||||
issue: TranslationValidationIssue;
|
||||
kind: 'db' | 'file';
|
||||
}): React.JSX.Element {
|
||||
const { t: tr } = useI18n();
|
||||
|
||||
return (
|
||||
<article className={`translation-validation-card translation-validation-card-${kind}`}>
|
||||
<p className="translation-validation-card-title">{getIssueLabel(issue.issue, tr)}</p>
|
||||
<dl className="translation-validation-card-meta">
|
||||
<dt>{tr('translationValidation.field.translationFor')}</dt>
|
||||
<dd>{issue.translationFor}</dd>
|
||||
{issue.translationId ? (
|
||||
<>
|
||||
<dt>{tr('translationValidation.field.translationId')}</dt>
|
||||
<dd>{issue.translationId}</dd>
|
||||
</>
|
||||
) : null}
|
||||
{issue.title ? (
|
||||
<>
|
||||
<dt>{tr('translationValidation.field.title')}</dt>
|
||||
<dd>{issue.title}</dd>
|
||||
</>
|
||||
) : null}
|
||||
<dt>{tr('translationValidation.field.languages')}</dt>
|
||||
<dd>
|
||||
{issue.canonicalLanguage
|
||||
? tr('translationValidation.languagesWithCanonical', {
|
||||
canonical: issue.canonicalLanguage,
|
||||
translation: issue.translationLanguage,
|
||||
})
|
||||
: issue.translationLanguage}
|
||||
</dd>
|
||||
{issue.filePath ? (
|
||||
<>
|
||||
<dt>{tr('translationValidation.field.filePath')}</dt>
|
||||
<dd>{issue.filePath}</dd>
|
||||
</>
|
||||
) : null}
|
||||
</dl>
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
export const TranslationValidationView: React.FC = () => {
|
||||
const { t: tr } = useI18n();
|
||||
const { activeProject } = useAppStore();
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isRevalidating, setIsRevalidating] = useState(false);
|
||||
const [isFixing, setIsFixing] = useState(false);
|
||||
const [report, setReport] = useState<TranslationValidationReport | null>(null);
|
||||
|
||||
const loadPersistedReport = () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const projectId = activeProject?.id;
|
||||
if (!projectId) {
|
||||
setReport(null);
|
||||
return;
|
||||
}
|
||||
|
||||
setReport(getPersistedTranslationValidationReport(projectId));
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevalidate = async () => {
|
||||
setIsRevalidating(true);
|
||||
try {
|
||||
const freshReport = await window.electronAPI?.blog.validateTranslations();
|
||||
const projectId = activeProject?.id;
|
||||
if (projectId && freshReport) {
|
||||
persistTranslationValidationReport(projectId, freshReport);
|
||||
window.dispatchEvent(new CustomEvent('bds:translation-validation-updated', {
|
||||
detail: { projectId },
|
||||
}));
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Translation revalidation failed:', error);
|
||||
showToast.error(tr('translationValidation.error.validate'));
|
||||
} finally {
|
||||
setIsRevalidating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFix = async () => {
|
||||
if (!report) return;
|
||||
|
||||
setIsFixing(true);
|
||||
try {
|
||||
const result = await window.electronAPI.blog.fixInvalidTranslations(report) as TranslationValidationFixResult;
|
||||
showToast.success(tr('translationValidation.toast.fixSuccess', {
|
||||
dbRows: result.deletedDatabaseRows,
|
||||
files: result.deletedFiles,
|
||||
flushed: result.flushedTranslations,
|
||||
}));
|
||||
// Re-validate after fixing to refresh the report
|
||||
await handleRevalidate();
|
||||
} catch (error) {
|
||||
console.error('Fixing invalid translations failed:', error);
|
||||
showToast.error(tr('translationValidation.error.fix'));
|
||||
} finally {
|
||||
setIsFixing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
loadPersistedReport();
|
||||
}, [activeProject?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (event: Event) => {
|
||||
const detail = (event as CustomEvent<{ projectId?: string }>).detail;
|
||||
if (!activeProject?.id || detail?.projectId !== activeProject.id) {
|
||||
return;
|
||||
}
|
||||
loadPersistedReport();
|
||||
};
|
||||
|
||||
window.addEventListener('bds:translation-validation-updated', handler);
|
||||
return () => window.removeEventListener('bds:translation-validation-updated', handler);
|
||||
}, [activeProject?.id]);
|
||||
|
||||
const summary = useMemo(() => {
|
||||
if (!report) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tr('translationValidation.summary', {
|
||||
dbRows: report.checkedDatabaseRowCount,
|
||||
files: report.checkedFilesystemFileCount,
|
||||
invalidDb: report.invalidDatabaseRows.length,
|
||||
invalidFiles: report.invalidFilesystemFiles.length,
|
||||
});
|
||||
}, [report, tr]);
|
||||
|
||||
const canFix = useMemo(() => {
|
||||
if (!report) return false;
|
||||
return report.invalidDatabaseRows.length > 0 || report.invalidFilesystemFiles.length > 0;
|
||||
}, [report]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="translation-validation-view">
|
||||
<p className="translation-validation-status">{tr('translationValidation.loading')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!report) {
|
||||
return (
|
||||
<div className="translation-validation-view">
|
||||
<p className="translation-validation-status">{tr('translationValidation.empty')}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="translation-validation-view">
|
||||
<div className="translation-validation-summary">
|
||||
<h2>{tr('translationValidation.title')}</h2>
|
||||
<p>{summary}</p>
|
||||
</div>
|
||||
|
||||
<section className="translation-validation-section">
|
||||
<h3>{tr('translationValidation.databaseTitle')}</h3>
|
||||
{report.invalidDatabaseRows.length === 0 ? (
|
||||
<p className="translation-validation-empty">{tr('translationValidation.noneDatabase')}</p>
|
||||
) : (
|
||||
<div className="translation-validation-list">
|
||||
{report.invalidDatabaseRows.map((issue, index) => (
|
||||
<ValidationIssueCard key={`db:${issue.translationId || issue.translationFor}:${index}`} issue={issue} kind="db" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="translation-validation-section">
|
||||
<h3>{tr('translationValidation.filesystemTitle')}</h3>
|
||||
{report.invalidFilesystemFiles.length === 0 ? (
|
||||
<p className="translation-validation-empty">{tr('translationValidation.noneFilesystem')}</p>
|
||||
) : (
|
||||
<div className="translation-validation-list">
|
||||
{report.invalidFilesystemFiles.map((issue, index) => (
|
||||
<ValidationIssueCard key={`file:${issue.filePath || issue.translationFor}:${index}`} issue={issue} kind="file" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="translation-validation-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="translation-validation-revalidate"
|
||||
onClick={handleRevalidate}
|
||||
disabled={isRevalidating || isFixing}
|
||||
>
|
||||
{isRevalidating ? tr('translationValidation.revalidating') : tr('translationValidation.revalidate')}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="translation-validation-fix"
|
||||
onClick={handleFix}
|
||||
disabled={!canFix || isFixing || isRevalidating}
|
||||
>
|
||||
{isFixing ? tr('translationValidation.fixing') : tr('translationValidation.fix')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1 @@
|
||||
export { TranslationValidationView } from './TranslationValidationView';
|
||||
@@ -54,6 +54,33 @@
|
||||
"siteValidation.error.validate": "Website-Validierung fehlgeschlagen",
|
||||
"siteValidation.error.apply": "Anwenden der Validierung fehlgeschlagen",
|
||||
"siteValidation.toast.applySuccess": "Validierung angewendet: {rendered} gerendert, {deleted} gelöscht",
|
||||
"menu.item.validateTranslations": "Übersetzungen validieren",
|
||||
"translationValidation.tabTitle": "Übersetzungsvalidierung",
|
||||
"translationValidation.title": "Übersetzungen validieren",
|
||||
"translationValidation.summary": "Geprüfte DB-Zeilen: {dbRows} · Geprüfte Dateien: {files} · Ungültige DB-Zeilen: {invalidDb} · Ungültige Dateien: {invalidFiles}",
|
||||
"translationValidation.loading": "Übersetzungen werden validiert...",
|
||||
"translationValidation.empty": "Führe Blog -> Übersetzungen validieren aus, um die Übersetzungsintegrität zu prüfen.",
|
||||
"translationValidation.databaseTitle": "Ungültige Übersetzungszeilen in der Datenbank",
|
||||
"translationValidation.filesystemTitle": "Ungültige Übersetzungsdateien auf dem Datenträger",
|
||||
"translationValidation.noneDatabase": "Keine ungültigen Übersetzungszeilen gefunden.",
|
||||
"translationValidation.noneFilesystem": "Keine ungültigen Übersetzungsdateien gefunden.",
|
||||
"translationValidation.error.validate": "Übersetzungsvalidierung fehlgeschlagen",
|
||||
"translationValidation.issue.sameLanguage": "Übersetzungssprache entspricht der kanonischen Beitragssprache",
|
||||
"translationValidation.issue.missingSource": "Übersetzung verweist auf einen fehlenden Quellbeitrag",
|
||||
"translationValidation.issue.doNotTranslate": "Beitrag ist als nicht-übersetzen markiert, hat aber Übersetzungen",
|
||||
"translationValidation.issue.contentInDatabase": "Veröffentlichte Übersetzung hat Inhalt in der DB statt im Dateisystem",
|
||||
"translationValidation.field.translationFor": "Quellbeitrag",
|
||||
"translationValidation.field.translationId": "Übersetzungszeile",
|
||||
"translationValidation.field.title": "Titel",
|
||||
"translationValidation.field.languages": "Sprachen",
|
||||
"translationValidation.field.filePath": "Datei",
|
||||
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
|
||||
"translationValidation.revalidate": "Erneut validieren",
|
||||
"translationValidation.revalidating": "Wird validiert…",
|
||||
"translationValidation.fix": "Probleme beheben",
|
||||
"translationValidation.fixing": "Wird behoben…",
|
||||
"translationValidation.toast.fixSuccess": "{dbRows} DB-Zeilen und {files} Dateien gelöscht, {flushed} Übersetzungen auf Disk geschrieben",
|
||||
"translationValidation.error.fix": "Fehler beim Beheben ungültiger Übersetzungen",
|
||||
"menuEditor.tabTitle": "Blog-Menü",
|
||||
"menuEditor.title": "Blog-Menü-Editor",
|
||||
"menuEditor.description": "Verwalte die zentrale Blog-Navigationsstruktur und speichere sie in meta/menu.opml.",
|
||||
@@ -419,18 +446,18 @@
|
||||
"metadataDiff.orphanFiles.badge": "Verwaiste Datei",
|
||||
"metadataDiff.orphanFiles.slug": "Slug",
|
||||
"metadataDiff.orphanFiles.path": "Pfad",
|
||||
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
|
||||
"metadataDiff.orphanFiles.importButton": "D → DB",
|
||||
"metadataDiff.orphanFiles.importTitle": "Alle verwaisten Dateien in die Datenbank importieren",
|
||||
"metadataDiff.orphanFiles.importing": "Importiere…",
|
||||
"metadataDiff.orphanFiles.importSuccess": "{success} verwaiste Dateien importiert{failed}",
|
||||
"metadataDiff.orphanFiles.importError": "Import der verwaisten Dateien fehlgeschlagen",
|
||||
"metadataDiff.sync.failed": "fehlgeschlagen",
|
||||
"metadataDiff.sync.dbToFile.title": "Dateien mit Datenbankwerten aktualisieren",
|
||||
"metadataDiff.sync.dbToFile.short": "DB\u2192D",
|
||||
"metadataDiff.sync.dbToFile.short": "DB→D",
|
||||
"metadataDiff.sync.dbToFile.success": "{success} Beiträge in Dateien synchronisiert{fehlgeschlagen}",
|
||||
"metadataDiff.sync.dbToFile.error": "Synchronisierung in Dateien fehlgeschlagen",
|
||||
"metadataDiff.sync.fileToDb.title": "Datenbank mit Dateiwerten aktualisieren",
|
||||
"metadataDiff.sync.fileToDb.short": "D\u2192DB",
|
||||
"metadataDiff.sync.fileToDb.short": "D→DB",
|
||||
"metadataDiff.sync.fileToDb.success": "{success} Dateien in die Datenbank synchronisiert{fehlgeschlagen}",
|
||||
"metadataDiff.sync.fileToDb.error": "Synchronisierung in die Datenbank fehlgeschlagen",
|
||||
"metadataDiff.value.database": "Datenbank",
|
||||
@@ -461,6 +488,7 @@
|
||||
"sidebar.published": "Veröffentlicht",
|
||||
"sidebar.archived": "Archiviert",
|
||||
"sidebar.untitled": "Ohne Titel",
|
||||
"sidebar.languagesAvailable": "{count} Sprachen verfugbar",
|
||||
"sidebar.noMatchingPosts": "Keine passenden Beiträge",
|
||||
"sidebar.createFirstPost": "Ersten Beitrag erstellen",
|
||||
"sidebar.loadMore": "Mehr laden ({loaded} von {total})",
|
||||
@@ -525,6 +553,8 @@
|
||||
"settings.project.publicUrlPlaceholder": "https://example.com",
|
||||
"settings.project.mainLanguageLabel": "Hauptsprache",
|
||||
"settings.project.mainLanguageDescription": "Die primäre Sprache für deine Blog-Inhalte. KI-generierte Titel, Alt-Texte und Bildunterschriften nutzen diese Sprache.",
|
||||
"settings.project.blogLanguagesLabel": "Blog-Sprachen",
|
||||
"settings.project.blogLanguagesDescription": "Sprachen, in denen der Blog gerendert wird. Die Hauptsprache ist immer enthalten. Zusätzliche Sprachen erzeugen übersetzte Unterbäume.",
|
||||
"settings.project.defaultAuthorLabel": "Standardautor",
|
||||
"settings.project.defaultAuthorDescription": "Der Standard-Autorname für neue Beiträge und Medien. Kann pro Element überschrieben werden.",
|
||||
"settings.project.defaultAuthorPlaceholder": "Autorenname",
|
||||
@@ -578,6 +608,27 @@
|
||||
"editor.previewFrameTitle": "Beitragsvorschau",
|
||||
"editor.previewLoading": "Vorschau wird geladen...",
|
||||
"editor.metadata.toggle": "Metadaten",
|
||||
"editor.translations.title": "Ubersetzungen",
|
||||
"editor.translations.currentLanguage": "Aktuelle Sprache: {language}",
|
||||
"editor.translations.none": "Noch keine Ubersetzungen.",
|
||||
"editor.translations.selectTarget": "Zielsprache auswahlen",
|
||||
"editor.translations.translateButton": "Ubersetzen nach...",
|
||||
"editor.translations.translateTitle": "Ubersetzung per KI erstellen oder aktualisieren",
|
||||
"editor.translations.translating": "Wird ubersetzt...",
|
||||
"editor.translations.refresh": "Aktualisieren",
|
||||
"editor.translations.refreshTitle": "Diese Ubersetzung per KI neu erzeugen",
|
||||
"editor.translations.publish": "Veroffentlichen",
|
||||
"editor.translations.publishTitle": "Diese Ubersetzung in ihre Markdown-Datei veroffentlichen",
|
||||
"editor.translations.publishing": "Wird veroffentlicht...",
|
||||
"editor.translations.missing": "Fehlend: {languages}",
|
||||
"editor.translations.complete": "Alle unterstutzten Ubersetzungssprachen sind verfugbar.",
|
||||
"editor.translations.translateSuccess": "Ubersetzung fur {language} aktualisiert",
|
||||
"editor.translations.translateFailed": "Ubersetzung fehlgeschlagen",
|
||||
"editor.translations.publishSuccess": "Ubersetzung fur {language} veroffentlicht",
|
||||
"editor.translations.publishFailed": "Ubersetzung konnte nicht veroffentlicht werden",
|
||||
"editor.translations.status.draft": "Entwurf",
|
||||
"editor.translations.status.published": "Veroffentlicht",
|
||||
"editor.translations.status.archived": "Archiviert",
|
||||
"editor.excerpt.toggle": "Auszug",
|
||||
"editor.footer.created": "Erstellt",
|
||||
"editor.footer.updated": "Aktualisiert",
|
||||
@@ -927,6 +978,12 @@
|
||||
"editor.media.quickActions.button": "⚡ Schnellaktionen",
|
||||
"editor.media.quickActions.aiTitle": "KI: Titel, Alt-Text und Bildunterschrift erzeugen",
|
||||
"editor.media.quickActions.aiDescription": "Analysiert das Bild und schlägt Metadaten vor",
|
||||
"editor.media.quickActions.detectLanguageTitle": "Sprache erkennen",
|
||||
"editor.media.quickActions.detectLanguageDescription": "Sprache aus Metadaten per KI erkennen",
|
||||
"editor.media.quickActions.translateTitle": "Übersetzen in…",
|
||||
"editor.media.quickActions.translateDescription": "Übersetzung per KI erstellen oder aktualisieren",
|
||||
"editor.media.translations.currentLanguage": "Aktuelle Sprache: {language}",
|
||||
"editor.media.translations.selectTarget": "Zielsprache wählen",
|
||||
"editor.post.quickActions.title": "Schnellaktionen",
|
||||
"editor.post.quickActions.analyzing": "⏳ Wird analysiert…",
|
||||
"editor.post.quickActions.button": "⚡ Schnellaktionen",
|
||||
@@ -949,6 +1006,24 @@
|
||||
"editor.media.field.caption": "Bildunterschrift",
|
||||
"editor.media.field.tags": "Tags (kommagetrennt)",
|
||||
"editor.media.field.author": "Autor",
|
||||
"editor.media.field.language": "Sprache",
|
||||
"editor.media.field.languageNone": "Nicht festgelegt",
|
||||
"editor.media.translations.title": "Übersetzungen",
|
||||
"editor.media.translations.none": "Noch keine Übersetzungen.",
|
||||
"editor.media.translations.translateButton": "Übersetzen in…",
|
||||
"editor.media.translations.translating": "Übersetze…",
|
||||
"editor.media.translations.translateSuccess": "Übersetzung aktualisiert für {language}",
|
||||
"editor.media.translations.translateFailed": "Übersetzung fehlgeschlagen",
|
||||
"editor.media.translations.refresh": "Aktualisieren",
|
||||
"editor.media.translations.refreshTitle": "Diese Übersetzung per KI neu generieren",
|
||||
"editor.media.translations.deleteTitle": "Diese Übersetzung löschen",
|
||||
"editor.media.translations.deleted": "Übersetzung gelöscht für {language}",
|
||||
"editor.media.translations.deleteFailed": "Löschen der Übersetzung fehlgeschlagen",
|
||||
"editor.media.translations.editTitle": "Übersetzung bearbeiten — {language}",
|
||||
"editor.media.translations.saved": "Übersetzung gespeichert für {language}",
|
||||
"editor.media.translations.saveFailed": "Speichern der Übersetzung fehlgeschlagen",
|
||||
"editor.media.toast.languageDetected": "Sprache erkannt: {language}",
|
||||
"editor.media.error.detectLanguage": "Spracherkennung fehlgeschlagen",
|
||||
"editor.media.placeholder.title": "Titel für Listen und Suchergebnisse",
|
||||
"editor.media.placeholder.altText": "Bild für Barrierefreiheit beschreiben",
|
||||
"editor.media.placeholder.caption": "Bildunterschrift",
|
||||
@@ -1097,9 +1172,7 @@
|
||||
"importAnalysis.usedIn": "Verwendet in: {items}{more}",
|
||||
"importAnalysis.moreSuffix": ", +{count} weitere",
|
||||
"importAnalysis.noParameters": "(keine Parameter)",
|
||||
|
||||
"sidebar.nav.mcp": "MCP-Server",
|
||||
|
||||
"settings.mcp.title": "MCP-Server",
|
||||
"settings.mcp.description": "Konfigurieren Sie den Model Context Protocol Server, der KI-Programmieragenten die Interaktion mit Ihrem Blog ermöglicht.",
|
||||
"settings.mcp.statusLabel": "Serverstatus",
|
||||
@@ -1132,5 +1205,9 @@
|
||||
"duplicatesView.checkAll": "Alle auswählen",
|
||||
"duplicatesView.uncheckAll": "Alle abwählen",
|
||||
"duplicatesView.dismissChecked": "Ausgewählte ignorieren ({count})",
|
||||
"duplicatesView.notEnabled": "Semantische Ähnlichkeit ist nicht aktiviert. Aktivieren Sie sie unter Einstellungen → Technologie."
|
||||
"duplicatesView.notEnabled": "Semantische Ähnlichkeit ist nicht aktiviert. Aktivieren Sie sie unter Einstellungen → Technologie.",
|
||||
"editor.doNotTranslateLabel": "Nicht übersetzen",
|
||||
"blog.fillMissing.nothingToDo": "Alle Übersetzungen sind aktuell.",
|
||||
"blog.fillMissing.started": "Übersetzungsaufgabe gestartet. Fortschritt im Aufgabenbereich.",
|
||||
"blog.fillMissing.error": "Fehlende Übersetzungen konnten nicht erstellt werden."
|
||||
}
|
||||
|
||||
@@ -54,6 +54,33 @@
|
||||
"siteValidation.error.validate": "Site validation failed",
|
||||
"siteValidation.error.apply": "Applying validation failed",
|
||||
"siteValidation.toast.applySuccess": "Validation applied: {rendered} rendered, {deleted} deleted",
|
||||
"menu.item.validateTranslations": "Validate Translations",
|
||||
"translationValidation.tabTitle": "Translation Validation",
|
||||
"translationValidation.title": "Validate Translations",
|
||||
"translationValidation.summary": "Checked DB rows: {dbRows} · Checked files: {files} · Invalid DB rows: {invalidDb} · Invalid files: {invalidFiles}",
|
||||
"translationValidation.loading": "Validating translations...",
|
||||
"translationValidation.empty": "Run Blog -> Validate Translations to inspect translation integrity.",
|
||||
"translationValidation.databaseTitle": "Invalid database translation rows",
|
||||
"translationValidation.filesystemTitle": "Invalid translation files on disk",
|
||||
"translationValidation.noneDatabase": "No invalid translation rows found.",
|
||||
"translationValidation.noneFilesystem": "No invalid translation files found.",
|
||||
"translationValidation.error.validate": "Translation validation failed",
|
||||
"translationValidation.issue.sameLanguage": "Translation language matches canonical post language",
|
||||
"translationValidation.issue.missingSource": "Translation points to a missing source post",
|
||||
"translationValidation.issue.doNotTranslate": "Post is marked as do-not-translate but has translations",
|
||||
"translationValidation.issue.contentInDatabase": "Published translation has content stuck in DB instead of filesystem",
|
||||
"translationValidation.field.translationFor": "Source post",
|
||||
"translationValidation.field.translationId": "Translation row",
|
||||
"translationValidation.field.title": "Title",
|
||||
"translationValidation.field.languages": "Languages",
|
||||
"translationValidation.field.filePath": "File",
|
||||
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
|
||||
"translationValidation.revalidate": "Revalidate",
|
||||
"translationValidation.revalidating": "Revalidating…",
|
||||
"translationValidation.fix": "Fix Issues",
|
||||
"translationValidation.fixing": "Fixing…",
|
||||
"translationValidation.toast.fixSuccess": "Deleted {dbRows} DB rows and {files} files, flushed {flushed} translations to disk",
|
||||
"translationValidation.error.fix": "Failed to fix invalid translations",
|
||||
"menuEditor.tabTitle": "Blog Menu",
|
||||
"menuEditor.title": "Blog Menu Editor",
|
||||
"menuEditor.description": "Manage the central blog navigation outline and save it to meta/menu.opml.",
|
||||
@@ -412,11 +439,11 @@
|
||||
"metadataDiff.fieldFilter.toggle": "Filter by {field}",
|
||||
"metadataDiff.sync.failed": "failed",
|
||||
"metadataDiff.sync.dbToFile.title": "Update files with database values",
|
||||
"metadataDiff.sync.dbToFile.short": "DB\u2192F",
|
||||
"metadataDiff.sync.dbToFile.short": "DB→F",
|
||||
"metadataDiff.sync.dbToFile.success": "Synced {success} posts to files{failed}",
|
||||
"metadataDiff.sync.dbToFile.error": "Failed to sync to files",
|
||||
"metadataDiff.sync.fileToDb.title": "Update database with file values",
|
||||
"metadataDiff.sync.fileToDb.short": "F\u2192DB",
|
||||
"metadataDiff.sync.fileToDb.short": "F→DB",
|
||||
"metadataDiff.sync.fileToDb.success": "Synced {success} files to database{failed}",
|
||||
"metadataDiff.sync.fileToDb.error": "Failed to sync to database",
|
||||
"metadataDiff.value.database": "Database",
|
||||
@@ -433,7 +460,7 @@
|
||||
"metadataDiff.orphanFiles.badge": "Orphan file",
|
||||
"metadataDiff.orphanFiles.slug": "Slug",
|
||||
"metadataDiff.orphanFiles.path": "Path",
|
||||
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
|
||||
"metadataDiff.orphanFiles.importButton": "D → DB",
|
||||
"metadataDiff.orphanFiles.importTitle": "Import all orphan files into the database",
|
||||
"metadataDiff.orphanFiles.importing": "Importing…",
|
||||
"metadataDiff.orphanFiles.importSuccess": "{success} orphan files imported{failed}",
|
||||
@@ -461,6 +488,7 @@
|
||||
"sidebar.published": "Published",
|
||||
"sidebar.archived": "Archived",
|
||||
"sidebar.untitled": "Untitled",
|
||||
"sidebar.languagesAvailable": "{count} languages available",
|
||||
"sidebar.noMatchingPosts": "No matching posts",
|
||||
"sidebar.createFirstPost": "Create your first post",
|
||||
"sidebar.loadMore": "Load more ({loaded} of {total})",
|
||||
@@ -525,6 +553,8 @@
|
||||
"settings.project.publicUrlPlaceholder": "https://example.com",
|
||||
"settings.project.mainLanguageLabel": "Main Language",
|
||||
"settings.project.mainLanguageDescription": "The primary language for your blog content. AI-generated titles, alt text, and captions will use this language.",
|
||||
"settings.project.blogLanguagesLabel": "Blog Languages",
|
||||
"settings.project.blogLanguagesDescription": "Languages the blog is rendered in. The main language is always included. Additional languages generate translated subtrees.",
|
||||
"settings.project.defaultAuthorLabel": "Default Author",
|
||||
"settings.project.defaultAuthorDescription": "The default author name for new posts and media. Can be overridden per item.",
|
||||
"settings.project.defaultAuthorPlaceholder": "Author Name",
|
||||
@@ -578,6 +608,27 @@
|
||||
"editor.previewFrameTitle": "Post preview",
|
||||
"editor.previewLoading": "Loading preview...",
|
||||
"editor.metadata.toggle": "Metadata",
|
||||
"editor.translations.title": "Translations",
|
||||
"editor.translations.currentLanguage": "Current language: {language}",
|
||||
"editor.translations.none": "No translations yet.",
|
||||
"editor.translations.selectTarget": "Select target language",
|
||||
"editor.translations.translateButton": "Translate to...",
|
||||
"editor.translations.translateTitle": "Create or refresh a translation using AI",
|
||||
"editor.translations.translating": "Translating...",
|
||||
"editor.translations.refresh": "Refresh",
|
||||
"editor.translations.refreshTitle": "Regenerate this translation using AI",
|
||||
"editor.translations.publish": "Publish",
|
||||
"editor.translations.publishTitle": "Publish this translation to its markdown file",
|
||||
"editor.translations.publishing": "Publishing...",
|
||||
"editor.translations.missing": "Missing: {languages}",
|
||||
"editor.translations.complete": "All supported translation languages are available.",
|
||||
"editor.translations.translateSuccess": "Translation updated for {language}",
|
||||
"editor.translations.translateFailed": "Translation failed",
|
||||
"editor.translations.publishSuccess": "Published translation for {language}",
|
||||
"editor.translations.publishFailed": "Publishing translation failed",
|
||||
"editor.translations.status.draft": "Draft",
|
||||
"editor.translations.status.published": "Published",
|
||||
"editor.translations.status.archived": "Archived",
|
||||
"editor.excerpt.toggle": "Excerpt",
|
||||
"editor.footer.created": "Created",
|
||||
"editor.footer.updated": "Updated",
|
||||
@@ -927,6 +978,12 @@
|
||||
"editor.media.quickActions.button": "⚡ Quick Actions",
|
||||
"editor.media.quickActions.aiTitle": "AI: Generate Title, Alt & Caption",
|
||||
"editor.media.quickActions.aiDescription": "Analyzes the image to suggest metadata",
|
||||
"editor.media.quickActions.detectLanguageTitle": "Detect Language",
|
||||
"editor.media.quickActions.detectLanguageDescription": "Detect language from metadata using AI",
|
||||
"editor.media.quickActions.translateTitle": "Translate to...",
|
||||
"editor.media.quickActions.translateDescription": "Create or refresh a translation using AI",
|
||||
"editor.media.translations.currentLanguage": "Current language: {language}",
|
||||
"editor.media.translations.selectTarget": "Select target language",
|
||||
"editor.post.quickActions.title": "Quick Actions",
|
||||
"editor.post.quickActions.analyzing": "⏳ Analyzing…",
|
||||
"editor.post.quickActions.button": "⚡ Quick Actions",
|
||||
@@ -949,6 +1006,24 @@
|
||||
"editor.media.field.caption": "Caption",
|
||||
"editor.media.field.tags": "Tags (comma-separated)",
|
||||
"editor.media.field.author": "Author",
|
||||
"editor.media.field.language": "Language",
|
||||
"editor.media.field.languageNone": "Not set",
|
||||
"editor.media.translations.title": "Translations",
|
||||
"editor.media.translations.none": "No translations yet.",
|
||||
"editor.media.translations.translateButton": "Translate to…",
|
||||
"editor.media.translations.translating": "Translating…",
|
||||
"editor.media.translations.translateSuccess": "Translation updated for {language}",
|
||||
"editor.media.translations.translateFailed": "Translation failed",
|
||||
"editor.media.translations.refresh": "Refresh",
|
||||
"editor.media.translations.refreshTitle": "Regenerate this translation using AI",
|
||||
"editor.media.translations.deleteTitle": "Delete this translation",
|
||||
"editor.media.translations.deleted": "Translation deleted for {language}",
|
||||
"editor.media.translations.deleteFailed": "Failed to delete translation",
|
||||
"editor.media.translations.editTitle": "Edit Translation — {language}",
|
||||
"editor.media.translations.saved": "Translation saved for {language}",
|
||||
"editor.media.translations.saveFailed": "Failed to save translation",
|
||||
"editor.media.toast.languageDetected": "Language detected: {language}",
|
||||
"editor.media.error.detectLanguage": "Failed to detect language",
|
||||
"editor.media.placeholder.title": "Title for lists and search results",
|
||||
"editor.media.placeholder.altText": "Describe the image for accessibility",
|
||||
"editor.media.placeholder.caption": "Image caption",
|
||||
@@ -1097,9 +1172,7 @@
|
||||
"importAnalysis.usedIn": "Used in: {items}{more}",
|
||||
"importAnalysis.moreSuffix": ", +{count} more",
|
||||
"importAnalysis.noParameters": "(no parameters)",
|
||||
|
||||
"sidebar.nav.mcp": "MCP Server",
|
||||
|
||||
"settings.mcp.title": "MCP Server",
|
||||
"settings.mcp.description": "Configure the Model Context Protocol server that allows AI coding agents to interact with your blog.",
|
||||
"settings.mcp.statusLabel": "Server Status",
|
||||
@@ -1132,5 +1205,9 @@
|
||||
"duplicatesView.checkAll": "Check All",
|
||||
"duplicatesView.uncheckAll": "Uncheck All",
|
||||
"duplicatesView.dismissChecked": "Dismiss Checked ({count})",
|
||||
"duplicatesView.notEnabled": "Semantic similarity is not enabled. Enable it in Settings → Technology."
|
||||
"duplicatesView.notEnabled": "Semantic similarity is not enabled. Enable it in Settings → Technology.",
|
||||
"editor.doNotTranslateLabel": "Do not translate",
|
||||
"blog.fillMissing.nothingToDo": "All translations are up to date.",
|
||||
"blog.fillMissing.started": "Translation task started. Check the task panel for progress.",
|
||||
"blog.fillMissing.error": "Failed to fill missing translations."
|
||||
}
|
||||
|
||||
@@ -54,6 +54,33 @@
|
||||
"siteValidation.error.validate": "La validación del sitio falló",
|
||||
"siteValidation.error.apply": "La aplicación de la validación falló",
|
||||
"siteValidation.toast.applySuccess": "Validación aplicada: {rendered} renderizadas, {deleted} eliminadas",
|
||||
"menu.item.validateTranslations": "Validar traducciones",
|
||||
"translationValidation.tabTitle": "Validación de traducciones",
|
||||
"translationValidation.title": "Validar traducciones",
|
||||
"translationValidation.summary": "Filas de BD revisadas: {dbRows} · Archivos revisados: {files} · Filas de BD inválidas: {invalidDb} · Archivos inválidos: {invalidFiles}",
|
||||
"translationValidation.loading": "Validando traducciones...",
|
||||
"translationValidation.empty": "Ejecuta Blog -> Validar traducciones para inspeccionar la integridad de las traducciones.",
|
||||
"translationValidation.databaseTitle": "Filas de traducción inválidas en la base de datos",
|
||||
"translationValidation.filesystemTitle": "Archivos de traducción inválidos en disco",
|
||||
"translationValidation.noneDatabase": "No se encontraron filas de traducción inválidas.",
|
||||
"translationValidation.noneFilesystem": "No se encontraron archivos de traducción inválidos.",
|
||||
"translationValidation.error.validate": "La validación de traducciones falló",
|
||||
"translationValidation.issue.sameLanguage": "El idioma de la traducción coincide con el idioma canónico de la entrada",
|
||||
"translationValidation.issue.missingSource": "La traducción apunta a una entrada de origen inexistente",
|
||||
"translationValidation.issue.doNotTranslate": "La entrada está marcada como no-traducir pero tiene traducciones",
|
||||
"translationValidation.issue.contentInDatabase": "Traducción publicada con contenido en la BD en lugar del sistema de archivos",
|
||||
"translationValidation.field.translationFor": "Entrada de origen",
|
||||
"translationValidation.field.translationId": "Fila de traducción",
|
||||
"translationValidation.field.title": "Título",
|
||||
"translationValidation.field.languages": "Idiomas",
|
||||
"translationValidation.field.filePath": "Archivo",
|
||||
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
|
||||
"translationValidation.revalidate": "Revalidar",
|
||||
"translationValidation.revalidating": "Revalidando…",
|
||||
"translationValidation.fix": "Corregir problemas",
|
||||
"translationValidation.fixing": "Corrigiendo…",
|
||||
"translationValidation.toast.fixSuccess": "{dbRows} filas de BD y {files} archivos eliminados, {flushed} traducciones escritas a disco",
|
||||
"translationValidation.error.fix": "Error al corregir traducciones inválidas",
|
||||
"menuEditor.tabTitle": "Menú del blog",
|
||||
"menuEditor.title": "Editor del menú del blog",
|
||||
"menuEditor.description": "Gestiona la estructura central de navegación del blog y guárdala en meta/menu.opml.",
|
||||
@@ -412,7 +439,7 @@
|
||||
"metadataDiff.fieldFilter.toggle": "Filtrar por {field}",
|
||||
"metadataDiff.sync.failed": "falló",
|
||||
"metadataDiff.sync.dbToFile.title": "Actualizar archivos con valores de la base de datos",
|
||||
"metadataDiff.sync.dbToFile.short": "BD\u2192A",
|
||||
"metadataDiff.sync.dbToFile.short": "BD→A",
|
||||
"metadataDiff.sync.dbToFile.success": "Se sincronizaron {success} entradas a archivos{falló}",
|
||||
"metadataDiff.sync.dbToFile.error": "No se pudo sincronizar a archivos",
|
||||
"metadataDiff.sync.fileToDb.title": "Actualizar base de datos con valores de archivos",
|
||||
@@ -433,7 +460,7 @@
|
||||
"metadataDiff.orphanFiles.badge": "Archivo huérfano",
|
||||
"metadataDiff.orphanFiles.slug": "Slug",
|
||||
"metadataDiff.orphanFiles.path": "Ruta",
|
||||
"metadataDiff.orphanFiles.importButton": "D \u2192 BD",
|
||||
"metadataDiff.orphanFiles.importButton": "D → BD",
|
||||
"metadataDiff.orphanFiles.importTitle": "Importar todos los archivos huérfanos a la base de datos",
|
||||
"metadataDiff.orphanFiles.importing": "Importando…",
|
||||
"metadataDiff.orphanFiles.importSuccess": "{success} archivos huérfanos importados{failed}",
|
||||
@@ -461,6 +488,7 @@
|
||||
"sidebar.published": "Publicadas",
|
||||
"sidebar.archived": "Archivadas",
|
||||
"sidebar.untitled": "Sin título",
|
||||
"sidebar.languagesAvailable": "{count} idiomas disponibles",
|
||||
"sidebar.noMatchingPosts": "No hay entradas coincidentes",
|
||||
"sidebar.createFirstPost": "Crea tu primera entrada",
|
||||
"sidebar.loadMore": "Cargar más ({loaded} de {total})",
|
||||
@@ -525,6 +553,8 @@
|
||||
"settings.project.publicUrlPlaceholder": "https://example.com",
|
||||
"settings.project.mainLanguageLabel": "Idioma principal",
|
||||
"settings.project.mainLanguageDescription": "Idioma principal del contenido del blog. Los títulos, textos alternativos y pies generados por IA usarán este idioma.",
|
||||
"settings.project.blogLanguagesLabel": "Idiomas del blog",
|
||||
"settings.project.blogLanguagesDescription": "Idiomas en los que se genera el blog. El idioma principal siempre está incluido. Los idiomas adicionales generan subárboles traducidos.",
|
||||
"settings.project.defaultAuthorLabel": "Autor predeterminado",
|
||||
"settings.project.defaultAuthorDescription": "Nombre de autor predeterminado para nuevas entradas y medios. Se puede reemplazar por elemento.",
|
||||
"settings.project.defaultAuthorPlaceholder": "Nombre del autor",
|
||||
@@ -578,6 +608,27 @@
|
||||
"editor.previewFrameTitle": "Vista previa de la entrada",
|
||||
"editor.previewLoading": "Cargando vista previa...",
|
||||
"editor.metadata.toggle": "Metadatos",
|
||||
"editor.translations.title": "Traducciones",
|
||||
"editor.translations.currentLanguage": "Idioma actual: {language}",
|
||||
"editor.translations.none": "Todavía no hay traducciones.",
|
||||
"editor.translations.selectTarget": "Selecciona el idioma de destino",
|
||||
"editor.translations.translateButton": "Traducir a...",
|
||||
"editor.translations.translateTitle": "Crear o actualizar una traducción con IA",
|
||||
"editor.translations.translating": "Traduciendo...",
|
||||
"editor.translations.refresh": "Actualizar",
|
||||
"editor.translations.refreshTitle": "Regenerar esta traducción con IA",
|
||||
"editor.translations.publish": "Publicar",
|
||||
"editor.translations.publishTitle": "Publicar esta traducción en su archivo Markdown",
|
||||
"editor.translations.publishing": "Publicando...",
|
||||
"editor.translations.missing": "Faltan: {languages}",
|
||||
"editor.translations.complete": "Todos los idiomas de traducción compatibles están disponibles.",
|
||||
"editor.translations.translateSuccess": "Traducción actualizada para {language}",
|
||||
"editor.translations.translateFailed": "La traducción falló",
|
||||
"editor.translations.publishSuccess": "Traducción publicada para {language}",
|
||||
"editor.translations.publishFailed": "No se pudo publicar la traducción",
|
||||
"editor.translations.status.draft": "Borrador",
|
||||
"editor.translations.status.published": "Publicada",
|
||||
"editor.translations.status.archived": "Archivada",
|
||||
"editor.excerpt.toggle": "Extracto",
|
||||
"editor.footer.created": "Creado",
|
||||
"editor.footer.updated": "Actualizado",
|
||||
@@ -927,6 +978,12 @@
|
||||
"editor.media.quickActions.button": "✨ Analizar con IA",
|
||||
"editor.media.quickActions.aiTitle": "Título sugerido por IA",
|
||||
"editor.media.quickActions.aiDescription": "Genera automáticamente título, texto alternativo y pie de foto.",
|
||||
"editor.media.quickActions.detectLanguageTitle": "Detectar idioma",
|
||||
"editor.media.quickActions.detectLanguageDescription": "Detectar el idioma de los metadatos con IA",
|
||||
"editor.media.quickActions.translateTitle": "Traducir a…",
|
||||
"editor.media.quickActions.translateDescription": "Crear o actualizar una traducción con IA",
|
||||
"editor.media.translations.currentLanguage": "Idioma actual: {language}",
|
||||
"editor.media.translations.selectTarget": "Seleccionar idioma de destino",
|
||||
"editor.post.quickActions.title": "Acciones rápidas",
|
||||
"editor.post.quickActions.analyzing": "⏳ Analizando…",
|
||||
"editor.post.quickActions.button": "⚡ Acciones rápidas",
|
||||
@@ -949,6 +1006,24 @@
|
||||
"editor.media.field.caption": "Pie de foto",
|
||||
"editor.media.field.tags": "Etiquetas",
|
||||
"editor.media.field.author": "Autor",
|
||||
"editor.media.field.language": "Idioma",
|
||||
"editor.media.field.languageNone": "No definido",
|
||||
"editor.media.translations.title": "Traducciones",
|
||||
"editor.media.translations.none": "Aún no hay traducciones.",
|
||||
"editor.media.translations.translateButton": "Traducir a…",
|
||||
"editor.media.translations.translating": "Traduciendo…",
|
||||
"editor.media.translations.translateSuccess": "Traducción actualizada para {language}",
|
||||
"editor.media.translations.translateFailed": "Error en la traducción",
|
||||
"editor.media.translations.refresh": "Actualizar",
|
||||
"editor.media.translations.refreshTitle": "Regenerar esta traducción con IA",
|
||||
"editor.media.translations.deleteTitle": "Eliminar esta traducción",
|
||||
"editor.media.translations.deleted": "Traducción eliminada para {language}",
|
||||
"editor.media.translations.deleteFailed": "Error al eliminar la traducción",
|
||||
"editor.media.translations.editTitle": "Editar traducción — {language}",
|
||||
"editor.media.translations.saved": "Traducción guardada para {language}",
|
||||
"editor.media.translations.saveFailed": "Error al guardar la traducción",
|
||||
"editor.media.toast.languageDetected": "Idioma detectado: {language}",
|
||||
"editor.media.error.detectLanguage": "Error al detectar el idioma",
|
||||
"editor.media.placeholder.title": "Introduce un título",
|
||||
"editor.media.placeholder.altText": "Describe la imagen para accesibilidad",
|
||||
"editor.media.placeholder.caption": "Añadir pie de foto",
|
||||
@@ -1097,9 +1172,7 @@
|
||||
"importAnalysis.usedIn": "Usado en: {items}{more}",
|
||||
"importAnalysis.moreSuffix": ", +{count} más",
|
||||
"importAnalysis.noParameters": "(sin parámetros)",
|
||||
|
||||
"sidebar.nav.mcp": "Servidor MCP",
|
||||
|
||||
"settings.mcp.title": "Servidor MCP",
|
||||
"settings.mcp.description": "Configure el servidor Model Context Protocol que permite a los agentes de programación IA interactuar con su blog.",
|
||||
"settings.mcp.statusLabel": "Estado del servidor",
|
||||
@@ -1132,5 +1205,9 @@
|
||||
"duplicatesView.checkAll": "Seleccionar todo",
|
||||
"duplicatesView.uncheckAll": "Deseleccionar todo",
|
||||
"duplicatesView.dismissChecked": "Descartar seleccionados ({count})",
|
||||
"duplicatesView.notEnabled": "La similitud semántica no está activada. Actívela en Configuración → Tecnología."
|
||||
"duplicatesView.notEnabled": "La similitud semántica no está activada. Actívela en Configuración → Tecnología.",
|
||||
"editor.doNotTranslateLabel": "No traducir",
|
||||
"blog.fillMissing.nothingToDo": "Todas las traducciones están al día.",
|
||||
"blog.fillMissing.started": "Tarea de traducción iniciada. Consulte el panel de tareas para ver el progreso.",
|
||||
"blog.fillMissing.error": "Error al rellenar las traducciones faltantes."
|
||||
}
|
||||
|
||||
@@ -54,6 +54,33 @@
|
||||
"siteValidation.error.validate": "Échec de la validation du site",
|
||||
"siteValidation.error.apply": "Échec de l’application de la validation",
|
||||
"siteValidation.toast.applySuccess": "Validation appliquée : {rendered} rendues, {deleted} supprimées",
|
||||
"menu.item.validateTranslations": "Valider les traductions",
|
||||
"translationValidation.tabTitle": "Validation des traductions",
|
||||
"translationValidation.title": "Valider les traductions",
|
||||
"translationValidation.summary": "Lignes BD vérifiées : {dbRows} · Fichiers vérifiés : {files} · Lignes BD invalides : {invalidDb} · Fichiers invalides : {invalidFiles}",
|
||||
"translationValidation.loading": "Validation des traductions en cours...",
|
||||
"translationValidation.empty": "Exécutez Blog -> Valider les traductions pour inspecter l’intégrité des traductions.",
|
||||
"translationValidation.databaseTitle": "Lignes de traduction invalides dans la base de données",
|
||||
"translationValidation.filesystemTitle": "Fichiers de traduction invalides sur le disque",
|
||||
"translationValidation.noneDatabase": "Aucune ligne de traduction invalide trouvée.",
|
||||
"translationValidation.noneFilesystem": "Aucun fichier de traduction invalide trouvé.",
|
||||
"translationValidation.error.validate": "Échec de la validation des traductions",
|
||||
"translationValidation.issue.sameLanguage": "La langue de traduction correspond à la langue canonique de l’article",
|
||||
"translationValidation.issue.missingSource": "La traduction pointe vers un article source manquant",
|
||||
"translationValidation.issue.doNotTranslate": "L'article est marqué ne-pas-traduire mais a des traductions",
|
||||
"translationValidation.issue.contentInDatabase": "Traduction publiée avec contenu encore en base au lieu du système de fichiers",
|
||||
"translationValidation.field.translationFor": "Article source",
|
||||
"translationValidation.field.translationId": "Ligne de traduction",
|
||||
"translationValidation.field.title": "Titre",
|
||||
"translationValidation.field.languages": "Langues",
|
||||
"translationValidation.field.filePath": "Fichier",
|
||||
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
|
||||
"translationValidation.revalidate": "Revalider",
|
||||
"translationValidation.revalidating": "Revalidation…",
|
||||
"translationValidation.fix": "Corriger les problèmes",
|
||||
"translationValidation.fixing": "Correction…",
|
||||
"translationValidation.toast.fixSuccess": "{dbRows} lignes DB et {files} fichiers supprimés, {flushed} traductions écrites sur disque",
|
||||
"translationValidation.error.fix": "Échec de la correction des traductions invalides",
|
||||
"menuEditor.tabTitle": "Menu du blog",
|
||||
"menuEditor.title": "Éditeur du menu du blog",
|
||||
"menuEditor.description": "Gérez la structure centrale de navigation du blog et enregistrez-la dans meta/menu.opml.",
|
||||
@@ -419,7 +446,7 @@
|
||||
"metadataDiff.orphanFiles.badge": "Fichier orphelin",
|
||||
"metadataDiff.orphanFiles.slug": "Slug",
|
||||
"metadataDiff.orphanFiles.path": "Chemin",
|
||||
"metadataDiff.orphanFiles.importButton": "D \u2192 BD",
|
||||
"metadataDiff.orphanFiles.importButton": "D → BD",
|
||||
"metadataDiff.orphanFiles.importTitle": "Importer tous les fichiers orphelins dans la base de données",
|
||||
"metadataDiff.orphanFiles.importing": "Importation…",
|
||||
"metadataDiff.orphanFiles.importSuccess": "{success} fichiers orphelins importés{failed}",
|
||||
@@ -461,6 +488,7 @@
|
||||
"sidebar.published": "Publiés",
|
||||
"sidebar.archived": "Archivés",
|
||||
"sidebar.untitled": "Sans titre",
|
||||
"sidebar.languagesAvailable": "{count} langues disponibles",
|
||||
"sidebar.noMatchingPosts": "Aucun article correspondant",
|
||||
"sidebar.createFirstPost": "Créer votre premier article",
|
||||
"sidebar.loadMore": "Charger plus ({loaded} sur {total})",
|
||||
@@ -525,6 +553,8 @@
|
||||
"settings.project.publicUrlPlaceholder": "https://example.com",
|
||||
"settings.project.mainLanguageLabel": "Langue principale",
|
||||
"settings.project.mainLanguageDescription": "Langue principale de votre contenu. Les titres, textes alternatifs et légendes générés par l’IA utiliseront cette langue.",
|
||||
"settings.project.blogLanguagesLabel": "Langues du blog",
|
||||
"settings.project.blogLanguagesDescription": "Langues dans lesquelles le blog est rendu. La langue principale est toujours incluse. Les langues supplémentaires génèrent des sous-arborescences traduites.",
|
||||
"settings.project.defaultAuthorLabel": "Auteur par défaut",
|
||||
"settings.project.defaultAuthorDescription": "Nom d’auteur par défaut pour les nouveaux articles et médias. Peut être remplacé par élément.",
|
||||
"settings.project.defaultAuthorPlaceholder": "Nom de l’auteur",
|
||||
@@ -578,6 +608,27 @@
|
||||
"editor.previewFrameTitle": "Aperçu de l’article",
|
||||
"editor.previewLoading": "Chargement de l'aperçu...",
|
||||
"editor.metadata.toggle": "Métadonnées",
|
||||
"editor.translations.title": "Traductions",
|
||||
"editor.translations.currentLanguage": "Langue actuelle : {language}",
|
||||
"editor.translations.none": "Aucune traduction pour le moment.",
|
||||
"editor.translations.selectTarget": "Sélectionner la langue cible",
|
||||
"editor.translations.translateButton": "Traduire vers...",
|
||||
"editor.translations.translateTitle": "Créer ou actualiser une traduction avec l’IA",
|
||||
"editor.translations.translating": "Traduction...",
|
||||
"editor.translations.refresh": "Actualiser",
|
||||
"editor.translations.refreshTitle": "Regénérer cette traduction avec l’IA",
|
||||
"editor.translations.publish": "Publier",
|
||||
"editor.translations.publishTitle": "Publier cette traduction dans son fichier Markdown",
|
||||
"editor.translations.publishing": "Publication...",
|
||||
"editor.translations.missing": "Manquantes : {languages}",
|
||||
"editor.translations.complete": "Toutes les langues de traduction prises en charge sont disponibles.",
|
||||
"editor.translations.translateSuccess": "Traduction mise à jour pour {language}",
|
||||
"editor.translations.translateFailed": "La traduction a échoué",
|
||||
"editor.translations.publishSuccess": "Traduction publiée pour {language}",
|
||||
"editor.translations.publishFailed": "Échec de la publication de la traduction",
|
||||
"editor.translations.status.draft": "Brouillon",
|
||||
"editor.translations.status.published": "Publié",
|
||||
"editor.translations.status.archived": "Archivé",
|
||||
"editor.excerpt.toggle": "Extrait",
|
||||
"editor.footer.created": "Créé",
|
||||
"editor.footer.updated": "Mis à jour",
|
||||
@@ -927,6 +978,12 @@
|
||||
"editor.media.quickActions.button": "✨ Analyser avec l’IA",
|
||||
"editor.media.quickActions.aiTitle": "Titre suggéré par l’IA",
|
||||
"editor.media.quickActions.aiDescription": "Générez automatiquement un titre, un texte alternatif et une légende.",
|
||||
"editor.media.quickActions.detectLanguageTitle": "Détecter la langue",
|
||||
"editor.media.quickActions.detectLanguageDescription": "Détecter la langue des métadonnées avec l’IA",
|
||||
"editor.media.quickActions.translateTitle": "Traduire en…",
|
||||
"editor.media.quickActions.translateDescription": "Créer ou actualiser une traduction avec l’IA",
|
||||
"editor.media.translations.currentLanguage": "Langue actuelle : {language}",
|
||||
"editor.media.translations.selectTarget": "Sélectionner la langue cible",
|
||||
"editor.post.quickActions.title": "Actions rapides",
|
||||
"editor.post.quickActions.analyzing": "⏳ Analyse…",
|
||||
"editor.post.quickActions.button": "⚡ Actions rapides",
|
||||
@@ -949,6 +1006,24 @@
|
||||
"editor.media.field.caption": "Légende",
|
||||
"editor.media.field.tags": "Tags",
|
||||
"editor.media.field.author": "Auteur",
|
||||
"editor.media.field.language": "Langue",
|
||||
"editor.media.field.languageNone": "Non défini",
|
||||
"editor.media.translations.title": "Traductions",
|
||||
"editor.media.translations.none": "Aucune traduction pour le moment.",
|
||||
"editor.media.translations.translateButton": "Traduire en…",
|
||||
"editor.media.translations.translating": "Traduction…",
|
||||
"editor.media.translations.translateSuccess": "Traduction mise à jour pour {language}",
|
||||
"editor.media.translations.translateFailed": "Échec de la traduction",
|
||||
"editor.media.translations.refresh": "Actualiser",
|
||||
"editor.media.translations.refreshTitle": "Régénérer cette traduction par IA",
|
||||
"editor.media.translations.deleteTitle": "Supprimer cette traduction",
|
||||
"editor.media.translations.deleted": "Traduction supprimée pour {language}",
|
||||
"editor.media.translations.deleteFailed": "Échec de la suppression de la traduction",
|
||||
"editor.media.translations.editTitle": "Modifier la traduction — {language}",
|
||||
"editor.media.translations.saved": "Traduction enregistrée pour {language}",
|
||||
"editor.media.translations.saveFailed": "Échec de l’enregistrement de la traduction",
|
||||
"editor.media.toast.languageDetected": "Langue détectée : {language}",
|
||||
"editor.media.error.detectLanguage": "Échec de la détection de la langue",
|
||||
"editor.media.placeholder.title": "Saisissez un titre",
|
||||
"editor.media.placeholder.altText": "Décrivez l’image pour l’accessibilité",
|
||||
"editor.media.placeholder.caption": "Ajouter une légende",
|
||||
@@ -1130,5 +1205,9 @@
|
||||
"duplicatesView.checkAll": "Tout cocher",
|
||||
"duplicatesView.uncheckAll": "Tout décocher",
|
||||
"duplicatesView.dismissChecked": "Ignorer cochés ({count})",
|
||||
"duplicatesView.notEnabled": "La similarité sémantique n'est pas activée. Activez-la dans Paramètres → Technologie."
|
||||
"duplicatesView.notEnabled": "La similarité sémantique n'est pas activée. Activez-la dans Paramètres → Technologie.",
|
||||
"editor.doNotTranslateLabel": "Ne pas traduire",
|
||||
"blog.fillMissing.nothingToDo": "Toutes les traductions sont à jour.",
|
||||
"blog.fillMissing.started": "Tâche de traduction démarrée. Consultez le panneau des tâches pour le progrès.",
|
||||
"blog.fillMissing.error": "Échec du remplissage des traductions manquantes."
|
||||
}
|
||||
|
||||
@@ -54,6 +54,33 @@
|
||||
"siteValidation.error.validate": "Validazione del sito non riuscita",
|
||||
"siteValidation.error.apply": "Applicazione della validazione non riuscita",
|
||||
"siteValidation.toast.applySuccess": "Validazione applicata: {rendered} renderizzati, {deleted} eliminati",
|
||||
"menu.item.validateTranslations": "Valida traduzioni",
|
||||
"translationValidation.tabTitle": "Validazione traduzioni",
|
||||
"translationValidation.title": "Valida traduzioni",
|
||||
"translationValidation.summary": "Righe DB controllate: {dbRows} · File controllati: {files} · Righe DB non valide: {invalidDb} · File non validi: {invalidFiles}",
|
||||
"translationValidation.loading": "Validazione traduzioni in corso...",
|
||||
"translationValidation.empty": "Esegui Blog -> Valida traduzioni per controllare l’integrità delle traduzioni.",
|
||||
"translationValidation.databaseTitle": "Righe di traduzione non valide nel database",
|
||||
"translationValidation.filesystemTitle": "File di traduzione non validi sul disco",
|
||||
"translationValidation.noneDatabase": "Nessuna riga di traduzione non valida trovata.",
|
||||
"translationValidation.noneFilesystem": "Nessun file di traduzione non valido trovato.",
|
||||
"translationValidation.error.validate": "Validazione traduzioni non riuscita",
|
||||
"translationValidation.issue.sameLanguage": "La lingua della traduzione coincide con la lingua canonica del post",
|
||||
"translationValidation.issue.missingSource": "La traduzione punta a un post sorgente mancante",
|
||||
"translationValidation.issue.doNotTranslate": "Il post è contrassegnato come non-tradurre ma ha traduzioni",
|
||||
"translationValidation.issue.contentInDatabase": "Traduzione pubblicata con contenuto nel DB invece del filesystem",
|
||||
"translationValidation.field.translationFor": "Post sorgente",
|
||||
"translationValidation.field.translationId": "Riga traduzione",
|
||||
"translationValidation.field.title": "Titolo",
|
||||
"translationValidation.field.languages": "Lingue",
|
||||
"translationValidation.field.filePath": "File",
|
||||
"translationValidation.languagesWithCanonical": "{canonical} = {translation}",
|
||||
"translationValidation.revalidate": "Rivalidare",
|
||||
"translationValidation.revalidating": "Rivalidazione…",
|
||||
"translationValidation.fix": "Correggi problemi",
|
||||
"translationValidation.fixing": "Correzione…",
|
||||
"translationValidation.toast.fixSuccess": "{dbRows} righe DB e {files} file eliminati, {flushed} traduzioni scritte su disco",
|
||||
"translationValidation.error.fix": "Correzione delle traduzioni non valide fallita",
|
||||
"menuEditor.tabTitle": "Menu blog",
|
||||
"menuEditor.title": "Editor del menu blog",
|
||||
"menuEditor.description": "Gestisci la struttura centrale di navigazione del blog e salvala in meta/menu.opml.",
|
||||
@@ -419,18 +446,18 @@
|
||||
"metadataDiff.orphanFiles.badge": "File orfano",
|
||||
"metadataDiff.orphanFiles.slug": "Slug",
|
||||
"metadataDiff.orphanFiles.path": "Percorso",
|
||||
"metadataDiff.orphanFiles.importButton": "D \u2192 DB",
|
||||
"metadataDiff.orphanFiles.importButton": "D → DB",
|
||||
"metadataDiff.orphanFiles.importTitle": "Importa tutti i file orfani nel database",
|
||||
"metadataDiff.orphanFiles.importing": "Importazione…",
|
||||
"metadataDiff.orphanFiles.importSuccess": "{success} file orfani importati{failed}",
|
||||
"metadataDiff.orphanFiles.importError": "Impossibile importare i file orfani",
|
||||
"metadataDiff.sync.failed": "fallito",
|
||||
"metadataDiff.sync.dbToFile.title": "Aggiorna i file con i valori del database",
|
||||
"metadataDiff.sync.dbToFile.short": "DB\u2192F",
|
||||
"metadataDiff.sync.dbToFile.short": "DB→F",
|
||||
"metadataDiff.sync.dbToFile.success": "Sincronizzati {success} post nei file{fallito}",
|
||||
"metadataDiff.sync.dbToFile.error": "Impossibile sincronizzare nei file",
|
||||
"metadataDiff.sync.fileToDb.title": "Aggiorna il database con i valori dei file",
|
||||
"metadataDiff.sync.fileToDb.short": "F\u2192DB",
|
||||
"metadataDiff.sync.fileToDb.short": "F→DB",
|
||||
"metadataDiff.sync.fileToDb.success": "Sincronizzati {success} file nel database{fallito}",
|
||||
"metadataDiff.sync.fileToDb.error": "Impossibile sincronizzare nel database",
|
||||
"metadataDiff.value.database": "Database locale",
|
||||
@@ -461,6 +488,7 @@
|
||||
"sidebar.published": "Pubblicati",
|
||||
"sidebar.archived": "Archiviati",
|
||||
"sidebar.untitled": "Senza titolo",
|
||||
"sidebar.languagesAvailable": "{count} lingue disponibili",
|
||||
"sidebar.noMatchingPosts": "Nessun post corrispondente",
|
||||
"sidebar.createFirstPost": "Crea il tuo primo post",
|
||||
"sidebar.loadMore": "Carica altro ({loaded} di {total})",
|
||||
@@ -525,6 +553,8 @@
|
||||
"settings.project.publicUrlPlaceholder": "https://example.com",
|
||||
"settings.project.mainLanguageLabel": "Lingua principale",
|
||||
"settings.project.mainLanguageDescription": "Lingua principale dei contenuti del blog. Titoli, alt text e didascalie generate dall’IA useranno questa lingua.",
|
||||
"settings.project.blogLanguagesLabel": "Lingue del blog",
|
||||
"settings.project.blogLanguagesDescription": "Lingue in cui viene generato il blog. La lingua principale è sempre inclusa. Le lingue aggiuntive generano sottocartelle tradotte.",
|
||||
"settings.project.defaultAuthorLabel": "Autore predefinito",
|
||||
"settings.project.defaultAuthorDescription": "Nome autore predefinito per nuovi post e media. Può essere modificato per singolo elemento.",
|
||||
"settings.project.defaultAuthorPlaceholder": "Nome autore",
|
||||
@@ -578,6 +608,27 @@
|
||||
"editor.previewFrameTitle": "Anteprima post",
|
||||
"editor.previewLoading": "Caricamento anteprima...",
|
||||
"editor.metadata.toggle": "Metadati",
|
||||
"editor.translations.title": "Traduzioni",
|
||||
"editor.translations.currentLanguage": "Lingua corrente: {language}",
|
||||
"editor.translations.none": "Nessuna traduzione disponibile.",
|
||||
"editor.translations.selectTarget": "Seleziona lingua di destinazione",
|
||||
"editor.translations.translateButton": "Traduci in...",
|
||||
"editor.translations.translateTitle": "Crea o aggiorna una traduzione con l'IA",
|
||||
"editor.translations.translating": "Traduzione in corso...",
|
||||
"editor.translations.refresh": "Aggiorna",
|
||||
"editor.translations.refreshTitle": "Rigenera questa traduzione con l'IA",
|
||||
"editor.translations.publish": "Pubblica",
|
||||
"editor.translations.publishTitle": "Pubblica questa traduzione nel suo file Markdown",
|
||||
"editor.translations.publishing": "Pubblicazione...",
|
||||
"editor.translations.missing": "Mancanti: {languages}",
|
||||
"editor.translations.complete": "Tutte le lingue di traduzione supportate sono disponibili.",
|
||||
"editor.translations.translateSuccess": "Traduzione aggiornata per {language}",
|
||||
"editor.translations.translateFailed": "Traduzione non riuscita",
|
||||
"editor.translations.publishSuccess": "Traduzione pubblicata per {language}",
|
||||
"editor.translations.publishFailed": "Pubblicazione della traduzione non riuscita",
|
||||
"editor.translations.status.draft": "Bozza",
|
||||
"editor.translations.status.published": "Pubblicato",
|
||||
"editor.translations.status.archived": "Archiviato",
|
||||
"editor.excerpt.toggle": "Estratto",
|
||||
"editor.footer.created": "Creato",
|
||||
"editor.footer.updated": "Aggiornato",
|
||||
@@ -927,6 +978,12 @@
|
||||
"editor.media.quickActions.button": "✨ Analizza con IA",
|
||||
"editor.media.quickActions.aiTitle": "Titolo suggerito dall’IA",
|
||||
"editor.media.quickActions.aiDescription": "Genera automaticamente titolo, testo alternativo e didascalia.",
|
||||
"editor.media.quickActions.detectLanguageTitle": "Rileva lingua",
|
||||
"editor.media.quickActions.detectLanguageDescription": "Rileva la lingua dai metadati con l’IA",
|
||||
"editor.media.quickActions.translateTitle": "Traduci in…",
|
||||
"editor.media.quickActions.translateDescription": "Crea o aggiorna una traduzione con l’IA",
|
||||
"editor.media.translations.currentLanguage": "Lingua corrente: {language}",
|
||||
"editor.media.translations.selectTarget": "Seleziona la lingua di destinazione",
|
||||
"editor.post.quickActions.title": "Azioni rapide",
|
||||
"editor.post.quickActions.analyzing": "⏳ Analisi…",
|
||||
"editor.post.quickActions.button": "⚡ Azioni rapide",
|
||||
@@ -949,6 +1006,24 @@
|
||||
"editor.media.field.caption": "Didascalia",
|
||||
"editor.media.field.tags": "Tag",
|
||||
"editor.media.field.author": "Autore",
|
||||
"editor.media.field.language": "Lingua",
|
||||
"editor.media.field.languageNone": "Non impostata",
|
||||
"editor.media.translations.title": "Traduzioni",
|
||||
"editor.media.translations.none": "Nessuna traduzione ancora.",
|
||||
"editor.media.translations.translateButton": "Traduci in…",
|
||||
"editor.media.translations.translating": "Traduzione…",
|
||||
"editor.media.translations.translateSuccess": "Traduzione aggiornata per {language}",
|
||||
"editor.media.translations.translateFailed": "Traduzione fallita",
|
||||
"editor.media.translations.refresh": "Aggiorna",
|
||||
"editor.media.translations.refreshTitle": "Rigenera questa traduzione tramite IA",
|
||||
"editor.media.translations.deleteTitle": "Elimina questa traduzione",
|
||||
"editor.media.translations.deleted": "Traduzione eliminata per {language}",
|
||||
"editor.media.translations.deleteFailed": "Eliminazione della traduzione fallita",
|
||||
"editor.media.translations.editTitle": "Modifica traduzione — {language}",
|
||||
"editor.media.translations.saved": "Traduzione salvata per {language}",
|
||||
"editor.media.translations.saveFailed": "Salvataggio della traduzione fallito",
|
||||
"editor.media.toast.languageDetected": "Lingua rilevata: {language}",
|
||||
"editor.media.error.detectLanguage": "Rilevamento della lingua fallito",
|
||||
"editor.media.placeholder.title": "Inserisci un titolo",
|
||||
"editor.media.placeholder.altText": "Descrivi l’immagine per l’accessibilità",
|
||||
"editor.media.placeholder.caption": "Aggiungi una didascalia",
|
||||
@@ -1130,5 +1205,9 @@
|
||||
"duplicatesView.checkAll": "Seleziona tutto",
|
||||
"duplicatesView.uncheckAll": "Deseleziona tutto",
|
||||
"duplicatesView.dismissChecked": "Ignora selezionati ({count})",
|
||||
"duplicatesView.notEnabled": "La similarità semantica non è abilitata. Abilitala in Impostazioni → Tecnologia."
|
||||
"duplicatesView.notEnabled": "La similarità semantica non è abilitata. Abilitala in Impostazioni → Tecnologia.",
|
||||
"editor.doNotTranslateLabel": "Non tradurre",
|
||||
"blog.fillMissing.nothingToDo": "Tutte le traduzioni sono aggiornate.",
|
||||
"blog.fillMissing.started": "Attività di traduzione avviata. Controlla il pannello attività per il progresso.",
|
||||
"blog.fillMissing.error": "Impossibile completare le traduzioni mancanti."
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ export type EditorRoute =
|
||||
| 'documentation'
|
||||
| 'api-documentation'
|
||||
| 'site-validation'
|
||||
| 'translation-validation'
|
||||
| 'scripts'
|
||||
| 'templates'
|
||||
| 'find-duplicates';
|
||||
@@ -34,6 +35,7 @@ export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'da
|
||||
documentation: 'documentation',
|
||||
'api-documentation': 'api-documentation',
|
||||
'site-validation': 'site-validation',
|
||||
'translation-validation': 'translation-validation',
|
||||
scripts: 'scripts',
|
||||
templates: 'templates',
|
||||
'find-duplicates': 'find-duplicates',
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface CreateAndFocusPostOptions {
|
||||
setSelectedPost: (postId: string) => void;
|
||||
ensurePostsSidebar?: () => void;
|
||||
onError?: (error: unknown) => void;
|
||||
categories?: string[];
|
||||
}
|
||||
|
||||
export async function createAndFocusPost(options: CreateAndFocusPostOptions): Promise<string | null> {
|
||||
@@ -16,7 +17,7 @@ export async function createAndFocusPost(options: CreateAndFocusPostOptions): Pr
|
||||
title: '',
|
||||
content: '',
|
||||
tags: [],
|
||||
categories: [],
|
||||
categories: options.categories ?? [],
|
||||
});
|
||||
|
||||
if (!post) {
|
||||
|
||||
@@ -10,6 +10,7 @@ export type SingletonToolTabKey =
|
||||
| 'api-documentation'
|
||||
| 'metadata-diff'
|
||||
| 'site-validation'
|
||||
| 'translation-validation'
|
||||
| 'find-duplicates';
|
||||
|
||||
export interface CanonicalTabSpec {
|
||||
@@ -34,6 +35,7 @@ const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec>
|
||||
'api-documentation': { type: 'api-documentation', id: 'api-documentation', isTransient: false },
|
||||
'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false },
|
||||
'site-validation': { type: 'site-validation', id: 'site-validation', isTransient: false },
|
||||
'translation-validation': { type: 'translation-validation', id: 'translation-validation', isTransient: false },
|
||||
'find-duplicates': { type: 'find-duplicates', id: 'find-duplicates', isTransient: false },
|
||||
};
|
||||
|
||||
|
||||
24
src/renderer/navigation/translationValidationPersistence.ts
Normal file
24
src/renderer/navigation/translationValidationPersistence.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import type { TranslationValidationReport } from '../../main/shared/electronApi';
|
||||
|
||||
const TRANSLATION_VALIDATION_REPORT_PREFIX = 'bds-translation-validation-report';
|
||||
|
||||
function buildStorageKey(projectId: string): string {
|
||||
return `${TRANSLATION_VALIDATION_REPORT_PREFIX}:${projectId}`;
|
||||
}
|
||||
|
||||
export function persistTranslationValidationReport(projectId: string, report: TranslationValidationReport): void {
|
||||
localStorage.setItem(buildStorageKey(projectId), JSON.stringify(report));
|
||||
}
|
||||
|
||||
export function getPersistedTranslationValidationReport(projectId: string): TranslationValidationReport | null {
|
||||
const raw = localStorage.getItem(buildStorageKey(projectId));
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(raw) as TranslationValidationReport;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -13,7 +13,7 @@ import type {
|
||||
const STORAGE_KEY = 'bds-app-state';
|
||||
|
||||
// Tab types
|
||||
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'scripts' | 'templates' | 'find-duplicates';
|
||||
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'translation-validation' | 'scripts' | 'templates' | 'find-duplicates';
|
||||
|
||||
export interface Tab {
|
||||
type: TabType;
|
||||
|
||||
@@ -4,7 +4,8 @@ export const BDS_EVENT_TEMPLATES_CHANGED = 'bds:templates-changed' as const;
|
||||
export type BdsWindowEventName =
|
||||
| typeof BDS_EVENT_SCRIPTS_CHANGED
|
||||
| typeof BDS_EVENT_TEMPLATES_CHANGED
|
||||
| 'bds:site-validation-updated';
|
||||
| 'bds:site-validation-updated'
|
||||
| 'bds:translation-validation-updated';
|
||||
|
||||
export function addWindowEventListener<TDetail = unknown>(
|
||||
eventName: BdsWindowEventName,
|
||||
|
||||
@@ -2,9 +2,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { mkdtemp, readFile, rm, readdir, stat, mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||
import type { PostData, PostTranslationData } from '../../src/main/engine/PostEngine';
|
||||
import { resolveUiLanguageFromSystemLocale } from '../../src/main/shared/i18n';
|
||||
import type { MenuDocument } from '../../src/main/engine/MenuEngine';
|
||||
import { createPreviewBackedGenerationRouteRenderer } from '../../src/main/engine/GenerationRouteRendererFactory';
|
||||
|
||||
const generatedFileHashes = new Map<string, string>();
|
||||
const generatedFileUpdatedAt = new Map<string, number>();
|
||||
@@ -72,6 +73,8 @@ vi.mock('../../src/main/engine/PostEngine', async (importOriginal) => {
|
||||
getPostsFiltered: vi.fn(async () => []),
|
||||
getPublishedVersion: vi.fn(async () => null),
|
||||
getPost: vi.fn(async () => null),
|
||||
getPostTranslation: vi.fn(async () => null),
|
||||
getPostTranslations: vi.fn(async () => []),
|
||||
setProjectContext: vi.fn(),
|
||||
};
|
||||
return {
|
||||
@@ -115,11 +118,13 @@ function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||
content: overrides.content ?? '# Test\n\nBody text',
|
||||
status: overrides.status ?? 'published',
|
||||
author: overrides.author,
|
||||
language: overrides.language,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
publishedAt: overrides.publishedAt ?? createdAt,
|
||||
tags: overrides.tags ?? [],
|
||||
categories: overrides.categories ?? [],
|
||||
availableLanguages: overrides.availableLanguages ?? (overrides.language ? [overrides.language] : []),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -197,6 +202,8 @@ describe('BlogGenerationEngine', () => {
|
||||
mockPostEngine.getPost.mockImplementation(async (id: string) => {
|
||||
return posts.find((p) => p.id === id) ?? null;
|
||||
});
|
||||
mockPostEngine.getPostTranslation.mockResolvedValue(null);
|
||||
mockPostEngine.getPostTranslations.mockResolvedValue([]);
|
||||
}
|
||||
|
||||
async function generate(
|
||||
@@ -682,11 +689,122 @@ describe('BlogGenerationEngine', () => {
|
||||
const monthArchivePath = path.join(tempDir, 'html', '2020', '02', 'index.html');
|
||||
const monthHtml = await readFile(monthArchivePath, 'utf-8');
|
||||
|
||||
expect(monthHtml).toContain('<html lang="fr">');
|
||||
expect(monthHtml).toContain('<html lang="fr"');
|
||||
expect(monthHtml).toContain('<h1 class="archive-heading">Archives février 2020</h1>');
|
||||
expect(monthHtml).not.toContain('<h1 class="archive-heading">Archiv Februar 2020</h1>');
|
||||
});
|
||||
|
||||
it('renders canonical single-post route with project main language content when available', async () => {
|
||||
const canonicalPost = makePost({
|
||||
id: 'post-1',
|
||||
slug: 'hello-world',
|
||||
title: 'Hello World',
|
||||
content: '# Hello World\n\nCanonical body',
|
||||
language: 'en',
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
});
|
||||
|
||||
setupPosts([canonicalPost]);
|
||||
mockPostEngine.getPostTranslation.mockImplementation(async (postId: string, language: string) => {
|
||||
if (postId === 'post-1' && language === 'fr') {
|
||||
return {
|
||||
id: 'translation-1-fr',
|
||||
projectId: 'default',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Bonjour le monde',
|
||||
excerpt: 'Resume FR',
|
||||
content: '# Bonjour le monde\n\nCorps FR',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-01-15T10:05:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:05:00Z'),
|
||||
publishedAt: new Date('2025-01-15T10:06:00Z'),
|
||||
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'fr',
|
||||
}, vi.fn());
|
||||
|
||||
const canonicalHtml = await readFile(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world', 'index.html'), 'utf-8');
|
||||
|
||||
expect(mockPostEngine.getPostTranslation).toHaveBeenCalledWith('post-1', 'fr');
|
||||
expect(canonicalHtml).toContain('<html lang="fr"');
|
||||
expect(canonicalHtml).toContain('Bonjour le monde');
|
||||
expect(canonicalHtml).toContain('Corps FR');
|
||||
expect(canonicalHtml).not.toContain('Canonical body');
|
||||
});
|
||||
|
||||
it('preview-backed generation route renderer prefers project main language content on canonical single-post routes', async () => {
|
||||
const canonicalPost = makePost({
|
||||
id: 'post-1',
|
||||
slug: 'hello-world',
|
||||
title: 'Hello World',
|
||||
content: '# Hello World\n\nCanonical body',
|
||||
language: 'en',
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
});
|
||||
|
||||
const renderRoute = createPreviewBackedGenerationRouteRenderer({
|
||||
options: {
|
||||
projectId: 'test',
|
||||
dataDir: tempDir,
|
||||
projectName: 'Test Blog',
|
||||
language: 'fr',
|
||||
},
|
||||
maxPostsPerPage: 50,
|
||||
publishedPostsForLookup: [canonicalPost],
|
||||
engines: {
|
||||
postEngine: {
|
||||
getPostsFiltered: mockPostEngine.getPostsFiltered,
|
||||
getPublishedVersion: mockPostEngine.getPublishedVersion,
|
||||
getPost: mockPostEngine.getPost,
|
||||
getPostTranslation: vi.fn(async (postId: string, language: string) => {
|
||||
if (postId === 'post-1' && language === 'fr') {
|
||||
return {
|
||||
id: 'translation-1-fr',
|
||||
projectId: 'default',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Bonjour le monde',
|
||||
excerpt: 'Resume FR',
|
||||
content: '# Bonjour le monde\n\nCorps FR',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-01-15T10:05:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:05:00Z'),
|
||||
publishedAt: new Date('2025-01-15T10:06:00Z'),
|
||||
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
|
||||
} satisfies PostTranslationData;
|
||||
}
|
||||
return null;
|
||||
}),
|
||||
hasPublishedVersion: mockPostEngine.hasPublishedVersion,
|
||||
setProjectContext: mockPostEngine.setProjectContext,
|
||||
},
|
||||
mediaEngine: mockMediaEngine,
|
||||
postMediaEngine: mockPostMediaEngine,
|
||||
},
|
||||
});
|
||||
|
||||
const html = await renderRoute('/2025/01/15/hello-world');
|
||||
|
||||
expect(html).not.toBeNull();
|
||||
expect(html).toContain('<html lang="fr"');
|
||||
expect(html).toContain('Bonjour le monde');
|
||||
expect(html).toContain('Corps FR');
|
||||
expect(html).not.toContain('Canonical body');
|
||||
});
|
||||
|
||||
it('excludes draft-only posts from generated pages', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'published', title: 'Published', status: 'published' }),
|
||||
@@ -1221,6 +1339,121 @@ describe('BlogGenerationEngine', () => {
|
||||
expect(sitemap).toContain('<loc>https://example.com/page/2/</loc>');
|
||||
});
|
||||
|
||||
it('generates published translation pages with alternate links and sitemap entries', async () => {
|
||||
const sourcePost = makePost({
|
||||
id: '1',
|
||||
slug: 'hello-world',
|
||||
title: 'Hello World',
|
||||
content: '# Hello World\n\nEnglish body',
|
||||
language: 'en',
|
||||
availableLanguages: ['en', 'fr'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:00:00Z'),
|
||||
});
|
||||
const translationsByPostId = new Map<string, PostTranslationData[]>([
|
||||
['1', [{
|
||||
id: 'translation-1-fr',
|
||||
projectId: 'default',
|
||||
translationFor: '1',
|
||||
language: 'fr',
|
||||
title: 'Bonjour le monde',
|
||||
excerpt: 'Resume FR',
|
||||
content: '# Bonjour le monde\n\nCorps FR',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-01-15T10:05:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:05:00Z'),
|
||||
publishedAt: new Date('2025-01-15T10:06:00Z'),
|
||||
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
|
||||
}]],
|
||||
]);
|
||||
|
||||
setupPosts([sourcePost]);
|
||||
mockPostEngine.getPostTranslations.mockImplementation(async (postId: string) => translationsByPostId.get(postId) ?? []);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
}, vi.fn());
|
||||
|
||||
const canonicalHtml = await readFile(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world', 'index.html'), 'utf-8');
|
||||
const translationHtml = await readFile(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world.fr', 'index.html'), 'utf-8');
|
||||
const sitemap = await readFile(path.join(tempDir, 'html', 'sitemap.xml'), 'utf-8');
|
||||
|
||||
expect(canonicalHtml).toContain('hreflang="fr"');
|
||||
expect(canonicalHtml).toContain('href="/2025/01/15/hello-world.fr"');
|
||||
expect(translationHtml).toContain('<html lang="fr"');
|
||||
expect(translationHtml).toContain('Bonjour le monde');
|
||||
expect(sitemap).toContain('<loc>https://example.com/2025/01/15/hello-world/</loc>');
|
||||
expect(sitemap).toContain('<loc>https://example.com/2025/01/15/hello-world.fr/</loc>');
|
||||
});
|
||||
|
||||
it('preserves post engine method binding when loading published translations', async () => {
|
||||
const sourcePost = makePost({
|
||||
id: '1',
|
||||
slug: 'hello-world',
|
||||
title: 'Hello World',
|
||||
content: '# Hello World\n\nEnglish body',
|
||||
language: 'en',
|
||||
availableLanguages: ['en', 'fr'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:00:00Z'),
|
||||
});
|
||||
const translationsByPostId = new Map<string, PostTranslationData[]>([
|
||||
['1', [{
|
||||
id: 'translation-1-fr',
|
||||
projectId: 'default',
|
||||
translationFor: '1',
|
||||
language: 'fr',
|
||||
title: 'Bonjour le monde',
|
||||
excerpt: 'Resume FR',
|
||||
content: '# Bonjour le monde\n\nCorps FR',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-01-15T10:05:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:05:00Z'),
|
||||
publishedAt: new Date('2025-01-15T10:06:00Z'),
|
||||
filePath: path.join(tempDir, 'posts', 'hello-world.fr.md'),
|
||||
}]],
|
||||
]);
|
||||
|
||||
const postEngine = {
|
||||
translationsByPostId,
|
||||
setProjectContext: vi.fn(),
|
||||
async getPostsFiltered(filter: { status?: string }) {
|
||||
return filter.status === 'published' ? [sourcePost] : [];
|
||||
},
|
||||
async getPublishedVersion() {
|
||||
return null;
|
||||
},
|
||||
async getPost(postId: string) {
|
||||
return postId === sourcePost.id ? sourcePost : null;
|
||||
},
|
||||
async getPostTranslations(postId: string) {
|
||||
return this.translationsByPostId.get(postId) ?? [];
|
||||
},
|
||||
};
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(postEngine as any, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await expect(engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
}, vi.fn())).resolves.toMatchObject({
|
||||
postCount: 1,
|
||||
});
|
||||
|
||||
expect(await fileExists(path.join(tempDir, 'html', '2025', '01', '15', 'hello-world.fr', 'index.html'))).toBe(true);
|
||||
});
|
||||
|
||||
it('applies validation by generating only missing category and tag routes', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'ordered-post', categories: ['news'], tags: ['ordered-tag'], createdAt: new Date('2025-01-15T10:00:00Z') }),
|
||||
@@ -1617,6 +1850,49 @@ describe('BlogGenerationEngine', () => {
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'hello-world', 'index.html'))).toBe(false);
|
||||
});
|
||||
|
||||
it('generates translated static page routes for published page translations', async () => {
|
||||
const pagePost = makePost({
|
||||
id: 'page-1',
|
||||
slug: 'tag-cloud',
|
||||
title: 'Tag Cloud',
|
||||
categories: ['page'],
|
||||
language: 'en',
|
||||
availableLanguages: ['en', 'de'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:00:00Z'),
|
||||
});
|
||||
|
||||
setupPosts([pagePost]);
|
||||
mockPostEngine.getPostTranslations.mockResolvedValue([{
|
||||
id: 'translation-page-1-de',
|
||||
projectId: 'default',
|
||||
translationFor: 'page-1',
|
||||
language: 'de',
|
||||
title: 'Schlagwortwolke',
|
||||
excerpt: 'Zusammenfassung DE',
|
||||
content: '# Schlagwortwolke\n\nInhalt DE',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-01-15T10:05:00Z'),
|
||||
updatedAt: new Date('2025-01-15T10:05:00Z'),
|
||||
publishedAt: new Date('2025-01-15T10:06:00Z'),
|
||||
filePath: path.join(tempDir, 'posts', 'tag-cloud.de.md'),
|
||||
}]);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
}, vi.fn());
|
||||
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'tag-cloud', 'index.html'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'tag-cloud.de', 'index.html'))).toBe(true);
|
||||
});
|
||||
|
||||
it('generates canonical post routes only and does not generate aliases', async () => {
|
||||
const posts = [
|
||||
makePost({ id: '1', slug: 'alias-test', createdAt: new Date('2025-03-15T10:00:00Z') }),
|
||||
@@ -1705,6 +1981,167 @@ describe('BlogGenerationEngine', () => {
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'media'))).toBe(false);
|
||||
});
|
||||
|
||||
it('validateSite reports missing language subtree pages and does not flag them as extra', async () => {
|
||||
const posts = [
|
||||
makePost({
|
||||
id: '1',
|
||||
slug: 'lang-post',
|
||||
title: 'Language Post',
|
||||
categories: ['news'],
|
||||
tags: ['lang-tag'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
}),
|
||||
];
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
// Generate only main language pages
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
}, vi.fn());
|
||||
|
||||
// Validate with blogLanguages including fr - should report missing fr pages
|
||||
const report = await engine.validateSite({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
}, vi.fn());
|
||||
|
||||
expect(report.missingUrlPaths).toContain('/fr');
|
||||
expect(report.missingUrlPaths).toContain('/fr/2025/01/15/lang-post');
|
||||
expect(report.missingUrlPaths).toContain('/fr/category/news');
|
||||
expect(report.missingUrlPaths).toContain('/fr/tag/lang-tag');
|
||||
expect(report.extraUrlPaths).not.toContain('/fr');
|
||||
});
|
||||
|
||||
it('validateSite reports no missing language pages after full multi-language generation', async () => {
|
||||
const posts = [
|
||||
makePost({
|
||||
id: '1',
|
||||
slug: 'multilang-post',
|
||||
title: 'Multi Lang Post',
|
||||
categories: ['news'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
}),
|
||||
];
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
}, vi.fn());
|
||||
|
||||
const report = await engine.validateSite({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
}, vi.fn());
|
||||
|
||||
expect(report.missingUrlPaths).toEqual([]);
|
||||
expect(report.extraUrlPaths).toEqual([]);
|
||||
});
|
||||
|
||||
it('applyValidation renders missing language subtree pages', async () => {
|
||||
const posts = [
|
||||
makePost({
|
||||
id: '1',
|
||||
slug: 'apply-lang-post',
|
||||
title: 'Apply Lang Post',
|
||||
categories: ['news'],
|
||||
tags: ['apply-lang-tag'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
}),
|
||||
];
|
||||
setupPosts(posts);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
const result = await engine.applyValidation({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
}, {
|
||||
sitemapPath: path.join(tempDir, 'html', 'sitemap.xml'),
|
||||
sitemapChanged: false,
|
||||
missingUrlPaths: ['/fr/category/news', '/fr/tag/apply-lang-tag'],
|
||||
extraUrlPaths: [],
|
||||
updatedPostUrlPaths: [],
|
||||
expectedUrlCount: 2,
|
||||
existingHtmlUrlCount: 0,
|
||||
}, vi.fn());
|
||||
|
||||
expect(result.renderedUrlCount).toBeGreaterThan(0);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'fr', 'category', 'news', 'index.html'))).toBe(true);
|
||||
expect(await fileExists(path.join(tempDir, 'html', 'fr', 'tag', 'apply-lang-tag', 'index.html'))).toBe(true);
|
||||
});
|
||||
|
||||
it('validateSite excludes doNotTranslate posts from language subtree expected urls', async () => {
|
||||
const translatablePost = makePost({
|
||||
id: '1',
|
||||
slug: 'translatable',
|
||||
title: 'Translatable',
|
||||
categories: ['news'],
|
||||
createdAt: new Date('2025-01-15T10:00:00Z'),
|
||||
});
|
||||
const dntPost = makePost({
|
||||
id: '2',
|
||||
slug: 'no-translate',
|
||||
title: 'Do Not Translate',
|
||||
categories: ['news'],
|
||||
createdAt: new Date('2025-01-16T10:00:00Z'),
|
||||
doNotTranslate: true,
|
||||
} as any);
|
||||
setupPosts([translatablePost, dntPost]);
|
||||
|
||||
const { BlogGenerationEngine } = await import('../../src/main/engine/BlogGenerationEngine');
|
||||
const engine = new BlogGenerationEngine(mockPostEngine, mockMediaEngine, mockPostMediaEngine);
|
||||
|
||||
await engine.generate({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
}, vi.fn());
|
||||
|
||||
const report = await engine.validateSite({
|
||||
projectId: 'test',
|
||||
projectName: 'Test Blog',
|
||||
dataDir: tempDir,
|
||||
baseUrl: 'https://example.com',
|
||||
language: 'en',
|
||||
blogLanguages: ['en', 'fr'],
|
||||
}, vi.fn());
|
||||
|
||||
expect(report.missingUrlPaths).toEqual([]);
|
||||
// The dnt post's single page should NOT be expected in /fr/ subtree
|
||||
expect(report.extraUrlPaths).not.toContain('/fr/2025/01/16/no-translate');
|
||||
});
|
||||
|
||||
it('generates zero pages when there are no published posts', async () => {
|
||||
const result = await generate([]);
|
||||
expect(result.pagesGenerated).toBe(0);
|
||||
|
||||
@@ -27,6 +27,8 @@ function createMockPostEngine() {
|
||||
getLinksTo: vi.fn().mockResolvedValue([]),
|
||||
getPostsFiltered: vi.fn().mockResolvedValue([]),
|
||||
getPostCounts: vi.fn().mockResolvedValue({ groups: [], totalPosts: 0 }),
|
||||
getPostTranslation: vi.fn().mockResolvedValue(null),
|
||||
getPostTranslations: vi.fn().mockResolvedValue([]),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1198,6 +1200,81 @@ describe('MCPServer', () => {
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toContain('not found');
|
||||
});
|
||||
|
||||
it('read_post_by_slug with language returns translation content', async () => {
|
||||
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', content: 'Deutscher Inhalt', language: 'de', status: 'published', tags: [], categories: [], availableLanguages: ['de', 'en'], createdAt: new Date(), updatedAt: new Date() };
|
||||
const translation = { id: 't1', translationFor: 'p1', language: 'en', title: 'Hello World', content: 'English content', excerpt: 'English excerpt', status: 'published', createdAt: new Date(), updatedAt: new Date() };
|
||||
mockPostEngine.getPostBySlug.mockResolvedValue(post);
|
||||
mockPostEngine.getPostTranslation.mockResolvedValue(translation);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([]);
|
||||
mockPostEngine.getLinksTo.mockResolvedValue([]);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'read_post_by_slug');
|
||||
const result = await tool.handler({ slug: 'hallo-welt', language: 'en' }, {}) as { content: Array<{ text: string }> };
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.post.title).toBe('Hello World');
|
||||
expect(parsed.post.content).toBe('English content');
|
||||
expect(mockPostEngine.getPostTranslation).toHaveBeenCalledWith('p1', 'en');
|
||||
});
|
||||
|
||||
it('read_post_by_slug with canonical language returns original post', async () => {
|
||||
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', content: 'Deutscher Inhalt', language: 'de', status: 'published', tags: [], categories: [], availableLanguages: ['de', 'en'], createdAt: new Date(), updatedAt: new Date() };
|
||||
mockPostEngine.getPostBySlug.mockResolvedValue(post);
|
||||
mockPostEngine.getLinkedBy.mockResolvedValue([]);
|
||||
mockPostEngine.getLinksTo.mockResolvedValue([]);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'read_post_by_slug');
|
||||
const result = await tool.handler({ slug: 'hallo-welt', language: 'de' }, {}) as { content: Array<{ text: string }> };
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.post.title).toBe('Hallo Welt');
|
||||
expect(parsed.post.content).toBe('Deutscher Inhalt');
|
||||
expect(mockPostEngine.getPostTranslation).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('read_post_by_slug returns error when translation not found', async () => {
|
||||
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', content: 'Deutsch', language: 'de', status: 'published', tags: [], categories: [], availableLanguages: ['de'], createdAt: new Date(), updatedAt: new Date() };
|
||||
mockPostEngine.getPostBySlug.mockResolvedValue(post);
|
||||
mockPostEngine.getPostTranslation.mockResolvedValue(null);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'read_post_by_slug');
|
||||
const result = await tool.handler({ slug: 'hallo-welt', language: 'fr' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.error).toContain('fr');
|
||||
});
|
||||
|
||||
// ── get_post_translations tool ──────────────────────────────────
|
||||
|
||||
it('registers get_post_translations tool', () => {
|
||||
const mcpServer = server.createMcpServer();
|
||||
expect(hasRegistered(mcpServer, '_registeredTools', 'get_post_translations')).toBe(true);
|
||||
});
|
||||
|
||||
it('get_post_translations returns all translations for a post', async () => {
|
||||
const post = { id: 'p1', title: 'Hallo Welt', slug: 'hallo-welt', language: 'de', status: 'published', tags: [], categories: [], createdAt: new Date(), updatedAt: new Date() };
|
||||
const translations = [
|
||||
{ id: 't1', translationFor: 'p1', language: 'en', title: 'Hello World', content: 'English', status: 'published', createdAt: new Date(), updatedAt: new Date() },
|
||||
{ id: 't2', translationFor: 'p1', language: 'fr', title: 'Bonjour le Monde', content: 'French', status: 'published', createdAt: new Date(), updatedAt: new Date() },
|
||||
];
|
||||
mockPostEngine.getPostBySlug.mockResolvedValue(post);
|
||||
mockPostEngine.getPostTranslations.mockResolvedValue(translations);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'get_post_translations');
|
||||
const result = await tool.handler({ slug: 'hallo-welt' }, {}) as { content: Array<{ text: string }> };
|
||||
const parsed = JSON.parse(result.content[0].text);
|
||||
expect(parsed.translations).toHaveLength(2);
|
||||
expect(parsed.translations[0].language).toBe('en');
|
||||
expect(parsed.translations[1].language).toBe('fr');
|
||||
expect(mockPostEngine.getPostTranslations).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('get_post_translations returns error for nonexistent slug', async () => {
|
||||
mockPostEngine.getPostBySlug.mockResolvedValue(null);
|
||||
const mcpServer = server.createMcpServer();
|
||||
const tool = getTool(mcpServer, 'get_post_translations');
|
||||
const result = await tool.handler({ slug: 'no-such-slug' }, {}) as { content: Array<{ text: string }>; isError?: boolean };
|
||||
expect(result.isError).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Prompt handler behavior ────────────────────────────────────────
|
||||
|
||||
491
tests/engine/MediaTranslationSystem.test.ts
Normal file
491
tests/engine/MediaTranslationSystem.test.ts
Normal file
@@ -0,0 +1,491 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { MediaEngine } from '../../src/main/engine/MediaEngine';
|
||||
import { media, mediaTranslations } from '../../src/main/database/schema';
|
||||
|
||||
const mockMedia = new Map<string, any>();
|
||||
const mockTranslations = new Map<string, any>();
|
||||
const mockFiles = new Map<string, string>();
|
||||
|
||||
function resetData(): void {
|
||||
mockMedia.clear();
|
||||
mockTranslations.clear();
|
||||
mockFiles.clear();
|
||||
}
|
||||
|
||||
function getTableRows(table: unknown): any[] {
|
||||
if (table === media) {
|
||||
return Array.from(mockMedia.values());
|
||||
}
|
||||
if (table === mediaTranslations) {
|
||||
return Array.from(mockTranslations.values());
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function extractEqValue(predicate: unknown): string | undefined {
|
||||
const chunks = (predicate as any)?.queryChunks;
|
||||
if (!Array.isArray(chunks)) return undefined;
|
||||
for (const chunk of chunks) {
|
||||
if (chunk?.value !== undefined && typeof chunk.value === 'string') {
|
||||
return chunk.value;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function createSelectChain() {
|
||||
let selectedTable: unknown;
|
||||
let filterValue: string | undefined;
|
||||
|
||||
return {
|
||||
from: vi.fn().mockImplementation(function from(table: unknown) {
|
||||
selectedTable = table;
|
||||
return this;
|
||||
}),
|
||||
where: vi.fn().mockImplementation(function where(predicate: unknown) {
|
||||
filterValue = extractEqValue(predicate);
|
||||
return this;
|
||||
}),
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
limit: vi.fn().mockReturnThis(),
|
||||
offset: vi.fn().mockReturnThis(),
|
||||
all: vi.fn().mockImplementation(async () => {
|
||||
const rows = getTableRows(selectedTable);
|
||||
if (filterValue) {
|
||||
return rows.filter((row) =>
|
||||
row.id === filterValue ||
|
||||
row.translationFor === filterValue ||
|
||||
row.projectId === filterValue
|
||||
);
|
||||
}
|
||||
return rows;
|
||||
}),
|
||||
get: vi.fn().mockImplementation(async () => {
|
||||
const rows = getTableRows(selectedTable);
|
||||
if (filterValue) {
|
||||
return rows.find((row) =>
|
||||
row.id === filterValue ||
|
||||
row.translationFor === filterValue ||
|
||||
row.projectId === filterValue
|
||||
);
|
||||
}
|
||||
return rows[0];
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createInsertChain(table: unknown) {
|
||||
return {
|
||||
values: vi.fn(async (value: any) => {
|
||||
const rows = Array.isArray(value) ? value : [value];
|
||||
for (const row of rows) {
|
||||
if (table === media) {
|
||||
mockMedia.set(row.id, row);
|
||||
} else if (table === mediaTranslations) {
|
||||
mockTranslations.set(row.id, row);
|
||||
}
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
function createUpdateChain(table: unknown) {
|
||||
return {
|
||||
set: vi.fn().mockImplementation((value: Record<string, unknown>) => ({
|
||||
where: vi.fn(async (predicate: unknown) => {
|
||||
const targetMap = table === media ? mockMedia : table === mediaTranslations ? mockTranslations : null;
|
||||
if (!targetMap || targetMap.size === 0) return;
|
||||
const targetId = extractEqValue(predicate);
|
||||
if (targetId && targetMap.has(targetId)) {
|
||||
const existing = targetMap.get(targetId);
|
||||
targetMap.set(targetId, { ...existing, ...value });
|
||||
}
|
||||
}),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
function createDeleteChain(table: unknown) {
|
||||
return {
|
||||
where: vi.fn(async (predicate: unknown) => {
|
||||
const targetId = extractEqValue(predicate);
|
||||
if (targetId) {
|
||||
const targetMap = table === media ? mockMedia : table === mediaTranslations ? mockTranslations : null;
|
||||
if (targetMap) {
|
||||
// Try direct key match first
|
||||
if (targetMap.has(targetId)) {
|
||||
targetMap.delete(targetId);
|
||||
} else {
|
||||
// Filter by translationFor (cascade delete pattern)
|
||||
for (const [key, row] of targetMap.entries()) {
|
||||
if ((row as any).translationFor === targetId || (row as any).mediaId === targetId) {
|
||||
targetMap.delete(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
const mockLocalDb = {
|
||||
select: vi.fn(() => createSelectChain()),
|
||||
insert: vi.fn((table: unknown) => createInsertChain(table)),
|
||||
update: vi.fn((table: unknown) => createUpdateChain(table)),
|
||||
delete: vi.fn((table: unknown) => createDeleteChain(table)),
|
||||
};
|
||||
|
||||
const mockLocalClient = {
|
||||
execute: vi.fn(async () => ({ rows: [] })),
|
||||
};
|
||||
|
||||
vi.mock('../../src/main/database', () => ({
|
||||
getDatabase: vi.fn(() => ({
|
||||
getLocal: vi.fn(() => mockLocalDb),
|
||||
getLocalClient: vi.fn(() => mockLocalClient),
|
||||
})),
|
||||
}));
|
||||
|
||||
vi.mock('fs/promises', () => ({
|
||||
access: vi.fn(async (filePath: string) => {
|
||||
if (!mockFiles.has(filePath)) {
|
||||
const error = new Error('ENOENT');
|
||||
(error as NodeJS.ErrnoException).code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
}),
|
||||
mkdir: vi.fn(async () => {}),
|
||||
readFile: vi.fn(async (filePath: string) => {
|
||||
const content = mockFiles.get(filePath);
|
||||
if (content == null) {
|
||||
const error = new Error('ENOENT');
|
||||
(error as NodeJS.ErrnoException).code = 'ENOENT';
|
||||
throw error;
|
||||
}
|
||||
return content;
|
||||
}),
|
||||
readdir: vi.fn(async () => []),
|
||||
rename: vi.fn(async () => {}),
|
||||
unlink: vi.fn(async () => {}),
|
||||
writeFile: vi.fn(async (filePath: string, content: string) => {
|
||||
mockFiles.set(filePath, content);
|
||||
}),
|
||||
copyFile: vi.fn(async () => {}),
|
||||
stat: vi.fn(async () => ({ size: 1024 })),
|
||||
}));
|
||||
|
||||
vi.mock('uuid', () => {
|
||||
let counter = 1;
|
||||
return {
|
||||
v4: vi.fn(() => `uuid-${counter++}`),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('electron', () => ({
|
||||
app: {
|
||||
getPath: vi.fn(() => '/tmp/electron-test'),
|
||||
},
|
||||
}));
|
||||
|
||||
function seedMediaItem(overrides: Partial<any> = {}): any {
|
||||
const id = overrides.id || 'media-1';
|
||||
const item = {
|
||||
id,
|
||||
projectId: 'project-1',
|
||||
filename: `${id}.jpg`,
|
||||
originalName: 'photo.jpg',
|
||||
mimeType: 'image/jpeg',
|
||||
size: 1024,
|
||||
width: 800,
|
||||
height: 600,
|
||||
title: 'A photo',
|
||||
alt: 'Alt text',
|
||||
caption: 'Photo caption',
|
||||
author: 'Author',
|
||||
language: null,
|
||||
filePath: `/tmp/project-1/media/2024/01/${id}.jpg`,
|
||||
sidecarPath: `/tmp/project-1/media/2024/01/${id}.jpg.meta`,
|
||||
createdAt: new Date('2024-01-15T12:00:00Z'),
|
||||
updatedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
checksum: 'abc123',
|
||||
tags: '[]',
|
||||
...overrides,
|
||||
};
|
||||
mockMedia.set(id, item);
|
||||
return item;
|
||||
}
|
||||
|
||||
describe('Media translation system', () => {
|
||||
let engine: MediaEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetData();
|
||||
engine = new MediaEngine();
|
||||
engine.setProjectContext('project-1', '/tmp/project-1');
|
||||
});
|
||||
|
||||
describe('getMediaTranslation', () => {
|
||||
it('returns null when no translation exists', async () => {
|
||||
seedMediaItem();
|
||||
const result = await engine.getMediaTranslation('media-1', 'fr');
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('returns a translation when one exists', async () => {
|
||||
seedMediaItem();
|
||||
mockTranslations.set('trans-1', {
|
||||
id: 'trans-1',
|
||||
projectId: 'project-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'fr',
|
||||
title: 'Une photo',
|
||||
alt: 'Texte alt',
|
||||
caption: 'Légende photo',
|
||||
createdAt: new Date('2024-01-15T12:00:00Z'),
|
||||
updatedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const result = await engine.getMediaTranslation('media-1', 'fr');
|
||||
expect(result).toMatchObject({
|
||||
id: 'trans-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'fr',
|
||||
title: 'Une photo',
|
||||
alt: 'Texte alt',
|
||||
caption: 'Légende photo',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getMediaTranslations', () => {
|
||||
it('returns all translations for a media item', async () => {
|
||||
seedMediaItem();
|
||||
mockTranslations.set('trans-fr', {
|
||||
id: 'trans-fr',
|
||||
projectId: 'project-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'fr',
|
||||
title: 'Une photo',
|
||||
alt: null,
|
||||
caption: null,
|
||||
createdAt: new Date('2024-01-15T12:00:00Z'),
|
||||
updatedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
});
|
||||
mockTranslations.set('trans-de', {
|
||||
id: 'trans-de',
|
||||
projectId: 'project-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'de',
|
||||
title: 'Ein Foto',
|
||||
alt: null,
|
||||
caption: null,
|
||||
createdAt: new Date('2024-01-15T12:00:00Z'),
|
||||
updatedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const result = await engine.getMediaTranslations('media-1');
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map(t => t.language).sort()).toEqual(['de', 'fr']);
|
||||
});
|
||||
|
||||
it('returns empty array when no translations exist', async () => {
|
||||
seedMediaItem();
|
||||
const result = await engine.getMediaTranslations('media-1');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsertMediaTranslation', () => {
|
||||
it('creates a new translation', async () => {
|
||||
seedMediaItem({ language: 'en' });
|
||||
|
||||
const result = await engine.upsertMediaTranslation('media-1', 'fr', {
|
||||
title: 'Une photo',
|
||||
alt: 'Texte alt',
|
||||
caption: 'Légende photo',
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
translationFor: 'media-1',
|
||||
language: 'fr',
|
||||
title: 'Une photo',
|
||||
alt: 'Texte alt',
|
||||
caption: 'Légende photo',
|
||||
});
|
||||
expect(result.id).toBeTruthy();
|
||||
});
|
||||
|
||||
it('updates an existing translation instead of creating duplicates', async () => {
|
||||
seedMediaItem({ language: 'en' });
|
||||
|
||||
const first = await engine.upsertMediaTranslation('media-1', 'fr', {
|
||||
title: 'Titre v1',
|
||||
});
|
||||
const second = await engine.upsertMediaTranslation('media-1', 'fr', {
|
||||
title: 'Titre v2',
|
||||
alt: 'Alt v2',
|
||||
});
|
||||
|
||||
expect(second.id).toBe(first.id);
|
||||
const translations = await engine.getMediaTranslations('media-1');
|
||||
expect(translations).toHaveLength(1);
|
||||
expect(translations[0].title).toBe('Titre v2');
|
||||
});
|
||||
|
||||
it('rejects translations whose language matches the canonical media language', async () => {
|
||||
seedMediaItem({ language: 'de' });
|
||||
|
||||
await expect(
|
||||
engine.upsertMediaTranslation('media-1', 'DE', {
|
||||
title: 'Titel',
|
||||
})
|
||||
).rejects.toThrow('Translation language must differ from canonical media language');
|
||||
});
|
||||
|
||||
it('rejects translations for non-existent media', async () => {
|
||||
await expect(
|
||||
engine.upsertMediaTranslation('nonexistent', 'fr', { title: 'Test' })
|
||||
).rejects.toThrow('Media item not found');
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMediaTranslation', () => {
|
||||
it('deletes an existing translation', async () => {
|
||||
seedMediaItem();
|
||||
mockTranslations.set('trans-fr', {
|
||||
id: 'trans-fr',
|
||||
projectId: 'project-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'fr',
|
||||
title: 'Une photo',
|
||||
alt: null,
|
||||
caption: null,
|
||||
createdAt: new Date('2024-01-15T12:00:00Z'),
|
||||
updatedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const result = await engine.deleteMediaTranslation('media-1', 'fr');
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false when translation does not exist', async () => {
|
||||
seedMediaItem();
|
||||
const result = await engine.deleteMediaTranslation('media-1', 'fr');
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('availableLanguages on media', () => {
|
||||
it('includes canonical language and translation languages', async () => {
|
||||
seedMediaItem({ language: 'en' });
|
||||
mockTranslations.set('trans-fr', {
|
||||
id: 'trans-fr',
|
||||
projectId: 'project-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'fr',
|
||||
title: 'Une photo',
|
||||
alt: null,
|
||||
caption: null,
|
||||
createdAt: new Date('2024-01-15T12:00:00Z'),
|
||||
updatedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
const result = await engine.getMedia('media-1');
|
||||
expect(result?.availableLanguages).toEqual(['en', 'fr']);
|
||||
});
|
||||
|
||||
it('returns empty array when no language is set and no translations exist', async () => {
|
||||
seedMediaItem();
|
||||
const result = await engine.getMedia('media-1');
|
||||
expect(result?.availableLanguages).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('translated sidecar I/O', () => {
|
||||
it('writes a translated sidecar file with language suffix', async () => {
|
||||
seedMediaItem({ language: 'en' });
|
||||
await engine.upsertMediaTranslation('media-1', 'fr', {
|
||||
title: 'Une photo',
|
||||
alt: 'Texte alt',
|
||||
caption: 'Légende photo',
|
||||
});
|
||||
|
||||
// Verify a sidecar file was written at the .fr.meta path
|
||||
const sidecarPath = '/tmp/project-1/media/2024/01/media-1.jpg.fr.meta';
|
||||
expect(mockFiles.has(sidecarPath)).toBe(true);
|
||||
const content = mockFiles.get(sidecarPath)!;
|
||||
expect(content).toContain('language: fr');
|
||||
expect(content).toContain('title: "Une photo"');
|
||||
expect(content).toContain('alt: "Texte alt"');
|
||||
});
|
||||
|
||||
it('reads a translated sidecar file', async () => {
|
||||
const sidecarContent = [
|
||||
'---',
|
||||
'translationFor: media-1',
|
||||
'language: de',
|
||||
'title: "Ein Foto"',
|
||||
'alt: "Alt-Text"',
|
||||
'caption: "Bildunterschrift"',
|
||||
'---',
|
||||
].join('\n');
|
||||
mockFiles.set('/tmp/project-1/media/2024/01/media-1.jpg.de.meta', sidecarContent);
|
||||
|
||||
const result = await engine.readTranslatedSidecarFile(
|
||||
'/tmp/project-1/media/2024/01/media-1.jpg.de.meta'
|
||||
);
|
||||
expect(result).toMatchObject({
|
||||
translationFor: 'media-1',
|
||||
language: 'de',
|
||||
title: 'Ein Foto',
|
||||
alt: 'Alt-Text',
|
||||
caption: 'Bildunterschrift',
|
||||
});
|
||||
});
|
||||
|
||||
it('returns null for non-existent sidecar', async () => {
|
||||
const result = await engine.readTranslatedSidecarFile(
|
||||
'/tmp/nonexistent.fr.meta'
|
||||
);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('canonical sidecar includes language', () => {
|
||||
it('includes language field in sidecar when set on media', async () => {
|
||||
seedMediaItem({ language: 'en' });
|
||||
// Trigger a sidecar write via updateMedia
|
||||
await engine.updateMedia('media-1', { language: 'en' });
|
||||
|
||||
const sidecarPath = '/tmp/project-1/media/2024/01/media-1.jpg.meta';
|
||||
if (mockFiles.has(sidecarPath)) {
|
||||
const content = mockFiles.get(sidecarPath)!;
|
||||
expect(content).toContain('language: en');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('deleteMedia cascades to translations', () => {
|
||||
it('deletes all translations when media is deleted', async () => {
|
||||
seedMediaItem();
|
||||
mockTranslations.set('trans-fr', {
|
||||
id: 'trans-fr',
|
||||
projectId: 'project-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'fr',
|
||||
title: 'Une photo',
|
||||
alt: null,
|
||||
caption: null,
|
||||
createdAt: new Date('2024-01-15T12:00:00Z'),
|
||||
updatedAt: new Date('2024-01-15T12:00:00Z'),
|
||||
});
|
||||
|
||||
await engine.deleteMedia('media-1');
|
||||
|
||||
// Translations should be cleaned up
|
||||
expect(mockTranslations.size).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -123,12 +123,19 @@ vi.mock('gray-matter', () => ({
|
||||
const colonIndex = line.indexOf(':');
|
||||
if (colonIndex > 0) {
|
||||
const key = line.slice(0, colonIndex).trim();
|
||||
let value = line.slice(colonIndex + 1).trim();
|
||||
let value: any = line.slice(colonIndex + 1).trim();
|
||||
|
||||
// Parse arrays
|
||||
if (value.startsWith('[') && value.endsWith(']')) {
|
||||
value = JSON.parse(value.replace(/'/g, '"'));
|
||||
}
|
||||
// Parse booleans
|
||||
else if (value === 'true') {
|
||||
value = true;
|
||||
}
|
||||
else if (value === 'false') {
|
||||
value = false;
|
||||
}
|
||||
// Parse strings
|
||||
else if (value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.slice(1, -1);
|
||||
@@ -160,13 +167,17 @@ vi.mock('../../src/main/engine/TaskManager', () => ({
|
||||
|
||||
// Track the mock function for PostEngine.syncPublishedPostFile
|
||||
const mockSyncPublishedPostFile = vi.fn(async () => true);
|
||||
const mockSyncPublishedPostTranslationFile = vi.fn(async () => true);
|
||||
const mockImportOrphanFile = vi.fn(async () => ({ id: 'imported-id', title: 'Imported' }));
|
||||
const mockImportOrphanTranslationFile = vi.fn(async () => ({ id: 'imported-translation-id', title: 'Imported translation' }));
|
||||
|
||||
// Mock PostEngine
|
||||
vi.mock('../../src/main/engine/PostEngine', () => ({
|
||||
getPostEngine: vi.fn(() => ({
|
||||
syncPublishedPostFile: mockSyncPublishedPostFile,
|
||||
syncPublishedPostTranslationFile: mockSyncPublishedPostTranslationFile,
|
||||
importOrphanFile: mockImportOrphanFile,
|
||||
importOrphanTranslationFile: mockImportOrphanTranslationFile,
|
||||
})),
|
||||
}));
|
||||
|
||||
@@ -179,10 +190,23 @@ describe('MetadataDiffEngine', () => {
|
||||
mockPostsGetQueue = [];
|
||||
mockFileData.clear();
|
||||
mockAllPostsRows = [];
|
||||
mockSyncPublishedPostFile.mockClear();
|
||||
mockImportOrphanFile.mockClear();
|
||||
mockLocalDb.select.mockImplementation(() => createSelectChain(mockAllPostsRows));
|
||||
mockLocalDb.update.mockImplementation(() => ({
|
||||
set: vi.fn(() => ({
|
||||
where: vi.fn(() => Promise.resolve()),
|
||||
})),
|
||||
}) as any);
|
||||
mockSyncPublishedPostFile.mockReset().mockResolvedValue(true);
|
||||
mockSyncPublishedPostTranslationFile.mockReset().mockResolvedValue(true);
|
||||
mockImportOrphanFile.mockReset().mockResolvedValue({ id: 'imported-id', title: 'Imported' });
|
||||
mockImportOrphanTranslationFile.mockReset().mockResolvedValue({ id: 'imported-translation-id', title: 'Imported translation' });
|
||||
resetMockCounters();
|
||||
engine = new MetadataDiffEngine({ syncPublishedPostFile: mockSyncPublishedPostFile, importOrphanFile: mockImportOrphanFile } as any);
|
||||
engine = new MetadataDiffEngine({
|
||||
syncPublishedPostFile: mockSyncPublishedPostFile,
|
||||
syncPublishedPostTranslationFile: mockSyncPublishedPostTranslationFile,
|
||||
importOrphanFile: mockImportOrphanFile,
|
||||
importOrphanTranslationFile: mockImportOrphanTranslationFile,
|
||||
} as any);
|
||||
engine.setProjectContext('test-project');
|
||||
});
|
||||
|
||||
@@ -498,38 +522,48 @@ Content here`);
|
||||
|
||||
describe('scanAllPublishedPosts', () => {
|
||||
it('should scan all published posts and return differences', async () => {
|
||||
// Mock the raw SQL query that returns published posts
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{
|
||||
id: 'post-1',
|
||||
title: 'Post 1',
|
||||
slug: 'post-1',
|
||||
file_path: '/mock/userData/posts/2024/01/post-1.md',
|
||||
tags: '["new-tag"]',
|
||||
categories: '["cat1"]',
|
||||
excerpt: null,
|
||||
author: null,
|
||||
},
|
||||
{
|
||||
id: 'post-2',
|
||||
title: 'Post 2',
|
||||
slug: 'post-2',
|
||||
file_path: '/mock/userData/posts/2024/01/post-2.md',
|
||||
tags: '["tag1"]',
|
||||
categories: '["cat1"]',
|
||||
excerpt: null,
|
||||
author: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
// Mock the second query that gets ALL post file paths (for orphan detection)
|
||||
mockLocalClient.execute.mockResolvedValueOnce({
|
||||
rows: [
|
||||
{ file_path: '/mock/userData/posts/2024/01/post-1.md' },
|
||||
{ file_path: '/mock/userData/posts/2024/01/post-2.md' },
|
||||
],
|
||||
mockLocalClient.execute.mockImplementation(async (query: { sql: string; args: unknown[] }) => {
|
||||
if (query.sql.includes('FROM posts') && query.sql.includes("status = 'published'")) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: 'post-1',
|
||||
title: 'Post 1',
|
||||
slug: 'post-1',
|
||||
file_path: '/mock/userData/posts/2024/01/post-1.md',
|
||||
tags: '["new-tag"]',
|
||||
categories: '["cat1"]',
|
||||
excerpt: null,
|
||||
author: null,
|
||||
},
|
||||
{
|
||||
id: 'post-2',
|
||||
title: 'Post 2',
|
||||
slug: 'post-2',
|
||||
file_path: '/mock/userData/posts/2024/01/post-2.md',
|
||||
tags: '["tag1"]',
|
||||
categories: '["cat1"]',
|
||||
excerpt: null,
|
||||
author: null,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (query.sql.includes('FROM post_translations') && query.sql.includes("status = 'published'")) {
|
||||
return { rows: [] };
|
||||
}
|
||||
if (query.sql.includes('SELECT file_path FROM posts')) {
|
||||
return {
|
||||
rows: [
|
||||
{ file_path: '/mock/userData/posts/2024/01/post-1.md' },
|
||||
{ file_path: '/mock/userData/posts/2024/01/post-2.md' },
|
||||
],
|
||||
};
|
||||
}
|
||||
if (query.sql.includes('SELECT file_path FROM post_translations')) {
|
||||
return { rows: [] };
|
||||
}
|
||||
return { rows: [] };
|
||||
});
|
||||
|
||||
// Queue the posts for sequential .get() calls in comparePostMetadata
|
||||
@@ -545,6 +579,7 @@ Content here`);
|
||||
categories: '["cat1"]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
},
|
||||
{
|
||||
id: 'post-2',
|
||||
@@ -557,6 +592,7 @@ Content here`);
|
||||
categories: '["cat1"]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -734,6 +770,93 @@ Content`);
|
||||
// Draft post file should NOT be flagged as orphan since it's in the DB
|
||||
expect(result.orphanFiles).toEqual([]);
|
||||
});
|
||||
|
||||
it('should include published translation metadata differences in the scan', async () => {
|
||||
mockLocalClient.execute.mockImplementation(async (query: { sql: string; args: unknown[] }) => {
|
||||
if (query.sql.includes('FROM posts') && query.sql.includes("status = 'published'")) {
|
||||
return { rows: [] };
|
||||
}
|
||||
if (query.sql.includes('FROM post_translations') && query.sql.includes("status = 'published'")) {
|
||||
return {
|
||||
rows: [
|
||||
{
|
||||
id: 'translation-1',
|
||||
translation_for: 'post-1',
|
||||
language: 'de',
|
||||
title: 'Hallo Welt',
|
||||
excerpt: 'DB excerpt',
|
||||
file_path: '/mock/posts/2024/01/post-1.de.md',
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
if (query.sql.includes('SELECT file_path FROM posts')) {
|
||||
return { rows: [] };
|
||||
}
|
||||
if (query.sql.includes('SELECT file_path FROM post_translations')) {
|
||||
return { rows: [{ file_path: '/mock/posts/2024/01/post-1.de.md' }] };
|
||||
}
|
||||
return { rows: [] };
|
||||
});
|
||||
|
||||
let selectCall = 0;
|
||||
mockLocalDb.select.mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.where = vi.fn().mockReturnValue({
|
||||
...chain,
|
||||
get: vi.fn().mockImplementation(() => {
|
||||
selectCall += 1;
|
||||
if (selectCall === 1) return Promise.resolve(undefined);
|
||||
if (selectCall === 2) {
|
||||
return Promise.resolve({
|
||||
id: 'translation-1',
|
||||
projectId: 'test-project',
|
||||
translationFor: 'post-1',
|
||||
language: 'de',
|
||||
title: 'Hallo Welt',
|
||||
excerpt: 'DB excerpt',
|
||||
content: null,
|
||||
status: 'published',
|
||||
createdAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
publishedAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
filePath: '/mock/posts/2024/01/post-1.de.md',
|
||||
});
|
||||
}
|
||||
return Promise.resolve({
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Hello World',
|
||||
slug: 'post-1',
|
||||
status: 'published',
|
||||
filePath: '/mock/posts/2024/01/post-1.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
});
|
||||
}),
|
||||
});
|
||||
return chain;
|
||||
});
|
||||
|
||||
mockFileData.set('/mock/posts/2024/01/post-1.de.md', `---
|
||||
translationFor: post-1
|
||||
language: de
|
||||
title: "Hallo Welt"
|
||||
excerpt: "File excerpt"
|
||||
---
|
||||
Translated content`);
|
||||
|
||||
const result = await engine.scanAllPublishedPosts((current, total) => {}, '/mock/posts');
|
||||
|
||||
expect(result.totalScanned).toBe(1);
|
||||
expect(result.postsWithDifferences).toBe(1);
|
||||
expect(result.differences).toHaveLength(1);
|
||||
expect(result.differences[0].postId).toBe('translation-1');
|
||||
expect(result.differences[0].differences.excerpt).toEqual({ dbValue: 'DB excerpt', fileValue: 'File excerpt' });
|
||||
expect(result.orphanFiles).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importOrphanFiles', () => {
|
||||
@@ -778,6 +901,22 @@ Content`);
|
||||
expect(progressCalls[0][0]).toBe(5);
|
||||
expect(progressCalls[0][1]).toBe(5);
|
||||
});
|
||||
|
||||
it('should import orphan translation files into the translations table path', async () => {
|
||||
mockFileData.set('/posts/2024/01/post.de.md', `---
|
||||
translationFor: post-1
|
||||
language: de
|
||||
title: "Hallo Welt"
|
||||
excerpt: "Translated excerpt"
|
||||
---
|
||||
Translated content`);
|
||||
|
||||
const result = await engine.importOrphanFiles(['/posts/2024/01/post.de.md']);
|
||||
|
||||
expect(result).toEqual({ success: 1, failed: 0 });
|
||||
expect(mockImportOrphanTranslationFile).toHaveBeenCalledWith('/posts/2024/01/post.de.md');
|
||||
expect(mockImportOrphanFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupDifferencesByField', () => {
|
||||
@@ -856,12 +995,26 @@ Content`);
|
||||
.mockResolvedValueOnce(true)
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockRejectedValueOnce(new Error('sync failure'));
|
||||
mockSyncPublishedPostTranslationFile
|
||||
.mockResolvedValueOnce(false)
|
||||
.mockResolvedValueOnce(false);
|
||||
|
||||
const result = await engine.syncDbToFile(['post-1', 'post-2', 'post-3']);
|
||||
|
||||
expect(result).toEqual({ success: 1, failed: 2 });
|
||||
expect(mockSyncPublishedPostFile).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('should fall back to syncing published translation files when the post file sync does not apply', async () => {
|
||||
mockSyncPublishedPostFile.mockResolvedValueOnce(false);
|
||||
mockSyncPublishedPostTranslationFile.mockResolvedValueOnce(true);
|
||||
|
||||
const result = await engine.syncDbToFile(['translation-1']);
|
||||
|
||||
expect(result).toEqual({ success: 1, failed: 0 });
|
||||
expect(mockSyncPublishedPostFile).toHaveBeenCalledWith('translation-1');
|
||||
expect(mockSyncPublishedPostTranslationFile).toHaveBeenCalledWith('translation-1');
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncFileToDb', () => {
|
||||
@@ -1019,6 +1172,49 @@ Content here`);
|
||||
expect(result).toEqual({ success: 1, failed: 2 });
|
||||
expect(mockLocalDb.update).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should sync published translation metadata from file to database', async () => {
|
||||
let selectCall = 0;
|
||||
mockLocalDb.select.mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.where = vi.fn().mockReturnValue({
|
||||
...chain,
|
||||
get: vi.fn().mockImplementation(() => {
|
||||
selectCall += 1;
|
||||
if (selectCall === 1) return Promise.resolve(undefined);
|
||||
return Promise.resolve({
|
||||
id: 'translation-1',
|
||||
projectId: 'test-project',
|
||||
translationFor: 'post-1',
|
||||
language: 'de',
|
||||
title: 'Hallo Welt',
|
||||
excerpt: 'DB excerpt',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/post-1.de.md',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
});
|
||||
}),
|
||||
});
|
||||
return chain;
|
||||
});
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/post-1.de.md', `---
|
||||
translationFor: post-1
|
||||
language: de
|
||||
title: "Hallo Datei"
|
||||
excerpt: "File excerpt"
|
||||
---
|
||||
Translated content`);
|
||||
|
||||
await engine.syncFileToDb(['translation-1'], 'title');
|
||||
|
||||
expect(mockLocalDb.update).toHaveBeenCalled();
|
||||
const updateResult = mockLocalDb.update.mock.results[0].value;
|
||||
const setCall = updateResult.set.mock.calls[0][0];
|
||||
expect(setCall.title).toBe('Hallo Datei');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Media diff tests ──
|
||||
@@ -1383,4 +1579,595 @@ Content here`);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparePostMetadata – new fields', () => {
|
||||
it('should detect doNotTranslate differences between DB and file', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-dnt',
|
||||
projectId: 'test-project',
|
||||
title: 'DNT Post',
|
||||
slug: 'dnt-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/dnt-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
doNotTranslate: true,
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-dnt', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/dnt-post.md', `---
|
||||
id: post-dnt
|
||||
projectId: test-project
|
||||
title: "DNT Post"
|
||||
slug: dnt-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-dnt');
|
||||
expect(result?.hasDifferences).toBe(true);
|
||||
expect(result?.differences.doNotTranslate).toBeDefined();
|
||||
expect(result?.differences.doNotTranslate?.dbValue).toBe(true);
|
||||
expect(result?.differences.doNotTranslate?.fileValue).toBe(false);
|
||||
});
|
||||
|
||||
it('should detect templateSlug differences between DB and file', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-tpl',
|
||||
projectId: 'test-project',
|
||||
title: 'Template Post',
|
||||
slug: 'template-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/template-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
templateSlug: 'blog-default',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-tpl', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/template-post.md', `---
|
||||
id: post-tpl
|
||||
projectId: test-project
|
||||
title: "Template Post"
|
||||
slug: template-post
|
||||
status: published
|
||||
templateSlug: old-template
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-tpl');
|
||||
expect(result?.hasDifferences).toBe(true);
|
||||
expect(result?.differences.templateSlug).toBeDefined();
|
||||
expect(result?.differences.templateSlug?.dbValue).toBe('blog-default');
|
||||
expect(result?.differences.templateSlug?.fileValue).toBe('old-template');
|
||||
});
|
||||
|
||||
it('should detect status differences between DB and file', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-status',
|
||||
projectId: 'test-project',
|
||||
title: 'Archived Post',
|
||||
slug: 'archived-post',
|
||||
status: 'archived',
|
||||
filePath: '/mock/userData/posts/2024/01/archived-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-status', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/archived-post.md', `---
|
||||
id: post-status
|
||||
projectId: test-project
|
||||
title: "Archived Post"
|
||||
slug: archived-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-status');
|
||||
expect(result?.hasDifferences).toBe(true);
|
||||
expect(result?.differences.status).toBeDefined();
|
||||
expect(result?.differences.status?.dbValue).toBe('archived');
|
||||
expect(result?.differences.status?.fileValue).toBe('published');
|
||||
});
|
||||
|
||||
it('should show no differences when new fields match', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-match',
|
||||
projectId: 'test-project',
|
||||
title: 'Matching Post',
|
||||
slug: 'matching-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/matching-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
doNotTranslate: false,
|
||||
templateSlug: null,
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-match', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/matching-post.md', `---
|
||||
id: post-match
|
||||
projectId: test-project
|
||||
title: "Matching Post"
|
||||
slug: matching-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-match');
|
||||
expect(result?.hasDifferences).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncFileToDb – new fields', () => {
|
||||
it('should sync doNotTranslate from file to database', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Published Post',
|
||||
slug: 'published-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/published-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-1', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
|
||||
id: post-1
|
||||
projectId: test-project
|
||||
title: "Published Post"
|
||||
slug: published-post
|
||||
status: published
|
||||
doNotTranslate: true
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
await engine.syncFileToDb(['post-1'], 'doNotTranslate');
|
||||
|
||||
expect(mockLocalDb.update).toHaveBeenCalled();
|
||||
const updateResult = mockLocalDb.update.mock.results[0].value;
|
||||
const setCall = updateResult.set.mock.calls[0][0];
|
||||
expect(setCall.doNotTranslate).toBe(true);
|
||||
});
|
||||
|
||||
it('should sync templateSlug from file to database', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Published Post',
|
||||
slug: 'published-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/published-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-1', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
|
||||
id: post-1
|
||||
projectId: test-project
|
||||
title: "Published Post"
|
||||
slug: published-post
|
||||
status: published
|
||||
templateSlug: my-template
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
await engine.syncFileToDb(['post-1'], 'templateSlug');
|
||||
|
||||
expect(mockLocalDb.update).toHaveBeenCalled();
|
||||
const updateResult = mockLocalDb.update.mock.results[0].value;
|
||||
const setCall = updateResult.set.mock.calls[0][0];
|
||||
expect(setCall.templateSlug).toBe('my-template');
|
||||
});
|
||||
|
||||
it('should sync status from file to database', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Published Post',
|
||||
slug: 'published-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/published-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-1', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
|
||||
id: post-1
|
||||
projectId: test-project
|
||||
title: "Published Post"
|
||||
slug: published-post
|
||||
status: archived
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
await engine.syncFileToDb(['post-1'], 'status');
|
||||
|
||||
expect(mockLocalDb.update).toHaveBeenCalled();
|
||||
const updateResult = mockLocalDb.update.mock.results[0].value;
|
||||
const setCall = updateResult.set.mock.calls[0][0];
|
||||
expect(setCall.status).toBe('archived');
|
||||
});
|
||||
});
|
||||
|
||||
describe('groupDifferencesByField – new fields', () => {
|
||||
it('should include new fields in group labels', () => {
|
||||
const diffs: PostMetadataDiff[] = [
|
||||
{
|
||||
postId: 'p1',
|
||||
title: 'Post 1',
|
||||
slug: 'post-1',
|
||||
hasDifferences: true,
|
||||
differences: {
|
||||
doNotTranslate: { dbValue: true, fileValue: false },
|
||||
templateSlug: { dbValue: 'tpl-a', fileValue: 'tpl-b' },
|
||||
status: { dbValue: 'published', fileValue: 'archived' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const groups = engine.groupDifferencesByField(diffs);
|
||||
|
||||
const fieldNames = groups.map(g => g.field);
|
||||
expect(fieldNames).toContain('doNotTranslate');
|
||||
expect(fieldNames).toContain('templateSlug');
|
||||
expect(fieldNames).toContain('status');
|
||||
|
||||
const dntGroup = groups.find(g => g.field === 'doNotTranslate');
|
||||
expect(dntGroup?.label).toBe('Do Not Translate');
|
||||
const tplGroup = groups.find(g => g.field === 'templateSlug');
|
||||
expect(tplGroup?.label).toBe('Template');
|
||||
const statusGroup = groups.find(g => g.field === 'status');
|
||||
expect(statusGroup?.label).toBe('Status');
|
||||
});
|
||||
|
||||
it('should include timestamp fields in group labels', () => {
|
||||
const diffs: PostMetadataDiff[] = [
|
||||
{
|
||||
postId: 'p1',
|
||||
title: 'Post 1',
|
||||
slug: 'post-1',
|
||||
hasDifferences: true,
|
||||
differences: {
|
||||
createdAt: { dbValue: '2024-01-15T00:00:00.000Z', fileValue: '2024-02-15T00:00:00.000Z' },
|
||||
updatedAt: { dbValue: '2024-01-15T00:00:00.000Z', fileValue: '2024-03-01T00:00:00.000Z' },
|
||||
publishedAt: { dbValue: '2024-01-15T00:00:00.000Z', fileValue: '' },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const groups = engine.groupDifferencesByField(diffs);
|
||||
|
||||
const fieldNames = groups.map(g => g.field);
|
||||
expect(fieldNames).toContain('createdAt');
|
||||
expect(fieldNames).toContain('updatedAt');
|
||||
expect(fieldNames).toContain('publishedAt');
|
||||
|
||||
expect(groups.find(g => g.field === 'createdAt')?.label).toBe('Created At');
|
||||
expect(groups.find(g => g.field === 'updatedAt')?.label).toBe('Updated At');
|
||||
expect(groups.find(g => g.field === 'publishedAt')?.label).toBe('Published At');
|
||||
});
|
||||
});
|
||||
|
||||
describe('comparePostMetadata – timestamp diffs', () => {
|
||||
it('should detect createdAt differences between DB and file', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-ts',
|
||||
projectId: 'test-project',
|
||||
title: 'Timestamp Post',
|
||||
slug: 'timestamp-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/timestamp-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
publishedAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
};
|
||||
mockPosts.set('post-ts', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/timestamp-post.md', `---
|
||||
id: post-ts
|
||||
projectId: test-project
|
||||
title: "Timestamp Post"
|
||||
slug: timestamp-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-06-01T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-ts');
|
||||
expect(result?.hasDifferences).toBe(true);
|
||||
expect(result?.differences.createdAt).toBeDefined();
|
||||
expect(result?.differences.createdAt?.fileValue).toContain('2024-06-01');
|
||||
});
|
||||
|
||||
it('should detect publishedAt differences when DB has it but file does not', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-pa',
|
||||
projectId: 'test-project',
|
||||
title: 'Published At Post',
|
||||
slug: 'published-at-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/published-at-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
updatedAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
publishedAt: new Date('2024-01-15T00:00:00.000Z'),
|
||||
};
|
||||
mockPosts.set('post-pa', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/published-at-post.md', `---
|
||||
id: post-pa
|
||||
projectId: test-project
|
||||
title: "Published At Post"
|
||||
slug: published-at-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-pa');
|
||||
expect(result?.hasDifferences).toBe(true);
|
||||
expect(result?.differences.publishedAt).toBeDefined();
|
||||
});
|
||||
|
||||
it('should show no timestamp differences when they match at second precision', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-eq',
|
||||
projectId: 'test-project',
|
||||
title: 'Equal Post',
|
||||
slug: 'equal-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/equal-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15T12:30:00.000Z'),
|
||||
updatedAt: new Date('2024-01-15T12:30:00.000Z'),
|
||||
publishedAt: new Date('2024-01-15T12:30:00.000Z'),
|
||||
};
|
||||
mockPosts.set('post-eq', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/equal-post.md', `---
|
||||
id: post-eq
|
||||
projectId: test-project
|
||||
title: "Equal Post"
|
||||
slug: equal-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-01-15T12:30:00.000Z
|
||||
updatedAt: 2024-01-15T12:30:00.000Z
|
||||
publishedAt: 2024-01-15T12:30:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
const result = await engine.comparePostMetadata('post-eq');
|
||||
expect(result?.hasDifferences).toBe(false);
|
||||
expect(result?.differences.createdAt).toBeUndefined();
|
||||
expect(result?.differences.updatedAt).toBeUndefined();
|
||||
expect(result?.differences.publishedAt).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('syncFileToDb – timestamp fields', () => {
|
||||
it('should sync createdAt from file to database using file value', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Published Post',
|
||||
slug: 'published-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/published-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-1', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
|
||||
id: post-1
|
||||
projectId: test-project
|
||||
title: "Published Post"
|
||||
slug: published-post
|
||||
status: published
|
||||
tags: []
|
||||
categories: []
|
||||
createdAt: 2024-06-01T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
await engine.syncFileToDb(['post-1'], 'createdAt');
|
||||
|
||||
expect(mockLocalDb.update).toHaveBeenCalled();
|
||||
const updateResult = mockLocalDb.update.mock.results[0].value;
|
||||
const setCall = updateResult.set.mock.calls[0][0];
|
||||
expect(setCall.createdAt).toBeInstanceOf(Date);
|
||||
expect(setCall.createdAt.toISOString()).toContain('2024-06-01');
|
||||
// Should NOT auto-set updatedAt when syncing a timestamp field
|
||||
expect(setCall.updatedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it('should auto-set updatedAt when syncing a non-timestamp field', async () => {
|
||||
const dbPost = {
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Published Post',
|
||||
slug: 'published-post',
|
||||
status: 'published',
|
||||
filePath: '/mock/userData/posts/2024/01/published-post.md',
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
createdAt: new Date('2024-01-15'),
|
||||
updatedAt: new Date('2024-01-15'),
|
||||
publishedAt: new Date('2024-01-15'),
|
||||
};
|
||||
mockPosts.set('post-1', dbPost);
|
||||
|
||||
mockFileData.set('/mock/userData/posts/2024/01/published-post.md', `---
|
||||
id: post-1
|
||||
projectId: test-project
|
||||
title: "Published Post"
|
||||
slug: published-post
|
||||
status: published
|
||||
tags: ["new-tag"]
|
||||
categories: []
|
||||
createdAt: 2024-01-15T00:00:00.000Z
|
||||
updatedAt: 2024-01-15T00:00:00.000Z
|
||||
publishedAt: 2024-01-15T00:00:00.000Z
|
||||
---
|
||||
Content here`);
|
||||
|
||||
await engine.syncFileToDb(['post-1'], 'tags');
|
||||
|
||||
const updateResult = mockLocalDb.update.mock.results[0].value;
|
||||
const setCall = updateResult.set.mock.calls[0][0];
|
||||
// Should auto-set updatedAt for non-timestamp field syncs
|
||||
expect(setCall.updatedAt).toBeInstanceOf(Date);
|
||||
});
|
||||
});
|
||||
|
||||
describe('compareMediaMetadata – language diff', () => {
|
||||
let mediaEngine: MetadataDiffEngine;
|
||||
const mockReadSidecarFile = vi.fn();
|
||||
|
||||
beforeEach(() => {
|
||||
mediaEngine = new MetadataDiffEngine(
|
||||
undefined,
|
||||
{ readSidecarFile: mockReadSidecarFile, getMedia: vi.fn(), updateMedia: vi.fn() } as any,
|
||||
);
|
||||
mediaEngine.setProjectContext('test-project');
|
||||
});
|
||||
|
||||
it('should detect media language differences', async () => {
|
||||
mockPostsGetQueue = [{
|
||||
id: 'media-1',
|
||||
projectId: 'test-project',
|
||||
originalName: 'photo.jpg',
|
||||
filePath: '/mock/media/photo.jpg',
|
||||
title: 'Photo',
|
||||
alt: 'A photo',
|
||||
caption: '',
|
||||
author: '',
|
||||
language: 'en',
|
||||
tags: '[]',
|
||||
}];
|
||||
|
||||
mockReadSidecarFile.mockResolvedValueOnce({
|
||||
id: 'media-1',
|
||||
originalName: 'photo.jpg',
|
||||
title: 'Photo',
|
||||
alt: 'A photo',
|
||||
caption: '',
|
||||
author: '',
|
||||
language: 'fr',
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const result = await mediaEngine.compareMediaMetadata('media-1');
|
||||
expect(result?.hasDifferences).toBe(true);
|
||||
expect(result?.differences.language).toBeDefined();
|
||||
expect(result?.differences.language?.dbValue).toBe('en');
|
||||
expect(result?.differences.language?.fileValue).toBe('fr');
|
||||
});
|
||||
|
||||
it('should not flag language when both are empty', async () => {
|
||||
mockPostsGetQueue = [{
|
||||
id: 'media-2',
|
||||
projectId: 'test-project',
|
||||
originalName: 'photo.jpg',
|
||||
filePath: '/mock/media/photo.jpg',
|
||||
title: 'Photo',
|
||||
alt: '',
|
||||
caption: '',
|
||||
author: '',
|
||||
language: null,
|
||||
tags: '[]',
|
||||
}];
|
||||
|
||||
mockReadSidecarFile.mockResolvedValueOnce({
|
||||
title: 'Photo',
|
||||
alt: '',
|
||||
caption: '',
|
||||
author: '',
|
||||
language: '',
|
||||
tags: [],
|
||||
});
|
||||
|
||||
const result = await mediaEngine.compareMediaMetadata('media-2');
|
||||
expect(result?.differences.language).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -109,7 +109,7 @@ describe('replaceAllMacrosAsync', () => {
|
||||
expect(result).toContain('<aside class="custom-box">Custom Content</aside>');
|
||||
});
|
||||
|
||||
it('returns empty string for unknown macros without Python renderer', async () => {
|
||||
it('preserves unknown macros without Python renderer', async () => {
|
||||
const result = await replaceAllMacrosAsync(
|
||||
'Before [[unknown_macro]] After',
|
||||
'post-1',
|
||||
@@ -120,10 +120,10 @@ describe('replaceAllMacrosAsync', () => {
|
||||
null,
|
||||
);
|
||||
|
||||
expect(result).toBe('Before After');
|
||||
expect(result).toBe('Before [[unknown_macro]] After');
|
||||
});
|
||||
|
||||
it('returns empty string for unmatched Python macros', async () => {
|
||||
it('preserves unmatched Python macros', async () => {
|
||||
const mockRenderer: PythonMacroRendererContract = {
|
||||
getEnabledMacroScripts: vi.fn().mockResolvedValue([]),
|
||||
renderMacro: vi.fn(),
|
||||
@@ -139,7 +139,7 @@ describe('replaceAllMacrosAsync', () => {
|
||||
mockRenderer,
|
||||
);
|
||||
|
||||
expect(result).toBe('Before After');
|
||||
expect(result).toBe('Before [[nonexistent_macro]] After');
|
||||
expect(mockRenderer.renderMacro).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
@@ -186,7 +186,21 @@ describe('replaceAllMacrosAsync', () => {
|
||||
mockRenderer,
|
||||
);
|
||||
|
||||
expect(result).toBe('Before After');
|
||||
expect(result).toBe('Before [[my_macro]] After');
|
||||
});
|
||||
|
||||
it('preserves the original unknown macro tag including params', async () => {
|
||||
const result = await replaceAllMacrosAsync(
|
||||
'Before [[unknown_macro title="Hello" count="2"]] After',
|
||||
'post-1',
|
||||
[],
|
||||
null,
|
||||
[],
|
||||
'en',
|
||||
null,
|
||||
);
|
||||
|
||||
expect(result).toBe('Before [[unknown_macro title="Hello" count="2"]] After');
|
||||
});
|
||||
|
||||
it('does not look up Python scripts when all macros are built-in', async () => {
|
||||
@@ -244,6 +258,73 @@ describe('replaceAllMacrosAsync', () => {
|
||||
expect(call.cacheKey).toBe('ctx-script:2');
|
||||
});
|
||||
|
||||
it('passes languagePrefix and translations in Python macro context', async () => {
|
||||
const mockRenderer: PythonMacroRendererContract = {
|
||||
getEnabledMacroScripts: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'lang-script',
|
||||
slug: 'lang_test',
|
||||
entrypoint: 'render',
|
||||
content: 'def render(ctx, post): return {"html": "ok"}',
|
||||
version: 1,
|
||||
},
|
||||
] satisfies PythonMacroScript[]),
|
||||
renderMacro: vi.fn().mockResolvedValue({ html: 'ok' }),
|
||||
};
|
||||
|
||||
await replaceAllMacrosAsync(
|
||||
'[[lang_test]]',
|
||||
'post-1',
|
||||
[],
|
||||
null,
|
||||
[],
|
||||
'fr',
|
||||
mockRenderer,
|
||||
null,
|
||||
'/fr',
|
||||
);
|
||||
|
||||
const call = (mockRenderer.renderMacro as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
const parsedContext = JSON.parse(call.contextJson);
|
||||
|
||||
expect(parsedContext.env.languagePrefix).toBe('/fr');
|
||||
expect(parsedContext.env.mainLanguage).toBe('fr');
|
||||
expect(parsedContext.env.translations).toBeDefined();
|
||||
expect(typeof parsedContext.env.translations).toBe('object');
|
||||
expect(parsedContext.env.translations['render.archive']).toBe('Archives');
|
||||
});
|
||||
|
||||
it('passes empty languagePrefix when not provided', async () => {
|
||||
const mockRenderer: PythonMacroRendererContract = {
|
||||
getEnabledMacroScripts: vi.fn().mockResolvedValue([
|
||||
{
|
||||
id: 'no-prefix-script',
|
||||
slug: 'no_prefix',
|
||||
entrypoint: 'render',
|
||||
content: 'def render(ctx, post): return {"html": "ok"}',
|
||||
version: 1,
|
||||
},
|
||||
] satisfies PythonMacroScript[]),
|
||||
renderMacro: vi.fn().mockResolvedValue({ html: 'ok' }),
|
||||
};
|
||||
|
||||
await replaceAllMacrosAsync(
|
||||
'[[no_prefix]]',
|
||||
'post-1',
|
||||
[],
|
||||
null,
|
||||
[],
|
||||
'en',
|
||||
mockRenderer,
|
||||
);
|
||||
|
||||
const call = (mockRenderer.renderMacro as ReturnType<typeof vi.fn>).mock.calls[0][0];
|
||||
const parsedContext = JSON.parse(call.contextJson);
|
||||
|
||||
expect(parsedContext.env.languagePrefix).toBe('');
|
||||
expect(parsedContext.env.translations).toBeDefined();
|
||||
});
|
||||
|
||||
it('returns unchanged text when there are no macros', async () => {
|
||||
const content = 'Just plain text with no macros';
|
||||
const result = await replaceAllMacrosAsync(content, '', [], null, [], 'en', null);
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { PostEngine, PostData } from '../../src/main/engine/PostEngine';
|
||||
import { postTranslations } from '../../src/main/database/schema';
|
||||
import { resetMockCounters } from '../utils/factories';
|
||||
import * as fs from 'fs/promises';
|
||||
|
||||
@@ -1838,6 +1839,71 @@ Content`);
|
||||
expect(insertedProjects).toHaveLength(1);
|
||||
expect(insertedProjects[0]).toBe('current-project-id');
|
||||
});
|
||||
|
||||
it('should rebuild published translation files into the translations table', async () => {
|
||||
const fs = await import('fs/promises');
|
||||
const insertedRows: Array<{ table: unknown; data: any }> = [];
|
||||
|
||||
vi.mocked(mockLocalDb.insert).mockImplementation((table: unknown) => ({
|
||||
values: vi.fn((data: any) => {
|
||||
insertedRows.push({ table, data });
|
||||
if (data && data.id) {
|
||||
mockPosts.set(data.id, data);
|
||||
}
|
||||
return Promise.resolve();
|
||||
}),
|
||||
}) as any);
|
||||
|
||||
vi.mocked(fs.readdir).mockResolvedValueOnce([
|
||||
mockDirent('source-post.md'),
|
||||
mockDirent('source-post.de.md'),
|
||||
] as any);
|
||||
vi.mocked(fs.access).mockResolvedValue(undefined);
|
||||
vi.mocked(fs.readFile).mockImplementation(async (filePath: any) => {
|
||||
if (filePath.includes('source-post.md') && !filePath.includes('source-post.de.md')) {
|
||||
return `---
|
||||
id: source-post-id
|
||||
projectId: default
|
||||
title: Source Post
|
||||
slug: source-post
|
||||
status: published
|
||||
language: en
|
||||
createdAt: 2024-01-01T00:00:00.000Z
|
||||
updatedAt: 2024-01-02T00:00:00.000Z
|
||||
publishedAt: 2024-01-02T00:00:00.000Z
|
||||
tags: []
|
||||
categories: []
|
||||
---
|
||||
Canonical content`;
|
||||
}
|
||||
|
||||
if (filePath.includes('source-post.de.md')) {
|
||||
return `---
|
||||
translationFor: source-post-id
|
||||
language: de
|
||||
title: Quellbeitrag
|
||||
excerpt: Deutsche Zusammenfassung
|
||||
---
|
||||
Deutscher Inhalt`;
|
||||
}
|
||||
|
||||
throw new Error('ENOENT');
|
||||
});
|
||||
|
||||
await postEngine.rebuildDatabaseFromFiles();
|
||||
|
||||
const translationInsert = insertedRows.find((row) => row.table === postTranslations);
|
||||
expect(translationInsert).toBeDefined();
|
||||
expect(translationInsert?.data).toMatchObject({
|
||||
projectId: 'default',
|
||||
translationFor: 'source-post-id',
|
||||
language: 'de',
|
||||
title: 'Quellbeitrag',
|
||||
excerpt: 'Deutsche Zusammenfassung',
|
||||
status: 'published',
|
||||
filePath: expect.stringContaining('source-post.de.md'),
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Date-based folder structure', () => {
|
||||
@@ -3787,4 +3853,198 @@ Body.`);
|
||||
expect(result!.slug).toBe('existing-slug-2');
|
||||
});
|
||||
});
|
||||
|
||||
describe('FTS translation indexing', () => {
|
||||
it('should include translation content in FTS index when updating a post', async () => {
|
||||
// Arrange: set up a post with a German translation
|
||||
postEngine.setMainLanguage('en');
|
||||
postEngine.setSearchLanguage('english');
|
||||
|
||||
// Mock getTranslationRowsForPost to return a translation
|
||||
const translationRow = {
|
||||
id: 'trans-1',
|
||||
projectId: 'default',
|
||||
translationFor: 'post-1',
|
||||
language: 'de',
|
||||
title: 'German Title Häuser',
|
||||
excerpt: 'German Excerpt',
|
||||
content: 'German draft content Haus',
|
||||
status: 'draft',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
publishedAt: null,
|
||||
filePath: '',
|
||||
checksum: null,
|
||||
};
|
||||
|
||||
// getAllTranslationRows returns all translations for the current project
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.all = vi.fn().mockResolvedValue([translationRow]);
|
||||
chain.where = vi.fn().mockReturnValue({
|
||||
...chain,
|
||||
get: vi.fn().mockResolvedValue(undefined),
|
||||
all: vi.fn().mockResolvedValue([translationRow]),
|
||||
});
|
||||
return chain;
|
||||
});
|
||||
|
||||
mockExecuteArgs = [];
|
||||
|
||||
// Act
|
||||
await postEngine.updateFTSIndex({
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'English Title',
|
||||
content: 'English content about houses',
|
||||
excerpt: 'Summary',
|
||||
tags: ['test'],
|
||||
categories: ['blog'],
|
||||
});
|
||||
|
||||
// Assert: the FTS insert should contain both English and German stemmed content
|
||||
const ftsInsert = mockExecuteArgs.find((q: { sql: string }) =>
|
||||
q.sql.includes('INSERT INTO posts_fts'),
|
||||
);
|
||||
expect(ftsInsert).toBeDefined();
|
||||
const indexedContent = ftsInsert.args[2] as string;
|
||||
// English content should be stemmed with English stemmer
|
||||
expect(indexedContent).toContain('hous'); // "houses" stemmed in English
|
||||
// German content should be stemmed with German stemmer
|
||||
expect(indexedContent).toContain('haus'); // "Haus/Häuser" stemmed in German
|
||||
});
|
||||
|
||||
it('should re-index FTS when a translation is created', async () => {
|
||||
// Arrange: source post exists
|
||||
const sourcePost = {
|
||||
id: 'post-1',
|
||||
projectId: 'test-project',
|
||||
title: 'Source Post',
|
||||
slug: 'source-post',
|
||||
excerpt: null,
|
||||
content: 'Source content',
|
||||
status: 'draft',
|
||||
author: null,
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
publishedAt: null,
|
||||
filePath: null,
|
||||
checksum: null,
|
||||
tags: '[]',
|
||||
categories: '[]',
|
||||
language: 'en',
|
||||
translationOfId: null,
|
||||
templateSlug: null,
|
||||
doNotTranslate: 0,
|
||||
version: 1,
|
||||
stemmedTitle: '',
|
||||
stemmedContent: '',
|
||||
};
|
||||
|
||||
let selectCallCount = 0;
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
selectCallCount++;
|
||||
const chain = createSelectChain();
|
||||
chain.where = vi.fn().mockReturnValue({
|
||||
...chain,
|
||||
get: vi.fn().mockResolvedValue(selectCallCount <= 2 ? sourcePost : undefined),
|
||||
all: vi.fn().mockResolvedValue(selectCallCount <= 2 ? [sourcePost] : []),
|
||||
});
|
||||
chain.all = vi.fn().mockResolvedValue([]);
|
||||
return chain;
|
||||
});
|
||||
|
||||
mockExecuteArgs = [];
|
||||
|
||||
// Act: create a French translation
|
||||
await postEngine.upsertPostTranslation('post-1', 'fr', {
|
||||
title: 'Titre Français',
|
||||
content: 'Contenu en français avec des maisons',
|
||||
});
|
||||
|
||||
// Assert: FTS should have been updated (at least one INSERT INTO posts_fts)
|
||||
const ftsInserts = mockExecuteArgs.filter((q: { sql: string }) =>
|
||||
q.sql.includes('INSERT INTO posts_fts'),
|
||||
);
|
||||
expect(ftsInserts.length).toBeGreaterThanOrEqual(1);
|
||||
});
|
||||
|
||||
it('should stem search query with multiple languages for cross-language matching', async () => {
|
||||
postEngine.setMainLanguage('en');
|
||||
postEngine.setSearchLanguage('english');
|
||||
|
||||
// Mock translations with German language to simulate a project with translations
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.all = vi.fn().mockResolvedValue([
|
||||
{ id: 'trans-1', projectId: 'test-project', translationFor: 'post-1', language: 'de', title: 'T', content: 'C', status: 'draft', createdAt: new Date(), updatedAt: new Date(), publishedAt: null, filePath: '', checksum: null },
|
||||
]);
|
||||
chain.where = vi.fn().mockReturnValue({
|
||||
...chain,
|
||||
get: vi.fn().mockResolvedValue({ id: 'post-1', title: 'Found', slug: 'found', excerpt: null, tags: '[]', categories: '[]' }),
|
||||
});
|
||||
return chain;
|
||||
});
|
||||
|
||||
mockLocalClient.execute.mockResolvedValueOnce({ rows: [{ id: 'post-1' }] });
|
||||
|
||||
await postEngine.searchPosts('Häuser');
|
||||
|
||||
// Verify the FTS MATCH query was called
|
||||
const matchCall = mockLocalClient.execute.mock.calls[0]?.[0] as { sql: string; args: any[] };
|
||||
expect(matchCall.sql).toContain('MATCH');
|
||||
|
||||
// The query should contain stems from multiple languages combined with OR
|
||||
const matchArg = matchCall.args[1] as string;
|
||||
expect(matchArg).toBeDefined();
|
||||
});
|
||||
|
||||
it('should rebuild FTS index including translation content', async () => {
|
||||
postEngine.setMainLanguage('en');
|
||||
postEngine.setSearchLanguage('english');
|
||||
|
||||
const translationRow = {
|
||||
id: 'trans-1',
|
||||
projectId: 'test-project',
|
||||
translationFor: 'post-1',
|
||||
language: 'de',
|
||||
title: 'Deutscher Titel',
|
||||
excerpt: null,
|
||||
content: 'Deutscher Inhalt',
|
||||
status: 'draft',
|
||||
createdAt: new Date(),
|
||||
updatedAt: new Date(),
|
||||
publishedAt: null,
|
||||
filePath: '',
|
||||
checksum: null,
|
||||
};
|
||||
|
||||
vi.mocked(mockLocalDb.select).mockImplementation(() => {
|
||||
const chain = createSelectChain();
|
||||
chain.where = vi.fn().mockReturnValue({
|
||||
...chain,
|
||||
orderBy: vi.fn().mockReturnThis(),
|
||||
all: vi.fn().mockResolvedValue([
|
||||
{ id: 'post-1', projectId: 'test-project', title: 'English Post', content: 'English content', tags: '[]', categories: '[]', language: 'en' },
|
||||
]),
|
||||
get: vi.fn().mockResolvedValue(undefined),
|
||||
});
|
||||
chain.all = vi.fn().mockResolvedValue([translationRow]);
|
||||
return chain;
|
||||
});
|
||||
|
||||
mockExecuteArgs = [];
|
||||
await postEngine.rebuildFTSIndex();
|
||||
|
||||
// Verify FTS was populated
|
||||
const ftsInserts = mockExecuteArgs.filter((q: { sql: string }) =>
|
||||
q.sql.includes('INSERT INTO posts_fts'),
|
||||
);
|
||||
expect(ftsInserts.length).toBeGreaterThanOrEqual(1);
|
||||
|
||||
// The indexed content should include German translation content
|
||||
const insertContent = ftsInserts[0]?.args?.[2] as string;
|
||||
expect(insertContent).toBeDefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
1194
tests/engine/PostTranslationSystem.test.ts
Normal file
1194
tests/engine/PostTranslationSystem.test.ts
Normal file
File diff suppressed because it is too large
Load Diff
@@ -16,7 +16,7 @@ type PostEngineLike = {
|
||||
};
|
||||
|
||||
type SettingsEngineLike = {
|
||||
getProjectMetadata: () => Promise<{ maxPostsPerPage?: number; mainLanguage?: string } | null>;
|
||||
getProjectMetadata: () => Promise<{ maxPostsPerPage?: number; mainLanguage?: string; blogLanguages?: string[] } | null>;
|
||||
setProjectContext: (projectId: string, dataDir?: string) => void;
|
||||
};
|
||||
|
||||
@@ -39,6 +39,7 @@ function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||
content: overrides.content ?? `# ${title}\n\nBody`,
|
||||
status: overrides.status ?? 'published',
|
||||
author: overrides.author,
|
||||
language: overrides.language,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
publishedAt: overrides.publishedAt,
|
||||
@@ -463,7 +464,6 @@ describe('PreviewServer', () => {
|
||||
postMediaEngine,
|
||||
settingsEngine: settingsEngine as any,
|
||||
menuEngine,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default' }),
|
||||
});
|
||||
|
||||
@@ -618,6 +618,142 @@ describe('PreviewServer', () => {
|
||||
expect(draftHtml).not.toContain('Published body');
|
||||
});
|
||||
|
||||
it('serves translated draft content for single post route when lang query param is provided', async () => {
|
||||
const draftPost = makePost({
|
||||
id: 'post-2',
|
||||
slug: 'shared-slug',
|
||||
title: 'Draft Title',
|
||||
content: 'Draft-only body',
|
||||
status: 'draft',
|
||||
language: 'en',
|
||||
createdAt: new Date('2025-01-03T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
server = new PreviewServer({
|
||||
postEngine: {
|
||||
...makeEngine([draftPost]),
|
||||
async getPostTranslation(postId: string, language: string) {
|
||||
if (postId === 'post-2' && language === 'fr') {
|
||||
return {
|
||||
id: 'translation-2-fr',
|
||||
translationFor: 'post-2',
|
||||
language: 'fr',
|
||||
title: 'Titre brouillon',
|
||||
excerpt: 'Resume brouillon',
|
||||
content: 'Contenu brouillon traduit',
|
||||
status: 'draft',
|
||||
createdAt: new Date('2025-01-03T10:00:00.000Z'),
|
||||
updatedAt: new Date('2025-01-03T11:00:00.000Z'),
|
||||
publishedAt: null,
|
||||
filePath: 'posts/shared-slug.fr.md',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as any,
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
|
||||
const draftResponse = await fetch(`${server.getBaseUrl()}/2025/01/03/shared-slug?draft=true&postId=post-2&lang=fr`);
|
||||
expect(draftResponse.status).toBe(200);
|
||||
const draftHtml = await draftResponse.text();
|
||||
expect(draftHtml).toContain('Titre brouillon');
|
||||
expect(draftHtml).toContain('Contenu brouillon traduit');
|
||||
expect(draftHtml).toContain('<html lang="fr"');
|
||||
expect(draftHtml).not.toContain('Draft-only body');
|
||||
});
|
||||
|
||||
it('prefers project main language content for canonical single post route and falls back to canonical content when unavailable', async () => {
|
||||
const publishedPost = makePost({
|
||||
id: 'post-1',
|
||||
slug: 'shared-slug',
|
||||
title: 'Published Title',
|
||||
content: 'Published body',
|
||||
status: 'published',
|
||||
language: 'en',
|
||||
createdAt: new Date('2025-01-03T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
server = new PreviewServer({
|
||||
postEngine: {
|
||||
...makeEngine([publishedPost]),
|
||||
async getPostTranslation(postId: string, language: string) {
|
||||
if (postId === 'post-1' && language === 'fr') {
|
||||
return {
|
||||
id: 'translation-1-fr',
|
||||
projectId: 'default',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Titre publie',
|
||||
excerpt: 'Resume FR',
|
||||
content: 'Contenu FR',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-01-03T10:00:00.000Z'),
|
||||
updatedAt: new Date('2025-01-03T11:00:00.000Z'),
|
||||
publishedAt: new Date('2025-01-03T11:00:00.000Z'),
|
||||
filePath: 'posts/shared-slug.fr.md',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as any,
|
||||
settingsEngine: {
|
||||
setProjectContext: vi.fn(),
|
||||
async getProjectMetadata() {
|
||||
return { maxPostsPerPage: 50, mainLanguage: 'fr' };
|
||||
},
|
||||
} as any,
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
|
||||
const translatedResponse = await fetch(`${server.getBaseUrl()}/2025/01/03/shared-slug`);
|
||||
expect(translatedResponse.status).toBe(200);
|
||||
const translatedHtml = await translatedResponse.text();
|
||||
expect(translatedHtml).toContain('<html lang="fr"');
|
||||
expect(translatedHtml).toContain('Titre publie');
|
||||
expect(translatedHtml).toContain('Contenu FR');
|
||||
expect(translatedHtml).not.toContain('Published body');
|
||||
|
||||
server = new PreviewServer({
|
||||
postEngine: {
|
||||
...makeEngine([publishedPost]),
|
||||
async getPostTranslation() {
|
||||
return null;
|
||||
},
|
||||
} as any,
|
||||
settingsEngine: {
|
||||
setProjectContext: vi.fn(),
|
||||
async getProjectMetadata() {
|
||||
return { maxPostsPerPage: 50, mainLanguage: 'fr' };
|
||||
},
|
||||
} as any,
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
|
||||
const fallbackResponse = await fetch(`${server.getBaseUrl()}/2025/01/03/shared-slug`);
|
||||
expect(fallbackResponse.status).toBe(200);
|
||||
const fallbackHtml = await fallbackResponse.text();
|
||||
expect(fallbackHtml).toContain('Published Title');
|
||||
expect(fallbackHtml).toContain('Published body');
|
||||
expect(fallbackHtml).not.toContain('Contenu FR');
|
||||
});
|
||||
|
||||
it('uses selected pico theme stylesheet from project metadata', async () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([makePost()]),
|
||||
@@ -674,11 +810,87 @@ describe('PreviewServer', () => {
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
|
||||
expect(html).toContain('<html lang="en" data-theme="dark">');
|
||||
expect(html).toContain('<html lang="en" data-language-prefix="" data-theme="dark">');
|
||||
expect(html).toContain('href="/assets/pico.green.min.css"');
|
||||
expect(html).toContain('/assets/bds.css');
|
||||
});
|
||||
|
||||
it('renders language switcher with flags on style preview when blogLanguages configured', async () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([makePost()]),
|
||||
settingsEngine: {
|
||||
setProjectContext: vi.fn(),
|
||||
async getProjectMetadata() {
|
||||
return {
|
||||
maxPostsPerPage: 50,
|
||||
mainLanguage: 'en',
|
||||
blogLanguages: ['en', 'de'],
|
||||
};
|
||||
},
|
||||
} as any,
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
|
||||
const response = await fetch(`${server.getBaseUrl()}/__style-preview`);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
|
||||
expect(html).toContain('class="language-switcher"');
|
||||
expect(html).toContain('🇬🇧');
|
||||
expect(html).toContain('🇩🇪');
|
||||
});
|
||||
|
||||
it('includes mainLanguage in language switcher even when not listed in blogLanguages', async () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([makePost()]),
|
||||
settingsEngine: {
|
||||
setProjectContext: vi.fn(),
|
||||
async getProjectMetadata() {
|
||||
return {
|
||||
maxPostsPerPage: 50,
|
||||
mainLanguage: 'de',
|
||||
blogLanguages: ['en'],
|
||||
};
|
||||
},
|
||||
} as any,
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
|
||||
// Check style-preview route
|
||||
const styleResponse = await fetch(`${server.getBaseUrl()}/__style-preview`);
|
||||
expect(styleResponse.status).toBe(200);
|
||||
const styleHtml = await styleResponse.text();
|
||||
expect(styleHtml).toContain('class="language-switcher"');
|
||||
expect(styleHtml).toContain('🇩🇪');
|
||||
expect(styleHtml).toContain('🇬🇧');
|
||||
|
||||
// Check root route
|
||||
const rootResponse = await fetch(`${server.getBaseUrl()}/`);
|
||||
expect(rootResponse.status).toBe(200);
|
||||
const rootHtml = await rootResponse.text();
|
||||
expect(rootHtml).toContain('class="language-switcher"');
|
||||
expect(rootHtml).toContain('🇩🇪');
|
||||
expect(rootHtml).toContain('🇬🇧');
|
||||
|
||||
// Check language-prefixed route (/en/)
|
||||
const enResponse = await fetch(`${server.getBaseUrl()}/en/`);
|
||||
expect(enResponse.status).toBe(200);
|
||||
const enHtml = await enResponse.text();
|
||||
expect(enHtml).toContain('class="language-switcher"');
|
||||
expect(enHtml).toContain('🇩🇪');
|
||||
expect(enHtml).toContain('🇬🇧');
|
||||
});
|
||||
|
||||
it('limits list routes to 50 posts', async () => {
|
||||
const posts = Array.from({ length: 60 }).map((_, index) =>
|
||||
makePost({
|
||||
@@ -1179,7 +1391,7 @@ describe('PreviewServer', () => {
|
||||
await server.start(0);
|
||||
|
||||
const monthPageHtml = await (await fetch(`${server.getBaseUrl()}/2020/2/`)).text();
|
||||
expect(monthPageHtml).toContain('<html lang="fr">');
|
||||
expect(monthPageHtml).toContain('<html lang="fr"');
|
||||
expect(monthPageHtml).toContain('<h1 class="archive-heading">Archives février 2020</h1>');
|
||||
expect(monthPageHtml).not.toContain('<h1 class="archive-heading">Archiv Februar 2020</h1>');
|
||||
});
|
||||
@@ -1579,7 +1791,7 @@ describe('PreviewServer', () => {
|
||||
const response = await fetch(`${server.getBaseUrl()}/`);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
expect(html).toContain('<html lang="de">');
|
||||
expect(html).toContain('<html lang="de"');
|
||||
});
|
||||
|
||||
it('initializes metadata before reading language when supported by settings engine', async () => {
|
||||
@@ -1610,7 +1822,7 @@ describe('PreviewServer', () => {
|
||||
const response = await fetch(`${server.getBaseUrl()}/`);
|
||||
expect(response.status).toBe(200);
|
||||
const html = await response.text();
|
||||
expect(html).toContain('<html lang="fr">');
|
||||
expect(html).toContain('<html lang="fr"');
|
||||
});
|
||||
|
||||
it('falls back to active project name in page title when metadata is unavailable', async () => {
|
||||
@@ -1692,7 +1904,7 @@ describe('PreviewServer', () => {
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
settingsEngine: makeSettings(50),
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
getActiveProjectContext: async () => ({ projectId: 'default', dataDir: '/tmp/default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
@@ -2068,4 +2280,59 @@ describe('PreviewServer', () => {
|
||||
expect(html).toContain('data-template="not-found"');
|
||||
expect(html).toContain('class="not-found"');
|
||||
});
|
||||
|
||||
it('returns 503 after stop is called', async () => {
|
||||
server = new PreviewServer({
|
||||
postEngine: makeEngine([makePost()]),
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
const baseUrl = server.getBaseUrl();
|
||||
|
||||
const okResponse = await fetch(`${baseUrl}/`);
|
||||
expect(okResponse.status).toBe(200);
|
||||
|
||||
await server.stop();
|
||||
|
||||
await expect(fetch(`${baseUrl}/`)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it('includes translation variant slugs in rewrite context using batch method', async () => {
|
||||
const publishedPost = makePost({
|
||||
id: 'post-1',
|
||||
slug: 'hello',
|
||||
status: 'published',
|
||||
language: 'en',
|
||||
createdAt: new Date('2025-02-15T10:00:00.000Z'),
|
||||
});
|
||||
|
||||
const getPublishedTranslationLanguagesByPost = vi.fn(async () => {
|
||||
const map = new Map<string, string[]>();
|
||||
map.set('post-1', ['de', 'fr']);
|
||||
return map;
|
||||
});
|
||||
|
||||
server = new PreviewServer({
|
||||
postEngine: {
|
||||
...makeEngine([publishedPost]),
|
||||
getPublishedTranslationLanguagesByPost,
|
||||
} as any,
|
||||
settingsEngine: makeSettings(50),
|
||||
mediaEngine: makeMediaEngine([]) as any,
|
||||
postMediaEngine: makePostMediaEngine({}) as any,
|
||||
menuEngine: makeMenuEngine({ items: [] }) as any,
|
||||
getActiveProjectContext: async () => ({ projectId: 'default' }),
|
||||
});
|
||||
|
||||
await server.start(0);
|
||||
|
||||
const response = await fetch(`${server.getBaseUrl()}/`);
|
||||
expect(response.status).toBe(200);
|
||||
expect(getPublishedTranslationLanguagesByPost).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -21,6 +21,7 @@ function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||
content: overrides.content ?? `# ${title}\n\nBody`,
|
||||
status: overrides.status ?? 'published',
|
||||
author: overrides.author,
|
||||
language: overrides.language,
|
||||
createdAt,
|
||||
updatedAt,
|
||||
publishedAt: overrides.publishedAt,
|
||||
@@ -50,8 +51,8 @@ function makeEngine(posts: PostData[], snapshotsById: Record<string, PostData |
|
||||
result = result.filter((post) => filter.categories!.some((category) => post.categories.includes(category)));
|
||||
}
|
||||
|
||||
if ((filter as any).excludeCategories && (filter as any).excludeCategories.length > 0) {
|
||||
result = result.filter((post) => !(filter as any).excludeCategories.some((category: string) => post.categories.includes(category)));
|
||||
if (filter.excludeCategories && filter.excludeCategories.length > 0) {
|
||||
result = result.filter((post) => !filter.excludeCategories!.some((category: string) => post.categories.includes(category)));
|
||||
}
|
||||
|
||||
if (filter.year !== undefined) {
|
||||
@@ -116,6 +117,118 @@ describe('SharedSnapshotService', () => {
|
||||
expect(result?.id).toBe('draft-1');
|
||||
});
|
||||
|
||||
it('returns a translated draft variant when preview language is provided', async () => {
|
||||
const draft = makePost({ id: 'draft-1', slug: 'my-post', status: 'draft', language: 'en', title: 'Canonical title', content: 'Canonical body', createdAt: new Date('2025-03-21T10:00:00.000Z') });
|
||||
const engine = {
|
||||
...makeEngine([draft]),
|
||||
async getPostTranslation(postId: string, language: string) {
|
||||
if (postId === 'draft-1' && language === 'fr') {
|
||||
return {
|
||||
id: 'translation-1',
|
||||
translationFor: 'draft-1',
|
||||
language: 'fr',
|
||||
title: 'Titre traduit',
|
||||
excerpt: 'Resume traduit',
|
||||
content: 'Contenu traduit',
|
||||
status: 'draft',
|
||||
createdAt: new Date('2025-03-21T10:00:00.000Z'),
|
||||
updatedAt: new Date('2025-03-21T11:00:00.000Z'),
|
||||
publishedAt: null,
|
||||
filePath: 'posts/my-post.fr.md',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
},
|
||||
} as SharedSnapshotPostEngine & {
|
||||
getPostTranslation: (postId: string, language: string) => Promise<any>;
|
||||
};
|
||||
|
||||
const result = await findSinglePostBySlug(
|
||||
engine,
|
||||
'my-post',
|
||||
{ useDraftContent: true, draftPostId: 'draft-1', lang: 'fr' },
|
||||
{ year: 2025, month: 3, day: 21 },
|
||||
);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
id: 'translation-1',
|
||||
slug: 'my-post.fr',
|
||||
language: 'fr',
|
||||
title: 'Titre traduit',
|
||||
excerpt: 'Resume traduit',
|
||||
content: 'Contenu traduit',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers project main language content and falls back to canonical language when no translation exists', async () => {
|
||||
const published = makePost({
|
||||
id: 'post-1',
|
||||
slug: 'my-post',
|
||||
status: 'published',
|
||||
language: 'en',
|
||||
title: 'Canonical title',
|
||||
content: 'Canonical body',
|
||||
createdAt: new Date('2025-03-21T10:00:00.000Z'),
|
||||
});
|
||||
const getPostTranslation = vi.fn(async (postId: string, language: string) => {
|
||||
if (postId === 'post-1' && language === 'fr') {
|
||||
return {
|
||||
id: 'translation-1',
|
||||
projectId: 'default',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Titre principal',
|
||||
excerpt: 'Resume principal',
|
||||
content: 'Contenu principal',
|
||||
status: 'published',
|
||||
createdAt: new Date('2025-03-21T10:00:00.000Z'),
|
||||
updatedAt: new Date('2025-03-21T11:00:00.000Z'),
|
||||
publishedAt: new Date('2025-03-21T11:00:00.000Z'),
|
||||
filePath: 'posts/my-post.fr.md',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
const engine = {
|
||||
...makeEngine([published]),
|
||||
getPostTranslation,
|
||||
} as SharedSnapshotPostEngine & {
|
||||
getPostTranslation: (postId: string, language: string) => Promise<any>;
|
||||
};
|
||||
|
||||
const translated = await findSinglePostBySlug(
|
||||
engine,
|
||||
'my-post',
|
||||
{ preferredLanguage: 'fr' },
|
||||
{ year: 2025, month: 3, day: 21 },
|
||||
);
|
||||
|
||||
expect(translated).toMatchObject({
|
||||
id: 'translation-1',
|
||||
slug: 'my-post.fr',
|
||||
language: 'fr',
|
||||
title: 'Titre principal',
|
||||
content: 'Contenu principal',
|
||||
});
|
||||
|
||||
const fallback = await findSinglePostBySlug(
|
||||
engine,
|
||||
'my-post',
|
||||
{ preferredLanguage: 'de' },
|
||||
{ year: 2025, month: 3, day: 21 },
|
||||
);
|
||||
|
||||
expect(fallback).toMatchObject({
|
||||
id: 'post-1',
|
||||
slug: 'my-post',
|
||||
language: 'en',
|
||||
title: 'Canonical title',
|
||||
content: 'Canonical body',
|
||||
});
|
||||
expect(getPostTranslation).toHaveBeenNthCalledWith(1, 'post-1', 'fr');
|
||||
expect(getPostTranslation).toHaveBeenNthCalledWith(2, 'post-1', 'de');
|
||||
});
|
||||
|
||||
it('uses findPublishedBySlug shortcut when present', async () => {
|
||||
const post = makePost({ id: 'x1', slug: 'shortcut', status: 'published' });
|
||||
const engine = makeEngine([post]);
|
||||
|
||||
@@ -92,4 +92,49 @@ describe('ValidationApplyPlannerService', () => {
|
||||
expect(targeted.requestedPageSlugs.has('about')).toBe(true);
|
||||
expect(targeted.requestRootRoutes).toBe(true);
|
||||
});
|
||||
|
||||
it('classifies language-prefixed missing paths into per-language plans', () => {
|
||||
const plan = planMissingValidationPaths(
|
||||
[
|
||||
'/fr/',
|
||||
'/fr/page/2',
|
||||
'/fr/category/news',
|
||||
'/fr/tag/dev',
|
||||
'/fr/2025/01/15/my-post',
|
||||
'/fr/2025',
|
||||
'/fr/2025/01',
|
||||
'/fr/about',
|
||||
'/de/',
|
||||
'/de/category/tech',
|
||||
],
|
||||
['fr', 'de'],
|
||||
);
|
||||
|
||||
expect(plan.requiresFallbackSectionRender).toBe(false);
|
||||
expect(plan.requestRootRoutes).toBe(false);
|
||||
|
||||
const frPlan = plan.languagePlans.get('fr');
|
||||
expect(frPlan).toBeDefined();
|
||||
expect(frPlan!.requestRootRoutes).toBe(true);
|
||||
expect(Array.from(frPlan!.requestedCategories)).toEqual(['news']);
|
||||
expect(Array.from(frPlan!.requestedTags)).toEqual(['dev']);
|
||||
expect(frPlan!.requestedPostRoutes).toEqual([
|
||||
{ year: 2025, month: 1, day: 15, slug: 'my-post' },
|
||||
]);
|
||||
expect(Array.from(frPlan!.requestedYears)).toContain(2025);
|
||||
expect(Array.from(frPlan!.requestedYearMonths)).toContain('2025/01');
|
||||
expect(Array.from(frPlan!.requestedPageSlugs)).toEqual(['about']);
|
||||
|
||||
const dePlan = plan.languagePlans.get('de');
|
||||
expect(dePlan).toBeDefined();
|
||||
expect(dePlan!.requestRootRoutes).toBe(true);
|
||||
expect(Array.from(dePlan!.requestedCategories)).toEqual(['tech']);
|
||||
});
|
||||
|
||||
it('treats unknown prefixes as page slugs when no languages specified', () => {
|
||||
const plan = planMissingValidationPaths(['/fr/category/news', '/fr/']);
|
||||
|
||||
expect(plan.languagePlans.size).toBe(0);
|
||||
expect(plan.requiresFallbackSectionRender).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -43,6 +43,7 @@ function createMockDeps() {
|
||||
|
||||
const postEngine = {
|
||||
getPost: vi.fn(),
|
||||
upsertPostTranslation: vi.fn(),
|
||||
} as any;
|
||||
|
||||
return { chatEngine, providers, mediaEngine, postEngine };
|
||||
@@ -182,3 +183,283 @@ describe('OneShotTasks.analyzePost', () => {
|
||||
expect(deps.providers.resolveModel).toHaveBeenCalledWith('custom-model-id');
|
||||
});
|
||||
});
|
||||
|
||||
describe('OneShotTasks.translatePost', () => {
|
||||
let deps: ReturnType<typeof createMockDeps>;
|
||||
let tasks: OneShotTasks;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
deps = createMockDeps();
|
||||
tasks = new OneShotTasks(deps.providers, deps.chatEngine, deps.mediaEngine, deps.postEngine);
|
||||
});
|
||||
|
||||
it('instructs the AI to leave fenced code blocks untranslated', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'Intro\n\n```ts\nconst label = "Hello";\n```',
|
||||
language: 'en',
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Mon Post',
|
||||
excerpt: 'Resume',
|
||||
content: 'Intro\n\n```ts\nconst label = "Hello";\n```',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({
|
||||
text: '{"title":"Mon Post","excerpt":"Resume"}',
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
text: 'Intro\n\n```ts\nconst label = "Hello";\n```',
|
||||
} as any);
|
||||
|
||||
await tasks.translatePost('post-1', 'fr');
|
||||
|
||||
expect(mockGenerateText).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
system: expect.stringContaining('Leave text inside fenced code blocks untranslated.'),
|
||||
}));
|
||||
});
|
||||
|
||||
it('translates markdown body outside a JSON envelope', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'Intro\n\n```ts\nconst config = { hello: "world" };\n```\n\nEnd.',
|
||||
language: 'en',
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Mon Post',
|
||||
excerpt: 'Resume',
|
||||
content: 'Bonjour',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({
|
||||
text: '{"title":"Mon Post","excerpt":"Resume"}',
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
text: 'Introduction\n\n```ts\nconst config = { hello: "world" };\n```\n\nFin.',
|
||||
} as any);
|
||||
|
||||
const result = await tasks.translatePost('post-1', 'fr');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(2);
|
||||
expect(mockGenerateText).toHaveBeenNthCalledWith(1, expect.objectContaining({
|
||||
system: expect.stringContaining('keys title and excerpt'),
|
||||
}));
|
||||
expect(mockGenerateText).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
system: expect.stringContaining('Return ONLY the translated Markdown body'),
|
||||
}));
|
||||
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'fr', {
|
||||
title: 'Mon Post',
|
||||
excerpt: 'Resume',
|
||||
content: 'Introduction\n\n```ts\nconst config = { hello: "world" };\n```\n\nFin.',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes the raw markdown body without a leading content label', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: '# Heading\n\nBody text',
|
||||
language: 'en',
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Mon Post',
|
||||
excerpt: 'Resume',
|
||||
content: '# Titre\n\nTexte',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({
|
||||
text: '{"title":"Mon Post","excerpt":"Resume"}',
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
text: '# Titre\n\nTexte',
|
||||
} as any);
|
||||
|
||||
await tasks.translatePost('post-1', 'fr');
|
||||
|
||||
expect(mockGenerateText).toHaveBeenNthCalledWith(2, expect.objectContaining({
|
||||
prompt: '# Heading\n\nBody text',
|
||||
}));
|
||||
});
|
||||
|
||||
it('strips an accidental leading content label from translated markdown', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'Mein Beitrag',
|
||||
excerpt: 'Zusammenfassung',
|
||||
content: '# Uberschrift\n\nText',
|
||||
language: 'de',
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'en',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: '# Heading\n\nText',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({
|
||||
text: '{"title":"My Post","excerpt":"Summary"}',
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
text: 'content:\n\n# Heading\n\nText',
|
||||
} as any);
|
||||
|
||||
await tasks.translatePost('post-1', 'en');
|
||||
|
||||
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'en', {
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: '# Heading\n\nText',
|
||||
});
|
||||
});
|
||||
|
||||
it('instructs the AI to translate only — never invent or add content', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: '# Hello\n\nWorld',
|
||||
language: 'en',
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Mon Post',
|
||||
excerpt: 'Resume',
|
||||
content: '# Bonjour\n\nMonde',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"title":"Mon Post","excerpt":"Resume"}' } as any)
|
||||
.mockResolvedValueOnce({ text: '# Bonjour\n\nMonde' } as any);
|
||||
|
||||
await tasks.translatePost('post-1', 'fr');
|
||||
|
||||
const contentSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
|
||||
expect(contentSystemPrompt).toMatch(/do not invent|do not add|only translate/i);
|
||||
expect(contentSystemPrompt).toMatch(/macro|shortcode|non-translatable/i);
|
||||
});
|
||||
|
||||
it('instructs the AI to keep markdown link text unchanged and translate surrounding prose', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'USearch Library',
|
||||
excerpt: '',
|
||||
content: '[unum-cloud/USearch: Fast Search](https://github.com/unum-cloud/USearch) - drin was drauf steht. Eine Library.',
|
||||
language: 'de',
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'en',
|
||||
title: 'USearch Library',
|
||||
excerpt: '',
|
||||
content: '[unum-cloud/USearch: Fast Search](https://github.com/unum-cloud/USearch) - what it says on the tin. A library.',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"title":"USearch Library","excerpt":""}' } as any)
|
||||
.mockResolvedValueOnce({
|
||||
text: '[unum-cloud/USearch: Fast Search](https://github.com/unum-cloud/USearch) - what it says on the tin. A library.',
|
||||
} as any);
|
||||
|
||||
await tasks.translatePost('post-1', 'en');
|
||||
|
||||
const contentSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
|
||||
expect(contentSystemPrompt).toMatch(/link text/i);
|
||||
expect(contentSystemPrompt).toMatch(/url/i);
|
||||
});
|
||||
|
||||
it('falls back to source title and excerpt when metadata JSON is invalid', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'Intro',
|
||||
language: 'en',
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'Bonjour',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({
|
||||
text: 'not valid json',
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
text: 'Bonjour',
|
||||
} as any);
|
||||
|
||||
const result = await tasks.translatePost('post-1', 'fr');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'fr', {
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'Bonjour',
|
||||
});
|
||||
});
|
||||
|
||||
it('passes status published when autoPublish is set', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'Hello world',
|
||||
language: 'en',
|
||||
status: 'published',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Mon Post',
|
||||
excerpt: 'Résumé',
|
||||
content: 'Bonjour le monde',
|
||||
});
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({
|
||||
text: '{"title":"Mon Post","excerpt":"Résumé"}',
|
||||
} as any)
|
||||
.mockResolvedValueOnce({
|
||||
text: 'Bonjour le monde',
|
||||
} as any);
|
||||
|
||||
const result = await tasks.translatePost('post-1', 'fr', { autoPublish: true });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(deps.postEngine.upsertPostTranslation).toHaveBeenCalledWith('post-1', 'fr', {
|
||||
title: 'Mon Post',
|
||||
excerpt: 'Résumé',
|
||||
content: 'Bonjour le monde',
|
||||
status: 'published',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
386
tests/engine/ai/languageDetectionBeforeTranslation.test.ts
Normal file
386
tests/engine/ai/languageDetectionBeforeTranslation.test.ts
Normal file
@@ -0,0 +1,386 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
// Mock the AI SDK generateText before importing OneShotTasks
|
||||
vi.mock('ai', () => ({
|
||||
generateText: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock i18n
|
||||
vi.mock('../../../src/main/shared/i18n', () => ({
|
||||
resolveSupportedRenderLanguage: vi.fn((lang: string) => lang),
|
||||
translateRender: vi.fn((_lang: string, key: string) => key),
|
||||
}));
|
||||
|
||||
import { OneShotTasks } from '../../../src/main/engine/ai/tasks';
|
||||
import { generateText } from 'ai';
|
||||
|
||||
const mockGenerateText = vi.mocked(generateText);
|
||||
|
||||
function createMockDeps() {
|
||||
const chatEngine = {
|
||||
getSetting: vi.fn().mockResolvedValue(null),
|
||||
} as any;
|
||||
|
||||
const providers = {
|
||||
detectModelProvider: vi.fn().mockReturnValue('opencode'),
|
||||
isProviderKeySet: vi.fn().mockReturnValue(true),
|
||||
getOpencodeKey: vi.fn().mockReturnValue('test-key'),
|
||||
getMistralKey: vi.fn().mockReturnValue(null),
|
||||
resolveModel: vi.fn().mockReturnValue('mock-model'),
|
||||
isOfflineMode: vi.fn().mockReturnValue(false),
|
||||
isOllamaModel: vi.fn().mockReturnValue(false),
|
||||
isLmstudioModel: vi.fn().mockReturnValue(false),
|
||||
getFirstKnownLocalModelId: vi.fn().mockReturnValue(null),
|
||||
} as any;
|
||||
|
||||
const mediaEngine = {
|
||||
getMedia: vi.fn(),
|
||||
updateMedia: vi.fn(),
|
||||
upsertMediaTranslation: vi.fn(),
|
||||
} as any;
|
||||
|
||||
const postEngine = {
|
||||
getPost: vi.fn(),
|
||||
updatePost: vi.fn(),
|
||||
upsertPostTranslation: vi.fn(),
|
||||
} as any;
|
||||
|
||||
return { chatEngine, providers, mediaEngine, postEngine };
|
||||
}
|
||||
|
||||
describe('translatePost: auto-detect language when not set', () => {
|
||||
let deps: ReturnType<typeof createMockDeps>;
|
||||
let tasks: OneShotTasks;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
deps = createMockDeps();
|
||||
tasks = new OneShotTasks(deps.providers, deps.chatEngine, deps.mediaEngine, deps.postEngine);
|
||||
});
|
||||
|
||||
it('detects and persists language before translating when post has no language', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'Mein Beitrag',
|
||||
excerpt: 'Zusammenfassung',
|
||||
content: 'Dies ist ein deutscher Blogbeitrag über verschiedene Themen.',
|
||||
language: undefined, // No language set
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.updatePost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
language: 'de',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'en',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'This is a German blog post about various topics.',
|
||||
});
|
||||
|
||||
// First call: language detection response
|
||||
// Then: metadata translation response
|
||||
// Then: content translation response
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"language": "de"}' } as any) // language detection
|
||||
.mockResolvedValueOnce({ text: '{"title":"My Post","excerpt":"Summary"}' } as any) // metadata translation
|
||||
.mockResolvedValueOnce({ text: 'This is a German blog post about various topics.' } as any); // content translation
|
||||
|
||||
const result = await tasks.translatePost('post-1', 'en');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Language detection should have been called (first generateText call)
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(3);
|
||||
// Language should have been persisted
|
||||
expect(deps.postEngine.updatePost).toHaveBeenCalledWith('post-1', { language: 'de' });
|
||||
// Translation prompts should use detected language 'de', not fallback 'en'
|
||||
const metadataCall = mockGenerateText.mock.calls[1][0];
|
||||
expect((metadataCall as any).system).toContain('de');
|
||||
});
|
||||
|
||||
it('skips detection when post has explicit language set', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'This is an English blog post.',
|
||||
language: 'en', // Explicitly set
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Mon Article',
|
||||
excerpt: 'Résumé',
|
||||
content: 'Ceci est un article de blog en anglais.',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"title":"Mon Article","excerpt":"Résumé"}' } as any) // metadata translation
|
||||
.mockResolvedValueOnce({ text: 'Ceci est un article de blog en anglais.' } as any); // content translation
|
||||
|
||||
const result = await tasks.translatePost('post-1', 'fr');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// Only 2 calls: metadata + content translation, NO language detection
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(2);
|
||||
// updatePost should NOT have been called for language
|
||||
expect(deps.postEngine.updatePost).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proceeds with fallback when language detection fails', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'My Post',
|
||||
excerpt: 'Summary',
|
||||
content: 'Some content here.',
|
||||
language: undefined,
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'fr',
|
||||
title: 'Mon Article',
|
||||
excerpt: 'Résumé',
|
||||
content: 'Du contenu ici.',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: 'garbage response' } as any) // language detection fails
|
||||
.mockResolvedValueOnce({ text: '{"title":"Mon Article","excerpt":"Résumé"}' } as any)
|
||||
.mockResolvedValueOnce({ text: 'Du contenu ici.' } as any);
|
||||
|
||||
const result = await tasks.translatePost('post-1', 'fr');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
// updatePost should NOT have been called since detection failed
|
||||
expect(deps.postEngine.updatePost).not.toHaveBeenCalled();
|
||||
// Should still proceed with translation (3 calls total: detection + 2 translation)
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it('uses detected language in translation prompts', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'Mon Article',
|
||||
excerpt: '',
|
||||
content: 'Ceci est un article français.',
|
||||
language: undefined,
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.updatePost.mockResolvedValue({ id: 'post-1', language: 'fr' });
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'en',
|
||||
title: 'My Article',
|
||||
excerpt: '',
|
||||
content: 'This is a French article.',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"language": "fr"}' } as any)
|
||||
.mockResolvedValueOnce({ text: '{"title":"My Article","excerpt":""}' } as any)
|
||||
.mockResolvedValueOnce({ text: 'This is a French article.' } as any);
|
||||
|
||||
await tasks.translatePost('post-1', 'en');
|
||||
|
||||
// The translation prompt should use 'fr' as source language
|
||||
const metadataSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
|
||||
const contentSystemPrompt = mockGenerateText.mock.calls[2][0].system as string;
|
||||
expect(metadataSystemPrompt).toContain('from fr to en');
|
||||
expect(contentSystemPrompt).toContain('from fr to en');
|
||||
});
|
||||
|
||||
it('treats null language the same as undefined (no language)', async () => {
|
||||
deps.postEngine.getPost.mockResolvedValue({
|
||||
id: 'post-1',
|
||||
title: 'Test Post',
|
||||
excerpt: '',
|
||||
content: 'Some content.',
|
||||
language: null, // null, not undefined
|
||||
status: 'draft',
|
||||
});
|
||||
deps.postEngine.updatePost.mockResolvedValue({ id: 'post-1', language: 'en' });
|
||||
deps.postEngine.upsertPostTranslation.mockResolvedValue({
|
||||
id: 'translation-1',
|
||||
translationFor: 'post-1',
|
||||
language: 'de',
|
||||
title: 'Test',
|
||||
excerpt: '',
|
||||
content: 'Inhalt.',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"language": "en"}' } as any)
|
||||
.mockResolvedValueOnce({ text: '{"title":"Test","excerpt":""}' } as any)
|
||||
.mockResolvedValueOnce({ text: 'Inhalt.' } as any);
|
||||
|
||||
await tasks.translatePost('post-1', 'de');
|
||||
|
||||
// Should have called detection since language is null
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(3);
|
||||
expect(deps.postEngine.updatePost).toHaveBeenCalledWith('post-1', { language: 'en' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('translateMediaMetadata: auto-detect language when not set', () => {
|
||||
let deps: ReturnType<typeof createMockDeps>;
|
||||
let tasks: OneShotTasks;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
deps = createMockDeps();
|
||||
tasks = new OneShotTasks(deps.providers, deps.chatEngine, deps.mediaEngine, deps.postEngine);
|
||||
});
|
||||
|
||||
it('detects and persists language before translating when media has no language', async () => {
|
||||
deps.mediaEngine.getMedia.mockResolvedValue({
|
||||
id: 'media-1',
|
||||
title: 'Sonnenuntergang am Meer',
|
||||
alt: 'Ein wunderschöner Sonnenuntergang',
|
||||
caption: 'Aufgenommen im Sommer',
|
||||
language: undefined,
|
||||
});
|
||||
deps.mediaEngine.updateMedia.mockResolvedValue({
|
||||
id: 'media-1',
|
||||
language: 'de',
|
||||
});
|
||||
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
|
||||
id: 'mt-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'en',
|
||||
title: 'Sunset at Sea',
|
||||
alt: 'A beautiful sunset',
|
||||
caption: 'Taken in summer',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"language": "de"}' } as any) // language detection
|
||||
.mockResolvedValueOnce({ text: '{"title":"Sunset at Sea","alt":"A beautiful sunset","caption":"Taken in summer"}' } as any); // translation
|
||||
|
||||
const result = await tasks.translateMediaMetadata('media-1', 'en');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(2);
|
||||
expect(deps.mediaEngine.updateMedia).toHaveBeenCalledWith('media-1', { language: 'de' });
|
||||
// Translation prompt should use detected language 'de'
|
||||
const translationCall = mockGenerateText.mock.calls[1][0];
|
||||
expect((translationCall as any).system).toContain('de');
|
||||
});
|
||||
|
||||
it('skips detection when media has explicit language set', async () => {
|
||||
deps.mediaEngine.getMedia.mockResolvedValue({
|
||||
id: 'media-1',
|
||||
title: 'Sunset at Sea',
|
||||
alt: 'A beautiful sunset',
|
||||
caption: 'Taken in summer',
|
||||
language: 'en',
|
||||
});
|
||||
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
|
||||
id: 'mt-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'de',
|
||||
title: 'Sonnenuntergang',
|
||||
alt: 'Schön',
|
||||
caption: 'Im Sommer',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"title":"Sonnenuntergang","alt":"Schön","caption":"Im Sommer"}' } as any);
|
||||
|
||||
const result = await tasks.translateMediaMetadata('media-1', 'de');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(1);
|
||||
expect(deps.mediaEngine.updateMedia).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('proceeds with fallback when media language detection fails', async () => {
|
||||
deps.mediaEngine.getMedia.mockResolvedValue({
|
||||
id: 'media-1',
|
||||
title: 'Test Image',
|
||||
alt: 'Alt text',
|
||||
caption: 'Caption',
|
||||
language: undefined,
|
||||
});
|
||||
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
|
||||
id: 'mt-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'de',
|
||||
title: 'Testbild',
|
||||
alt: 'Alt',
|
||||
caption: 'Beschriftung',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: 'garbage' } as any) // detection fails
|
||||
.mockResolvedValueOnce({ text: '{"title":"Testbild","alt":"Alt","caption":"Beschriftung"}' } as any);
|
||||
|
||||
const result = await tasks.translateMediaMetadata('media-1', 'de');
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(deps.mediaEngine.updateMedia).not.toHaveBeenCalled();
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('uses detected language in media translation prompts', async () => {
|
||||
deps.mediaEngine.getMedia.mockResolvedValue({
|
||||
id: 'media-1',
|
||||
title: 'Tramonto sul mare',
|
||||
alt: 'Un bel tramonto',
|
||||
caption: 'Fotografato in estate',
|
||||
language: undefined,
|
||||
});
|
||||
deps.mediaEngine.updateMedia.mockResolvedValue({ id: 'media-1', language: 'it' });
|
||||
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
|
||||
id: 'mt-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'en',
|
||||
title: 'Sunset',
|
||||
alt: 'Beautiful',
|
||||
caption: 'Summer',
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"language": "it"}' } as any)
|
||||
.mockResolvedValueOnce({ text: '{"title":"Sunset","alt":"Beautiful","caption":"Summer"}' } as any);
|
||||
|
||||
await tasks.translateMediaMetadata('media-1', 'en');
|
||||
|
||||
const translationSystemPrompt = mockGenerateText.mock.calls[1][0].system as string;
|
||||
expect(translationSystemPrompt).toContain('from it to en');
|
||||
});
|
||||
|
||||
it('treats null language the same as undefined (no language)', async () => {
|
||||
deps.mediaEngine.getMedia.mockResolvedValue({
|
||||
id: 'media-1',
|
||||
title: 'Test',
|
||||
alt: 'Alt',
|
||||
caption: null,
|
||||
language: null,
|
||||
});
|
||||
deps.mediaEngine.updateMedia.mockResolvedValue({ id: 'media-1', language: 'en' });
|
||||
deps.mediaEngine.upsertMediaTranslation.mockResolvedValue({
|
||||
id: 'mt-1',
|
||||
translationFor: 'media-1',
|
||||
language: 'de',
|
||||
title: 'Test',
|
||||
alt: 'Alt',
|
||||
caption: null,
|
||||
});
|
||||
|
||||
mockGenerateText
|
||||
.mockResolvedValueOnce({ text: '{"language": "en"}' } as any)
|
||||
.mockResolvedValueOnce({ text: '{"title":"Test","alt":"Alt","caption":null}' } as any);
|
||||
|
||||
await tasks.translateMediaMetadata('media-1', 'de');
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledTimes(2);
|
||||
expect(deps.mediaEngine.updateMedia).toHaveBeenCalledWith('media-1', { language: 'en' });
|
||||
});
|
||||
});
|
||||
116
tests/engine/ai/retryWithBackoff.test.ts
Normal file
116
tests/engine/ai/retryWithBackoff.test.ts
Normal file
@@ -0,0 +1,116 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { retryWithBackoff } from '../../../src/main/engine/ai/retry';
|
||||
|
||||
describe('retryWithBackoff', () => {
|
||||
it('returns immediately on success (no retries)', async () => {
|
||||
const fn = vi.fn(async () => ({ success: true, value: 42 }));
|
||||
|
||||
const result = await retryWithBackoff(fn);
|
||||
|
||||
expect(result).toEqual({ success: true, value: 42 });
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('retries up to maxRetries times with exponential delays on failure', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fn = vi.fn(async () => ({ success: false, error: 'rate limited' }));
|
||||
|
||||
const promise = retryWithBackoff(fn, { maxRetries: 3, baseDelayMs: 5000 });
|
||||
|
||||
// Initial call happens immediately
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Retry 1 after 5s
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
|
||||
// Retry 2 after 10s
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
expect(fn).toHaveBeenCalledTimes(3);
|
||||
|
||||
// Retry 3 after 20s
|
||||
await vi.advanceTimersByTimeAsync(20000);
|
||||
expect(fn).toHaveBeenCalledTimes(4);
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toEqual({ success: false, error: 'rate limited' });
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('stops retrying once the function succeeds', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fn = vi.fn()
|
||||
.mockResolvedValueOnce({ success: false, error: 'fail' })
|
||||
.mockResolvedValueOnce({ success: true, value: 'ok' });
|
||||
|
||||
const promise = retryWithBackoff(fn, { maxRetries: 3, baseDelayMs: 5000 });
|
||||
|
||||
// Initial call fails
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(fn).toHaveBeenCalledTimes(1);
|
||||
|
||||
// Retry 1 after 5s — succeeds
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
|
||||
const result = await promise;
|
||||
expect(result).toEqual({ success: true, value: 'ok' });
|
||||
// Should not retry further
|
||||
expect(fn).toHaveBeenCalledTimes(2);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses default 3 retries with 5s base delay', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const fn = vi.fn(async () => ({ success: false }));
|
||||
|
||||
const promise = retryWithBackoff(fn);
|
||||
|
||||
// Initial + 3 retries = 4 total calls
|
||||
await vi.advanceTimersByTimeAsync(0); // initial
|
||||
await vi.advanceTimersByTimeAsync(5000); // retry 1 (5s)
|
||||
await vi.advanceTimersByTimeAsync(10000); // retry 2 (10s)
|
||||
await vi.advanceTimersByTimeAsync(20000); // retry 3 (20s)
|
||||
|
||||
await promise;
|
||||
expect(fn).toHaveBeenCalledTimes(4);
|
||||
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('applies exponential doubling: 5s, 10s, 20s', async () => {
|
||||
vi.useFakeTimers();
|
||||
|
||||
const delays: number[] = [];
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, 'setTimeout');
|
||||
|
||||
const fn = vi.fn(async () => ({ success: false }));
|
||||
|
||||
const promise = retryWithBackoff(fn, { maxRetries: 3, baseDelayMs: 5000 });
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(5000);
|
||||
await vi.advanceTimersByTimeAsync(10000);
|
||||
await vi.advanceTimersByTimeAsync(20000);
|
||||
|
||||
await promise;
|
||||
|
||||
// Extract the delay values passed to setTimeout for our retries
|
||||
const timeoutCalls = setTimeoutSpy.mock.calls
|
||||
.filter(([, ms]) => typeof ms === 'number' && ms >= 5000)
|
||||
.map(([, ms]) => ms);
|
||||
|
||||
expect(timeoutCalls).toContain(5000);
|
||||
expect(timeoutCalls).toContain(10000);
|
||||
expect(timeoutCalls).toContain(20000);
|
||||
|
||||
setTimeoutSpy.mockRestore();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
@@ -24,4 +24,12 @@ describe('main/shared locale completeness', () => {
|
||||
expect(localeKeys, `Locale ${locale} is missing or has extra keys`).toEqual(englishKeys);
|
||||
}
|
||||
});
|
||||
|
||||
it('includes a native-menu label for validate translations', () => {
|
||||
expect((en as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
|
||||
expect((de as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
|
||||
expect((fr as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
|
||||
expect((itLocale as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
|
||||
expect((es as LocaleMap)['menu.item.validateTranslations']).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -3,6 +3,7 @@ import {
|
||||
resolveSupportedRenderLanguage,
|
||||
resolveRenderLanguageFromProjectPreferences,
|
||||
translateRender,
|
||||
getRenderTranslations,
|
||||
} from '../../src/main/shared/i18n';
|
||||
|
||||
describe('render i18n', () => {
|
||||
@@ -24,4 +25,17 @@ describe('render i18n', () => {
|
||||
expect(translateRender('es', 'render.pagination.older')).toBe('más antiguo');
|
||||
expect(translateRender('fr', 'missing.key')).toBe('missing.key');
|
||||
});
|
||||
|
||||
it('returns full translation map for a language', () => {
|
||||
const translations = getRenderTranslations('de');
|
||||
expect(translations).toBeDefined();
|
||||
expect(typeof translations).toBe('object');
|
||||
expect(translations['render.pagination.newer']).toBe('neuer');
|
||||
expect(translations['render.archive']).toBe('Archiv');
|
||||
});
|
||||
|
||||
it('falls back to English for unsupported languages', () => {
|
||||
const translations = getRenderTranslations('en');
|
||||
expect(translations['render.archive']).toBe('Archive');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -12,6 +12,9 @@ const mockPostEngine: Record<string, ReturnType<typeof vi.fn>> = {
|
||||
updatePost: vi.fn().mockResolvedValue(null),
|
||||
deletePost: vi.fn().mockResolvedValue(true),
|
||||
publishPost: vi.fn().mockResolvedValue(null),
|
||||
publishPostTranslation: vi.fn().mockResolvedValue({ id: 'tr1', language: 'fr' }),
|
||||
getPostTranslation: vi.fn().mockResolvedValue({ id: 'tr1', language: 'en', content: 'hello' }),
|
||||
getPostTranslations: vi.fn().mockResolvedValue([{ id: 'tr1', language: 'en' }]),
|
||||
discardChanges: vi.fn().mockResolvedValue(null),
|
||||
hasPublishedVersion: vi.fn().mockResolvedValue(false),
|
||||
rebuildDatabaseFromFiles: vi.fn().mockResolvedValue(undefined),
|
||||
@@ -431,6 +434,21 @@ describe('invokeMainProcessPythonApi', () => {
|
||||
expect(mockPostEngine.isSlugAvailable).toHaveBeenCalledWith('test', undefined);
|
||||
});
|
||||
|
||||
it('routes posts.publishTranslation to postEngine.publishPostTranslation', async () => {
|
||||
await invokeMainProcessPythonApi('posts.publishTranslation', { postId: 'p1', language: 'fr' });
|
||||
expect(mockPostEngine.publishPostTranslation).toHaveBeenCalledWith('p1', 'fr');
|
||||
});
|
||||
|
||||
it('routes posts.getTranslation to postEngine.getPostTranslation', async () => {
|
||||
await invokeMainProcessPythonApi('posts.getTranslation', { postId: 'p1', language: 'en' });
|
||||
expect(mockPostEngine.getPostTranslation).toHaveBeenCalledWith('p1', 'en');
|
||||
});
|
||||
|
||||
it('routes posts.getTranslations to postEngine.getPostTranslations', async () => {
|
||||
await invokeMainProcessPythonApi('posts.getTranslations', { postId: 'p1' });
|
||||
expect(mockPostEngine.getPostTranslations).toHaveBeenCalledWith('p1');
|
||||
});
|
||||
|
||||
it('handles null args gracefully (normalizes to empty record)', async () => {
|
||||
await expect(
|
||||
invokeMainProcessPythonApi('posts.get', null as unknown as Record<string, unknown>),
|
||||
|
||||
@@ -1221,6 +1221,7 @@ describe('main bootstrap preview behavior', () => {
|
||||
createPost,
|
||||
setProjectContext: vi.fn(),
|
||||
setSearchLanguage: vi.fn(),
|
||||
setMainLanguage: vi.fn(),
|
||||
}; }),
|
||||
}));
|
||||
|
||||
|
||||
483
tests/engine/multilinguality.test.ts
Normal file
483
tests/engine/multilinguality.test.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import type { PostData } from '../../src/main/engine/PostEngine';
|
||||
import {
|
||||
applyLanguagePrefixToHtml,
|
||||
PageRenderer,
|
||||
type HtmlRewriteContext,
|
||||
} from '../../src/main/engine/PageRenderer';
|
||||
import {
|
||||
buildSitemapAndFeeds,
|
||||
buildMultiLanguageSitemap,
|
||||
type GenerationPostIndexLike,
|
||||
} from '../../src/main/engine/GenerationSitemapFeedService';
|
||||
|
||||
function makePost(overrides: Partial<PostData> = {}): PostData {
|
||||
const createdAt = overrides.createdAt ?? new Date('2025-03-08T10:00:00.000Z');
|
||||
return {
|
||||
id: overrides.id ?? 'post-1',
|
||||
projectId: overrides.projectId ?? 'default',
|
||||
title: overrides.title ?? 'Test Post',
|
||||
slug: overrides.slug ?? 'test-post',
|
||||
excerpt: overrides.excerpt,
|
||||
content: overrides.content ?? '# Test\n\nBody text',
|
||||
status: overrides.status ?? 'published',
|
||||
author: overrides.author,
|
||||
language: overrides.language ?? 'en',
|
||||
createdAt,
|
||||
updatedAt: overrides.updatedAt ?? createdAt,
|
||||
publishedAt: overrides.publishedAt,
|
||||
tags: overrides.tags ?? [],
|
||||
categories: overrides.categories ?? [],
|
||||
};
|
||||
}
|
||||
|
||||
function buildIndex(posts: PostData[]): GenerationPostIndexLike {
|
||||
const postsByCategory = new Map<string, PostData[]>();
|
||||
const postsByTag = new Map<string, PostData[]>();
|
||||
const postsByYear = new Map<number, PostData[]>();
|
||||
const postsByYearMonth = new Map<string, PostData[]>();
|
||||
const postsByYearMonthDay = new Map<string, PostData[]>();
|
||||
|
||||
for (const post of posts) {
|
||||
for (const category of (post.categories ?? [])) {
|
||||
postsByCategory.set(category, [...(postsByCategory.get(category) ?? []), post]);
|
||||
}
|
||||
for (const tag of (post.tags ?? [])) {
|
||||
postsByTag.set(tag, [...(postsByTag.get(tag) ?? []), post]);
|
||||
}
|
||||
const year = post.createdAt.getFullYear();
|
||||
const month = String(post.createdAt.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(post.createdAt.getDate()).padStart(2, '0');
|
||||
postsByYear.set(year, [...(postsByYear.get(year) ?? []), post]);
|
||||
postsByYearMonth.set(`${year}/${month}`, [...(postsByYearMonth.get(`${year}/${month}`) ?? []), post]);
|
||||
postsByYearMonthDay.set(`${year}/${month}/${day}`, [...(postsByYearMonthDay.get(`${year}/${month}/${day}`) ?? []), post]);
|
||||
}
|
||||
|
||||
return { postsByCategory, postsByTag, postsByYear, postsByYearMonth, postsByYearMonthDay };
|
||||
}
|
||||
|
||||
describe('applyLanguagePrefixToHtml', () => {
|
||||
it('prefixes internal hrefs with language prefix', () => {
|
||||
const html = '<a href="/2025/03/08/my-post/">Post</a>';
|
||||
const result = applyLanguagePrefixToHtml(html, '/de');
|
||||
expect(result).toBe('<a href="/de/2025/03/08/my-post/">Post</a>');
|
||||
});
|
||||
|
||||
it('does not prefix media or asset paths', () => {
|
||||
const html = '<img src="/media/2025/03/photo.jpg" /><link href="/assets/bds.css" />';
|
||||
const result = applyLanguagePrefixToHtml(html, '/de');
|
||||
expect(result).toBe(html);
|
||||
});
|
||||
|
||||
it('does not double-prefix already prefixed hrefs', () => {
|
||||
const html = '<a href="/de/2025/03/08/my-post/">Post</a>';
|
||||
const result = applyLanguagePrefixToHtml(html, '/de');
|
||||
expect(result).toBe('<a href="/de/2025/03/08/my-post/">Post</a>');
|
||||
});
|
||||
|
||||
it('prefixes root href', () => {
|
||||
const html = '<a href="/">Home</a>';
|
||||
const result = applyLanguagePrefixToHtml(html, '/fr');
|
||||
expect(result).toBe('<a href="/fr/">Home</a>');
|
||||
});
|
||||
|
||||
it('prefixes category and tag hrefs', () => {
|
||||
const html = '<a href="/category/tech/">Tech</a><a href="/tag/js/">JS</a>';
|
||||
const result = applyLanguagePrefixToHtml(html, '/es');
|
||||
expect(result).toBe('<a href="/es/category/tech/">Tech</a><a href="/es/tag/js/">JS</a>');
|
||||
});
|
||||
|
||||
it('returns html unchanged when prefix is empty', () => {
|
||||
const html = '<a href="/some/path">Link</a>';
|
||||
const result = applyLanguagePrefixToHtml(html, '');
|
||||
expect(result).toBe(html);
|
||||
});
|
||||
|
||||
it('prefixes pagination hrefs', () => {
|
||||
const html = '<a href="/page/2">Next</a>';
|
||||
const result = applyLanguagePrefixToHtml(html, '/de');
|
||||
expect(result).toBe('<a href="/de/page/2">Next</a>');
|
||||
});
|
||||
|
||||
it('handles both single and double quotes', () => {
|
||||
const html = `<a href='/tag/foo/'>Foo</a><a href="/tag/bar/">Bar</a>`;
|
||||
const result = applyLanguagePrefixToHtml(html, '/it');
|
||||
expect(result).toBe(`<a href='/it/tag/foo/'>Foo</a><a href="/it/tag/bar/">Bar</a>`);
|
||||
});
|
||||
});
|
||||
|
||||
describe('data-language-prefix on html tag', () => {
|
||||
it('renders data-language-prefix on post-list html tag', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderPostList(
|
||||
[makePost()],
|
||||
{
|
||||
canonicalPostPathBySlug: new Map(),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
archiveGrouping: false,
|
||||
routeKind: 'date',
|
||||
basePathname: '/',
|
||||
page_title: 'Blog',
|
||||
language: 'fr',
|
||||
language_prefix: '/fr',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('data-language-prefix="/fr"');
|
||||
});
|
||||
|
||||
it('renders empty data-language-prefix for main language', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderPostList(
|
||||
[makePost()],
|
||||
{
|
||||
canonicalPostPathBySlug: new Map(),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
archiveGrouping: false,
|
||||
routeKind: 'date',
|
||||
basePathname: '/',
|
||||
page_title: 'Blog',
|
||||
language: 'en',
|
||||
language_prefix: '',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('data-language-prefix=""');
|
||||
});
|
||||
|
||||
it('renders data-language-prefix on single-post html tag', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderSinglePost(
|
||||
makePost({ content: 'Hello world' }),
|
||||
{
|
||||
canonicalPostPathBySlug: new Map(),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
page_title: 'Blog',
|
||||
language: 'de',
|
||||
language_prefix: '/de',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('data-language-prefix="/de"');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Feed language filtering', () => {
|
||||
it('includes feedLanguage element in RSS when specified', () => {
|
||||
const posts = [makePost({ id: '1', slug: 'a', title: 'Post A' })];
|
||||
const result = buildSitemapAndFeeds({
|
||||
baseUrl: 'https://example.com',
|
||||
projectName: 'Blog',
|
||||
maxPostsPerPage: 10,
|
||||
publishedPosts: posts,
|
||||
publishedListPosts: posts,
|
||||
postIndex: buildIndex(posts),
|
||||
includeFeeds: true,
|
||||
feedLanguage: 'de',
|
||||
});
|
||||
|
||||
expect(result.rssXml).toContain('<language>de</language>');
|
||||
});
|
||||
|
||||
it('includes xml:lang in Atom feed when feedLanguage is specified', () => {
|
||||
const posts = [makePost({ id: '1', slug: 'a', title: 'Post A' })];
|
||||
const result = buildSitemapAndFeeds({
|
||||
baseUrl: 'https://example.com',
|
||||
projectName: 'Blog',
|
||||
maxPostsPerPage: 10,
|
||||
publishedPosts: posts,
|
||||
publishedListPosts: posts,
|
||||
postIndex: buildIndex(posts),
|
||||
includeFeeds: true,
|
||||
feedLanguage: 'fr',
|
||||
});
|
||||
|
||||
expect(result.atomXml).toContain('xml:lang="fr"');
|
||||
});
|
||||
|
||||
it('omits language elements when feedLanguage is not specified', () => {
|
||||
const posts = [makePost({ id: '1', slug: 'a', title: 'Post A' })];
|
||||
const result = buildSitemapAndFeeds({
|
||||
baseUrl: 'https://example.com',
|
||||
projectName: 'Blog',
|
||||
maxPostsPerPage: 10,
|
||||
publishedPosts: posts,
|
||||
publishedListPosts: posts,
|
||||
postIndex: buildIndex(posts),
|
||||
includeFeeds: true,
|
||||
});
|
||||
|
||||
expect(result.rssXml).not.toContain('<language>');
|
||||
expect(result.atomXml).not.toMatch(/<feed[^>]+xml:lang=/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMultiLanguageSitemap', () => {
|
||||
it('generates hreflang links for translatable posts in all languages', () => {
|
||||
const post = makePost({ id: '1', slug: 'hello', title: 'Hello' });
|
||||
const postIndex = buildIndex([post]);
|
||||
|
||||
const sitemap = buildMultiLanguageSitemap({
|
||||
baseUrl: 'https://example.com',
|
||||
mainLanguage: 'en',
|
||||
allLanguages: ['en', 'de'],
|
||||
translatablePosts: [post],
|
||||
doNotTranslatePosts: [],
|
||||
publishedListPosts: [post],
|
||||
maxPostsPerPage: 10,
|
||||
postIndex,
|
||||
});
|
||||
|
||||
expect(sitemap).toContain('xmlns:xhtml="http://www.w3.org/1999/xhtml"');
|
||||
expect(sitemap).toContain('hreflang="en"');
|
||||
expect(sitemap).toContain('hreflang="de"');
|
||||
expect(sitemap).toContain('hreflang="x-default"');
|
||||
// Main language post URL is unprefixed
|
||||
expect(sitemap).toContain('href="https://example.com/2025/03/08/hello/"');
|
||||
// Alternative language post URL is prefixed
|
||||
expect(sitemap).toContain('href="https://example.com/de/2025/03/08/hello/"');
|
||||
});
|
||||
|
||||
it('generates hreflang links only for main language for doNotTranslate posts', () => {
|
||||
const dntPost = makePost({ id: '2', slug: 'private-note', title: 'Private' });
|
||||
(dntPost as PostData & { doNotTranslate?: boolean }).doNotTranslate = true;
|
||||
const postIndex = buildIndex([dntPost]);
|
||||
|
||||
const sitemap = buildMultiLanguageSitemap({
|
||||
baseUrl: 'https://example.com',
|
||||
mainLanguage: 'en',
|
||||
allLanguages: ['en', 'de', 'fr'],
|
||||
translatablePosts: [],
|
||||
doNotTranslatePosts: [dntPost],
|
||||
publishedListPosts: [dntPost],
|
||||
maxPostsPerPage: 10,
|
||||
postIndex,
|
||||
});
|
||||
|
||||
// The doNotTranslate post URL entry should exist
|
||||
expect(sitemap).toContain('https://example.com/2025/03/08/private-note/');
|
||||
// But it should NOT have de or fr hreflang links for this specific post
|
||||
const postUrlBlock = sitemap.split('<url>').find((block) => block.includes('private-note'));
|
||||
expect(postUrlBlock).toBeDefined();
|
||||
expect(postUrlBlock).toContain('hreflang="en"');
|
||||
expect(postUrlBlock).not.toContain('hreflang="de"');
|
||||
expect(postUrlBlock).not.toContain('hreflang="fr"');
|
||||
});
|
||||
|
||||
it('includes root page and pagination in all languages', () => {
|
||||
const posts = Array.from({ length: 15 }, (_, i) =>
|
||||
makePost({
|
||||
id: `p-${i}`,
|
||||
slug: `post-${i}`,
|
||||
createdAt: new Date(`2025-03-${String(i + 1).padStart(2, '0')}T10:00:00Z`),
|
||||
}),
|
||||
);
|
||||
const postIndex = buildIndex(posts);
|
||||
|
||||
const sitemap = buildMultiLanguageSitemap({
|
||||
baseUrl: 'https://example.com',
|
||||
mainLanguage: 'en',
|
||||
allLanguages: ['en', 'de'],
|
||||
translatablePosts: posts,
|
||||
doNotTranslatePosts: [],
|
||||
publishedListPosts: posts,
|
||||
maxPostsPerPage: 10,
|
||||
postIndex,
|
||||
});
|
||||
|
||||
// Root page has both languages
|
||||
expect(sitemap).toContain('https://example.com/');
|
||||
expect(sitemap).toContain('https://example.com/de/');
|
||||
// Pagination page 2
|
||||
expect(sitemap).toContain('https://example.com/page/2');
|
||||
expect(sitemap).toContain('https://example.com/de/page/2');
|
||||
});
|
||||
|
||||
it('includes archive, category, and tag URLs in all languages', () => {
|
||||
const post = makePost({
|
||||
id: '1', slug: 'tagged', tags: ['javascript'], categories: ['tutorial'],
|
||||
});
|
||||
const postIndex = buildIndex([post]);
|
||||
|
||||
const sitemap = buildMultiLanguageSitemap({
|
||||
baseUrl: 'https://example.com',
|
||||
mainLanguage: 'en',
|
||||
allLanguages: ['en', 'fr'],
|
||||
translatablePosts: [post],
|
||||
doNotTranslatePosts: [],
|
||||
publishedListPosts: [post],
|
||||
maxPostsPerPage: 10,
|
||||
postIndex,
|
||||
});
|
||||
|
||||
expect(sitemap).toContain('https://example.com/category/tutorial');
|
||||
expect(sitemap).toContain('https://example.com/fr/category/tutorial');
|
||||
expect(sitemap).toContain('https://example.com/tag/javascript');
|
||||
expect(sitemap).toContain('https://example.com/fr/tag/javascript');
|
||||
expect(sitemap).toContain('https://example.com/2025/');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Language switcher in templates', () => {
|
||||
it('renders language switcher badges when blog_languages has multiple entries', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderPostList(
|
||||
[makePost()],
|
||||
{
|
||||
canonicalPostPathBySlug: new Map([['test-post', '/2025/03/08/test-post']]),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
archiveGrouping: true,
|
||||
routeKind: 'date',
|
||||
archiveContext: { kind: 'root' },
|
||||
basePathname: '/',
|
||||
page_title: 'Blog',
|
||||
language: 'en',
|
||||
blog_languages: [
|
||||
{ code: 'en', flag: '🇬🇧', href_prefix: '', is_current: true },
|
||||
{ code: 'de', flag: '🇩🇪', href_prefix: '/de', is_current: false },
|
||||
],
|
||||
current_language: 'en',
|
||||
language_prefix: '',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('class="language-switcher"');
|
||||
expect(html).toContain('class="language-switcher-badge language-switcher-badge-current"');
|
||||
expect(html).toContain('🇬🇧');
|
||||
expect(html).toContain('href="/de"');
|
||||
expect(html).toContain('🇩🇪');
|
||||
});
|
||||
|
||||
it('does not render language switcher when blog_languages has one or zero entries', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderPostList(
|
||||
[makePost()],
|
||||
{
|
||||
canonicalPostPathBySlug: new Map(),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
archiveGrouping: false,
|
||||
routeKind: 'date',
|
||||
basePathname: '/',
|
||||
page_title: 'Blog',
|
||||
language: 'en',
|
||||
blog_languages: [{ code: 'en', flag: '🇬🇧', href_prefix: '', is_current: true }],
|
||||
current_language: 'en',
|
||||
language_prefix: '',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).not.toContain('class="language-switcher"');
|
||||
});
|
||||
|
||||
it('renders language switcher in single post template', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderSinglePost(
|
||||
makePost({ content: 'Hello world' }),
|
||||
{
|
||||
canonicalPostPathBySlug: new Map(),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
page_title: 'Blog',
|
||||
language: 'en',
|
||||
blog_languages: [
|
||||
{ code: 'en', flag: '🇬🇧', href_prefix: '', is_current: false },
|
||||
{ code: 'de', flag: '🇩🇪', href_prefix: '/de', is_current: true },
|
||||
],
|
||||
current_language: 'de',
|
||||
language_prefix: '/de',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('class="language-switcher"');
|
||||
expect(html).toContain('aria-current="true"');
|
||||
expect(html).toContain('🇩🇪');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Per-language feed links in head', () => {
|
||||
it('renders language-prefixed feed links in head partial', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderPostList(
|
||||
[makePost()],
|
||||
{
|
||||
canonicalPostPathBySlug: new Map(),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
archiveGrouping: false,
|
||||
routeKind: 'date',
|
||||
basePathname: '/',
|
||||
page_title: 'Blog',
|
||||
language: 'de',
|
||||
language_prefix: '/de',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('href="/de/rss.xml"');
|
||||
expect(html).toContain('href="/de/atom.xml"');
|
||||
});
|
||||
|
||||
it('renders unprefixed feed links when no language prefix', async () => {
|
||||
const renderer = new PageRenderer(
|
||||
{ getAllMedia: async () => [] },
|
||||
{ getLinkedMediaDataForPost: async () => [], setProjectContext: () => {} },
|
||||
);
|
||||
|
||||
const html = await renderer.renderPostList(
|
||||
[makePost()],
|
||||
{
|
||||
canonicalPostPathBySlug: new Map(),
|
||||
canonicalMediaPathBySourcePath: new Map(),
|
||||
},
|
||||
{
|
||||
archiveGrouping: false,
|
||||
routeKind: 'date',
|
||||
basePathname: '/',
|
||||
page_title: 'Blog',
|
||||
language: 'en',
|
||||
},
|
||||
);
|
||||
|
||||
expect(html).toContain('href="/rss.xml"');
|
||||
expect(html).toContain('href="/atom.xml"');
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user