fix: even more closing of M0/M1/M2 gaps against the spec
This commit is contained in:
17
assets/starter-templates/not-found.liquid
Normal file
17
assets/starter-templates/not-found.liquid
Normal file
@@ -0,0 +1,17 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href %}
|
||||
<body>
|
||||
<main>
|
||||
<section class="not-found" data-template="not-found">
|
||||
<article>
|
||||
<h1>404</h1>
|
||||
{% assign default_not_found_message = 'render.notFound.message' | i18n: language %}
|
||||
{% assign default_not_found_back = 'render.notFound.back' | i18n: language %}
|
||||
<p>{{ not_found_message | default: default_not_found_message }}</p>
|
||||
<p><a href="/" role="button">{{ not_found_back_label | default: default_not_found_back }}</a></p>
|
||||
</article>
|
||||
</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
27
assets/starter-templates/partials/head.liquid
Normal file
27
assets/starter-templates/partials/head.liquid
Normal file
@@ -0,0 +1,27 @@
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<title>{{ page_title }}</title>
|
||||
{% assign resolved_pico_stylesheet_href = pico_stylesheet_href | default: '/assets/pico.min.css' %}
|
||||
<link rel="stylesheet" href="{{ resolved_pico_stylesheet_href }}" />
|
||||
<link rel="stylesheet" href="/assets/lightbox.min.css" />
|
||||
<link rel="stylesheet" href="/assets/highlight.min.css" />
|
||||
<link rel="stylesheet" href="/assets/vanilla-calendar.min.css" />
|
||||
<link rel="stylesheet" href="/assets/bds.css" />
|
||||
{% assign feed_prefix = language_prefix | default: '' %}
|
||||
<link rel="alternate" type="application/rss+xml" title="RSS" href="{{ feed_prefix }}/rss.xml" />
|
||||
<link rel="alternate" type="application/atom+xml" title="Atom" href="{{ feed_prefix }}/atom.xml" />
|
||||
{% for alternate_link in alternate_links %}
|
||||
<link rel="alternate" hreflang="{{ alternate_link.hreflang | escape }}" href="{{ alternate_link.href | escape }}" />
|
||||
{% endfor %}
|
||||
<script defer src="/assets/highlight.min.js"></script>
|
||||
<script defer src="/assets/code-enhancements.js"></script>
|
||||
<script defer src="/assets/d3.layout.cloud.js"></script>
|
||||
<script defer src="/assets/tag-cloud.js"></script>
|
||||
<script defer src="/assets/lightbox.min.js"></script>
|
||||
<script defer src="/assets/vanilla-calendar.min.js"></script>
|
||||
<script defer src="/assets/calendar-runtime.js"></script>
|
||||
<script defer src="/assets/search-runtime.js"></script>
|
||||
<link rel="stylesheet" href="{{ language_prefix }}/pagefind/pagefind-ui.css" />
|
||||
<script defer src="{{ language_prefix }}/pagefind/pagefind-ui.js"></script>
|
||||
</head>
|
||||
41
assets/starter-templates/partials/language-switcher.liquid
Normal file
41
assets/starter-templates/partials/language-switcher.liquid
Normal file
@@ -0,0 +1,41 @@
|
||||
{% if blog_languages.size > 1 %}
|
||||
<nav class="language-switcher" aria-label="{{ 'render.languageSwitcher.ariaLabel' | i18n: language }}">
|
||||
{% for lang in blog_languages %}
|
||||
{% if lang.is_current %}
|
||||
<span class="language-switcher-badge language-switcher-badge-current" aria-current="true" title="{{ lang.code }}">{{ lang.flag }}</span>
|
||||
{% else %}
|
||||
<a class="language-switcher-badge" href="{{ lang.href_prefix | default: '/' }}" data-lang-prefix="{{ lang.href_prefix }}" title="{{ lang.code }}">{{ lang.flag }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<div class="blog-search-widget" aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
|
||||
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="blog-search-panel" data-blog-search-panel hidden>
|
||||
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<script>
|
||||
(function(){
|
||||
var links=document.querySelectorAll('.language-switcher-badge[data-lang-prefix]');
|
||||
var path=location.pathname.replace(/^\/[a-z]{2}(?=\/|$)/,'') || '/';
|
||||
links.forEach(function(a){a.href=(a.dataset.langPrefix||'')+path;});
|
||||
}());
|
||||
</script>
|
||||
{% else %}
|
||||
<div class="blog-search-standalone" aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
|
||||
<button type="button" class="blog-search-toggle" data-blog-search-toggle aria-label="{{ 'render.search.ariaLabel' | i18n: language }}">
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
||||
<circle cx="11" cy="11" r="8"></circle>
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65"></line>
|
||||
</svg>
|
||||
</button>
|
||||
<div class="blog-search-panel" data-blog-search-panel hidden>
|
||||
<div id="blog-search" data-blog-search-root data-search-placeholder="{{ 'render.search.placeholder' | i18n: language }}"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
63
assets/starter-templates/partials/menu-items.liquid
Normal file
63
assets/starter-templates/partials/menu-items.liquid
Normal file
@@ -0,0 +1,63 @@
|
||||
<ul class="blog-menu-list">
|
||||
{% for item in items %}
|
||||
<li class="blog-menu-item{% if item.has_children %} blog-menu-item-with-children{% endif %}">
|
||||
{% if item.href == '#' %}
|
||||
<span class="blog-menu-link">{{ item.title }}</span>
|
||||
{% else %}
|
||||
<a class="blog-menu-link" href="{{ item.href }}">{{ item.title }}</a>
|
||||
{% endif %}
|
||||
{% if item.has_children %}
|
||||
<div class="blog-menu-submenu">
|
||||
{% render 'partials/menu-items', items: item.children %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
{% if include_calendar %}
|
||||
<li class="blog-menu-item blog-menu-calendar">
|
||||
<button
|
||||
type="button"
|
||||
class="blog-menu-calendar-button"
|
||||
data-blog-calendar-toggle
|
||||
{% if calendar_initial_year %}data-blog-calendar-year="{{ calendar_initial_year }}"{% endif %}
|
||||
{% if calendar_initial_month %}data-blog-calendar-month="{{ calendar_initial_month }}"{% endif %}
|
||||
aria-label="{{ 'render.calendar.open' | i18n: language }}"
|
||||
title="{{ 'render.calendar.open' | i18n: language }}"
|
||||
>
|
||||
<svg aria-hidden="true" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" focusable="false">
|
||||
<rect x="3" y="5" width="18" height="16" rx="2" ry="2"></rect>
|
||||
<line x1="3" y1="9" x2="21" y2="9"></line>
|
||||
<line x1="8" y1="3" x2="8" y2="7"></line>
|
||||
<line x1="16" y1="3" x2="16" y2="7"></line>
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<section
|
||||
id="blog-calendar"
|
||||
class="blog-calendar-panel"
|
||||
data-blog-calendar-panel
|
||||
data-i18n-loading="{{ 'render.calendar.loading' | i18n: language }}"
|
||||
data-i18n-error="{{ 'render.calendar.error' | i18n: language }}"
|
||||
hidden
|
||||
>
|
||||
<header class="blog-calendar-header">
|
||||
<strong>{{ 'render.calendar.title' | i18n: language }}</strong>
|
||||
<button
|
||||
type="button"
|
||||
class="blog-calendar-close"
|
||||
data-blog-calendar-close
|
||||
aria-label="{{ 'render.calendar.close' | i18n: language }}"
|
||||
title="{{ 'render.calendar.close' | i18n: language }}"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<div class="blog-calendar-content">
|
||||
<div data-blog-calendar-root></div>
|
||||
<p class="blog-calendar-status" data-blog-calendar-status>{{ 'render.calendar.loading' | i18n: language }}</p>
|
||||
</div>
|
||||
</section>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
7
assets/starter-templates/partials/menu.liquid
Normal file
7
assets/starter-templates/partials/menu.liquid
Normal file
@@ -0,0 +1,7 @@
|
||||
<nav class="blog-menu">
|
||||
{% if menu_items and menu_items.size > 0 %}
|
||||
{% render 'partials/menu-items', items: menu_items, include_calendar: true, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %}
|
||||
{% else %}
|
||||
{% render 'partials/menu-items', items: menu_items, include_calendar: true, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %}
|
||||
{% endif %}
|
||||
</nav>
|
||||
91
assets/starter-templates/post-list.liquid
Normal file
91
assets/starter-templates/post-list.liquid
Normal file
@@ -0,0 +1,91 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, language_prefix: language_prefix %}
|
||||
<body>
|
||||
<main>
|
||||
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language %}
|
||||
{% if archive_context %}
|
||||
{% if show_archive_range_heading and min_date and max_date %}
|
||||
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
|
||||
<h1 class="archive-heading">{{ archive_context.name }} - {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}</h1>
|
||||
{% else %}
|
||||
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ min_date.day }}.{{ min_date.month }}.{{ min_date.year }} - {{ max_date.day }}.{{ max_date.month }}.{{ max_date.year }}</h1>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
{% if archive_context.kind == 'tag' or archive_context.kind == 'category' %}
|
||||
<h1 class="archive-heading">{{ archive_context.name }}</h1>
|
||||
{% elsif archive_context.kind == 'month' and archive_context.month and archive_context.year %}
|
||||
{% assign month_key = 'render.month.' | append: archive_context.month %}
|
||||
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ month_key | i18n: language }} {{ archive_context.year }}</h1>
|
||||
{% elsif archive_context.kind == 'year' and archive_context.year %}
|
||||
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ archive_context.year }}</h1>
|
||||
{% elsif archive_context.kind == 'day' and archive_context.day and archive_context.month and archive_context.year %}
|
||||
{% assign day_month_key = 'render.month.' | append: archive_context.month %}
|
||||
<h1 class="archive-heading">{{ 'render.archive' | i18n: language }} {{ archive_context.day }}. {{ day_month_key | i18n: language }} {{ archive_context.year }}</h1>
|
||||
{% else %}
|
||||
<h1 class="archive-heading">{{ page_title }}</h1>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
{% render 'partials/menu', menu_items: menu_items, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %}
|
||||
|
||||
<section class="post-list" data-template="post-list" data-list-page="{{ is_list_page }}" data-first-page="{{ is_first_page }}" data-last-page="{{ is_last_page }}">
|
||||
{% for day_block in day_blocks %}
|
||||
{% if day_block.show_date_marker %}
|
||||
<section class="archive-day-group">
|
||||
<aside class="archive-day-marker"><span>{{ day_block.date_label }}</span></aside>
|
||||
<div class="archive-day-posts">
|
||||
{% for post in day_block.posts %}
|
||||
<div class="post">
|
||||
{% if post.show_title %}
|
||||
{% assign canonical_post_href = canonical_post_path_by_slug[post.slug] %}
|
||||
{% if canonical_post_href == blank %}
|
||||
{% assign canonical_post_href = '/posts/' | append: post.slug %}
|
||||
{% endif %}
|
||||
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
|
||||
{% endif %}
|
||||
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</section>
|
||||
{% else %}
|
||||
{% for post in day_block.posts %}
|
||||
<div class="post">
|
||||
{% if post.show_title %}
|
||||
{% assign canonical_post_href = canonical_post_path_by_slug[post.slug] %}
|
||||
{% if canonical_post_href == blank %}
|
||||
{% assign canonical_post_href = '/posts/' | append: post.slug %}
|
||||
{% endif %}
|
||||
<h2 class="post-title"><a href="{{ canonical_post_href }}">{{ post.title }}</a></h2>
|
||||
{% endif %}
|
||||
{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
|
||||
{% if day_block.show_separator %}
|
||||
<div class="archive-day-separator" aria-hidden="true"></div>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</section>
|
||||
|
||||
{% if has_prev_page or has_next_page %}
|
||||
<nav class="preview-pagination" aria-label="{{ 'render.pagination.label' | i18n: language }}">
|
||||
{% if has_prev_page %}
|
||||
<a href="{{ prev_page_href }}" class="preview-pagination-link" aria-label="{{ 'render.pagination.newer' | i18n: language }}">{{ 'render.pagination.newer' | i18n: language }}</a>
|
||||
{% else %}
|
||||
<span class="spacer"></span>
|
||||
{% endif %}
|
||||
|
||||
{% if has_next_page %}
|
||||
<a href="{{ next_page_href }}" class="preview-pagination-link" aria-label="{{ 'render.pagination.older' | i18n: language }}">{{ 'render.pagination.older' | i18n: language }}</a>
|
||||
{% else %}
|
||||
<span class="spacer"></span>
|
||||
{% endif %}
|
||||
</nav>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
33
assets/starter-templates/single-post.liquid
Normal file
33
assets/starter-templates/single-post.liquid
Normal file
@@ -0,0 +1,33 @@
|
||||
<!doctype html>
|
||||
<html lang="{{ language }}" data-language-prefix="{{ language_prefix }}"{% if html_theme_attribute %} {{ html_theme_attribute }}{% endif %}>
|
||||
{% render 'partials/head', page_title: page_title, pico_stylesheet_href: pico_stylesheet_href, alternate_links: alternate_links, language_prefix: language_prefix %}
|
||||
<body>
|
||||
<main>
|
||||
{% render 'partials/language-switcher', blog_languages: blog_languages, language: language %}
|
||||
<h1>{{ post.title }}</h1>
|
||||
{% render 'partials/menu', menu_items: menu_items, language: language, calendar_initial_year: calendar_initial_year, calendar_initial_month: calendar_initial_month %}
|
||||
{% if post_categories.size > 0 or post_tags.size > 0 %}
|
||||
<div class="single-post-taxonomy" aria-label="{{ 'render.taxonomy.ariaLabel' | i18n: language }}">
|
||||
{% for category in post_categories %}
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-category" href="/category/{{ category | url_encode }}/">{{ category | escape }}</a>
|
||||
{% endfor %}
|
||||
{% for tag in post_tags %}
|
||||
{% assign tag_color = tag_color_by_name[tag] %}
|
||||
<a class="single-post-taxonomy-bubble single-post-taxonomy-bubble-tag" href="/tag/{{ tag | url_encode }}/"{% if tag_color %} style="--bubble-accent: {{ tag_color | escape }};"{% endif %}>{{ tag | escape }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
<article class="single-post" data-template="single-post" data-pagefind-body>
|
||||
<div class="post">{{ post.content | markdown: post.id, post_data_json_by_id, canonical_post_path_by_slug, canonical_media_path_by_source_path, language, language_prefix }}</div>
|
||||
</article>
|
||||
{% if backlinks.size > 0 %}
|
||||
<div class="single-post-backlinks" aria-label="{{ 'render.backlinks.ariaLabel' | i18n: language }}">
|
||||
<span class="single-post-backlinks-label">{{ 'render.backlinks.label' | i18n: language }}</span>
|
||||
{% for backlink in backlinks %}
|
||||
<a class="single-post-taxonomy-bubble single-post-backlink-bubble" href="{{ backlink.path }}">{{ backlink.display_slug }}</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
405
crates/bds-core/src/engine/menu.rs
Normal file
405
crates/bds-core/src/engine/menu.rs
Normal file
@@ -0,0 +1,405 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use crate::engine::EngineResult;
|
||||
use crate::util::atomic_write_str;
|
||||
|
||||
/// A navigation menu item per menu.allium.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub struct MenuItem {
|
||||
pub kind: MenuItemKind,
|
||||
pub label: String,
|
||||
pub slug: Option<String>,
|
||||
pub children: Vec<MenuItem>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum MenuItemKind {
|
||||
Home,
|
||||
Page,
|
||||
Submenu,
|
||||
CategoryArchive,
|
||||
}
|
||||
|
||||
impl MenuItemKind {
|
||||
fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
Self::Home => "home",
|
||||
Self::Page => "page",
|
||||
Self::Submenu => "submenu",
|
||||
Self::CategoryArchive => "category_archive",
|
||||
}
|
||||
}
|
||||
|
||||
fn from_str(s: &str) -> Self {
|
||||
match s {
|
||||
"home" => Self::Home,
|
||||
"submenu" => Self::Submenu,
|
||||
"category_archive" => Self::CategoryArchive,
|
||||
_ => Self::Page,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the navigation menu from meta/menu.opml.
|
||||
pub fn read_menu(data_dir: &Path) -> EngineResult<Vec<MenuItem>> {
|
||||
let path = data_dir.join("meta").join("menu.opml");
|
||||
if !path.exists() {
|
||||
return Ok(vec![MenuItem {
|
||||
kind: MenuItemKind::Home,
|
||||
label: "Home".to_string(),
|
||||
slug: None,
|
||||
children: Vec::new(),
|
||||
}]);
|
||||
}
|
||||
let content = fs::read_to_string(&path)?;
|
||||
Ok(parse_opml(&content))
|
||||
}
|
||||
|
||||
/// Write the navigation menu to meta/menu.opml.
|
||||
/// Per menu.allium UpdateMenu rule: Home entry is always extracted and prepended.
|
||||
pub fn write_menu(data_dir: &Path, items: &[MenuItem]) -> EngineResult<()> {
|
||||
let normalized = normalize_menu(items);
|
||||
let opml = serialize_opml(&normalized);
|
||||
let path = data_dir.join("meta").join("menu.opml");
|
||||
atomic_write_str(&path, &opml)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Return the default menu OPML for new projects.
|
||||
pub fn default_menu_opml() -> String {
|
||||
let items = vec![MenuItem {
|
||||
kind: MenuItemKind::Home,
|
||||
label: "Home".to_string(),
|
||||
slug: None,
|
||||
children: Vec::new(),
|
||||
}];
|
||||
serialize_opml(&items)
|
||||
}
|
||||
|
||||
/// Per menu.allium HomeAlwaysPresent: ensure Home is always first.
|
||||
fn normalize_menu(items: &[MenuItem]) -> Vec<MenuItem> {
|
||||
let without_home: Vec<MenuItem> = items
|
||||
.iter()
|
||||
.filter(|i| i.kind != MenuItemKind::Home)
|
||||
.cloned()
|
||||
.collect();
|
||||
let mut result = vec![MenuItem {
|
||||
kind: MenuItemKind::Home,
|
||||
label: "Home".to_string(),
|
||||
slug: None,
|
||||
children: Vec::new(),
|
||||
}];
|
||||
result.extend(without_home);
|
||||
result
|
||||
}
|
||||
|
||||
/// Parse OPML 2.0 XML into menu items.
|
||||
fn parse_opml(content: &str) -> Vec<MenuItem> {
|
||||
// Simple XML parsing using quick-xml-style manual parsing.
|
||||
// OPML structure: <opml><body><outline .../></body></opml>
|
||||
let mut items = Vec::new();
|
||||
parse_outlines(content, &mut items);
|
||||
|
||||
// Ensure HomeAlwaysPresent
|
||||
if items.is_empty() || items[0].kind != MenuItemKind::Home {
|
||||
let without_home: Vec<MenuItem> = items
|
||||
.into_iter()
|
||||
.filter(|i| i.kind != MenuItemKind::Home)
|
||||
.collect();
|
||||
let mut normalized = vec![MenuItem {
|
||||
kind: MenuItemKind::Home,
|
||||
label: "Home".to_string(),
|
||||
slug: None,
|
||||
children: Vec::new(),
|
||||
}];
|
||||
normalized.extend(without_home);
|
||||
return normalized;
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Simple OPML outline parser.
|
||||
/// Parses <outline text="..." type="..." htmlUrl="..."> elements.
|
||||
fn parse_outlines(xml: &str, items: &mut Vec<MenuItem>) {
|
||||
// Find all outline elements at the body level
|
||||
let body_start = xml.find("<body>");
|
||||
let body_end = xml.find("</body>");
|
||||
if body_start.is_none() || body_end.is_none() {
|
||||
return;
|
||||
}
|
||||
let body = &xml[body_start.unwrap() + 6..body_end.unwrap()];
|
||||
parse_outline_children(body, items);
|
||||
}
|
||||
|
||||
fn parse_outline_children(content: &str, items: &mut Vec<MenuItem>) {
|
||||
let mut pos = 0;
|
||||
while pos < content.len() {
|
||||
// Find next <outline
|
||||
let Some(start) = content[pos..].find("<outline") else {
|
||||
break;
|
||||
};
|
||||
let abs_start = pos + start;
|
||||
let tag_start = abs_start;
|
||||
|
||||
// Find the end of the opening tag
|
||||
let rest = &content[tag_start..];
|
||||
let is_self_closing;
|
||||
let tag_end;
|
||||
|
||||
if let Some(sc) = rest.find("/>") {
|
||||
if let Some(gt) = rest.find('>') {
|
||||
if sc < gt {
|
||||
is_self_closing = true;
|
||||
tag_end = tag_start + sc + 2;
|
||||
} else {
|
||||
is_self_closing = false;
|
||||
tag_end = tag_start + gt + 1;
|
||||
}
|
||||
} else {
|
||||
is_self_closing = true;
|
||||
tag_end = tag_start + sc + 2;
|
||||
}
|
||||
} else if let Some(gt) = rest.find('>') {
|
||||
is_self_closing = false;
|
||||
tag_end = tag_start + gt + 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
|
||||
let tag_content = &content[tag_start..tag_end];
|
||||
|
||||
// Extract attributes
|
||||
let label = extract_attr(tag_content, "text")
|
||||
.unwrap_or_else(|| "Untitled".to_string());
|
||||
let kind_str = extract_attr(tag_content, "type")
|
||||
.unwrap_or_else(|| "page".to_string());
|
||||
let slug = extract_attr(tag_content, "htmlUrl");
|
||||
let kind = MenuItemKind::from_str(&kind_str);
|
||||
|
||||
let mut children = Vec::new();
|
||||
let after_tag;
|
||||
|
||||
if is_self_closing {
|
||||
after_tag = tag_end;
|
||||
} else {
|
||||
// Find matching </outline>
|
||||
let inner = &content[tag_end..];
|
||||
if let Some(close_idx) = find_closing_outline(inner) {
|
||||
let inner_content = &inner[..close_idx];
|
||||
parse_outline_children(inner_content, &mut children);
|
||||
after_tag = tag_end + close_idx + "</outline>".len();
|
||||
} else {
|
||||
after_tag = tag_end;
|
||||
}
|
||||
}
|
||||
|
||||
items.push(MenuItem {
|
||||
kind,
|
||||
label,
|
||||
slug,
|
||||
children,
|
||||
});
|
||||
pos = after_tag;
|
||||
}
|
||||
}
|
||||
|
||||
fn find_closing_outline(content: &str) -> Option<usize> {
|
||||
let mut depth = 0i32;
|
||||
let mut pos = 0;
|
||||
while pos < content.len() {
|
||||
if content[pos..].starts_with("<outline") {
|
||||
if let Some(sc) = content[pos..].find("/>") {
|
||||
if let Some(gt) = content[pos..].find('>') {
|
||||
if sc < gt {
|
||||
pos += sc + 2;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
depth += 1;
|
||||
pos += 8; // skip past "<outline"
|
||||
} else if content[pos..].starts_with("</outline>") {
|
||||
if depth == 0 {
|
||||
return Some(pos);
|
||||
}
|
||||
depth -= 1;
|
||||
pos += 10;
|
||||
} else {
|
||||
pos += 1;
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn extract_attr(tag: &str, name: &str) -> Option<String> {
|
||||
let pattern = format!("{name}=\"");
|
||||
let start = tag.find(&pattern)?;
|
||||
let value_start = start + pattern.len();
|
||||
let rest = &tag[value_start..];
|
||||
let end = rest.find('"')?;
|
||||
let value = &rest[..end];
|
||||
// Unescape XML entities
|
||||
Some(
|
||||
value
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'"),
|
||||
)
|
||||
}
|
||||
|
||||
/// Serialize menu items to OPML 2.0 format.
|
||||
fn serialize_opml(items: &[MenuItem]) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
|
||||
out.push_str("<opml version=\"2.0\">\n");
|
||||
out.push_str(" <head><title>Blog Menu</title></head>\n");
|
||||
out.push_str(" <body>\n");
|
||||
for item in items {
|
||||
serialize_outline(&mut out, item, 2);
|
||||
}
|
||||
out.push_str(" </body>\n");
|
||||
out.push_str("</opml>\n");
|
||||
out
|
||||
}
|
||||
|
||||
fn serialize_outline(out: &mut String, item: &MenuItem, indent: usize) {
|
||||
let pad = " ".repeat(indent);
|
||||
out.push_str(&pad);
|
||||
out.push_str("<outline");
|
||||
out.push_str(&format!(
|
||||
" text=\"{}\"",
|
||||
xml_escape(&item.label)
|
||||
));
|
||||
out.push_str(&format!(
|
||||
" type=\"{}\"",
|
||||
item.kind.as_str()
|
||||
));
|
||||
if let Some(ref slug) = item.slug {
|
||||
out.push_str(&format!(
|
||||
" htmlUrl=\"{}\"",
|
||||
xml_escape(slug)
|
||||
));
|
||||
}
|
||||
if item.children.is_empty() {
|
||||
out.push_str("/>\n");
|
||||
} else {
|
||||
out.push_str(">\n");
|
||||
for child in &item.children {
|
||||
serialize_outline(out, child, indent + 1);
|
||||
}
|
||||
out.push_str(&pad);
|
||||
out.push_str("</outline>\n");
|
||||
}
|
||||
}
|
||||
|
||||
fn xml_escape(s: &str) -> String {
|
||||
s.replace('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn default_opml_has_home() {
|
||||
let opml = default_menu_opml();
|
||||
assert!(opml.contains("type=\"home\""));
|
||||
assert!(opml.contains("text=\"Home\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roundtrip_menu() {
|
||||
let items = vec![
|
||||
MenuItem {
|
||||
kind: MenuItemKind::Home,
|
||||
label: "Home".into(),
|
||||
slug: None,
|
||||
children: Vec::new(),
|
||||
},
|
||||
MenuItem {
|
||||
kind: MenuItemKind::Page,
|
||||
label: "About".into(),
|
||||
slug: Some("/about".into()),
|
||||
children: Vec::new(),
|
||||
},
|
||||
MenuItem {
|
||||
kind: MenuItemKind::Submenu,
|
||||
label: "Categories".into(),
|
||||
slug: None,
|
||||
children: vec![
|
||||
MenuItem {
|
||||
kind: MenuItemKind::CategoryArchive,
|
||||
label: "Tech".into(),
|
||||
slug: Some("/category/tech/".into()),
|
||||
children: Vec::new(),
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
let opml = serialize_opml(&items);
|
||||
let parsed = parse_opml(&opml);
|
||||
assert_eq!(parsed.len(), 3);
|
||||
assert_eq!(parsed[0].kind, MenuItemKind::Home);
|
||||
assert_eq!(parsed[1].label, "About");
|
||||
assert_eq!(parsed[1].slug.as_deref(), Some("/about"));
|
||||
assert_eq!(parsed[2].children.len(), 1);
|
||||
assert_eq!(parsed[2].children[0].label, "Tech");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_prepends_home() {
|
||||
let items = vec![
|
||||
MenuItem {
|
||||
kind: MenuItemKind::Page,
|
||||
label: "About".into(),
|
||||
slug: Some("/about".into()),
|
||||
children: Vec::new(),
|
||||
},
|
||||
];
|
||||
let normalized = normalize_menu(&items);
|
||||
assert_eq!(normalized.len(), 2);
|
||||
assert_eq!(normalized[0].kind, MenuItemKind::Home);
|
||||
assert_eq!(normalized[1].label, "About");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn read_menu_missing_file_returns_home() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
||||
let items = read_menu(dir.path()).unwrap();
|
||||
assert_eq!(items.len(), 1);
|
||||
assert_eq!(items[0].kind, MenuItemKind::Home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_read_menu() {
|
||||
let dir = tempfile::TempDir::new().unwrap();
|
||||
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
|
||||
let items = vec![
|
||||
MenuItem {
|
||||
kind: MenuItemKind::Page,
|
||||
label: "Blog".into(),
|
||||
slug: Some("/blog".into()),
|
||||
children: Vec::new(),
|
||||
},
|
||||
];
|
||||
write_menu(dir.path(), &items).unwrap();
|
||||
let read = read_menu(dir.path()).unwrap();
|
||||
// Home is always prepended
|
||||
assert_eq!(read.len(), 2);
|
||||
assert_eq!(read[0].kind, MenuItemKind::Home);
|
||||
assert_eq!(read[1].label, "Blog");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn xml_escape_special_chars() {
|
||||
let escaped = xml_escape("Tom & Jerry <3 \"quotes\"");
|
||||
assert_eq!(escaped, "Tom & Jerry <3 "quotes"");
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,123 @@ pub fn remove_category(data_dir: &Path, category: &str) -> EngineResult<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── update helpers ──────────────────────────────────────────────────
|
||||
|
||||
/// Update the blog_languages list in project.json.
|
||||
/// Per metadata.allium UpdateProjectMetadata: writes ProjectJsonWritten.
|
||||
pub fn update_blog_languages(data_dir: &Path, languages: Vec<String>) -> EngineResult<()> {
|
||||
let mut meta = read_project_json(data_dir)?;
|
||||
meta.blog_languages = languages;
|
||||
write_project_json(data_dir, &meta)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update arbitrary fields of project metadata.
|
||||
/// Per metadata.allium UpdateProjectMetadata rule.
|
||||
pub fn update_project_metadata(
|
||||
data_dir: &Path,
|
||||
changes: &serde_json::Value,
|
||||
) -> EngineResult<()> {
|
||||
let mut meta = read_project_json(data_dir)?;
|
||||
if let Some(name) = changes.get("name").and_then(|v| v.as_str()) {
|
||||
meta.name = name.to_string();
|
||||
}
|
||||
if let Some(desc) = changes.get("description") {
|
||||
meta.description = desc.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(url) = changes.get("publicUrl") {
|
||||
meta.public_url = url.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(lang) = changes.get("mainLanguage") {
|
||||
meta.main_language = lang.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(author) = changes.get("defaultAuthor") {
|
||||
meta.default_author = author.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(max) = changes.get("maxPostsPerPage").and_then(|v| v.as_i64()) {
|
||||
meta.max_posts_per_page = max as i32;
|
||||
}
|
||||
if let Some(cat) = changes.get("blogmarkCategory") {
|
||||
meta.blogmark_category = cat.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(theme) = changes.get("picoTheme") {
|
||||
meta.pico_theme = theme.as_str().map(|s| s.to_string());
|
||||
}
|
||||
if let Some(enabled) = changes.get("semanticSimilarityEnabled").and_then(|v| v.as_bool()) {
|
||||
meta.semantic_similarity_enabled = enabled;
|
||||
}
|
||||
if let Some(langs) = changes.get("blogLanguages").and_then(|v| v.as_array()) {
|
||||
meta.blog_languages = langs
|
||||
.iter()
|
||||
.filter_map(|v| v.as_str().map(|s| s.to_string()))
|
||||
.collect();
|
||||
}
|
||||
meta.validate().map_err(|e| crate::engine::EngineError::Validation(e))?;
|
||||
write_project_json(data_dir, &meta)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── startup sync ────────────────────────────────────────────────────
|
||||
|
||||
/// Per metadata.allium StartupSync: loads metadata from filesystem, creating
|
||||
/// default files if missing. Called on project activation.
|
||||
pub fn startup_sync(data_dir: &Path) -> EngineResult<()> {
|
||||
let meta_dir = data_dir.join("meta");
|
||||
fs::create_dir_all(&meta_dir)?;
|
||||
|
||||
// Ensure project.json exists
|
||||
if !meta_dir.join("project.json").exists() {
|
||||
let default_meta = ProjectMetadata {
|
||||
name: "My Blog".to_string(),
|
||||
description: None,
|
||||
public_url: None,
|
||||
main_language: None,
|
||||
default_author: None,
|
||||
max_posts_per_page: 50,
|
||||
blogmark_category: None,
|
||||
pico_theme: None,
|
||||
semantic_similarity_enabled: false,
|
||||
blog_languages: Vec::new(),
|
||||
};
|
||||
write_project_json(data_dir, &default_meta)?;
|
||||
}
|
||||
|
||||
// Ensure categories.json exists with defaults
|
||||
if !meta_dir.join("categories.json").exists() {
|
||||
let defaults = vec![
|
||||
"article".to_string(),
|
||||
"aside".to_string(),
|
||||
"page".to_string(),
|
||||
"picture".to_string(),
|
||||
];
|
||||
write_categories_json(data_dir, &defaults)?;
|
||||
}
|
||||
|
||||
// Ensure category-meta.json exists
|
||||
if !meta_dir.join("category-meta.json").exists() {
|
||||
let empty: HashMap<String, CategorySettings> = HashMap::new();
|
||||
write_category_meta_json(data_dir, &empty)?;
|
||||
}
|
||||
|
||||
// Ensure publishing.json exists
|
||||
if !meta_dir.join("publishing.json").exists() {
|
||||
atomic_write_str(&meta_dir.join("publishing.json"), "{}")?;
|
||||
}
|
||||
|
||||
// Ensure tags.json exists
|
||||
if !meta_dir.join("tags.json").exists() {
|
||||
write_tags_json(data_dir, &[])?;
|
||||
}
|
||||
|
||||
// Ensure menu.opml exists
|
||||
if !meta_dir.join("menu.opml").exists() {
|
||||
let opml = crate::engine::menu::default_menu_opml();
|
||||
atomic_write_str(&meta_dir.join("menu.opml"), &opml)?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -14,6 +14,9 @@ pub mod rebuild;
|
||||
pub mod search;
|
||||
pub mod calendar;
|
||||
pub mod validate_translations;
|
||||
pub mod validate_media;
|
||||
pub mod validate_content;
|
||||
pub mod menu;
|
||||
|
||||
pub use error::{EngineError, EngineResult};
|
||||
pub use context::EngineContext;
|
||||
|
||||
@@ -194,6 +194,48 @@ fn write_default_meta_files(data_dir: &Path, project_name: &str) -> EngineResult
|
||||
// tags.json — empty array
|
||||
atomic_write_str(&meta_dir.join("tags.json"), "[]")?;
|
||||
|
||||
// menu.opml — default empty menu per menu.allium HomeAlwaysPresent
|
||||
let default_opml = crate::engine::menu::default_menu_opml();
|
||||
atomic_write_str(&meta_dir.join("menu.opml"), &default_opml)?;
|
||||
|
||||
// Starter templates — per project.allium StarterTemplatesCopied
|
||||
copy_starter_templates(data_dir)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Copy bundled starter templates into the project templates directory.
|
||||
/// Per project.allium: "Bundled starter templates are copied into the new project."
|
||||
fn copy_starter_templates(data_dir: &Path) -> EngineResult<()> {
|
||||
let templates_dir = data_dir.join("templates");
|
||||
let partials_dir = templates_dir.join("partials");
|
||||
fs::create_dir_all(&partials_dir)?;
|
||||
|
||||
// Starter templates embedded at compile time from assets/starter-templates/
|
||||
let templates: &[(&str, &str)] = &[
|
||||
("single-post.liquid", include_str!("../../../../assets/starter-templates/single-post.liquid")),
|
||||
("post-list.liquid", include_str!("../../../../assets/starter-templates/post-list.liquid")),
|
||||
("not-found.liquid", include_str!("../../../../assets/starter-templates/not-found.liquid")),
|
||||
];
|
||||
let partials: &[(&str, &str)] = &[
|
||||
("head.liquid", include_str!("../../../../assets/starter-templates/partials/head.liquid")),
|
||||
("menu.liquid", include_str!("../../../../assets/starter-templates/partials/menu.liquid")),
|
||||
("menu-items.liquid", include_str!("../../../../assets/starter-templates/partials/menu-items.liquid")),
|
||||
("language-switcher.liquid", include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid")),
|
||||
];
|
||||
|
||||
for (name, content) in templates {
|
||||
let path = templates_dir.join(name);
|
||||
if !path.exists() {
|
||||
atomic_write_str(&path, content)?;
|
||||
}
|
||||
}
|
||||
for (name, content) in partials {
|
||||
let path = partials_dir.join(name);
|
||||
if !path.exists() {
|
||||
atomic_write_str(&path, content)?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
218
crates/bds-core/src/engine/validate_content.rs
Normal file
218
crates/bds-core/src/engine/validate_content.rs
Normal file
@@ -0,0 +1,218 @@
|
||||
/// Validation functions for template (Liquid) and script (Lua/Python) content.
|
||||
/// Per template.allium and script.allium, these are pre-publish gates.
|
||||
///
|
||||
/// Current implementation: basic structural checks.
|
||||
/// When a full Liquid parser crate is added, upgrade `validate_liquid` to
|
||||
/// attempt a real parse. Similarly for `validate_script` with a Lua parser.
|
||||
|
||||
/// Result of a validation check.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ValidationResult {
|
||||
pub valid: bool,
|
||||
pub errors: Vec<String>,
|
||||
}
|
||||
|
||||
impl ValidationResult {
|
||||
pub fn ok() -> Self {
|
||||
Self {
|
||||
valid: true,
|
||||
errors: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn fail(errors: Vec<String>) -> Self {
|
||||
Self {
|
||||
valid: false,
|
||||
errors,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate Liquid template content.
|
||||
/// Per template.allium: "LiquidJS parser must accept the template".
|
||||
/// Currently performs structural tag-matching. Upgrade to full liquid crate parse later.
|
||||
pub fn validate_liquid(content: &str) -> ValidationResult {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Check for unmatched Liquid block tags
|
||||
let block_tags = ["if", "unless", "for", "case", "capture", "comment",
|
||||
"raw", "paginate", "tablerow", "block", "schema"];
|
||||
|
||||
for tag in &block_tags {
|
||||
let open_pattern = format!("{{% {tag}");
|
||||
let close_pattern = format!("{{% end{tag}");
|
||||
let opens = content.matches(&open_pattern).count();
|
||||
let closes = content.matches(&close_pattern).count();
|
||||
if opens != closes {
|
||||
errors.push(format!(
|
||||
"Unmatched {{% {tag} %}}: {opens} opens, {closes} closes"
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
// Check for unclosed {{ }}
|
||||
let double_opens = content.matches("{{").count();
|
||||
let double_closes = content.matches("}}").count();
|
||||
if double_opens != double_closes {
|
||||
errors.push(format!(
|
||||
"Unmatched {{}}: {double_opens} opens, {double_closes} closes"
|
||||
));
|
||||
}
|
||||
|
||||
// Check for unclosed {% %}
|
||||
let tag_opens = content.matches("{%").count();
|
||||
let tag_closes = content.matches("%}").count();
|
||||
if tag_opens != tag_closes {
|
||||
errors.push(format!(
|
||||
"Unmatched {{% %}}: {tag_opens} opens, {tag_closes} closes"
|
||||
));
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
ValidationResult::ok()
|
||||
} else {
|
||||
ValidationResult::fail(errors)
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate script content (Lua syntax).
|
||||
/// Per script.allium: "AST parsing must succeed".
|
||||
/// Currently performs basic bracket-matching. Upgrade to full Lua parser later.
|
||||
pub fn validate_script(content: &str) -> ValidationResult {
|
||||
let mut errors = Vec::new();
|
||||
|
||||
// Basic bracket matching
|
||||
let mut paren_depth: i32 = 0;
|
||||
let mut brace_depth: i32 = 0;
|
||||
let mut bracket_depth: i32 = 0;
|
||||
|
||||
for (line_num, line) in content.lines().enumerate() {
|
||||
// Skip comment lines
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("--") {
|
||||
continue;
|
||||
}
|
||||
for ch in line.chars() {
|
||||
match ch {
|
||||
'(' => paren_depth += 1,
|
||||
')' => paren_depth -= 1,
|
||||
'{' => brace_depth += 1,
|
||||
'}' => brace_depth -= 1,
|
||||
'[' => bracket_depth += 1,
|
||||
']' => bracket_depth -= 1,
|
||||
_ => {}
|
||||
}
|
||||
if paren_depth < 0 {
|
||||
errors.push(format!("Unexpected ')' at line {}", line_num + 1));
|
||||
paren_depth = 0;
|
||||
}
|
||||
if brace_depth < 0 {
|
||||
errors.push(format!("Unexpected '}}' at line {}", line_num + 1));
|
||||
brace_depth = 0;
|
||||
}
|
||||
if bracket_depth < 0 {
|
||||
errors.push(format!("Unexpected ']' at line {}", line_num + 1));
|
||||
bracket_depth = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if paren_depth != 0 {
|
||||
errors.push(format!("Unclosed parentheses: depth {paren_depth}"));
|
||||
}
|
||||
if brace_depth != 0 {
|
||||
errors.push(format!("Unclosed braces: depth {brace_depth}"));
|
||||
}
|
||||
if bracket_depth != 0 {
|
||||
errors.push(format!("Unclosed brackets: depth {bracket_depth}"));
|
||||
}
|
||||
|
||||
// Check for unmatched Lua block keywords.
|
||||
// In Lua, `for ... do ... end` and `while ... do ... end` each form a
|
||||
// single block, so `do` on such lines must NOT be counted as a separate
|
||||
// opener.
|
||||
let mut block_opens: usize = 0;
|
||||
let mut block_closes: usize = 0;
|
||||
for line in content.lines() {
|
||||
let trimmed = line.trim();
|
||||
if trimmed.starts_with("--") {
|
||||
continue;
|
||||
}
|
||||
let words: Vec<&str> = trimmed.split_whitespace().collect();
|
||||
let has_for_or_while = words.iter().any(|w| matches!(*w, "for" | "while"));
|
||||
for w in &words {
|
||||
match *w {
|
||||
"function" | "if" | "for" | "while" | "repeat" => block_opens += 1,
|
||||
"do" if !has_for_or_while => block_opens += 1,
|
||||
"end" | "until" => block_closes += 1,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if block_opens > block_closes {
|
||||
errors.push(format!(
|
||||
"Possible unclosed blocks: {block_opens} openers, {block_closes} closers"
|
||||
));
|
||||
}
|
||||
|
||||
if errors.is_empty() {
|
||||
ValidationResult::ok()
|
||||
} else {
|
||||
ValidationResult::fail(errors)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn valid_liquid_template() {
|
||||
let content = r#"
|
||||
<html>
|
||||
{% if user %}
|
||||
<p>{{ user.name }}</p>
|
||||
{% endif %}
|
||||
</html>"#;
|
||||
let result = validate_liquid(content);
|
||||
assert!(result.valid, "Errors: {:?}", result.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_liquid_if() {
|
||||
let content = "{% if true %}<p>Hello</p>";
|
||||
let result = validate_liquid(content);
|
||||
assert!(!result.valid);
|
||||
assert!(result.errors.iter().any(|e| e.contains("if")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_liquid_output() {
|
||||
let content = "{{ user.name";
|
||||
let result = validate_liquid(content);
|
||||
assert!(!result.valid);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_lua_script() {
|
||||
let content = r#"
|
||||
function render(ctx)
|
||||
local result = {}
|
||||
for i = 1, 10 do
|
||||
result[i] = i * 2
|
||||
end
|
||||
return result
|
||||
end
|
||||
"#;
|
||||
let result = validate_script(content);
|
||||
assert!(result.valid, "Errors: {:?}", result.errors);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unmatched_lua_parens() {
|
||||
let content = "function test(\n local x = 1\nend";
|
||||
let result = validate_script(content);
|
||||
assert!(!result.valid);
|
||||
}
|
||||
}
|
||||
191
crates/bds-core/src/engine/validate_media.rs
Normal file
191
crates/bds-core/src/engine/validate_media.rs
Normal file
@@ -0,0 +1,191 @@
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
|
||||
use rusqlite::Connection;
|
||||
|
||||
use crate::db::queries::media as mq;
|
||||
use crate::db::queries::post_media as pmq;
|
||||
use crate::engine::EngineResult;
|
||||
use crate::model::Media;
|
||||
use crate::util::{media_sidecar_path, thumbnail_path};
|
||||
|
||||
/// Thumbnail sizes per media_processing.allium.
|
||||
const THUMBNAIL_SIZES: &[&str] = &["small", "medium", "large", "ai"];
|
||||
|
||||
/// Types of media validation issues.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum MediaIssueKind {
|
||||
MissingBinary,
|
||||
MissingSidecar,
|
||||
MissingThumbnail { size: String },
|
||||
Corrupted,
|
||||
Orphan,
|
||||
}
|
||||
|
||||
/// A single media validation issue.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MediaIssue {
|
||||
pub media_id: String,
|
||||
pub original_name: String,
|
||||
pub kind: MediaIssueKind,
|
||||
pub detail: String,
|
||||
}
|
||||
|
||||
/// Full validation report.
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub struct MediaValidationReport {
|
||||
pub issues: Vec<MediaIssue>,
|
||||
pub total_checked: usize,
|
||||
}
|
||||
|
||||
/// Progress callback type for media validation.
|
||||
pub type ProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
|
||||
|
||||
/// Validate all media for a project per media_processing.allium ValidateMedia rule.
|
||||
///
|
||||
/// Checks for:
|
||||
/// - Missing binary files
|
||||
/// - Missing sidecar files
|
||||
/// - Missing thumbnails (all 4 sizes)
|
||||
/// - Corrupted image files (basic header check)
|
||||
/// - Orphan media (not linked to any post)
|
||||
pub fn validate_media(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
project_id: &str,
|
||||
on_progress: Option<ProgressFn>,
|
||||
) -> EngineResult<MediaValidationReport> {
|
||||
let all_media = mq::list_media_by_project(conn, project_id)?;
|
||||
let total = all_media.len();
|
||||
let mut report = MediaValidationReport {
|
||||
issues: Vec::new(),
|
||||
total_checked: total,
|
||||
};
|
||||
|
||||
for (idx, media) in all_media.iter().enumerate() {
|
||||
if let Some(ref cb) = on_progress {
|
||||
cb(idx + 1, total, &media.original_name);
|
||||
}
|
||||
check_media_item(conn, data_dir, media, &mut report.issues)?;
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
fn check_media_item(
|
||||
conn: &Connection,
|
||||
data_dir: &Path,
|
||||
media: &Media,
|
||||
issues: &mut Vec<MediaIssue>,
|
||||
) -> EngineResult<()> {
|
||||
let binary_path = data_dir.join(&media.file_path);
|
||||
|
||||
// 1. Missing binary
|
||||
if !binary_path.exists() {
|
||||
issues.push(MediaIssue {
|
||||
media_id: media.id.clone(),
|
||||
original_name: media.original_name.clone(),
|
||||
kind: MediaIssueKind::MissingBinary,
|
||||
detail: format!("Binary file not found: {}", media.file_path),
|
||||
});
|
||||
} else {
|
||||
// 3. Corrupted check — basic: file is empty or not readable
|
||||
match fs::metadata(&binary_path) {
|
||||
Ok(meta) if meta.len() == 0 => {
|
||||
issues.push(MediaIssue {
|
||||
media_id: media.id.clone(),
|
||||
original_name: media.original_name.clone(),
|
||||
kind: MediaIssueKind::Corrupted,
|
||||
detail: "Binary file is empty (0 bytes)".to_string(),
|
||||
});
|
||||
}
|
||||
Err(e) => {
|
||||
issues.push(MediaIssue {
|
||||
media_id: media.id.clone(),
|
||||
original_name: media.original_name.clone(),
|
||||
kind: MediaIssueKind::Corrupted,
|
||||
detail: format!("Cannot read binary: {e}"),
|
||||
});
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Missing sidecar
|
||||
let sidecar_rel = media_sidecar_path(&media.file_path);
|
||||
let sidecar_path = data_dir.join(&sidecar_rel);
|
||||
if !sidecar_path.exists() {
|
||||
issues.push(MediaIssue {
|
||||
media_id: media.id.clone(),
|
||||
original_name: media.original_name.clone(),
|
||||
kind: MediaIssueKind::MissingSidecar,
|
||||
detail: format!("Sidecar not found: {sidecar_rel}"),
|
||||
});
|
||||
}
|
||||
|
||||
// 3. Missing thumbnails — only for image types
|
||||
if is_image_mime(&media.mime_type) {
|
||||
let ext = thumbnail_extension(&media.mime_type);
|
||||
for size in THUMBNAIL_SIZES {
|
||||
let thumb_rel = thumbnail_path(&media.id, size, ext);
|
||||
let thumb_path = data_dir.join(&thumb_rel);
|
||||
if !thumb_path.exists() {
|
||||
issues.push(MediaIssue {
|
||||
media_id: media.id.clone(),
|
||||
original_name: media.original_name.clone(),
|
||||
kind: MediaIssueKind::MissingThumbnail {
|
||||
size: size.to_string(),
|
||||
},
|
||||
detail: format!("Thumbnail missing: {thumb_rel}"),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Orphan check — media not linked to any post
|
||||
let links = pmq::list_post_media_by_media(conn, &media.id)?;
|
||||
if links.is_empty() {
|
||||
issues.push(MediaIssue {
|
||||
media_id: media.id.clone(),
|
||||
original_name: media.original_name.clone(),
|
||||
kind: MediaIssueKind::Orphan,
|
||||
detail: "Media is not linked to any post".to_string(),
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn is_image_mime(mime: &str) -> bool {
|
||||
mime.starts_with("image/")
|
||||
}
|
||||
|
||||
fn thumbnail_extension(mime: &str) -> &str {
|
||||
match mime {
|
||||
"image/png" => "png",
|
||||
"image/gif" => "gif",
|
||||
"image/webp" => "webp",
|
||||
_ => "jpg",
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn is_image_recognizes_types() {
|
||||
assert!(is_image_mime("image/jpeg"));
|
||||
assert!(is_image_mime("image/png"));
|
||||
assert!(!is_image_mime("application/pdf"));
|
||||
assert!(!is_image_mime("video/mp4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn thumbnail_ext_defaults_to_jpg() {
|
||||
assert_eq!(thumbnail_extension("image/jpeg"), "jpg");
|
||||
assert_eq!(thumbnail_extension("image/png"), "png");
|
||||
assert_eq!(thumbnail_extension("image/webp"), "webp");
|
||||
assert_eq!(thumbnail_extension("image/tiff"), "jpg"); // fallback
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ use bds_core::db::Database;
|
||||
use bds_core::engine::task::{TaskId, TaskManager, TaskStatus};
|
||||
use bds_core::engine;
|
||||
use bds_core::i18n::{detect_os_locale, UiLocale};
|
||||
use bds_core::model::{Media, Post, Project};
|
||||
use bds_core::model::{Media, Post, Project, Script, Template};
|
||||
|
||||
use crate::i18n::{t, tw};
|
||||
use crate::platform::menu::{self, MenuAction, MenuRegistry};
|
||||
@@ -16,7 +16,7 @@ use crate::state::navigation::{
|
||||
};
|
||||
use crate::state::tabs::{self, Tab, TabType};
|
||||
use crate::state::toast::{Toast, ToastLevel};
|
||||
use crate::views::workspace;
|
||||
use crate::views::{modal, workspace};
|
||||
|
||||
// ───────────────────────────────────────────────────────────
|
||||
// Message
|
||||
@@ -79,11 +79,17 @@ pub enum Message {
|
||||
DismissToast(u64),
|
||||
ExpireToasts,
|
||||
|
||||
// Modal
|
||||
ShowModal(modal::ModalState),
|
||||
DismissModal,
|
||||
ConfirmModal(modal::ConfirmAction),
|
||||
|
||||
// Blog actions (dispatched to engine)
|
||||
RebuildDatabase,
|
||||
ReindexText,
|
||||
RegenerateCalendar,
|
||||
ValidateTranslations,
|
||||
ValidateMedia,
|
||||
GenerateSite,
|
||||
RunMetadataDiff,
|
||||
EngineTaskDone { task_id: TaskId, label: String, result: Result<String, String> },
|
||||
@@ -113,6 +119,8 @@ pub struct BdsApp {
|
||||
// Sidebar data
|
||||
sidebar_posts: Vec<Post>,
|
||||
sidebar_media: Vec<Media>,
|
||||
sidebar_scripts: Vec<Script>,
|
||||
sidebar_templates: Vec<Template>,
|
||||
|
||||
// Navigation
|
||||
sidebar_view: SidebarView,
|
||||
@@ -139,6 +147,9 @@ pub struct BdsApp {
|
||||
|
||||
// i18n
|
||||
ui_locale: UiLocale,
|
||||
/// Content/render language — the blog's main_language from project.json.
|
||||
/// Separate from ui_locale per i18n.allium TwoLocaleAxes.
|
||||
content_language: String,
|
||||
|
||||
// Flags
|
||||
offline_mode: bool,
|
||||
@@ -148,6 +159,9 @@ pub struct BdsApp {
|
||||
|
||||
// Toasts
|
||||
toasts: Vec<Toast>,
|
||||
|
||||
// Modal
|
||||
active_modal: Option<modal::ModalState>,
|
||||
}
|
||||
|
||||
// ───────────────────────────────────────────────────────────
|
||||
@@ -236,6 +250,8 @@ impl BdsApp {
|
||||
media_count: 0,
|
||||
sidebar_posts: Vec::new(),
|
||||
sidebar_media: Vec::new(),
|
||||
sidebar_scripts: Vec::new(),
|
||||
sidebar_templates: Vec::new(),
|
||||
sidebar_view: SidebarView::Posts,
|
||||
sidebar_visible: true,
|
||||
sidebar_width: 280.0,
|
||||
@@ -250,11 +266,13 @@ impl BdsApp {
|
||||
_menu_bar: menu_bar,
|
||||
menu_registry: registry,
|
||||
ui_locale: locale,
|
||||
content_language: "en".to_string(),
|
||||
offline_mode: false,
|
||||
locale_dropdown_open: false,
|
||||
project_dropdown_open: false,
|
||||
theme_badge: String::from("pico"),
|
||||
toasts: Vec::new(),
|
||||
active_modal: None,
|
||||
},
|
||||
init_task,
|
||||
)
|
||||
@@ -353,6 +371,16 @@ impl BdsApp {
|
||||
.and_then(|p| p.data_path.as_ref())
|
||||
.map(PathBuf::from);
|
||||
}
|
||||
// Per metadata.allium StartupSync: sync metadata from filesystem
|
||||
if let Some(data_dir) = self.data_dir.clone() {
|
||||
if let Err(e) = engine::meta::startup_sync(&data_dir) {
|
||||
self.add_output(&format!("Metadata sync failed: {e}"));
|
||||
}
|
||||
// Extract content language from project metadata
|
||||
if let Ok(meta) = engine::meta::read_project_json(&data_dir) {
|
||||
self.content_language = meta.main_language.unwrap_or_else(|| "en".to_string());
|
||||
}
|
||||
}
|
||||
self.refresh_counts();
|
||||
self.sync_menu_state();
|
||||
Task::none()
|
||||
@@ -368,6 +396,13 @@ impl BdsApp {
|
||||
.as_ref()
|
||||
.and_then(|p| p.data_path.as_ref())
|
||||
.map(PathBuf::from);
|
||||
// Per metadata.allium StartupSync
|
||||
if let Some(data_dir) = self.data_dir.clone() {
|
||||
let _ = engine::meta::startup_sync(&data_dir);
|
||||
if let Ok(meta) = engine::meta::read_project_json(&data_dir) {
|
||||
self.content_language = meta.main_language.unwrap_or_else(|| "en".to_string());
|
||||
}
|
||||
}
|
||||
let name = self.active_project.as_ref().map(|p| p.name.clone()).unwrap_or_default();
|
||||
self.notify(ToastLevel::Success, &tw(self.ui_locale, "projectSelector.toast.switched", &[("name", &name)]));
|
||||
}
|
||||
@@ -592,6 +627,23 @@ impl BdsApp {
|
||||
},
|
||||
)
|
||||
}
|
||||
Message::ValidateMedia => {
|
||||
self.spawn_engine_task(
|
||||
"engine.validateMediaStarted",
|
||||
|db_path, project_id, _data_dir, tm, tid| {
|
||||
let db = Database::open(&db_path).map_err(|e| e.to_string())?;
|
||||
let on_item: engine::validate_media::ProgressFn = Box::new(move |current, total, name| {
|
||||
let pct = if total > 0 { current as f32 / total as f32 } else { 1.0 };
|
||||
let msg = format!("Checking: {current}/{total} \u{2014} {name}");
|
||||
tm.report_progress(tid, Some(pct), Some(msg));
|
||||
});
|
||||
let report = engine::validate_media::validate_media(
|
||||
db.conn(), &_data_dir, &project_id, Some(on_item),
|
||||
).map_err(|e| e.to_string())?;
|
||||
Ok(format!("checked={}, issues={}", report.total_checked, report.issues.len()))
|
||||
},
|
||||
)
|
||||
}
|
||||
Message::GenerateSite => {
|
||||
self.spawn_engine_task(
|
||||
"engine.generateSiteStarted",
|
||||
@@ -639,6 +691,40 @@ impl BdsApp {
|
||||
Task::none()
|
||||
}
|
||||
|
||||
// ── Modal ──
|
||||
Message::ShowModal(state) => {
|
||||
self.active_modal = Some(state);
|
||||
Task::none()
|
||||
}
|
||||
Message::DismissModal => {
|
||||
self.active_modal = None;
|
||||
Task::none()
|
||||
}
|
||||
Message::ConfirmModal(action) => {
|
||||
self.active_modal = None;
|
||||
match action {
|
||||
modal::ConfirmAction::DeleteProject(id) => {
|
||||
Task::done(Message::DeleteProject(id))
|
||||
}
|
||||
modal::ConfirmAction::DeletePost(_id) => {
|
||||
// Post deletion will be implemented in M3 editors
|
||||
Task::none()
|
||||
}
|
||||
modal::ConfirmAction::DeleteMedia(_id) => {
|
||||
Task::none()
|
||||
}
|
||||
modal::ConfirmAction::DeleteScript(_id) => {
|
||||
Task::none()
|
||||
}
|
||||
modal::ConfirmAction::DeleteTemplate(_id) => {
|
||||
Task::none()
|
||||
}
|
||||
modal::ConfirmAction::MergeTags { .. } => {
|
||||
Task::none()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Message::Noop => Task::none(),
|
||||
Message::InitMenuBar => {
|
||||
#[cfg(target_os = "macos")]
|
||||
@@ -663,6 +749,8 @@ impl BdsApp {
|
||||
&self.output_entries,
|
||||
&self.sidebar_posts,
|
||||
&self.sidebar_media,
|
||||
&self.sidebar_scripts,
|
||||
&self.sidebar_templates,
|
||||
active_name,
|
||||
&self.projects,
|
||||
self.active_project.as_ref().map(|p| p.id.as_str()),
|
||||
@@ -674,6 +762,7 @@ impl BdsApp {
|
||||
&self.theme_badge,
|
||||
self.ui_locale,
|
||||
&self.toasts,
|
||||
self.active_modal.as_ref(),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -978,6 +1067,16 @@ impl BdsApp {
|
||||
0,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
self.sidebar_scripts = bds_core::db::queries::script::list_scripts_by_project(
|
||||
db.conn(),
|
||||
&project.id,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
self.sidebar_templates = bds_core::db::queries::template::list_templates_by_project(
|
||||
db.conn(),
|
||||
&project.id,
|
||||
)
|
||||
.unwrap_or_default();
|
||||
// Read pico theme from project metadata for status bar badge
|
||||
if let Some(ref data_dir) = self.data_dir {
|
||||
if let Ok(meta) = engine::meta::read_project_json(data_dir) {
|
||||
|
||||
@@ -7,3 +7,4 @@ pub mod panel;
|
||||
pub mod project_selector;
|
||||
pub mod toast;
|
||||
pub mod welcome;
|
||||
pub mod modal;
|
||||
|
||||
252
crates/bds-ui/src/views/modal.rs
Normal file
252
crates/bds-ui/src/views/modal.rs
Normal file
@@ -0,0 +1,252 @@
|
||||
use iced::widget::{button, column, container, row, text, Space};
|
||||
use iced::widget::text::Shaping;
|
||||
use iced::{Alignment, Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::i18n::t;
|
||||
|
||||
/// Active modal state. Only one modal at a time.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ModalState {
|
||||
/// Per modals.allium ConfirmDeleteModal: warning, entity name, references.
|
||||
ConfirmDelete {
|
||||
entity_name: String,
|
||||
references: Vec<String>,
|
||||
on_confirm: ConfirmAction,
|
||||
},
|
||||
/// Per modals.allium ConfirmDialog: generic confirmation.
|
||||
Confirm {
|
||||
title: String,
|
||||
message: String,
|
||||
on_confirm: ConfirmAction,
|
||||
},
|
||||
}
|
||||
|
||||
/// What action to perform when modal is confirmed.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ConfirmAction {
|
||||
DeleteProject(String),
|
||||
DeletePost(String),
|
||||
DeleteMedia(String),
|
||||
DeleteScript(String),
|
||||
DeleteTemplate(String),
|
||||
MergeTags { source: String, target: String },
|
||||
}
|
||||
|
||||
// ── Modal backdrop style ──
|
||||
|
||||
fn backdrop_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgba(0.0, 0.0, 0.0, 0.5))),
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn modal_box_style(_theme: &Theme) -> container::Style {
|
||||
container::Style {
|
||||
background: Some(Background::Color(Color::from_rgb(0.18, 0.18, 0.22))),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.30, 0.30, 0.35),
|
||||
width: 1.0,
|
||||
radius: 8.0.into(),
|
||||
},
|
||||
..container::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn cancel_button_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.28, 0.28, 0.33),
|
||||
_ => Color::from_rgb(0.22, 0.22, 0.27),
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
text_color: Color::from_rgb(0.80, 0.80, 0.85),
|
||||
border: Border {
|
||||
color: Color::from_rgb(0.35, 0.35, 0.40),
|
||||
width: 1.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn danger_button_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.85, 0.20, 0.20),
|
||||
_ => Color::from_rgb(0.75, 0.15, 0.15),
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
text_color: Color::WHITE,
|
||||
border: Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn confirm_button_style(_theme: &Theme, status: button::Status) -> button::Style {
|
||||
let bg = match status {
|
||||
button::Status::Hovered => Color::from_rgb(0.20, 0.55, 0.85),
|
||||
_ => Color::from_rgb(0.15, 0.45, 0.75),
|
||||
};
|
||||
button::Style {
|
||||
background: Some(Background::Color(bg)),
|
||||
text_color: Color::WHITE,
|
||||
border: Border {
|
||||
color: Color::TRANSPARENT,
|
||||
width: 0.0,
|
||||
radius: 4.0.into(),
|
||||
},
|
||||
..button::Style::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Render the modal overlay.
|
||||
pub fn view(state: &ModalState, locale: UiLocale) -> Element<'static, Message> {
|
||||
let modal_content: Element<'static, Message> = match state {
|
||||
ModalState::ConfirmDelete {
|
||||
entity_name,
|
||||
references,
|
||||
on_confirm,
|
||||
} => {
|
||||
let title = text(t(locale, "modal.confirmDelete.title"))
|
||||
.size(16)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE);
|
||||
|
||||
let warning_icon = text("\u{26A0}")
|
||||
.size(20)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(1.0, 0.80, 0.0));
|
||||
|
||||
let entity_label = text(entity_name.clone())
|
||||
.size(14)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.90, 0.90, 0.95));
|
||||
|
||||
let warning_text = text(t(locale, "modal.confirmDelete.warning"))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.70, 0.70, 0.75));
|
||||
|
||||
let mut content_col = column![
|
||||
row![warning_icon, Space::with_width(8.0), title]
|
||||
.align_y(Alignment::Center),
|
||||
Space::with_height(12.0),
|
||||
entity_label,
|
||||
warning_text,
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
// Reference section
|
||||
if !references.is_empty() {
|
||||
content_col = content_col.push(Space::with_height(8.0));
|
||||
for r in references {
|
||||
content_col = content_col.push(
|
||||
text(format!("\u{2022} {r}"))
|
||||
.size(11)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.60, 0.60, 0.65)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let on_confirm_clone = on_confirm.clone();
|
||||
let buttons = row![
|
||||
button(
|
||||
text(t(locale, "modal.confirmDelete.cancel"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),
|
||||
Space::with_width(Length::Fill),
|
||||
button(
|
||||
text(t(locale, "modal.confirmDelete.delete"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::ConfirmModal(on_confirm_clone))
|
||||
.padding([6, 16])
|
||||
.style(danger_button_style),
|
||||
];
|
||||
|
||||
content_col = content_col.push(Space::with_height(16.0));
|
||||
content_col = content_col.push(buttons);
|
||||
|
||||
container(content_col.padding(20))
|
||||
.width(Length::Fixed(380.0))
|
||||
.style(modal_box_style)
|
||||
.into()
|
||||
}
|
||||
|
||||
ModalState::Confirm {
|
||||
title: dialog_title,
|
||||
message: dialog_message,
|
||||
on_confirm,
|
||||
} => {
|
||||
let title = text(dialog_title.clone())
|
||||
.size(16)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::WHITE);
|
||||
|
||||
let msg = text(dialog_message.clone())
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.80, 0.80, 0.85));
|
||||
|
||||
let on_confirm_clone = on_confirm.clone();
|
||||
let buttons = row![
|
||||
button(
|
||||
text(t(locale, "modal.confirm.cancel"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::DismissModal)
|
||||
.padding([6, 16])
|
||||
.style(cancel_button_style),
|
||||
Space::with_width(Length::Fill),
|
||||
button(
|
||||
text(t(locale, "modal.confirm.confirm"))
|
||||
.size(13)
|
||||
.shaping(Shaping::Advanced)
|
||||
)
|
||||
.on_press(Message::ConfirmModal(on_confirm_clone))
|
||||
.padding([6, 16])
|
||||
.style(confirm_button_style),
|
||||
];
|
||||
|
||||
let content_col = column![
|
||||
title,
|
||||
Space::with_height(12.0),
|
||||
msg,
|
||||
Space::with_height(16.0),
|
||||
buttons,
|
||||
]
|
||||
.spacing(4);
|
||||
|
||||
container(content_col.padding(20))
|
||||
.width(Length::Fixed(380.0))
|
||||
.style(modal_box_style)
|
||||
.into()
|
||||
}
|
||||
};
|
||||
|
||||
// Center the modal in a full-screen backdrop
|
||||
container(
|
||||
container(modal_content)
|
||||
.center_x(Length::Fill)
|
||||
.center_y(Length::Fill),
|
||||
)
|
||||
.width(Length::Fill)
|
||||
.height(Length::Fill)
|
||||
.style(backdrop_style)
|
||||
.into()
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use iced::widget::text::Shaping;
|
||||
use iced::{Background, Border, Color, Element, Length, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
use bds_core::model::{Media, Post};
|
||||
use bds_core::model::{Media, Post, Script, Template};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::i18n::t;
|
||||
@@ -72,9 +72,6 @@ const AVG_CHAR_WIDTH_PX: f32 = 6.8;
|
||||
/// Sidebar padding on each side (12px) plus item padding (6px each side).
|
||||
const SIDEBAR_TEXT_OVERHEAD_PX: f32 = 36.0;
|
||||
|
||||
/// Extra space used by the post status indicator prefix ("○ " etc.).
|
||||
const STATUS_INDICATOR_CHARS: usize = 2;
|
||||
|
||||
/// Truncate a string to fit approximately within `available_px` pixels,
|
||||
/// appending "…" if truncation occurs.
|
||||
fn truncate_to_fit(s: &str, available_px: f32) -> String {
|
||||
@@ -98,10 +95,35 @@ fn truncate_media_title(title: &str) -> String {
|
||||
}
|
||||
}
|
||||
|
||||
/// Per sidebar_views.allium PostTypeIcon: map first category to emoji.
|
||||
fn post_type_icon(categories: &[String]) -> &'static str {
|
||||
for cat in categories {
|
||||
match cat.to_lowercase().as_str() {
|
||||
"picture" => return "\u{1F4F7}", // 📷 camera
|
||||
"article" => return "\u{1F5D2}", // 🗒 notepad
|
||||
"aside" | "blogmark" => return "\u{1F517}", // 🔗 link
|
||||
"video" => return "\u{1F3AC}", // 🎬 film
|
||||
"podcast" => return "\u{1F4AC}", // 💬 speech bubble
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
"\u{1F4C4}" // 📄 document (default)
|
||||
}
|
||||
|
||||
/// Per sidebar_views.allium PostDateFormat: "Feb 10, 2026".
|
||||
fn format_post_date(unix_ms: i64) -> String {
|
||||
let secs = unix_ms / 1000;
|
||||
let dt = chrono::DateTime::from_timestamp(secs, 0)
|
||||
.unwrap_or_else(|| chrono::DateTime::from_timestamp(0, 0).unwrap());
|
||||
dt.format("%b %d, %Y").to_string()
|
||||
}
|
||||
|
||||
pub fn view(
|
||||
sidebar_view: SidebarView,
|
||||
posts: &[Post],
|
||||
media: &[Media],
|
||||
scripts: &[Script],
|
||||
templates: &[Template],
|
||||
width: f32,
|
||||
active_tab: Option<&str>,
|
||||
locale: UiLocale,
|
||||
@@ -133,24 +155,33 @@ pub fn view(
|
||||
|
||||
let make_post_item = |p: &Post| -> Element<'static, Message> {
|
||||
let is_active = active_tab == Some(p.id.as_str());
|
||||
// Per sidebar_views.allium PostTypeIcon
|
||||
let icon = post_type_icon(&p.categories);
|
||||
let status_indicator = match p.status {
|
||||
bds_core::model::PostStatus::Draft => "\u{25CB} ",
|
||||
bds_core::model::PostStatus::Published => "\u{25CF} ",
|
||||
bds_core::model::PostStatus::Archived => "\u{25A1} ",
|
||||
bds_core::model::PostStatus::Draft => "\u{25CB}",
|
||||
bds_core::model::PostStatus::Published => "\u{25CF}",
|
||||
bds_core::model::PostStatus::Archived => "\u{25A1}",
|
||||
};
|
||||
// Per sidebar_views.allium PostDateFormat
|
||||
let date = format_post_date(p.created_at);
|
||||
// Truncate title to fit sidebar width, accounting for
|
||||
// padding and the status indicator prefix.
|
||||
// padding and the status/icon prefix.
|
||||
let prefix_chars: usize = 5; // icon + space + status + space
|
||||
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX
|
||||
- (STATUS_INDICATOR_CHARS as f32 * AVG_CHAR_WIDTH_PX);
|
||||
- (prefix_chars as f32 * AVG_CHAR_WIDTH_PX);
|
||||
let display_title = truncate_to_fit(&p.title, text_px);
|
||||
let label = format!("{status_indicator}{display_title}");
|
||||
let label = format!("{icon} {status_indicator} {display_title}");
|
||||
let label_text = text(label)
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.wrapping(iced::widget::text::Wrapping::None);
|
||||
let date_text = text(date)
|
||||
.size(10)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(Color::from_rgb(0.50, 0.50, 0.55));
|
||||
let style_fn = if is_active { item_active_style } else { item_style };
|
||||
button(
|
||||
container(label_text)
|
||||
container(column![label_text, date_text].spacing(1))
|
||||
.width(Length::Fill)
|
||||
.clip(true)
|
||||
)
|
||||
@@ -254,6 +285,166 @@ pub fn view(
|
||||
.into()
|
||||
}
|
||||
}
|
||||
SidebarView::Scripts => {
|
||||
if scripts.is_empty() {
|
||||
text(t(locale, placeholder_key(sidebar_view)))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted)
|
||||
.into()
|
||||
} else {
|
||||
let items: Vec<Element<'static, Message>> = scripts
|
||||
.iter()
|
||||
.map(|s| {
|
||||
let is_active = active_tab == Some(s.id.as_str());
|
||||
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX;
|
||||
let display_title = truncate_to_fit(&s.title, text_px);
|
||||
let label_text = text(display_title.clone())
|
||||
.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 {
|
||||
id: s.id.clone(),
|
||||
tab_type: TabType::Scripts,
|
||||
title: display_title,
|
||||
is_transient: true,
|
||||
is_dirty: false,
|
||||
}))
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill)
|
||||
.style(style_fn)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
SidebarView::Templates => {
|
||||
if templates.is_empty() {
|
||||
text(t(locale, placeholder_key(sidebar_view)))
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced)
|
||||
.color(muted)
|
||||
.into()
|
||||
} else {
|
||||
let items: Vec<Element<'static, Message>> = templates
|
||||
.iter()
|
||||
.map(|tmpl| {
|
||||
let is_active = active_tab == Some(tmpl.id.as_str());
|
||||
let text_px = width - SIDEBAR_TEXT_OVERHEAD_PX;
|
||||
let display_title = truncate_to_fit(&tmpl.title, text_px);
|
||||
let label_text = text(display_title.clone())
|
||||
.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 {
|
||||
id: tmpl.id.clone(),
|
||||
tab_type: TabType::Templates,
|
||||
title: display_title,
|
||||
is_transient: true,
|
||||
is_dirty: false,
|
||||
}))
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill)
|
||||
.style(style_fn)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
}
|
||||
}
|
||||
SidebarView::Settings => {
|
||||
// Per sidebar_views.allium SettingsNav: 9 fixed-order sections
|
||||
let sections = [
|
||||
"settings.nav.project",
|
||||
"settings.nav.editor",
|
||||
"settings.nav.content",
|
||||
"settings.nav.ai",
|
||||
"settings.nav.technology",
|
||||
"settings.nav.publishing",
|
||||
"settings.nav.data",
|
||||
"settings.nav.mcp",
|
||||
"settings.nav.style",
|
||||
];
|
||||
let items: Vec<Element<'static, Message>> = sections
|
||||
.iter()
|
||||
.map(|key| {
|
||||
let label = t(locale, key);
|
||||
let label_text = text(label)
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced);
|
||||
button(
|
||||
container(label_text)
|
||||
.width(Length::Fill)
|
||||
)
|
||||
.on_press(Message::OpenTab(Tab {
|
||||
id: "settings".to_string(),
|
||||
tab_type: TabType::Settings,
|
||||
title: t(locale, "common.settings"),
|
||||
is_transient: false,
|
||||
is_dirty: false,
|
||||
}))
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill)
|
||||
.style(item_style)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
}
|
||||
SidebarView::Tags => {
|
||||
// Per sidebar_views.allium TagsNav: 3 fixed-order sections
|
||||
let sections = [
|
||||
"tags.nav.cloud",
|
||||
"tags.nav.manage",
|
||||
"tags.nav.merge",
|
||||
];
|
||||
let items: Vec<Element<'static, Message>> = sections
|
||||
.iter()
|
||||
.map(|key| {
|
||||
let label = t(locale, key);
|
||||
let label_text = text(label)
|
||||
.size(12)
|
||||
.shaping(Shaping::Advanced);
|
||||
button(
|
||||
container(label_text)
|
||||
.width(Length::Fill)
|
||||
)
|
||||
.on_press(Message::OpenTab(Tab {
|
||||
id: "tags".to_string(),
|
||||
tab_type: TabType::Tags,
|
||||
title: t(locale, "tabBar.tags"),
|
||||
is_transient: false,
|
||||
is_dirty: false,
|
||||
}))
|
||||
.padding([3, 6])
|
||||
.width(Length::Fill)
|
||||
.style(item_style)
|
||||
.into()
|
||||
})
|
||||
.collect();
|
||||
iced::widget::Column::with_children(items)
|
||||
.spacing(1)
|
||||
.into()
|
||||
}
|
||||
_ => {
|
||||
text(t(locale, placeholder_key(sidebar_view)))
|
||||
.size(12)
|
||||
|
||||
@@ -3,13 +3,13 @@ use iced::widget::text::Shaping;
|
||||
use iced::{Alignment, Background, Color, Element, Length, Padding, Theme};
|
||||
|
||||
use bds_core::i18n::UiLocale;
|
||||
use bds_core::model::{Media, Post, Project};
|
||||
use bds_core::model::{Media, Post, Project, Script, Template};
|
||||
|
||||
use crate::app::Message;
|
||||
use crate::state::navigation::{OutputEntry, PanelTab, SidebarView, TaskSnapshot};
|
||||
use crate::state::tabs::{Tab, TabType};
|
||||
use crate::state::toast::Toast;
|
||||
use crate::views::{activity_bar, panel, project_selector, sidebar, status_bar, tab_bar, toast, welcome};
|
||||
use crate::views::{activity_bar, modal, panel, project_selector, sidebar, status_bar, tab_bar, toast, welcome};
|
||||
|
||||
/// Main content area background.
|
||||
fn content_bg(_theme: &Theme) -> container::Style {
|
||||
@@ -55,6 +55,8 @@ pub fn view(
|
||||
// Sidebar data
|
||||
sidebar_posts: &[Post],
|
||||
sidebar_media: &[Media],
|
||||
sidebar_scripts: &[Script],
|
||||
sidebar_templates: &[Template],
|
||||
// Status bar
|
||||
active_project_name: Option<&str>,
|
||||
projects: &[Project],
|
||||
@@ -69,6 +71,8 @@ pub fn view(
|
||||
locale: UiLocale,
|
||||
// Toasts
|
||||
toasts: &[Toast],
|
||||
// Modal
|
||||
active_modal: Option<&modal::ModalState>,
|
||||
) -> Element<'static, Message> {
|
||||
// Activity bar (leftmost column)
|
||||
let activity = activity_bar::view(sidebar_view, sidebar_visible, locale);
|
||||
@@ -106,7 +110,7 @@ pub fn view(
|
||||
let mut main_row = row![activity];
|
||||
|
||||
if sidebar_visible {
|
||||
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_width, active_tab, locale));
|
||||
main_row = main_row.push(sidebar::view(sidebar_view, sidebar_posts, sidebar_media, sidebar_scripts, sidebar_templates, sidebar_width, active_tab, locale));
|
||||
|
||||
// Resize drag handle: 4px wide strip between sidebar and content
|
||||
let handle = container(Space::new(0, 0))
|
||||
@@ -234,6 +238,11 @@ pub fn view(
|
||||
overlays.push(overlay);
|
||||
}
|
||||
|
||||
// Modal overlay (highest z-index)
|
||||
if let Some(modal_state) = active_modal {
|
||||
overlays.push(modal::view(modal_state, locale));
|
||||
}
|
||||
|
||||
if overlays.is_empty() {
|
||||
base_layout
|
||||
} else {
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"engine.calendarFailed": "Kalender-Generierung fehlgeschlagen: {error}",
|
||||
"engine.calendarNoProject": "Kein aktives Projekt",
|
||||
"engine.validateTranslationsStarted": "Übersetzungen werden validiert...",
|
||||
"engine.validateMediaStarted": "Medien werden überprüft...",
|
||||
"engine.validateTranslationsComplete": "Übersetzungsvalidierung: {dbIssues} DB-Probleme, {fsIssues} Dateisystem-Probleme",
|
||||
"engine.validateTranslationsFailed": "Übersetzungsvalidierung fehlgeschlagen: {error}",
|
||||
"engine.fillMissingTranslationsOffline": "Übersetzungen können nicht ergänzt werden: KI im Offline-Modus nicht verfügbar",
|
||||
@@ -143,5 +144,25 @@
|
||||
"engine.generateSiteNoProject": "Kein aktives Projekt",
|
||||
"dialog.selectFolder": "Ordner auswählen",
|
||||
"dialog.importMedia": "Medien importieren",
|
||||
"dialog.imageFilter": "Bilder"
|
||||
"dialog.imageFilter": "Bilder",
|
||||
"settings.nav.project": "Projekt",
|
||||
"settings.nav.editor": "Editor",
|
||||
"settings.nav.content": "Inhalt",
|
||||
"settings.nav.ai": "KI",
|
||||
"settings.nav.technology": "Technologie",
|
||||
"settings.nav.publishing": "Veröffentlichung",
|
||||
"settings.nav.data": "Daten",
|
||||
"settings.nav.mcp": "MCP",
|
||||
"settings.nav.style": "Stil",
|
||||
"tags.nav.cloud": "Tag-Wolke",
|
||||
"tags.nav.manage": "Tags verwalten",
|
||||
"tags.nav.merge": "Tags zusammenführen",
|
||||
"sidebar.chatYesterday": "Gestern",
|
||||
"modal.confirmDelete.title": "Löschen bestätigen",
|
||||
"modal.confirmDelete.warning": "Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||
"modal.confirmDelete.cancel": "Abbrechen",
|
||||
"modal.confirmDelete.delete": "Löschen",
|
||||
"modal.confirm.title": "Bestätigen",
|
||||
"modal.confirm.cancel": "Abbrechen",
|
||||
"modal.confirm.confirm": "Bestätigen"
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"engine.calendarFailed": "Calendar regeneration failed: {error}",
|
||||
"engine.calendarNoProject": "No active project",
|
||||
"engine.validateTranslationsStarted": "Validating translations...",
|
||||
"engine.validateMediaStarted": "Validating media assets...",
|
||||
"engine.validateTranslationsComplete": "Translation validation: {dbIssues} DB issues, {fsIssues} filesystem issues",
|
||||
"engine.validateTranslationsFailed": "Translation validation failed: {error}",
|
||||
"engine.fillMissingTranslationsOffline": "Cannot fill translations: AI is unavailable in offline mode",
|
||||
@@ -143,5 +144,25 @@
|
||||
"engine.generateSiteNoProject": "No active project",
|
||||
"dialog.selectFolder": "Select Folder",
|
||||
"dialog.importMedia": "Import Media",
|
||||
"dialog.imageFilter": "Images"
|
||||
"dialog.imageFilter": "Images",
|
||||
"settings.nav.project": "Project",
|
||||
"settings.nav.editor": "Editor",
|
||||
"settings.nav.content": "Content",
|
||||
"settings.nav.ai": "AI",
|
||||
"settings.nav.technology": "Technology",
|
||||
"settings.nav.publishing": "Publishing",
|
||||
"settings.nav.data": "Data",
|
||||
"settings.nav.mcp": "MCP",
|
||||
"settings.nav.style": "Style",
|
||||
"tags.nav.cloud": "Tag Cloud",
|
||||
"tags.nav.manage": "Manage Tags",
|
||||
"tags.nav.merge": "Merge Tags",
|
||||
"sidebar.chatYesterday": "Yesterday",
|
||||
"modal.confirmDelete.title": "Confirm Delete",
|
||||
"modal.confirmDelete.warning": "This action cannot be undone.",
|
||||
"modal.confirmDelete.cancel": "Cancel",
|
||||
"modal.confirmDelete.delete": "Delete",
|
||||
"modal.confirm.title": "Confirm",
|
||||
"modal.confirm.cancel": "Cancel",
|
||||
"modal.confirm.confirm": "Confirm"
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"engine.calendarFailed": "La regeneración del calendario falló: {error}",
|
||||
"engine.calendarNoProject": "No hay proyecto activo",
|
||||
"engine.validateTranslationsStarted": "Validando traducciones...",
|
||||
"engine.validateMediaStarted": "Validando medios...",
|
||||
"engine.validateTranslationsComplete": "Validación de traducciones: {dbIssues} problemas en la base de datos, {fsIssues} problemas en el sistema de archivos",
|
||||
"engine.validateTranslationsFailed": "La validación de traducciones falló: {error}",
|
||||
"engine.fillMissingTranslationsOffline": "No se pueden completar las traducciones: la IA no está disponible en modo avión",
|
||||
@@ -143,5 +144,25 @@
|
||||
"engine.generateSiteNoProject": "No hay proyecto activo",
|
||||
"dialog.selectFolder": "Seleccionar carpeta",
|
||||
"dialog.importMedia": "Importar medios",
|
||||
"dialog.imageFilter": "Imágenes"
|
||||
"dialog.imageFilter": "Imágenes",
|
||||
"settings.nav.project": "Proyecto",
|
||||
"settings.nav.editor": "Editor",
|
||||
"settings.nav.content": "Contenido",
|
||||
"settings.nav.ai": "IA",
|
||||
"settings.nav.technology": "Tecnología",
|
||||
"settings.nav.publishing": "Publicación",
|
||||
"settings.nav.data": "Datos",
|
||||
"settings.nav.mcp": "MCP",
|
||||
"settings.nav.style": "Estilo",
|
||||
"tags.nav.cloud": "Nube de etiquetas",
|
||||
"tags.nav.manage": "Gestionar etiquetas",
|
||||
"tags.nav.merge": "Fusionar etiquetas",
|
||||
"sidebar.chatYesterday": "Ayer",
|
||||
"modal.confirmDelete.title": "Confirmar eliminación",
|
||||
"modal.confirmDelete.warning": "Esta acción no se puede deshacer.",
|
||||
"modal.confirmDelete.cancel": "Cancelar",
|
||||
"modal.confirmDelete.delete": "Eliminar",
|
||||
"modal.confirm.title": "Confirmar",
|
||||
"modal.confirm.cancel": "Cancelar",
|
||||
"modal.confirm.confirm": "Confirmar"
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"engine.calendarFailed": "Échec de la régénération du calendrier : {error}",
|
||||
"engine.calendarNoProject": "Aucun projet actif",
|
||||
"engine.validateTranslationsStarted": "Validation des traductions...",
|
||||
"engine.validateMediaStarted": "Validation des médias...",
|
||||
"engine.validateTranslationsComplete": "Validation des traductions : {dbIssues} problèmes en base, {fsIssues} problèmes sur le système de fichiers",
|
||||
"engine.validateTranslationsFailed": "Échec de la validation des traductions : {error}",
|
||||
"engine.fillMissingTranslationsOffline": "Impossible de compléter les traductions : l'IA n'est pas disponible en mode avion",
|
||||
@@ -143,5 +144,25 @@
|
||||
"engine.generateSiteNoProject": "Aucun projet actif",
|
||||
"dialog.selectFolder": "Sélectionner un dossier",
|
||||
"dialog.importMedia": "Importer des médias",
|
||||
"dialog.imageFilter": "Images"
|
||||
"dialog.imageFilter": "Images",
|
||||
"settings.nav.project": "Projet",
|
||||
"settings.nav.editor": "Éditeur",
|
||||
"settings.nav.content": "Contenu",
|
||||
"settings.nav.ai": "IA",
|
||||
"settings.nav.technology": "Technologie",
|
||||
"settings.nav.publishing": "Publication",
|
||||
"settings.nav.data": "Données",
|
||||
"settings.nav.mcp": "MCP",
|
||||
"settings.nav.style": "Style",
|
||||
"tags.nav.cloud": "Nuage de tags",
|
||||
"tags.nav.manage": "Gérer les tags",
|
||||
"tags.nav.merge": "Fusionner les tags",
|
||||
"sidebar.chatYesterday": "Hier",
|
||||
"modal.confirmDelete.title": "Confirmer la suppression",
|
||||
"modal.confirmDelete.warning": "Cette action est irréversible.",
|
||||
"modal.confirmDelete.cancel": "Annuler",
|
||||
"modal.confirmDelete.delete": "Supprimer",
|
||||
"modal.confirm.title": "Confirmer",
|
||||
"modal.confirm.cancel": "Annuler",
|
||||
"modal.confirm.confirm": "Confirmer"
|
||||
}
|
||||
|
||||
@@ -132,6 +132,7 @@
|
||||
"engine.calendarFailed": "Rigenerazione del calendario non riuscita: {error}",
|
||||
"engine.calendarNoProject": "Nessun progetto attivo",
|
||||
"engine.validateTranslationsStarted": "Validazione delle traduzioni...",
|
||||
"engine.validateMediaStarted": "Validazione dei media...",
|
||||
"engine.validateTranslationsComplete": "Validazione traduzioni: {dbIssues} problemi nel database, {fsIssues} problemi nel filesystem",
|
||||
"engine.validateTranslationsFailed": "Validazione delle traduzioni non riuscita: {error}",
|
||||
"engine.fillMissingTranslationsOffline": "Impossibile completare le traduzioni: l'IA non è disponibile in modalità aereo",
|
||||
@@ -143,5 +144,25 @@
|
||||
"engine.generateSiteNoProject": "Nessun progetto attivo",
|
||||
"dialog.selectFolder": "Seleziona cartella",
|
||||
"dialog.importMedia": "Importa media",
|
||||
"dialog.imageFilter": "Immagini"
|
||||
"dialog.imageFilter": "Immagini",
|
||||
"settings.nav.project": "Progetto",
|
||||
"settings.nav.editor": "Editor",
|
||||
"settings.nav.content": "Contenuto",
|
||||
"settings.nav.ai": "IA",
|
||||
"settings.nav.technology": "Tecnologia",
|
||||
"settings.nav.publishing": "Pubblicazione",
|
||||
"settings.nav.data": "Dati",
|
||||
"settings.nav.mcp": "MCP",
|
||||
"settings.nav.style": "Stile",
|
||||
"tags.nav.cloud": "Nuvola di tag",
|
||||
"tags.nav.manage": "Gestisci tag",
|
||||
"tags.nav.merge": "Unisci tag",
|
||||
"sidebar.chatYesterday": "Ieri",
|
||||
"modal.confirmDelete.title": "Conferma eliminazione",
|
||||
"modal.confirmDelete.warning": "Questa azione non può essere annullata.",
|
||||
"modal.confirmDelete.cancel": "Annulla",
|
||||
"modal.confirmDelete.delete": "Elimina",
|
||||
"modal.confirm.title": "Conferma",
|
||||
"modal.confirm.cancel": "Annulla",
|
||||
"modal.confirm.confirm": "Conferma"
|
||||
}
|
||||
|
||||
@@ -224,17 +224,55 @@ entity ChatMessage {
|
||||
created_at: Timestamp
|
||||
}
|
||||
|
||||
entity AiCachedModel {
|
||||
-- Cached model list fetched from endpoint /v1/models API.
|
||||
-- Keyed by endpoint kind so online and airplane caches are separate.
|
||||
endpoint_kind: String -- "online" | "airplane"
|
||||
model_id: String
|
||||
name: String -- Display name
|
||||
context_window: Integer?
|
||||
max_output_tokens: Integer?
|
||||
entity AiProvider {
|
||||
-- Provider catalog, populated from upstream model registry.
|
||||
-- Read-only in Rust core; TypeScript app manages inserts.
|
||||
id: String -- PRIMARY KEY
|
||||
name: String
|
||||
env: String? -- Environment variable for API key
|
||||
npm: String? -- NPM package reference (legacy)
|
||||
api: String? -- Base API URL
|
||||
doc: String? -- Documentation URL
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
entity AiModel {
|
||||
-- Full model catalog with capability metadata.
|
||||
-- Composite primary key: (provider, model_id).
|
||||
provider: AiProvider
|
||||
model_id: String
|
||||
name: String
|
||||
family: String?
|
||||
attachment: Boolean -- supports file attachments
|
||||
reasoning: Boolean -- supports chain-of-thought
|
||||
tool_call: Boolean -- supports tool/function calling
|
||||
structured_output: Boolean
|
||||
temperature: Boolean -- supports temperature parameter
|
||||
knowledge: String? -- training data cutoff
|
||||
release_date: String?
|
||||
last_updated_date: String?
|
||||
open_weights: Boolean
|
||||
input_price: Integer? -- price per million input tokens
|
||||
output_price: Integer? -- price per million output tokens
|
||||
cache_read_price: Integer?
|
||||
cache_write_price: Integer?
|
||||
context_window: Integer
|
||||
max_input_tokens: Integer
|
||||
max_output_tokens: Integer
|
||||
interleaved: String? -- interleaved capability descriptor
|
||||
status: String? -- active | deprecated | preview
|
||||
provider_npm: String? -- provider-specific NPM reference (legacy)
|
||||
updated_at: Timestamp
|
||||
}
|
||||
|
||||
entity AiModelModality {
|
||||
-- Input/output modality declarations per model.
|
||||
provider: AiProvider
|
||||
model_id: String
|
||||
direction: String -- "input" | "output"
|
||||
modality: String -- "text" | "image" | "audio" | "video"
|
||||
}
|
||||
|
||||
entity AiCatalogMeta {
|
||||
key: String -- "{endpoint_kind}_etag" | "{endpoint_kind}_lastFetchedAt"
|
||||
value: String
|
||||
|
||||
Reference in New Issue
Block a user