aven 0.1.13

Local-first task manager CLI and sync server
Documentation
use std::path::PathBuf;

use anyhow::Result;

use crate::attachments::optimization::ImageOptimizationPolicy;
use crate::config::resolve_blob_dir;
use crate::ids::new_id;
use crate::operations::AttachmentAddInput;
use crate::tui::app::App;
use crate::tui::attachment_controller::{
    AttachmentCompletion, AttachmentRequest, AttachmentSource,
};
use crate::tui::authoring::PendingTaskAttachment;
use crate::tui::overlay::{MultilineInputState, OverlayRoute, OverlayState};
use crate::tui::platform::{ClipboardImage, read_clipboard_image, read_clipboard_text};
use crate::tui::ui::attachment_is_locally_openable;

pub(crate) const DELETE_ATTACHMENT_TITLE: &str = "Remove image";

impl App {
    pub(super) fn begin_delete_attachment(&mut self, attachment_id: &str, scroll: u16) {
        let Some(attachment) = self
            .store
            .selected_task(self.widgets.table.selected())
            .and_then(|item| {
                item.attachments.iter().find(|attachment| {
                    attachment.attachment_id == attachment_id && !attachment.deleted
                })
            })
        else {
            self.set_warning("image attachment is unavailable");
            return;
        };
        let label = attachment
            .filename
            .as_deref()
            .or(attachment.alt_text.as_deref())
            .unwrap_or("attached image");
        self.pending_delete_attachment = Some(attachment_id.to_string());
        self.detail_context = true;
        self.detail_context_scroll = scroll;
        self.overlay = Some(OverlayState::confirm(
            OverlayRoute::DeleteAttachmentConfirm,
            DELETE_ATTACHMENT_TITLE,
            format!("Remove {label}?"),
        ));
    }

    pub(super) async fn submit_delete_attachment(&mut self) -> Result<()> {
        let Some(attachment_id) = self.pending_delete_attachment.take() else {
            self.set_warning("image removal confirmation is not active");
            self.restore_detail_overlay(true);
            return Ok(());
        };
        let replacement_attachment_id = self.attachment_focus_after_delete(&attachment_id);
        self.store.delete_attachment(&attachment_id).await?;
        self.selected_detail_attachment_id = None;
        self.external_image_exports
            .retain(|(retained_id, _)| retained_id != &attachment_id);
        self.refresh().await?;
        if replacement_attachment_id.is_some() {
            self.selected_detail_child_task_id = None;
            self.selected_detail_attachment_id = replacement_attachment_id;
        }
        self.set_success("removed image");
        self.restore_detail_overlay(true);
        Ok(())
    }

    fn attachment_focus_after_delete(&self, attachment_id: &str) -> Option<String> {
        let attachment_ids = self
            .store
            .selected_task(self.widgets.table.selected())?
            .attachments
            .iter()
            .filter(|attachment| attachment_is_locally_openable(attachment))
            .map(|attachment| attachment.attachment_id.clone())
            .collect::<Vec<_>>();
        let index = attachment_ids
            .iter()
            .position(|candidate| candidate == attachment_id)?;
        attachment_ids.get(index + 1).cloned().or_else(|| {
            index
                .checked_sub(1)
                .and_then(|previous| attachment_ids.get(previous).cloned())
        })
    }

    pub(super) async fn paste_image_from_empty_terminal_paste(&mut self) -> Result<bool> {
        if self.detail_accepts_image_paste() {
            self.paste_detail_image_from_clipboard().await?;
            return Ok(true);
        }
        if self.add_task_accepts_image_paste() {
            self.paste_add_task_image_from_clipboard().await?;
            return Ok(true);
        }
        Ok(false)
    }

    pub(super) async fn paste_detail_image_from_clipboard(&mut self) -> Result<()> {
        if !self.detail_accepts_image_paste() {
            self.set_info("open task detail to attach an image");
            return Ok(());
        }
        match read_clipboard_image()? {
            Some(image) => self.attach_clipboard_image(image).await?,
            None => {
                if let Some(text) = read_clipboard_text()?
                    && self.paste_detail_image_from_text(&text).await?
                {
                    return Ok(());
                }
                self.set_warning("clipboard does not contain an image");
            }
        }
        Ok(())
    }

    pub(super) async fn paste_detail_image_from_text(&mut self, text: &str) -> Result<bool> {
        if !self.detail_accepts_image_paste() {
            return Ok(false);
        }
        let Some(path) = pasted_image_path(text) else {
            return Ok(false);
        };
        let filename = path
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("pasted-image")
            .to_string();
        self.attach_image_source(filename, AttachmentSource::Path(path))?;
        Ok(true)
    }

    pub(super) async fn paste_add_task_image_from_clipboard(&mut self) -> Result<()> {
        if !self.add_task_accepts_image_paste() {
            self.set_info("open add task composer to attach an image");
            return Ok(());
        }
        match read_clipboard_image()? {
            Some(image) => self.attach_add_task_image_bytes(image.filename, image.bytes)?,
            None => {
                if let Some(text) = read_clipboard_text()?
                    && self.paste_add_task_image_from_text(&text)?
                {
                    return Ok(());
                }
                self.set_warning("clipboard does not contain an image");
            }
        }
        Ok(())
    }

    pub(super) fn paste_add_task_image_from_text(&mut self, text: &str) -> Result<bool> {
        if !self.add_task_accepts_image_paste() {
            return Ok(false);
        }
        let Some(path) = pasted_image_path(text) else {
            return Ok(false);
        };
        let bytes = std::fs::read(&path)?;
        let filename = path
            .file_name()
            .and_then(|name| name.to_str())
            .unwrap_or("pasted-image")
            .to_string();
        self.attach_add_task_image_bytes(filename, bytes)?;
        Ok(true)
    }

    fn add_task_accepts_image_paste(&self) -> bool {
        matches!(
            self.overlay,
            Some(OverlayState::AddTask(_))
                | Some(OverlayState::MultilineInput(MultilineInputState {
                    route: OverlayRoute::AddTaskNatural,
                    ..
                }))
        ) && self.pending_shortcut.is_empty()
            && self.footer_choice_mode.is_none()
    }

    fn attach_add_task_image_bytes(&mut self, filename: String, bytes: Vec<u8>) -> Result<()> {
        let pending = PendingTaskAttachment::new(
            new_id(),
            AttachmentAddInput {
                filename: Some(filename),
                alt_text: Some("pasted image".to_string()),
                declared_media_type: None,
                bytes,
                optimization_policy: if self
                    .intake
                    .config()
                    .local
                    .image_optimization
                    .optimizes_pasted_images()
                {
                    ImageOptimizationPolicy::Optimize
                } else {
                    ImageOptimizationPolicy::Preserve
                },
                dedupe_existing: false,
            },
        );
        let Some(is_new) = self.authoring.add_pending_add_task_attachment(pending) else {
            self.set_info("open add task composer to attach an image");
            return Ok(());
        };
        if is_new {
            self.sync_add_task_attachments();
            if let Some(OverlayState::AddTask(state)) = self.overlay.as_mut() {
                state.selected_attachment = state.attachments.len().saturating_sub(1);
            }
            self.set_success("attached image");
        } else {
            self.set_info("image already attached");
        }
        Ok(())
    }

    pub(super) fn remove_selected_add_task_image(&mut self) {
        let Some(index) = self.overlay.as_ref().and_then(|overlay| match overlay {
            OverlayState::AddTask(state) => Some(state.selected_attachment),
            _ => None,
        }) else {
            self.set_info("focus draft images to remove one");
            return;
        };
        let Some(filename) = self.authoring.remove_add_task_attachment(index) else {
            self.set_info("no draft images to remove");
            return;
        };
        self.sync_add_task_attachments();
        self.set_success(format!("removed draft image {filename}"));
    }

    fn sync_add_task_attachments(&mut self) {
        let attachments = self.authoring.add_task_attachment_summaries();
        if let Some(OverlayState::AddTask(state)) = self.overlay.as_mut() {
            state.selected_attachment = state
                .selected_attachment
                .min(attachments.len().saturating_sub(1));
            if attachments.is_empty() && state.focus == crate::tui::authoring::AddTaskStep::Images {
                state.focus = crate::tui::authoring::AddTaskStep::Title;
            }
            state.attachments = attachments;
        }
    }

    fn detail_accepts_image_paste(&self) -> bool {
        matches!(self.overlay, Some(OverlayState::Detail { .. }))
            && self.pending_shortcut.is_empty()
            && self.footer_choice_mode.is_none()
    }

    async fn attach_clipboard_image(&mut self, image: ClipboardImage) -> Result<()> {
        self.attach_image_source(image.filename, AttachmentSource::Bytes(image.bytes))
    }

    fn attach_image_source(&mut self, filename: String, source: AttachmentSource) -> Result<()> {
        let Some(item) = self
            .store
            .selected_task(self.widgets.table.selected())
            .cloned()
        else {
            self.set_info("no selected task to edit");
            return Ok(());
        };
        let db_path = self
            .intake
            .db_path()
            .ok_or_else(|| anyhow::anyhow!("database path is not available"))?;
        let blob_dir = resolve_blob_dir(db_path, self.intake.config())?;
        let optimization_policy = if self
            .intake
            .config()
            .local
            .image_optimization
            .optimizes_pasted_images()
        {
            ImageOptimizationPolicy::Optimize
        } else {
            ImageOptimizationPolicy::Preserve
        };
        let attachment_id = new_id();
        let store = self.store.attachment_worker_context();
        self.attachment_controller.start(AttachmentRequest {
            attachment_id,
            task_id: item.task.id,
            source,
            input: AttachmentAddInput {
                filename: Some(filename),
                alt_text: Some("pasted image".to_string()),
                declared_media_type: None,
                bytes: Vec::new(),
                optimization_policy,
                dedupe_existing: true,
            },
            blob_dir,
            lifecycle: self.intake.config().local.attachment_lifecycle,
            store,
        })?;
        self.set_info("preparing image attachment");
        Ok(())
    }

    pub(super) async fn poll_attachment_work(&mut self) -> Result<bool> {
        let results = self.attachment_controller.poll();
        if results.is_empty() {
            return Ok(false);
        }
        if results.iter().any(|result| {
            matches!(
                result.completion,
                AttachmentCompletion::Success | AttachmentCompletion::Duplicate
            )
        }) {
            self.refresh().await?;
        }
        let completion = if results
            .iter()
            .any(|result| result.completion == AttachmentCompletion::Failure)
        {
            AttachmentCompletion::Failure
        } else if results
            .iter()
            .any(|result| result.completion == AttachmentCompletion::Duplicate)
        {
            AttachmentCompletion::Duplicate
        } else {
            AttachmentCompletion::Success
        };
        match completion {
            AttachmentCompletion::Success => self.set_success("attached image"),
            AttachmentCompletion::Duplicate => self.set_info("image already attached"),
            AttachmentCompletion::Failure => self.set_error("image attachment failed"),
        }
        Ok(true)
    }
}

fn pasted_image_path(text: &str) -> Option<PathBuf> {
    let trimmed = text.trim();
    if trimmed.is_empty() || trimmed.lines().count() != 1 {
        return None;
    }
    let trimmed = trimmed.trim_matches('"').trim_matches('\'');
    let path = if trimmed.starts_with("file:") {
        url::Url::parse(trimmed).ok()?.to_file_path().ok()?
    } else {
        PathBuf::from(trimmed)
    };
    if !path.is_file() {
        return None;
    }
    Some(path)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn pasted_image_path_accepts_supported_single_file() {
        let temp = tempfile::tempdir().unwrap();
        let path = temp.path().join("chart.png");
        std::fs::write(&path, b"png bytes").unwrap();

        assert_eq!(pasted_image_path(path.to_str().unwrap()), Some(path));
    }

    #[test]
    fn pasted_image_path_decodes_file_urls() {
        let temp = tempfile::tempdir().unwrap();
        let path = temp.path().join("chart image.png");
        std::fs::write(&path, b"png bytes").unwrap();
        let url = url::Url::from_file_path(&path).unwrap();

        assert_eq!(pasted_image_path(url.as_str()), Some(path));
    }

    #[test]
    fn pasted_image_path_ignores_plain_text() {
        assert_eq!(pasted_image_path("not an image"), None);
    }
}