Skip to main content

kernoid_client/
files.rs

1// SPDX-License-Identifier: MIT
2//! Files over JMAP (draft-ietf-jmap-filenode-19): the stores the user reaches – their own, those shared with them,
3//! the tenant's drives –, directories and files with the rights on them, downloads, uploads, kernoid's versions of a
4//! file (`https://kernoid.dev/jmap/file-versions`) and its links for people without an account
5//! (`https://kernoid.dev/jmap/file-links`).
6
7use std::{collections::BTreeMap, path::Path};
8
9use serde::Deserialize;
10use serde_json::{Value, json};
11
12use crate::{Client, Error, FILE_LINKS, FILE_VERSIONS, FILENODE, PRINCIPALS, Result, ids_of};
13
14const USING: [&str; 3] = [FILENODE, FILE_VERSIONS, PRINCIPALS];
15const PROPERTIES: [&str; 12] = [
16    "parentId",
17    "name",
18    "role",
19    "blobId",
20    "size",
21    "mediaType",
22    "modified",
23    "myRights",
24    "shareWith",
25    "kernoid:vault",
26    "kernoid:vaultKey",
27    "kernoid:encryptedName",
28];
29
30#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
31#[serde(rename_all = "camelCase", default)]
32pub struct Rights {
33    pub may_read: bool,
34    pub may_add_children: bool,
35    pub may_rename: bool,
36    pub may_delete: bool,
37    pub may_modify_content: bool,
38    pub may_share: bool,
39}
40
41#[derive(Debug, Clone, Deserialize)]
42#[serde(rename_all = "camelCase")]
43pub struct FileNode {
44    #[serde(rename = "@type", default)]
45    pub kind: String,
46    pub id: String,
47    #[serde(default)]
48    pub parent_id: Option<String>,
49    pub name: String,
50    #[serde(default)]
51    pub role: Option<String>,
52    #[serde(default)]
53    pub blob_id: Option<String>,
54    #[serde(default)]
55    pub size: Option<u64>,
56    #[serde(default)]
57    pub media_type: Option<String>,
58    #[serde(default)]
59    pub modified: Option<String>,
60    #[serde(default)]
61    pub my_rights: Option<Rights>,
62    #[serde(default)]
63    pub share_with: Option<BTreeMap<String, Rights>>,
64    /// The vault the node lies in – its own id on the vault itself (RFC 0006, 6); see [`crate::e2e::E2e::open_names`].
65    #[serde(rename = "kernoid:vault", default)]
66    pub vault: Option<String>,
67    /// The vault's public key (base64), on the vault itself.
68    #[serde(rename = "kernoid:vaultKey", default)]
69    pub vault_key: Option<String>,
70    /// The real name, encrypted to the vault's key (base64); `name` is then an id.
71    #[serde(rename = "kernoid:encryptedName", default)]
72    pub encrypted_name: Option<String>,
73    /// In a vault whose key is not at hand: `name` is still the id.
74    #[serde(skip)]
75    pub locked: bool,
76}
77
78impl FileNode {
79    /// Whether the node lies in a vault, or is one.
80    pub fn in_vault(&self) -> bool {
81        self.vault.is_some()
82    }
83
84    pub fn is_directory(&self) -> bool {
85        self.kind == "Directory" || (self.kind.is_empty() && self.blob_id.is_none())
86    }
87
88    pub fn rights(&self) -> Rights {
89        self.my_rights.unwrap_or_default()
90    }
91}
92
93/// What kind of store an account is to the user.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
95pub enum StoreKind {
96    Own,
97    Drive,
98    Shared,
99}
100
101/// A file store the user reaches.
102#[derive(Debug, Clone)]
103pub struct Store {
104    pub id: String,
105    pub name: String,
106    pub kind: StoreKind,
107}
108
109/// The stores of the session: the own first, then drives, then other people's.
110pub fn stores(client: &Client) -> Vec<Store> {
111    let own = client.session.primary(FILENODE);
112    let mut out: Vec<Store> = client
113        .session
114        .accounts_with(FILENODE)
115        .into_iter()
116        .map(|(id, account)| Store {
117            id: id.to_owned(),
118            name: account.name.clone(),
119            kind: if Some(id) == own {
120                StoreKind::Own
121            } else if account.drive {
122                StoreKind::Drive
123            } else {
124                StoreKind::Shared
125            },
126        })
127        .collect();
128    out.sort_by(|a, b| (a.kind, &a.name).cmp(&(b.kind, &b.name)));
129    out
130}
131
132#[derive(Deserialize)]
133struct List<T> {
134    list: Vec<T>,
135}
136
137fn list<T: serde::de::DeserializeOwned>(value: Option<&Value>) -> Result<Vec<T>> {
138    let value = value.cloned().unwrap_or(json!({ "list": [] }));
139    serde_json::from_value::<List<T>>(value).map(|list| list.list).map_err(|error| Error::Protocol(error.to_string()))
140}
141
142/// Directories first, then by name, ignoring case.
143pub fn sort(nodes: &mut [FileNode]) {
144    nodes.sort_by(|a, b| {
145        b.is_directory().cmp(&a.is_directory()).then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
146    });
147}
148
149/// The nodes in a directory of a store – or at its top, for `None` – sorted.
150pub async fn children(client: &Client, account: &str, parent: Option<&str>) -> Result<Vec<FileNode>> {
151    let filter = match parent {
152        Some(parent) => json!({ "parentId": parent }),
153        None => json!({ "isTopLevel": true }),
154    };
155    let responses = client
156        .request(
157            &USING,
158            vec![
159                ("FileNode/query", json!({ "accountId": account, "filter": filter }), "q"),
160                (
161                    "FileNode/get",
162                    json!({ "accountId": account, "properties": PROPERTIES, "#ids": ids_of("q", "FileNode/query") }),
163                    "g",
164                ),
165            ],
166        )
167        .await?;
168    let mut nodes = list(responses.get("g"))?;
169    sort(&mut nodes);
170    Ok(nodes)
171}
172
173/// Everything in a store the user sees, and its state – the start of keeping it in step (`kernoid-sync`), and the way
174/// back when a state is too old for `FileNode/changes`.
175#[derive(Debug, Clone)]
176pub struct Snapshot {
177    pub state: String,
178    pub nodes: Vec<FileNode>,
179}
180
181pub async fn snapshot(client: &Client, account: &str) -> Result<Snapshot> {
182    let responses = client
183        .request(
184            &USING,
185            vec![
186                ("FileNode/query", json!({ "accountId": account }), "q"),
187                (
188                    "FileNode/get",
189                    json!({ "accountId": account, "properties": PROPERTIES, "#ids": ids_of("q", "FileNode/query") }),
190                    "g",
191                ),
192            ],
193        )
194        .await?;
195    let state = responses.get("g").and_then(|g| g.get("state")).and_then(Value::as_str).unwrap_or_default().to_owned();
196    Ok(Snapshot { state, nodes: list(responses.get("g"))? })
197}
198
199/// What changed in a store since a state (RFC 8620, 5.2), with the nodes created or updated.
200#[derive(Debug, Clone)]
201pub struct Changes {
202    pub old_state: String,
203    pub new_state: String,
204    pub has_more_changes: bool,
205    /// Created or updated, as they are now.
206    pub changed: Vec<FileNode>,
207    /// Gone – or no longer visible to the user.
208    pub destroyed: Vec<String>,
209}
210
211/// The changes since `since`, at most `max` ids at once. A state the server cannot bring up to date is
212/// `Error::Method` with the kind `cannotCalculateChanges`: take a [`snapshot`] then.
213pub async fn changes(client: &Client, account: &str, since: &str, max: u64) -> Result<Changes> {
214    let responses = client
215        .request(
216            &USING,
217            vec![
218                ("FileNode/changes", json!({ "accountId": account, "sinceState": since, "maxChanges": max }), "c"),
219                (
220                    "FileNode/get",
221                    json!({ "accountId": account, "properties": PROPERTIES,
222                            "#ids": { "resultOf": "c", "name": "FileNode/changes", "path": "/created" } }),
223                    "n",
224                ),
225                (
226                    "FileNode/get",
227                    json!({ "accountId": account, "properties": PROPERTIES,
228                            "#ids": { "resultOf": "c", "name": "FileNode/changes", "path": "/updated" } }),
229                    "u",
230                ),
231            ],
232        )
233        .await?;
234    let found = responses.get("c").ok_or_else(|| Error::Protocol("no FileNode/changes".into()))?;
235    let text = |key: &str| found.get(key).and_then(Value::as_str).unwrap_or_default().to_owned();
236    let mut changed = list::<FileNode>(responses.get("n"))?;
237    changed.extend(list::<FileNode>(responses.get("u"))?);
238    let destroyed = found
239        .get("destroyed")
240        .and_then(Value::as_array)
241        .map(|ids| ids.iter().filter_map(Value::as_str).map(str::to_owned).collect())
242        .unwrap_or_default();
243    Ok(Changes {
244        old_state: text("oldState"),
245        new_state: text("newState"),
246        has_more_changes: found.get("hasMoreChanges").and_then(Value::as_bool).unwrap_or(false),
247        changed,
248        destroyed,
249    })
250}
251
252/// The directory with the role `trash`, if the store has one.
253pub async fn trash(client: &Client, account: &str) -> Result<Option<FileNode>> {
254    let responses = client
255        .request(
256            &USING,
257            vec![
258                ("FileNode/query", json!({ "accountId": account, "filter": { "role": "trash" } }), "q"),
259                (
260                    "FileNode/get",
261                    json!({ "accountId": account, "properties": PROPERTIES, "#ids": ids_of("q", "FileNode/query") }),
262                    "g",
263                ),
264            ],
265        )
266        .await?;
267    Ok(list(responses.get("g"))?.into_iter().next())
268}
269
270pub async fn create_directory(client: &Client, account: &str, parent: Option<&str>, name: &str) -> Result<()> {
271    let create = json!({ "create": { "d": { "@type": "Directory", "parentId": parent, "name": name } } });
272    client.set(&USING, "FileNode", account, create, json!({})).await?;
273    Ok(())
274}
275
276pub async fn rename(client: &Client, account: &str, id: &str, name: &str) -> Result<()> {
277    client.set(&USING, "FileNode", account, json!({ "update": { id: { "name": name } } }), json!({})).await?;
278    Ok(())
279}
280
281/// Moves a node into another directory (`None`: the top); a name taken there gets a number.
282pub async fn move_to(client: &Client, account: &str, id: &str, parent: Option<&str>) -> Result<()> {
283    let update = json!({ "update": { id: { "parentId": parent } } });
284    client.set(&USING, "FileNode", account, update, json!({ "onExists": "rename" })).await?;
285    Ok(())
286}
287
288/// Copies a node – a directory with all it holds – from one store into a directory of another (`None`: its top); with
289/// `move_it` the original goes (it needs the right to delete). A name taken there gets a number.
290pub async fn copy_to(
291    client: &Client,
292    from: &str,
293    to: &str,
294    id: &str,
295    parent: Option<&str>,
296    move_it: bool,
297) -> Result<()> {
298    let result = client
299        .call(
300            &USING,
301            "FileNode/copy",
302            json!({
303                "fromAccountId": from,
304                "accountId": to,
305                "create": { "c": { "id": id, "parentId": parent } },
306                "onSuccessDestroyOriginal": move_it,
307                "onExists": "rename",
308            }),
309        )
310        .await?;
311    crate::set_error(&result)
312}
313
314/// A node found by a search, and the directories above it, the top first.
315#[derive(Debug, Clone)]
316pub struct Found {
317    pub node: FileNode,
318    pub path: Vec<FileNode>,
319}
320
321/// The nodes of a store whose name or text content holds `text` (kernoid's `text` filter), with where they are.
322pub async fn search(client: &Client, account: &str, text: &str) -> Result<Vec<Found>> {
323    let responses = client
324        .request(
325            &USING,
326            vec![
327                ("FileNode/query", json!({ "accountId": account, "filter": { "text": text }, "limit": 200 }), "q"),
328                (
329                    "FileNode/get",
330                    json!({
331                        "accountId": account, "properties": PROPERTIES, "fetchParents": true,
332                        "#ids": ids_of("q", "FileNode/query"),
333                    }),
334                    "g",
335                ),
336            ],
337        )
338        .await?;
339    let hits: Vec<String> = responses
340        .get("q")
341        .and_then(|query| query.get("ids"))
342        .and_then(Value::as_array)
343        .map(|ids| ids.iter().filter_map(Value::as_str).map(str::to_owned).collect())
344        .unwrap_or_default();
345    let nodes: Vec<FileNode> = list(responses.get("g"))?;
346    let by_id: BTreeMap<&str, &FileNode> = nodes.iter().map(|node| (node.id.as_str(), node)).collect();
347    Ok(hits
348        .iter()
349        .filter_map(|id| by_id.get(id.as_str()))
350        .map(|node| {
351            let mut path = Vec::new();
352            let mut next = node.parent_id.as_deref();
353            while let Some(parent) = next.and_then(|id| by_id.get(id)) {
354                if path.len() > 64 {
355                    break;
356                }
357                path.insert(0, (*parent).clone());
358                next = parent.parent_id.as_deref();
359            }
360            Found { node: (*node).clone(), path }
361        })
362        .collect())
363}
364
365/// Removes a node and all it holds, for good.
366pub async fn destroy(client: &Client, account: &str, id: &str) -> Result<()> {
367    client
368        .set(&USING, "FileNode", account, json!({ "destroy": [id] }), json!({ "onDestroyRemoveChildren": true }))
369        .await?;
370    Ok(())
371}
372
373/// A media type for a file name, from its extension; `application/octet-stream` for what is not known.
374pub fn media_type(name: &str) -> &'static str {
375    let extension = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()).unwrap_or_default();
376    match extension.as_str() {
377        "txt" | "text" | "log" => "text/plain",
378        "md" => "text/markdown",
379        "csv" => "text/csv",
380        "html" | "htm" => "text/html",
381        "css" => "text/css",
382        "json" => "application/json",
383        "xml" => "application/xml",
384        "pdf" => "application/pdf",
385        "zip" => "application/zip",
386        "gz" => "application/gzip",
387        "png" => "image/png",
388        "jpg" | "jpeg" => "image/jpeg",
389        "gif" => "image/gif",
390        "webp" => "image/webp",
391        "svg" => "image/svg+xml",
392        "mp3" => "audio/mpeg",
393        "mp4" => "video/mp4",
394        "ics" => "text/calendar",
395        "vcf" => "text/vcard",
396        "eml" => "message/rfc822",
397        "odt" => "application/vnd.oasis.opendocument.text",
398        "ods" => "application/vnd.oasis.opendocument.spreadsheet",
399        "docx" => "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
400        "xlsx" => "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
401        "pptx" => "application/vnd.openxmlformats-officedocument.presentationml.presentation",
402        _ => "application/octet-stream",
403    }
404}
405
406/// Uploads a local file into a directory. A file of the same name there gets it as new content – what it had stays
407/// as a version; otherwise a new file is made (a name taken by a directory gets a number).
408pub async fn upload_file(
409    client: &Client,
410    account: &str,
411    parent: Option<&str>,
412    path: &Path,
413    existing: Option<&FileNode>,
414    progress: impl FnMut(u64, u64) + Send + 'static,
415) -> Result<()> {
416    let file = tokio::fs::File::open(path).await?;
417    let metadata = file.metadata().await?;
418    let name = path
419        .file_name()
420        .and_then(|name| name.to_str())
421        .ok_or_else(|| Error::Protocol("the file name is not UTF-8".into()))?
422        .to_owned();
423    let kind = media_type(&name);
424    let upload = client.upload(account, kind, file, metadata.len(), progress).await?;
425    let modified = metadata
426        .modified()
427        .ok()
428        .map(|time| chrono::DateTime::<chrono::Utc>::from(time).to_rfc3339_opts(chrono::SecondsFormat::Secs, true));
429    match existing.filter(|node| !node.is_directory()) {
430        Some(node) => {
431            let update = json!({ "update": { node.id.as_str(): {
432                "blobId": upload.blob_id, "mediaType": kind, "modified": modified,
433            } } });
434            client.set(&USING, "FileNode", account, update, json!({})).await?;
435        }
436        None => {
437            let create = json!({ "create": { "f": {
438                "@type": "File", "parentId": parent, "name": name, "blobId": upload.blob_id,
439                "mediaType": kind, "modified": modified,
440            } } });
441            client.set(&USING, "FileNode", account, create, json!({ "onExists": "rename" })).await?;
442        }
443    }
444    Ok(())
445}
446
447/// A content of a file: the current one or an earlier version.
448#[derive(Debug, Clone, Deserialize)]
449#[serde(rename_all = "camelCase")]
450pub struct Version {
451    pub id: String,
452    pub blob_id: String,
453    #[serde(default)]
454    pub size: u64,
455    #[serde(default)]
456    pub media_type: Option<String>,
457    #[serde(default)]
458    pub modified: Option<String>,
459    #[serde(default)]
460    pub created: Option<String>,
461    #[serde(default)]
462    pub is_current: bool,
463}
464
465/// The current content and the earlier versions of a file, newest first.
466pub async fn versions(client: &Client, account: &str, node: &str) -> Result<Vec<Version>> {
467    let result = client.call(&USING, "FileVersion/get", json!({ "accountId": account, "nodeId": node })).await?;
468    list(Some(&result))
469}
470
471/// Makes an earlier version the file's content again.
472pub async fn restore(client: &Client, account: &str, node: &str, version: &Version) -> Result<()> {
473    let update = json!({ "update": { node: { "blobId": version.blob_id } } });
474    client.set(&USING, "FileNode", account, update, json!({})).await?;
475    Ok(())
476}
477
478/// The levels a client offers when sharing, as the web app has them.
479#[derive(Debug, Clone, Copy, PartialEq, Eq)]
480pub enum Level {
481    Read,
482    Edit,
483    Full,
484}
485
486impl Level {
487    pub fn rights(self) -> Rights {
488        let edit = self != Level::Read;
489        Rights {
490            may_read: true,
491            may_add_children: edit,
492            may_rename: edit,
493            may_delete: edit,
494            may_modify_content: edit,
495            may_share: self == Level::Full,
496        }
497    }
498
499    pub fn of(rights: &Rights) -> Self {
500        if rights.may_share {
501            Level::Full
502        } else if rights.may_modify_content || rights.may_add_children {
503            Level::Edit
504        } else {
505            Level::Read
506        }
507    }
508
509    pub fn name(self) -> &'static str {
510        match self {
511            Level::Read => "read",
512            Level::Edit => "edit",
513            Level::Full => "edit and share",
514        }
515    }
516
517    pub fn next(self) -> Self {
518        match self {
519            Level::Read => Level::Edit,
520            Level::Edit => Level::Full,
521            Level::Full => Level::Read,
522        }
523    }
524}
525
526impl Rights {
527    fn json(&self) -> Value {
528        json!({
529            "mayRead": self.may_read, "mayAddChildren": self.may_add_children, "mayRename": self.may_rename,
530            "mayDelete": self.may_delete, "mayModifyContent": self.may_modify_content, "mayShare": self.may_share,
531        })
532    }
533}
534
535/// Sets with whom a directory is shared, replacing what was (draft-ietf-jmap-filenode, 11).
536pub async fn share(client: &Client, account: &str, node: &str, with: &[(String, Level)]) -> Result<()> {
537    let share_with: serde_json::Map<String, Value> =
538        with.iter().map(|(principal, level)| (principal.clone(), level.rights().json())).collect();
539    let update = json!({ "update": { node: { "shareWith": share_with } } });
540    client.set(&USING, "FileNode", account, update, json!({})).await?;
541    Ok(())
542}
543
544/// People and groups of the tenant whose name or address holds `text` (RFC 9670).
545pub async fn find_principals(client: &Client, text: &str) -> Result<Vec<Principal>> {
546    let account = client.primary(PRINCIPALS)?;
547    let filter = if text.trim().is_empty() { json!({}) } else { json!({ "text": text.trim() }) };
548    let responses = client
549        .request(
550            &USING,
551            vec![
552                ("Principal/query", json!({ "accountId": account, "filter": filter, "limit": 20 }), "q"),
553                ("Principal/get", json!({ "accountId": account, "#ids": ids_of("q", "Principal/query") }), "g"),
554            ],
555        )
556        .await?;
557    list(responses.get("g"))
558}
559
560/// A person or group of the tenant (RFC 9670).
561#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
562pub struct Principal {
563    pub id: String,
564    #[serde(rename = "type", default)]
565    pub kind: String,
566    #[serde(default)]
567    pub name: String,
568    #[serde(default)]
569    pub email: Option<String>,
570}
571
572/// The people and groups named – those a directory is shared with.
573pub async fn principals(client: &Client, ids: &[&str]) -> Result<Vec<Principal>> {
574    if ids.is_empty() {
575        return Ok(Vec::new());
576    }
577    let account = client.primary(PRINCIPALS)?;
578    let result = client.call(&USING, "Principal/get", json!({ "accountId": account, "ids": ids })).await?;
579    list(Some(&result))
580}
581
582/// A link to a file or folder for people without an account; its address is known only when it is made.
583#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
584#[serde(rename_all = "camelCase")]
585pub struct FileLink {
586    pub id: String,
587    pub node_id: String,
588    #[serde(default)]
589    pub expires: Option<String>,
590    #[serde(default)]
591    pub has_password: bool,
592    #[serde(default = "yes")]
593    pub may_read: bool,
594    #[serde(default)]
595    pub may_add: bool,
596    #[serde(default)]
597    pub created: Option<String>,
598}
599
600fn yes() -> bool {
601    true
602}
603
604/// What a link lets people do: view, view and add files, or only add them (a drop box) – the last two for folders.
605#[derive(Debug, Clone, Copy, PartialEq, Eq)]
606pub enum LinkMode {
607    View,
608    Add,
609    Drop,
610}
611
612impl LinkMode {
613    pub fn of(link: &FileLink) -> Self {
614        match (link.may_read, link.may_add) {
615            (_, false) => LinkMode::View,
616            (true, true) => LinkMode::Add,
617            (false, true) => LinkMode::Drop,
618        }
619    }
620
621    pub fn name(self) -> &'static str {
622        match self {
623            LinkMode::View => "view and download",
624            LinkMode::Add => "view, download and add files",
625            LinkMode::Drop => "only add files (drop box)",
626        }
627    }
628
629    /// The next mode; for a file there is only one.
630    pub fn next(self, folder: bool) -> Self {
631        match self {
632            _ if !folder => LinkMode::View,
633            LinkMode::View => LinkMode::Add,
634            LinkMode::Add => LinkMode::Drop,
635            LinkMode::Drop => LinkMode::View,
636        }
637    }
638}
639
640/// Whether the server makes links at all.
641pub fn links_available(client: &Client) -> bool {
642    client.session.capabilities.contains_key(FILE_LINKS)
643}
644
645/// The links to a node.
646pub async fn links(client: &Client, account: &str, node: &str) -> Result<Vec<FileLink>> {
647    let result =
648        client.call(&[FILENODE, FILE_LINKS], "FileLink/get", json!({ "accountId": account, "nodeId": node })).await?;
649    list(Some(&result))
650}
651
652/// Makes a link, with an expiry (a UTCDate) and a password if given; answers its address – the only time it can be
653/// known.
654pub async fn create_link(
655    client: &Client,
656    account: &str,
657    node: &str,
658    mode: LinkMode,
659    expires: Option<&str>,
660    password: Option<&str>,
661) -> Result<String> {
662    let link = json!({
663        "nodeId": node,
664        "mayRead": mode != LinkMode::Drop,
665        "mayAdd": mode != LinkMode::View,
666        "expires": expires,
667        "password": password,
668    });
669    let result =
670        client.set(&[FILENODE, FILE_LINKS], "FileLink", account, json!({ "create": { "l": link } }), json!({})).await?;
671    let token = result
672        .pointer("/created/l/token")
673        .and_then(Value::as_str)
674        .ok_or_else(|| Error::Protocol("the link was made without a token".into()))?;
675    let template = client
676        .session
677        .capabilities
678        .get(FILE_LINKS)
679        .and_then(|capability| capability.get("urlTemplate"))
680        .and_then(Value::as_str)
681        .ok_or_else(|| Error::Protocol("the server names no address for links".into()))?;
682    // The address goes to other people, as the server names it; the client does not open it.
683    Ok(template.replace("{token}", token))
684}
685
686pub async fn remove_link(client: &Client, account: &str, id: &str) -> Result<()> {
687    client.set(&[FILENODE, FILE_LINKS], "FileLink", account, json!({ "destroy": [id] }), json!({})).await?;
688    Ok(())
689}
690
691#[cfg(test)]
692mod tests {
693    use super::*;
694
695    #[test]
696    fn media_types() {
697        assert_eq!(media_type("Report.PDF"), "application/pdf");
698        assert_eq!(media_type("noextension"), "application/octet-stream");
699    }
700
701    #[test]
702    fn sorting() {
703        let node = |kind: &str, name: &str| FileNode {
704            kind: kind.into(),
705            id: name.into(),
706            parent_id: None,
707            name: name.into(),
708            role: None,
709            blob_id: None,
710            size: None,
711            media_type: None,
712            modified: None,
713            my_rights: None,
714            share_with: None,
715            vault: None,
716            vault_key: None,
717            encrypted_name: None,
718            locked: false,
719        };
720        let mut nodes = vec![node("File", "b"), node("Directory", "z"), node("File", "A")];
721        sort(&mut nodes);
722        let names: Vec<_> = nodes.iter().map(|n| n.name.as_str()).collect();
723        assert_eq!(names, ["z", "A", "b"]);
724    }
725}