chore: make posts title optional
b3c09037
10 file(s) · +133 −51
| 16 | 16 | pub struct Post { |
|
| 17 | 17 | pub id: i64, |
|
| 18 | 18 | pub short_id: String, |
|
| 19 | - | pub title: String, |
|
| 19 | + | pub title: Option<String>, |
|
| 20 | 20 | pub slug: String, |
|
| 21 | 21 | pub alias: Option<String>, |
|
| 22 | 22 | pub canonical_url: Option<String>, |
|
| 31 | 31 | pub updated_at: String, |
|
| 32 | 32 | } |
|
| 33 | 33 | ||
| 34 | + | impl Post { |
|
| 35 | + | pub fn display_title(&self) -> String { |
|
| 36 | + | if let Some(t) = self.title.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) { |
|
| 37 | + | return t.to_string(); |
|
| 38 | + | } |
|
| 39 | + | let snippet: String = self |
|
| 40 | + | .content |
|
| 41 | + | .chars() |
|
| 42 | + | .filter(|c| !matches!(c, '\n' | '\r')) |
|
| 43 | + | .take(60) |
|
| 44 | + | .collect(); |
|
| 45 | + | let snippet = snippet.trim(); |
|
| 46 | + | if snippet.is_empty() { |
|
| 47 | + | "Untitled".to_string() |
|
| 48 | + | } else if self.content.chars().count() > 60 { |
|
| 49 | + | format!("{}…", snippet) |
|
| 50 | + | } else { |
|
| 51 | + | snippet.to_string() |
|
| 52 | + | } |
|
| 53 | + | } |
|
| 54 | + | } |
|
| 55 | + | ||
| 34 | 56 | #[derive(Serialize)] |
|
| 35 | 57 | pub struct PostInput<'a> { |
|
| 36 | - | pub title: &'a str, |
|
| 58 | + | pub title: Option<&'a str>, |
|
| 37 | 59 | pub slug: &'a str, |
|
| 38 | 60 | pub content: &'a str, |
|
| 39 | 61 | pub status: &'a str, |
|
| 74 | 96 | CREATE TABLE IF NOT EXISTS posts ( |
|
| 75 | 97 | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 76 | 98 | short_id TEXT NOT NULL UNIQUE, |
|
| 77 | - | title TEXT NOT NULL, |
|
| 99 | + | title TEXT, |
|
| 78 | 100 | slug TEXT NOT NULL UNIQUE, |
|
| 79 | 101 | alias TEXT, |
|
| 80 | 102 | canonical_url TEXT, |
|
| 142 | 164 | let conn = Connection::open(&path).expect("Failed to open database"); |
|
| 143 | 165 | ||
| 144 | 166 | conn.execute_batch(SCHEMA).expect("Failed to create tables"); |
|
| 167 | + | migrate_post_title_nullable(&conn).expect("Failed to migrate posts.title"); |
|
| 145 | 168 | ||
| 146 | 169 | for (key, value) in DEFAULT_SETTINGS { |
|
| 147 | 170 | conn.execute( |
|
| 152 | 175 | } |
|
| 153 | 176 | ||
| 154 | 177 | Arc::new(Mutex::new(conn)) |
|
| 178 | + | } |
|
| 179 | + | ||
| 180 | + | fn migrate_post_title_nullable(conn: &Connection) -> rusqlite::Result<()> { |
|
| 181 | + | let title_notnull: i64 = conn |
|
| 182 | + | .query_row( |
|
| 183 | + | "SELECT \"notnull\" FROM pragma_table_info('posts') WHERE name = 'title'", |
|
| 184 | + | [], |
|
| 185 | + | |row| row.get(0), |
|
| 186 | + | ) |
|
| 187 | + | .unwrap_or(0); |
|
| 188 | + | if title_notnull == 0 { |
|
| 189 | + | return Ok(()); |
|
| 190 | + | } |
|
| 191 | + | conn.execute_batch( |
|
| 192 | + | "BEGIN; |
|
| 193 | + | CREATE TABLE posts_new ( |
|
| 194 | + | id INTEGER PRIMARY KEY AUTOINCREMENT, |
|
| 195 | + | short_id TEXT NOT NULL UNIQUE, |
|
| 196 | + | title TEXT, |
|
| 197 | + | slug TEXT NOT NULL UNIQUE, |
|
| 198 | + | alias TEXT, |
|
| 199 | + | canonical_url TEXT, |
|
| 200 | + | published_date TEXT, |
|
| 201 | + | meta_description TEXT, |
|
| 202 | + | meta_image TEXT, |
|
| 203 | + | lang TEXT NOT NULL DEFAULT 'en', |
|
| 204 | + | tags TEXT, |
|
| 205 | + | content TEXT NOT NULL, |
|
| 206 | + | status TEXT NOT NULL DEFAULT 'draft', |
|
| 207 | + | created_at TEXT NOT NULL DEFAULT (datetime('now')), |
|
| 208 | + | updated_at TEXT NOT NULL DEFAULT (datetime('now')) |
|
| 209 | + | ); |
|
| 210 | + | INSERT INTO posts_new (id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, content, status, created_at, updated_at) |
|
| 211 | + | SELECT id, short_id, title, slug, alias, canonical_url, published_date, meta_description, meta_image, lang, tags, content, status, created_at, updated_at FROM posts; |
|
| 212 | + | DROP TABLE posts; |
|
| 213 | + | ALTER TABLE posts_new RENAME TO posts; |
|
| 214 | + | COMMIT;", |
|
| 215 | + | ) |
|
| 155 | 216 | } |
|
| 156 | 217 | ||
| 157 | 218 | // --- Post CRUD --- |
|
| 484 | 545 | ||
| 485 | 546 | fn test_post_input<'a>(title: &'a str, slug: &'a str, content: &'a str, status: &'a str) -> PostInput<'a> { |
|
| 486 | 547 | PostInput { |
|
| 487 | - | title, |
|
| 548 | + | title: Some(title), |
|
| 488 | 549 | slug, |
|
| 489 | 550 | content, |
|
| 490 | 551 | status, |
|
| 504 | 565 | fn create_and_get_post() { |
|
| 505 | 566 | let db = test_db(); |
|
| 506 | 567 | let post = create_post(&db, &test_post_input("Hello World", "hello-world", "# Hello", "draft")).unwrap(); |
|
| 507 | - | assert_eq!(post.title, "Hello World"); |
|
| 568 | + | assert_eq!(post.title.as_deref(), Some("Hello World")); |
|
| 508 | 569 | assert_eq!(post.slug, "hello-world"); |
|
| 509 | 570 | assert_eq!(post.status, "draft"); |
|
| 510 | 571 | ||
| 511 | 572 | let fetched = get_post_by_short_id(&db, &post.short_id).unwrap().unwrap(); |
|
| 512 | - | assert_eq!(fetched.title, "Hello World"); |
|
| 573 | + | assert_eq!(fetched.title.as_deref(), Some("Hello World")); |
|
| 574 | + | } |
|
| 575 | + | ||
| 576 | + | #[test] |
|
| 577 | + | fn create_post_without_title() { |
|
| 578 | + | let db = test_db(); |
|
| 579 | + | let input = PostInput { |
|
| 580 | + | title: None, |
|
| 581 | + | slug: "no-title", |
|
| 582 | + | content: "just a quick thought", |
|
| 583 | + | status: "draft", |
|
| 584 | + | alias: None, |
|
| 585 | + | canonical_url: None, |
|
| 586 | + | published_date: None, |
|
| 587 | + | meta_description: None, |
|
| 588 | + | meta_image: None, |
|
| 589 | + | lang: "en", |
|
| 590 | + | tags: None, |
|
| 591 | + | }; |
|
| 592 | + | let post = create_post(&db, &input).unwrap(); |
|
| 593 | + | assert!(post.title.is_none()); |
|
| 594 | + | assert_eq!(post.display_title(), "just a quick thought"); |
|
| 513 | 595 | } |
|
| 514 | 596 | ||
| 515 | 597 | #[test] |
|
| 520 | 602 | create_post(&db, &input).unwrap(); |
|
| 521 | 603 | ||
| 522 | 604 | let post = get_post_by_slug(&db, "test-slug").unwrap().unwrap(); |
|
| 523 | - | assert_eq!(post.title, "Test"); |
|
| 605 | + | assert_eq!(post.title.as_deref(), Some("Test")); |
|
| 524 | 606 | } |
|
| 525 | 607 | ||
| 526 | 608 | #[test] |
|
| 539 | 621 | ||
| 540 | 622 | let all = get_all_posts(&db).unwrap(); |
|
| 541 | 623 | assert_eq!(all.len(), 2); |
|
| 542 | - | assert_eq!(all[0].title, "Second"); |
|
| 543 | - | assert_eq!(all[1].title, "First"); |
|
| 624 | + | assert_eq!(all[0].title.as_deref(), Some("Second")); |
|
| 625 | + | assert_eq!(all[1].title.as_deref(), Some("First")); |
|
| 544 | 626 | } |
|
| 545 | 627 | ||
| 546 | 628 | #[test] |
|
| 553 | 635 | ||
| 554 | 636 | let published = get_published_posts(&db, None).unwrap(); |
|
| 555 | 637 | assert_eq!(published.len(), 1); |
|
| 556 | - | assert_eq!(published[0].title, "Published"); |
|
| 638 | + | assert_eq!(published[0].title.as_deref(), Some("Published")); |
|
| 557 | 639 | } |
|
| 558 | 640 | ||
| 559 | 641 | #[test] |
|
| 101 | 101 | ) -> Response { |
|
| 102 | 102 | let attrs = parse_attributes(&form.attributes); |
|
| 103 | 103 | let title = attrs.title.trim(); |
|
| 104 | - | if title.is_empty() { |
|
| 105 | - | return Redirect::to("/admin/posts/new?error=Title+is+required").into_response(); |
|
| 106 | - | } |
|
| 107 | - | let slug = if attrs.slug.trim().is_empty() { |
|
| 108 | - | slugify(title) |
|
| 109 | - | } else { |
|
| 110 | - | attrs.slug.trim().to_string() |
|
| 111 | - | }; |
|
| 104 | + | let slug = derive_slug(title, attrs.slug.trim()); |
|
| 112 | 105 | ||
| 113 | 106 | let status = if form.action == "publish" { "published" } else { "draft" }; |
|
| 114 | 107 | let lang = if attrs.lang.trim().is_empty() { "en" } else { attrs.lang.trim() }; |
|
| 119 | 112 | }; |
|
| 120 | 113 | ||
| 121 | 114 | let input = db::PostInput { |
|
| 122 | - | title, |
|
| 115 | + | title: opt_str(title), |
|
| 123 | 116 | slug: &slug, |
|
| 124 | 117 | content: &form.content, |
|
| 125 | 118 | status, |
|
| 140 | 133 | } |
|
| 141 | 134 | } |
|
| 142 | 135 | ||
| 136 | + | fn derive_slug(title: &str, slug: &str) -> String { |
|
| 137 | + | if !slug.is_empty() { |
|
| 138 | + | return slug.to_string(); |
|
| 139 | + | } |
|
| 140 | + | let from_title = slugify(title); |
|
| 141 | + | if !from_title.is_empty() { |
|
| 142 | + | return from_title; |
|
| 143 | + | } |
|
| 144 | + | nanoid::nanoid!(10) |
|
| 145 | + | } |
|
| 146 | + | ||
| 143 | 147 | pub async fn admin_edit_post( |
|
| 144 | 148 | _session: auth::AuthSession, |
|
| 145 | 149 | State(state): State<Arc<AppState>>, |
|
| 168 | 172 | ) -> Response { |
|
| 169 | 173 | let attrs = parse_attributes(&form.attributes); |
|
| 170 | 174 | let title = attrs.title.trim(); |
|
| 171 | - | if title.is_empty() { |
|
| 172 | - | return Redirect::to(&format!("/admin/posts/{}/edit?error=Title+is+required", short_id)) |
|
| 173 | - | .into_response(); |
|
| 174 | - | } |
|
| 175 | - | let slug = if attrs.slug.trim().is_empty() { |
|
| 176 | - | slugify(title) |
|
| 177 | - | } else { |
|
| 178 | - | attrs.slug.trim().to_string() |
|
| 179 | - | }; |
|
| 175 | + | let slug = derive_slug(title, attrs.slug.trim()); |
|
| 180 | 176 | ||
| 181 | 177 | let status = if form.action == "publish" { "published" } else { "draft" }; |
|
| 182 | 178 | let lang = if attrs.lang.trim().is_empty() { "en" } else { attrs.lang.trim() }; |
|
| 187 | 183 | }; |
|
| 188 | 184 | ||
| 189 | 185 | let input = db::PostInput { |
|
| 190 | - | title, |
|
| 186 | + | title: opt_str(title), |
|
| 191 | 187 | slug: &slug, |
|
| 192 | 188 | content: &form.content, |
|
| 193 | 189 | status, |
|
| 748 | 744 | } else { |
|
| 749 | 745 | attrs.title.trim().to_string() |
|
| 750 | 746 | }; |
|
| 751 | - | if title.is_empty() { |
|
| 752 | - | return false; |
|
| 753 | - | } |
|
| 754 | 747 | ||
| 755 | - | let slug = if attrs.slug.trim().is_empty() { |
|
| 756 | - | slugify(&title) |
|
| 757 | - | } else { |
|
| 758 | - | attrs.slug.trim().to_string() |
|
| 759 | - | }; |
|
| 748 | + | let slug = derive_slug(&title, attrs.slug.trim()); |
|
| 760 | 749 | if slug.is_empty() { |
|
| 761 | 750 | return false; |
|
| 762 | 751 | } |
|
| 790 | 779 | }; |
|
| 791 | 780 | ||
| 792 | 781 | let input = db::PostInput { |
|
| 793 | - | title: &title, |
|
| 782 | + | title: opt_str(&title), |
|
| 794 | 783 | slug: &slug, |
|
| 795 | 784 | content: body, |
|
| 796 | 785 | status, |
|
| 21 | 21 | #[derive(Serialize)] |
|
| 22 | 22 | struct ApiPostSummary { |
|
| 23 | 23 | short_id: String, |
|
| 24 | - | title: String, |
|
| 24 | + | title: Option<String>, |
|
| 25 | 25 | slug: String, |
|
| 26 | 26 | published_date: Option<String>, |
|
| 27 | 27 | meta_description: Option<String>, |
|
| 37 | 37 | #[derive(Serialize)] |
|
| 38 | 38 | struct ApiPostDetail { |
|
| 39 | 39 | short_id: String, |
|
| 40 | - | title: String, |
|
| 40 | + | title: Option<String>, |
|
| 41 | 41 | slug: String, |
|
| 42 | 42 | alias: Option<String>, |
|
| 43 | 43 | canonical_url: Option<String>, |
|
| 215 | 215 | let mut items = String::new(); |
|
| 216 | 216 | for post in &posts { |
|
| 217 | 217 | let link = format!("{}/posts/{}", site_url, xml_escape(&post.slug)); |
|
| 218 | - | let title = xml_escape(&post.title); |
|
| 218 | + | let title_elem = match post.title.as_deref().map(str::trim).filter(|s| !s.is_empty()) { |
|
| 219 | + | Some(t) => format!(" <title>{}</title>\n", xml_escape(t)), |
|
| 220 | + | None => String::new(), |
|
| 221 | + | }; |
|
| 219 | 222 | let description = match &post.meta_description { |
|
| 220 | 223 | Some(d) if !d.is_empty() => xml_escape(d), |
|
| 221 | 224 | _ => { |
|
| 228 | 231 | let guid = format!("{}/posts/{}", site_url, xml_escape(&post.slug)); |
|
| 229 | 232 | ||
| 230 | 233 | items.push_str(&format!( |
|
| 231 | - | " <item>\n <title>{title}</title>\n <link>{link}</link>\n <guid>{guid}</guid>\n <description>{description}</description>\n <pubDate>{pub_date}</pubDate>\n </item>\n" |
|
| 234 | + | " <item>\n{title_elem} <link>{link}</link>\n <guid>{guid}</guid>\n <description>{description}</description>\n <pubDate>{pub_date}</pubDate>\n </item>\n" |
|
| 232 | 235 | )); |
|
| 233 | 236 | } |
|
| 234 | 237 | ||
| 423 | 423 | html.push_str(&format!( |
|
| 424 | 424 | r#"<a href="/posts/{slug}" class="post-item"><div class="post-item-info"><span class="post-title">{title}</span>"#, |
|
| 425 | 425 | slug = post.slug, |
|
| 426 | - | title = post.title, |
|
| 426 | + | title = post.display_title(), |
|
| 427 | 427 | )); |
|
| 428 | 428 | if let Some(ref tags) = post.tags { |
|
| 429 | 429 | if !tags.is_empty() { |
|
| 449 | 449 | ||
| 450 | 450 | fn post_to_markdown(post: &Post) -> String { |
|
| 451 | 451 | use std::fmt::Write; |
|
| 452 | - | let mut out = format!("---\ntitle: {}\nslug: {}\nstatus: {}", post.title, post.slug, post.status); |
|
| 452 | + | let mut out = String::from("---"); |
|
| 453 | + | if let Some(t) = &post.title { |
|
| 454 | + | let _ = write!(out, "\ntitle: {}", t); |
|
| 455 | + | } |
|
| 456 | + | let _ = write!(out, "\nslug: {}\nstatus: {}", post.slug, post.status); |
|
| 453 | 457 | let optional_fields: &[(&str, &Option<String>)] = &[ |
|
| 454 | 458 | ("published_date", &post.published_date), |
|
| 455 | 459 | ("tags", &post.tags), |
|
| 12 | 12 | {% for post in posts %} |
|
| 13 | 13 | <div class="admin-list-item"> |
|
| 14 | 14 | <div class="admin-list-info"> |
|
| 15 | - | <a href="/admin/posts/{{ post.short_id }}/edit" class="admin-list-title">{{ post.title }}</a> |
|
| 15 | + | <a href="/admin/posts/{{ post.short_id }}/edit" class="admin-list-title">{{ post.display_title() }}</a> |
|
| 16 | 16 | <div class="admin-list-meta"> |
|
| 17 | 17 | <span class="status-badge {% if post.status == "published" %}status-published{% else %}status-draft{% endif %}">{{ post.status }}</span> |
|
| 18 | 18 | <span class="admin-list-date">{{ post.updated_at }}</span> |
| 8 | 8 | {% match post %} |
|
| 9 | 9 | {% when Some with (p) %} |
|
| 10 | 10 | <form method="POST" action="/admin/posts/{{ p.short_id }}" class="form post-form"> |
|
| 11 | - | <textarea name="attributes" class="attributes-textarea">title: {{ p.title }} |
|
| 11 | + | <textarea name="attributes" class="attributes-textarea">title: {{ p.title.as_deref().unwrap_or("") }} |
|
| 12 | 12 | slug: {{ p.slug }} |
|
| 13 | 13 | {%- if p.published_date.is_some() %} |
|
| 14 | 14 | published_date: {{ p.published_date.as_deref().unwrap_or_default() }} |
| 25 | 25 | {% for post in posts %} |
|
| 26 | 26 | <a href="/posts/{{ post.slug }}" class="post-item"> |
|
| 27 | 27 | <div class="post-item-info"> |
|
| 28 | - | <span class="post-title">{{ post.title }}</span> |
|
| 28 | + | <span class="post-title">{{ post.display_title() }}</span> |
|
| 29 | 29 | </div> |
|
| 30 | 30 | {% if post.published_date.is_some() %} |
|
| 31 | 31 | <time class="post-date">{{ post.published_date.as_deref().unwrap_or_default() }}</time> |
| 1 | 1 | {% extends "base.html" %} |
|
| 2 | - | {% block title %}{{ post.title }} — {{ blog_title }}{% endblock %} |
|
| 2 | + | {% block title %}{{ post.display_title() }} — {{ blog_title }}{% endblock %} |
|
| 3 | 3 | {% block meta %} |
|
| 4 | 4 | {% if post.meta_description.is_some() %} |
|
| 5 | 5 | <meta name="description" content="{{ post.meta_description.as_deref().unwrap_or_default() }}"> |
|
| 15 | 15 | {% if post.canonical_url.is_some() %} |
|
| 16 | 16 | <link rel="canonical" href="{{ post.canonical_url.as_deref().unwrap_or_default() }}"> |
|
| 17 | 17 | {% endif %} |
|
| 18 | - | <meta property="og:title" content="{{ post.title }}"> |
|
| 18 | + | <meta property="og:title" content="{{ post.display_title() }}"> |
|
| 19 | 19 | <meta property="og:type" content="article"> |
|
| 20 | 20 | <meta property="og:url" content="{{ site_url }}/posts/{{ post.slug }}"> |
|
| 21 | 21 | <meta property="article:published_time" content="{{ post.published_date.as_deref().unwrap_or_default() }}"> |
|
| 22 | 22 | {% endblock %} |
|
| 23 | 23 | {% block content %} |
|
| 24 | 24 | <div class="post-header"> |
|
| 25 | - | <h1>{{ post.title }}</h1> |
|
| 25 | + | {% if let Some(title) = post.title.as_deref() %} |
|
| 26 | + | {% if !title.trim().is_empty() %} |
|
| 27 | + | <h1>{{ title }}</h1> |
|
| 28 | + | {% endif %} |
|
| 29 | + | {% endif %} |
|
| 26 | 30 | {% if post.meta_description.is_some() %} |
|
| 27 | 31 | <p class="post-description">{{ post.meta_description.as_deref().unwrap_or_default() }}</p> |
|
| 28 | 32 | {% endif %} |
|
| 17 | 17 | {% for post in posts %} |
|
| 18 | 18 | <a href="/posts/{{ post.slug }}" class="post-item post-item-enhanced"> |
|
| 19 | 19 | <div class="post-item-info"> |
|
| 20 | - | <span class="post-title">{{ post.title }}</span> |
|
| 20 | + | <span class="post-title">{{ post.display_title() }}</span> |
|
| 21 | 21 | </div> |
|
| 22 | 22 | {% if post.published_date.is_some() %} |
|
| 23 | 23 | <time class="post-date">{{ post.published_date.as_deref().unwrap_or_default() }}</time> |