fix: database migration

This commit is contained in:
2026-04-05 12:47:58 +02:00
parent e0c0c8a31e
commit c7a939736c
59 changed files with 766 additions and 306 deletions

3
.gitignore vendored
View File

@@ -24,6 +24,9 @@
*~ *~
.DS_Store .DS_Store
# Test artifacts
crates/bds-core/projects/
# Environment # Environment
.env .env
.env.* .env.*

View File

@@ -44,4 +44,5 @@ Invariants and behaviours in the allium spec should be covered by unit tests of
- always make sure you follow proper i18n best practices. no untranslated string constants. - always make sure you follow proper i18n best practices. no untranslated string constants.
- when creating rust source code, always follow what the allium spec is saying for that part - when creating rust source code, always follow what the allium spec is saying for that part
- when tending the allium spec, make sure you validate the spec with the installed command line utility - when tending the allium spec, make sure you validate the spec with the installed command line utility
- don't be lazy. don't defer or skip implementations just because you have to write code for that, that is ridiculous. if the spec says something has to be there, it has to be there.

View File

@@ -0,0 +1,58 @@
-- Fix script and template default status from 'published' to 'draft'
-- and make tag name uniqueness case-insensitive.
--
-- SQLite cannot ALTER COLUMN defaults, so we recreate the affected tables.
-- ── Scripts: default status 'published' → 'draft' ──
CREATE TABLE scripts_new (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
slug TEXT NOT NULL,
title TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'utility',
entrypoint TEXT NOT NULL DEFAULT 'render',
enabled INTEGER NOT NULL DEFAULT 1,
version INTEGER NOT NULL DEFAULT 1,
file_path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
content TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
INSERT INTO scripts_new SELECT * FROM scripts;
DROP TABLE scripts;
ALTER TABLE scripts_new RENAME TO scripts;
CREATE UNIQUE INDEX IF NOT EXISTS scripts_project_slug_idx
ON scripts(project_id, slug);
-- ── Templates: default status 'published' → 'draft' ──
CREATE TABLE templates_new (
id TEXT PRIMARY KEY,
project_id TEXT NOT NULL REFERENCES projects(id),
slug TEXT NOT NULL,
title TEXT NOT NULL,
kind TEXT NOT NULL DEFAULT 'post',
enabled INTEGER NOT NULL DEFAULT 1,
version INTEGER NOT NULL DEFAULT 1,
file_path TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
content TEXT,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
INSERT INTO templates_new SELECT * FROM templates;
DROP TABLE templates;
ALTER TABLE templates_new RENAME TO templates;
CREATE UNIQUE INDEX IF NOT EXISTS templates_project_slug_idx
ON templates(project_id, slug);
-- ── Tags: case-insensitive unique index ──
DROP INDEX IF EXISTS tags_project_name_idx;
CREATE UNIQUE INDEX tags_project_name_idx ON tags(project_id, name COLLATE NOCASE);

View File

@@ -1,6 +0,0 @@
[
"article",
"aside",
"page",
"picture"
]

View File

@@ -1,6 +0,0 @@
{
"name": "P",
"maxPostsPerPage": 50,
"semanticSimilarityEnabled": false,
"blogLanguages": []
}

View File

@@ -1,6 +0,0 @@
[
"article",
"aside",
"page",
"picture"
]

View File

@@ -1,6 +0,0 @@
{
"name": "P",
"maxPostsPerPage": 50,
"semanticSimilarityEnabled": false,
"blogLanguages": []
}

View File

@@ -1,6 +0,0 @@
[
"article",
"aside",
"page",
"picture"
]

View File

@@ -1,6 +0,0 @@
{
"name": "P",
"maxPostsPerPage": 50,
"semanticSimilarityEnabled": false,
"blogLanguages": []
}

View File

@@ -1,6 +0,0 @@
[
"article",
"aside",
"page",
"picture"
]

View File

@@ -1,6 +0,0 @@
{
"name": "P",
"maxPostsPerPage": 50,
"semanticSimilarityEnabled": false,
"blogLanguages": []
}

View File

@@ -1,6 +0,0 @@
[
"article",
"aside",
"page",
"picture"
]

View File

@@ -1,6 +0,0 @@
{
"name": "P",
"maxPostsPerPage": 50,
"semanticSimilarityEnabled": false,
"blogLanguages": []
}

View File

@@ -1,6 +0,0 @@
[
"article",
"aside",
"page",
"picture"
]

View File

@@ -1,6 +0,0 @@
{
"name": "P",
"maxPostsPerPage": 50,
"semanticSimilarityEnabled": false,
"blogLanguages": []
}

View File

@@ -1,6 +0,0 @@
[
"article",
"aside",
"page",
"picture"
]

View File

@@ -1,6 +0,0 @@
{
"name": "P",
"maxPostsPerPage": 50,
"semanticSimilarityEnabled": false,
"blogLanguages": []
}

View File

@@ -63,9 +63,25 @@ pub fn stem_text(text: &str, language: &str) -> String {
.join(" ") .join(" ")
} }
/// Structured translation data for FTS indexing of posts.
pub struct PostTranslationFts {
pub title: String,
pub excerpt: Option<String>,
pub content: Option<String>,
pub language: String,
}
/// Structured translation data for FTS indexing of media.
pub struct MediaTranslationFts {
pub title: Option<String>,
pub alt: Option<String>,
pub caption: Option<String>,
pub language: String,
}
/// Index a post in the FTS table with separate columns per spec. /// Index a post in the FTS table with separate columns per spec.
/// ///
/// Concatenates translation text into the content column (stemmed per-language). /// Translation titles go to the title column, excerpts to excerpt, content to content.
pub fn index_post( pub fn index_post(
conn: &Connection, conn: &Connection,
post_id: &str, post_id: &str,
@@ -74,22 +90,37 @@ pub fn index_post(
content: Option<&str>, content: Option<&str>,
tags: &[String], tags: &[String],
categories: &[String], categories: &[String],
translations: &[(String, String)], // (text, language) pairs translations: &[PostTranslationFts],
language: &str, language: &str,
) -> rusqlite::Result<()> { ) -> rusqlite::Result<()> {
// Remove existing entry // Remove existing entry
remove_post_from_index(conn, post_id)?; remove_post_from_index(conn, post_id)?;
let stemmed_title = stem_text(title, language); // Title column: post title + all translation titles
let stemmed_excerpt = stem_text(excerpt.unwrap_or(""), language); let mut title_parts = vec![stem_text(title, language)];
for t in translations {
title_parts.push(stem_text(&t.title, &t.language));
}
let stemmed_title = title_parts.join(" ");
// Content column: post content + all translation texts // Excerpt column: post excerpt + all translation excerpts
let mut excerpt_parts = vec![stem_text(excerpt.unwrap_or(""), language)];
for t in translations {
if let Some(ref exc) = t.excerpt {
excerpt_parts.push(stem_text(exc, &t.language));
}
}
let stemmed_excerpt = excerpt_parts.join(" ");
// Content column: post content + all translation content
let mut content_parts = Vec::new(); let mut content_parts = Vec::new();
if let Some(cnt) = content { if let Some(cnt) = content {
content_parts.push(stem_text(cnt, language)); content_parts.push(stem_text(cnt, language));
} }
for (text, trans_lang) in translations { for t in translations {
content_parts.push(stem_text(text, trans_lang)); if let Some(ref cnt) = t.content {
content_parts.push(stem_text(cnt, &t.language));
}
} }
let stemmed_content = content_parts.join(" "); let stemmed_content = content_parts.join(" ");
@@ -104,6 +135,8 @@ pub fn index_post(
} }
/// Index a media item in the FTS table with separate columns per spec. /// Index a media item in the FTS table with separate columns per spec.
///
/// Translation titles go to the title column, alts to alt, captions to caption.
pub fn index_media( pub fn index_media(
conn: &Connection, conn: &Connection,
media_id: &str, media_id: &str,
@@ -112,21 +145,38 @@ pub fn index_media(
caption: Option<&str>, caption: Option<&str>,
original_name: &str, original_name: &str,
tags: &[String], tags: &[String],
translations: &[(String, String)], // (text, language) pairs translations: &[MediaTranslationFts],
language: &str, language: &str,
) -> rusqlite::Result<()> { ) -> rusqlite::Result<()> {
remove_media_from_index(conn, media_id)?; remove_media_from_index(conn, media_id)?;
let stemmed_title = stem_text(title.unwrap_or(""), language); // Title column: media title + all translation titles
let stemmed_alt = stem_text(alt.unwrap_or(""), language); let mut title_parts = vec![stem_text(title.unwrap_or(""), language)];
for t in translations {
if let Some(ref ttl) = t.title {
title_parts.push(stem_text(ttl, &t.language));
}
}
let stemmed_title = title_parts.join(" ");
// Caption column: media caption + all translation texts // Alt column: media alt + all translation alts
let mut alt_parts = vec![stem_text(alt.unwrap_or(""), language)];
for t in translations {
if let Some(ref a) = t.alt {
alt_parts.push(stem_text(a, &t.language));
}
}
let stemmed_alt = alt_parts.join(" ");
// Caption column: media caption + all translation captions
let mut caption_parts = Vec::new(); let mut caption_parts = Vec::new();
if let Some(c) = caption { if let Some(c) = caption {
caption_parts.push(stem_text(c, language)); caption_parts.push(stem_text(c, language));
} }
for (text, trans_lang) in translations { for t in translations {
caption_parts.push(stem_text(text, trans_lang)); if let Some(ref cap) = t.caption {
caption_parts.push(stem_text(cap, &t.language));
}
} }
let stemmed_caption = caption_parts.join(" "); let stemmed_caption = caption_parts.join(" ");
@@ -176,8 +226,12 @@ pub struct PostSearchFilters<'a> {
pub status: Option<&'a str>, pub status: Option<&'a str>,
pub tags: Option<&'a [String]>, pub tags: Option<&'a [String]>,
pub categories: Option<&'a [String]>, pub categories: Option<&'a [String]>,
pub language: Option<&'a str>,
pub missing_translation_language: Option<&'a str>,
pub year: Option<i32>, pub year: Option<i32>,
pub month: Option<u32>, pub month: Option<u32>,
pub from: Option<i64>,
pub to: Option<i64>,
pub limit: Option<usize>, pub limit: Option<usize>,
pub offset: Option<usize>, pub offset: Option<usize>,
} }
@@ -263,6 +317,32 @@ pub fn search_posts_filtered(
} }
} }
if let Some(lang) = filters.language {
sql.push_str(&format!("AND (language = ?{param_idx} OR language IS NULL) "));
params.push(Box::new(lang.to_string()));
param_idx += 1;
}
if let Some(missing_lang) = filters.missing_translation_language {
sql.push_str(&format!(
"AND NOT EXISTS (SELECT 1 FROM post_translations WHERE post_translations.translation_for = posts.id AND post_translations.language = ?{param_idx}) "
));
params.push(Box::new(missing_lang.to_string()));
param_idx += 1;
}
if let Some(from_ts) = filters.from {
sql.push_str(&format!("AND created_at >= ?{param_idx} "));
params.push(Box::new(from_ts));
param_idx += 1;
}
if let Some(to_ts) = filters.to {
sql.push_str(&format!("AND created_at <= ?{param_idx} "));
params.push(Box::new(to_ts));
param_idx += 1;
}
let _ = param_idx; // suppress unused warning let _ = param_idx; // suppress unused warning
// First get total count (without LIMIT/OFFSET) // First get total count (without LIMIT/OFFSET)
@@ -439,7 +519,12 @@ mod tests {
Some("Beautiful spider web"), Some("Beautiful spider web"),
&["fotografie".into(), "natur".into()], &["fotografie".into(), "natur".into()],
&["picture".into()], &["picture".into()],
&[("Esmeralda English".into(), "en".into())], &[PostTranslationFts {
title: "Esmeralda English".into(),
excerpt: None,
content: None,
language: "en".into(),
}],
"en", "en",
).unwrap(); ).unwrap();
@@ -503,7 +588,12 @@ mod tests {
index_post( index_post(
db.conn(), "p1", "Programmierung", None, Some("Deutsche Entwicklung"), db.conn(), "p1", "Programmierung", None, Some("Deutsche Entwicklung"),
&[], &[], &[], &[],
&[("English development programming".into(), "en".into())], &[PostTranslationFts {
title: "English development programming".into(),
excerpt: None,
content: Some("English development programming".into()),
language: "en".into(),
}],
"de", "de",
).unwrap(); ).unwrap();

View File

@@ -745,7 +745,7 @@ mod tests {
assert_eq!(kind, "post", "default kind must be 'post'"); assert_eq!(kind, "post", "default kind must be 'post'");
assert_eq!(enabled, 1, "default enabled must be 1"); assert_eq!(enabled, 1, "default enabled must be 1");
assert_eq!(version, 1, "default version must be 1"); assert_eq!(version, 1, "default version must be 1");
assert_eq!(status, "published", "default status must be 'published'"); assert_eq!(status, "draft", "default status must be 'draft'");
} }
#[test] #[test]
@@ -766,6 +766,6 @@ mod tests {
assert_eq!(ep, "render", "default entrypoint must be 'render'"); assert_eq!(ep, "render", "default entrypoint must be 'render'");
assert_eq!(enabled, 1, "default enabled must be 1"); assert_eq!(enabled, 1, "default enabled must be 1");
assert_eq!(version, 1, "default version must be 1"); assert_eq!(version, 1, "default version must be 1");
assert_eq!(status, "published", "default status must be 'published'"); assert_eq!(status, "draft", "default status must be 'draft'");
} }
} }

View File

@@ -444,20 +444,13 @@ pub fn rebuild_media_from_filesystem_with_progress(
/// Index a media item in FTS, gathering translation texts. /// Index a media item in FTS, gathering translation texts.
fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> { fn fts_index_media(conn: &Connection, media: &Media) -> EngineResult<()> {
let translations = qmt::list_media_translations_by_media(conn, &media.id).unwrap_or_default(); let translations = qmt::list_media_translations_by_media(conn, &media.id).unwrap_or_default();
let translation_data: Vec<(String, String)> = translations let translation_data: Vec<fts::MediaTranslationFts> = translations
.iter() .iter()
.map(|t| { .map(|t| fts::MediaTranslationFts {
let mut parts = Vec::new(); title: t.title.clone(),
if let Some(ref title) = t.title { alt: t.alt.clone(),
parts.push(title.clone()); caption: t.caption.clone(),
} language: t.language.clone(),
if let Some(ref alt) = t.alt {
parts.push(alt.clone());
}
if let Some(ref caption) = t.caption {
parts.push(caption.clone());
}
(parts.join(" "), t.language.clone())
}) })
.collect(); .collect();

View File

@@ -156,8 +156,19 @@ pub fn update_post(
post.do_not_translate = dnt; post.do_not_translate = dnt;
} }
// Auto-transition published post back to draft on content/metadata change // Auto-transition published or archived post back to draft on content/metadata change
if post.status == PostStatus::Published { if post.status == PostStatus::Published || post.status == PostStatus::Archived {
// Reload content from filesystem if content field is NULL (published state)
if post.content.is_none() && !post.file_path.is_empty() {
let abs_path = _data_dir.join(&post.file_path);
if abs_path.exists() {
if let Ok(file_content) = fs::read_to_string(&abs_path) {
if let Ok((_fm, body)) = read_post_file(&file_content) {
post.content = Some(body);
}
}
}
}
post.status = PostStatus::Draft; post.status = PostStatus::Draft;
} }
@@ -294,10 +305,24 @@ pub fn publish_post(
} }
/// Archive a post. /// Archive a post.
pub fn archive_post(conn: &Connection, post_id: &str) -> EngineResult<()> { pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<()> {
let post = qp::get_post_by_id(conn, post_id)?; let mut post = qp::get_post_by_id(conn, post_id)?;
if post.status == PostStatus::Archived { if post.status == PostStatus::Archived {
return Ok(()); return Err(EngineError::Conflict(
"post is already archived".to_string(),
));
}
// Reload content from filesystem if transitioning from published (content is NULL)
if post.status == PostStatus::Published && post.content.is_none() && !post.file_path.is_empty() {
let abs_path = data_dir.join(&post.file_path);
if abs_path.exists() {
if let Ok(file_content) = fs::read_to_string(&abs_path) {
if let Ok((_fm, body)) = read_post_file(&file_content) {
post.content = Some(body);
qp::update_post(conn, &post)?;
}
}
}
} }
let now = now_unix_ms(); let now = now_unix_ms();
qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?; qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?;
@@ -646,17 +671,13 @@ fn publish_translation(
/// Index a post in FTS, gathering translation texts. /// Index a post in FTS, gathering translation texts.
fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> { fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> {
let translations = qt::list_post_translations_by_post(conn, &post.id).unwrap_or_default(); let translations = qt::list_post_translations_by_post(conn, &post.id).unwrap_or_default();
let translation_data: Vec<(String, String)> = translations let translation_data: Vec<fts::PostTranslationFts> = translations
.iter() .iter()
.map(|t| { .map(|t| fts::PostTranslationFts {
let mut parts = vec![t.title.clone()]; title: t.title.clone(),
if let Some(ref exc) = t.excerpt { excerpt: t.excerpt.clone(),
parts.push(exc.clone()); content: t.content.clone(),
} language: t.language.clone(),
if let Some(ref cnt) = t.content {
parts.push(cnt.clone());
}
(parts.join(" "), t.language.clone())
}) })
.collect(); .collect();
@@ -1031,7 +1052,7 @@ mod tests {
publish_post(db.conn(), dir.path(), &post.id).unwrap(); publish_post(db.conn(), dir.path(), &post.id).unwrap();
// Archive so we can try updating // Archive so we can try updating
archive_post(db.conn(), &post.id).unwrap(); archive_post(db.conn(), dir.path(), &post.id).unwrap();
// Try to change slug - should fail // Try to change slug - should fail
let result = update_post( let result = update_post(
@@ -1124,7 +1145,7 @@ mod tests {
let original_published_at = published.published_at.unwrap(); let original_published_at = published.published_at.unwrap();
// Archive then re-publish // Archive then re-publish
archive_post(db.conn(), &post.id).unwrap(); archive_post(db.conn(), dir.path(), &post.id).unwrap();
// Brief delay to ensure now() is different // Brief delay to ensure now() is different
let republished = publish_post(db.conn(), dir.path(), &post.id).unwrap(); let republished = publish_post(db.conn(), dir.path(), &post.id).unwrap();
@@ -1406,7 +1427,7 @@ mod tests {
publish_post(db.conn(), dir.path(), &post.id).unwrap(); publish_post(db.conn(), dir.path(), &post.id).unwrap();
// Archive then update to test auto-draft // Archive then update to test auto-draft
archive_post(db.conn(), &post.id).unwrap(); archive_post(db.conn(), dir.path(), &post.id).unwrap();
// Re-publish // Re-publish
let _ = publish_post(db.conn(), dir.path(), &post.id).unwrap(); let _ = publish_post(db.conn(), dir.path(), &post.id).unwrap();
@@ -1475,7 +1496,7 @@ mod tests {
vec![], vec![], None, None, None, vec![], vec![], None, None, None,
).unwrap(); ).unwrap();
publish_post(db.conn(), dir.path(), &post.id).unwrap(); publish_post(db.conn(), dir.path(), &post.id).unwrap();
archive_post(db.conn(), &post.id).unwrap(); archive_post(db.conn(), dir.path(), &post.id).unwrap();
let from_db = qp::get_post_by_id(db.conn(), &post.id).unwrap(); let from_db = qp::get_post_by_id(db.conn(), &post.id).unwrap();
assert_eq!(from_db.status, PostStatus::Archived); assert_eq!(from_db.status, PostStatus::Archived);

View File

@@ -298,16 +298,24 @@ mod tests {
#[test] #[test]
fn delete_project_removes_row() { fn delete_project_removes_row() {
let (db, dir) = setup(); let (db, dir) = setup();
// Project with no custom data_path → uses internal directory // Simulate an internal project (no custom data_path) by inserting directly
let p = create_project( let now = crate::util::now_unix_ms();
db.conn(), let project = Project {
"P", id: uuid::Uuid::new_v4().to_string(),
None, name: "P".to_string(),
) slug: "p".to_string(),
.unwrap(); description: None,
let internal_dir = dir.path().join("projects").join(&p.id); data_path: None,
let _ = std::fs::create_dir_all(&internal_dir); is_active: false,
delete_project(db.conn(), &p.id, Some(&internal_dir)).unwrap(); created_at: now,
updated_at: now,
};
crate::db::queries::project::insert_project(db.conn(), &project).unwrap();
// Create the internal directory structure under the temp dir
let internal_dir = dir.path().join("projects").join(&project.id);
create_directory_structure(&internal_dir).unwrap();
assert!(internal_dir.join("posts").is_dir());
delete_project(db.conn(), &project.id, Some(&internal_dir)).unwrap();
assert!(list_projects(db.conn()).unwrap().is_empty()); assert!(list_projects(db.conn()).unwrap().is_empty());
assert!(!internal_dir.exists(), "internal directory should be cleaned up"); assert!(!internal_dir.exists(), "internal directory should be cleaned up");
} }

View File

@@ -50,16 +50,13 @@ pub fn reindex_all_with_progress(
} }
let translations = post_translation::list_post_translations_by_post(conn, &post.id)?; let translations = post_translation::list_post_translations_by_post(conn, &post.id)?;
let trans_pairs: Vec<(String, String)> = translations let trans_data: Vec<fts::PostTranslationFts> = translations
.iter() .iter()
.map(|t| { .map(|t| fts::PostTranslationFts {
let text = [ title: t.title.clone(),
t.title.as_str(), excerpt: t.excerpt.clone(),
t.excerpt.as_deref().unwrap_or(""), content: t.content.clone(),
t.content.as_deref().unwrap_or(""), language: t.language.clone(),
]
.join(" ");
(text, t.language.clone())
}) })
.collect(); .collect();
@@ -72,7 +69,7 @@ pub fn reindex_all_with_progress(
post.content.as_deref(), post.content.as_deref(),
&post.tags, &post.tags,
&post.categories, &post.categories,
&trans_pairs, &trans_data,
language, language,
)?; )?;
@@ -87,16 +84,13 @@ pub fn reindex_all_with_progress(
} }
let translations = media_translation::list_media_translations_by_media(conn, &m.id)?; let translations = media_translation::list_media_translations_by_media(conn, &m.id)?;
let trans_pairs: Vec<(String, String)> = translations let trans_data: Vec<fts::MediaTranslationFts> = translations
.iter() .iter()
.map(|t| { .map(|t| fts::MediaTranslationFts {
let text = [ title: t.title.clone(),
t.title.as_deref().unwrap_or(""), alt: t.alt.clone(),
t.alt.as_deref().unwrap_or(""), caption: t.caption.clone(),
t.caption.as_deref().unwrap_or(""), language: t.language.clone(),
]
.join(" ");
(text, t.language.clone())
}) })
.collect(); .collect();
@@ -109,7 +103,7 @@ pub fn reindex_all_with_progress(
m.caption.as_deref(), m.caption.as_deref(),
&m.original_name, &m.original_name,
&m.tags, &m.tags,
&trans_pairs, &trans_data,
language, language,
)?; )?;

View File

@@ -8,7 +8,7 @@ pub type TaskId = u64;
/// Task status. /// Task status.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum TaskStatus { pub enum TaskStatus {
Queued, Pending,
Running, Running,
Completed, Completed,
Failed(String), Failed(String),
@@ -28,11 +28,14 @@ pub struct TaskProgress {
struct TaskEntry { struct TaskEntry {
id: TaskId, id: TaskId,
label: String, label: String,
group_id: Option<String>,
group_name: Option<String>,
status: TaskStatus, status: TaskStatus,
cancel_flag: Arc<AtomicBool>, cancel_flag: Arc<AtomicBool>,
progress: Option<f32>, progress: Option<f32>,
message: Option<String>, message: Option<String>,
created_at: Instant, created_at: Instant,
last_progress_report: Option<Instant>,
} }
/// Manages concurrent tasks with a max concurrency limit and FIFO queue. /// Manages concurrent tasks with a max concurrency limit and FIFO queue.
@@ -61,11 +64,14 @@ impl TaskManager {
let entry = TaskEntry { let entry = TaskEntry {
id, id,
label: label.to_owned(), label: label.to_owned(),
status: TaskStatus::Queued, group_id: None,
group_name: None,
status: TaskStatus::Pending,
cancel_flag: Arc::new(AtomicBool::new(false)), cancel_flag: Arc::new(AtomicBool::new(false)),
progress: None, progress: None,
message: None, message: None,
created_at: Instant::now(), created_at: Instant::now(),
last_progress_report: None,
}; };
let mut tasks = self.tasks.lock().unwrap(); let mut tasks = self.tasks.lock().unwrap();
@@ -73,13 +79,24 @@ impl TaskManager {
// Auto-start if under capacity // Auto-start if under capacity
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count(); let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
if running < self.max_concurrent { if running < self.max_concurrent {
if let Some(t) = tasks.iter_mut().find(|t| t.id == id && t.status == TaskStatus::Queued) { if let Some(t) = tasks.iter_mut().find(|t| t.id == id && t.status == TaskStatus::Pending) {
t.status = TaskStatus::Running; t.status = TaskStatus::Running;
} }
} }
id id
} }
/// Submit a new task within a group. Returns its unique identifier.
pub fn submit_grouped(&self, label: &str, group_id: &str, group_name: &str) -> TaskId {
let id = self.submit(label);
let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == id) {
entry.group_id = Some(group_id.to_owned());
entry.group_name = Some(group_name.to_owned());
}
id
}
/// Try to start a queued task. Returns true if the task was moved to /// Try to start a queued task. Returns true if the task was moved to
/// Running, false if concurrency is at capacity or the task is not Queued. /// Running, false if concurrency is at capacity or the task is not Queued.
pub fn try_start(&self, task_id: TaskId) -> bool { pub fn try_start(&self, task_id: TaskId) -> bool {
@@ -89,7 +106,7 @@ impl TaskManager {
return false; return false;
} }
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) { if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
if entry.status == TaskStatus::Queued { if entry.status == TaskStatus::Pending {
entry.status = TaskStatus::Running; entry.status = TaskStatus::Running;
return true; return true;
} }
@@ -125,7 +142,7 @@ impl TaskManager {
pub fn cancel(&self, task_id: TaskId) { pub fn cancel(&self, task_id: TaskId) {
let mut tasks = self.tasks.lock().unwrap(); let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) { if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
if matches!(entry.status, TaskStatus::Running | TaskStatus::Queued) { if matches!(entry.status, TaskStatus::Running | TaskStatus::Pending) {
entry.cancel_flag.store(true, Ordering::Release); entry.cancel_flag.store(true, Ordering::Release);
entry.status = TaskStatus::Cancelled; entry.status = TaskStatus::Cancelled;
} }
@@ -152,7 +169,7 @@ impl TaskManager {
/// Count tasks that are still queued. /// Count tasks that are still queued.
pub fn pending_count(&self) -> usize { pub fn pending_count(&self) -> usize {
let tasks = self.tasks.lock().unwrap(); let tasks = self.tasks.lock().unwrap();
tasks.iter().filter(|t| t.status == TaskStatus::Queued).count() tasks.iter().filter(|t| t.status == TaskStatus::Pending).count()
} }
/// Count tasks that are currently running. /// Count tasks that are currently running.
@@ -164,7 +181,7 @@ impl TaskManager {
/// Remove all completed, failed, and cancelled tasks. /// Remove all completed, failed, and cancelled tasks.
pub fn drain_completed(&self) { pub fn drain_completed(&self) {
let mut tasks = self.tasks.lock().unwrap(); let mut tasks = self.tasks.lock().unwrap();
tasks.retain(|t| matches!(t.status, TaskStatus::Queued | TaskStatus::Running)); tasks.retain(|t| matches!(t.status, TaskStatus::Pending | TaskStatus::Running));
} }
/// Return the label of a task. /// Return the label of a task.
@@ -176,16 +193,24 @@ impl TaskManager {
/// Return the id of the first queued task (FIFO order). /// Return the id of the first queued task (FIFO order).
pub fn next_queued(&self) -> Option<TaskId> { pub fn next_queued(&self) -> Option<TaskId> {
let tasks = self.tasks.lock().unwrap(); let tasks = self.tasks.lock().unwrap();
tasks.iter().find(|t| t.status == TaskStatus::Queued).map(|t| t.id) tasks.iter().find(|t| t.status == TaskStatus::Pending).map(|t| t.id)
} }
/// Update progress for a running task. /// Update progress for a running task. Throttled to at most once per 250ms.
pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) { pub fn report_progress(&self, task_id: TaskId, progress: Option<f32>, message: Option<String>) {
let mut tasks = self.tasks.lock().unwrap(); let mut tasks = self.tasks.lock().unwrap();
if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) { if let Some(entry) = tasks.iter_mut().find(|t| t.id == task_id) {
if entry.status == TaskStatus::Running { if entry.status == TaskStatus::Running {
entry.progress = progress; let now = Instant::now();
entry.message = message; let should_report = match entry.last_progress_report {
Some(prev) => now.duration_since(prev).as_millis() >= PROGRESS_THROTTLE_MS as u128,
None => true,
};
if should_report {
entry.progress = progress;
entry.message = message;
entry.last_progress_report = Some(now);
}
} }
} }
} }
@@ -215,7 +240,7 @@ impl TaskManager {
fn promote_next(tasks: &mut Vec<TaskEntry>, max_concurrent: usize) { fn promote_next(tasks: &mut Vec<TaskEntry>, max_concurrent: usize) {
let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count(); let running = tasks.iter().filter(|t| t.status == TaskStatus::Running).count();
if running < max_concurrent { if running < max_concurrent {
if let Some(t) = tasks.iter_mut().find(|t| t.status == TaskStatus::Queued) { if let Some(t) = tasks.iter_mut().find(|t| t.status == TaskStatus::Pending) {
t.status = TaskStatus::Running; t.status = TaskStatus::Running;
} }
} }
@@ -279,7 +304,7 @@ mod tests {
// First 3 auto-started, 4th stays queued // First 3 auto-started, 4th stays queued
assert_eq!(mgr.running_count(), 3); assert_eq!(mgr.running_count(), 3);
assert_eq!(mgr.status(ids[3]), Some(TaskStatus::Queued)); assert_eq!(mgr.status(ids[3]), Some(TaskStatus::Pending));
} }
#[test] #[test]
@@ -354,7 +379,7 @@ mod tests {
let b = mgr.submit("second"); // queued let b = mgr.submit("second"); // queued
assert_eq!(mgr.status(a), Some(TaskStatus::Running)); assert_eq!(mgr.status(a), Some(TaskStatus::Running));
assert_eq!(mgr.status(b), Some(TaskStatus::Queued)); assert_eq!(mgr.status(b), Some(TaskStatus::Pending));
mgr.complete(a); mgr.complete(a);
assert_eq!(mgr.status(b), Some(TaskStatus::Running)); assert_eq!(mgr.status(b), Some(TaskStatus::Running));

View File

@@ -58,5 +58,10 @@ pub struct PublishingPreferences {
pub ssh_user: Option<String>, pub ssh_user: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub ssh_remote_path: Option<String>, pub ssh_remote_path: Option<String>,
#[serde(default = "default_ssh_mode")]
pub ssh_mode: SshMode, pub ssh_mode: SshMode,
} }
fn default_ssh_mode() -> SshMode {
SshMode::Scp
}

View File

@@ -309,11 +309,11 @@ fn script_defaults_match_spec() {
assert_eq!(entrypoint, "render", "spec: default entrypoint is 'render'"); assert_eq!(entrypoint, "render", "spec: default entrypoint is 'render'");
assert!(enabled, "spec: default enabled is true"); assert!(enabled, "spec: default enabled is true");
assert_eq!(version, 1, "spec: default version is 1"); assert_eq!(version, 1, "spec: default version is 1");
assert_eq!(status, "published", "spec: default status is published"); assert_eq!(status, "draft", "spec: default status is draft");
} }
// ── spec: schema.allium — Template defaults ── // ── spec: schema.allium — Template defaults ──
// kind default 'post', enabled default true, version default 1, status default 'published' // kind default 'post', enabled default true, version default 1, status default 'draft'
#[test] #[test]
fn template_defaults_match_spec() { fn template_defaults_match_spec() {
@@ -339,7 +339,7 @@ fn template_defaults_match_spec() {
assert_eq!(kind, "post", "spec: default kind is post"); assert_eq!(kind, "post", "spec: default kind is post");
assert!(enabled, "spec: default enabled is true"); assert!(enabled, "spec: default enabled is true");
assert_eq!(version, 1, "spec: default version is 1"); assert_eq!(version, 1, "spec: default version is 1");
assert_eq!(status, "published", "spec: default status is published"); assert_eq!(status, "draft", "spec: default status is draft");
} }
// ── spec: tag.allium — UniqueTagNamePerProject (case-insensitive) ── // ── spec: tag.allium — UniqueTagNamePerProject (case-insensitive) ──

View File

@@ -32,11 +32,17 @@ pub enum Message {
ToggleSidebar, ToggleSidebar,
TogglePanel, TogglePanel,
// Sidebar resize
SidebarResizeStart,
SidebarResizeMove(f32),
SidebarResizeEnd,
// Tabs // Tabs
OpenTab(Tab), OpenTab(Tab),
CloseTab(String), CloseTab(String),
SelectTab(String), SelectTab(String),
PinTab(String), PinTab(String),
ClearTabs,
// Project // Project
ProjectsLoaded(Vec<Project>), ProjectsLoaded(Vec<Project>),
@@ -111,6 +117,8 @@ pub struct BdsApp {
// Navigation // Navigation
sidebar_view: SidebarView, sidebar_view: SidebarView,
sidebar_visible: bool, sidebar_visible: bool,
sidebar_width: f32,
sidebar_dragging: bool,
// Tabs // Tabs
tabs: Vec<Tab>, tabs: Vec<Tab>,
@@ -230,6 +238,8 @@ impl BdsApp {
sidebar_media: Vec::new(), sidebar_media: Vec::new(),
sidebar_view: SidebarView::Posts, sidebar_view: SidebarView::Posts,
sidebar_visible: true, sidebar_visible: true,
sidebar_width: 280.0,
sidebar_dragging: false,
tabs: Vec::new(), tabs: Vec::new(),
active_tab: None, active_tab: None,
panel_visible: false, panel_visible: false,
@@ -276,6 +286,22 @@ impl BdsApp {
self.panel_visible = !self.panel_visible; self.panel_visible = !self.panel_visible;
Task::none() Task::none()
} }
Message::SidebarResizeStart => {
self.sidebar_dragging = true;
Task::none()
}
Message::SidebarResizeMove(x) => {
if self.sidebar_dragging {
// x is global cursor position; subtract activity bar width (~48px)
let effective = x - 48.0;
self.sidebar_width = effective.clamp(200.0, 500.0);
}
Task::none()
}
Message::SidebarResizeEnd => {
self.sidebar_dragging = false;
Task::none()
}
// ── Tabs ── // ── Tabs ──
Message::OpenTab(tab) => { Message::OpenTab(tab) => {
@@ -308,6 +334,11 @@ impl BdsApp {
tabs::pin_tab(&mut self.tabs, &id); tabs::pin_tab(&mut self.tabs, &id);
Task::none() Task::none()
} }
Message::ClearTabs => {
self.tabs.clear();
self.active_tab = None;
Task::none()
}
// ── Project management ── // ── Project management ──
Message::ProjectsLoaded(projects) => { Message::ProjectsLoaded(projects) => {
@@ -463,6 +494,12 @@ impl BdsApp {
self.ui_locale = locale; self.ui_locale = locale;
self.locale_dropdown_open = false; self.locale_dropdown_open = false;
menu::update_menu_labels(&self.menu_registry, locale); menu::update_menu_labels(&self.menu_registry, locale);
// Re-translate singleton tab titles per tabs.allium
for tab in &mut self.tabs {
if let Some(key) = tab.tab_type.i18n_key() {
tab.title = t(locale, key);
}
}
Task::none() Task::none()
} }
Message::ToggleLocaleDropdown => { Message::ToggleLocaleDropdown => {
@@ -533,7 +570,7 @@ impl BdsApp {
) )
} }
Message::ValidateTranslations => { Message::ValidateTranslations => {
self.open_singleton_tab(TabType::TranslationValidation, "Translation Validation"); self.open_singleton_tab(TabType::TranslationValidation, "tabBar.translationValidation");
self.spawn_engine_task( self.spawn_engine_task(
"engine.validateTranslationsStarted", "engine.validateTranslationsStarted",
|db_path, project_id, data_dir, tm, tid| { |db_path, project_id, data_dir, tm, tid| {
@@ -569,7 +606,7 @@ impl BdsApp {
) )
} }
Message::RunMetadataDiff => { Message::RunMetadataDiff => {
self.open_singleton_tab(TabType::MetadataDiff, "Metadata Diff"); self.open_singleton_tab(TabType::MetadataDiff, "tabBar.metadataDiff");
Task::none() Task::none()
} }
Message::EngineTaskDone { task_id, label, result } => { Message::EngineTaskDone { task_id, label, result } => {
@@ -617,6 +654,7 @@ impl BdsApp {
workspace::view( workspace::view(
self.sidebar_view, self.sidebar_view,
self.sidebar_visible, self.sidebar_visible,
self.sidebar_width,
&self.tabs, &self.tabs,
self.active_tab.as_deref(), self.active_tab.as_deref(),
self.panel_visible, self.panel_visible,
@@ -664,12 +702,12 @@ impl BdsApp {
if let (Some(db), Some(project), Some(data_dir)) = if let (Some(db), Some(project), Some(data_dir)) =
(&self.db, &self.active_project, &self.data_dir) (&self.db, &self.active_project, &self.data_dir)
{ {
let title = t(self.ui_locale, "post.untitled"); let display_title = t(self.ui_locale, "post.untitled");
match engine::post::create_post( match engine::post::create_post(
db.conn(), db.conn(),
data_dir, data_dir,
&project.id, &project.id,
&title, "",
Some(""), Some(""),
Vec::new(), Vec::new(),
Vec::new(), Vec::new(),
@@ -681,7 +719,7 @@ impl BdsApp {
let tab = Tab { let tab = Tab {
id: post.id.clone(), id: post.id.clone(),
tab_type: TabType::Post, tab_type: TabType::Post,
title: post.title.clone(), title: display_title.to_string(),
is_transient: true, is_transient: true,
is_dirty: false, is_dirty: false,
}; };
@@ -713,7 +751,7 @@ impl BdsApp {
MenuAction::Find => Task::none(), MenuAction::Find => Task::none(),
MenuAction::Replace => Task::none(), MenuAction::Replace => Task::none(),
MenuAction::EditPreferences => { MenuAction::EditPreferences => {
self.open_singleton_tab(TabType::Settings, "Settings"); self.open_singleton_tab(TabType::Settings, "common.settings");
Task::none() Task::none()
} }
// View // View
@@ -733,7 +771,7 @@ impl BdsApp {
MenuAction::PublishSelected => Task::none(), // Disabled in M2 MenuAction::PublishSelected => Task::none(), // Disabled in M2
MenuAction::PreviewPost => Task::none(), // Disabled in M2 MenuAction::PreviewPost => Task::none(), // Disabled in M2
MenuAction::EditMenu => { MenuAction::EditMenu => {
self.open_singleton_tab(TabType::MenuEditor, "Menu Editor"); self.open_singleton_tab(TabType::MenuEditor, "tabBar.menuEditor");
Task::none() Task::none()
} }
MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase), MenuAction::RebuildDatabase => Task::done(Message::RebuildDatabase),
@@ -751,7 +789,7 @@ impl BdsApp {
} }
MenuAction::GenerateSitemap => Task::done(Message::GenerateSite), MenuAction::GenerateSitemap => Task::done(Message::GenerateSite),
MenuAction::ValidateSite => { MenuAction::ValidateSite => {
self.open_singleton_tab(TabType::SiteValidation, "Site Validation"); self.open_singleton_tab(TabType::SiteValidation, "tabBar.siteValidation");
Task::none() Task::none()
} }
MenuAction::UploadSite => { MenuAction::UploadSite => {
@@ -780,7 +818,7 @@ impl BdsApp {
Task::none() Task::none()
} }
MenuAction::OpenDocumentation => { MenuAction::OpenDocumentation => {
self.open_singleton_tab(TabType::Documentation, "Documentation"); self.open_singleton_tab(TabType::Documentation, "tabBar.documentation");
Task::none() Task::none()
} }
MenuAction::ViewOnGitHub => { MenuAction::ViewOnGitHub => {
@@ -794,11 +832,12 @@ impl BdsApp {
} }
} }
fn open_singleton_tab(&mut self, tab_type: TabType, title: &str) { fn open_singleton_tab(&mut self, tab_type: TabType, i18n_key: &str) {
let title = t(self.ui_locale, i18n_key);
let tab = Tab { let tab = Tab {
id: tab_type.singleton_id().to_string(), id: tab_type.singleton_id().to_string(),
tab_type, tab_type,
title: title.to_string(), title,
is_transient: false, is_transient: false,
is_dirty: false, is_dirty: false,
}; };
@@ -806,6 +845,7 @@ impl BdsApp {
if let Some(t) = self.tabs.get(idx) { if let Some(t) = self.tabs.get(idx) {
self.active_tab = Some(t.id.clone()); self.active_tab = Some(t.id.clone());
} }
self.enforce_panel_tab_availability();
} }
fn refresh_task_snapshots(&mut self) { fn refresh_task_snapshots(&mut self) {
@@ -815,7 +855,7 @@ impl BdsApp {
.into_iter() .into_iter()
.map(|(id, label, status, progress, message)| { .map(|(id, label, status, progress, message)| {
let status_str = match &status { let status_str = match &status {
TaskStatus::Queued => "queued".to_string(), TaskStatus::Pending => "pending".to_string(),
TaskStatus::Running => "running".to_string(), TaskStatus::Running => "running".to_string(),
TaskStatus::Completed => "completed".to_string(), TaskStatus::Completed => "completed".to_string(),
TaskStatus::Failed(e) => format!("failed: {e}"), TaskStatus::Failed(e) => format!("failed: {e}"),

View File

@@ -28,7 +28,7 @@ pub enum MenuAction {
ViewMedia, ViewMedia,
ToggleSidebar, ToggleSidebar,
TogglePanel, TogglePanel,
// Blog // Window
PublishSelected, PublishSelected,
PreviewPost, PreviewPost,
EditMenu, EditMenu,
@@ -249,28 +249,28 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
let _ = view_menu.append(&PredefinedMenuItem::separator()); let _ = view_menu.append(&PredefinedMenuItem::separator());
let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None)); let _ = view_menu.append(&PredefinedMenuItem::fullscreen(None));
// -- Blog -- // -- Window --
let blog_menu = Submenu::new(translate(locale, "menu.group.blog"), true); let window_menu = Submenu::new(translate(locale, "menu.group.window"), true);
let _ = blog_menu.append(&item(&mut reg, MenuAction::PublishSelected, locale, let _ = window_menu.append(&item(&mut reg, MenuAction::PublishSelected, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyP)))); Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyP))));
let _ = blog_menu.append(&item(&mut reg, MenuAction::PreviewPost, locale, let _ = window_menu.append(&item(&mut reg, MenuAction::PreviewPost, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyV)))); Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyV))));
let _ = blog_menu.append(&PredefinedMenuItem::separator()); let _ = window_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item(&mut reg, MenuAction::EditMenu, locale, None)); let _ = window_menu.append(&item(&mut reg, MenuAction::EditMenu, locale, None));
let _ = blog_menu.append(&PredefinedMenuItem::separator()); let _ = window_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item(&mut reg, MenuAction::RebuildDatabase, locale, None)); let _ = window_menu.append(&item(&mut reg, MenuAction::RebuildDatabase, locale, None));
let _ = blog_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None)); let _ = window_menu.append(&item(&mut reg, MenuAction::ReindexText, locale, None));
let _ = blog_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None)); let _ = window_menu.append(&item(&mut reg, MenuAction::MetadataDiff, locale, None));
let _ = blog_menu.append(&PredefinedMenuItem::separator()); let _ = window_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item(&mut reg, MenuAction::RegenerateCalendar, locale, None)); let _ = window_menu.append(&item(&mut reg, MenuAction::RegenerateCalendar, locale, None));
let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateTranslations, locale, None)); let _ = window_menu.append(&item(&mut reg, MenuAction::ValidateTranslations, locale, None));
let _ = blog_menu.append(&item(&mut reg, MenuAction::FillMissingTranslations, locale, None)); let _ = window_menu.append(&item(&mut reg, MenuAction::FillMissingTranslations, locale, None));
let _ = blog_menu.append(&PredefinedMenuItem::separator()); let _ = window_menu.append(&PredefinedMenuItem::separator());
let _ = blog_menu.append(&item(&mut reg, MenuAction::GenerateSitemap, locale, let _ = window_menu.append(&item(&mut reg, MenuAction::GenerateSitemap, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR)))); Some(Accelerator::new(Some(CMD_OR_CTRL), Code::KeyR))));
let _ = blog_menu.append(&item(&mut reg, MenuAction::ValidateSite, locale, let _ = window_menu.append(&item(&mut reg, MenuAction::ValidateSite, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyL)))); Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyL))));
let _ = blog_menu.append(&item(&mut reg, MenuAction::UploadSite, locale, let _ = window_menu.append(&item(&mut reg, MenuAction::UploadSite, locale,
Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyU)))); Some(Accelerator::new(Some(CMD_OR_CTRL | Modifiers::SHIFT), Code::KeyU))));
// -- Help -- // -- Help --
@@ -286,7 +286,7 @@ pub fn build_menu_bar(locale: UiLocale) -> (Menu, MenuRegistry) {
let _ = menu.append(&file_menu); let _ = menu.append(&file_menu);
let _ = menu.append(&edit_menu); let _ = menu.append(&edit_menu);
let _ = menu.append(&view_menu); let _ = menu.append(&view_menu);
let _ = menu.append(&blog_menu); let _ = menu.append(&window_menu);
let _ = menu.append(&help_menu); let _ = menu.append(&help_menu);
(menu, reg) (menu, reg)

View File

@@ -64,6 +64,24 @@ impl TabType {
_ => "", _ => "",
} }
} }
/// Return the i18n key for the tab title of a singleton tab type.
/// Per tabs.allium: singletons use i18n key lookup.
pub fn i18n_key(&self) -> Option<&'static str> {
match self {
Self::Settings => Some("common.settings"),
Self::Style => Some("tabBar.style"),
Self::Tags => Some("tabBar.tags"),
Self::MenuEditor => Some("tabBar.menuEditor"),
Self::MetadataDiff => Some("tabBar.metadataDiff"),
Self::Documentation => Some("tabBar.documentation"),
Self::ApiDocumentation => Some("tabBar.apiDocumentation"),
Self::SiteValidation => Some("tabBar.siteValidation"),
Self::TranslationValidation => Some("tabBar.translationValidation"),
Self::FindDuplicates => Some("tabBar.findDuplicates"),
_ => None,
}
}
} }
/// A single tab in the editor area. /// A single tab in the editor area.

View File

@@ -127,8 +127,11 @@ pub fn view(
.padding(8) .padding(8)
.into() .into()
} else { } else {
// Per layout.allium: last 10 tasks, newest first
let items: Vec<Element<'static, Message>> = task_snapshots let items: Vec<Element<'static, Message>> = task_snapshots
.iter() .iter()
.rev()
.take(10)
.map(|snap| { .map(|snap| {
let progress_str = snap.progress let progress_str = snap.progress
.map(|p| format!(" ({:.0}%)", p * 100.0)) .map(|p| format!(" ({:.0}%)", p * 100.0))

View File

@@ -10,15 +10,10 @@ use crate::i18n::t;
use crate::state::navigation::SidebarView; use crate::state::navigation::SidebarView;
use crate::state::tabs::{Tab, TabType}; use crate::state::tabs::{Tab, TabType};
/// Sidebar container style — dark background with right border separator. /// Sidebar container style — dark background.
fn sidebar_style(_theme: &Theme) -> container::Style { fn sidebar_style(_theme: &Theme) -> container::Style {
container::Style { container::Style {
background: Some(Background::Color(Color::from_rgb(0.16, 0.16, 0.20))), background: Some(Background::Color(Color::from_rgb(0.16, 0.16, 0.20))),
border: Border {
color: Color::from_rgb(0.25, 0.25, 0.30),
width: 1.0,
radius: 0.0.into(),
},
..container::Style::default() ..container::Style::default()
} }
} }
@@ -37,6 +32,20 @@ fn item_style(_theme: &Theme, status: button::Status) -> button::Style {
} }
} }
/// Sidebar item button style — active/selected.
fn item_active_style(_theme: &Theme, status: button::Status) -> button::Style {
let bg = match status {
button::Status::Hovered => Color::from_rgb(0.26, 0.26, 0.32),
_ => Color::from_rgb(0.22, 0.22, 0.28),
};
button::Style {
background: Some(Background::Color(bg)),
text_color: Color::WHITE,
border: Border::default(),
..button::Style::default()
}
}
/// Get the appropriate empty-state message key for each sidebar view. /// Get the appropriate empty-state message key for each sidebar view.
fn placeholder_key(view: SidebarView) -> &'static str { fn placeholder_key(view: SidebarView) -> &'static str {
match view { match view {
@@ -53,10 +62,26 @@ fn placeholder_key(view: SidebarView) -> &'static str {
} }
} }
/// sidebar_views.allium media_title_max_length = 60
const MEDIA_TITLE_MAX_LEN: usize = 60;
/// Truncate a media title to the max length, appending "..." if over limit.
/// Per sidebar_views.allium: JS hard limit of 60 chars on title (substring + "...").
fn truncate_media_title(title: &str) -> String {
if title.chars().count() > MEDIA_TITLE_MAX_LEN {
let truncated: String = title.chars().take(MEDIA_TITLE_MAX_LEN).collect();
format!("{truncated}...")
} else {
title.to_string()
}
}
pub fn view( pub fn view(
sidebar_view: SidebarView, sidebar_view: SidebarView,
posts: &[Post], posts: &[Post],
media: &[Media], media: &[Media],
width: f32,
active_tab: Option<&str>,
locale: UiLocale, locale: UiLocale,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
let header_text = t(locale, sidebar_view.i18n_key()); let header_text = t(locale, sidebar_view.i18n_key());
@@ -76,30 +101,77 @@ pub fn view(
.color(muted) .color(muted)
.into() .into()
} else { } else {
let items: Vec<Element<'static, Message>> = posts let section_header = |label: &str| -> Element<'static, Message> {
.iter() text(label.to_string())
.map(|p| { .size(11)
let status_indicator = match p.status { .shaping(Shaping::Advanced)
bds_core::model::PostStatus::Draft => "\u{25CB} ", // ○ .color(Color::from_rgb(0.55, 0.55, 0.60))
bds_core::model::PostStatus::Published => "\u{25CF} ", // ● .into()
bds_core::model::PostStatus::Archived => "\u{25A1} ", // □ };
};
let label = format!("{status_indicator}{}", p.title); let make_post_item = |p: &Post| -> Element<'static, Message> {
button(text(label).size(12).shaping(Shaping::Advanced)) let is_active = active_tab == Some(p.id.as_str());
.on_press(Message::OpenTab(Tab { let status_indicator = match p.status {
id: p.id.clone(), bds_core::model::PostStatus::Draft => "\u{25CB} ",
tab_type: TabType::Post, bds_core::model::PostStatus::Published => "\u{25CF} ",
title: p.title.clone(), bds_core::model::PostStatus::Archived => "\u{25A1} ",
is_transient: true, };
is_dirty: false, let label = format!("{status_indicator}{}", p.title);
})) let label_text = text(label)
.padding([3, 6]) .size(12)
.shaping(Shaping::Advanced)
.wrapping(iced::widget::text::Wrapping::None);
let style_fn = if is_active { item_active_style } else { item_style };
button(
container(label_text)
.width(Length::Fill) .width(Length::Fill)
.style(item_style) .clip(true)
.into() )
}) .on_press(Message::OpenTab(Tab {
.collect(); id: p.id.clone(),
iced::widget::Column::with_children(items) tab_type: TabType::Post,
title: p.title.clone(),
is_transient: true,
is_dirty: false,
}))
.padding([3, 6])
.width(Length::Fill)
.style(style_fn)
.into()
};
let mut sections: Vec<Element<'static, Message>> = Vec::new();
// Draft section
let drafts: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Draft).collect();
if !drafts.is_empty() {
sections.push(section_header(&t(locale, "sidebar.drafts")));
for p in &drafts {
sections.push(make_post_item(p));
}
sections.push(Space::with_height(6.0).into());
}
// Published section
let published: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Published).collect();
if !published.is_empty() {
sections.push(section_header(&t(locale, "sidebar.published")));
for p in &published {
sections.push(make_post_item(p));
}
sections.push(Space::with_height(6.0).into());
}
// Archived section
let archived: Vec<&Post> = posts.iter().filter(|p| p.status == bds_core::model::PostStatus::Archived).collect();
if !archived.is_empty() {
sections.push(section_header(&t(locale, "sidebar.archived")));
for p in &archived {
sections.push(make_post_item(p));
}
}
iced::widget::Column::with_children(sections)
.spacing(1) .spacing(1)
.into() .into()
} }
@@ -115,20 +187,34 @@ pub fn view(
let items: Vec<Element<'static, Message>> = media let items: Vec<Element<'static, Message>> = media
.iter() .iter()
.map(|m| { .map(|m| {
let display_name = m.title.as_deref() let is_active = active_tab == Some(m.id.as_str());
.unwrap_or(&m.original_name); // Per sidebar_views.allium MediaGridItem: title truncated to 60 chars + "..."
// if over limit; fallback originalName (no truncation).
let display_name = match m.title.as_deref() {
Some(title) => truncate_media_title(title),
None => m.original_name.clone(),
};
let label = format!("\u{1F5BC} {display_name}"); let label = format!("\u{1F5BC} {display_name}");
button(text(label).size(12).shaping(Shaping::Advanced)) let label_text = text(label)
.size(12)
.shaping(Shaping::Advanced)
.wrapping(iced::widget::text::Wrapping::None);
let style_fn = if is_active { item_active_style } else { item_style };
button(
container(label_text)
.width(Length::Fill)
.clip(true)
)
.on_press(Message::OpenTab(Tab { .on_press(Message::OpenTab(Tab {
id: m.id.clone(), id: m.id.clone(),
tab_type: TabType::Media, tab_type: TabType::Media,
title: display_name.to_string(), title: display_name.clone(),
is_transient: true, is_transient: true,
is_dirty: false, is_dirty: false,
})) }))
.padding([3, 6]) .padding([3, 6])
.width(Length::Fill) .width(Length::Fill)
.style(item_style) .style(style_fn)
.into() .into()
}) })
.collect(); .collect();
@@ -154,9 +240,41 @@ pub fn view(
.spacing(4) .spacing(4)
.padding(12); .padding(12);
// layout.allium: sidebar width is resizable, passed as parameter
container(scrollable(content)) container(scrollable(content))
.width(Length::Fixed(280.0)) .width(Length::Fixed(width))
.height(Length::Fill) .height(Length::Fill)
.style(sidebar_style) .style(sidebar_style)
.into() .into()
} }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_media_title_short() {
assert_eq!(truncate_media_title("short title"), "short title");
}
#[test]
fn truncate_media_title_exact_60() {
let title: String = "a".repeat(60);
assert_eq!(truncate_media_title(&title), title);
}
#[test]
fn truncate_media_title_over_60() {
let title: String = "a".repeat(65);
let expected = format!("{}...", "a".repeat(60));
assert_eq!(truncate_media_title(&title), expected);
}
#[test]
fn truncate_media_title_unicode() {
// 61 Unicode chars should trigger truncation
let title: String = "\u{00FC}".repeat(61); // ü × 61
let expected = format!("{}...", "\u{00FC}".repeat(60));
assert_eq!(truncate_media_title(&title), expected);
}
}

View File

@@ -1,10 +1,20 @@
use iced::widget::{button, container, row, text, Space}; use iced::widget::{button, container, row, scrollable, text, tooltip, Space};
use iced::widget::scrollable::Direction;
use iced::widget::text::Shaping; use iced::widget::text::Shaping;
use iced::widget::tooltip::Position;
use iced::{Background, Border, Color, Element, Font, Length, Theme}; use iced::{Background, Border, Color, Element, Font, Length, Theme};
use bds_core::i18n::UiLocale;
use crate::app::Message; use crate::app::Message;
use crate::i18n::t;
use crate::state::tabs::Tab; use crate::state::tabs::Tab;
/// tabs.allium config: tab_min_width=100, tab_max_width=160
/// In a scrollable tab bar, tabs use the max width since they never need to shrink.
const TAB_WIDTH: f32 = 160.0;
const CHAT_TITLE_MAX_LEN: usize = 18;
/// Tab bar background. /// Tab bar background.
fn bar_style(_theme: &Theme) -> container::Style { fn bar_style(_theme: &Theme) -> container::Style {
container::Style { container::Style {
@@ -64,9 +74,49 @@ fn close_style(_theme: &Theme, status: button::Status) -> button::Style {
} }
} }
/// Tooltip style.
fn tooltip_style(_theme: &Theme) -> container::Style {
container::Style {
background: Some(Background::Color(Color::from_rgb(0.20, 0.20, 0.24))),
border: Border {
color: Color::from_rgb(0.35, 0.35, 0.40),
width: 1.0,
radius: 4.0.into(),
},
..container::Style::default()
}
}
/// Truncate chat title per tabs.allium: chat_title_max_length = 18.
fn truncate_chat_title(title: &str) -> String {
if title.chars().count() > CHAT_TITLE_MAX_LEN {
let truncated: String = title.chars().take(CHAT_TITLE_MAX_LEN).collect();
format!("{truncated}...")
} else {
title.to_string()
}
}
/// Build tooltip text per tabs.allium:
/// Base: tab title. If transient: append " (Preview)". If dirty: append " * Modified".
fn build_tooltip_text(tab: &Tab, locale: UiLocale) -> String {
let mut tip = tab.title.clone();
if tab.is_transient {
tip.push_str(" (");
tip.push_str(&t(locale, "tabBar.preview"));
tip.push(')');
}
if tab.is_dirty {
tip.push_str(" * ");
tip.push_str(&t(locale, "tabBar.modified"));
}
tip
}
pub fn view( pub fn view(
tabs: &[Tab], tabs: &[Tab],
active_tab: Option<&str>, active_tab: Option<&str>,
locale: UiLocale,
) -> Element<'static, Message> { ) -> Element<'static, Message> {
// Per tabs.allium: "Hidden when no tabs exist." // Per tabs.allium: "Hidden when no tabs exist."
if tabs.is_empty() { if tabs.is_empty() {
@@ -80,16 +130,25 @@ pub fn view(
let tab_id = tab.id.clone(); let tab_id = tab.id.clone();
let close_id = tab.id.clone(); let close_id = tab.id.clone();
// Per tabs.allium: chat titles are JS-truncated to 18 chars + "..."
let display_title = if tab.tab_type == crate::state::tabs::TabType::Chat {
truncate_chat_title(&tab.title)
} else {
tab.title.clone()
};
// Per tabs.allium: transient tabs show italic title // Per tabs.allium: transient tabs show italic title
let title_label = if tab.is_transient { let title_label = if tab.is_transient {
text(tab.title.clone()) text(display_title)
.size(12) .size(12)
.shaping(Shaping::Advanced) .shaping(Shaping::Advanced)
.font(Font { style: iced::font::Style::Italic, ..Font::DEFAULT }) .font(Font { style: iced::font::Style::Italic, ..Font::DEFAULT })
.wrapping(iced::widget::text::Wrapping::None)
} else { } else {
text(tab.title.clone()) text(display_title)
.size(12) .size(12)
.shaping(Shaping::Advanced) .shaping(Shaping::Advanced)
.wrapping(iced::widget::text::Wrapping::None)
}; };
// Per tabs.allium DirtyIndicator: dot for dirty post tabs // Per tabs.allium DirtyIndicator: dot for dirty post tabs
@@ -100,32 +159,85 @@ pub fn view(
text("").size(10) text("").size(10)
}; };
// Title + dirty indicator clipped to enforce ellipsis-like truncation
let title_area = container(
row![title_label, dirty_indicator]
.spacing(2)
.align_y(iced::Alignment::Center)
)
.width(Length::Fill)
.clip(true);
let label = row![ let label = row![
title_label, title_area,
dirty_indicator,
button(text("\u{2715}").size(10).shaping(Shaping::Advanced)) button(text("\u{2715}").size(10).shaping(Shaping::Advanced))
.on_press(Message::CloseTab(close_id)) .on_press(Message::CloseTab(close_id))
.padding(2) .padding(2)
.style(close_style), .style(close_style),
] ]
.spacing(6) .spacing(4)
.align_y(iced::Alignment::Center); .align_y(iced::Alignment::Center);
button(label) // tabs.allium: tab_min_width=100, tab_max_width=160
let tab_btn = button(label)
.on_press(Message::SelectTab(tab_id)) .on_press(Message::SelectTab(tab_id))
.padding([6, 12]) .padding([6, 8])
.style(if is_active { tab_active } else { tab_inactive }) .width(Length::Fixed(TAB_WIDTH))
.into() .style(if is_active { tab_active } else { tab_inactive });
// tabs.allium tooltip: title + "(Preview)" if transient + "* Modified" if dirty
let tooltip_text = build_tooltip_text(tab, locale);
let tip: Element<'static, Message> = tooltip(
tab_btn,
text(tooltip_text).size(11).shaping(Shaping::Advanced),
Position::Bottom,
)
.gap(4)
.style(tooltip_style)
.into();
tip
}) })
.collect(); .collect();
container( // tabs.allium: horizontal strip with overflow scroll
iced::widget::Row::with_children(tab_buttons) let tab_row = iced::widget::Row::with_children(tab_buttons)
.spacing(1) .spacing(1)
.height(Length::Fixed(35.0)), .height(Length::Fixed(35.0));
)
.width(Length::Fill) let scrollable_tabs = scrollable(tab_row)
.height(Length::Fixed(35.0)) .direction(Direction::Horizontal(
.style(bar_style) scrollable::Scrollbar::new().width(0).scroller_width(0),
.into() ))
.width(Length::Fill)
.height(Length::Fixed(35.0));
container(scrollable_tabs)
.width(Length::Fill)
.height(Length::Fixed(35.0))
.style(bar_style)
.into()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn truncate_chat_title_short() {
assert_eq!(truncate_chat_title("Hello"), "Hello");
}
#[test]
fn truncate_chat_title_exact_18() {
let title: String = "a".repeat(18);
assert_eq!(truncate_chat_title(&title), title);
}
#[test]
fn truncate_chat_title_over_18() {
let title: String = "a".repeat(25);
let expected = format!("{}...", "a".repeat(18));
assert_eq!(truncate_chat_title(&title), expected);
}
} }

View File

@@ -1,4 +1,4 @@
use iced::widget::{button, column, container, row, stack, text, Space}; use iced::widget::{button, column, container, mouse_area, row, stack, text, Space};
use iced::widget::text::Shaping; use iced::widget::text::Shaping;
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme}; use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
@@ -19,15 +19,12 @@ fn content_bg(_theme: &Theme) -> container::Style {
} }
} }
/// Horizontal separator line between regions. /// Sidebar resize drag handle style.
fn separator_v() -> iced::widget::Container<'static, Message> { fn drag_handle_style(_theme: &Theme) -> container::Style {
container(Space::new(0, 0)) container::Style {
.width(Length::Fixed(1.0)) background: Some(Background::Color(Color::from_rgb(0.25, 0.25, 0.30))),
.height(Length::Fill) ..container::Style::default()
.style(|_theme: &Theme| container::Style { }
background: Some(Background::Color(Color::from_rgb(0.25, 0.25, 0.30))),
..container::Style::default()
})
} }
/// Horizontal line separator (full width). /// Horizontal line separator (full width).
@@ -46,6 +43,7 @@ pub fn view(
// Navigation // Navigation
sidebar_view: SidebarView, sidebar_view: SidebarView,
sidebar_visible: bool, sidebar_visible: bool,
sidebar_width: f32,
// Tabs // Tabs
tabs: &[Tab], tabs: &[Tab],
active_tab: Option<&str>, active_tab: Option<&str>,
@@ -76,7 +74,7 @@ pub fn view(
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale); let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
// Tab bar // Tab bar
let tabs_el = tab_bar::view(tabs, active_tab); let tabs_el = tab_bar::view(tabs, active_tab, locale);
// Content area // Content area
let content_area = welcome::view(locale); let content_area = welcome::view(locale);
@@ -104,12 +102,25 @@ pub fn view(
.height(Length::Fill) .height(Length::Fill)
.style(content_bg); .style(content_bg);
// Main row: activity bar | separator | sidebar? | separator | right column // Main row: activity bar | sidebar? | drag handle | right column
let mut main_row = row![activity]; let mut main_row = row![activity];
if sidebar_visible { if sidebar_visible {
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, locale)); main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_width, active_tab, locale));
main_row = main_row.push(separator_v());
// Resize drag handle: 4px wide strip between sidebar and content
let handle = container(Space::new(0, 0))
.width(Length::Fixed(4.0))
.height(Length::Fill)
.style(drag_handle_style);
let handle_hover = mouse_area(handle)
.on_press(Message::SidebarResizeStart)
.on_release(Message::SidebarResizeEnd)
.on_move(|point| Message::SidebarResizeMove(point.x))
.interaction(iced::mouse::Interaction::ResizingHorizontally);
main_row = main_row.push(handle_hover);
} }
main_row = main_row.push(right); main_row = main_row.push(right);

View File

@@ -2,7 +2,7 @@
"menu.group.file": "Datei", "menu.group.file": "Datei",
"menu.group.edit": "Bearbeiten", "menu.group.edit": "Bearbeiten",
"menu.group.view": "Ansicht", "menu.group.view": "Ansicht",
"menu.group.blog": "Blogbereich", "menu.group.window": "Fenster",
"menu.group.help": "Hilfe", "menu.group.help": "Hilfe",
"menu.item.newPost": "Neuer Beitrag", "menu.item.newPost": "Neuer Beitrag",
"menu.item.importMedia": "Medien importieren...", "menu.item.importMedia": "Medien importieren...",
@@ -88,6 +88,16 @@
"tabBar.loading": "Laden...", "tabBar.loading": "Laden...",
"tabBar.unknown": "Unbekannt", "tabBar.unknown": "Unbekannt",
"tabBar.style": "Stil", "tabBar.style": "Stil",
"tabBar.menuEditor": "Menü-Editor",
"tabBar.metadataDiff": "Metadaten-Diff",
"tabBar.siteValidation": "Website-Validierung",
"tabBar.translationValidation": "Übersetzungsvalidierung",
"tabBar.documentation": "Dokumentation",
"tabBar.apiDocumentation": "API-Dokumentation",
"tabBar.findDuplicates": "Duplikate finden",
"tabBar.tags": "Tags",
"tabBar.preview": "Vorschau",
"tabBar.modified": "Geändert",
"sidebar.noPostsYet": "Noch keine Beiträge", "sidebar.noPostsYet": "Noch keine Beiträge",
"sidebar.noPagesYet": "Noch keine Seiten", "sidebar.noPagesYet": "Noch keine Seiten",
"sidebar.noMediaYet": "Noch keine Medien", "sidebar.noMediaYet": "Noch keine Medien",
@@ -99,6 +109,9 @@
"sidebar.loading": "Lädt...", "sidebar.loading": "Lädt...",
"sidebar.settingsHeader": "Einstellungen", "sidebar.settingsHeader": "Einstellungen",
"sidebar.tagsHeader": "Schlagwörter", "sidebar.tagsHeader": "Schlagwörter",
"sidebar.drafts": "Entwürfe",
"sidebar.published": "Veröffentlicht",
"sidebar.archived": "Archiviert",
"language.en": "Englisch", "language.en": "Englisch",
"language.de": "Deutsch", "language.de": "Deutsch",
"language.fr": "Französisch", "language.fr": "Französisch",

View File

@@ -2,7 +2,7 @@
"menu.group.file": "File", "menu.group.file": "File",
"menu.group.edit": "Edit", "menu.group.edit": "Edit",
"menu.group.view": "View", "menu.group.view": "View",
"menu.group.blog": "Blog", "menu.group.window": "Window",
"menu.group.help": "Help", "menu.group.help": "Help",
"menu.item.newPost": "New Post", "menu.item.newPost": "New Post",
"menu.item.importMedia": "Import Media...", "menu.item.importMedia": "Import Media...",
@@ -88,6 +88,16 @@
"tabBar.loading": "Loading...", "tabBar.loading": "Loading...",
"tabBar.unknown": "Unknown", "tabBar.unknown": "Unknown",
"tabBar.style": "Style", "tabBar.style": "Style",
"tabBar.menuEditor": "Menu Editor",
"tabBar.metadataDiff": "Metadata Diff",
"tabBar.siteValidation": "Site Validation",
"tabBar.translationValidation": "Translation Validation",
"tabBar.documentation": "Documentation",
"tabBar.apiDocumentation": "API Documentation",
"tabBar.findDuplicates": "Find Duplicates",
"tabBar.tags": "Tags",
"tabBar.preview": "Preview",
"tabBar.modified": "Modified",
"sidebar.noPostsYet": "No posts yet", "sidebar.noPostsYet": "No posts yet",
"sidebar.noPagesYet": "No pages yet", "sidebar.noPagesYet": "No pages yet",
"sidebar.noMediaYet": "No media yet", "sidebar.noMediaYet": "No media yet",
@@ -99,6 +109,9 @@
"sidebar.loading": "Loading...", "sidebar.loading": "Loading...",
"sidebar.settingsHeader": "Settings", "sidebar.settingsHeader": "Settings",
"sidebar.tagsHeader": "Tags", "sidebar.tagsHeader": "Tags",
"sidebar.drafts": "Drafts",
"sidebar.published": "Published",
"sidebar.archived": "Archived",
"language.en": "English", "language.en": "English",
"language.de": "German", "language.de": "German",
"language.fr": "French", "language.fr": "French",

View File

@@ -2,7 +2,7 @@
"menu.group.file": "Archivo", "menu.group.file": "Archivo",
"menu.group.edit": "Editar", "menu.group.edit": "Editar",
"menu.group.view": "Ver", "menu.group.view": "Ver",
"menu.group.blog": "Bitácora", "menu.group.window": "Ventana",
"menu.group.help": "Ayuda", "menu.group.help": "Ayuda",
"menu.item.newPost": "Nueva entrada", "menu.item.newPost": "Nueva entrada",
"menu.item.importMedia": "Importar medios...", "menu.item.importMedia": "Importar medios...",
@@ -88,6 +88,16 @@
"tabBar.loading": "Cargando...", "tabBar.loading": "Cargando...",
"tabBar.unknown": "Desconocido", "tabBar.unknown": "Desconocido",
"tabBar.style": "Estilo", "tabBar.style": "Estilo",
"tabBar.menuEditor": "Editor de menú",
"tabBar.metadataDiff": "Diff de metadatos",
"tabBar.siteValidation": "Validación del sitio",
"tabBar.translationValidation": "Validación de traducciones",
"tabBar.documentation": "Documentación",
"tabBar.apiDocumentation": "Documentación API",
"tabBar.findDuplicates": "Buscar duplicados",
"tabBar.tags": "Etiquetas",
"tabBar.preview": "Vista previa",
"tabBar.modified": "Modificado",
"sidebar.noPostsYet": "Aún no hay entradas", "sidebar.noPostsYet": "Aún no hay entradas",
"sidebar.noPagesYet": "Aún no hay páginas", "sidebar.noPagesYet": "Aún no hay páginas",
"sidebar.noMediaYet": "Aún no hay medios", "sidebar.noMediaYet": "Aún no hay medios",
@@ -99,6 +109,9 @@
"sidebar.loading": "Cargando...", "sidebar.loading": "Cargando...",
"sidebar.settingsHeader": "Configuración", "sidebar.settingsHeader": "Configuración",
"sidebar.tagsHeader": "Etiquetas", "sidebar.tagsHeader": "Etiquetas",
"sidebar.drafts": "Borradores",
"sidebar.published": "Publicados",
"sidebar.archived": "Archivados",
"language.en": "Inglés", "language.en": "Inglés",
"language.de": "Alemán", "language.de": "Alemán",
"language.fr": "Francés", "language.fr": "Francés",

View File

@@ -2,7 +2,7 @@
"menu.group.file": "Fichier", "menu.group.file": "Fichier",
"menu.group.edit": "Édition", "menu.group.edit": "Édition",
"menu.group.view": "Affichage", "menu.group.view": "Affichage",
"menu.group.blog": "Espace blog", "menu.group.window": "Fenêtre",
"menu.group.help": "Aide", "menu.group.help": "Aide",
"menu.item.newPost": "Nouvel article", "menu.item.newPost": "Nouvel article",
"menu.item.importMedia": "Importer des médias...", "menu.item.importMedia": "Importer des médias...",
@@ -88,6 +88,16 @@
"tabBar.loading": "Chargement...", "tabBar.loading": "Chargement...",
"tabBar.unknown": "Inconnu", "tabBar.unknown": "Inconnu",
"tabBar.style": "Style", "tabBar.style": "Style",
"tabBar.menuEditor": "Éditeur de menu",
"tabBar.metadataDiff": "Diff métadonnées",
"tabBar.siteValidation": "Validation du site",
"tabBar.translationValidation": "Validation des traductions",
"tabBar.documentation": "Documentation",
"tabBar.apiDocumentation": "Documentation API",
"tabBar.findDuplicates": "Trouver les doublons",
"tabBar.tags": "Tags",
"tabBar.preview": "Aperçu",
"tabBar.modified": "Modifié",
"sidebar.noPostsYet": "Aucun article pour le moment", "sidebar.noPostsYet": "Aucun article pour le moment",
"sidebar.noPagesYet": "Aucune page pour le moment", "sidebar.noPagesYet": "Aucune page pour le moment",
"sidebar.noMediaYet": "Aucun média pour le moment", "sidebar.noMediaYet": "Aucun média pour le moment",
@@ -99,6 +109,9 @@
"sidebar.loading": "Chargement...", "sidebar.loading": "Chargement...",
"sidebar.settingsHeader": "Paramètres", "sidebar.settingsHeader": "Paramètres",
"sidebar.tagsHeader": "Étiquettes", "sidebar.tagsHeader": "Étiquettes",
"sidebar.drafts": "Brouillons",
"sidebar.published": "Publiés",
"sidebar.archived": "Archivés",
"language.en": "Anglais", "language.en": "Anglais",
"language.de": "Allemand", "language.de": "Allemand",
"language.fr": "Français", "language.fr": "Français",

View File

@@ -2,7 +2,7 @@
"menu.group.file": "Archivio", "menu.group.file": "Archivio",
"menu.group.edit": "Modifica", "menu.group.edit": "Modifica",
"menu.group.view": "Vista", "menu.group.view": "Vista",
"menu.group.blog": "Sezione blog", "menu.group.window": "Finestra",
"menu.group.help": "Aiuto", "menu.group.help": "Aiuto",
"menu.item.newPost": "Nuovo post", "menu.item.newPost": "Nuovo post",
"menu.item.importMedia": "Importa media...", "menu.item.importMedia": "Importa media...",
@@ -88,6 +88,16 @@
"tabBar.loading": "Caricamento...", "tabBar.loading": "Caricamento...",
"tabBar.unknown": "Sconosciuto", "tabBar.unknown": "Sconosciuto",
"tabBar.style": "Stile", "tabBar.style": "Stile",
"tabBar.menuEditor": "Editor menu",
"tabBar.metadataDiff": "Diff metadati",
"tabBar.siteValidation": "Validazione sito",
"tabBar.translationValidation": "Validazione traduzioni",
"tabBar.documentation": "Documentazione",
"tabBar.apiDocumentation": "Documentazione API",
"tabBar.findDuplicates": "Trova duplicati",
"tabBar.tags": "Tag",
"tabBar.preview": "Anteprima",
"tabBar.modified": "Modificato",
"sidebar.noPostsYet": "Nessun post", "sidebar.noPostsYet": "Nessun post",
"sidebar.noPagesYet": "Nessuna pagina", "sidebar.noPagesYet": "Nessuna pagina",
"sidebar.noMediaYet": "Nessun media", "sidebar.noMediaYet": "Nessun media",
@@ -99,6 +109,9 @@
"sidebar.loading": "Caricamento...", "sidebar.loading": "Caricamento...",
"sidebar.settingsHeader": "Impostazioni", "sidebar.settingsHeader": "Impostazioni",
"sidebar.tagsHeader": "Tag", "sidebar.tagsHeader": "Tag",
"sidebar.drafts": "Bozze",
"sidebar.published": "Pubblicati",
"sidebar.archived": "Archiviati",
"language.en": "Inglese", "language.en": "Inglese",
"language.de": "Tedesco", "language.de": "Tedesco",
"language.fr": "Francese", "language.fr": "Francese",