1use 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#[derive(Debug, Clone, PartialEq, Eq)]
24pub struct Folder {
25 pub id: i64,
26 pub parent_id: Option<i64>,
27 pub name: String,
28 pub path: String,
30}
31
32#[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
55pub 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
83pub 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
103pub async fn children(db: &Db, parent: Option<i64>) -> Result<Vec<Folder>, FolderError> {
105 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
128pub 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
144pub 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
172pub 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
208async 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 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 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
246pub 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
273pub 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
306pub 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
335pub 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}