aven-core 0.1.32

Core library for the Aven local-first task manager
Documentation
use crate::ids::{TaskId, WorkspaceId};
use crate::metadata::TaskMetadataValue;
use std::collections::{HashMap, HashSet};

use crate::query::{
    AttachmentMetadata, EpicRollup, RecentActionItem, TaskDependencyLink, TaskNote,
    TaskRecurrenceSummary,
};
use crate::refs::DisplayRefContext;
use anyhow::Result;
use sqlx::sqlite::SqliteRow;
use sqlx::{QueryBuilder, Row, Sqlite, SqliteConnection};

mod attachments;
mod dependencies;
mod epics;
mod notes;

const SQLITE_BIND_CHUNK_SIZE: usize = 900;

#[derive(Default)]
pub struct TaskEnrichment {
    pub labels_by_task: HashMap<TaskId, Vec<String>>,
    pub notes_by_task: HashMap<TaskId, Vec<TaskNote>>,
    pub task_ids_with_notes: HashSet<TaskId>,
    pub attachments_by_task: HashMap<TaskId, Vec<AttachmentMetadata>>,
    pub live_attachment_counts_by_task: HashMap<TaskId, u32>,
    pub metadata_by_task: HashMap<TaskId, Vec<TaskMetadataValue>>,
    pub activity_by_task: HashMap<TaskId, Vec<RecentActionItem>>,
    pub conflicted_task_ids: HashSet<TaskId>,
    pub unresolved_blocker_counts_by_task: HashMap<TaskId, i64>,
    pub dependent_counts_by_task: HashMap<TaskId, i64>,
    pub depends_on_by_task: HashMap<TaskId, Vec<TaskDependencyLink>>,
    pub blocks_by_task: HashMap<TaskId, Vec<TaskDependencyLink>>,
    pub related_by_task: HashMap<TaskId, Vec<crate::query::TaskRelatedLink>>,
    pub epic_children_by_task: HashMap<TaskId, Vec<TaskDependencyLink>>,
    pub epic_child_dependencies_by_task: HashMap<TaskId, Vec<TaskDependencyLink>>,
    pub epic_parent_by_task: HashMap<TaskId, TaskDependencyLink>,
    pub epic_rollups_by_task: HashMap<TaskId, EpicRollup>,
    pub recurrence_by_task: HashMap<TaskId, TaskRecurrenceSummary>,
}

pub async fn load_task_enrichment(
    conn: &mut SqliteConnection,
    workspace_id: &WorkspaceId,
    task_ids: &[TaskId],
    display_refs: &DisplayRefContext,
) -> Result<TaskEnrichment> {
    load_task_enrichment_with_detail(conn, workspace_id, task_ids, display_refs, true, true).await
}

pub(crate) async fn load_task_enrichment_without_activity(
    conn: &mut SqliteConnection,
    workspace_id: &WorkspaceId,
    task_ids: &[TaskId],
    display_refs: &DisplayRefContext,
) -> Result<TaskEnrichment> {
    load_task_enrichment_with_detail(conn, workspace_id, task_ids, display_refs, true, false).await
}

pub(crate) async fn load_task_list_enrichment(
    conn: &mut SqliteConnection,
    workspace_id: &WorkspaceId,
    task_ids: &[TaskId],
    display_refs: &DisplayRefContext,
) -> Result<TaskEnrichment> {
    load_task_enrichment_with_detail(conn, workspace_id, task_ids, display_refs, false, false).await
}

pub(crate) async fn load_task_bulk_update_enrichment(
    conn: &mut SqliteConnection,
    workspace_id: &WorkspaceId,
    task_ids: &[TaskId],
) -> Result<TaskEnrichment> {
    Ok(TaskEnrichment {
        labels_by_task: labels_for_tasks(conn, workspace_id, task_ids).await?,
        metadata_by_task: crate::metadata::metadata_by_task_ids(conn, workspace_id, task_ids)
            .await?,
        recurrence_by_task: crate::query::task_recurrence_summaries(conn, workspace_id, task_ids)
            .await?,
        ..TaskEnrichment::default()
    })
}

async fn load_task_enrichment_with_detail(
    conn: &mut SqliteConnection,
    workspace_id: &WorkspaceId,
    task_ids: &[TaskId],
    display_refs: &DisplayRefContext,
    include_detail: bool,
    include_activity: bool,
) -> Result<TaskEnrichment> {
    let (notes_by_task, task_ids_with_notes, attachments_by_task, metadata_by_task) =
        if include_detail {
            (
                notes::notes_for_tasks(conn, workspace_id, task_ids).await?,
                HashSet::new(),
                attachments::attachments_for_tasks(conn, workspace_id, task_ids).await?,
                crate::metadata::metadata_by_task_ids(conn, workspace_id, task_ids).await?,
            )
        } else {
            (
                HashMap::new(),
                notes::task_ids_with_notes(conn, workspace_id, task_ids).await?,
                HashMap::new(),
                HashMap::new(),
            )
        };
    let live_attachment_counts_by_task = if include_detail {
        attachments_by_task
            .iter()
            .map(|(task_id, attachments)| {
                (
                    task_id.clone(),
                    attachments.len().min(u32::MAX as usize) as u32,
                )
            })
            .collect()
    } else {
        attachments::live_attachment_counts_for_tasks(conn, workspace_id, task_ids).await?
    };
    let activity_by_task = if include_activity {
        crate::query::task_activity_for_tasks_in_workspace(conn, workspace_id, task_ids).await?
    } else {
        HashMap::new()
    };
    let epic_children_by_task =
        epics::epic_children_for_tasks(conn, workspace_id, task_ids, display_refs).await?;
    let epic_child_dependencies_by_task = if include_detail {
        let child_ids = epic_children_by_task
            .values()
            .flatten()
            .map(|child| child.task_id.clone())
            .collect::<HashSet<_>>()
            .into_iter()
            .collect::<Vec<_>>();
        dependencies::dependency_links_for_tasks(
            conn,
            workspace_id,
            &child_ids,
            false,
            display_refs,
        )
        .await?
    } else {
        HashMap::new()
    };
    Ok(TaskEnrichment {
        labels_by_task: labels_for_tasks(conn, workspace_id, task_ids).await?,
        notes_by_task,
        task_ids_with_notes,
        attachments_by_task,
        live_attachment_counts_by_task,
        metadata_by_task,
        activity_by_task,
        conflicted_task_ids: tasks_with_unresolved_conflicts(conn, workspace_id, task_ids).await?,
        unresolved_blocker_counts_by_task: dependencies::unresolved_blocker_counts_for_tasks(
            conn,
            workspace_id,
            task_ids,
        )
        .await?,
        dependent_counts_by_task: dependencies::dependent_counts_for_tasks(
            conn,
            workspace_id,
            task_ids,
        )
        .await?,
        depends_on_by_task: dependencies::dependency_links_for_tasks(
            conn,
            workspace_id,
            task_ids,
            false,
            display_refs,
        )
        .await?,
        blocks_by_task: dependencies::dependency_links_for_tasks(
            conn,
            workspace_id,
            task_ids,
            true,
            display_refs,
        )
        .await?,
        related_by_task: if include_detail {
            crate::query::related_links_for_tasks(conn, workspace_id, task_ids, display_refs)
                .await?
        } else {
            HashMap::new()
        },
        epic_children_by_task,
        epic_child_dependencies_by_task,
        epic_parent_by_task: epics::epic_parents_for_tasks(
            conn,
            workspace_id,
            task_ids,
            display_refs,
        )
        .await?,
        epic_rollups_by_task: epics::epic_rollups_for_tasks(conn, workspace_id, task_ids).await?,
        recurrence_by_task: crate::query::task_recurrence_summaries(conn, workspace_id, task_ids)
            .await?,
    })
}
pub(crate) async fn labels_for_tasks(
    conn: &mut SqliteConnection,
    workspace_id: &WorkspaceId,
    task_ids: &[TaskId],
) -> Result<HashMap<TaskId, Vec<String>>> {
    let mut labels_by_task = HashMap::new();
    if task_ids.is_empty() {
        return Ok(labels_by_task);
    }
    for chunk in task_ids.chunks(SQLITE_BIND_CHUNK_SIZE) {
        if chunk.is_empty() {
            continue;
        }
        let mut query = QueryBuilder::<Sqlite>::new(
            "SELECT task_id, label FROM task_labels WHERE workspace_id = ",
        );
        query.push_bind(workspace_id);
        query.push(" AND task_id IN (");
        {
            let mut separated = query.separated(", ");
            for task_id in chunk {
                separated.push_bind(task_id);
            }
        }
        query.push(") ORDER BY task_id, label");

        for row in query.build().fetch_all(&mut *conn).await? {
            let task_id: TaskId = row.get("task_id");
            let label: String = row.get("label");
            labels_by_task
                .entry(task_id)
                .or_insert_with(Vec::new)
                .push(label);
        }
    }
    Ok(labels_by_task)
}

fn dependency_link_from_row(
    row: &SqliteRow,
    workspace_id: &WorkspaceId,
    display_refs: &DisplayRefContext,
) -> TaskDependencyLink {
    let task_id: TaskId = row.get("id");
    let project_prefix: String = row.get("project_prefix");
    TaskDependencyLink {
        project_key: row.get("project_key"),
        task_id: task_id.clone(),
        display_ref: display_refs.display_ref_for_id(workspace_id, &project_prefix, &task_id),
        title: row.get("title"),
        status: row.get("status"),
        priority: row.get("priority"),
        unresolved: row.get::<i64, _>("unresolved") != 0,
    }
}
async fn tasks_with_unresolved_conflicts(
    conn: &mut SqliteConnection,
    workspace_id: &WorkspaceId,
    task_ids: &[TaskId],
) -> Result<HashSet<TaskId>> {
    let mut conflicted = HashSet::new();
    if task_ids.is_empty() {
        return Ok(conflicted);
    }
    for chunk in task_ids.chunks(SQLITE_BIND_CHUNK_SIZE) {
        if chunk.is_empty() {
            continue;
        }
        let mut query = QueryBuilder::<Sqlite>::new(
            "SELECT DISTINCT task_id FROM conflicts WHERE workspace_id = ",
        );
        query.push_bind(workspace_id);
        query.push(" AND resolved = 0 AND task_id IN (");
        {
            let mut separated = query.separated(", ");
            for task_id in chunk {
                separated.push_bind(task_id);
            }
        }
        query.push(")");

        for row in query.build().fetch_all(&mut *conn).await? {
            conflicted.insert(row.get("task_id"));
        }
    }
    Ok(conflicted)
}
pub(crate) async fn epic_parents_for_tasks(
    conn: &mut SqliteConnection,
    workspace_id: &WorkspaceId,
    task_ids: &[TaskId],
    display_refs: &DisplayRefContext,
) -> Result<HashMap<TaskId, TaskDependencyLink>> {
    epics::epic_parents_for_tasks(conn, workspace_id, task_ids, display_refs).await
}

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

    #[tokio::test]
    async fn task_enrichment_loads_notes_across_bind_chunks() {
        let (_temp, mut conn) = crate::test_support::test_conn().await;
        let workspace_id = crate::workspaces::default_workspace_id();
        let task_ids = (0..=SQLITE_BIND_CHUNK_SIZE)
            .map(|index| format!("{index:016}").parse().unwrap())
            .collect::<Vec<TaskId>>();

        sqlx::query(
            "INSERT INTO notes(workspace_id, id, task_id, body, created_at, change_id)
             VALUES (?, 'note-first-old', '0000000000000000', 'older', '001', 'change-first-old'),
                    (?, 'note-first-new', '0000000000000000', 'newer', '002', 'change-first-new'),
                    (?, 'note-last', ?, 'last', '003', 'change-last')",
        )
        .bind(&workspace_id)
        .bind(&workspace_id)
        .bind(&workspace_id)
        .bind(task_ids.last().unwrap())
        .execute(&mut *conn)
        .await
        .unwrap();

        let display_refs = DisplayRefContext::for_workspace(&mut conn, &workspace_id)
            .await
            .unwrap();
        let enrichment = load_task_enrichment(&mut conn, &workspace_id, &task_ids, &display_refs)
            .await
            .unwrap();

        assert_eq!(
            enrichment
                .notes_by_task
                .get("0000000000000000")
                .unwrap()
                .iter()
                .map(|note| note.body.as_str())
                .collect::<Vec<_>>(),
            ["newer", "older"]
        );
        assert_eq!(
            enrichment
                .notes_by_task
                .get(task_ids.last().unwrap())
                .unwrap()[0]
                .body,
            "last"
        );
    }
}