1use super::schema::{ThemeTemplateRevisions, ThemeTemplates, Themes};
8use super::{valid_code, valid_path, Kind, Revision, Template, Theme, ThemeConfig, ThemeError};
9use laterite_core::query::{bind_values, build, insert_returning_id};
10use laterite_core::{AnyRowExt, Db};
11use sea_query::{Expr, Order, Query};
12use sqlx::any::AnyRow;
13
14fn now() -> String {
15 chrono::Utc::now().to_rfc3339()
16}
17
18fn theme_from_row(row: &AnyRow) -> Result<Theme, ThemeError> {
19 let config_source = row.get_text("config")?;
20 let settings = row
21 .get_text_opt("settings")?
22 .and_then(|text| serde_json::from_str(&text).ok())
23 .filter(serde_json::Value::is_object)
24 .unwrap_or_else(|| serde_json::json!({}));
25 Ok(Theme {
26 id: row.get_int("id")?,
27 code: row.get_text("code")?,
28 parent: row.get_text_opt("parent")?,
29 locked: row.get_bool("locked")?,
30 config: ThemeConfig::parse(&config_source)?,
31 config_source,
32 settings,
33 created_at: row.get_text("created_at")?,
34 updated_at: row.get_text("updated_at")?,
35 })
36}
37
38fn template_from_row(row: &AnyRow) -> Result<Template, ThemeError> {
39 let settings = row.get_text("settings")?;
40 Ok(Template {
41 id: row.get_int("id")?,
42 theme_id: row.get_int("theme_id")?,
43 kind: row.get_text("kind")?.parse()?,
44 path: row.get_text("path")?,
45 settings: serde_json::from_str(&settings).unwrap_or(serde_json::Value::Null),
46 markup: row.get_text("markup")?,
47 created_at: row.get_text("created_at")?,
48 updated_at: row.get_text("updated_at")?,
49 })
50}
51
52fn revision_from_row(row: &AnyRow) -> Result<Revision, ThemeError> {
53 let settings = row.get_text("settings")?;
54 Ok(Revision {
55 id: row.get_int("id")?,
56 template_id: row.get_int("template_id")?,
57 settings: serde_json::from_str(&settings).unwrap_or(serde_json::Value::Null),
58 markup: row.get_text("markup")?,
59 saved_at: row.get_text("saved_at")?,
60 saved_by: row.get_int_opt("saved_by")?,
61 })
62}
63
64const THEME_COLUMNS: [Themes; 8] = [
65 Themes::Id,
66 Themes::Code,
67 Themes::Parent,
68 Themes::Locked,
69 Themes::Config,
70 Themes::Settings,
71 Themes::CreatedAt,
72 Themes::UpdatedAt,
73];
74
75const TEMPLATE_COLUMNS: [ThemeTemplates; 8] = [
76 ThemeTemplates::Id,
77 ThemeTemplates::ThemeId,
78 ThemeTemplates::Kind,
79 ThemeTemplates::Path,
80 ThemeTemplates::Settings,
81 ThemeTemplates::Markup,
82 ThemeTemplates::CreatedAt,
83 ThemeTemplates::UpdatedAt,
84];
85
86pub async fn list_themes(db: &Db) -> Result<Vec<Theme>, ThemeError> {
88 let (sql, values) = build(
89 db.backend,
90 Query::select()
91 .columns(THEME_COLUMNS)
92 .from(Themes::Table)
93 .order_by(Themes::Code, Order::Asc)
94 .to_owned(),
95 );
96 let rows = bind_values(sqlx::query(&sql), values)
97 .fetch_all(&db.pool)
98 .await?;
99 rows.iter().map(theme_from_row).collect()
100}
101
102pub async fn find_theme(db: &Db, code: &str) -> Result<Option<Theme>, ThemeError> {
104 let (sql, values) = build(
105 db.backend,
106 Query::select()
107 .columns(THEME_COLUMNS)
108 .from(Themes::Table)
109 .and_where(Expr::col(Themes::Code).eq(code))
110 .to_owned(),
111 );
112 let row = bind_values(sqlx::query(&sql), values)
113 .fetch_optional(&db.pool)
114 .await?;
115 row.as_ref().map(theme_from_row).transpose()
116}
117
118async fn require_theme(db: &Db, code: &str) -> Result<Theme, ThemeError> {
119 find_theme(db, code)
120 .await?
121 .ok_or_else(|| ThemeError::NotFound(code.to_string()))
122}
123
124async fn check_parent(db: &Db, code: &str, parent: Option<&str>) -> Result<(), ThemeError> {
126 let Some(parent) = parent else {
127 return Ok(());
128 };
129 let mut next = Some(parent.to_string());
130 let mut hops = 0;
131 while let Some(current) = next {
132 if current == code {
133 return Err(ThemeError::Parent(format!(
134 "the parent {parent:?} leads back to {code:?}"
135 )));
136 }
137 hops += 1;
138 if hops > 32 {
139 return Err(ThemeError::Parent(format!(
140 "the parent chain from {parent:?} is too long"
141 )));
142 }
143 let theme = find_theme(db, ¤t)
144 .await?
145 .ok_or_else(|| ThemeError::Parent(format!("no parent theme {current:?}")))?;
146 next = theme.parent;
147 }
148 Ok(())
149}
150
151pub async fn create_theme(db: &Db, code: &str, config_yaml: &str) -> Result<Theme, ThemeError> {
153 if !valid_code(code) {
154 return Err(ThemeError::Code(code.to_string()));
155 }
156 let config = ThemeConfig::parse(config_yaml)?;
157 if find_theme(db, code).await?.is_some() {
158 return Err(ThemeError::Exists(code.to_string()));
159 }
160 check_parent(db, code, config.parent.as_deref()).await?;
161 let now = now();
162 let insert = Query::insert()
163 .into_table(Themes::Table)
164 .columns([
165 Themes::Code,
166 Themes::Parent,
167 Themes::Locked,
168 Themes::Config,
169 Themes::CreatedAt,
170 Themes::UpdatedAt,
171 ])
172 .values_panic([
173 code.into(),
174 config.parent.clone().into(),
175 false.into(),
176 config_yaml.into(),
177 now.clone().into(),
178 now.into(),
179 ])
180 .to_owned();
181 insert_returning_id(db, insert, Themes::Id).await?;
182 require_theme(db, code).await
183}
184
185pub async fn update_theme_config(
187 db: &Db,
188 code: &str,
189 config_yaml: &str,
190) -> Result<Theme, ThemeError> {
191 let theme = require_theme(db, code).await?;
192 if theme.locked {
193 return Err(ThemeError::Locked(code.to_string()));
194 }
195 let config = ThemeConfig::parse(config_yaml)?;
196 check_parent(db, code, config.parent.as_deref()).await?;
197 let (sql, values) = build(
198 db.backend,
199 Query::update()
200 .table(Themes::Table)
201 .value(Themes::Config, config_yaml)
202 .value(Themes::Parent, config.parent.clone())
203 .value(Themes::UpdatedAt, now())
204 .and_where(Expr::col(Themes::Id).eq(theme.id))
205 .to_owned(),
206 );
207 bind_values(sqlx::query(&sql), values)
208 .execute(&db.pool)
209 .await?;
210 require_theme(db, code).await
211}
212
213pub async fn save_settings(
215 db: &Db,
216 code: &str,
217 settings: &serde_json::Value,
218) -> Result<(), ThemeError> {
219 if !settings.is_object() {
220 return Err(ThemeError::Config(
221 "the settings must be a JSON object".into(),
222 ));
223 }
224 let theme = require_theme(db, code).await?;
225 if theme.locked {
226 return Err(ThemeError::Locked(code.to_string()));
227 }
228 let (sql, values) = build(
229 db.backend,
230 Query::update()
231 .table(Themes::Table)
232 .value(Themes::Settings, settings.to_string())
233 .value(Themes::UpdatedAt, now())
234 .and_where(Expr::col(Themes::Id).eq(theme.id))
235 .to_owned(),
236 );
237 bind_values(sqlx::query(&sql), values)
238 .execute(&db.pool)
239 .await?;
240 Ok(())
241}
242
243pub fn form_fields(chain: &[Theme]) -> Vec<laterite_admin::form::FormField> {
246 chain
247 .iter()
248 .find_map(|t| t.config.form.as_ref())
249 .map(|f| f.fields.clone())
250 .unwrap_or_default()
251}
252
253pub async fn set_locked(db: &Db, code: &str, locked: bool) -> Result<(), ThemeError> {
255 let theme = require_theme(db, code).await?;
256 let (sql, values) = build(
257 db.backend,
258 Query::update()
259 .table(Themes::Table)
260 .value(Themes::Locked, locked)
261 .value(Themes::UpdatedAt, now())
262 .and_where(Expr::col(Themes::Id).eq(theme.id))
263 .to_owned(),
264 );
265 bind_values(sqlx::query(&sql), values)
266 .execute(&db.pool)
267 .await?;
268 Ok(())
269}
270
271pub async fn children(db: &Db, code: &str) -> Result<Vec<String>, ThemeError> {
273 let (sql, values) = build(
274 db.backend,
275 Query::select()
276 .column(Themes::Code)
277 .from(Themes::Table)
278 .and_where(Expr::col(Themes::Parent).eq(code))
279 .order_by(Themes::Code, Order::Asc)
280 .to_owned(),
281 );
282 let rows = bind_values(sqlx::query(&sql), values)
283 .fetch_all(&db.pool)
284 .await?;
285 rows.iter().map(|r| Ok(r.get_text("code")?)).collect()
286}
287
288pub async fn delete_theme(db: &Db, code: &str) -> Result<(), ThemeError> {
291 let theme = require_theme(db, code).await?;
292 if theme.locked {
293 return Err(ThemeError::Locked(code.to_string()));
294 }
295 let settings: super::ThemeSettings = laterite_admin::settings::load(db).await?;
296 if settings.active.as_deref() == Some(code) {
297 return Err(ThemeError::Active(code.to_string()));
298 }
299 let dependants = children(db, code).await?;
300 if !dependants.is_empty() {
301 return Err(ThemeError::Parent(format!(
302 "{code:?} is the parent of {}",
303 dependants.join(", ")
304 )));
305 }
306 let (sql, values) = build(
307 db.backend,
308 Query::delete()
309 .from_table(Themes::Table)
310 .and_where(Expr::col(Themes::Id).eq(theme.id))
311 .to_owned(),
312 );
313 bind_values(sqlx::query(&sql), values)
314 .execute(&db.pool)
315 .await?;
316 Ok(())
317}
318
319pub async fn duplicate_theme(db: &Db, code: &str, new_code: &str) -> Result<Theme, ThemeError> {
322 let source = require_theme(db, code).await?;
323 let copy = create_theme(db, new_code, &source.config_source).await?;
324 for t in list_templates(db, source.id, None).await? {
325 save_template(
326 db,
327 copy.id,
328 t.kind,
329 &t.path,
330 Some(t.settings),
331 &t.markup,
332 None,
333 )
334 .await?;
335 }
336 Ok(copy)
337}
338
339pub async fn create_child_theme(
341 db: &Db,
342 code: &str,
343 new_code: &str,
344 name: &str,
345) -> Result<Theme, ThemeError> {
346 let parent = require_theme(db, code).await?;
347 let config = ThemeConfig {
348 name: name.to_string(),
349 author: parent.config.author.clone(),
350 homepage: parent.config.homepage.clone(),
351 description: String::new(),
352 preview: None,
353 parent: Some(code.to_string()),
354 require: parent.config.require.clone(),
355 form: None,
356 };
357 create_theme(db, new_code, &config.to_yaml()?).await
358}
359
360pub async fn list_templates(
362 db: &Db,
363 theme_id: i64,
364 kind: Option<Kind>,
365) -> Result<Vec<Template>, ThemeError> {
366 let (sql, values) = {
369 let mut query = Query::select()
370 .columns(TEMPLATE_COLUMNS)
371 .from(ThemeTemplates::Table)
372 .and_where(Expr::col(ThemeTemplates::ThemeId).eq(theme_id))
373 .order_by(ThemeTemplates::Kind, Order::Asc)
374 .order_by(ThemeTemplates::Path, Order::Asc)
375 .to_owned();
376 if let Some(kind) = kind {
377 query.and_where(Expr::col(ThemeTemplates::Kind).eq(kind.as_str()));
378 }
379 build(db.backend, query)
380 };
381 let rows = bind_values(sqlx::query(&sql), values)
382 .fetch_all(&db.pool)
383 .await?;
384 rows.iter().map(template_from_row).collect()
385}
386
387pub async fn find_template(
389 db: &Db,
390 theme_id: i64,
391 kind: Kind,
392 path: &str,
393) -> Result<Option<Template>, ThemeError> {
394 let (sql, values) = build(
395 db.backend,
396 Query::select()
397 .columns(TEMPLATE_COLUMNS)
398 .from(ThemeTemplates::Table)
399 .and_where(Expr::col(ThemeTemplates::ThemeId).eq(theme_id))
400 .and_where(Expr::col(ThemeTemplates::Kind).eq(kind.as_str()))
401 .and_where(Expr::col(ThemeTemplates::Path).eq(path))
402 .to_owned(),
403 );
404 let row = bind_values(sqlx::query(&sql), values)
405 .fetch_optional(&db.pool)
406 .await?;
407 row.as_ref().map(template_from_row).transpose()
408}
409
410pub async fn template(db: &Db, id: i64) -> Result<Option<Template>, ThemeError> {
412 let (sql, values) = build(
413 db.backend,
414 Query::select()
415 .columns(TEMPLATE_COLUMNS)
416 .from(ThemeTemplates::Table)
417 .and_where(Expr::col(ThemeTemplates::Id).eq(id))
418 .to_owned(),
419 );
420 let row = bind_values(sqlx::query(&sql), values)
421 .fetch_optional(&db.pool)
422 .await?;
423 row.as_ref().map(template_from_row).transpose()
424}
425
426pub async fn resolve_template(
429 db: &Db,
430 code: &str,
431 kind: Kind,
432 path: &str,
433) -> Result<Option<(Theme, Template)>, ThemeError> {
434 let mut theme = require_theme(db, code).await?;
435 let mut hops = 0;
436 loop {
437 if let Some(t) = find_template(db, theme.id, kind, path).await? {
438 return Ok(Some((theme, t)));
439 }
440 let Some(parent) = theme.parent.clone() else {
441 return Ok(None);
442 };
443 hops += 1;
444 if hops > 32 {
445 return Ok(None);
446 }
447 theme = match find_theme(db, &parent).await? {
448 Some(p) => p,
449 None => return Ok(None),
450 };
451 }
452}
453
454pub async fn chain(db: &Db, code: &str) -> Result<Vec<Theme>, ThemeError> {
457 let mut out = vec![require_theme(db, code).await?];
458 while let Some(parent) = out.last().and_then(|t| t.parent.clone()) {
459 if out.len() > 32 {
460 break;
461 }
462 match find_theme(db, &parent).await? {
463 Some(p) => out.push(p),
464 None => break,
465 }
466 }
467 Ok(out)
468}
469
470async fn theme_by_id(db: &Db, id: i64) -> Result<Theme, ThemeError> {
471 let (sql, values) = build(
472 db.backend,
473 Query::select()
474 .columns(THEME_COLUMNS)
475 .from(Themes::Table)
476 .and_where(Expr::col(Themes::Id).eq(id))
477 .to_owned(),
478 );
479 let row = bind_values(sqlx::query(&sql), values)
480 .fetch_optional(&db.pool)
481 .await?;
482 match row {
483 Some(r) => theme_from_row(&r),
484 None => Err(ThemeError::NotFound(id.to_string())),
485 }
486}
487
488fn touch_theme(
489 db: &Db,
490 id: i64,
491) -> impl std::future::Future<Output = Result<(), sqlx::Error>> + Send + '_ {
492 let (sql, values) = build(
493 db.backend,
494 Query::update()
495 .table(Themes::Table)
496 .value(Themes::UpdatedAt, now())
497 .and_where(Expr::col(Themes::Id).eq(id))
498 .to_owned(),
499 );
500 async move {
501 bind_values(sqlx::query(&sql), values)
502 .execute(&db.pool)
503 .await?;
504 Ok(())
505 }
506}
507
508pub async fn save_template(
513 db: &Db,
514 theme_id: i64,
515 kind: Kind,
516 path: &str,
517 settings: Option<serde_json::Value>,
518 markup: &str,
519 saved_by: Option<i64>,
520) -> Result<Template, ThemeError> {
521 if !valid_path(path) {
522 return Err(ThemeError::Path(path.to_string()));
523 }
524 let theme = theme_by_id(db, theme_id).await?;
525 if theme.locked {
526 return Err(ThemeError::Locked(theme.code));
527 }
528 let now = now();
529 let id = match find_template(db, theme_id, kind, path).await? {
530 Some(current) => {
531 record_revision(db, ¤t, &now, saved_by).await?;
532 let settings = settings.unwrap_or(current.settings);
533 let (sql, values) = build(
534 db.backend,
535 Query::update()
536 .table(ThemeTemplates::Table)
537 .value(ThemeTemplates::Settings, settings.to_string())
538 .value(ThemeTemplates::Markup, markup)
539 .value(ThemeTemplates::UpdatedAt, now.clone())
540 .and_where(Expr::col(ThemeTemplates::Id).eq(current.id))
541 .to_owned(),
542 );
543 bind_values(sqlx::query(&sql), values)
544 .execute(&db.pool)
545 .await?;
546 current.id
547 }
548 None => {
549 let settings = settings.unwrap_or_else(|| serde_json::json!({}));
550 let insert = Query::insert()
551 .into_table(ThemeTemplates::Table)
552 .columns([
553 ThemeTemplates::ThemeId,
554 ThemeTemplates::Kind,
555 ThemeTemplates::Path,
556 ThemeTemplates::Settings,
557 ThemeTemplates::Markup,
558 ThemeTemplates::CreatedAt,
559 ThemeTemplates::UpdatedAt,
560 ])
561 .values_panic([
562 theme_id.into(),
563 kind.as_str().into(),
564 path.into(),
565 settings.to_string().into(),
566 markup.into(),
567 now.clone().into(),
568 now.clone().into(),
569 ])
570 .to_owned();
571 insert_returning_id(db, insert, ThemeTemplates::Id).await?
572 }
573 };
574 touch_theme(db, theme_id).await?;
575 template(db, id)
576 .await?
577 .ok_or_else(|| ThemeError::TemplateNotFound(path.to_string()))
578}
579
580async fn record_revision(
581 db: &Db,
582 current: &Template,
583 saved_at: &str,
584 saved_by: Option<i64>,
585) -> Result<(), ThemeError> {
586 let insert = Query::insert()
587 .into_table(ThemeTemplateRevisions::Table)
588 .columns([
589 ThemeTemplateRevisions::TemplateId,
590 ThemeTemplateRevisions::Settings,
591 ThemeTemplateRevisions::Markup,
592 ThemeTemplateRevisions::SavedAt,
593 ThemeTemplateRevisions::SavedBy,
594 ])
595 .values_panic([
596 current.id.into(),
597 current.settings.to_string().into(),
598 current.markup.clone().into(),
599 saved_at.into(),
600 saved_by.into(),
601 ])
602 .to_owned();
603 insert_returning_id(db, insert, ThemeTemplateRevisions::Id).await?;
604 Ok(())
605}
606
607pub async fn delete_template(db: &Db, id: i64) -> Result<(), ThemeError> {
609 let Some(current) = template(db, id).await? else {
610 return Err(ThemeError::TemplateNotFound(id.to_string()));
611 };
612 let theme = theme_by_id(db, current.theme_id).await?;
613 if theme.locked {
614 return Err(ThemeError::Locked(theme.code));
615 }
616 let (sql, values) = build(
617 db.backend,
618 Query::delete()
619 .from_table(ThemeTemplates::Table)
620 .and_where(Expr::col(ThemeTemplates::Id).eq(id))
621 .to_owned(),
622 );
623 bind_values(sqlx::query(&sql), values)
624 .execute(&db.pool)
625 .await?;
626 touch_theme(db, theme.id).await?;
627 Ok(())
628}
629
630pub async fn revisions(db: &Db, template_id: i64) -> Result<Vec<Revision>, ThemeError> {
632 let (sql, values) = build(
633 db.backend,
634 Query::select()
635 .columns([
636 ThemeTemplateRevisions::Id,
637 ThemeTemplateRevisions::TemplateId,
638 ThemeTemplateRevisions::Settings,
639 ThemeTemplateRevisions::Markup,
640 ThemeTemplateRevisions::SavedAt,
641 ThemeTemplateRevisions::SavedBy,
642 ])
643 .from(ThemeTemplateRevisions::Table)
644 .and_where(Expr::col(ThemeTemplateRevisions::TemplateId).eq(template_id))
645 .order_by(ThemeTemplateRevisions::Id, Order::Desc)
646 .to_owned(),
647 );
648 let rows = bind_values(sqlx::query(&sql), values)
649 .fetch_all(&db.pool)
650 .await?;
651 rows.iter().map(revision_from_row).collect()
652}
653
654pub async fn restore_revision(
657 db: &Db,
658 revision_id: i64,
659 saved_by: Option<i64>,
660) -> Result<Template, ThemeError> {
661 let (sql, values) = build(
662 db.backend,
663 Query::select()
664 .columns([
665 ThemeTemplateRevisions::Id,
666 ThemeTemplateRevisions::TemplateId,
667 ThemeTemplateRevisions::Settings,
668 ThemeTemplateRevisions::Markup,
669 ThemeTemplateRevisions::SavedAt,
670 ThemeTemplateRevisions::SavedBy,
671 ])
672 .from(ThemeTemplateRevisions::Table)
673 .and_where(Expr::col(ThemeTemplateRevisions::Id).eq(revision_id))
674 .to_owned(),
675 );
676 let row = bind_values(sqlx::query(&sql), values)
677 .fetch_optional(&db.pool)
678 .await?;
679 let revision = match row {
680 Some(r) => revision_from_row(&r)?,
681 None => return Err(ThemeError::TemplateNotFound(revision_id.to_string())),
682 };
683 let current = template(db, revision.template_id)
684 .await?
685 .ok_or_else(|| ThemeError::TemplateNotFound(revision.template_id.to_string()))?;
686 save_template(
687 db,
688 current.theme_id,
689 current.kind,
690 ¤t.path,
691 Some(revision.settings),
692 &revision.markup,
693 saved_by,
694 )
695 .await
696}