aven-core 0.1.34

Core library for the Aven local-first task manager
Documentation
use std::collections::BTreeSet;

use anyhow::Result;

use crate::db::{Database, begin_immediate, get_meta, set_meta};
use crate::ids::WorkspaceId;

const IOS_QUEUE_WORKSPACE_META_KEY: &str = "ios_queue_workspace_id";
const ONBOARDING_META_KEY: &str = "tui_onboarding_version";
const SYNC_ACCESS_REFUSED_AT_META_KEY: &str = "sync_access_refused_at";
const FIRST_LAUNCH_ONBOARDING_VERSION: u32 = 1;

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SyncAccessRefusal {
    pub at: String,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OnboardingStatus {
    Due,
    Complete,
    Established,
}

impl Database {
    /// Returns the last time this installation's sync credentials were refused.
    pub async fn sync_access_refusal(&self) -> Result<Option<SyncAccessRefusal>> {
        Ok(self
            .meta(SYNC_ACCESS_REFUSED_AT_META_KEY)
            .await?
            .map(|at| SyncAccessRefusal { at }))
    }

    /// Remembers an authentication refusal until a later sync succeeds.
    pub async fn record_sync_access_refusal(&self) -> Result<SyncAccessRefusal> {
        let at = crate::ids::now();
        let mut conn = self.acquire_writer().await?;
        set_meta(&mut conn, SYNC_ACCESS_REFUSED_AT_META_KEY, &at).await?;
        Ok(SyncAccessRefusal { at })
    }

    /// Clears a previous authentication refusal after a successful sync.
    pub async fn clear_sync_access_refusal(&self) -> Result<()> {
        let mut conn = self.acquire_writer().await?;
        sqlx::query("DELETE FROM meta WHERE key = ?")
            .bind(SYNC_ACCESS_REFUSED_AT_META_KEY)
            .execute(&mut *conn)
            .await?;
        Ok(())
    }

    pub async fn restore_queue_workspace(&self) -> Result<crate::workspaces::Workspace> {
        let mut conn = self.acquire_writer().await?;
        let mut tx = begin_immediate(&mut conn).await?;
        let workspaces = crate::workspaces::list_workspaces(&mut tx).await?;
        let stored_id = get_meta(&mut tx, IOS_QUEUE_WORKSPACE_META_KEY)
            .await?
            .and_then(|value| value.parse::<WorkspaceId>().ok());
        let selected = stored_id
            .and_then(|id| workspaces.iter().find(|workspace| workspace.id == id))
            .or_else(|| workspaces.first())
            .cloned()
            .ok_or_else(|| anyhow::anyhow!("no available workspace"))?;
        set_meta(&mut tx, IOS_QUEUE_WORKSPACE_META_KEY, selected.id.as_str()).await?;
        tx.commit().await?;
        Ok(selected)
    }

    pub async fn select_queue_workspace(
        &self,
        workspace_id: &WorkspaceId,
    ) -> Result<crate::workspaces::Workspace> {
        let mut conn = self.acquire_writer().await?;
        let mut tx = begin_immediate(&mut conn).await?;
        let workspace = crate::workspaces::workspace_for_id(&mut tx, workspace_id).await?;
        set_meta(&mut tx, IOS_QUEUE_WORKSPACE_META_KEY, workspace.id.as_str()).await?;
        tx.commit().await?;
        Ok(workspace)
    }

    pub async fn onboarding_status(&self) -> Result<OnboardingStatus> {
        let mut conn = self.acquire_reader().await?;
        let marker = get_meta(&mut conn, ONBOARDING_META_KEY).await?;
        if marker_version(marker.as_deref())
            .is_some_and(|version| version >= FIRST_LAUNCH_ONBOARDING_VERSION)
        {
            return Ok(OnboardingStatus::Complete);
        }

        let has_tasks: bool = sqlx::query_scalar("SELECT EXISTS(SELECT 1 FROM tasks)")
            .fetch_one(&mut *conn)
            .await?;
        Ok(if has_tasks {
            OnboardingStatus::Established
        } else {
            OnboardingStatus::Due
        })
    }

    pub async fn complete_onboarding(&self) -> Result<()> {
        let mut conn = self.acquire_writer().await?;
        let mut tx = begin_immediate(&mut conn).await?;
        let marker = get_meta(&mut tx, ONBOARDING_META_KEY).await?;
        if marker_version(marker.as_deref())
            .is_none_or(|version| version < FIRST_LAUNCH_ONBOARDING_VERSION)
        {
            set_meta(
                &mut tx,
                ONBOARDING_META_KEY,
                &FIRST_LAUNCH_ONBOARDING_VERSION.to_string(),
            )
            .await?;
        }
        tx.commit().await?;
        Ok(())
    }
}

fn marker_version(value: Option<&str>) -> Option<u32> {
    value?.trim().parse().ok()
}

/// Database-local sidebar preferences shared across workspaces and projects.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum SidebarSection {
    Views,
    Scope,
    Projects,
}

impl SidebarSection {
    fn meta_key(self) -> &'static str {
        match self {
            Self::Views => "tui_sidebar_views_collapsed",
            Self::Scope => "tui_sidebar_scope_collapsed",
            Self::Projects => "tui_sidebar_projects_collapsed",
        }
    }
}

impl Database {
    pub async fn collapsed_sidebar_sections(&self) -> Result<BTreeSet<SidebarSection>> {
        let mut conn = self.acquire_reader().await?;
        let mut sections = BTreeSet::new();
        for section in [
            SidebarSection::Views,
            SidebarSection::Scope,
            SidebarSection::Projects,
        ] {
            if get_meta(&mut conn, section.meta_key())
                .await?
                .as_deref()
                .map(str::trim)
                == Some("true")
            {
                sections.insert(section);
            }
        }
        Ok(sections)
    }

    pub async fn set_sidebar_section_collapsed(
        &self,
        section: SidebarSection,
        collapsed: bool,
    ) -> Result<()> {
        let mut conn = self.acquire_writer().await?;
        let mut tx = begin_immediate(&mut conn).await?;
        set_meta(
            &mut tx,
            section.meta_key(),
            if collapsed { "true" } else { "false" },
        )
        .await?;
        tx.commit().await?;
        Ok(())
    }
}

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

    #[tokio::test]
    async fn sync_access_refusal_persists_until_cleared() {
        let database = Database::open(std::path::Path::new(":memory:"))
            .await
            .unwrap();
        assert_eq!(database.sync_access_refusal().await.unwrap(), None);

        let recorded = database.record_sync_access_refusal().await.unwrap();
        assert!(!recorded.at.is_empty());
        assert_eq!(
            database.sync_access_refusal().await.unwrap(),
            Some(recorded)
        );

        database.clear_sync_access_refusal().await.unwrap();
        assert_eq!(database.sync_access_refusal().await.unwrap(), None);
    }

    #[tokio::test]
    async fn sidebar_preferences_are_local_and_invalid_values_default_to_expanded() {
        let database = Database::open(std::path::Path::new(":memory:"))
            .await
            .unwrap();
        let before: i64 = {
            let mut conn = database.acquire_reader().await.unwrap();
            sqlx::query_scalar("SELECT COUNT(*) FROM changes")
                .fetch_one(&mut *conn)
                .await
                .unwrap()
        };
        database
            .set_sidebar_section_collapsed(SidebarSection::Scope, true)
            .await
            .unwrap();
        {
            let mut conn = database.acquire_writer().await.unwrap();
            set_meta(&mut conn, SidebarSection::Views.meta_key(), "invalid")
                .await
                .unwrap();
        }
        assert_eq!(
            database.collapsed_sidebar_sections().await.unwrap(),
            BTreeSet::from([SidebarSection::Scope])
        );
        let mut conn = database.acquire_reader().await.unwrap();
        let after: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM changes")
            .fetch_one(&mut *conn)
            .await
            .unwrap();
        assert_eq!(before, after);
    }
}