Enhance RustyCMS: Update .gitignore to include demo assets, improve admin UI dependency management, and add new translations for asset management. Implement asset date filtering and enhance content forms with asset previews. Introduce caching mechanisms for improved performance and add support for draft status in content entries.
This commit is contained in:
@@ -13,9 +13,11 @@
|
||||
//! DELETE /api/assets/*path – delete image (auth required)
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::UNIX_EPOCH;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Multipart, Path as AxumPath, Query, State};
|
||||
use chrono::{DateTime, Utc};
|
||||
use axum::extract::{DefaultBodyLimit, Multipart, Path as AxumPath, Query, State};
|
||||
use axum::http::{header, HeaderMap, Response, StatusCode};
|
||||
use axum::response::IntoResponse;
|
||||
use axum::Json;
|
||||
@@ -131,18 +133,33 @@ async fn read_images(
|
||||
if mime_for_ext(&ext).is_none() {
|
||||
continue;
|
||||
}
|
||||
let size = e.metadata().await.map(|m| m.len()).unwrap_or(0);
|
||||
let meta = e.metadata().await.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let size = meta.len();
|
||||
let mime = mime_for_ext(&ext).unwrap_or("application/octet-stream");
|
||||
let url = match folder {
|
||||
Some(f) => format!("/api/assets/{}/{}", f, fname),
|
||||
None => format!("/api/assets/{}", fname),
|
||||
};
|
||||
let created_at = meta
|
||||
.created()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
|
||||
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
|
||||
let modified_at = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
|
||||
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
|
||||
entries.push(json!({
|
||||
"filename": fname,
|
||||
"folder": folder,
|
||||
"url": url,
|
||||
"mime_type": mime,
|
||||
"size": size,
|
||||
"created_at": created_at,
|
||||
"modified_at": modified_at,
|
||||
}));
|
||||
}
|
||||
Ok(entries)
|
||||
@@ -340,6 +357,13 @@ pub struct UploadParams {
|
||||
folder: Option<String>,
|
||||
}
|
||||
|
||||
/// Max upload size for asset uploads (50 MB).
|
||||
const MAX_UPLOAD_SIZE: usize = 50 * 1024 * 1024;
|
||||
|
||||
pub fn upload_body_limit() -> DefaultBodyLimit {
|
||||
DefaultBodyLimit::max(MAX_UPLOAD_SIZE)
|
||||
}
|
||||
|
||||
pub async fn upload_asset(
|
||||
State(state): State<Arc<AppState>>,
|
||||
headers: HeaderMap,
|
||||
@@ -409,6 +433,19 @@ pub async fn upload_asset(
|
||||
Some(f) => format!("/api/assets/{}/{}", f, filename),
|
||||
None => format!("/api/assets/{}", filename),
|
||||
};
|
||||
let file_meta = fs::metadata(&dest).await.ok();
|
||||
let created_at = file_meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.created().ok())
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
|
||||
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
|
||||
let modified_at = file_meta
|
||||
.as_ref()
|
||||
.and_then(|m| m.modified().ok())
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
|
||||
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
|
||||
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
@@ -418,6 +455,8 @@ pub async fn upload_asset(
|
||||
"url": url,
|
||||
"mime_type": mime,
|
||||
"size": data.len(),
|
||||
"created_at": created_at,
|
||||
"modified_at": modified_at,
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
@@ -504,6 +543,18 @@ pub async fn rename_asset(
|
||||
let meta = fs::metadata(&new_path)
|
||||
.await
|
||||
.map_err(|e| ApiError::Internal(e.to_string()))?;
|
||||
let created_at = meta
|
||||
.created()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
|
||||
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
|
||||
let modified_at = meta
|
||||
.modified()
|
||||
.ok()
|
||||
.and_then(|t| t.duration_since(UNIX_EPOCH).ok())
|
||||
.and_then(|d| DateTime::<Utc>::from_timestamp_secs(d.as_secs() as i64))
|
||||
.map(|dt: DateTime<Utc>| dt.to_rfc3339());
|
||||
Ok((
|
||||
StatusCode::OK,
|
||||
Json(json!({
|
||||
@@ -512,6 +563,8 @@ pub async fn rename_asset(
|
||||
"url": url,
|
||||
"mime_type": mime,
|
||||
"size": meta.len(),
|
||||
"created_at": created_at,
|
||||
"modified_at": modified_at,
|
||||
})),
|
||||
)
|
||||
.into_response())
|
||||
|
||||
@@ -65,6 +65,12 @@ impl ContentCache {
|
||||
let mut guard = self.data.write().await;
|
||||
guard.retain(|k, _| !k.starts_with(&prefix_e) && !k.starts_with(&prefix_l));
|
||||
}
|
||||
|
||||
/// Clears the entire cache (e.g. after schema hot-reload so content is re-read with new schema).
|
||||
pub async fn invalidate_all(&self) {
|
||||
let mut guard = self.data.write().await;
|
||||
guard.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Cache key for a single entry (incl. _resolve and optional _locale).
|
||||
|
||||
@@ -14,7 +14,7 @@ use tokio::sync::RwLock;
|
||||
use crate::schema::types::{SchemaDefinition, VALID_FIELD_TYPES};
|
||||
use crate::schema::validator;
|
||||
use crate::schema::SchemaRegistry;
|
||||
use crate::store::query::QueryParams;
|
||||
use crate::store::query::{QueryParams, StatusFilter, entry_is_draft};
|
||||
use crate::store::ContentStore;
|
||||
use crate::store::slug;
|
||||
|
||||
@@ -368,6 +368,7 @@ pub async fn list_entries(
|
||||
State(state): State<Arc<AppState>>,
|
||||
Path(collection): Path<String>,
|
||||
Query(params): Query<HashMap<String, String>>,
|
||||
headers: HeaderMap,
|
||||
) -> Result<Json<Value>, ApiError> {
|
||||
let registry = state.registry.read().await;
|
||||
let schema = registry
|
||||
@@ -411,7 +412,15 @@ pub async fn list_entries(
|
||||
let entries = state.store.list(&collection, locale_ref).await.map_err(ApiError::from)?;
|
||||
|
||||
let resolve = parse_resolve(list_params.get("_resolve").map(|s| s.as_str()));
|
||||
let query = QueryParams::from_map(list_params);
|
||||
// When API key is required but not sent, show only published entries. Otherwise use _status param.
|
||||
let status_override = if state.api_key.as_ref().is_some()
|
||||
&& auth::token_from_headers(&headers).as_deref() != state.api_key.as_ref().map(String::as_str)
|
||||
{
|
||||
Some(StatusFilter::Published)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let query = QueryParams::from_map_with_status(list_params, status_override);
|
||||
let mut result = query.apply(entries);
|
||||
|
||||
for item in result.items.iter_mut() {
|
||||
@@ -461,29 +470,36 @@ pub async fn get_entry(
|
||||
let resolve_key = params.get("_resolve").map(|s| s.as_str()).unwrap_or("");
|
||||
let cache_key = cache::entry_cache_key(&collection, &slug, resolve_key, locale_ref);
|
||||
if let Some(ref cached) = state.cache.get(&cache_key).await {
|
||||
let json_str = serde_json::to_string(cached).unwrap_or_default();
|
||||
let mut hasher = DefaultHasher::new();
|
||||
json_str.hash(&mut hasher);
|
||||
let etag_plain = format!("\"{:016x}\"", hasher.finish());
|
||||
let etag_header =
|
||||
HeaderValue::from_str(&etag_plain).unwrap_or_else(|_| HeaderValue::from_static("\"0\""));
|
||||
let if_none_match = headers
|
||||
.get("if-none-match")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.trim().trim_matches('"').to_string());
|
||||
if if_none_match.as_deref() == Some(etag_plain.trim_matches('"')) {
|
||||
// Don't serve cached draft to unauthenticated requests.
|
||||
let is_authenticated = state.api_key.as_ref().is_none()
|
||||
|| auth::token_from_headers(&headers).as_deref() == state.api_key.as_ref().map(String::as_str);
|
||||
if !is_authenticated && entry_is_draft(cached) {
|
||||
// Fall through to load from store (will 404 if draft).
|
||||
} else {
|
||||
let json_str = serde_json::to_string(cached).unwrap_or_default();
|
||||
let mut hasher = DefaultHasher::new();
|
||||
json_str.hash(&mut hasher);
|
||||
let etag_plain = format!("\"{:016x}\"", hasher.finish());
|
||||
let etag_header =
|
||||
HeaderValue::from_str(&etag_plain).unwrap_or_else(|_| HeaderValue::from_static("\"0\""));
|
||||
let if_none_match = headers
|
||||
.get("if-none-match")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.map(|s| s.trim().trim_matches('"').to_string());
|
||||
if if_none_match.as_deref() == Some(etag_plain.trim_matches('"')) {
|
||||
return Ok((
|
||||
StatusCode::NOT_MODIFIED,
|
||||
[("ETag", etag_header.clone())],
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
return Ok((
|
||||
StatusCode::NOT_MODIFIED,
|
||||
[("ETag", etag_header.clone())],
|
||||
StatusCode::OK,
|
||||
[("ETag", etag_header)],
|
||||
Json(cached.clone()),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
return Ok((
|
||||
StatusCode::OK,
|
||||
[("ETag", etag_header)],
|
||||
Json(cached.clone()),
|
||||
)
|
||||
.into_response());
|
||||
}
|
||||
|
||||
let entry = state
|
||||
@@ -495,6 +511,17 @@ pub async fn get_entry(
|
||||
ApiError::NotFound(format!("Entry '{}' not found in '{}'", slug, collection))
|
||||
})?;
|
||||
|
||||
// When no API key is sent, hide draft entries (return 404).
|
||||
if state.api_key.as_ref().is_some()
|
||||
&& auth::token_from_headers(&headers).as_deref() != state.api_key.as_ref().map(String::as_str)
|
||||
&& entry_is_draft(&entry)
|
||||
{
|
||||
return Err(ApiError::NotFound(format!(
|
||||
"Entry '{}' not found in '{}'",
|
||||
slug, collection
|
||||
)));
|
||||
}
|
||||
|
||||
let resolve = parse_resolve(params.get("_resolve").map(|s| s.as_str()));
|
||||
let mut formatted = format_references(
|
||||
entry,
|
||||
@@ -509,10 +536,10 @@ pub async fn get_entry(
|
||||
expand_asset_urls(&mut formatted, &state.base_url);
|
||||
}
|
||||
|
||||
state
|
||||
.cache
|
||||
.set(cache_key, formatted.clone())
|
||||
.await;
|
||||
// Only cache published entries so unauthenticated requests never see cached drafts.
|
||||
if !entry_is_draft(&formatted) {
|
||||
state.cache.set(cache_key, formatted.clone()).await;
|
||||
}
|
||||
|
||||
let json_str = serde_json::to_string(&formatted).unwrap_or_default();
|
||||
let mut hasher = DefaultHasher::new();
|
||||
@@ -581,9 +608,19 @@ pub async fn create_entry(
|
||||
slug::validate_slug(&slug_raw).map_err(ApiError::BadRequest)?;
|
||||
let slug = slug::normalize_slug(&slug_raw);
|
||||
|
||||
// Remove _slug from content data
|
||||
// Remove _slug from content data; validate and default _status (publish/draft)
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.remove("_slug");
|
||||
if let Some(v) = obj.get("_status") {
|
||||
let s = v.as_str().unwrap_or("");
|
||||
if s != "draft" && s != "published" {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Field '_status' must be \"draft\" or \"published\"".to_string(),
|
||||
));
|
||||
}
|
||||
} else {
|
||||
obj.insert("_status".to_string(), Value::String("published".to_string()));
|
||||
}
|
||||
}
|
||||
|
||||
// Apply defaults and auto-generated values
|
||||
@@ -639,7 +676,7 @@ pub async fn create_entry(
|
||||
.await
|
||||
.map_err(ApiError::from)?
|
||||
.unwrap();
|
||||
let mut formatted = format_references(
|
||||
let formatted = format_references(
|
||||
entry,
|
||||
&schema,
|
||||
state.store.as_ref(),
|
||||
@@ -682,9 +719,17 @@ pub async fn update_entry(
|
||||
)));
|
||||
}
|
||||
|
||||
// Remove _slug if present in body
|
||||
// Remove _slug if present; validate _status when present
|
||||
if let Some(obj) = body.as_object_mut() {
|
||||
obj.remove("_slug");
|
||||
if let Some(v) = obj.get("_status") {
|
||||
let s = v.as_str().unwrap_or("");
|
||||
if s != "draft" && s != "published" {
|
||||
return Err(ApiError::BadRequest(
|
||||
"Field '_status' must be \"draft\" or \"published\"".to_string(),
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Normalize reference arrays: objects with _slug → slug strings (so clients can send resolved refs)
|
||||
@@ -754,7 +799,7 @@ pub async fn update_entry(
|
||||
.await
|
||||
.map_err(ApiError::from)?
|
||||
.unwrap();
|
||||
let mut formatted = format_references(
|
||||
let formatted = format_references(
|
||||
entry,
|
||||
&schema,
|
||||
state.store.as_ref(),
|
||||
|
||||
@@ -50,7 +50,12 @@ pub fn create_router(state: Arc<AppState>) -> Router {
|
||||
// Image transformation (external URL → transformed image)
|
||||
.route("/api/transform", get(transform::transform_image))
|
||||
// Asset management (images in content/assets/ with folder support)
|
||||
.route("/api/assets", get(assets::list_assets).post(assets::upload_asset))
|
||||
.route(
|
||||
"/api/assets",
|
||||
get(assets::list_assets)
|
||||
.post(assets::upload_asset)
|
||||
.layer(assets::upload_body_limit()),
|
||||
)
|
||||
.route(
|
||||
"/api/assets/folders",
|
||||
get(assets::list_folders).post(assets::create_folder),
|
||||
|
||||
47
src/main.rs
47
src/main.rs
@@ -11,6 +11,7 @@ use tower_http::trace::{DefaultOnResponse, TraceLayer};
|
||||
use tracing::{info_span, Level};
|
||||
use tracing_subscriber::EnvFilter;
|
||||
|
||||
use rustycms::api::cache::ContentCache;
|
||||
use rustycms::api::handlers::AppState;
|
||||
use rustycms::schema::SchemaRegistry;
|
||||
use rustycms::store::{filesystem::FileStore, sqlite::SqliteStore, ContentStore};
|
||||
@@ -35,16 +36,50 @@ struct Cli {
|
||||
host: String,
|
||||
}
|
||||
|
||||
/// Auto-detect locale subdirectories in the content dir.
|
||||
/// Matches 2-3 letter directory names (e.g. "de", "en", "fra") that contain at
|
||||
/// least one subdirectory themselves, ignoring known non-locale dirs like "assets".
|
||||
fn detect_locales(content_dir: &std::path::Path) -> Option<Vec<String>> {
|
||||
let entries = std::fs::read_dir(content_dir).ok()?;
|
||||
let mut locales: Vec<String> = entries
|
||||
.filter_map(|e| e.ok())
|
||||
.filter(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false))
|
||||
.filter_map(|e| {
|
||||
let name = e.file_name().to_string_lossy().to_string();
|
||||
if name == "assets" || name.starts_with('.') {
|
||||
return None;
|
||||
}
|
||||
let is_locale = (2..=3).contains(&name.len()) && name.chars().all(|c| c.is_ascii_lowercase());
|
||||
if !is_locale {
|
||||
return None;
|
||||
}
|
||||
let has_subdirs = std::fs::read_dir(e.path())
|
||||
.ok()
|
||||
.map(|rd| rd.filter_map(|e| e.ok()).any(|e| e.file_type().map(|ft| ft.is_dir()).unwrap_or(false)))
|
||||
.unwrap_or(false);
|
||||
if has_subdirs { Some(name) } else { None }
|
||||
})
|
||||
.collect();
|
||||
if locales.is_empty() {
|
||||
return None;
|
||||
}
|
||||
locales.sort();
|
||||
tracing::info!("Auto-detected locales from content directory: {:?}", locales);
|
||||
Some(locales)
|
||||
}
|
||||
|
||||
fn reload_schemas(
|
||||
types_dir: &PathBuf,
|
||||
server_url: &str,
|
||||
registry: &Arc<RwLock<SchemaRegistry>>,
|
||||
openapi_spec: &Arc<RwLock<serde_json::Value>>,
|
||||
cache: &Arc<ContentCache>,
|
||||
) {
|
||||
let types_dir = types_dir.clone();
|
||||
let server_url = server_url.to_string();
|
||||
let registry = Arc::clone(registry);
|
||||
let openapi_spec = Arc::clone(openapi_spec);
|
||||
let cache = Arc::clone(cache);
|
||||
std::thread::spawn(move || {
|
||||
let rt = tokio::runtime::Handle::current();
|
||||
rt.block_on(async move {
|
||||
@@ -53,7 +88,8 @@ fn reload_schemas(
|
||||
let spec = rustycms::api::openapi::generate_spec(&new_registry, &server_url);
|
||||
*registry.write().await = new_registry;
|
||||
*openapi_spec.write().await = spec;
|
||||
tracing::info!("Hot-reload: schemas and OpenAPI spec updated");
|
||||
cache.invalidate_all().await;
|
||||
tracing::info!("Hot-reload: schemas and OpenAPI spec updated, content cache cleared");
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!("Hot-reload failed: {}", e);
|
||||
@@ -129,7 +165,8 @@ async fn main() -> anyhow::Result<()> {
|
||||
let locales: Option<Vec<String>> = std::env::var("RUSTYCMS_LOCALES")
|
||||
.ok()
|
||||
.map(|s| s.split(',').map(|l| l.trim().to_string()).filter(|l| !l.is_empty()).collect())
|
||||
.filter(|v: &Vec<String>| !v.is_empty());
|
||||
.filter(|v: &Vec<String>| !v.is_empty())
|
||||
.or_else(|| detect_locales(&cli.content_dir));
|
||||
if let Some(ref locs) = locales {
|
||||
tracing::info!("Multilingual: locales {:?} (default: {})", locs, &locs[0]);
|
||||
}
|
||||
@@ -147,7 +184,7 @@ async fn main() -> anyhow::Result<()> {
|
||||
openapi_spec: Arc::clone(&openapi_spec),
|
||||
types_dir: cli.types_dir.clone(),
|
||||
api_key,
|
||||
cache,
|
||||
cache: Arc::clone(&cache),
|
||||
transform_cache,
|
||||
http_client,
|
||||
locales,
|
||||
@@ -187,9 +224,9 @@ async fn main() -> anyhow::Result<()> {
|
||||
let _watcher = watcher;
|
||||
while rx.recv().is_ok() {
|
||||
// Debounce: wait for editor to finish writing, drain extra events, then reload once
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
std::thread::sleep(Duration::from_millis(800));
|
||||
while rx.try_recv().is_ok() {}
|
||||
reload_schemas(&types_dir_watch, &server_url_watch, ®istry, &openapi_spec);
|
||||
reload_schemas(&types_dir_watch, &server_url_watch, ®istry, &openapi_spec, &cache);
|
||||
}
|
||||
});
|
||||
tracing::info!("Hot-reload: watching {}", cli.types_dir.display());
|
||||
|
||||
@@ -18,10 +18,18 @@ pub enum FilterOp {
|
||||
Max(String),
|
||||
}
|
||||
|
||||
/// Publish/draft filter for list: only published, only draft, or all.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StatusFilter {
|
||||
Published,
|
||||
Draft,
|
||||
All,
|
||||
}
|
||||
|
||||
/// Parsed query parameters for list endpoints.
|
||||
///
|
||||
/// Special parameters (prefixed with `_`):
|
||||
/// - `_sort`, `_order`, `_page`, `_per_page`
|
||||
/// - `_sort`, `_order`, `_page`, `_per_page`, `_status` (draft | published | all)
|
||||
///
|
||||
/// Field filters support suffixes: `field`, `field_prefix`, `field_contains`, `field_min`, `field_max`.
|
||||
pub struct QueryParams {
|
||||
@@ -29,20 +37,38 @@ pub struct QueryParams {
|
||||
pub order: Option<String>,
|
||||
pub page: Option<usize>,
|
||||
pub per_page: Option<usize>,
|
||||
/// Publish/draft filter from _status param.
|
||||
pub status_filter: StatusFilter,
|
||||
/// (field_name, filter_op)
|
||||
pub filters: Vec<(String, FilterOp)>,
|
||||
/// Full-text search: case-insensitive contains across all string values (_q param).
|
||||
pub full_text_query: Option<String>,
|
||||
}
|
||||
|
||||
/// Entry is draft if _status == "draft". Missing or "published" = published.
|
||||
pub fn entry_is_draft(value: &Value) -> bool {
|
||||
value.get("_status").and_then(|v| v.as_str()) == Some("draft")
|
||||
}
|
||||
|
||||
impl QueryParams {
|
||||
/// Parse query parameters. Extracts system params and builds list of (field, FilterOp).
|
||||
pub fn from_map(mut map: HashMap<String, String>) -> Self {
|
||||
/// Pass `status_override` to force status filter (e.g. Published when unauthenticated).
|
||||
pub fn from_map_with_status(
|
||||
mut map: HashMap<String, String>,
|
||||
status_override: Option<StatusFilter>,
|
||||
) -> Self {
|
||||
let sort = map.remove("_sort");
|
||||
let order = map.remove("_order");
|
||||
let page = map.remove("_page").and_then(|v| v.parse().ok());
|
||||
let per_page = map.remove("_per_page").and_then(|v| v.parse().ok());
|
||||
let full_text_query = map.remove("_q").filter(|s| !s.trim().is_empty());
|
||||
let status_filter = status_override.unwrap_or_else(|| {
|
||||
match map.remove("_status").as_deref().map(str::trim) {
|
||||
Some("draft") => StatusFilter::Draft,
|
||||
Some("published") => StatusFilter::Published,
|
||||
_ => StatusFilter::All,
|
||||
}
|
||||
});
|
||||
map.retain(|k, _| !k.starts_with('_'));
|
||||
|
||||
let mut filters = Vec::new();
|
||||
@@ -70,11 +96,17 @@ impl QueryParams {
|
||||
order,
|
||||
page,
|
||||
per_page,
|
||||
status_filter,
|
||||
filters,
|
||||
full_text_query,
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse without status override (uses _status from params or All).
|
||||
pub fn from_map(map: HashMap<String, String>) -> Self {
|
||||
Self::from_map_with_status(map, None)
|
||||
}
|
||||
|
||||
/// Apply filters, sorting, and pagination to a list of entries.
|
||||
pub fn apply(&self, mut entries: Vec<(String, Value)>) -> QueryResult {
|
||||
entries.retain(|(_, value)| self.matches_filters(value));
|
||||
@@ -125,6 +157,11 @@ impl QueryParams {
|
||||
}
|
||||
|
||||
fn matches_filters(&self, value: &Value) -> bool {
|
||||
match self.status_filter {
|
||||
StatusFilter::Published if entry_is_draft(value) => return false,
|
||||
StatusFilter::Draft if !entry_is_draft(value) => return false,
|
||||
_ => {}
|
||||
}
|
||||
if let Some(ref q) = self.full_text_query {
|
||||
if !value_contains_text(value, q) {
|
||||
return false;
|
||||
|
||||
Reference in New Issue
Block a user