chore: make posts title optional b3c09037
Steve Simkins · 2026-05-01 12:38 10 file(s) · +133 −51
apps/posts/src/db.rs +92 −10
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]
apps/posts/src/server/handlers/admin.rs +17 −28
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,
apps/posts/src/server/handlers/api.rs +2 −2
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>,
apps/posts/src/server/handlers/public.rs +5 −2
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
apps/posts/src/server/mod.rs +6 −2
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),
apps/posts/templates/admin_index.html +1 −1
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>
apps/posts/templates/admin_post_form.html +1 −1
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() }}
apps/posts/templates/index.html +1 −1
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>
apps/posts/templates/post.html +7 −3
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 %}
apps/posts/templates/posts.html +1 −1
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>