Skip to main content

post_archiver/manager/
post.rs

1use chrono::{DateTime, Utc};
2use rusqlite::params;
3
4use crate::{
5    error::Result,
6    manager::{binded::Binded, PostArchiverConnection},
7    query::FromQuery,
8    AuthorId, CollectionId, Comment, Content, FileMetaId, PlatformId, Post, PostId, TagId,
9};
10
11/// Specifies how to update a post's `updated` timestamp.
12#[derive(Debug, Clone)]
13pub enum PostUpdated {
14    /// Unconditionally set to this value.
15    Set(DateTime<Utc>),
16    /// Set to this value only if it is more recent than the current value.
17    ByLatest(DateTime<Utc>),
18}
19
20/// Builder for updating a post's fields.
21///
22/// Fields left as `None` are not modified.
23/// For nullable columns (source, platform, thumb), use `Some(None)` to clear the value.
24#[derive(Debug, Clone, Default)]
25pub struct UpdatePost {
26    pub title: Option<String>,
27    pub source: Option<Option<String>>,
28    pub platform: Option<Option<PlatformId>>,
29    pub thumb: Option<Option<FileMetaId>>,
30    pub content: Option<Vec<Content>>,
31    pub comments: Option<Vec<Comment>>,
32    pub published: Option<DateTime<Utc>>,
33    pub updated: Option<PostUpdated>,
34}
35
36impl UpdatePost {
37    /// Set the title.
38    pub fn title(mut self, title: String) -> Self {
39        self.title = Some(title);
40        self
41    }
42    /// Set or clear the source URL.
43    pub fn source(mut self, source: Option<String>) -> Self {
44        self.source = Some(source);
45        self
46    }
47    /// Set or clear the platform.
48    pub fn platform(mut self, platform: Option<PlatformId>) -> Self {
49        self.platform = Some(platform);
50        self
51    }
52    /// Set or clear the thumbnail.
53    pub fn thumb(mut self, thumb: Option<FileMetaId>) -> Self {
54        self.thumb = Some(thumb);
55        self
56    }
57    /// Replace the content list.
58    pub fn content(mut self, content: Vec<Content>) -> Self {
59        self.content = Some(content);
60        self
61    }
62    /// Replace the comments list.
63    pub fn comments(mut self, comments: Vec<Comment>) -> Self {
64        self.comments = Some(comments);
65        self
66    }
67    /// Set the published timestamp.
68    pub fn published(mut self, published: DateTime<Utc>) -> Self {
69        self.published = Some(published);
70        self
71    }
72    /// Unconditionally set the updated timestamp.
73    pub fn updated(mut self, updated: DateTime<Utc>) -> Self {
74        self.updated = Some(PostUpdated::Set(updated));
75        self
76    }
77    /// Set the updated timestamp only if `updated` is more recent than the stored value.
78    pub fn updated_by_latest(mut self, updated: DateTime<Utc>) -> Self {
79        self.updated = Some(PostUpdated::ByLatest(updated));
80        self
81    }
82}
83
84//=============================================================
85// Update / Delete
86//=============================================================
87impl<'a, C: PostArchiverConnection> Binded<'a, PostId, C> {
88    /// Get this post's current data from the database.
89    pub fn value(&self) -> Result<Post> {
90        let mut stmt = self
91            .conn()
92            .prepare_cached("SELECT * FROM posts WHERE id = ?")?;
93        Ok(stmt.query_row([self.id()], Post::from_row)?)
94    }
95
96    /// Remove this post from the archive.
97    ///
98    /// This also removes all associated file metadata, author associations,
99    /// tag associations, and collection associations.
100    pub fn delete(self) -> Result<()> {
101        let mut stmt = self
102            .conn()
103            .prepare_cached("DELETE FROM posts WHERE id = ?")?;
104        stmt.execute([self.id()])?;
105        Ok(())
106    }
107
108    /// Apply a batch of field updates to this post in a single SQL statement.
109    ///
110    /// Only fields set on `update` (i.e. `Some(...)`) are written to the database.
111    pub fn update(&self, update: UpdatePost) -> Result<()> {
112        use rusqlite::types::ToSql;
113
114        // Pre-serialize JSON fields so they outlive the params slice.
115        let content_json = update.content.map(|c| serde_json::to_string(&c).unwrap());
116        let comments_json = update.comments.map(|c| serde_json::to_string(&c).unwrap());
117
118        let mut sets: Vec<&str> = Vec::new();
119        let mut params: Vec<&dyn ToSql> = Vec::new();
120
121        macro_rules! push {
122            ($field:expr, $col:expr) => {
123                if let Some(ref v) = $field {
124                    sets.push($col);
125                    params.push(v);
126                }
127            };
128        }
129
130        push!(update.title, "title = ?");
131        push!(update.source, "source = ?");
132        push!(update.platform, "platform = ?");
133        push!(update.thumb, "thumb = ?");
134        push!(content_json, "content = ?");
135        push!(comments_json, "comments = ?");
136        push!(update.published, "published = ?");
137
138        match &update.updated {
139            Some(PostUpdated::Set(t)) => {
140                sets.push("updated = ?");
141                params.push(t);
142            }
143            Some(PostUpdated::ByLatest(t)) => {
144                sets.push("updated = MAX(updated, ?)");
145                params.push(t);
146            }
147            None => {}
148        }
149
150        if sets.is_empty() {
151            return Ok(());
152        }
153
154        let id = self.id();
155        params.push(&id);
156
157        let sql = format!("UPDATE posts SET {} WHERE id = ?", sets.join(", "));
158        self.conn().execute(&sql, params.as_slice())?;
159        Ok(())
160    }
161
162    /// List all file metadata IDs associated with this post.
163    pub fn list_file_metas(&self) -> Result<Vec<FileMetaId>> {
164        let mut stmt = self
165            .conn()
166            .prepare_cached("SELECT id FROM file_metas WHERE post = ?")?;
167        let rows = stmt.query_map([self.id()], |row| row.get(0))?;
168        rows.collect::<std::result::Result<_, _>>()
169            .map_err(Into::into)
170    }
171}
172
173//=============================================================
174// Relations: Authors
175//=============================================================
176impl<'a, C: PostArchiverConnection> Binded<'a, PostId, C> {
177    /// List all author IDs associated with this post.
178    pub fn list_authors(&self) -> Result<Vec<AuthorId>> {
179        let mut stmt = self
180            .conn()
181            .prepare_cached("SELECT author FROM author_posts WHERE post = ?")?;
182        let rows = stmt.query_map([self.id()], |row| row.get(0))?;
183        rows.collect::<std::result::Result<_, _>>()
184            .map_err(Into::into)
185    }
186
187    /// Associate one or more authors with this post.
188    /// Duplicate associations are silently ignored.
189    pub fn add_authors(&self, authors: &[AuthorId]) -> Result<()> {
190        let mut stmt = self
191            .conn()
192            .prepare_cached("INSERT OR IGNORE INTO author_posts (author, post) VALUES (?, ?)")?;
193        for author in authors {
194            stmt.execute(params![author, self.id()])?;
195        }
196        Ok(())
197    }
198
199    /// Remove one or more authors from this post.
200    pub fn remove_authors(&self, authors: &[AuthorId]) -> Result<()> {
201        let mut stmt = self
202            .conn()
203            .prepare_cached("DELETE FROM author_posts WHERE post = ? AND author = ?")?;
204        for author in authors {
205            stmt.execute(params![self.id(), author])?;
206        }
207        Ok(())
208    }
209}
210
211//=============================================================
212// Relations: Tags
213//=============================================================
214impl<'a, C: PostArchiverConnection> Binded<'a, PostId, C> {
215    /// List all tag IDs associated with this post.
216    pub fn list_tags(&self) -> Result<Vec<TagId>> {
217        let mut stmt = self
218            .conn()
219            .prepare_cached("SELECT tag FROM post_tags WHERE post = ?")?;
220        let rows = stmt.query_map([self.id()], |row| row.get(0))?;
221        rows.collect::<std::result::Result<_, _>>()
222            .map_err(Into::into)
223    }
224
225    /// Associate one or more tags with this post.
226    /// Duplicate associations are silently ignored.
227    pub fn add_tags(&self, tags: &[TagId]) -> Result<()> {
228        let mut stmt = self
229            .conn()
230            .prepare_cached("INSERT OR IGNORE INTO post_tags (post, tag) VALUES (?, ?)")?;
231        for tag in tags {
232            stmt.execute(params![self.id(), tag])?;
233        }
234        Ok(())
235    }
236
237    /// Remove one or more tags from this post.
238    pub fn remove_tags(&self, tags: &[TagId]) -> Result<()> {
239        let mut stmt = self
240            .conn()
241            .prepare_cached("DELETE FROM post_tags WHERE post = ? AND tag = ?")?;
242        for tag in tags {
243            stmt.execute(params![self.id(), tag])?;
244        }
245        Ok(())
246    }
247}
248
249//=============================================================
250// Relations: Collections
251//=============================================================
252impl<'a, C: PostArchiverConnection> Binded<'a, PostId, C> {
253    /// List all collection IDs associated with this post.
254    pub fn list_collections(&self) -> Result<Vec<CollectionId>> {
255        let mut stmt = self
256            .conn()
257            .prepare_cached("SELECT collection FROM collection_posts WHERE post = ?")?;
258        let rows = stmt.query_map([self.id()], |row| row.get(0))?;
259        rows.collect::<std::result::Result<_, _>>()
260            .map_err(Into::into)
261    }
262
263    /// Associate one or more collections with this post.
264    /// Duplicate associations are silently ignored.
265    pub fn add_collections(&self, collections: &[CollectionId]) -> Result<()> {
266        let mut stmt = self.conn().prepare_cached(
267            "INSERT OR IGNORE INTO collection_posts (collection, post) VALUES (?, ?)",
268        )?;
269        for collection in collections {
270            stmt.execute(params![collection, self.id()])?;
271        }
272        Ok(())
273    }
274
275    /// Remove one or more collections from this post.
276    pub fn remove_collections(&self, collections: &[CollectionId]) -> Result<()> {
277        let mut stmt = self
278            .conn()
279            .prepare_cached("DELETE FROM collection_posts WHERE collection = ? AND post = ?")?;
280        for collection in collections {
281            stmt.execute(params![collection, self.id()])?;
282        }
283        Ok(())
284    }
285}