Skip to main content

nextcloud_activity/
activity.rs

1//! Nextcloud Activity item model.
2//!
3//! Official documentation:
4//! - [Activity API](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/activity-api.html)
5//! - [Developer manual source](https://github.com/nextcloud/documentation/blob/master/developer_manual/client_apis/activity-api.rst)
6//! - [Activity endpoint specification](https://github.com/nextcloud/activity/blob/master/docs/endpoint-v2.md)
7
8use serde::{Deserialize, Serialize};
9
10use crate::wire::{lossy_opt_u64, lossy_u64};
11
12/// A Nextcloud activity entry in the activity stream.
13///
14/// Returned in the OCS data payload from `GET /ocs/v2.php/apps/activity/api/v2/activity`.
15///
16/// See the [Nextcloud Activity API documentation](https://docs.nextcloud.com/server/latest/developer_manual/client_apis/activity-api.html#activity-element-fields)
17/// and [developer_manual source](https://github.com/nextcloud/documentation/blob/master/developer_manual/client_apis/activity-api.rst).
18#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
19pub struct Activity {
20    /// Unique auto-incremented database ID for the activity event.
21    #[serde(deserialize_with = "lossy_u64")]
22    pub activity_id: u64,
23
24    /// Identifier of the application that generated this activity (e.g. `"files"`).
25    pub app: String,
26
27    /// Activity type identifier (corresponds to the JSON `type` field, e.g. `"file_created"`).
28    #[serde(rename = "type", alias = "activity_type")]
29    pub activity_type: String,
30
31    /// User ID of the actor who performed the action (may be empty if generated by the system).
32    #[serde(default)]
33    pub user: String,
34
35    /// Translated plain-text summary describing the activity.
36    pub subject: String,
37
38    /// Additional translated plain-text message or context, if available.
39    #[serde(default)]
40    pub message: Option<String>,
41
42    /// ISO 8601 formatted timestamp when the activity took place.
43    pub datetime: String,
44
45    /// Full URL pointing to the resource or context associated with this activity.
46    #[serde(default)]
47    pub link: Option<String>,
48
49    /// Category or type of the related target object (e.g. `"files"`).
50    #[serde(default)]
51    pub object_type: Option<String>,
52
53    /// Numeric identifier of the related target object.
54    #[serde(default, deserialize_with = "lossy_opt_u64")]
55    pub object_id: Option<u64>,
56
57    /// Name or path of the related target object (e.g. `"/test/hello.txt"`).
58    #[serde(default)]
59    pub object_name: Option<String>,
60}
61
62impl Activity {
63    /// Creates a new `Activity` with the required core fields.
64    pub fn new(
65        activity_id: u64,
66        app: impl Into<String>,
67        activity_type: impl Into<String>,
68        user: impl Into<String>,
69        subject: impl Into<String>,
70        datetime: impl Into<String>,
71    ) -> Self {
72        Self {
73            activity_id,
74            app: app.into(),
75            activity_type: activity_type.into(),
76            user: user.into(),
77            subject: subject.into(),
78            message: None,
79            datetime: datetime.into(),
80            link: None,
81            object_type: None,
82            object_id: None,
83            object_name: None,
84        }
85    }
86
87    /// Unique activity ID.
88    pub const fn activity_id(&self) -> u64 {
89        self.activity_id
90    }
91
92    /// App that generated the activity.
93    pub fn app(&self) -> &str {
94        &self.app
95    }
96
97    /// Activity type identifier.
98    pub fn activity_type(&self) -> &str {
99        &self.activity_type
100    }
101
102    /// User ID of the actor.
103    pub fn user(&self) -> &str {
104        &self.user
105    }
106
107    /// Plain-text subject.
108    pub fn subject(&self) -> &str {
109        &self.subject
110    }
111
112    /// Plain-text message, if set.
113    pub fn message(&self) -> Option<&str> {
114        self.message.as_deref()
115    }
116
117    /// ISO 8601 timestamp string.
118    pub fn datetime(&self) -> &str {
119        &self.datetime
120    }
121
122    /// Associated URL link, if set.
123    pub fn link(&self) -> Option<&str> {
124        self.link.as_deref()
125    }
126
127    /// Type of the related object, if set.
128    pub fn object_type(&self) -> Option<&str> {
129        self.object_type.as_deref()
130    }
131
132    /// Identifier of the related object, if set.
133    pub const fn object_id(&self) -> Option<u64> {
134        self.object_id
135    }
136
137    /// Name or path of the related object, if set.
138    pub fn object_name(&self) -> Option<&str> {
139        self.object_name.as_deref()
140    }
141
142    /// Sets the optional message text.
143    #[must_use]
144    pub fn with_message(mut self, message: impl Into<String>) -> Self {
145        self.message = Some(message.into());
146        self
147    }
148
149    /// Sets the optional link URL.
150    #[must_use]
151    pub fn with_link(mut self, link: impl Into<String>) -> Self {
152        self.link = Some(link.into());
153        self
154    }
155
156    /// Sets the optional object metadata (type, ID, and name).
157    #[must_use]
158    pub fn with_object(
159        mut self,
160        object_type: impl Into<String>,
161        object_id: u64,
162        object_name: impl Into<String>,
163    ) -> Self {
164        self.object_type = Some(object_type.into());
165        self.object_id = Some(object_id);
166        self.object_name = Some(object_name.into());
167        self
168    }
169}
170
171#[cfg(test)]
172mod tests {
173    use super::*;
174
175    #[test]
176    fn parses_official_doc_example_json() {
177        let json = r#"{
178            "activity_id": 1,
179            "datetime": "2015-11-20T12:49:31+00:00",
180            "app": "files",
181            "type": "file_created",
182            "user": "test1",
183            "subject": "test1 created hello.txt",
184            "subject_rich": {
185                "0": "{user1} created {file1}",
186                "1": {
187                    "user1": {
188                        "type": "user",
189                        "id": "test1",
190                        "name": "Test User"
191                    },
192                    "file1": {
193                        "type": "file",
194                        "id": 23,
195                        "name": "hello.txt",
196                        "path": "/test/hello.txt"
197                    }
198                }
199            },
200            "icon": "https://cloud.example.com/apps/files/img/add-color.svg",
201            "link": "https://cloud.example.com/apps/files/?dir=/test",
202            "object_type": "files",
203            "object_id": 23,
204            "object_name": "/test/hello.txt",
205            "previews": [
206                {
207                    "source": "https://cloud.example.com/core/preview.png?file=/hello.txt&x=150&y=150",
208                    "link": "https://cloud.example.com/apps/files/?dir=/test&scrollto=hello.txt",
209                    "mimeType": "text/plain",
210                    "fileId": 23,
211                    "view": "files",
212                    "isMimeTypeIcon": false,
213                    "filename": "hello.txt"
214                }
215            ]
216        }"#;
217
218        let activity: Activity = serde_json::from_str(json).expect("valid activity json");
219        assert_eq!(activity.activity_id(), 1);
220        assert_eq!(activity.datetime(), "2015-11-20T12:49:31+00:00");
221        assert_eq!(activity.app(), "files");
222        assert_eq!(activity.activity_type(), "file_created");
223        assert_eq!(activity.user(), "test1");
224        assert_eq!(activity.subject(), "test1 created hello.txt");
225        assert_eq!(activity.message(), None);
226        assert_eq!(
227            activity.link(),
228            Some("https://cloud.example.com/apps/files/?dir=/test")
229        );
230        assert_eq!(activity.object_type(), Some("files"));
231        assert_eq!(activity.object_id(), Some(23));
232        assert_eq!(activity.object_name(), Some("/test/hello.txt"));
233    }
234
235    #[test]
236    fn parses_string_ids_and_optional_message() {
237        let json = r#"{
238            "activity_id": "42",
239            "datetime": "2026-10-24T15:00:00+00:00",
240            "app": "comments",
241            "type": "comment_added",
242            "user": "alice",
243            "subject": "alice commented on report.pdf",
244            "message": "Looks great to me!",
245            "link": null,
246            "object_type": "files",
247            "object_id": "100",
248            "object_name": "/report.pdf"
249        }"#;
250
251        let activity: Activity = serde_json::from_str(json).expect("valid activity json");
252        assert_eq!(activity.activity_id(), 42);
253        assert_eq!(activity.activity_type(), "comment_added");
254        assert_eq!(activity.user(), "alice");
255        assert_eq!(activity.message(), Some("Looks great to me!"));
256        assert_eq!(activity.link(), None);
257        assert_eq!(activity.object_id(), Some(100));
258    }
259
260    #[test]
261    fn constructor_and_builder_methods() {
262        let activity = Activity::new(
263            7,
264            "deck",
265            "card_created",
266            "bob",
267            "bob created card",
268            "2026-01-01T00:00:00Z",
269        )
270        .with_message("Card in Todo list")
271        .with_link("https://cloud.example.com/apps/deck/#/boards/1")
272        .with_object("deck_card", 55, "New Task");
273
274        assert_eq!(activity.activity_id, 7);
275        assert_eq!(activity.app, "deck");
276        assert_eq!(activity.activity_type, "card_created");
277        assert_eq!(activity.user, "bob");
278        assert_eq!(activity.subject, "bob created card");
279        assert_eq!(activity.datetime, "2026-01-01T00:00:00Z");
280        assert_eq!(activity.message.as_deref(), Some("Card in Todo list"));
281        assert_eq!(
282            activity.link.as_deref(),
283            Some("https://cloud.example.com/apps/deck/#/boards/1")
284        );
285        assert_eq!(activity.object_type.as_deref(), Some("deck_card"));
286        assert_eq!(activity.object_id, Some(55));
287        assert_eq!(activity.object_name.as_deref(), Some("New Task"));
288    }
289}