use crate::ids::WorkspaceId;
use anyhow::Result;
use chrono::Local;
use sqlx::{QueryBuilder, Sqlite, SqliteConnection};
use crate::choices::{TaskPriority, TaskStatus};
use crate::db::task_from_row;
use crate::labels::ensure_label_exists_in_workspace;
use crate::projects::resolve_existing_project_in_workspace;
use crate::queue::{now_seconds, queue_order};
use crate::refs::DisplayRefContext;
use super::fragments;
use super::hydration::{TaskHydration, build_task_list_items};
use super::sorting::push_sort;
use super::{
SortDirection, TaskAvailabilityFilter, TaskFilters, TaskIdFilter, TaskListItem, TaskQueryMode,
TaskSort,
};
struct TaskListRead {
filters: TaskFilters,
mode: TaskQueryMode,
sort: TaskSort,
direction: SortDirection,
limit: Option<usize>,
}
pub async fn list_task_items_in_workspace(
conn: &mut SqliteConnection,
workspace_id: &WorkspaceId,
filters: TaskFilters,
mode: TaskQueryMode,
sort: TaskSort,
direction: SortDirection,
) -> Result<Vec<TaskListItem>> {
let display_refs = DisplayRefContext::for_workspace(conn, workspace_id).await?;
list_task_items_with_display_refs(
conn,
workspace_id,
filters,
mode,
sort,
direction,
&display_refs,
)
.await
}
pub async fn list_task_items_with_display_refs(
conn: &mut SqliteConnection,
workspace_id: &WorkspaceId,
filters: TaskFilters,
mode: TaskQueryMode,
sort: TaskSort,
direction: SortDirection,
display_refs: &DisplayRefContext,
) -> Result<Vec<TaskListItem>> {
query_task_items(
conn,
workspace_id,
display_refs,
TaskListRead {
filters,
mode,
sort,
direction,
limit: None,
},
TaskHydration::Detail,
)
.await
}
pub async fn list_task_items_without_activity_in_workspace(
conn: &mut SqliteConnection,
workspace_id: &WorkspaceId,
filters: TaskFilters,
mode: TaskQueryMode,
sort: TaskSort,
direction: SortDirection,
) -> Result<Vec<TaskListItem>> {
let display_refs = DisplayRefContext::for_workspace(conn, workspace_id).await?;
list_task_items_without_activity_with_display_refs(
conn,
workspace_id,
filters,
mode,
sort,
direction,
&display_refs,
)
.await
}
pub async fn list_task_items_without_activity_with_display_refs(
conn: &mut SqliteConnection,
workspace_id: &WorkspaceId,
filters: TaskFilters,
mode: TaskQueryMode,
sort: TaskSort,
direction: SortDirection,
display_refs: &DisplayRefContext,
) -> Result<Vec<TaskListItem>> {
query_task_items(
conn,
workspace_id,
display_refs,
TaskListRead {
filters,
mode,
sort,
direction,
limit: None,
},
TaskHydration::DetailWithoutActivity,
)
.await
}
pub async fn list_bulk_update_task_items_in_workspace(
conn: &mut SqliteConnection,
workspace_id: &WorkspaceId,
filters: TaskFilters,
mode: TaskQueryMode,
sort: TaskSort,
direction: SortDirection,
) -> Result<Vec<TaskListItem>> {
let display_refs = DisplayRefContext::for_workspace(conn, workspace_id).await?;
query_task_items(
conn,
workspace_id,
&display_refs,
TaskListRead {
filters,
mode,
sort,
direction,
limit: None,
},
TaskHydration::BulkUpdate,
)
.await
}
pub async fn list_task_summary_items_in_workspace(
conn: &mut SqliteConnection,
workspace_id: &WorkspaceId,
filters: TaskFilters,
mode: TaskQueryMode,
sort: TaskSort,
direction: SortDirection,
limit: Option<usize>,
) -> Result<Vec<TaskListItem>> {
let display_refs = DisplayRefContext::for_workspace(conn, workspace_id).await?;
query_task_items(
conn,
workspace_id,
&display_refs,
TaskListRead {
filters,
mode,
sort,
direction,
limit,
},
TaskHydration::List,
)
.await
}
async fn query_task_items(
conn: &mut SqliteConnection,
workspace_id: &WorkspaceId,
display_refs: &DisplayRefContext,
read: TaskListRead,
hydration: TaskHydration,
) -> Result<Vec<TaskListItem>> {
let mode = read.mode;
let SelectedTasks {
tasks,
expand_recurring,
task_ids,
remaining_limit,
} = select_tasks(conn, workspace_id, read).await?;
let now_seconds = now_seconds();
let local_today = Local::now().date_naive();
let mut items = build_task_list_items(
conn,
workspace_id,
tasks,
now_seconds,
local_today,
display_refs,
hydration,
)
.await?;
if !expand_recurring {
let at = crate::ids::now_utc();
items = super::recurrence::group_terminal_task_items(conn, workspace_id, items, at).await?;
}
if mode == TaskQueryMode::RankedQueue {
items.sort_by(|a, b| queue_order((&a.task, a.queue), (&b.task, b.queue)));
}
if let TaskIdFilter::Only(order) = task_ids {
items.sort_by_key(|item| {
order
.iter()
.position(|task_id| task_id == &item.task.id)
.unwrap_or(order.len())
});
}
if let Some(limit) = remaining_limit {
items.truncate(limit);
}
Ok(items)
}
struct SelectedTasks {
tasks: Vec<crate::types::Task>,
expand_recurring: bool,
task_ids: TaskIdFilter,
remaining_limit: Option<usize>,
}
async fn select_tasks(
conn: &mut SqliteConnection,
workspace_id: &WorkspaceId,
read: TaskListRead,
) -> Result<SelectedTasks> {
let TaskListRead {
filters,
mode,
sort,
direction,
limit,
} = read;
let expand_recurring = filters.expand_recurring;
let status_filter = filters
.status
.as_deref()
.map(TaskStatus::parse)
.transpose()?;
let status_filters = filters
.statuses
.iter()
.map(|status| TaskStatus::parse(status).map_err(anyhow::Error::from))
.collect::<Result<Vec<_>>>()?;
let hide_done = filters.hide_done && filters.status.is_none() && filters.statuses.is_empty();
let terminal_tasks_excluded = hide_done
|| filters.ready_only
|| filters.blocked_only
|| filters.overdue_only
|| filters.availability == TaskAvailabilityFilter::Upcoming
|| status_filter.is_some_and(|status| !status.is_terminal())
|| (!status_filters.is_empty()
&& status_filters.iter().all(|status| !status.is_terminal()));
let limit_in_sql = limit.is_some()
&& mode == TaskQueryMode::Flat
&& matches!(&filters.task_ids, TaskIdFilter::Unrestricted)
&& (expand_recurring || terminal_tasks_excluded);
if let Some(priority) = filters.priority.as_deref() {
TaskPriority::parse(priority)?;
}
let project = if let Some(project) = filters.project.as_deref() {
Some(resolve_existing_project_in_workspace(conn, workspace_id, project).await?)
} else {
None
};
let label = if let Some(label) = filters.label.as_deref() {
Some(ensure_label_exists_in_workspace(conn, workspace_id, label).await?)
} else {
None
};
let mut metadata = Vec::with_capacity(filters.metadata.len());
for filter in &filters.metadata {
let field =
crate::metadata::find_metadata_field_in_workspace(conn, workspace_id, &filter.key)
.await?
.ok_or_else(|| anyhow::anyhow!("error unknown-metadata-field"))?;
metadata.push((field.id, filter.value.clone()));
}
let mut has_metadata = Vec::with_capacity(filters.has_metadata.len());
for key in &filters.has_metadata {
let field = crate::metadata::find_metadata_field_in_workspace(conn, workspace_id, key)
.await?
.ok_or_else(|| anyhow::anyhow!("error unknown-metadata-field"))?;
has_metadata.push(field.id);
}
let mut missing_metadata = Vec::with_capacity(filters.missing_metadata.len());
for key in &filters.missing_metadata {
let field = crate::metadata::find_metadata_field_in_workspace(conn, workspace_id, key)
.await?
.ok_or_else(|| anyhow::anyhow!("error unknown-metadata-field"))?;
missing_metadata.push(field.id);
}
let mut query = QueryBuilder::<Sqlite>::new(
"SELECT t.id, t.workspace_id, t.title, t.description, t.project_id,
p.key AS project_key, p.prefix AS project_prefix, t.status, t.priority, t.source, t.created_at, t.updated_at,
t.queue_activity_at, t.available_at, t.due_on, t.deleted, t.is_epic
FROM tasks t JOIN projects p ON p.workspace_id = t.workspace_id AND p.id = t.project_id",
);
let mut filters_added = 0;
push_filter_prefix(&mut query, &mut filters_added);
query.push("t.workspace_id = ");
query.push_bind(workspace_id.to_string());
if matches!(&filters.task_ids, TaskIdFilter::Unrestricted) {
push_filter_prefix(&mut query, &mut filters_added);
query.push(fragments::ordinary_task_clause("t"));
}
if filters.deleted_only {
push_filter_prefix(&mut query, &mut filters_added);
query.push("t.deleted = 1");
} else if !filters.include_deleted {
push_filter_prefix(&mut query, &mut filters_added);
query.push("t.deleted = 0");
}
if hide_done {
push_filter_prefix(&mut query, &mut filters_added);
query.push("t.status NOT IN ('done', 'canceled')");
}
if let Some(project) = project {
push_filter_prefix(&mut query, &mut filters_added);
query.push("t.project_id = ");
query.push_bind(project.id);
}
if let Some(status) = filters.status {
push_filter_prefix(&mut query, &mut filters_added);
query.push("t.status = ");
query.push_bind(status);
}
if !filters.statuses.is_empty() {
push_filter_prefix(&mut query, &mut filters_added);
query.push("t.status IN (");
let mut separated = query.separated(", ");
for status in filters.statuses {
separated.push_bind(status);
}
separated.push_unseparated(")");
}
if let Some(priority) = filters.priority {
push_filter_prefix(&mut query, &mut filters_added);
query.push("t.priority = ");
query.push_bind(priority);
}
if let Some(label) = label {
push_filter_prefix(&mut query, &mut filters_added);
query.push("t.id IN (SELECT tl.task_id FROM task_labels tl INDEXED BY idx_task_labels_workspace_label_task WHERE tl.workspace_id = ");
query.push_bind(workspace_id);
query.push(" AND tl.label = ");
query.push_bind(label);
query.push(")");
}
for (field_id, value) in metadata {
push_filter_prefix(&mut query, &mut filters_added);
query.push("EXISTS (SELECT 1 FROM task_metadata m WHERE m.workspace_id = t.workspace_id AND m.task_id = t.id AND m.field_id = ");
query.push_bind(field_id);
query.push(" AND m.value = ");
query.push_bind(value);
query.push(")");
}
for field_id in has_metadata {
push_filter_prefix(&mut query, &mut filters_added);
query.push("EXISTS (SELECT 1 FROM task_metadata m WHERE m.workspace_id = t.workspace_id AND m.task_id = t.id AND m.field_id = ");
query.push_bind(field_id);
query.push(")");
}
for field_id in missing_metadata {
push_filter_prefix(&mut query, &mut filters_added);
query.push("NOT EXISTS (SELECT 1 FROM task_metadata m WHERE m.workspace_id = t.workspace_id AND m.task_id = t.id AND m.field_id = ");
query.push_bind(field_id);
query.push(")");
}
push_availability_filter(&mut query, &mut filters_added, filters.availability);
if filters.overdue_only {
push_filter_prefix(&mut query, &mut filters_added);
query.push(fragments::overdue_task_prefix("t"));
query.push_bind(Local::now().date_naive().format("%Y-%m-%d").to_string());
}
if filters.conflicts_only {
push_filter_prefix(&mut query, &mut filters_added);
query.push("EXISTS (SELECT 1 FROM conflicts c WHERE c.workspace_id = t.workspace_id AND c.task_id = t.id AND c.resolved = 0)");
}
match &filters.task_ids {
TaskIdFilter::Unrestricted => {}
TaskIdFilter::Only(task_ids) if task_ids.is_empty() => {
push_filter_prefix(&mut query, &mut filters_added);
query.push("0 = 1");
}
TaskIdFilter::Only(task_ids) => {
push_filter_prefix(&mut query, &mut filters_added);
query.push("t.id IN (");
let mut separated = query.separated(", ");
for task_id in task_ids {
separated.push_bind(task_id);
}
separated.push_unseparated(")");
}
}
if filters.ready_only || filters.blocked_only {
push_filter_prefix(&mut query, &mut filters_added);
query.push(fragments::open_task_clause("t"));
}
if filters.ready_only {
push_filter_prefix(&mut query, &mut filters_added);
query.push(fragments::ready_dependency_clause("t"));
}
if filters.blocked_only {
push_filter_prefix(&mut query, &mut filters_added);
query.push(fragments::unresolved_blocker_clause("t"));
}
if filters.epics_only {
push_filter_prefix(&mut query, &mut filters_added);
query.push("t.is_epic = 1");
}
if filters.without_epic_link {
push_filter_prefix(&mut query, &mut filters_added);
query.push("NOT EXISTS (SELECT 1 FROM task_epic_links l WHERE l.workspace_id = t.workspace_id AND l.child_task_id = t.id)");
}
if filters.exclude_epics {
push_filter_prefix(&mut query, &mut filters_added);
query.push("t.is_epic = 0");
}
if let Some(search) = filters.search.filter(|search| !search.is_empty()) {
push_filter_prefix(&mut query, &mut filters_added);
query.push("(t.title LIKE ");
query.push_bind(format!("%{search}%"));
query.push(" OR t.description LIKE ");
query.push_bind(format!("%{search}%"));
query.push(")");
}
if mode == TaskQueryMode::Flat {
push_sort(&mut query, sort, direction);
}
if limit_in_sql {
query.push(" LIMIT ");
query.push_bind(i64::try_from(limit.unwrap_or(usize::MAX)).unwrap_or(i64::MAX));
}
let rows = query.build().fetch_all(&mut *conn).await?;
let tasks = rows
.into_iter()
.map(|row| task_from_row(&row))
.collect::<Result<Vec<_>>>()?;
Ok(SelectedTasks {
tasks,
expand_recurring,
task_ids: filters.task_ids,
remaining_limit: limit.filter(|_| !limit_in_sql),
})
}
pub(super) async fn list_base_tasks_in_workspace(
conn: &mut SqliteConnection,
workspace_id: &WorkspaceId,
filters: TaskFilters,
sort: TaskSort,
direction: SortDirection,
limit: Option<usize>,
) -> Result<Vec<crate::types::Task>> {
let SelectedTasks {
mut tasks,
expand_recurring,
task_ids,
remaining_limit,
} = select_tasks(
conn,
workspace_id,
TaskListRead {
filters,
mode: TaskQueryMode::Flat,
sort,
direction,
limit,
},
)
.await?;
if !expand_recurring {
let terminal_ids = tasks
.iter()
.filter(|task| task.status.is_terminal())
.map(|task| task.id.clone())
.collect::<Vec<_>>();
let summaries = super::task_recurrence_summaries(conn, workspace_id, &terminal_ids).await?;
let mut seen = std::collections::HashSet::new();
tasks.retain(|task| {
!task.status.is_terminal()
|| summaries
.get(&task.id)
.is_none_or(|summary| seen.insert(summary.series_id.clone()))
});
}
if let TaskIdFilter::Only(order) = task_ids {
tasks.sort_by_key(|task| {
order
.iter()
.position(|task_id| task_id == &task.id)
.unwrap_or(order.len())
});
}
if let Some(limit) = remaining_limit {
tasks.truncate(limit);
}
Ok(tasks)
}
fn push_availability_filter(
query: &mut QueryBuilder<Sqlite>,
filters_added: &mut usize,
availability: TaskAvailabilityFilter,
) {
match availability {
TaskAvailabilityFilter::All => {}
TaskAvailabilityFilter::Available => {
push_filter_prefix(query, filters_added);
query.push(fragments::available_task_prefix("t"));
query.push_bind(crate::ids::now());
query.push(")");
}
TaskAvailabilityFilter::Upcoming => {
push_filter_prefix(query, filters_added);
query.push("t.deleted = 0 AND t.status NOT IN ('done', 'canceled') AND t.available_at != '' AND t.available_at > ");
query.push_bind(crate::ids::now());
}
}
}
fn push_filter_prefix(query: &mut QueryBuilder<Sqlite>, filters: &mut usize) {
if *filters == 0 {
query.push(" WHERE ");
} else {
query.push(" AND ");
}
*filters += 1;
}
#[cfg(test)]
#[path = "tasks_tests.rs"]
mod tests;