fix: and even more fixes for M3

This commit is contained in:
2026-04-05 19:36:31 +02:00
parent 17c4c79fd6
commit eb80e8ef19
10 changed files with 568 additions and 152 deletions

View File

@@ -113,7 +113,7 @@ pub fn rebuild_from_filesystem_with_progress(
report.templates_updated = tpl_report.updated; report.templates_updated = tpl_report.updated;
report.errors.extend(tpl_report.errors); report.errors.extend(tpl_report.errors);
// 5. Rebuild scripts (0.85 .. 1.0) // 5. Rebuild scripts (0.85 .. 0.95)
progress(0.85, "Rebuilding scripts..."); progress(0.85, "Rebuilding scripts...");
let script_report = let script_report =
script_rebuild::rebuild_scripts_from_filesystem(conn, data_dir, project_id)?; script_rebuild::rebuild_scripts_from_filesystem(conn, data_dir, project_id)?;
@@ -121,6 +121,11 @@ pub fn rebuild_from_filesystem_with_progress(
report.scripts_updated = script_report.updated; report.scripts_updated = script_report.updated;
report.errors.extend(script_report.errors); report.errors.extend(script_report.errors);
// 6. Import tags from tags.json and sync from posts (0.95 .. 1.0)
progress(0.95, "Importing tags...");
let _ = super::tag::import_tags_from_file(conn, data_dir, project_id);
let _ = super::tag::sync_tags_from_posts(conn, project_id);
progress(1.0, "Rebuild complete"); progress(1.0, "Rebuild complete");
Ok(report) Ok(report)
} }

View File

@@ -188,11 +188,61 @@ pub fn merge_tags(
Ok(()) Ok(())
} }
/// Sync tags from all posts: collect unique tag names, create missing tags in DB. /// Import tags from meta/tags.json into DB, preserving colors and properties.
pub fn sync_tags_from_posts( /// Creates new tags or updates existing ones with file-based properties.
pub fn import_tags_from_file(
conn: &Connection, conn: &Connection,
data_dir: &Path, data_dir: &Path,
project_id: &str, project_id: &str,
) -> EngineResult<()> {
let entries = match meta::read_tags_json(data_dir) {
Ok(entries) => entries,
Err(_) => return Ok(()), // File doesn't exist or is invalid — nothing to import
};
let now = now_unix_ms();
for entry in &entries {
let name = entry.name.trim();
if name.is_empty() {
continue;
}
match tag_q::get_tag_by_project_and_name(conn, project_id, name) {
Ok(existing) => {
// Update color/post_template_slug from file if provided
if entry.color.is_some() || entry.post_template_slug.is_some() {
let mut updated = existing;
if entry.color.is_some() {
updated.color = entry.color.clone();
}
if entry.post_template_slug.is_some() {
updated.post_template_slug = entry.post_template_slug.clone();
}
updated.updated_at = now;
tag_q::update_tag(conn, &updated)?;
}
}
Err(_) => {
let tag = Tag {
id: Uuid::new_v4().to_string(),
project_id: project_id.to_string(),
name: name.to_string(),
color: entry.color.clone(),
post_template_slug: entry.post_template_slug.clone(),
created_at: now,
updated_at: now,
};
tag_q::insert_tag(conn, &tag)?;
}
}
}
Ok(())
}
/// Sync tags from all posts: collect unique tag names, create missing tags in DB.
/// This is additive only — it does NOT rewrite tags.json.
pub fn sync_tags_from_posts(
conn: &Connection,
project_id: &str,
) -> EngineResult<Vec<Tag>> { ) -> EngineResult<Vec<Tag>> {
let posts = post_q::list_posts_by_project(conn, project_id)?; let posts = post_q::list_posts_by_project(conn, project_id)?;
@@ -225,7 +275,6 @@ pub fn sync_tags_from_posts(
} }
} }
rewrite_tags_json(conn, data_dir, project_id)?;
let all_tags = tag_q::list_tags_by_project(conn, project_id)?; let all_tags = tag_q::list_tags_by_project(conn, project_id)?;
Ok(all_tags) Ok(all_tags)
} }
@@ -483,15 +532,32 @@ mod tests {
) )
.unwrap(); .unwrap();
let tags = sync_tags_from_posts(db.conn(), dir.path(), "p1").unwrap(); let tags = sync_tags_from_posts(db.conn(), "p1").unwrap();
assert_eq!(tags.len(), 3); assert_eq!(tags.len(), 3);
let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect(); let names: Vec<&str> = tags.iter().map(|t| t.name.as_str()).collect();
assert!(names.contains(&"go")); assert!(names.contains(&"go"));
assert!(names.contains(&"rust")); assert!(names.contains(&"rust"));
assert!(names.contains(&"web")); assert!(names.contains(&"web"));
}
let entries = meta::read_tags_json(dir.path()).unwrap(); #[test]
assert_eq!(entries.len(), 3); fn import_tags_from_file_preserves_colors() {
let (db, dir) = setup();
// Write tags.json with colors
let entries = vec![
TagEntry { name: "rust".into(), color: Some("#ff0000".into()), post_template_slug: None },
TagEntry { name: "web".into(), color: None, post_template_slug: Some("blog".into()) },
];
meta::write_tags_json(dir.path(), &entries).unwrap();
import_tags_from_file(db.conn(), dir.path(), "p1").unwrap();
let tags = tag_q::list_tags_by_project(db.conn(), "p1").unwrap();
assert_eq!(tags.len(), 2);
let rust_tag = tags.iter().find(|t| t.name == "rust").unwrap();
assert_eq!(rust_tag.color.as_deref(), Some("#ff0000"));
let web_tag = tags.iter().find(|t| t.name == "web").unwrap();
assert_eq!(web_tag.post_template_slug.as_deref(), Some("blog"));
} }
#[test] #[test]

View File

@@ -95,12 +95,49 @@ fn syntect_to_iced(c: syntect::highlighting::Color) -> Color {
) )
} }
/// Map a visual row index (from scroll top) to (logical_line, char_offset).
/// Returns None if beyond end of buffer.
fn visual_to_logical(
buf: &EditorBuffer,
scroll: usize,
visual_row: usize,
chars_per_visual_line: usize,
) -> Option<(usize, usize)> {
if chars_per_visual_line == 0 {
// No wrapping — direct 1:1 mapping
let line = scroll + visual_row;
return if line < buf.line_count() { Some((line, 0)) } else { None };
}
let target = scroll + visual_row;
let mut vis_count = 0usize;
for line_idx in 0..buf.line_count() {
let line_chars = buf.line(line_idx)
.map(|l| {
let n = l.len_chars();
if n > 0 && l.char(n - 1) == '\n' { n - 1 } else { n }
})
.unwrap_or(0);
let wrap_count = if line_chars > chars_per_visual_line {
(line_chars + chars_per_visual_line - 1) / chars_per_visual_line
} else {
1
};
if vis_count + wrap_count > target {
let sub = target - vis_count;
return Some((line_idx, sub * chars_per_visual_line));
}
vis_count += wrap_count;
}
None
}
/// A syntax-highlighting code editor widget for Iced. /// A syntax-highlighting code editor widget for Iced.
pub struct CodeEditor<'a, Message> { pub struct CodeEditor<'a, Message> {
buffer: &'a RefCell<EditorBuffer>, buffer: &'a RefCell<EditorBuffer>,
highlighter: &'a Highlighter, highlighter: &'a Highlighter,
extension: &'a str, extension: &'a str,
on_change: Option<Box<dyn Fn(EditorMessage) -> Message + 'a>>, on_change: Option<Box<dyn Fn(EditorMessage) -> Message + 'a>>,
word_wrap: bool,
} }
impl<'a, Message> CodeEditor<'a, Message> { impl<'a, Message> CodeEditor<'a, Message> {
@@ -114,6 +151,7 @@ impl<'a, Message> CodeEditor<'a, Message> {
highlighter, highlighter,
extension, extension,
on_change: None, on_change: None,
word_wrap: false,
} }
} }
@@ -121,6 +159,11 @@ impl<'a, Message> CodeEditor<'a, Message> {
self.on_change = Some(Box::new(f)); self.on_change = Some(Box::new(f));
self self
} }
pub fn word_wrap(mut self, enabled: bool) -> Self {
self.word_wrap = enabled;
self
}
} }
impl<'a, Message, Renderer> Widget<Message, Theme, Renderer> for CodeEditor<'a, Message> impl<'a, Message, Renderer> Widget<Message, Theme, Renderer> for CodeEditor<'a, Message>
@@ -200,22 +243,53 @@ where
let highlighted = self.highlighter.highlight_lines(&full_text, syntax); let highlighted = self.highlighter.highlight_lines(&full_text, syntax);
let font = iced::Font::MONOSPACE; let font = iced::Font::MONOSPACE;
let text_area_width = bounds.width - GUTTER_WIDTH - 8.0;
let chars_per_visual_line = if self.word_wrap && text_area_width > metrics.char_width {
(text_area_width / metrics.char_width).floor() as usize
} else {
0 // 0 = no wrapping
};
// Render visible lines // Build visual line map: skip 'scroll' visual lines, then render 'visible_lines' visual lines.
for vis_idx in 0..visible_lines { // Each entry: (logical_line, char_offset, is_first_visual_line)
let line_idx = scroll + vis_idx; let mut visual_rows: Vec<(usize, usize, bool)> = Vec::new();
if line_idx >= buf.line_count() { let mut vis_skip = 0usize; // visual lines to skip for scroll offset
break; let mut line_idx = 0usize;
while visual_rows.len() < visible_lines && line_idx < buf.line_count() {
let line_chars = buf.line(line_idx)
.map(|l| {
let n = l.len_chars();
if n > 0 && l.char(n - 1) == '\n' { n - 1 } else { n }
})
.unwrap_or(0);
let wrap_count = if chars_per_visual_line > 0 && line_chars > chars_per_visual_line {
(line_chars + chars_per_visual_line - 1) / chars_per_visual_line
} else {
1
};
for w in 0..wrap_count {
if vis_skip < scroll {
vis_skip += 1;
} else if visual_rows.len() < visible_lines {
visual_rows.push((line_idx, w * chars_per_visual_line, w == 0));
}
} }
line_idx += 1;
}
let text_x = bounds.x + GUTTER_WIDTH + 8.0;
// Render visual rows
for (vis_idx, &(line_idx, char_offset, is_first)) in visual_rows.iter().enumerate() {
let y = bounds.y + vis_idx as f32 * metrics.line_height; let y = bounds.y + vis_idx as f32 * metrics.line_height;
if y + metrics.line_height < bounds.y || y > bounds.y + bounds.height { if y + metrics.line_height < bounds.y || y > bounds.y + bounds.height {
continue; continue;
} }
let text_x = bounds.x + GUTTER_WIDTH + 8.0; // Draw selection highlight for this visual line
// Draw selection highlight for this line
if let Some(sel) = selection { if let Some(sel) = selection {
if !sel.is_empty() { if !sel.is_empty() {
let (start, end) = sel.ordered(); let (start, end) = sel.ordered();
@@ -223,33 +297,28 @@ where
.line(line_idx) .line(line_idx)
.map(|l| { .map(|l| {
let len = l.len_chars(); let len = l.len_chars();
if len > 0 && l.char(len - 1) == '\n' { if len > 0 && l.char(len - 1) == '\n' { len - 1 } else { len }
len - 1
} else {
len
}
}) })
.unwrap_or(0); .unwrap_or(0);
if line_idx >= start.0 && line_idx <= end.0 { if line_idx >= start.0 && line_idx <= end.0 {
let sel_start_col = if line_idx == start.0 { start.1 } else { 0 }; let sel_start_col = if line_idx == start.0 { start.1 } else { 0 };
let sel_end_col = if line_idx == end.0 { let sel_end_col = if line_idx == end.0 { end.1 } else { line_len + 1 };
end.1
// Clip selection to current visual line range
let vis_end = if chars_per_visual_line > 0 {
char_offset + chars_per_visual_line
} else { } else {
line_len + 1 usize::MAX
}; };
let sel_x = text_x + sel_start_col as f32 * metrics.char_width; let s = sel_start_col.max(char_offset);
let sel_w = let e = sel_end_col.min(vis_end);
(sel_end_col - sel_start_col) as f32 * metrics.char_width; if s < e {
if sel_w > 0.0 { let sel_x = text_x + (s - char_offset) as f32 * metrics.char_width;
let sel_w = (e - s) as f32 * metrics.char_width;
renderer.fill_quad( renderer.fill_quad(
renderer::Quad { renderer::Quad {
bounds: Rectangle { bounds: Rectangle { x: sel_x, y, width: sel_w, height: metrics.line_height },
x: sel_x,
y,
width: sel_w,
height: metrics.line_height,
},
border: iced::Border::default(), border: iced::Border::default(),
shadow: iced::Shadow::default(), shadow: iced::Shadow::default(),
}, },
@@ -260,54 +329,70 @@ where
} }
} }
// Line number // Line number (only on first visual line of each logical line)
let line_num = format!("{:>4}", line_idx + 1); if is_first {
let num_color = if line_idx == cursor_line { let line_num = format!("{:>4}", line_idx + 1);
ACTIVE_LINE_NUM let num_color = if line_idx == cursor_line { ACTIVE_LINE_NUM } else { GUTTER_TEXT };
} else { renderer.fill_text(
GUTTER_TEXT text::Text {
}; content: line_num,
renderer.fill_text( bounds: Size::new(GUTTER_WIDTH - 8.0, metrics.line_height),
text::Text { size: Pixels(FONT_SIZE),
content: line_num, line_height: iced::widget::text::LineHeight::Absolute(Pixels(metrics.line_height)),
bounds: Size::new(GUTTER_WIDTH - 8.0, metrics.line_height), font,
size: Pixels(FONT_SIZE), horizontal_alignment: iced::alignment::Horizontal::Right,
line_height: iced::widget::text::LineHeight::Absolute(Pixels( vertical_alignment: iced::alignment::Vertical::Top,
metrics.line_height, shaping: iced::widget::text::Shaping::Basic,
)), wrapping: iced::widget::text::Wrapping::None,
font, },
horizontal_alignment: iced::alignment::Horizontal::Right, Point::new(bounds.x, y),
vertical_alignment: iced::alignment::Vertical::Top, num_color,
shaping: iced::widget::text::Shaping::Basic, bounds,
wrapping: iced::widget::text::Wrapping::None, );
}, }
Point::new(bounds.x, y),
num_color,
bounds,
);
// Line content with syntax highlighting // Line content with syntax highlighting
if line_idx < highlighted.len() { if line_idx < highlighted.len() {
let spans = &highlighted[line_idx]; let spans = &highlighted[line_idx];
let mut x_off = text_x; // Flatten spans into characters with colors for correct wrapping
let mut chars_with_color: Vec<(char, Color)> = Vec::new();
for (style, span_text) in spans { for (style, span_text) in spans {
let mut display_text = span_text.clone();
if display_text.ends_with('\n') {
display_text.pop();
}
if display_text.is_empty() {
continue;
}
let color = syntect_to_iced(style.foreground); let color = syntect_to_iced(style.foreground);
for ch in span_text.chars() {
if ch == '\n' { continue; }
chars_with_color.push((ch, color));
}
}
// Extract the slice for this visual line
let end_char = if chars_per_visual_line > 0 {
(char_offset + chars_per_visual_line).min(chars_with_color.len())
} else {
chars_with_color.len()
};
let slice = if char_offset < chars_with_color.len() {
&chars_with_color[char_offset..end_char]
} else {
&[]
};
// Group consecutive chars with same color into spans
let mut x_off = text_x;
let mut span_start = 0;
while span_start < slice.len() {
let color = slice[span_start].1;
let mut span_end = span_start + 1;
while span_end < slice.len() && slice[span_end].1 == color {
span_end += 1;
}
let display_text: String = slice[span_start..span_end].iter().map(|(c, _)| *c).collect();
let span_width = display_text.len() as f32 * metrics.char_width; let span_width = display_text.len() as f32 * metrics.char_width;
renderer.fill_text( renderer.fill_text(
text::Text { text::Text {
content: display_text, content: display_text,
bounds: Size::new(span_width + metrics.char_width, metrics.line_height), bounds: Size::new(span_width + metrics.char_width, metrics.line_height),
size: Pixels(FONT_SIZE), size: Pixels(FONT_SIZE),
line_height: iced::widget::text::LineHeight::Absolute(Pixels( line_height: iced::widget::text::LineHeight::Absolute(Pixels(metrics.line_height)),
metrics.line_height,
)),
font, font,
horizontal_alignment: iced::alignment::Horizontal::Left, horizontal_alignment: iced::alignment::Horizontal::Left,
vertical_alignment: iced::alignment::Vertical::Top, vertical_alignment: iced::alignment::Vertical::Top,
@@ -319,52 +404,61 @@ where
bounds, bounds,
); );
x_off += span_width; x_off += span_width;
span_start = span_end;
} }
} else if let Some(line) = buf.line(line_idx) { } else if let Some(line) = buf.line(line_idx) {
// Fallback: plain text rendering // Fallback: plain text rendering
let mut line_text: String = line.chars().collect(); let line_text: String = line.chars().filter(|c| *c != '\n').collect();
if line_text.ends_with('\n') { let end_char = if chars_per_visual_line > 0 {
line_text.pop(); (char_offset + chars_per_visual_line).min(line_text.len())
} else {
line_text.len()
};
let display_text = if char_offset < line_text.len() {
&line_text[char_offset..end_char]
} else {
""
};
if !display_text.is_empty() {
renderer.fill_text(
text::Text {
content: display_text.to_string(),
bounds: Size::new(text_area_width, metrics.line_height),
size: Pixels(FONT_SIZE),
line_height: iced::widget::text::LineHeight::Absolute(Pixels(metrics.line_height)),
font,
horizontal_alignment: iced::alignment::Horizontal::Left,
vertical_alignment: iced::alignment::Vertical::Top,
shaping: iced::widget::text::Shaping::Advanced,
wrapping: iced::widget::text::Wrapping::None,
},
Point::new(text_x, y),
TEXT_COLOR,
bounds,
);
} }
renderer.fill_text(
text::Text {
content: line_text,
bounds: Size::new(
bounds.width - GUTTER_WIDTH - 8.0,
metrics.line_height,
),
size: Pixels(FONT_SIZE),
line_height: iced::widget::text::LineHeight::Absolute(Pixels(
metrics.line_height,
)),
font,
horizontal_alignment: iced::alignment::Horizontal::Left,
vertical_alignment: iced::alignment::Vertical::Top,
shaping: iced::widget::text::Shaping::Advanced,
wrapping: iced::widget::text::Wrapping::None,
},
Point::new(text_x, y),
TEXT_COLOR,
bounds,
);
} }
// Draw cursor on current line // Draw cursor on this visual line if it contains the cursor
if line_idx == cursor_line && state.is_focused { if line_idx == cursor_line && state.is_focused {
let cursor_x = text_x + cursor_col as f32 * metrics.char_width; let vis_end = if chars_per_visual_line > 0 {
renderer.fill_quad( char_offset + chars_per_visual_line
renderer::Quad { } else {
bounds: Rectangle { usize::MAX
x: cursor_x, };
y, if cursor_col >= char_offset && cursor_col < vis_end {
width: 2.0, let cursor_x = text_x + (cursor_col - char_offset) as f32 * metrics.char_width;
height: metrics.line_height, renderer.fill_quad(
renderer::Quad {
bounds: Rectangle {
x: cursor_x, y, width: 2.0, height: metrics.line_height,
},
border: iced::Border::default(),
shadow: iced::Shadow::default(),
}, },
border: iced::Border::default(), CURSOR_COLOR,
shadow: iced::Shadow::default(), );
}, }
CURSOR_COLOR,
);
} }
} }
} }
@@ -383,6 +477,12 @@ where
let state = tree.state.downcast_mut::<EditorState>(); let state = tree.state.downcast_mut::<EditorState>();
let bounds = layout.bounds(); let bounds = layout.bounds();
let metrics = mono_metrics(); let metrics = mono_metrics();
let text_area_width = bounds.width - GUTTER_WIDTH - 8.0;
let cpl = if self.word_wrap && text_area_width > metrics.char_width {
(text_area_width / metrics.char_width).floor() as usize
} else {
0
};
match event { match event {
Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => { Event::Mouse(mouse::Event::ButtonPressed(mouse::Button::Left)) => {
@@ -392,10 +492,11 @@ where
if let Some(pos) = cursor.position_in(bounds) { if let Some(pos) = cursor.position_in(bounds) {
let mut buf = self.buffer.borrow_mut(); let mut buf = self.buffer.borrow_mut();
let line = (pos.y / metrics.line_height) as usize let vis_row = (pos.y / metrics.line_height) as usize;
+ buf.scroll_offset(); let raw_col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize;
let col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) let (line, char_off) = visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
as usize; .unwrap_or((buf.line_count().saturating_sub(1), 0));
let col = char_off + raw_col;
// Double-click detection // Double-click detection
let now = std::time::Instant::now(); let now = std::time::Instant::now();
@@ -429,10 +530,11 @@ where
if state.is_dragging && state.is_focused { if state.is_dragging && state.is_focused {
if let Some(pos) = cursor.position_in(bounds) { if let Some(pos) = cursor.position_in(bounds) {
let mut buf = self.buffer.borrow_mut(); let mut buf = self.buffer.borrow_mut();
let line = (pos.y / metrics.line_height) as usize let vis_row = (pos.y / metrics.line_height) as usize;
+ buf.scroll_offset(); let raw_col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) as usize;
let col = ((pos.x - GUTTER_WIDTH - 8.0).max(0.0) / metrics.char_width) let (line, char_off) = visual_to_logical(&buf, buf.scroll_offset(), vis_row, cpl)
as usize; .unwrap_or((buf.line_count().saturating_sub(1), 0));
let col = char_off + raw_col;
// Extend selection by simulating shift+movement // Extend selection by simulating shift+movement
let clamped_line = line.min(buf.line_count().saturating_sub(1)); let clamped_line = line.min(buf.line_count().saturating_sub(1));
let clamped_col = if clamped_line < buf.line_count() { let clamped_col = if clamped_line < buf.line_count() {

View File

@@ -1002,6 +1002,7 @@ impl BdsApp {
MediaEditorMsg::AltChanged(s) => { state.alt = s; state.is_dirty = true; } MediaEditorMsg::AltChanged(s) => { state.alt = s; state.is_dirty = true; }
MediaEditorMsg::CaptionChanged(s) => { state.caption = s; state.is_dirty = true; } MediaEditorMsg::CaptionChanged(s) => { state.caption = s; state.is_dirty = true; }
MediaEditorMsg::AuthorChanged(s) => { state.author = s; state.is_dirty = true; } MediaEditorMsg::AuthorChanged(s) => { state.author = s; state.is_dirty = true; }
MediaEditorMsg::SwitchLanguage(lang) => { state.switch_language(&lang); }
MediaEditorMsg::Save => { MediaEditorMsg::Save => {
return self.save_media_editor(&tab_id); return self.save_media_editor(&tab_id);
} }
@@ -1134,11 +1135,31 @@ impl BdsApp {
Message::PostLoaded(result) => { Message::PostLoaded(result) => {
match result { match result {
Ok(post) => { Ok(post) => {
let translations = self.db.as_ref() let mut translations = self.db.as_ref()
.and_then(|db| bds_core::db::queries::post_translation::list_post_translations_by_post( .and_then(|db| bds_core::db::queries::post_translation::list_post_translations_by_post(
db.conn(), &post.id, db.conn(), &post.id,
).ok()) ).ok())
.unwrap_or_default(); .unwrap_or_default();
// Published translations don't store body in DB — read from file
if let Some(ref data_dir) = self.data_dir {
for tr in &mut translations {
if tr.content.is_none() {
let rel = bds_core::util::paths::translation_file_path(
post.created_at,
&post.slug,
&tr.language,
);
let path = data_dir.join(&rel);
if let Ok(raw) = std::fs::read_to_string(&path) {
if let Ok((_fm, body)) =
bds_core::util::frontmatter::read_translation_file(&raw)
{
tr.content = Some(body);
}
}
}
}
}
let (outlinks, backlinks) = self.load_post_links(&post.id); let (outlinks, backlinks) = self.load_post_links(&post.id);
let state = PostEditorState::from_post(&post, &self.blog_languages, &translations, outlinks, backlinks); let state = PostEditorState::from_post(&post, &self.blog_languages, &translations, outlinks, backlinks);
self.post_editors.insert(post.id.clone(), state); self.post_editors.insert(post.id.clone(), state);
@@ -1150,7 +1171,12 @@ impl BdsApp {
Message::MediaLoaded(result) => { Message::MediaLoaded(result) => {
match result { match result {
Ok(media) => { Ok(media) => {
let state = MediaEditorState::from_media(&media); let translations = self.db.as_ref()
.and_then(|db| bds_core::db::queries::media_translation::list_media_translations_by_media(
db.conn(), &media.id,
).ok())
.unwrap_or_default();
let state = MediaEditorState::from_media(&media, &self.blog_languages, &translations);
self.media_editors.insert(media.id.clone(), state); self.media_editors.insert(media.id.clone(), state);
} }
Err(e) => self.notify(ToastLevel::Error, &e), Err(e) => self.notify(ToastLevel::Error, &e),
@@ -1873,13 +1899,22 @@ impl BdsApp {
fn handle_tags_msg(&mut self, msg: TagsMsg) -> Task<Message> { fn handle_tags_msg(&mut self, msg: TagsMsg) -> Task<Message> {
// Ensure tags view state exists // Ensure tags view state exists
if self.tags_view_state.is_none() { if self.tags_view_state.is_none() {
let tags = if let (Some(db), Some(project)) = (&self.db, &self.active_project) { let (tags, counts) = if let (Some(db), Some(project)) = (&self.db, &self.active_project) {
bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id) let tags = bds_core::db::queries::tag::list_tags_by_project(db.conn(), &project.id)
.unwrap_or_default() .unwrap_or_default();
let posts = bds_core::db::queries::post::list_posts_by_project(db.conn(), &project.id)
.unwrap_or_default();
let mut counts = std::collections::HashMap::new();
for post in &posts {
for tag_name in &post.tags {
*counts.entry(tag_name.to_lowercase()).or_insert(0usize) += 1;
}
}
(tags, counts)
} else { } else {
Vec::new() (Vec::new(), std::collections::HashMap::new())
}; };
self.tags_view_state = Some(TagsViewState::new(tags)); self.tags_view_state = Some(TagsViewState::new(tags, counts));
} }
let state = self.tags_view_state.as_mut().unwrap(); let state = self.tags_view_state.as_mut().unwrap();
match msg { match msg {
@@ -2042,6 +2077,18 @@ impl BdsApp {
let _ = open::that(dir); let _ = open::that(dir);
} }
} }
SettingsMsg::FocusSection(section) => {
// Expand the target section, collapse all others
use crate::views::settings_view::SettingsSection;
let all_others: Vec<SettingsSection> = SettingsSection::all()
.iter()
.filter(|s| **s != section)
.cloned()
.collect();
state.collapsed = all_others;
// Clear search filter to ensure section is visible
state.search_query.clear();
}
} }
Task::none() Task::none()
} }
@@ -2104,9 +2151,29 @@ impl BdsApp {
} }
} }
// Load translations for translation flags bar // Load translations for translation flags bar
let translations = bds_core::db::queries::post_translation::list_post_translations_by_post( let mut translations = bds_core::db::queries::post_translation::list_post_translations_by_post(
db.conn(), &post.id, db.conn(), &post.id,
).unwrap_or_default(); ).unwrap_or_default();
// Published translations don't store body in DB — read from file
if let Some(ref data_dir) = self.data_dir {
for tr in &mut translations {
if tr.content.is_none() {
let rel = bds_core::util::paths::translation_file_path(
post.created_at,
&post.slug,
&tr.language,
);
let path = data_dir.join(&rel);
if let Ok(raw) = std::fs::read_to_string(&path) {
if let Ok((_fm, body)) =
bds_core::util::frontmatter::read_translation_file(&raw)
{
tr.content = Some(body);
}
}
}
}
}
let (outlinks, backlinks) = self.load_post_links(&post.id); let (outlinks, backlinks) = self.load_post_links(&post.id);
self.post_editors.insert( self.post_editors.insert(
post.id.clone(), post.id.clone(),
@@ -2123,7 +2190,10 @@ impl BdsApp {
if !self.media_editors.contains_key(&tab.id) { if !self.media_editors.contains_key(&tab.id) {
match bds_core::db::queries::media::get_media_by_id(db.conn(), &tab.id) { match bds_core::db::queries::media::get_media_by_id(db.conn(), &tab.id) {
Ok(media) => { Ok(media) => {
self.media_editors.insert(media.id.clone(), MediaEditorState::from_media(&media)); let translations = bds_core::db::queries::media_translation::list_media_translations_by_media(
db.conn(), &media.id,
).unwrap_or_default();
self.media_editors.insert(media.id.clone(), MediaEditorState::from_media(&media, &self.blog_languages, &translations));
} }
Err(e) => { Err(e) => {
self.notify(ToastLevel::Error, &format!("Failed to load media: {e}")); self.notify(ToastLevel::Error, &format!("Failed to load media: {e}"));
@@ -2185,17 +2255,30 @@ impl BdsApp {
TabType::Tags => { TabType::Tags => {
if self.tags_view_state.is_none() { if self.tags_view_state.is_none() {
let project_id = self.active_project.as_ref().map(|p| p.id.as_str()).unwrap_or(""); let project_id = self.active_project.as_ref().map(|p| p.id.as_str()).unwrap_or("");
// Sync tags from posts first to ensure tags table is populated // Import tags from file first, then sync from posts (additive only)
if let Some(ref data_dir) = self.data_dir { if let Some(ref data_dir) = self.data_dir {
let _ = bds_core::engine::tag::sync_tags_from_posts( let _ = bds_core::engine::tag::import_tags_from_file(
db.conn(), data_dir, project_id, db.conn(), data_dir, project_id,
); );
let _ = bds_core::engine::tag::sync_tags_from_posts(
db.conn(), project_id,
);
} }
let tags = bds_core::db::queries::tag::list_tags_by_project( let tags = bds_core::db::queries::tag::list_tags_by_project(
db.conn(), db.conn(),
project_id, project_id,
).unwrap_or_default(); ).unwrap_or_default();
self.tags_view_state = Some(TagsViewState::new(tags)); // Compute post counts per tag for cloud sizing
let posts = bds_core::db::queries::post::list_posts_by_project(
db.conn(), project_id,
).unwrap_or_default();
let mut tag_post_counts = std::collections::HashMap::new();
for post in &posts {
for tag_name in &post.tags {
*tag_post_counts.entry(tag_name.to_lowercase()).or_insert(0usize) += 1;
}
}
self.tags_view_state = Some(TagsViewState::new(tags, tag_post_counts));
} }
} }
TabType::Settings => { TabType::Settings => {

View File

@@ -1,14 +1,26 @@
use std::collections::HashMap;
use std::path::Path; use std::path::Path;
use iced::widget::{button, column, container, image, row, scrollable, text, Space}; use iced::widget::{button, column, container, image, row, scrollable, text, Space};
use iced::widget::text::Shaping;
use iced::{Color, Element, Length}; use iced::{Color, Element, Length};
use bds_core::i18n::UiLocale; use bds_core::i18n::{self, UiLocale};
use bds_core::model::Media; use bds_core::model::{Media, MediaTranslation};
use crate::app::Message; use crate::app::Message;
use crate::components::inputs; use crate::components::inputs;
use crate::i18n::t; use crate::i18n::t;
use crate::views::post_editor::TranslationFlag;
/// Saved draft content for a single media translation language.
#[derive(Debug, Clone)]
pub struct MediaTranslationDraft {
pub title: String,
pub alt: String,
pub caption: String,
pub is_dirty: bool,
}
/// State for an open media editor. /// State for an open media editor.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -30,10 +42,28 @@ pub struct MediaEditorState {
pub created_at: i64, pub created_at: i64,
pub updated_at: i64, pub updated_at: i64,
pub is_dirty: bool, pub is_dirty: bool,
// ── Translation flags ──
pub active_language: String,
pub canonical_language: String,
pub blog_languages: Vec<String>,
pub saved_canonical: Option<MediaTranslationDraft>,
pub translation_drafts: HashMap<String, MediaTranslationDraft>,
} }
impl MediaEditorState { impl MediaEditorState {
pub fn from_media(media: &Media) -> Self { pub fn from_media(media: &Media, blog_languages: &[String], translations: &[MediaTranslation]) -> Self {
let canonical_lang = media.language.clone().unwrap_or_else(|| "en".to_string());
let mut translation_drafts = HashMap::new();
for tr in translations {
translation_drafts.insert(tr.language.clone(), MediaTranslationDraft {
title: tr.title.clone().unwrap_or_default(),
alt: tr.alt.clone().unwrap_or_default(),
caption: tr.caption.clone().unwrap_or_default(),
is_dirty: false,
});
}
Self { Self {
media_id: media.id.clone(), media_id: media.id.clone(),
filename: media.filename.clone(), filename: media.filename.clone(),
@@ -46,14 +76,90 @@ impl MediaEditorState {
alt: media.alt.clone().unwrap_or_default(), alt: media.alt.clone().unwrap_or_default(),
caption: media.caption.clone().unwrap_or_default(), caption: media.caption.clone().unwrap_or_default(),
author: media.author.clone().unwrap_or_default(), author: media.author.clone().unwrap_or_default(),
language: media.language.clone().unwrap_or_default(), language: canonical_lang.clone(),
file_path: media.file_path.clone(), file_path: media.file_path.clone(),
tags: media.tags.clone(), tags: media.tags.clone(),
created_at: media.created_at, created_at: media.created_at,
updated_at: media.updated_at, updated_at: media.updated_at,
is_dirty: false, is_dirty: false,
active_language: canonical_lang.clone(),
canonical_language: canonical_lang,
blog_languages: blog_languages.to_vec(),
saved_canonical: None,
translation_drafts,
} }
} }
/// Switch to a different language. Saves current fields, loads target.
pub fn switch_language(&mut self, target_lang: &str) {
if target_lang == self.active_language {
return;
}
// Save current fields
if self.active_language == self.canonical_language {
self.saved_canonical = Some(MediaTranslationDraft {
title: self.title.clone(),
alt: self.alt.clone(),
caption: self.caption.clone(),
is_dirty: self.is_dirty,
});
} else {
self.translation_drafts.insert(self.active_language.clone(), MediaTranslationDraft {
title: self.title.clone(),
alt: self.alt.clone(),
caption: self.caption.clone(),
is_dirty: self.is_dirty,
});
}
// Load target fields
if target_lang == self.canonical_language {
if let Some(saved) = &self.saved_canonical {
self.title = saved.title.clone();
self.alt = saved.alt.clone();
self.caption = saved.caption.clone();
self.is_dirty = saved.is_dirty;
}
} else if let Some(draft) = self.translation_drafts.get(target_lang) {
self.title = draft.title.clone();
self.alt = draft.alt.clone();
self.caption = draft.caption.clone();
self.is_dirty = draft.is_dirty;
} else {
self.title = String::new();
self.alt = String::new();
self.caption = String::new();
self.is_dirty = false;
}
self.active_language = target_lang.to_string();
}
/// Build translation flags for the view.
pub fn translation_flags(&self) -> Vec<TranslationFlag> {
if self.translation_drafts.is_empty() {
return Vec::new();
}
let mut flags = Vec::new();
let canon = &self.canonical_language;
let canon_locale = i18n::normalize_language(canon);
flags.push(TranslationFlag {
language: canon.clone(),
flag_emoji: canon_locale.flag_emoji().to_string(),
status: "canonical".to_string(),
is_active: self.active_language == *canon,
});
let mut langs: Vec<&String> = self.translation_drafts.keys().collect();
langs.sort();
for lang in langs {
let locale = i18n::normalize_language(lang);
flags.push(TranslationFlag {
language: lang.clone(),
flag_emoji: locale.flag_emoji().to_string(),
status: "translation".to_string(),
is_active: self.active_language == **lang,
});
}
flags
}
} }
/// Media editor messages. /// Media editor messages.
@@ -63,6 +169,7 @@ pub enum MediaEditorMsg {
AltChanged(String), AltChanged(String),
CaptionChanged(String), CaptionChanged(String),
AuthorChanged(String), AuthorChanged(String),
SwitchLanguage(String),
Save, Save,
Delete, Delete,
} }
@@ -91,6 +198,30 @@ pub fn view<'a>(
], ],
); );
// Translation flags bar
let flags = state.translation_flags();
let flags_bar: Element<'a, Message> = if flags.is_empty() {
Space::new(0, 0).into()
} else {
let flag_buttons: Vec<Element<'a, Message>> = flags
.iter()
.map(|flag| {
let label = format!("{} {}", flag.flag_emoji, flag.language);
let color = if flag.is_active {
Color::WHITE
} else {
Color::from_rgb(0.55, 0.58, 0.65)
};
button(text(label).size(12).shaping(Shaping::Advanced).color(color))
.on_press(Message::MediaEditor(MediaEditorMsg::SwitchLanguage(flag.language.clone())))
.padding([4, 8])
.style(|_: &iced::Theme, _| button::Style::default())
.into()
})
.collect();
row(flag_buttons).spacing(4).into()
};
// Preview section // Preview section
let preview: Element<'a, Message> = if state.mime_type.starts_with("image/") { let preview: Element<'a, Message> = if state.mime_type.starts_with("image/") {
if let Some(dir) = data_dir { if let Some(dir) = data_dir {
@@ -166,6 +297,7 @@ pub fn view<'a>(
let body = scrollable( let body = scrollable(
column![ column![
header, header,
flags_bar,
preview, preview,
info, info,
inputs::section_header(&t(locale, "editor.metadata")), inputs::section_header(&t(locale, "editor.metadata")),

View File

@@ -301,6 +301,7 @@ pub enum PostEditorMsg {
pub fn view<'a>( pub fn view<'a>(
state: &'a PostEditorState, state: &'a PostEditorState,
locale: UiLocale, locale: UiLocale,
word_wrap: bool,
) -> Element<'a, Message> { ) -> Element<'a, Message> {
// ── Header bar ── // ── Header bar ──
let dirty_indicator = if state.is_dirty { " \u{25CF}" } else { "" }; let dirty_indicator = if state.is_dirty { " \u{25CF}" } else { "" };
@@ -521,6 +522,7 @@ pub fn view<'a>(
highlighter(), highlighter(),
"md", "md",
) )
.word_wrap(word_wrap)
.on_change(|msg| match msg { .on_change(|msg| match msg {
EditorMessage::ContentChanged(s) => Message::PostEditor(PostEditorMsg::ContentChanged(s)), EditorMessage::ContentChanged(s) => Message::PostEditor(PostEditorMsg::ContentChanged(s)),
EditorMessage::SaveRequested => Message::PostEditor(PostEditorMsg::Save), EditorMessage::SaveRequested => Message::PostEditor(PostEditorMsg::Save),

View File

@@ -174,6 +174,8 @@ pub enum SettingsMsg {
RebuildLinks, RebuildLinks,
RegenerateThumbnails, RegenerateThumbnails,
OpenDataFolder, OpenDataFolder,
/// Navigate to a specific section from sidebar; expand it, collapse all others.
FocusSection(SettingsSection),
} }
/// Render the settings view. /// Render the settings view.

View File

@@ -905,35 +905,41 @@ pub fn view(
} }
SidebarView::Settings => { SidebarView::Settings => {
// Per sidebar_views.allium SettingsNav: 9 fixed-order sections // Per sidebar_views.allium SettingsNav: 9 fixed-order sections
let sections = [ use crate::views::settings_view::{SettingsSection, SettingsMsg};
"settings.nav.project", let sections: &[(&str, Option<SettingsSection>)] = &[
"settings.nav.editor", ("settings.nav.project", Some(SettingsSection::Project)),
"settings.nav.content", ("settings.nav.editor", Some(SettingsSection::Editor)),
"settings.nav.ai", ("settings.nav.content", Some(SettingsSection::Content)),
"settings.nav.technology", ("settings.nav.ai", Some(SettingsSection::AI)),
"settings.nav.publishing", ("settings.nav.technology", Some(SettingsSection::Technology)),
"settings.nav.data", ("settings.nav.publishing", Some(SettingsSection::Publishing)),
"settings.nav.mcp", ("settings.nav.data", Some(SettingsSection::Data)),
"settings.nav.style", ("settings.nav.mcp", Some(SettingsSection::MCP)),
("settings.nav.style", None),
]; ];
let items: Vec<Element<'static, Message>> = sections let items: Vec<Element<'static, Message>> = sections
.iter() .iter()
.map(|key| { .map(|(key, section_opt)| {
let label = t(locale, key); let label = t(locale, key);
let label_text = text(label) let label_text = text(label)
.size(12) .size(12)
.shaping(Shaping::Advanced); .shaping(Shaping::Advanced);
button( let msg = if let Some(section) = section_opt {
container(label_text) Message::Settings(SettingsMsg::FocusSection(section.clone()))
.width(Length::Fill) } else {
) Message::OpenTab(Tab {
.on_press(Message::OpenTab(Tab {
id: "settings".to_string(), id: "settings".to_string(),
tab_type: TabType::Settings, tab_type: TabType::Settings,
title: t(locale, "common.settings"), title: t(locale, "common.settings"),
is_transient: false, is_transient: false,
is_dirty: false, is_dirty: false,
})) })
};
button(
container(label_text)
.width(Length::Fill)
)
.on_press(msg)
.padding([3, 6]) .padding([3, 6])
.width(Length::Fill) .width(Length::Fill)
.style(item_style) .style(item_style)

View File

@@ -1,3 +1,5 @@
use std::collections::HashMap;
use iced::widget::{button, column, container, row, scrollable, text, text_input, Space}; use iced::widget::{button, column, container, row, scrollable, text, text_input, Space};
use iced::{Alignment, Background, Color, Element, Length, Theme}; use iced::{Alignment, Background, Color, Element, Length, Theme};
@@ -23,6 +25,7 @@ pub enum TagsSection {
pub struct TagsViewState { pub struct TagsViewState {
pub section: TagsSection, pub section: TagsSection,
pub tags: Vec<Tag>, pub tags: Vec<Tag>,
pub tag_post_counts: HashMap<String, usize>,
pub search_query: String, pub search_query: String,
/// For "Manage": the currently editing tag /// For "Manage": the currently editing tag
pub editing_tag: Option<EditingTag>, pub editing_tag: Option<EditingTag>,
@@ -40,10 +43,11 @@ pub struct EditingTag {
} }
impl TagsViewState { impl TagsViewState {
pub fn new(tags: Vec<Tag>) -> Self { pub fn new(tags: Vec<Tag>, tag_post_counts: HashMap<String, usize>) -> Self {
Self { Self {
section: TagsSection::Cloud, section: TagsSection::Cloud,
tags, tags,
tag_post_counts,
search_query: String::new(), search_query: String::new(),
editing_tag: None, editing_tag: None,
merge_source: None, merge_source: None,
@@ -133,14 +137,27 @@ fn view_cloud<'a>(state: &'a TagsViewState, locale: UiLocale) -> Element<'a, Mes
.into(); .into();
} }
// Calculate min/max post counts for font scaling
let counts: Vec<usize> = state.tags.iter()
.map(|t| *state.tag_post_counts.get(&t.name.to_lowercase()).unwrap_or(&0))
.collect();
let min_count = *counts.iter().min().unwrap_or(&0);
let max_count = *counts.iter().max().unwrap_or(&1);
let count_range = if max_count > min_count { (max_count - min_count) as f32 } else { 1.0 };
const MIN_FONT: f32 = 11.0;
const MAX_FONT: f32 = 24.0;
let chips: Vec<Element<'a, Message>> = state let chips: Vec<Element<'a, Message>> = state
.tags .tags
.iter() .iter()
.map(|tag| { .map(|tag| {
let color = parse_tag_color(tag.color.as_deref().unwrap_or(DEFAULT_TAG_COLOR)); let color = parse_tag_color(tag.color.as_deref().unwrap_or(DEFAULT_TAG_COLOR));
button(text(&tag.name).size(13).color(Color::WHITE)) let post_count = *state.tag_post_counts.get(&tag.name.to_lowercase()).unwrap_or(&0);
let font_size = MIN_FONT + ((post_count - min_count) as f32 / count_range) * (MAX_FONT - MIN_FONT);
let vert_pad = ((MAX_FONT - font_size) / 4.0).max(2.0) as u16;
button(text(&tag.name).size(font_size).color(Color::WHITE))
.on_press(Message::Tags(TagsMsg::SelectTag(tag.id.clone()))) .on_press(Message::Tags(TagsMsg::SelectTag(tag.id.clone())))
.padding([4, 10]) .padding([vert_pad, 10])
.style(move |_: &Theme, _| button::Style { .style(move |_: &Theme, _| button::Style {
background: Some(Background::Color(color)), background: Some(Background::Color(color)),
border: iced::Border { border: iced::Border {

View File

@@ -310,7 +310,8 @@ fn route_content_area<'a>(
match tab.tab_type { match tab.tab_type {
TabType::Post => { TabType::Post => {
if let Some(state) = post_editors.get(tab_id) { if let Some(state) = post_editors.get(tab_id) {
post_editor::view(state, locale) let wrap = settings_state.map(|s| s.wrap_long_lines).unwrap_or(false);
post_editor::view(state, locale, wrap)
} else { } else {
loading_view(locale) loading_view(locale)
} }