use std::{fmt::Display, sync::Arc, time::Duration};
use chrono::{DateTime, NaiveDate, Utc};
use derive_more::Display;
use enumflags2::{BitFlags, bitflags};
use futures_util::TryStreamExt;
use reqwest::Method;
use serde::{Deserialize, Serialize};
use tokio::io::AsyncWriteExt;
use paperless_api_macros::UpdateDto;
use crate::{
Error, Result,
client::PaperlessClient,
id::{
CorrespondentId, CustomFieldId, DocumentId, DocumentTypeId, StoragePathId, TagId, UserId,
},
metadata::{custom_field::DocumentCustomField, permission::ItemPermissions},
note::Note,
share_link::{CreateShareLink, ShareLink, ShareLinkFileVersion},
};
#[derive(Debug, Clone)]
pub struct Document {
data: DocumentData,
client: Arc<PaperlessClient>,
content_is_truncated: bool,
changed_values: BitFlags<ChangedAttributes>,
}
#[derive(Debug, Clone, Deserialize, UpdateDto)]
#[api_info(id = DocumentId)]
pub(crate) struct DocumentData {
#[dto(skip)]
id: DocumentId,
archive_serial_number: Option<ArchiveSerialNumber>,
#[dto(skip)]
original_file_name: String,
#[dto(skip)]
added: DateTime<Utc>,
created: Option<NaiveDate>,
#[dto(skip)]
modified: DateTime<Utc>,
#[dto(skip)]
page_count: Option<u32>,
title: String,
content: String,
tags: Vec<TagId>,
owner: Option<UserId>,
correspondent: Option<CorrespondentId>,
custom_fields: Vec<DocumentCustomField>,
document_type: Option<DocumentTypeId>,
storage_path: Option<StoragePathId>,
#[dto(skip)]
notes: Vec<Note>,
#[serde(flatten)]
#[dto(skip)]
permissions: ItemPermissions,
#[dto(skip)]
mime_type: Option<String>,
}
#[derive(Debug, Display, Clone, Copy, PartialEq, Eq, Hash, Deserialize, Serialize)]
#[repr(transparent)]
pub struct ArchiveSerialNumber(pub u32);
#[bitflags]
#[repr(u16)]
#[derive(Copy, Clone, Debug, PartialEq)]
enum ChangedAttributes {
ArchiveSerialNumber,
Title,
Content,
Tags,
CustomFields,
Correspondent,
DocumentType,
Created,
Owner,
StoragePath,
Deleted,
}
#[derive(Debug, Clone)]
pub enum Content<'a> {
Full(&'a str),
Truncated(&'a str),
}
impl Document {
pub(crate) fn new(
data: DocumentData,
client: Arc<PaperlessClient>,
content_is_truncated: bool,
) -> Self {
Self {
data,
client,
content_is_truncated,
changed_values: BitFlags::default(),
}
}
#[inline]
#[must_use]
pub fn id(&self) -> DocumentId {
self.data.id
}
#[inline]
#[must_use]
pub fn archive_serial_number(&self) -> Option<ArchiveSerialNumber> {
self.data.archive_serial_number
}
#[inline]
#[must_use]
pub fn added(&self) -> &DateTime<Utc> {
&self.data.added
}
#[inline]
#[must_use]
pub fn created(&self) -> Option<&NaiveDate> {
self.data.created.as_ref()
}
#[inline]
#[must_use]
pub fn modified(&self) -> &DateTime<Utc> {
&self.data.modified
}
#[inline]
#[must_use]
pub fn title(&self) -> &str {
&self.data.title
}
#[inline]
#[must_use]
pub fn original_file_name(&self) -> &str {
&self.data.original_file_name
}
#[inline]
#[must_use]
pub fn mime_type(&self) -> Option<&str> {
self.data.mime_type.as_deref()
}
#[inline]
#[must_use]
pub fn correspondent(&self) -> Option<CorrespondentId> {
self.data.correspondent
}
#[inline]
#[must_use]
pub fn owner(&self) -> Option<UserId> {
self.data.owner
}
#[inline]
#[must_use]
pub fn document_type(&self) -> Option<DocumentTypeId> {
self.data.document_type
}
#[inline]
#[must_use]
pub fn page_count(&self) -> Option<u32> {
self.data.page_count
}
#[inline]
#[must_use]
pub fn tags(&self) -> &[TagId] {
&self.data.tags
}
#[inline]
#[must_use]
pub fn custom_fields(&self) -> &[DocumentCustomField] {
&self.data.custom_fields
}
#[inline]
#[must_use]
pub fn content(&self) -> Content<'_> {
if self.content_is_truncated {
Content::Truncated(&self.data.content)
} else {
Content::Full(&self.data.content)
}
}
#[inline]
#[must_use]
pub fn storage_path(&self) -> Option<StoragePathId> {
self.data.storage_path
}
#[inline]
#[must_use]
pub fn notes(&self) -> &[Note] {
&self.data.notes
}
#[inline]
#[must_use]
pub fn permissions(&self) -> &ItemPermissions {
&self.data.permissions
}
#[inline]
pub fn set_archive_serial_number(
&mut self,
archive_serial_number: Option<ArchiveSerialNumber>,
) {
self.data.archive_serial_number = archive_serial_number;
self.changed_values |= ChangedAttributes::ArchiveSerialNumber;
}
pub fn add_tag(&mut self, tag_id: TagId) {
if !self.data.tags.contains(&tag_id) {
self.data.tags.push(tag_id);
self.changed_values |= ChangedAttributes::Tags;
}
}
pub fn remove_tag(&mut self, tag_id: TagId) {
if let Some(index) = self.data.tags.iter().position(|id| *id == tag_id) {
self.data.tags.remove(index);
self.changed_values |= ChangedAttributes::Tags;
}
}
pub fn set_title(&mut self, title: impl Into<String>) {
self.data.title = title.into();
self.changed_values |= ChangedAttributes::Title;
}
pub fn set_content(&mut self, content: impl Into<String>) {
self.data.content = content.into();
self.content_is_truncated = false;
self.changed_values |= ChangedAttributes::Content;
}
pub fn set_custom_field(&mut self, field: CustomFieldId, value: impl Into<String>) {
for custom_field in &mut self.data.custom_fields {
if custom_field.field == field {
custom_field.value = value.into();
self.changed_values |= ChangedAttributes::CustomFields;
return;
}
}
self.data.custom_fields.push(DocumentCustomField {
field,
value: value.into(),
});
self.changed_values |= ChangedAttributes::CustomFields;
}
pub fn remove_custom_field(&mut self, field: CustomFieldId) {
if let Some(index) = self
.data
.custom_fields
.iter()
.position(|custom_field| custom_field.field == field)
{
self.data.custom_fields.remove(index);
self.changed_values |= ChangedAttributes::CustomFields;
}
}
pub fn set_created(&mut self, created: NaiveDate) {
self.data.created = Some(created);
self.changed_values |= ChangedAttributes::Created;
}
pub fn set_owner(&mut self, owner: UserId) {
self.data.owner = Some(owner);
self.changed_values |= ChangedAttributes::Owner;
}
pub fn set_correspondent(&mut self, correspondent: CorrespondentId) {
self.data.correspondent = Some(correspondent);
self.changed_values |= ChangedAttributes::Correspondent;
}
pub fn set_document_type(&mut self, document_type: DocumentTypeId) {
self.data.document_type = Some(document_type);
self.changed_values |= ChangedAttributes::DocumentType;
}
pub fn set_storage_path(&mut self, storage_path: StoragePathId) {
self.data.storage_path = Some(storage_path);
self.changed_values |= ChangedAttributes::StoragePath;
}
#[inline]
#[must_use]
pub fn is_dirty(&self) -> bool {
!self.changed_values.is_empty() && !self.changed_values.contains(ChangedAttributes::Deleted)
}
#[inline]
#[must_use]
pub fn is_deleted(&self) -> bool {
self.changed_values.contains(ChangedAttributes::Deleted)
}
fn fail_if_deleted(&self) -> Result<()> {
if self.is_deleted() {
Err(Error::AlreadyDeleted)
} else {
Ok(())
}
}
pub async fn refresh(&mut self) -> Result<()> {
let document_data = self
.client
.as_ref()
.get_document_data_by_id(self.data.id, Some(!self.content_is_truncated), None)
.await?;
self.data = document_data;
self.changed_values = BitFlags::empty();
Ok(())
}
pub async fn thumbnail(&self) -> Result<Vec<u8>> {
let resp = self
.client
.request_no_body(
Method::GET,
&format!("/api/documents/{}/thumb/", self.data.id),
None,
)
.await?;
Ok(resp
.bytes()
.await
.map_err(|e| Error::Other(format!("Failed to read response body: {e}")))?
.to_vec())
}
pub async fn patch(&mut self) -> Result<()> {
if !self.is_dirty() {
return Ok(());
}
self.fail_if_deleted()?;
let patch = UpdateDocumentData {
title: self
.changed_values
.contains(ChangedAttributes::Title)
.then_some(self.data.title.clone()),
archive_serial_number: self
.changed_values
.contains(ChangedAttributes::ArchiveSerialNumber)
.then_some(self.data.archive_serial_number),
content: self
.changed_values
.contains(ChangedAttributes::Content)
.then_some(self.data.content.clone()),
tags: self
.changed_values
.contains(ChangedAttributes::Tags)
.then_some(self.data.tags.clone()),
custom_fields: self
.changed_values
.contains(ChangedAttributes::CustomFields)
.then_some(self.data.custom_fields.clone()),
correspondent: self
.changed_values
.contains(ChangedAttributes::Correspondent)
.then_some(self.data.correspondent),
document_type: self
.changed_values
.contains(ChangedAttributes::DocumentType)
.then_some(self.data.document_type),
created: self
.changed_values
.contains(ChangedAttributes::Created)
.then_some(self.data.created),
owner: self
.changed_values
.contains(ChangedAttributes::Owner)
.then_some(self.data.owner),
storage_path: self
.changed_values
.contains(ChangedAttributes::StoragePath)
.then_some(self.data.storage_path),
};
self.client
.request(
Method::PATCH,
&format!("/api/documents/{}/", self.data.id),
Some(&patch),
None,
)
.await?;
self.changed_values = BitFlags::empty();
Ok(())
}
pub async fn delete(&mut self) -> Result<()> {
self.client
.request_no_body(
Method::DELETE,
&format!("/api/documents/{}/", self.data.id),
None,
)
.await?;
self.changed_values = BitFlags::from(ChangedAttributes::Deleted);
Ok(())
}
pub async fn get_full_content(&mut self) -> Result<()> {
self.fail_if_deleted()?;
if !self.content_is_truncated {
return Ok(());
}
let doc = self
.client
.get_document_data_by_id(self.data.id, Some(true), None)
.await?;
self.data.content = doc.content;
self.content_is_truncated = false;
Ok(())
}
pub async fn download_to_buffer(&self) -> Result<Vec<u8>> {
self.fail_if_deleted()?;
let resp = self
.client
.request_no_body(
Method::GET,
&format!("/api/documents/{}/download/", self.data.id),
None,
)
.await?;
if resp.status().is_success() {
let bytes = resp
.bytes()
.await
.map_err(|e| Error::Other(format!("Failed to read response body: {e}")))?;
Ok(bytes.to_vec())
} else {
Err(Error::Other(format!(
"Failed to download document: {}",
resp.status()
)))
}
}
pub async fn download_to_file(&self, path: &std::path::Path) -> Result<()> {
self.fail_if_deleted()?;
let resp = self
.client
.request_no_body(
Method::GET,
&format!("/api/documents/{}/download/", self.data.id),
None,
)
.await?;
if !resp.status().is_success() {
return Err(Error::Other(format!(
"Failed to download document: {}",
resp.status()
)));
}
let mut file = tokio::fs::File::create(path)
.await
.map_err(|e| Error::Other(format!("Failed to create file: {e}")))?;
resp.bytes_stream()
.map_err(|e| Error::Other(format!("Failed to read document chunk: {e}")))
.try_fold(&mut file, |file, chunk| async move {
file.write_all(&chunk).await.map_err(|e| {
Error::Other(format!("Failed to save document chunk to file: {e}"))
})?;
Ok(file)
})
.await?;
Ok(())
}
pub fn generate_share_link_duration(
&self,
valid_for: Duration,
version: ShareLinkFileVersion,
) -> impl Future<Output = Result<ShareLink>> {
let expires = Utc::now() + valid_for;
self.generate_share_link_expires(expires, version)
}
pub async fn generate_share_link_expires(
&self,
expires: DateTime<Utc>,
version: ShareLinkFileVersion,
) -> Result<ShareLink> {
self.fail_if_deleted()?;
self.client
.create(&CreateShareLink {
document: self.id(),
expiration: expires,
file_version: version,
})
.await
}
}
impl Display for Content<'_> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Content::Full(text) => write!(f, "{text}"),
Content::Truncated(text) => write!(f, "{text}..."),
}
}
}
impl AsRef<str> for Content<'_> {
fn as_ref(&self) -> &str {
match self {
Content::Full(text) | Content::Truncated(text) => text,
}
}
}