Skip to main content

laterite_media/
folder.rs

1//! Library folders: rows, never disk directories. A folder has a parent and a
2//! materialized path (`/photos/2026`), so a subtree is one prefix query and
3//! browsing never asks the storage driver to list anything.
4
5use chrono::{SecondsFormat, Utc};
6use laterite_core::strata::*;
7use laterite_core::Db;
8
9use crate::record::{Media, RecordError};
10
11#[derive(Iden)]
12pub enum MediaFolders {
13    Table,
14    Id,
15    ParentId,
16    Name,
17    Path,
18    CreatedAt,
19    UpdatedAt,
20}
21
22/// A folder row.
23#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Folder {
25    pub id: i64,
26    pub parent_id: Option<i64>,
27    pub name: String,
28    /// `/a/b` for the folder `b` under `a`; the root is `/`.
29    pub path: String,
30}
31
32/// Why a folder operation was refused.
33#[derive(Debug, thiserror::Error)]
34pub enum FolderError {
35    #[error("database error")]
36    Database(#[from] sqlx::Error),
37    #[error(transparent)]
38    Record(#[from] RecordError),
39    #[error("{0:?} is not a folder name")]
40    Name(String),
41    #[error("a folder named {0:?} is already there")]
42    Taken(String),
43    #[error("no folder {0}")]
44    NotFound(i64),
45    #[error("a folder cannot move into itself or below")]
46    Cycle,
47    #[error("the folder still holds {files} file(s) and {folders} folder(s)")]
48    NotEmpty { files: i64, folders: i64 },
49}
50
51fn now() -> String {
52    Utc::now().to_rfc3339_opts(SecondsFormat::Micros, true)
53}
54
55/// A folder name: no slash, no control character, trimmed, at most 120 characters.
56pub fn valid_name(name: &str) -> bool {
57    let name = name.trim();
58    !name.is_empty()
59        && name.len() <= 120
60        && name != "."
61        && name != ".."
62        && !name
63            .chars()
64            .any(|c| c == '/' || c == '\\' || c.is_control())
65}
66
67const COLUMNS: [MediaFolders; 4] = [
68    MediaFolders::Id,
69    MediaFolders::ParentId,
70    MediaFolders::Name,
71    MediaFolders::Path,
72];
73
74fn from_row(r: &sqlx::any::AnyRow) -> Result<Folder, sqlx::Error> {
75    Ok(Folder {
76        id: r.get_int("id")?,
77        parent_id: r.get_int_opt("parent_id")?,
78        name: r.get_text("name")?,
79        path: r.get_text("path")?,
80    })
81}
82
83/// A folder by id.
84pub async fn find(db: &Db, id: i64) -> Result<Option<Folder>, FolderError> {
85    let (sql, values) = build(
86        db.backend,
87        Query::select()
88            .columns(COLUMNS)
89            .from(MediaFolders::Table)
90            .and_where(Expr::col(MediaFolders::Id).eq(id))
91            .to_owned(),
92    );
93    let row = bind_values(sqlx::query(&sql), values)
94        .fetch_optional(&db.pool)
95        .await?;
96    Ok(row.as_ref().map(from_row).transpose()?)
97}
98
99async fn require(db: &Db, id: i64) -> Result<Folder, FolderError> {
100    find(db, id).await?.ok_or(FolderError::NotFound(id))
101}
102
103/// The folders directly under `parent` (`None` for the root), by name.
104pub async fn children(db: &Db, parent: Option<i64>) -> Result<Vec<Folder>, FolderError> {
105    // Built and rendered in one block, so the builder never crosses an await.
106    let (sql, values) = {
107        let mut query = Query::select()
108            .columns(COLUMNS)
109            .from(MediaFolders::Table)
110            .order_by(MediaFolders::Name, Order::Asc)
111            .to_owned();
112        match parent {
113            Some(id) => {
114                query.and_where(Expr::col(MediaFolders::ParentId).eq(id));
115            }
116            None => {
117                query.and_where(Expr::col(MediaFolders::ParentId).is_null());
118            }
119        }
120        build(db.backend, query)
121    };
122    let rows = bind_values(sqlx::query(&sql), values)
123        .fetch_all(&db.pool)
124        .await?;
125    Ok(rows.iter().map(from_row).collect::<Result<_, _>>()?)
126}
127
128/// Every folder, by path, for a tree or a picker.
129pub async fn all(db: &Db) -> Result<Vec<Folder>, FolderError> {
130    let (sql, values) = build(
131        db.backend,
132        Query::select()
133            .columns(COLUMNS)
134            .from(MediaFolders::Table)
135            .order_by(MediaFolders::Path, Order::Asc)
136            .to_owned(),
137    );
138    let rows = bind_values(sqlx::query(&sql), values)
139        .fetch_all(&db.pool)
140        .await?;
141    Ok(rows.iter().map(from_row).collect::<Result<_, _>>()?)
142}
143
144/// The way down to a folder: the root's children first, the folder last.
145pub async fn ancestors(db: &Db, id: i64) -> Result<Vec<Folder>, FolderError> {
146    let mut chain = Vec::new();
147    let mut next = Some(id);
148    while let Some(current) = next {
149        let folder = require(db, current).await?;
150        next = folder.parent_id;
151        chain.push(folder);
152        if chain.len() > 64 {
153            break;
154        }
155    }
156    chain.reverse();
157    Ok(chain)
158}
159
160async fn sibling_taken(
161    db: &Db,
162    parent: Option<i64>,
163    name: &str,
164    except: Option<i64>,
165) -> Result<bool, FolderError> {
166    Ok(children(db, parent)
167        .await?
168        .iter()
169        .any(|f| f.name.eq_ignore_ascii_case(name) && Some(f.id) != except))
170}
171
172/// Creates a folder under `parent` (`None` for the root).
173pub async fn create(db: &Db, parent: Option<i64>, name: &str) -> Result<Folder, FolderError> {
174    let name = name.trim();
175    if !valid_name(name) {
176        return Err(FolderError::Name(name.to_string()));
177    }
178    let parent_path = match parent {
179        Some(id) => require(db, id).await?.path,
180        None => String::new(),
181    };
182    if sibling_taken(db, parent, name, None).await? {
183        return Err(FolderError::Taken(name.to_string()));
184    }
185    let path = format!("{}/{name}", parent_path.trim_end_matches('/'));
186    let now = now();
187    let stmt = Query::insert()
188        .into_table(MediaFolders::Table)
189        .columns([
190            MediaFolders::ParentId,
191            MediaFolders::Name,
192            MediaFolders::Path,
193            MediaFolders::CreatedAt,
194            MediaFolders::UpdatedAt,
195        ])
196        .values_panic([
197            parent.into(),
198            name.into(),
199            path.into(),
200            now.clone().into(),
201            now.into(),
202        ])
203        .to_owned();
204    let id = insert_returning_id(db, stmt, MediaFolders::Id).await?;
205    require(db, id).await
206}
207
208/// Rewrites a folder's path and every descendant's after a rename or a move.
209async fn repath(db: &Db, folder: &Folder, new_path: &str) -> Result<(), FolderError> {
210    let old_prefix = format!("{}/", folder.path);
211    let new_prefix = format!("{}/", new_path);
212    // The folder itself.
213    let (sql, values) = build(
214        db.backend,
215        Query::update()
216            .table(MediaFolders::Table)
217            .value(MediaFolders::Path, new_path)
218            .value(MediaFolders::UpdatedAt, now())
219            .and_where(Expr::col(MediaFolders::Id).eq(folder.id))
220            .to_owned(),
221    );
222    bind_values(sqlx::query(&sql), values)
223        .execute(&db.pool)
224        .await?;
225    // Its descendants, by prefix.
226    for f in all(db).await? {
227        if let Some(rest) = f.path.strip_prefix(&old_prefix) {
228            let path = format!("{new_prefix}{rest}");
229            let (sql, values) = build(
230                db.backend,
231                Query::update()
232                    .table(MediaFolders::Table)
233                    .value(MediaFolders::Path, path)
234                    .value(MediaFolders::UpdatedAt, now())
235                    .and_where(Expr::col(MediaFolders::Id).eq(f.id))
236                    .to_owned(),
237            );
238            bind_values(sqlx::query(&sql), values)
239                .execute(&db.pool)
240                .await?;
241        }
242    }
243    Ok(())
244}
245
246/// Renames a folder.
247pub async fn rename(db: &Db, id: i64, name: &str) -> Result<Folder, FolderError> {
248    let name = name.trim();
249    if !valid_name(name) {
250        return Err(FolderError::Name(name.to_string()));
251    }
252    let folder = require(db, id).await?;
253    if sibling_taken(db, folder.parent_id, name, Some(id)).await? {
254        return Err(FolderError::Taken(name.to_string()));
255    }
256    let parent_path = folder.path.rsplit_once('/').map(|(p, _)| p).unwrap_or("");
257    let new_path = format!("{parent_path}/{name}");
258    let (sql, values) = build(
259        db.backend,
260        Query::update()
261            .table(MediaFolders::Table)
262            .value(MediaFolders::Name, name)
263            .and_where(Expr::col(MediaFolders::Id).eq(id))
264            .to_owned(),
265    );
266    bind_values(sqlx::query(&sql), values)
267        .execute(&db.pool)
268        .await?;
269    repath(db, &folder, &new_path).await?;
270    require(db, id).await
271}
272
273/// Moves a folder under `parent` (`None` for the root). Refused into itself
274/// or a descendant.
275pub async fn move_to(db: &Db, id: i64, parent: Option<i64>) -> Result<Folder, FolderError> {
276    let folder = require(db, id).await?;
277    let parent_path = match parent {
278        Some(pid) => {
279            let target = require(db, pid).await?;
280            if target.id == folder.id || target.path.starts_with(&format!("{}/", folder.path)) {
281                return Err(FolderError::Cycle);
282            }
283            target.path
284        }
285        None => String::new(),
286    };
287    if sibling_taken(db, parent, &folder.name, Some(id)).await? {
288        return Err(FolderError::Taken(folder.name.clone()));
289    }
290    let (sql, values) = build(
291        db.backend,
292        Query::update()
293            .table(MediaFolders::Table)
294            .value(MediaFolders::ParentId, parent)
295            .and_where(Expr::col(MediaFolders::Id).eq(id))
296            .to_owned(),
297    );
298    bind_values(sqlx::query(&sql), values)
299        .execute(&db.pool)
300        .await?;
301    let new_path = format!("{}/{}", parent_path.trim_end_matches('/'), folder.name);
302    repath(db, &folder, &new_path).await?;
303    require(db, id).await
304}
305
306/// How many files and folders a folder holds directly.
307pub async fn contents(db: &Db, id: i64) -> Result<(i64, i64), FolderError> {
308    let (sql, values) = build(
309        db.backend,
310        Query::select()
311            .expr(Expr::col(Media::Id).count())
312            .from(Media::Table)
313            .and_where(Expr::col(Media::FolderId).eq(id))
314            .to_owned(),
315    );
316    let files: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&sql), values)
317        .fetch_one(&db.pool)
318        .await?
319        .0;
320    let (sql, values) = build(
321        db.backend,
322        Query::select()
323            .expr(Expr::col(MediaFolders::Id).count())
324            .from(MediaFolders::Table)
325            .and_where(Expr::col(MediaFolders::ParentId).eq(id))
326            .to_owned(),
327    );
328    let folders: i64 = bind_values_as(sqlx::query_as::<_, (i64,)>(&sql), values)
329        .fetch_one(&db.pool)
330        .await?
331        .0;
332    Ok((files, folders))
333}
334
335/// Deletes an empty folder.
336pub async fn delete(db: &Db, id: i64) -> Result<(), FolderError> {
337    require(db, id).await?;
338    let (files, folders) = contents(db, id).await?;
339    if files > 0 || folders > 0 {
340        return Err(FolderError::NotEmpty { files, folders });
341    }
342    let (sql, values) = build(
343        db.backend,
344        Query::delete()
345            .from_table(MediaFolders::Table)
346            .and_where(Expr::col(MediaFolders::Id).eq(id))
347            .to_owned(),
348    );
349    bind_values(sqlx::query(&sql), values)
350        .execute(&db.pool)
351        .await?;
352    Ok(())
353}