#[derive(uniffi::Enum)]
pub enum MobileFirstDayOfWeek {
Monday,
Sunday,
}
use crate::alarm_index::{AlarmIndex, AlarmIndexEntry};
use crate::cache::Cache;
use crate::client::RustyClient;
use crate::config::Config;
use crate::context::{AppContext, StandardContext};
use crate::controller::TaskController;
use crate::help::HelpTab;
use crate::model::parser::{SyntaxType, tokenize_smart_input};
use crate::model::{AlarmTrigger, DateType, Task};
use crate::storage::{LOCAL_CALENDAR_HREF, LocalCalendarRegistry, LocalStorage};
use crate::store::{FilterOptions, TaskStore, UNCATEGORIZED_ID};
use chrono::{DateTime, NaiveTime, Utc};
use std::collections::{HashMap, HashSet};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::Mutex;
use uuid::Uuid;
use std::sync::OnceLock;
use tokio::runtime::Runtime;
#[derive(Debug, uniffi::Error)]
#[uniffi(flat_error)]
pub enum MobileError {
Generic(String),
}
impl From<String> for MobileError {
fn from(e: String) -> Self {
Self::Generic(e)
}
}
#[cfg(target_os = "android")]
#[unsafe(no_mangle)]
pub extern "system" fn Java_com_trougnouf_cfait_CfaitApplication_initNdkContext<'local>(
mut unowned_env: jni::EnvUnowned<'local>,
_class: jni::objects::JClass<'local>,
context: jni::objects::JObject<'local>,
) {
let _ = unowned_env.with_env(|env| -> jni::errors::Result<()> {
let vm = env.get_java_vm()?;
let global_context = env.new_global_ref(&context)?;
unsafe {
ndk_context::initialize_android_context(
vm.get_raw() as *mut std::ffi::c_void,
global_context.into_raw() as *mut std::ffi::c_void,
);
}
Ok(())
});
}
impl From<&str> for MobileError {
fn from(e: &str) -> Self {
Self::Generic(e.to_string())
}
}
impl From<anyhow::Error> for MobileError {
fn from(e: anyhow::Error) -> Self {
Self::Generic(e.to_string())
}
}
impl std::fmt::Display for MobileError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(
f,
"{}",
match self {
MobileError::Generic(s) => s,
}
)
}
}
impl std::error::Error for MobileError {}
static TOKIO_RUNTIME: OnceLock<Runtime> = OnceLock::new();
#[uniffi::export]
pub fn init_panic_hook(cache_dir: String) {
let cache_path = std::path::PathBuf::from(cache_dir);
std::panic::set_hook(Box::new(move |info| {
let msg = match info.payload().downcast_ref::<&'static str>() {
Some(s) => *s,
None => match info.payload().downcast_ref::<String>() {
Some(s) => &s[..],
None => "Box<dyn Any>",
},
};
let location = info
.location()
.map(|loc| format!("{}:{}", loc.file(), loc.line()))
.unwrap_or_else(|| "unknown".to_string());
log::error!("RUST PANIC: '{}' at {}", msg, location);
let panic_file = cache_path.join("cfait_rust_panic.txt");
if let Ok(mut f) = std::fs::OpenOptions::new()
.create(true)
.append(true)
.open(&panic_file)
{
use std::io::Write;
let _ = writeln!(f, "RUST PANIC: '{}' at {}", msg, location);
}
}));
}
#[uniffi::export]
pub fn init_tokio_runtime() -> Result<(), MobileError> {
if TOKIO_RUNTIME.get().is_none() {
let runtime = Runtime::new().map_err(|e| MobileError::from(e.to_string()))?;
if TOKIO_RUNTIME.set(runtime).is_err() {
#[cfg(target_os = "android")]
log::warn!("Tokio runtime was already initialized by another thread.");
} else {
#[cfg(target_os = "android")]
log::debug!("Tokio runtime initialized.");
}
}
Ok(())
}
#[derive(uniffi::Enum)]
pub enum MobileSyntaxType {
Text,
Priority,
DueDate,
StartDate,
Recurrence,
Duration,
Tag,
Location,
Url,
Geo,
Description,
Reminder,
Calendar,
Pin,
Filter,
Operator,
Goal,
Collection,
WikiLink,
Dependency,
Relation,
Note,
}
impl From<SyntaxType> for MobileSyntaxType {
fn from(t: SyntaxType) -> Self {
match t {
SyntaxType::Text => MobileSyntaxType::Text,
SyntaxType::Priority => MobileSyntaxType::Priority,
SyntaxType::DueDate => MobileSyntaxType::DueDate,
SyntaxType::StartDate => MobileSyntaxType::StartDate,
SyntaxType::Recurrence => MobileSyntaxType::Recurrence,
SyntaxType::Duration => MobileSyntaxType::Duration,
SyntaxType::Tag => MobileSyntaxType::Tag,
SyntaxType::Location => MobileSyntaxType::Location,
SyntaxType::Url => MobileSyntaxType::Url,
SyntaxType::Geo => MobileSyntaxType::Geo,
SyntaxType::Description => MobileSyntaxType::Description,
SyntaxType::Reminder => MobileSyntaxType::Reminder,
SyntaxType::Calendar => MobileSyntaxType::Calendar,
SyntaxType::Pin => MobileSyntaxType::Pin,
SyntaxType::Filter => MobileSyntaxType::Filter,
SyntaxType::Operator => MobileSyntaxType::Operator,
SyntaxType::Goal => MobileSyntaxType::Goal,
SyntaxType::Collection => MobileSyntaxType::Collection,
SyntaxType::WikiLink => MobileSyntaxType::WikiLink,
SyntaxType::Dependency => MobileSyntaxType::Dependency,
SyntaxType::Relation => MobileSyntaxType::Relation,
SyntaxType::Note => MobileSyntaxType::Note,
}
}
}
#[derive(uniffi::Record)]
pub struct MobileSyntaxToken {
pub kind: MobileSyntaxType,
pub start: i32,
pub end: i32,
}
#[derive(uniffi::Record)]
pub struct MobileResolvedDependency {
pub uid: String,
pub summary: String,
pub is_found: bool,
}
#[derive(uniffi::Record)]
pub struct MobileFilterOptions {
pub filter_tags: Vec<String>,
pub filter_locations: Vec<String>,
pub search_query: String,
pub expanded_groups: Vec<String>,
pub match_all_categories: bool,
pub expanded_tags: Vec<String>,
pub expanded_locations: Vec<String>,
pub offset: u32,
pub limit: u32,
}
#[derive(uniffi::Record)]
pub struct MobileWorkSession {
pub start_ms: i64,
pub end_ms: i64,
}
#[derive(uniffi::Record)]
pub struct MobileTaskSummary {
pub uid: String,
pub summary: String,
pub status_string: String,
pub priority: u8,
pub is_done: bool,
pub is_paused: bool,
pub is_note: bool,
pub is_journal: bool,
pub depth: u32,
pub calendar_href: String,
pub visible_categories: Vec<String>,
pub visible_locations: Vec<String>,
pub due_date_iso: Option<String>,
pub is_allday_due: bool,
pub is_due_today: bool,
pub completed_date_iso: Option<String>,
pub start_date_iso: Option<String>,
pub is_allday_start: bool,
pub is_future_start: bool,
pub has_alarms: bool,
pub duration_mins: Option<u32>,
pub duration_max_mins: Option<u32>,
pub percent_complete: Option<u8>,
pub is_blocked: bool,
pub has_subtasks: bool,
pub has_blocking_tasks: bool,
pub has_related_tasks: bool,
pub has_visible_subtasks: bool,
pub tree_location_count: u32,
pub url: Option<String>,
pub geo: Option<String>,
pub time_spent_seconds: u64,
pub last_started_at: Option<i64>,
pub is_recurring: bool,
pub is_relative_recurrence: bool,
pub parent_uid: Option<String>,
pub has_description: bool,
pub description_inline: String,
pub has_related_to: bool,
pub is_search_context: bool,
pub visible: bool,
pub is_collapsed: bool,
}
impl MobileTaskSummary {
fn empty_virtual(vtype: &str, payload: &str, depth: u32) -> Self {
Self {
uid: format!("virtual-{}-{}", vtype, payload),
summary: String::new(),
status_string: String::new(),
priority: 0,
is_done: false,
is_paused: false,
is_note: false,
is_journal: false,
depth,
calendar_href: String::new(),
visible_categories: vec![],
visible_locations: vec![],
due_date_iso: None,
is_allday_due: false,
is_due_today: false,
completed_date_iso: None,
start_date_iso: None,
is_allday_start: false,
is_future_start: false,
has_alarms: false,
duration_mins: None,
duration_max_mins: None,
percent_complete: None,
is_blocked: false,
has_subtasks: false,
has_blocking_tasks: false,
has_related_tasks: false,
has_visible_subtasks: false,
tree_location_count: 0,
url: None,
geo: None,
time_spent_seconds: 0,
last_started_at: None,
is_recurring: false,
is_relative_recurrence: false,
parent_uid: None,
has_description: false,
description_inline: String::new(),
has_related_to: false,
is_search_context: false,
visible: true,
is_collapsed: false,
}
}
}
#[derive(uniffi::Record)]
pub struct MobileTask {
pub uid: String,
pub summary: String,
pub description: String,
pub is_done: bool,
pub percent_complete: Option<u8>,
pub priority: u8,
pub due_date_iso: Option<String>,
pub completed_date_iso: Option<String>,
pub is_allday_due: bool,
pub start_date_iso: Option<String>,
pub is_allday_start: bool,
pub has_alarms: bool,
pub is_future_start: bool,
pub is_due_today: bool,
pub duration_mins: Option<u32>,
pub duration_max_mins: Option<u32>,
pub calendar_href: String,
pub categories: Vec<String>,
pub is_recurring: bool,
pub is_relative_recurrence: bool,
pub parent_uid: Option<String>,
pub smart_string: String,
pub depth: u32,
pub is_blocked: bool,
pub status_string: String,
pub blocked_by_names: Vec<String>,
pub blocked_by_uids: Vec<String>,
pub blocking_uids: Vec<String>,
pub blocking_names: Vec<String>,
pub related_to_uids: Vec<String>,
pub related_to_names: Vec<String>,
pub is_paused: bool,
pub has_subtasks: bool,
pub has_blocking_tasks: bool,
pub has_related_tasks: bool,
pub has_visible_subtasks: bool,
pub tree_location_count: u32,
pub locations: Vec<String>,
pub url: Option<String>,
pub geo: Option<String>,
pub time_spent_seconds: u64,
pub last_started_at: Option<i64>,
pub sessions: Vec<MobileWorkSession>,
pub virtual_type: String,
pub virtual_payload: String,
pub is_collapsed: bool,
pub pinned: bool,
pub has_extractable_subtasks: bool,
pub is_permanent: bool,
pub created_date_iso: Option<String>,
pub last_modified_date_iso: Option<String>,
pub goal_progress_str: Option<String>,
pub goal_target_str: Option<String>,
pub goal_history: Vec<f32>,
pub rrule_history_stat: Option<String>,
pub visible_categories: Vec<String>,
pub visible_locations: Vec<String>,
pub is_search_context: bool,
pub is_note: bool,
pub is_journal: bool,
}
#[derive(uniffi::Record, Clone)]
pub struct MobileCalendar {
pub name: String,
pub href: String,
pub color: Option<String>,
pub is_visible: bool,
pub is_local: bool,
pub is_disabled: bool,
}
#[derive(uniffi::Record)]
pub struct MobileTag {
pub name: String,
pub display_name: String,
pub count: u32,
pub depth: u32,
pub has_children: bool,
pub is_expanded: bool,
pub is_uncategorized: bool,
}
#[derive(uniffi::Record)]
pub struct MobileRelatedTask {
pub uid: String,
pub summary: String,
}
#[derive(uniffi::Record)]
pub struct MobileLocation {
pub name: String,
pub display_name: String,
pub count: u32,
pub depth: u32,
pub has_children: bool,
pub is_expanded: bool,
}
#[derive(uniffi::Record)]
pub struct MobileJournalDay {
pub day: u32,
pub colors: Vec<String>,
}
#[derive(uniffi::Record)]
pub struct MobileDayContext {
pub date: String,
pub total_tracked_mins: u32,
pub due_tasks: Vec<MobileRelatedTask>,
pub started_tasks: Vec<MobileRelatedTask>,
pub ongoing_tasks: Vec<MobileRelatedTask>,
pub session_tasks: Vec<MobileRelatedTask>,
pub completed_tasks: Vec<MobileRelatedTask>,
pub journal_days_in_month: Vec<MobileJournalDay>,
}
#[derive(uniffi::Record)]
pub struct MobileJournalPage {
pub uid: String,
pub title: String,
pub depth: u32,
pub has_children: bool,
pub is_expanded: bool,
pub is_task: bool,
pub calendar_href: String,
}
#[derive(uniffi::Record)]
pub struct MobileViewData {
pub tasks: Vec<MobileTaskSummary>,
pub tags: Vec<MobileTag>,
pub locations: Vec<MobileLocation>,
pub goals: Vec<MobileGoalProgress>,
pub focused_task_uid: Option<String>,
pub journal_context: MobileDayContext,
pub journal_pages: Vec<MobileJournalPage>,
pub selected_journal_date: String,
pub selected_journal_uid: Option<String>,
}
#[derive(uniffi::Record)]
pub struct MobileAlarmInfo {
pub task_uid: String,
pub alarm_uid: String,
pub title: String,
pub body: String,
}
#[derive(uniffi::Enum)]
pub enum MobileGoalType {
Count,
Duration,
}
#[derive(uniffi::Enum)]
pub enum MobileIntervalUnit {
Days,
Weeks,
Months,
Years,
}
#[derive(uniffi::Record)]
pub struct MobileInterval {
pub amount: u32,
pub unit: MobileIntervalUnit,
}
#[derive(uniffi::Record)]
pub struct MobileGoal {
pub goal_type: MobileGoalType,
pub target: u32,
pub interval: MobileInterval,
}
#[derive(uniffi::Record)]
pub struct MobileGoalProgress {
pub key: String,
pub progress_str: String,
pub target_str: String,
pub period_str: String,
pub pct: f32,
pub history: Vec<f32>,
}
#[derive(uniffi::Record)]
pub struct MobileConfig {
pub url: String,
pub username: String,
pub password: String,
pub tls_client_cert_path: Option<String>,
pub tls_client_key_path: Option<String>,
pub default_calendar: Option<String>,
pub allow_insecure: bool,
pub hide_completed: bool,
pub hide_aliases_in_sidebar: bool,
pub strikethrough_completed: bool,
pub tag_aliases: HashMap<String, Vec<String>>,
pub disabled_calendars: Vec<String>,
pub sort_cutoff_days: Option<u32>,
pub sort_standard_by_priority: bool,
pub sort_preset: String,
pub paused_sort_behavior: String,
pub sort_tiebreak_recent: bool,
pub urgent_days: u32,
pub urgent_prio: u8,
pub default_priority: u8,
pub start_grace_period_days: u32,
pub auto_reminders: bool,
pub default_reminder_time: String,
pub snooze_short: u32,
pub create_events_for_tasks: bool,
pub delete_events_on_completion: bool,
pub auto_refresh_interval: u32,
pub trash_retention: u32,
pub max_done_roots: u32,
pub max_done_subtasks: u32,
pub show_ongoing_notifications: bool,
pub show_inline_descriptions: bool,
pub show_quick_filter: bool,
pub quick_filter_term: String,
pub quick_filter_icon: String,
pub sync_settings: bool,
pub goals: HashMap<String, MobileGoal>,
pub default_duration_goal_mins: u32,
pub sessions_count_as_completions: bool,
pub show_calendars_tab: bool,
pub show_tags_tab: bool,
pub show_locations_tab: bool,
pub show_goals_tab: bool,
pub show_journal_tab: bool,
pub show_task_goals_in_sidebar: bool,
pub sort_collections_by_size: bool,
pub expanded_tags: Vec<String>,
pub expanded_locations: Vec<String>,
pub expanded_done_groups: Vec<String>,
pub show_undo_snackbar: bool,
pub first_day_of_week: MobileFirstDayOfWeek,
}
#[derive(uniffi::Record)]
pub struct MobileHelpItem {
pub keys: String,
pub desc: String,
pub example: String,
}
#[derive(uniffi::Record)]
pub struct MobileHelpSection {
pub title: String,
pub items: Vec<MobileHelpItem>,
}
#[derive(uniffi::Record)]
pub struct MobileHelpCategoryData {
pub category: HelpTab,
pub title: String,
pub sections: Vec<MobileHelpSection>,
}
#[derive(uniffi::Record)]
pub struct MobileVersionInfo {
pub version: String,
pub commit: String,
}
#[derive(uniffi::Record)]
pub struct MobileSuggestion {
pub replacement: String,
pub display: String,
pub description: String,
pub range_start: i32,
pub range_end: i32,
}
#[uniffi::export]
impl CfaitMobile {
pub fn get_help_data(&self) -> Vec<MobileHelpCategoryData> {
vec![
MobileHelpCategoryData {
category: HelpTab::Syntax,
title: rust_i18n::t!("syntax_help").to_string(),
sections: crate::help::get_syntax_help()
.into_iter()
.map(|s| MobileHelpSection {
title: s.title,
items: s
.items
.into_iter()
.map(|i| MobileHelpItem {
keys: i.keys,
desc: i.desc,
example: i.example,
})
.collect(),
})
.collect(),
},
MobileHelpCategoryData {
category: HelpTab::About,
title: rust_i18n::t!("help_about").to_string(),
sections: vec![],
},
]
}
pub fn get_version_info(&self) -> MobileVersionInfo {
MobileVersionInfo {
version: env!("CARGO_PKG_VERSION").to_string(),
commit: env!("GIT_COMMIT_HASH").to_string(),
}
}
pub fn get_syntax_help(&self) -> Vec<MobileHelpSection> {
crate::help::get_syntax_help()
.into_iter()
.map(|s| MobileHelpSection {
title: s.title,
items: s
.items
.into_iter()
.map(|i| MobileHelpItem {
keys: i.keys,
desc: i.desc,
example: i.example,
})
.collect(),
})
.collect()
}
pub fn get_available_locales(&self) -> Vec<String> {
rust_i18n::available_locales!()
.iter()
.map(|s| s.to_string())
.collect()
}
pub fn suggest(&self, input: String, cursor_byte_idx: i32) -> Vec<MobileSuggestion> {
let config = crate::config::Config::load(self.ctx.as_ref()).unwrap_or_default();
let cals_list = self.get_calendars();
let calendars = mobile_calendars_to_model(&cals_list);
let cursor_utf16_idx = cursor_byte_idx; let mut byte_to_utf16 = std::collections::BTreeMap::new();
let mut utf16_to_byte = std::collections::BTreeMap::new();
let mut b_pos: usize = 0;
let mut u_pos: i32 = 0;
byte_to_utf16.insert(0, 0);
utf16_to_byte.insert(0, 0);
for c in input.chars() {
b_pos += c.len_utf8();
u_pos += c.len_utf16() as i32;
byte_to_utf16.insert(b_pos, u_pos);
utf16_to_byte.insert(u_pos, b_pos);
}
let actual_byte_idx = utf16_to_byte
.range(..=cursor_utf16_idx)
.next_back()
.map(|(_, &b)| b)
.unwrap_or(b_pos);
let store = self.controller.store.blocking_lock();
if let Some((range, suggs)) = crate::model::autocomplete::suggest(
&input,
actual_byte_idx,
&store,
&config.tag_aliases,
&calendars,
) {
suggs
.into_iter()
.map(|s| {
let start_16 = byte_to_utf16
.range(..=range.start)
.next_back()
.map(|(_, &v)| v)
.unwrap_or(0);
let end_16 = byte_to_utf16
.range(..=range.end)
.next_back()
.map(|(_, &v)| v)
.unwrap_or(start_16);
MobileSuggestion {
replacement: s.replacement,
display: s.display,
description: s.description,
range_start: start_16,
range_end: end_16,
}
})
.collect()
} else {
Vec::new()
}
}
pub fn get_token_context(
&self,
raw_word: String,
kind: MobileSyntaxType,
context_uid: Option<String>,
) -> Option<MobileResolvedDependency> {
let clean_uid = if matches!(kind, MobileSyntaxType::WikiLink) {
crate::model::parser::strip_quotes(
raw_word.trim_start_matches("[[").trim_end_matches("]]"),
)
} else {
let lex_guard = crate::model::parser::LEXICON.read().unwrap();
let lower = raw_word.to_lowercase();
if let Some((p_str, _, _)) = lex_guard.match_prefix(&lower) {
crate::model::parser::strip_quotes(&raw_word[p_str.len()..])
} else {
crate::model::parser::strip_quotes(&raw_word)
}
};
if clean_uid.is_empty() {
return None;
}
let store = self.controller.store.blocking_lock();
match store.resolve_dependency_ref(&clean_uid, context_uid.as_deref()) {
Ok(uid) => {
let summary = store
.get_summary(&uid)
.unwrap_or_else(|| "Resolving...".to_string());
Some(MobileResolvedDependency {
uid,
summary,
is_found: true,
})
}
Err(_) => Some(MobileResolvedDependency {
uid: "".to_string(),
summary: format!("Unknown: {}", clean_uid),
is_found: false,
}),
}
}
pub fn resolve_selection_aliases(&self, selection: String, is_location: bool) -> Vec<String> {
let config = crate::config::Config::load(self.ctx.as_ref()).unwrap_or_default();
crate::model::parser::resolve_selection_aliases(
&selection,
is_location,
&config.tag_aliases,
)
}
pub fn log_message(&self, level: String, tag: String, message: String) {
match level.to_uppercase().as_str() {
"ERROR" => log::error!("[{}] {}", tag, message),
"WARN" => log::warn!("[{}] {}", tag, message),
"INFO" => log::info!("[{}] {}", tag, message),
"DEBUG" => log::debug!("[{}] {}", tag, message),
_ => log::trace!("[{}] {}", tag, message),
}
}
pub async fn reveal_task(&self, uid: String) -> Result<(), MobileError> {
let mut session = self.session.lock().await;
let mut config = crate::config::Config::load(self.ctx.as_ref()).unwrap_or_default();
let mut config_changed = false;
let store_guard = self.controller.store.lock().await;
let href = store_guard.index.get(&uid).cloned();
let task_clone = store_guard.get_task_ref(&uid).cloned();
let mut to_uncollapse = Vec::new();
if let Some(task) = &task_clone {
for p_uid in store_guard.collect_ancestor_uids(&task.uid) {
if let Some(p_task) = store_guard.get_task_ref(&p_uid)
&& p_task.collapsed
{
to_uncollapse.push(p_uid);
}
}
}
drop(store_guard);
if let Some(href) = href {
session.search_term.clear();
session.selected_categories.clear();
session.selected_locations.clear();
if session.active_calendar_href.as_ref() != Some(&href) {
session.active_calendar_href = Some(href.clone());
if config.hidden_calendars.contains(&href) {
config.hidden_calendars.retain(|h| h != &href);
config_changed = true;
}
}
if let Some(task) = task_clone {
if task.status.is_done() && config.hide_completed {
config.hide_completed = false;
config_changed = true;
}
if task.status.is_done() {
let group_key = task.parent_uid.clone().unwrap_or_default();
if !session.expanded_done_groups.contains(&group_key) {
session.expanded_done_groups.push(group_key);
}
}
}
}
if config_changed {
let _ = config.save(self.ctx.as_ref());
}
drop(session);
if !to_uncollapse.is_empty() {
let mut all_actions = Vec::new();
let mut store_guard = self.controller.store.lock().await;
for p_uid in to_uncollapse {
let intent = crate::model::AppIntent::SetTreeCollapse {
uid: p_uid,
collapsed: false,
};
let (actions, _, _, _) = store_guard.apply_task_intent(&intent, &config);
all_actions.extend(actions);
}
drop(store_guard);
if !all_actions.is_empty() {
let _ = self.controller.persist_changes(all_actions).await;
}
}
Ok(())
}
pub fn get_daily_note_uid(&self, date_str: String, calendar_href: String) -> Option<String> {
let date = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d").ok()?;
let store = self.controller.store.blocking_lock();
store
.get_journal_entry(&calendar_href, date)
.map(|t| t.uid.clone())
}
pub fn get_task_tree_markdown(&self, uid: String) -> String {
let store = self.controller.store.blocking_lock();
let cals = load_all_calendar_entries(self.ctx.as_ref());
let is_journal = store
.get_task_ref(&uid)
.map(|t| t.is_journal)
.unwrap_or(false);
crate::model::extractor::serialize_task_tree(&store, &uid, &cals, is_journal)
}
pub fn export_locations_gpx(&self, uid: String) -> Result<String, MobileError> {
let store = self.controller.store.blocking_lock();
let waypoints = store.get_tree_waypoints(&uid);
if waypoints.is_empty() {
return Err(MobileError::from(rust_i18n::t!("no_locations").to_string()));
}
let mut gpx = String::from(
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<gpx version=\"1.1\" creator=\"Cfait\" xmlns=\"http://www.topografix.com/GPX/1/1\">\n",
);
for (name, geo) in waypoints {
let parts: Vec<&str> = geo.split(',').collect();
if parts.len() >= 2 {
let escaped_name = name
.replace('&', "&")
.replace('<', "<")
.replace('>', ">");
gpx.push_str(&format!(
" <wpt lat=\"{}\" lon=\"{}\"><name>{}</name></wpt>\n",
parts[0].trim(),
parts[1].trim(),
escaped_name
));
}
}
gpx.push_str("</gpx>");
Ok(gpx)
}
}
fn populate_transient(t: &mut Task, store: &TaskStore, aliases: &HashMap<String, Vec<String>>) {
t.has_blocking_tasks = store.has_tasks_blocking(&t.uid);
t.has_related_tasks = store.has_tasks_related_to(&t.uid);
t.has_subtasks = store.children_index.contains_key(&t.uid);
let now = Utc::now();
t.is_future_start = t
.dtstart
.as_ref()
.map(|start| start.to_start_comparison_time() > now)
.unwrap_or(false);
t.is_overdue = t
.due
.as_ref()
.map(|d| !t.status.is_done() && d.to_comparison_time() < now)
.unwrap_or(false);
t.is_blocked = store.is_blocked(t);
let (p_tags, p_loc) = if let Some(p_uid) = &t.parent_uid {
if let Some(p) = store.get_task_ref(p_uid) {
(p.categories.iter().cloned().collect(), p.locations.clone())
} else {
(HashSet::new(), Vec::new())
}
} else {
(HashSet::new(), Vec::new())
};
let (visible_tags, visible_locations) = t.resolve_visual_attributes(&p_tags, &p_loc, aliases);
t.visible_categories = visible_tags;
t.visible_locations = visible_locations;
}
fn task_to_mobile(t: &Task, store: &TaskStore) -> MobileTask {
let smart = t.to_smart_string();
let status_str = format!("{:?}", t.status);
let mut blocked_by_names = Vec::new();
let mut blocked_by_uids = Vec::new();
for dep_uid in &t.dependencies {
if let Some(summary) = store.get_summary(dep_uid) {
blocked_by_names.push(summary);
blocked_by_uids.push(dep_uid.clone());
}
}
let blocking_pairs = store.get_tasks_blocking(&t.uid);
let (blocking_uids, blocking_names): (Vec<String>, Vec<String>) =
blocking_pairs.into_iter().unzip();
let mut related_to_names = Vec::new();
let mut related_to_uids = Vec::new();
for rel_uid in &t.related_to {
if let Some(summary) = store.get_summary(rel_uid) {
related_to_names.push(summary);
related_to_uids.push(rel_uid.clone());
}
}
let (due_iso, due_allday) = match &t.due {
Some(DateType::AllDay(d)) => (Some(d.format("%Y-%m-%d").to_string()), true),
Some(DateType::Specific(dt)) => (Some(dt.to_rfc3339()), false),
Some(DateType::Month(y, m)) => (Some(format!("{:04}-{:02}", y, m)), true),
Some(DateType::Year(y)) => (Some(format!("{:04}", y)), true),
None => (None, false),
};
let (start_iso, start_allday) = match &t.dtstart {
Some(DateType::AllDay(d)) => (Some(d.format("%Y-%m-%d").to_string()), true),
Some(DateType::Specific(dt)) => (Some(dt.to_rfc3339()), false),
Some(DateType::Month(y, m)) => (Some(format!("{:04}-{:02}", y, m)), true),
Some(DateType::Year(y)) => (Some(format!("{:04}", y)), true),
None => (None, false),
};
let completed_date_iso = t.completion_date().map(|d| d.to_rfc3339());
let created_date_iso = t.created_date().map(|d| d.to_rfc3339());
let last_modified_date_iso = t.last_modified_date().map(|d| d.to_rfc3339());
let has_alarms = !t
.alarms
.iter()
.all(|a| a.acknowledged.is_some() || a.is_snooze());
let tree_location_count = store.count_tree_locations(&t.uid) as u32;
let (v_type, v_payload) = ("none".to_string(), "".to_string());
let mut goal_progress_str = None;
let mut goal_target_str = None;
let mut goal_history = Vec::new();
let mut rrule_history_stat = None;
if let Some(rrule) = &t.rrule {
let (count, _, key) = store.get_completion_history_stats(&t.uid, rrule);
if count > 0 {
let window_str = rust_i18n::t!(key).to_string();
let text = if count == 1 {
rust_i18n::t!("habit_completed_in_past.one", window = window_str).to_string()
} else {
rust_i18n::t!(
"habit_completed_in_past.other",
count = count,
window = window_str
)
.to_string()
};
rrule_history_stat = Some(text);
}
}
if let Some(goal) = &t.goal {
let progress = store.calculate_goal_progress(&format!("task:{}", t.uid), goal);
let (c_str, t_str) = if goal.goal_type == crate::config::GoalType::Duration {
crate::model::parser::format_goal_duration(progress, goal.target)
} else {
(progress.to_string(), goal.target.to_string())
};
goal_progress_str = Some(c_str);
goal_target_str = Some(goal.format_target_display(&t_str));
}
if let Some(goal) = t.get_effective_goal() {
goal_history = store.calculate_goal_history(&format!("task:{}", t.uid), &goal, 7);
}
MobileTask {
uid: t.uid.clone(),
summary: t.summary.clone(),
description: t.description.clone(),
is_done: t.status.is_done(),
percent_complete: t.percent_complete,
priority: t.priority,
due_date_iso: due_iso,
completed_date_iso,
is_allday_due: due_allday,
start_date_iso: start_iso,
is_allday_start: start_allday,
has_alarms,
is_future_start: t.is_future_start,
is_due_today: t.is_due_today,
duration_mins: t.estimated_duration,
duration_max_mins: t.estimated_duration_max,
calendar_href: t.calendar_href.clone(),
categories: t.categories.clone(),
is_recurring: t.rrule.is_some(),
is_relative_recurrence: t.is_relative_recurrence(),
parent_uid: t.parent_uid.clone(),
smart_string: smart,
depth: t.depth as u32,
is_blocked: t.is_blocked,
status_string: status_str,
blocked_by_names,
blocked_by_uids,
blocking_uids,
blocking_names,
related_to_uids,
related_to_names,
is_paused: t.is_paused(),
has_subtasks: t.has_subtasks,
has_blocking_tasks: t.has_blocking_tasks,
has_related_tasks: t.has_related_tasks,
has_visible_subtasks: t.has_visible_subtasks,
tree_location_count,
locations: t.locations.clone(),
url: t.url.clone(),
geo: t.geo.clone(),
time_spent_seconds: store.get_aggregated_time_seconds(&t.uid),
last_started_at: t.last_started_at,
sessions: t
.sessions
.iter()
.map(|s| MobileWorkSession {
start_ms: s.start * 1000,
end_ms: s.end * 1000,
})
.collect(),
virtual_type: v_type,
virtual_payload: v_payload,
is_collapsed: t.collapsed,
pinned: t.pinned,
has_extractable_subtasks: t.has_extractable_subtasks(),
is_permanent: t.permanent,
created_date_iso,
last_modified_date_iso,
goal_progress_str,
goal_target_str,
goal_history,
rrule_history_stat,
visible_categories: t.visible_categories.clone(),
visible_locations: t.visible_locations.clone(),
is_search_context: t.is_search_context,
is_note: t.is_note || t.is_journal,
is_journal: t.is_journal,
}
}
fn task_to_summary(t: &Task, store: &TaskStore) -> MobileTaskSummary {
let status_str = format!("{:?}", t.status);
let (due_iso, due_allday) = match &t.due {
Some(DateType::AllDay(d)) => (Some(d.format("%Y-%m-%d").to_string()), true),
Some(DateType::Specific(dt)) => (Some(dt.to_rfc3339()), false),
Some(DateType::Month(y, m)) => (Some(format!("{:04}-{:02}", y, m)), true),
Some(DateType::Year(y)) => (Some(format!("{:04}", y)), true),
None => (None, false),
};
let (start_iso, start_allday) = match &t.dtstart {
Some(DateType::AllDay(d)) => (Some(d.format("%Y-%m-%d").to_string()), true),
Some(DateType::Specific(dt)) => (Some(dt.to_rfc3339()), false),
Some(DateType::Month(y, m)) => (Some(format!("{:04}-{:02}", y, m)), true),
Some(DateType::Year(y)) => (Some(format!("{:04}", y)), true),
None => (None, false),
};
let has_alarms = !t
.alarms
.iter()
.all(|a| a.acknowledged.is_some() || a.is_snooze());
let tree_location_count = store.count_tree_locations(&t.uid) as u32;
let completed_date_iso = t.completion_date().map(|d| d.to_rfc3339());
MobileTaskSummary {
uid: t.uid.clone(),
summary: t.summary.clone(),
status_string: status_str,
priority: t.priority,
is_done: t.status.is_done(),
is_paused: t.is_paused(),
is_note: t.is_note || t.is_journal,
is_journal: t.is_journal,
depth: t.depth as u32,
calendar_href: t.calendar_href.clone(),
visible_categories: t.visible_categories.clone(),
visible_locations: t.visible_locations.clone(),
due_date_iso: due_iso,
is_allday_due: due_allday,
is_due_today: t.is_due_today,
completed_date_iso,
start_date_iso: start_iso,
is_allday_start: start_allday,
is_future_start: t.is_future_start,
has_alarms,
duration_mins: t.estimated_duration,
duration_max_mins: t.estimated_duration_max,
percent_complete: t.percent_complete,
is_blocked: t.is_blocked,
has_subtasks: t.has_subtasks,
has_blocking_tasks: t.has_blocking_tasks,
has_related_tasks: t.has_related_tasks,
has_visible_subtasks: t.has_visible_subtasks,
tree_location_count,
url: t.url.clone(),
geo: t.geo.clone(),
time_spent_seconds: store.get_aggregated_time_seconds(&t.uid),
last_started_at: t.last_started_at,
is_recurring: t.rrule.is_some(),
is_relative_recurrence: t.is_relative_recurrence(),
parent_uid: t.parent_uid.clone(),
has_description: !t.description.is_empty(),
description_inline: if !t.description.is_empty() {
t.description
.lines()
.filter(|l| !l.trim().is_empty())
.take(3)
.collect::<Vec<&str>>()
.join("\n")
} else {
String::new()
},
has_related_to: !t.related_to.is_empty(),
is_search_context: t.is_search_context,
visible: true,
is_collapsed: t.collapsed,
}
}
fn load_all_calendar_entries(ctx: &dyn AppContext) -> Vec<crate::model::CalendarListEntry> {
let mut cals = crate::cache::Cache::load_calendars(ctx).unwrap_or_default();
if let Ok(locals) = crate::storage::LocalCalendarRegistry::load(ctx) {
cals.extend(locals);
}
cals
}
fn mobile_calendars_to_model(cals: &[MobileCalendar]) -> Vec<crate::model::CalendarListEntry> {
cals.iter()
.map(|c| crate::model::CalendarListEntry {
name: c.name.clone(),
href: c.href.clone(),
color: c.color.clone(),
supports_vjournal: None,
})
.collect()
}
fn firing_entries_to_mobile(
store: &TaskStore,
entries: Vec<AlarmIndexEntry>,
) -> Vec<MobileAlarmInfo> {
entries
.into_iter()
.filter(|e| {
store.get_task_ref(&e.task_uid).is_some_and(|task| {
!task.status.is_done()
&& task.calendar_href != crate::storage::LOCAL_TRASH_HREF
&& task.calendar_href != "local://recovery"
})
})
.map(|e| MobileAlarmInfo {
task_uid: e.task_uid,
alarm_uid: e.alarm_uid,
title: e.task_title,
body: e
.description
.unwrap_or_else(|| rust_i18n::t!("reminder").to_string()),
})
.collect()
}
#[derive(uniffi::Object)]
pub struct CfaitMobile {
controller: TaskController,
alarm_index_cache: Arc<Mutex<Option<AlarmIndex>>>,
ctx: Arc<dyn AppContext>,
session: Arc<Mutex<crate::model::SessionState>>,
}
fn load_mobile_config_with_credentials(ctx: &dyn AppContext) -> Config {
match Config::load_with_credentials(ctx) {
Ok(config) => config,
Err(err) => {
#[cfg(target_os = "android")]
log::warn!(
"Falling back to config-only load after credential load failure: {}",
err
);
#[cfg(not(target_os = "android"))]
let _ = err;
Config::load(ctx).unwrap_or_default()
}
}
}
fn apply_mobile_credentials_update(config: &mut Config, user: &str, pass: &str) {
let previous_username = config.username.clone();
config.username = user.to_string();
if !pass.is_empty() {
config.password = pass.to_string();
} else if !previous_username.is_empty() && previous_username != user {
config.password.clear();
}
}
#[uniffi::export]
impl CfaitMobile {
#[uniffi::constructor]
pub fn new(android_files_dir: String) -> Self {
let ctx: Arc<dyn AppContext> =
Arc::new(StandardContext::new(Some(PathBuf::from(android_files_dir))));
let config = crate::config::Config::load(ctx.as_ref()).unwrap_or_default();
crate::system::init_logging(
ctx.as_ref(),
false,
Some(config.log_level.to_level_filter()),
);
crate::system::init_keyring();
let store = Arc::new(Mutex::new(TaskStore::new(ctx.clone())));
let client = Arc::new(Mutex::new(None));
let controller = TaskController::new(store, client, ctx.clone());
let config = crate::config::Config::load(ctx.as_ref()).unwrap_or_default();
let session = crate::model::SessionState {
expanded_tags: config.expanded_tags,
expanded_locations: config.expanded_locations,
..Default::default()
};
let c_clone = controller.clone();
if let Some(runtime) = TOKIO_RUNTIME.get() {
runtime.spawn(async move {
let _ = c_clone.prune_trash().await;
});
} else {
#[cfg(target_os = "android")]
log::error!("Tokio runtime not initialized before CfaitMobile::new() was called!");
}
Self {
controller,
alarm_index_cache: Arc::new(Mutex::new(None)),
ctx,
session: Arc::new(Mutex::new(session)),
}
}
pub fn create_debug_export(&self) -> Result<String, MobileError> {
self.create_debug_export_internal()
}
pub fn set_locale(&self, locale: String) {
crate::config::set_locale_with_fallback(&locale);
}
pub fn has_unsynced_changes(&self) -> bool {
!crate::journal::Journal::load(self.ctx.as_ref()).is_empty()
}
pub fn has_any_tasks(&self) -> bool {
self.controller.store.blocking_lock().has_any_tasks()
}
pub fn export_local_ics(&self, calendar_href: String) -> Result<String, MobileError> {
let tasks = LocalStorage::load_for_href(self.ctx.as_ref(), &calendar_href)
.map_err(|e| MobileError::from(e.to_string()))?;
Ok(LocalStorage::to_ics_string(&tasks))
}
pub fn import_local_ics(
&self,
calendar_href: String,
ics_content: String,
) -> Result<String, MobileError> {
if calendar_href == crate::storage::LOCAL_TRASH_HREF || calendar_href == "local://recovery"
{
return Err(MobileError::from(
rust_i18n::t!("error_cannot_import_to_system_calendar").to_string(),
));
}
let count = LocalStorage::import_from_ics(self.ctx.as_ref(), &calendar_href, &ics_content)
.map_err(|e| MobileError::from(e.to_string()))?;
let msg = if count == 1 {
rust_i18n::t!("import_success.one").to_string()
} else {
rust_i18n::t!("import_success.other", count = count).to_string()
};
Ok(msg)
}
pub fn extract_list_prefix(&self, line: String) -> String {
crate::model::extractor::extract_list_prefix(&line)
}
pub fn extract_highlight_terms(&self, query: String) -> Vec<String> {
crate::model::matcher::extract_highlight_terms(&query)
}
pub fn parse_smart_string(&self, input: String, is_search: bool) -> Vec<MobileSyntaxToken> {
let tokens = tokenize_smart_input(&input, is_search);
let mut byte_to_utf16 = std::collections::BTreeMap::new();
let mut byte_pos = 0;
let mut utf16_pos = 0;
byte_to_utf16.insert(0, 0);
for c in input.chars() {
byte_pos += c.len_utf8();
utf16_pos += c.len_utf16();
byte_to_utf16.insert(byte_pos, utf16_pos as i32);
}
tokens
.into_iter()
.map(|t| {
let start_16 = byte_to_utf16
.range(..=t.start)
.next_back()
.map(|(_, &v)| v)
.unwrap_or(0);
let end_16 = byte_to_utf16
.range(..=t.end)
.next_back()
.map(|(_, &v)| v)
.unwrap_or(start_16);
MobileSyntaxToken {
kind: MobileSyntaxType::from(t.kind),
start: start_16,
end: end_16,
}
})
.collect()
}
pub fn undo(&self) -> Result<Option<String>, MobileError> {
let mut history = self.controller.undo_history.blocking_lock();
if let Some(record) = history.pop_undo() {
let mut store = self.controller.store.blocking_lock();
store.apply_actions(&record.reverse);
drop(store);
history.push_redo(record.clone());
let c_clone = self.controller.clone();
if let Some(runtime) = TOKIO_RUNTIME.get() {
runtime.spawn(async move {
let _ = c_clone.persist_changes(record.reverse).await;
});
}
Ok(Some(record.description))
} else {
Ok(None)
}
}
pub fn redo(&self) -> Result<Option<String>, MobileError> {
let mut history = self.controller.undo_history.blocking_lock();
if let Some(record) = history.pop_redo() {
let mut store = self.controller.store.blocking_lock();
store.apply_actions(&record.forward);
drop(store);
history.push_undo(record.clone());
let c_clone = self.controller.clone();
if let Some(runtime) = TOKIO_RUNTIME.get() {
runtime.spawn(async move {
let _ = c_clone.persist_changes(record.forward).await;
});
}
Ok(Some(record.description))
} else {
Ok(None)
}
}
pub async fn empty_trash(&self) -> Result<u32, MobileError> {
let count = self
.controller
.empty_trash()
.await
.map_err(MobileError::from)?;
Ok(count as u32)
}
pub fn get_config(&self) -> MobileConfig {
let c = load_mobile_config_with_credentials(self.ctx.as_ref());
MobileConfig {
url: c.url,
username: c.username,
password: c.password,
tls_client_cert_path: c.tls_client_cert_path,
tls_client_key_path: c.tls_client_key_path,
default_calendar: c.default_calendar,
allow_insecure: c.allow_insecure_certs,
hide_completed: c.hide_completed,
hide_aliases_in_sidebar: c.hide_aliases_in_sidebar,
strikethrough_completed: c.strikethrough_completed,
tag_aliases: c.tag_aliases,
disabled_calendars: c.disabled_calendars,
sort_cutoff_days: c.sort_cutoff_days,
sort_standard_by_priority: c.sort_standard_by_priority,
sort_preset: c.sort_preset.to_string(),
paused_sort_behavior: match c.paused_sort_behavior {
crate::config::PausedSortBehavior::Top => "top".to_string(),
crate::config::PausedSortBehavior::None => "none".to_string(),
crate::config::PausedSortBehavior::Tiebreak => "tiebreak".to_string(),
},
sort_tiebreak_recent: c.sort_tiebreak_recent,
urgent_days: c.urgent_days_horizon,
urgent_prio: c.urgent_priority_threshold,
default_priority: c.default_priority,
start_grace_period_days: c.start_grace_period_days,
auto_reminders: c.auto_reminders,
default_reminder_time: c.default_reminder_time,
snooze_short: c.snooze_short_mins,
create_events_for_tasks: c.create_events_for_tasks,
delete_events_on_completion: c.delete_events_on_completion,
auto_refresh_interval: c.auto_refresh_interval_mins,
trash_retention: c.trash_retention_days,
max_done_roots: c.max_done_roots as u32,
max_done_subtasks: c.max_done_subtasks as u32,
show_ongoing_notifications: c.show_ongoing_notifications,
show_inline_descriptions: c.show_inline_descriptions,
show_quick_filter: c.show_quick_filter,
quick_filter_term: c.quick_filter_term,
quick_filter_icon: c.quick_filter_icon,
sync_settings: c.sync_settings,
goals: c
.goals
.into_iter()
.map(|(k, v)| {
let mt = match v.goal_type {
crate::config::GoalType::Count => MobileGoalType::Count,
crate::config::GoalType::Duration => MobileGoalType::Duration,
};
let m_unit = match v.interval.unit {
crate::config::IntervalUnit::Days => MobileIntervalUnit::Days,
crate::config::IntervalUnit::Weeks => MobileIntervalUnit::Weeks,
crate::config::IntervalUnit::Months => MobileIntervalUnit::Months,
crate::config::IntervalUnit::Years => MobileIntervalUnit::Years,
};
(
k,
MobileGoal {
goal_type: mt,
target: v.target,
interval: MobileInterval {
amount: v.interval.amount,
unit: m_unit,
},
},
)
})
.collect(),
default_duration_goal_mins: c.default_duration_goal_mins,
sessions_count_as_completions: c.sessions_count_as_completions,
show_calendars_tab: c.show_calendars_tab,
show_tags_tab: c.show_tags_tab,
show_locations_tab: c.show_locations_tab,
show_goals_tab: c.show_goals_tab,
show_journal_tab: c.show_journal_tab,
show_task_goals_in_sidebar: c.show_task_goals_in_sidebar,
sort_collections_by_size: c.sort_collections_by_size,
expanded_tags: c.expanded_tags,
expanded_locations: c.expanded_locations,
expanded_done_groups: Vec::new(),
show_undo_snackbar: c.show_undo_snackbar,
first_day_of_week: match c.first_day_of_week {
crate::config::FirstDayOfWeek::Monday => MobileFirstDayOfWeek::Monday,
crate::config::FirstDayOfWeek::Sunday => MobileFirstDayOfWeek::Sunday,
},
}
}
pub fn parse_duration_string(&self, val: String) -> Option<u32> {
crate::model::parser::parse_duration(&val)
}
pub async fn add_session(&self, uid: String, input: String) -> Result<(), MobileError> {
if let Some(session) = crate::model::parser::parse_session_input(&input) {
let mut store = self.controller.store.lock().await;
if let Some((task, _)) = store.get_task_mut(&uid) {
task.add_session(session);
task.sequence += 1;
let cloned = task.clone();
drop(store);
self.controller
.update_task(cloned)
.await
.map_err(MobileError::from)?;
self.rebuild_alarm_index().await;
Ok(())
} else {
Err(MobileError::from(
rust_i18n::t!("error_task_not_found").to_string(),
))
}
} else {
Err(MobileError::from(
rust_i18n::t!("error_format", msg = "Invalid time format").to_string(),
))
}
}
pub async fn edit_session(
&self,
uid: String,
index: u32,
input: String,
) -> Result<(), MobileError> {
if let Some(session) = crate::model::parser::parse_session_input(&input) {
let mut store = self.controller.store.lock().await;
if let Some((task, _)) = store.get_task_mut(&uid) {
let idx = index as usize;
task.remove_session(idx);
task.add_session(session);
task.sequence += 1;
let cloned = task.clone();
drop(store);
self.controller
.update_task(cloned)
.await
.map_err(MobileError::from)?;
self.rebuild_alarm_index().await;
Ok(())
} else {
Err(MobileError::from(
rust_i18n::t!("error_task_not_found").to_string(),
))
}
} else {
Err(MobileError::from(
rust_i18n::t!("error_format", msg = "Invalid time format").to_string(),
))
}
}
pub async fn delete_session(&self, uid: String, index: u32) -> Result<(), MobileError> {
let mut store = self.controller.store.lock().await;
if let Some((task, _)) = store.get_task_mut(&uid) {
let idx = index as usize;
task.remove_session(idx);
task.sequence += 1;
let cloned = task.clone();
drop(store);
self.controller
.update_task(cloned)
.await
.map_err(MobileError::from)?;
self.rebuild_alarm_index().await;
Ok(())
} else {
Err(MobileError::from(
rust_i18n::t!("error_task_not_found").to_string(),
))
}
}
pub fn save_config(&self, config: MobileConfig) -> Result<(), MobileError> {
let mut c = load_mobile_config_with_credentials(self.ctx.as_ref());
let old_c = c.clone();
c.url = config.url;
apply_mobile_credentials_update(&mut c, &config.username, &config.password);
c.tls_client_cert_path = config.tls_client_cert_path;
c.tls_client_key_path = config.tls_client_key_path;
c.allow_insecure_certs = config.allow_insecure;
c.hide_completed = config.hide_completed;
c.hide_aliases_in_sidebar = config.hide_aliases_in_sidebar;
c.strikethrough_completed = config.strikethrough_completed;
c.tag_aliases = config.tag_aliases;
c.disabled_calendars = config.disabled_calendars;
c.sort_cutoff_days = config.sort_cutoff_days;
c.sort_standard_by_priority = config.sort_standard_by_priority;
c.paused_sort_behavior = match config.paused_sort_behavior.as_str() {
"top" => crate::config::PausedSortBehavior::Top,
"none" => crate::config::PausedSortBehavior::None,
_ => crate::config::PausedSortBehavior::Tiebreak,
};
c.sort_tiebreak_recent = config.sort_tiebreak_recent;
c.sort_preset = config.sort_preset.parse().unwrap_or_default();
c.urgent_days_horizon = config.urgent_days;
c.urgent_priority_threshold = config.urgent_prio;
c.default_priority = config.default_priority;
c.start_grace_period_days = config.start_grace_period_days;
c.auto_reminders = config.auto_reminders;
c.default_reminder_time = config.default_reminder_time;
c.snooze_short_mins = config.snooze_short;
c.create_events_for_tasks = config.create_events_for_tasks;
c.delete_events_on_completion = config.delete_events_on_completion;
c.auto_refresh_interval_mins = config.auto_refresh_interval;
c.trash_retention_days = config.trash_retention;
c.max_done_roots = config.max_done_roots as usize;
c.max_done_subtasks = config.max_done_subtasks as usize;
c.show_ongoing_notifications = config.show_ongoing_notifications;
c.show_inline_descriptions = config.show_inline_descriptions;
c.show_quick_filter = config.show_quick_filter;
c.quick_filter_term = config.quick_filter_term;
c.quick_filter_icon = config.quick_filter_icon;
c.sync_settings = config.sync_settings;
c.show_undo_snackbar = config.show_undo_snackbar;
c.goals = config
.goals
.into_iter()
.map(|(k, v)| {
let t = match v.goal_type {
MobileGoalType::Count => crate::config::GoalType::Count,
MobileGoalType::Duration => crate::config::GoalType::Duration,
};
let p = match v.interval.unit {
MobileIntervalUnit::Days => crate::config::IntervalUnit::Days,
MobileIntervalUnit::Weeks => crate::config::IntervalUnit::Weeks,
MobileIntervalUnit::Months => crate::config::IntervalUnit::Months,
MobileIntervalUnit::Years => crate::config::IntervalUnit::Years,
};
(
k,
crate::config::Goal {
goal_type: t,
target: v.target,
interval: crate::config::Interval {
amount: v.interval.amount,
unit: p,
},
},
)
})
.collect();
c.default_duration_goal_mins = config.default_duration_goal_mins;
c.sessions_count_as_completions = config.sessions_count_as_completions;
c.show_calendars_tab = config.show_calendars_tab;
c.show_tags_tab = config.show_tags_tab;
c.show_locations_tab = config.show_locations_tab;
c.show_goals_tab = config.show_goals_tab;
c.show_journal_tab = config.show_journal_tab;
c.sort_collections_by_size = config.sort_collections_by_size;
c.first_day_of_week = match config.first_day_of_week {
MobileFirstDayOfWeek::Monday => crate::config::FirstDayOfWeek::Monday,
MobileFirstDayOfWeek::Sunday => crate::config::FirstDayOfWeek::Sunday,
};
c.expanded_tags = config.expanded_tags;
c.expanded_locations = config.expanded_locations;
c.update_sync_timestamp_if_changed(&old_c);
c.save_with_credentials(self.ctx.as_ref())
.map_err(MobileError::from)
}
pub fn move_calendar(&self, href: String, direction: i8) -> Result<(), MobileError> {
let mut config = Config::load(self.ctx.as_ref()).unwrap_or_default();
let cals = self.get_calendars();
let mut current_order = config.collection_order.clone();
for cal in &cals {
if !current_order.contains(&cal.href)
&& cal.href != crate::storage::LOCAL_TRASH_HREF
&& cal.href != "local://recovery"
{
current_order.push(cal.href.clone());
}
}
if let Some(idx) = current_order.iter().position(|h| h == &href) {
let new_idx =
(idx as i32 + direction as i32).clamp(0, (current_order.len() - 1) as i32) as usize;
if idx != new_idx {
current_order.swap(idx, new_idx);
config.collection_order = current_order;
config.save(self.ctx.as_ref()).map_err(MobileError::from)?;
}
}
Ok(())
}
pub fn get_calendars(&self) -> Vec<MobileCalendar> {
let config = Config::load(self.ctx.as_ref()).unwrap_or_default();
let disabled_set: HashSet<String> = config.disabled_calendars.iter().cloned().collect();
let mut result = Vec::new();
let locals = LocalCalendarRegistry::load(self.ctx.as_ref()).unwrap_or_default();
let cals = crate::cache::Cache::load_calendars(self.ctx.as_ref()).unwrap_or_default();
let store = self.controller.store.blocking_lock();
if !locals.is_empty() {
for loc in locals {
if loc.href == crate::storage::LOCAL_TRASH_HREF || loc.href == "local://recovery" {
if let Some(map) = store.calendars.get(&loc.href) {
if map.is_empty() {
continue;
}
} else {
continue;
}
}
result.push(MobileCalendar {
name: loc.name,
href: loc.href.clone(),
color: loc.color,
is_visible: !config.hidden_calendars.contains(&loc.href),
is_local: true,
is_disabled: disabled_set.contains(&loc.href),
});
}
}
if !cals.is_empty() {
for c in cals {
if c.href.starts_with("local://") {
continue;
}
result.push(MobileCalendar {
name: c.name,
href: c.href.clone(),
color: c.color,
is_visible: !config.hidden_calendars.contains(&c.href),
is_local: false,
is_disabled: disabled_set.contains(&c.href),
});
}
}
let sort_by_size = config.sort_collections_by_size;
let mut sizes = std::collections::HashMap::new();
if sort_by_size {
for cal in &result {
let count = store.calendars.get(&cal.href).map(|m| m.len()).unwrap_or(0);
sizes.insert(cal.href.clone(), count);
}
}
drop(store);
let order = config.collection_order.clone();
result.sort_by(|a, b| {
if sort_by_size {
let count_a = sizes.get(&a.href).unwrap_or(&0);
let count_b = sizes.get(&b.href).unwrap_or(&0);
crate::model::compare_calendars_with_size(
&a.href, &a.name, *count_a, &b.href, &b.name, *count_b, &order,
)
} else {
crate::model::compare_calendars(&a.href, &a.name, &b.href, &b.name, &order)
}
});
result
}
pub fn get_ongoing_tasks(&self) -> Vec<MobileTask> {
let store = self.controller.store.blocking_lock();
let config = Config::load(self.ctx.as_ref()).unwrap_or_default();
let mut results = Vec::new();
for (href, map) in &store.calendars {
if href == crate::storage::LOCAL_TRASH_HREF || href == "local://recovery" {
continue;
}
for t in map.values() {
if t.status == crate::model::TaskStatus::InProcess {
let mut cloned = t.clone();
populate_transient(&mut cloned, &store, &config.tag_aliases);
results.push(task_to_mobile(&cloned, &store));
}
}
}
results
}
pub fn isolate_calendar(&self, href: String) -> Result<(), MobileError> {
let mut config = Config::load(self.ctx.as_ref()).unwrap_or_default();
config.hidden_calendars = self
.get_calendars()
.iter()
.filter(|c| c.href != href)
.map(|c| c.href.clone())
.collect();
config.save(self.ctx.as_ref()).map_err(MobileError::from)?;
if let Ok(mut session) = self.session.try_lock() {
session.active_calendar_href = Some(href);
}
Ok(())
}
pub fn remove_alias(&self, key: String) -> Result<(), MobileError> {
let mut c = Config::load(self.ctx.as_ref()).unwrap_or_default();
c.tag_aliases.remove(&key);
c.save(self.ctx.as_ref()).map_err(MobileError::from)
}
pub fn set_default_calendar(&self, href: String) -> Result<(), MobileError> {
let mut config = Config::load(self.ctx.as_ref()).unwrap_or_default();
config.default_calendar = Some(href.clone());
config.hidden_calendars.retain(|h| h != &href);
config.save(self.ctx.as_ref()).map_err(MobileError::from)
}
pub fn set_calendar_visibility(&self, href: String, visible: bool) -> Result<(), MobileError> {
let mut config = Config::load(self.ctx.as_ref()).unwrap_or_default();
if visible {
config.hidden_calendars.retain(|h| h != &href);
} else if !config.hidden_calendars.contains(&href) {
config.hidden_calendars.push(href);
}
config.save(self.ctx.as_ref()).map_err(MobileError::from)
}
pub fn toggle_all_calendars(&self, show_all: bool) -> Result<(), MobileError> {
let mut config = Config::load(self.ctx.as_ref()).unwrap_or_default();
if show_all {
config.hidden_calendars.clear();
if config.default_calendar.as_deref() != Some(crate::storage::LOCAL_TRASH_HREF) {
config
.hidden_calendars
.push(crate::storage::LOCAL_TRASH_HREF.to_string());
}
} else {
let cals = self.get_calendars();
for cal in cals {
if config.default_calendar.as_ref() != Some(&cal.href)
&& !config.hidden_calendars.contains(&cal.href)
{
config.hidden_calendars.push(cal.href);
}
}
}
config.save(self.ctx.as_ref()).map_err(MobileError::from)
}
pub fn load_from_cache(&self) {
let mut loaded_calendars = self.load_all_local_calendars();
if let Ok(cals) = Cache::load_calendars(self.ctx.as_ref()) {
for cal in cals {
if cal.href.starts_with("local://") {
continue;
}
if let Ok((mut tasks, _)) = Cache::load(self.ctx.as_ref(), &cal.href) {
crate::journal::Journal::apply_to_tasks(
self.ctx.as_ref(),
&mut tasks,
&cal.href,
);
loaded_calendars.push((cal.href, tasks));
}
}
}
let mut store = self.controller.store.blocking_lock();
store.clear();
for (href, tasks) in loaded_calendars {
store.insert(href, tasks);
}
let config = Config::load(self.ctx.as_ref()).unwrap_or_default();
let index = AlarmIndex::rebuild_from_tasks(
&store.calendars,
config.auto_reminders,
&config.default_reminder_time,
);
if let Err(e) = index.save(self.ctx.as_ref()) {
#[cfg(target_os = "android")]
log::warn!("Failed to save alarm index: {}", e);
#[cfg(not(target_os = "android"))]
let _ = e;
} else {
#[cfg(target_os = "android")]
log::debug!("Alarm index rebuilt with {} alarms", index.len());
}
*self.alarm_index_cache.blocking_lock() = Some(index);
}
pub fn get_next_alarm_timestamp(&self) -> Option<i64> {
let cached = self.alarm_index_cache.blocking_lock();
if let Some(ref index) = *cached
&& !index.is_empty()
{
if let Some(timestamp) = index.get_next_alarm_timestamp() {
return Some(timestamp as i64);
}
return None;
}
drop(cached);
let index = AlarmIndex::load(self.ctx.as_ref());
if !index.is_empty() {
if let Some(timestamp) = index.get_next_alarm_timestamp() {
*self.alarm_index_cache.blocking_lock() = Some(index);
return Some(timestamp as i64);
}
return None;
}
let store = self.controller.store.blocking_lock();
let config = Config::load(self.ctx.as_ref()).unwrap_or_default();
let default_time = NaiveTime::parse_from_str(&config.default_reminder_time, "%H:%M")
.unwrap_or_else(|_| NaiveTime::from_hms_opt(9, 0, 0).unwrap());
let now = Utc::now();
let mut global_earliest: Option<i64> = None;
let check_ts = |ts: i64, current_earliest: &mut Option<i64>| {
if ts > now.timestamp()
&& (current_earliest.is_none() || ts < current_earliest.unwrap())
{
*current_earliest = Some(ts);
}
};
for tasks_map in store.calendars.values() {
for task in tasks_map.values() {
if task.status.is_done() || task.status == crate::model::TaskStatus::InProcess {
continue;
}
if task.calendar_href == crate::storage::LOCAL_TRASH_HREF
|| task.calendar_href == "local://recovery"
{
continue;
}
if let Some(ts) = task.next_trigger_timestamp() {
check_ts(ts, &mut global_earliest);
}
if config.auto_reminders
&& !task
.alarms
.iter()
.any(|a| a.acknowledged.is_none() && !a.is_snooze())
{
let mut check_implicit = |dt: DateTime<Utc>| {
if !task.has_alarm_at(dt) {
check_ts(dt.timestamp(), &mut global_earliest);
}
};
if let Some(due) = &task.due {
check_implicit(due.to_utc_with_default_time(default_time));
}
if let Some(start) = &task.dtstart {
check_implicit(start.to_utc_with_default_time(default_time));
}
}
}
}
global_earliest
}
pub fn get_firing_alarms(&self) -> Vec<MobileAlarmInfo> {
let mut firing_entries = Vec::new();
{
let cached = self.alarm_index_cache.blocking_lock();
if let Some(ref index) = *cached {
firing_entries = index.get_firing_alarms();
}
}
if !firing_entries.is_empty() {
let store = self.controller.store.blocking_lock();
return firing_entries_to_mobile(&store, firing_entries);
}
let index = AlarmIndex::load(self.ctx.as_ref());
if !index.is_empty() {
let firing_from_disk = index.get_firing_alarms();
if !firing_from_disk.is_empty() {
*self.alarm_index_cache.blocking_lock() = Some(index);
let store = self.controller.store.blocking_lock();
return firing_entries_to_mobile(&store, firing_from_disk);
} else {
return Vec::new();
}
}
let store = self.controller.store.blocking_lock();
let config = Config::load(self.ctx.as_ref()).unwrap_or_default();
let default_time = NaiveTime::parse_from_str(&config.default_reminder_time, "%H:%M")
.unwrap_or_else(|_| NaiveTime::from_hms_opt(9, 0, 0).unwrap());
let now = Utc::now();
let mut results = Vec::new();
for tasks_map in store.calendars.values() {
for task in tasks_map.values() {
if task.status.is_done() || task.status == crate::model::TaskStatus::InProcess {
continue;
}
if task.calendar_href == crate::storage::LOCAL_TRASH_HREF
|| task.calendar_href == "local://recovery"
{
continue;
}
for alarm in &task.alarms {
if alarm.acknowledged.is_some() {
continue;
}
let trigger_dt = match alarm.trigger {
AlarmTrigger::Absolute(dt) => dt,
AlarmTrigger::Relative(mins) => {
let anchor = if let Some(DateType::Specific(d)) = task.due {
d
} else if let Some(DateType::Specific(s)) = task.dtstart {
s
} else {
continue;
};
anchor + chrono::Duration::minutes(mins as i64)
}
};
if trigger_dt <= now && (now - trigger_dt).num_minutes() < 120 {
results.push(MobileAlarmInfo {
task_uid: task.uid.clone(),
alarm_uid: alarm.uid.clone(),
title: task.summary.clone(),
body: alarm
.description
.clone()
.unwrap_or_else(|| rust_i18n::t!("reminder").to_string()),
});
}
}
if config.auto_reminders
&& !task
.alarms
.iter()
.any(|a| a.acknowledged.is_none() && !a.is_snooze())
{
let mut check_implicit = |dt: DateTime<Utc>, desc: &str, type_key: &str| {
if !task.has_alarm_at(dt) && dt <= now && (now - dt).num_minutes() < 120 {
let synth_id =
format!("implicit_{}:|{}|{}", type_key, dt.to_rfc3339(), task.uid);
results.push(MobileAlarmInfo {
task_uid: task.uid.clone(),
alarm_uid: synth_id,
title: task.summary.clone(),
body: desc.to_string(),
});
}
};
if let Some(due) = &task.due {
let alarm_due_now = rust_i18n::t!("alarm_due_now");
check_implicit(
due.to_utc_with_default_time(default_time),
alarm_due_now.as_ref(),
"due",
);
}
if let Some(start) = &task.dtstart {
let alarm_task_starting = rust_i18n::t!("alarm_task_starting");
check_implicit(
start.to_utc_with_default_time(default_time),
alarm_task_starting.as_ref(),
"start",
);
}
}
}
}
results
}
}
#[uniffi::export(async_runtime = "tokio")]
impl CfaitMobile {
pub async fn add_alias(&self, key: String, tags: Vec<String>) -> Result<(), MobileError> {
let mut c = Config::load(self.ctx.as_ref()).unwrap_or_default();
let tags_str = tags.join(",");
let proper_tags = crate::model::parser::parse_alias_values(&tags_str);
crate::model::validate_alias_integrity(&key, &proper_tags, &c.tag_aliases)
.map_err(MobileError::from)?;
c.tag_aliases.insert(key.clone(), proper_tags.clone());
c.save(self.ctx.as_ref()).map_err(MobileError::from)?;
let mut store = self.controller.store.lock().await;
let modified = store.apply_alias_retroactively(&key, &proper_tags);
drop(store);
if !modified.is_empty() {
for t in modified {
self.controller
.update_task(t)
.await
.map_err(MobileError::from)?;
}
}
Ok(())
}
pub async fn add_dependency(
&self,
task_uid: String,
blocker_uid: String,
) -> Result<(), MobileError> {
if task_uid == blocker_uid {
return Err(MobileError::from(
rust_i18n::t!("error_cannot_depend_on_self").to_string(),
));
}
self.apply_store_mutation(&task_uid, |store, id| store.add_dependency(id, blocker_uid))
.await
}
pub async fn remove_dependency(
&self,
task_uid: String,
blocker_uid: String,
) -> Result<(), MobileError> {
self.apply_store_mutation(&task_uid, |store, id| {
store.remove_dependency(id, &blocker_uid)
})
.await
}
pub async fn set_parent(
&self,
child_uid: String,
parent_uid: Option<String>,
) -> Result<(), MobileError> {
let mut err_msg = None;
let res = self
.apply_store_mutation(&child_uid, |store, id| {
match store.set_parent(id, parent_uid) {
Ok(t) => Some(t),
Err(e) => {
err_msg = Some(e);
None
}
}
})
.await;
if let Some(e) = err_msg {
return Err(MobileError::from(e));
}
res
}
pub async fn add_related_to(
&self,
task_uid: String,
related_uid: String,
) -> Result<(), MobileError> {
if task_uid == related_uid {
return Err(MobileError::from(
rust_i18n::t!("error_cannot_relate_to_self").to_string(),
));
}
self.apply_store_mutation(&task_uid, |store, id| store.add_related_to(id, related_uid))
.await
}
pub async fn remove_related_to(
&self,
task_uid: String,
related_uid: String,
) -> Result<(), MobileError> {
self.apply_store_mutation(&task_uid, |store, id| {
store.remove_related_to(id, &related_uid)
})
.await
}
pub async fn get_tasks_related_to(&self, uid: String) -> Vec<MobileRelatedTask> {
self.controller
.store
.lock()
.await
.get_tasks_related_to(&uid)
.into_iter()
.map(|(uid, summary)| MobileRelatedTask { uid, summary })
.collect()
}
pub async fn sync_journal(&self) -> Result<bool, MobileError> {
let (_warns, synced, _config_changed) = self
.controller
.sync_and_update_store()
.await
.map_err(MobileError::from)?;
Ok(synced
.iter()
.any(|t| t.summary.ends_with("(Conflict Copy)")))
}
pub async fn sync(&self) -> Result<String, MobileError> {
let config = Config::load_with_credentials(self.ctx.as_ref()).map_err(MobileError::from)?;
let client_opt = self.controller.client.lock().await.clone();
if let Some(client) = client_opt {
let _ = self.controller.sync_and_update_store().await;
let cals = crate::cache::Cache::load_calendars(self.ctx.as_ref()).unwrap_or_default();
match client.get_all_tasks(&cals).await {
Ok(results) => {
let mut store = self.controller.store.lock().await;
for (href, mut tasks) in results {
crate::journal::Journal::apply_to_tasks(
self.ctx.as_ref(),
&mut tasks,
&href,
);
store.insert(href, tasks);
}
drop(store);
self.rebuild_alarm_index().await;
return Ok(rust_i18n::t!("status_connected").to_string());
}
Err(e) => {
#[cfg(target_os = "android")]
log::warn!("Fast path sync failed: {}", e);
let _ = &e;
}
}
}
self.apply_connection(config).await
}
pub async fn connect(
&self,
url: String,
user: String,
pass: String,
insecure: bool,
) -> Result<String, MobileError> {
let mut config = load_mobile_config_with_credentials(self.ctx.as_ref());
config.url = url;
apply_mobile_credentials_update(&mut config, &user, &pass);
config.allow_insecure_certs = insecure;
self.apply_connection(config).await
}
pub async fn get_all_tags(&self) -> Vec<MobileTag> {
Vec::new()
}
pub async fn get_all_locations(&self) -> Vec<MobileLocation> {
Vec::new()
}
pub async fn get_task_by_uid(&self, uid: String) -> Option<MobileTask> {
let store = self.controller.store.lock().await;
let config = Config::load(self.ctx.as_ref()).unwrap_or_default();
if let Some(task) = store.get_task_ref(&uid) {
let mut cloned = task.clone();
populate_transient(&mut cloned, &store, &config.tag_aliases);
Some(task_to_mobile(&cloned, &store))
} else {
None
}
}
pub fn parse_snooze_target(&self, val: String) -> Option<u32> {
let lex_guard = crate::model::parser::LEXICON.read().unwrap();
let lex = &*lex_guard;
let val = val.trim().to_lowercase();
let dur_val = val.replace(' ', "");
if let Some(mins) = crate::model::parser::parse_duration_with_lex(&dur_val, lex) {
return Some(mins);
}
if let Some(d) = crate::model::parser::parse_smart_date_with_lex(&val, lex).or_else(|| {
crate::model::parser::parse_next_date_with_lex(&val, lex)
.map(crate::model::DateType::AllDay)
}) {
let now = chrono::Utc::now();
let config = crate::config::Config::load(self.ctx.as_ref()).unwrap_or_default();
let def_time =
chrono::NaiveTime::parse_from_str(&config.default_reminder_time, "%H:%M")
.unwrap_or_else(|_| chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap());
let dt = match d {
crate::model::DateType::AllDay(nd) => {
crate::model::item::safe_local_to_utc(nd, def_time)
}
crate::model::DateType::Specific(dt) => dt,
crate::model::DateType::Month(y, m) => crate::model::item::safe_local_to_utc(
chrono::NaiveDate::from_ymd_opt(y, m, 1).unwrap(),
def_time,
),
crate::model::DateType::Year(y) => crate::model::item::safe_local_to_utc(
chrono::NaiveDate::from_ymd_opt(y, 1, 1).unwrap(),
def_time,
),
};
let diff = dt.timestamp() - now.timestamp();
if diff > 0 {
return Some((diff / 60) as u32);
}
}
None
}
pub async fn get_view_tasks(&self, options: MobileFilterOptions) -> MobileViewData {
let mut session = self.session.lock().await;
session.search_term = options.search_query.clone();
session.selected_categories = options.filter_tags.clone();
session.selected_locations = options.filter_locations.clone();
session.expanded_done_groups = options.expanded_groups.clone();
session.match_all_categories = options.match_all_categories;
session.expanded_tags = options.expanded_tags.clone();
session.expanded_locations = options.expanded_locations.clone();
let search_collapsed_set: HashSet<String> =
session.search_collapsed_tasks.iter().cloned().collect();
let focused_task_uid = session.focused_task_uid.clone();
let selected_journal_date_session = session.selected_journal_date.clone();
drop(session);
let all_cals = self.get_calendars();
let store = self.controller.store.lock().await;
let config = Config::load(self.ctx.as_ref()).unwrap_or_default();
let mut hidden: HashSet<String> = config.hidden_calendars.into_iter().collect();
hidden.extend(config.disabled_calendars);
let expanded_set: HashSet<String> = options.expanded_groups.into_iter().collect();
let expanded_tags_set: HashSet<String> = options.expanded_tags.into_iter().collect();
let expanded_locations_set: HashSet<String> =
options.expanded_locations.into_iter().collect();
let cutoff_date = config
.sort_cutoff_days
.map(|d| Utc::now() + chrono::Duration::days(d as i64));
let filtered = store.filter(FilterOptions {
active_cal_href: None,
hidden_calendars: &hidden,
selected_categories: &options.filter_tags.into_iter().collect(),
selected_locations: &options.filter_locations.into_iter().collect(),
match_all_categories: options.match_all_categories,
search_term: &options.search_query,
hide_completed_global: config.hide_completed,
hide_fully_completed_tags: config.hide_fully_completed_tags,
hide_aliases_in_sidebar: config.hide_aliases_in_sidebar,
cutoff_date,
min_duration: None,
max_duration: None,
include_unset_duration: true,
urgent_days: config.urgent_days_horizon,
urgent_prio: config.urgent_priority_threshold,
default_priority: config.default_priority,
start_grace_period_days: config.start_grace_period_days,
sort_standard_by_priority: config.sort_standard_by_priority,
sort_preset: config.sort_preset,
paused_sort_behavior: config.paused_sort_behavior,
sort_tiebreak_recent: config.sort_tiebreak_recent,
expanded_done_groups: &expanded_set,
expanded_tags: &expanded_tags_set,
expanded_locations: &expanded_locations_set,
max_done_roots: config.max_done_roots,
max_done_subtasks: config.max_done_subtasks,
tag_aliases: &config.tag_aliases,
search_collapsed_tasks: &search_collapsed_set,
focused_task_uid: focused_task_uid.as_deref(),
});
let mut last_calendar_href = String::new();
let tasks = filtered
.items
.into_iter()
.filter_map(|item| {
if let crate::store::TaskListItem::Task(t) = item {
let mt = task_to_summary(&t, &store);
last_calendar_href = mt.calendar_href.clone();
Some(mt)
} else if let crate::store::TaskListItem::ExpandGroup(p_uid, depth) = item {
let mut vt = MobileTaskSummary::empty_virtual("expand", &p_uid, depth as u32);
vt.calendar_href = if p_uid.is_empty() {
last_calendar_href.clone()
} else if let Some(p) = store.get_task_ref(&p_uid) {
p.calendar_href.clone()
} else {
last_calendar_href.clone()
};
Some(vt)
} else if let crate::store::TaskListItem::CollapseGroup(p_uid, depth) = item {
let mut vt = MobileTaskSummary::empty_virtual("collapse", &p_uid, depth as u32);
vt.calendar_href = if p_uid.is_empty() {
last_calendar_href.clone()
} else if let Some(p) = store.get_task_ref(&p_uid) {
p.calendar_href.clone()
} else {
last_calendar_href.clone()
};
Some(vt)
} else {
None
}
})
.skip(options.offset as usize)
.take(options.limit as usize)
.collect();
let tags = filtered
.categories
.into_iter()
.map(|item| MobileTag {
name: item.full_key.clone(),
display_name: item.display_name,
count: item.count,
depth: item.depth,
has_children: item.has_children,
is_expanded: item.is_expanded,
is_uncategorized: item.full_key == UNCATEGORIZED_ID,
})
.collect();
let locations = filtered
.locations
.into_iter()
.map(|item| MobileLocation {
name: item.full_key.clone(),
display_name: item.display_name,
count: item.count,
depth: item.depth,
has_children: item.has_children,
is_expanded: item.is_expanded,
})
.collect();
let mut evaluated_goals = Vec::new();
for (key, goal) in &config.goals {
let progress = store.calculate_goal_progress(key, goal);
let (progress_str, target_str) = if goal.goal_type == crate::config::GoalType::Duration
{
crate::model::parser::format_goal_duration(progress, goal.target)
} else {
(progress.to_string(), goal.target.to_string())
};
let pct = if goal.target > 0 {
(progress as f32 / goal.target as f32).min(1.0)
} else {
0.0
};
let history = store.calculate_goal_history(key, goal, 7);
evaluated_goals.push(MobileGoalProgress {
key: key.clone(),
progress_str,
target_str: target_str.clone(),
period_str: goal.format_target_display(&target_str),
pct,
history,
});
}
if config.show_task_goals_in_sidebar {
let _now = chrono::Utc::now();
let mut task_goals = Vec::new();
for (href, map) in store.calendars.iter() {
if hidden.contains(href)
|| href == crate::storage::LOCAL_TRASH_HREF
|| href == "local://recovery"
{
continue;
}
for t in map.values() {
if t.unmapped_properties
.iter()
.any(|p| p.key == "X-CFAIT-HISTORY-OF")
{
continue;
}
if let Some(goal) = &t.goal {
let progress =
store.calculate_goal_progress(&format!("task:{}", t.uid), goal);
let (progress_str, target_str) =
if goal.goal_type == crate::config::GoalType::Duration {
crate::model::parser::format_goal_duration(progress, goal.target)
} else {
(progress.to_string(), goal.target.to_string())
};
let pct = if goal.target > 0 {
(progress as f32 / goal.target as f32).min(1.0)
} else {
0.0
};
let history =
store.calculate_goal_history(&format!("task:{}", t.uid), goal, 7);
task_goals.push(MobileGoalProgress {
key: format!("task:{}", t.uid), progress_str,
target_str: target_str.clone(),
period_str: format!(
"{} - {}",
t.summary,
goal.format_target_display(&target_str)
),
pct,
history,
});
}
}
}
task_goals.sort_by(|a, b| a.period_str.cmp(&b.period_str));
evaluated_goals.extend(task_goals);
}
let selected_journal_date = if selected_journal_date_session.is_empty() {
chrono::Local::now().date_naive()
} else {
chrono::NaiveDate::parse_from_str(&selected_journal_date_session, "%Y-%m-%d")
.unwrap_or_else(|_| chrono::Local::now().date_naive())
};
let visible_cals_set: std::collections::HashSet<String> = store
.calendars
.keys()
.filter(|href| !hidden.contains(*href))
.cloned()
.collect();
let day_ctx = store.get_day_context(selected_journal_date, &visible_cals_set);
use chrono::Datelike;
let mut journal_days_in_month: HashMap<u32, Vec<String>> = HashMap::new();
let target_month = selected_journal_date.month();
let target_year = selected_journal_date.year();
for (href, map) in store.calendars.iter() {
if !visible_cals_set.contains(href) {
continue;
}
let col_color = all_cals
.iter()
.find(|c| c.href == *href)
.and_then(|c| c.color.clone())
.unwrap_or_else(|| "#4CAF50".to_string());
for t in map.values() {
if t.is_journal
&& let Some(dt) = &t.dtstart
{
let d = dt.to_date_naive();
if d.year() == target_year && d.month() == target_month {
journal_days_in_month
.entry(d.day())
.or_default()
.push(col_color.clone());
}
}
}
}
let mut journal_days_vec: Vec<MobileJournalDay> = journal_days_in_month
.into_iter()
.map(|(day, mut colors)| {
colors.sort();
colors.dedup();
MobileJournalDay { day, colors }
})
.collect();
journal_days_vec.sort_by_key(|d| d.day);
let map_related = |tasks: Vec<crate::model::Task>| -> Vec<MobileRelatedTask> {
tasks
.into_iter()
.map(|t| MobileRelatedTask {
uid: t.uid,
summary: t.summary,
})
.collect()
};
let journal_context = MobileDayContext {
date: selected_journal_date.format("%Y-%m-%d").to_string(),
total_tracked_mins: day_ctx.total_tracked_mins,
due_tasks: map_related(day_ctx.due_tasks),
started_tasks: map_related(day_ctx.started_tasks),
ongoing_tasks: map_related(day_ctx.ongoing_tasks),
session_tasks: map_related(day_ctx.session_tasks.into_iter().map(|(t, _)| t).collect()),
completed_tasks: map_related(day_ctx.completed_tasks),
journal_days_in_month: journal_days_vec,
};
let journal_pages: Vec<MobileJournalPage> = filtered
.journal_pages
.clone()
.into_iter()
.map(|p| MobileJournalPage {
uid: p.key,
title: p.title,
depth: p.depth as u32,
has_children: p.has_children,
is_expanded: p.is_expanded,
is_task: p.is_task,
calendar_href: p.calendar_href,
})
.collect();
let selected_journal_uid: Option<String> =
filtered.journal_pages.into_iter().find_map(|p| {
if let Some(task) = store.get_task_ref(&p.key)
&& let Some(dt) = &task.dtstart
{
let date = dt.to_date_naive();
if date == selected_journal_date {
return Some(p.key);
}
}
None
});
MobileViewData {
tasks,
tags,
locations,
goals: evaluated_goals,
focused_task_uid,
journal_context,
journal_pages,
selected_journal_date: selected_journal_date.format("%Y-%m-%d").to_string(),
selected_journal_uid,
}
}
pub async fn dispatch(&self, intent: crate::model::AppIntent) -> Result<String, MobileError> {
let mut session = self.session.lock().await;
let mut store = self.controller.store.lock().await;
let config = crate::config::Config::load(self.ctx.as_ref()).unwrap_or_default();
session.apply_session_intent(&intent);
let mut config_to_save = config.clone();
config_to_save.expanded_tags = session.expanded_tags.clone();
config_to_save.expanded_locations = session.expanded_locations.clone();
let _ = config_to_save.save(self.ctx.as_ref());
let (forward, reverse, desc, primary_uid) = store.apply_task_intent(&intent, &config);
drop(store);
drop(session);
if !forward.is_empty() {
let mut history = self.controller.undo_history.lock().await;
history.push(crate::journal::UndoRecord {
description: desc.clone(),
primary_uid,
forward: forward.clone(),
reverse,
});
drop(history);
let _ = self.controller.persist_changes(forward).await;
}
let store_arc = self.controller.store.clone();
let alarm_cache = self.alarm_index_cache.clone();
let ctx_clone = self.ctx.clone();
tokio::spawn(async move {
let index = {
let s = store_arc.lock().await;
crate::alarm_index::AlarmIndex::rebuild_from_tasks(
&s.calendars,
config.auto_reminders,
&config.default_reminder_time,
)
}; let _ = index.save(ctx_clone.as_ref());
*alarm_cache.lock().await = Some(index);
});
Ok(desc)
}
pub async fn set_journal_date(&self, date: String) -> Result<(), MobileError> {
self.dispatch(crate::model::AppIntent::SelectJournalDate { date })
.await?;
Ok(())
}
pub async fn get_or_create_daily_note(
&self,
date_str: String,
calendar_href: String,
) -> Result<String, MobileError> {
let nd = chrono::NaiveDate::parse_from_str(&date_str, "%Y-%m-%d")
.map_err(|e| MobileError::from(e.to_string()))?;
let store = self.controller.store.lock().await;
if let Some(entry) = store.get_journal_entry(&calendar_href, nd) {
return Ok(entry.uid.clone());
}
drop(store);
let config = crate::config::Config::load(self.ctx.as_ref()).unwrap_or_default();
let mut new_note = crate::model::Task::new("", &config.tag_aliases, None);
new_note.is_journal = true;
new_note.calendar_href = calendar_href;
new_note.dtstart = Some(crate::model::DateType::AllDay(nd));
new_note.summary = date_str;
let uid = new_note.uid.clone();
self.controller
.persist_changes(vec![crate::journal::Action::Create(new_note.clone())])
.await
.map_err(MobileError::from)?;
self.controller.store.lock().await.add_task(new_note);
Ok(uid)
}
pub async fn create_wiki_page(
&self,
title: String,
calendar_href: String,
parent_uid: Option<String>,
) -> Result<String, MobileError> {
let config = crate::config::Config::load(self.ctx.as_ref()).unwrap_or_default();
let mut new_page = crate::model::Task::new("", &config.tag_aliases, None);
new_page.is_journal = true;
new_page.calendar_href = calendar_href;
new_page.parent_uid = parent_uid;
new_page.summary = if title.is_empty() {
rust_i18n::t!("untitled_page", default = "Untitled page").to_string()
} else {
title
};
let uid = new_page.uid.clone();
self.controller
.persist_changes(vec![crate::journal::Action::Create(new_page.clone())])
.await
.map_err(MobileError::from)?;
self.controller.store.lock().await.add_task(new_page);
Ok(uid)
}
pub async fn open_wiki_link(
&self,
title: String,
context_uid: Option<String>,
calendar_href: Option<String>,
) -> Result<String, MobileError> {
let clean_title = title.trim_start_matches("[[").trim_end_matches("]]").trim();
{
let store = self.controller.store.lock().await;
match store.resolve_dependency_ref(clean_title, context_uid.as_deref()) {
Ok(uid) => return Ok(uid),
Err(msg) if msg.starts_with("Ambiguous") => {
return Err(MobileError::from(msg));
}
Err(_) => {}
}
}
let config = crate::config::Config::load(self.ctx.as_ref()).unwrap_or_default();
let def_time =
chrono::NaiveTime::parse_from_str(&config.default_reminder_time, "%H:%M").ok();
let (final_uid, actions) = {
let mut store = self.controller.store.lock().await;
let context_is_journal = context_uid
.as_deref()
.and_then(|uid| store.get_task_ref(uid))
.map(|t| t.is_journal)
.unwrap_or(false);
store.walk_or_create_wiki_path(
clean_title,
context_uid.as_deref(),
context_is_journal,
&config.tag_aliases,
def_time,
calendar_href,
)
};
if !actions.is_empty() {
self.controller.persist_changes(actions).await?;
}
if final_uid.is_empty() {
Err(MobileError::from(
rust_i18n::t!("error_task_not_found_for_dep", reference = clean_title).to_string(),
))
} else {
Ok(final_uid)
}
}
pub async fn get_random_task_uid(
&self,
filter_tags: Vec<String>,
filter_locations: Vec<String>,
search_query: String,
) -> Option<String> {
let store = self.controller.store.lock().await;
let config = Config::load(self.ctx.as_ref()).unwrap_or_default();
let mut hidden: HashSet<String> = config.hidden_calendars.into_iter().collect();
hidden.extend(config.disabled_calendars);
let cutoff_date = config
.sort_cutoff_days
.map(|d| Utc::now() + chrono::Duration::days(d as i64));
let filter_res = store.filter(FilterOptions {
active_cal_href: None,
hidden_calendars: &hidden,
selected_categories: &filter_tags.into_iter().collect(),
selected_locations: &filter_locations.into_iter().collect(),
match_all_categories: false,
search_term: &search_query,
hide_completed_global: config.hide_completed,
hide_fully_completed_tags: config.hide_fully_completed_tags,
hide_aliases_in_sidebar: config.hide_aliases_in_sidebar,
cutoff_date,
min_duration: None,
max_duration: None,
include_unset_duration: true,
urgent_days: config.urgent_days_horizon,
urgent_prio: config.urgent_priority_threshold,
default_priority: config.default_priority,
start_grace_period_days: config.start_grace_period_days,
sort_standard_by_priority: config.sort_standard_by_priority,
sort_preset: config.sort_preset,
paused_sort_behavior: config.paused_sort_behavior,
sort_tiebreak_recent: config.sort_tiebreak_recent,
expanded_done_groups: &HashSet::new(),
expanded_tags: &HashSet::new(),
expanded_locations: &HashSet::new(),
max_done_roots: config.max_done_roots,
max_done_subtasks: config.max_done_subtasks,
tag_aliases: &config.tag_aliases,
search_collapsed_tasks: &HashSet::new(),
focused_task_uid: None,
});
let filtered: Vec<crate::model::Task> = filter_res
.items
.iter()
.filter_map(|item| {
if let crate::store::TaskListItem::Task(t) = item {
Some((**t).clone())
} else {
None
}
})
.collect();
let idx = crate::store::select_weighted_random_index(&filtered, config.default_priority)?;
filtered.get(idx).map(|t| t.uid.clone())
}
pub async fn yank_task(&self, _uid: String) -> Result<(), MobileError> {
Ok(())
}
pub async fn add_task_smart(&self, input: String) -> Result<String, MobileError> {
#[cfg(target_os = "android")]
log::debug!("add_task_smart: '{}'", input);
let mut config = Config::load(self.ctx.as_ref()).unwrap_or_default();
let (clean_input, early_return) = self.apply_inline_metadata(&input, &mut config).await?;
if let Some(ret) = early_return {
return Ok(ret);
}
let def_time = NaiveTime::parse_from_str(&config.default_reminder_time, "%H:%M").ok();
let mut task = Task::new(&clean_input, &config.tag_aliases, def_time);
let store = self.controller.store.lock().await;
let _warnings = store.resolve_dependencies(&mut task);
drop(store);
if task.summary.trim().is_empty() {
if !clean_input.trim().is_empty() {
task.summary = clean_input.clone();
} else {
return Ok("".to_string());
}
}
#[cfg(target_os = "android")]
log::debug!(
"Created task: uid={}, summary='{}', alarms={}",
task.uid,
task.summary,
!task.alarms.is_empty()
);
self.resolve_task_calendar(&mut task, &config).await;
let uid = self
.controller
.create_task(task)
.await
.map_err(MobileError::from)?;
#[cfg(target_os = "android")]
log::debug!("Rebuilding alarm index after adding {}", uid);
self.rebuild_alarm_index().await;
#[cfg(target_os = "android")]
log::debug!("Alarm index rebuilt. Returning uid: {}", uid);
Ok(uid)
}
pub async fn add_task_with_description(
&self,
input: String,
description: String,
) -> Result<String, MobileError> {
#[cfg(target_os = "android")]
log::debug!("add_task_with_description: '{}'", input);
let mut config = Config::load(self.ctx.as_ref()).unwrap_or_default();
let (clean_input, early_return) = self.apply_inline_metadata(&input, &mut config).await?;
if let Some(ret) = early_return {
return Ok(ret);
}
let def_time =
chrono::NaiveTime::parse_from_str(&config.default_reminder_time, "%H:%M").ok();
let (cleaned_desc, extracted_subtasks) =
crate::model::extractor::extract_markdown_tasks(&description, false);
let mut task = Task::new(&clean_input, &config.tag_aliases, def_time);
let store = self.controller.store.lock().await;
let _warnings2 = store.resolve_dependencies(&mut task);
drop(store);
if task.summary.trim().is_empty() && cleaned_desc.is_empty() {
if !clean_input.trim().is_empty() {
task.summary = clean_input.clone();
} else {
return Ok("".to_string());
}
}
if !cleaned_desc.is_empty() {
if task.description.is_empty() {
task.description = cleaned_desc;
} else {
task.description.push_str(&format!("\n\n{}", cleaned_desc));
}
}
self.resolve_task_calendar(&mut task, &config).await;
let parent_props = (
task.categories.clone(),
task.locations.clone(),
task.priority,
);
let parent_uid = self
.controller
.create_task(task)
.await
.map_err(MobileError::from)?;
let mut resolved_props = std::collections::HashMap::new();
resolved_props.insert(parent_uid.clone(), parent_props);
for ext in extracted_subtasks {
let mut sub = Task::new(&ext.raw_text, &config.tag_aliases, def_time);
sub.uid = ext.uid.clone();
let p_uid_str = ext.parent_uid.clone().unwrap_or_else(|| parent_uid.clone());
if let Some((p_cats, p_loc, p_prio)) = resolved_props.get(&p_uid_str) {
sub.inherit_properties(p_cats, p_loc, *p_prio);
}
resolved_props.insert(
sub.uid.clone(),
(sub.categories.clone(), sub.locations.clone(), sub.priority),
);
let store = self.controller.store.lock().await;
let _warnings2 = store.resolve_dependencies(&mut sub);
drop(store);
if !ext.description.is_empty() {
if sub.description.is_empty() {
sub.description = ext.description;
} else {
sub.description
.push_str(&format!("\n\n{}", ext.description));
}
}
sub.apply_extracted_status(ext.status);
sub.parent_uid = Some(ext.parent_uid.unwrap_or(parent_uid.clone()));
sub.dependencies = ext.dependencies;
let active_cal = self.session.lock().await.active_calendar_href.clone();
sub.calendar_href = active_cal
.or(config.default_calendar.clone())
.unwrap_or(crate::storage::LOCAL_CALENDAR_HREF.to_string());
self.controller
.create_task(sub)
.await
.map_err(MobileError::from)?;
}
#[cfg(target_os = "android")]
log::debug!("Rebuilding alarm index after adding {}", parent_uid);
self.rebuild_alarm_index().await;
Ok(parent_uid)
}
pub async fn change_priority(&self, uid: String, delta: i8) -> Result<String, MobileError> {
self.dispatch(crate::model::AppIntent::ChangePriority { uid, delta })
.await
}
pub async fn set_status_process(&self, uid: String) -> Result<String, MobileError> {
self.dispatch(crate::model::AppIntent::StartTask { uid })
.await
}
pub async fn set_status_cancelled(&self, uid: String) -> Result<String, MobileError> {
self.dispatch(crate::model::AppIntent::CancelTask { uid })
.await
}
pub async fn pause_task(&self, uid: String) -> Result<String, MobileError> {
self.dispatch(crate::model::AppIntent::PauseTask { uid })
.await
}
pub async fn stop_task(&self, uid: String) -> Result<String, MobileError> {
self.dispatch(crate::model::AppIntent::StopTask { uid })
.await
}
pub async fn start_task(&self, uid: String) -> Result<String, MobileError> {
self.dispatch(crate::model::AppIntent::StartTask { uid })
.await
}
pub async fn update_task_smart(
&self,
uid: String,
smart_input: String,
) -> Result<(), MobileError> {
let config = Config::load(self.ctx.as_ref()).unwrap_or_default();
let def_time = NaiveTime::parse_from_str(&config.default_reminder_time, "%H:%M").ok();
let calendars = self.get_calendars();
let calendars_model = mobile_calendars_to_model(&calendars);
let mut store = self.controller.store.lock().await;
let mut temp_task = match store.get_task_ref(&uid) {
Some(t) => t.clone(),
None => return Err(MobileError::from("Task not found".to_string())),
};
let original_task = temp_task.clone();
let old_href = temp_task.calendar_href.clone();
temp_task.apply_smart_input(&smart_input, &config.tag_aliases, def_time);
if let Some(target) = temp_task.target_collection.take() {
temp_task.calendar_href =
crate::model::resolve_collection(&target, &calendars_model, &old_href);
}
let new_href = temp_task.calendar_href.clone();
let _warnings = store.resolve_dependencies(&mut temp_task);
temp_task.sequence += 1;
let final_task = temp_task.clone();
store.update_or_add_task(temp_task);
drop(store);
let mut actions = Vec::new();
if old_href != new_href {
actions.push(crate::journal::Action::Move(
original_task,
new_href.clone(),
));
}
actions.push(crate::journal::Action::Update(final_task));
self.controller
.persist_changes(actions)
.await
.map_err(MobileError::from)?;
self.rebuild_alarm_index().await;
Ok(())
}
pub async fn update_task_description(
&self,
uid: String,
description: String,
) -> Result<(), MobileError> {
let config = crate::config::Config::load(self.ctx.as_ref()).unwrap_or_default();
let def_time =
chrono::NaiveTime::parse_from_str(&config.default_reminder_time, "%H:%M").ok();
let mut store = self.controller.store.lock().await;
let is_journal = store
.get_task_ref(&uid)
.map(|t| t.is_journal)
.unwrap_or(false);
let (clean_desc, extracted) =
crate::model::extractor::extract_markdown_tasks(&description, is_journal);
let mut actions = Vec::new();
let mut resolved_props = std::collections::HashMap::new();
let parent_href = if let Some((task, _)) = store.get_task_mut(&uid) {
task.description = clean_desc;
task.sequence += 1;
let href = task.calendar_href.clone();
resolved_props.insert(
uid.clone(),
(
task.categories.clone(),
task.locations.clone(),
task.priority,
),
);
actions.push(crate::journal::Action::Update(task.clone()));
href
} else {
return Ok(());
};
for ext in extracted {
let mut sub = crate::model::Task::new(&ext.raw_text, &config.tag_aliases, def_time);
sub.uid = ext.uid;
let p_uid_str = ext.parent_uid.clone().unwrap_or_else(|| uid.clone());
if let Some((p_cats, p_loc, p_prio)) = resolved_props.get(&p_uid_str) {
sub.inherit_properties(p_cats, p_loc, *p_prio);
}
resolved_props.insert(
sub.uid.clone(),
(sub.categories.clone(), sub.locations.clone(), sub.priority),
);
if !ext.description.is_empty() {
if sub.description.is_empty() {
sub.description = ext.description;
} else {
sub.description
.push_str(&format!("\n\n{}", ext.description));
}
}
sub.apply_extracted_status(ext.status);
sub.parent_uid = Some(ext.parent_uid.unwrap_or(uid.clone()));
sub.dependencies = ext.dependencies;
sub.calendar_href = parent_href.clone();
if let Some(pc) = ext.percent_complete {
sub.percent_complete = Some(pc);
}
sub.is_note = ext.is_note;
let _warnings = store.resolve_dependencies(&mut sub);
store.add_task(sub.clone());
actions.push(crate::journal::Action::Create(sub));
}
drop(store);
if !actions.is_empty() {
self.controller
.persist_changes(actions)
.await
.map_err(MobileError::from)?;
self.rebuild_alarm_index().await;
}
Ok(())
}
pub async fn toggle_task(&self, uid: String) -> Result<String, MobileError> {
self.dispatch(crate::model::AppIntent::ToggleTask { uid })
.await
}
pub async fn toggle_task_shift(&self, uid: String) -> Result<String, MobileError> {
self.dispatch(crate::model::AppIntent::ToggleTaskShift { uid })
.await
}
pub async fn move_task(
&self,
uid: String,
new_cal_href: String,
) -> Result<String, MobileError> {
self.dispatch(crate::model::AppIntent::MoveTask {
uid,
target_href: new_cal_href,
})
.await
}
pub async fn move_task_tree(
&self,
uid: String,
new_cal_href: String,
) -> Result<String, MobileError> {
self.dispatch(crate::model::AppIntent::MoveTaskTree {
uid,
target_href: new_cal_href,
})
.await
}
pub async fn delete_task(&self, uid: String) -> Result<String, MobileError> {
self.dispatch(crate::model::AppIntent::DeleteTask { uid })
.await
}
pub async fn duplicate_task_tree(&self, uid: String) -> Result<String, MobileError> {
self.dispatch(crate::model::AppIntent::DuplicateTaskTree { uid })
.await
}
pub async fn delete_task_tree(&self, uid: String) -> Result<String, MobileError> {
self.dispatch(crate::model::AppIntent::DeleteTaskTree { uid })
.await
}
pub async fn toggle_pin(&self, uid: String) -> Result<String, MobileError> {
self.dispatch(crate::model::AppIntent::TogglePin { uid })
.await
}
pub async fn sync_task_tree_from_markdown(
&self,
uid: String,
markdown: String,
) -> Result<(), MobileError> {
let config = Config::load(self.ctx.as_ref()).unwrap_or_default();
let def_time =
chrono::NaiveTime::parse_from_str(&config.default_reminder_time, "%H:%M").ok();
let cals = load_all_calendar_entries(self.ctx.as_ref());
let mut store = self.controller.store.lock().await;
let is_journal = store
.get_task_ref(&uid)
.map(|t| t.is_journal)
.unwrap_or(false);
let sync_options = crate::store::SyncTreeOptions {
aliases: &config.tag_aliases,
default_reminder_time: def_time,
trash_retention_days: config.trash_retention_days,
calendars: &cals,
};
match store.sync_tree_from_markdown(&uid, &markdown, &sync_options, is_journal) {
Ok((actions, warnings)) => {
drop(store);
if !warnings.is_empty() {
#[cfg(target_os = "android")]
for w in warnings {
log::warn!("Dependency resolution: {}", w);
}
}
if !actions.is_empty() {
self.controller
.persist_changes(actions)
.await
.map_err(MobileError::from)?;
self.rebuild_alarm_index().await;
}
Ok(())
}
Err(e) => Err(MobileError::from(e)),
}
}
pub async fn migrate_local_to(
&self,
source_href: String,
target_href: String,
) -> Result<String, MobileError> {
let client = self
.controller
.client
.lock()
.await
.as_ref()
.ok_or(MobileError::from(
rust_i18n::t!("error_client_not_connected").to_string(),
))?
.clone();
let tasks = LocalStorage::load_for_href(self.ctx.as_ref(), &source_href)
.map_err(|e| MobileError::from(e.to_string()))?;
if tasks.is_empty() {
return Ok(rust_i18n::t!("status_no_tasks_to_migrate").to_string());
}
let count = client
.migrate_tasks(tasks, &target_href)
.await
.map_err(MobileError::from)?;
Ok(format!("Migrated {} tasks.", count))
}
pub async fn create_local_calendar(
&self,
name: String,
color: Option<String>,
) -> Result<String, MobileError> {
let mut locals = LocalCalendarRegistry::load(self.ctx.as_ref())
.map_err(|e| MobileError::from(e.to_string()))?;
let href = format!("local://{}", Uuid::new_v4());
locals.push(crate::model::CalendarListEntry {
name,
href: href.clone(),
color,
supports_vjournal: Some(true),
});
LocalCalendarRegistry::save(self.ctx.as_ref(), &locals)
.map_err(|e| MobileError::from(e.to_string()))?;
self.controller
.store
.lock()
.await
.insert(href.clone(), vec![]);
Ok(href)
}
pub async fn create_remote_calendar(
&self,
name: String,
color: Option<String>,
) -> Result<String, MobileError> {
let client = self
.controller
.client
.lock()
.await
.clone()
.ok_or_else(|| MobileError::from("Offline"))?;
let href = client
.create_calendar(&name, color.as_deref())
.await
.map_err(|e| MobileError::from(e.to_string()))?;
if let Ok(mut cals) = crate::cache::Cache::load_calendars(self.ctx.as_ref()) {
cals.push(crate::model::CalendarListEntry {
name,
href: href.clone(),
color,
supports_vjournal: Some(true),
});
let _ = crate::cache::Cache::save_calendars(self.ctx.as_ref(), &cals);
}
Ok(href)
}
pub async fn update_remote_calendar(
&self,
href: String,
name: String,
color: Option<String>,
) -> Result<(), MobileError> {
let client = self
.controller
.client
.lock()
.await
.clone()
.ok_or_else(|| MobileError::from("Offline"))?;
client
.update_calendar(&href, &name, color.as_deref())
.await
.map_err(|e| MobileError::from(e.to_string()))?;
if let Ok(mut cals) = crate::cache::Cache::load_calendars(self.ctx.as_ref())
&& let Some(c) = cals.iter_mut().find(|c| c.href == href)
{
c.name = name;
c.color = color;
let _ = crate::cache::Cache::save_calendars(self.ctx.as_ref(), &cals);
}
Ok(())
}
pub async fn update_local_calendar(
&self,
href: String,
name: String,
color: Option<String>,
) -> Result<(), MobileError> {
let mut locals = LocalCalendarRegistry::load(self.ctx.as_ref())
.map_err(|e| MobileError::from(e.to_string()))?;
if let Some(cal) = locals.iter_mut().find(|c| c.href == href) {
cal.name = name;
cal.color = color;
LocalCalendarRegistry::save(self.ctx.as_ref(), &locals)
.map_err(|e| MobileError::from(e.to_string()))?;
Ok(())
} else {
Err(MobileError::from(
rust_i18n::t!("error_no_calendar_available").to_string(),
))
}
}
pub async fn delete_local_calendar(&self, href: String) -> Result<(), MobileError> {
if href == LOCAL_CALENDAR_HREF {
return Err(MobileError::from(
rust_i18n::t!("error_cannot_delete_default_calendar").to_string(),
));
}
let mut locals = LocalCalendarRegistry::load(self.ctx.as_ref())
.map_err(|e| MobileError::from(e.to_string()))?;
if let Some(idx) = locals.iter().position(|c| c.href == href) {
locals.remove(idx);
LocalCalendarRegistry::save(self.ctx.as_ref(), &locals)
.map_err(|e| MobileError::from(e.to_string()))?;
if let Some(path) = LocalStorage::get_path_for_href(self.ctx.as_ref(), &href) {
let _ = std::fs::remove_file(path);
}
let mut store = self.controller.store.lock().await;
store.remove(&href);
drop(store);
self.rebuild_alarm_index().await;
Ok(())
} else {
Err(MobileError::from(
rust_i18n::t!("error_no_calendar_available").to_string(),
))
}
}
pub async fn snooze_alarm(
&self,
task_uid: String,
alarm_uid: String,
minutes: u32,
) -> Result<(), MobileError> {
self.apply_store_mutation(&task_uid, |store, id| {
if let Some((task, _)) = store.get_task_mut(id)
&& task.handle_snooze(&alarm_uid, minutes)
{
return Some(task.clone());
}
None
})
.await?;
Ok(())
}
pub async fn dismiss_alarm(
&self,
task_uid: String,
alarm_uid: String,
) -> Result<(), MobileError> {
#[cfg(target_os = "android")]
log::debug!("dismiss_alarm: task={}, alarm={}", task_uid, alarm_uid);
self.apply_store_mutation(&task_uid, |store, id| {
if let Some((task, _)) = store.get_task_mut(id)
&& task.handle_dismiss(&alarm_uid)
{
return Some(task.clone());
}
None
})
.await?;
#[cfg(target_os = "android")]
log::debug!("Dismiss successful");
Ok(())
}
pub async fn get_next_global_alarm_time(&self) -> Option<i64> {
let store = self.controller.store.lock().await;
let mut earliest: Option<i64> = None;
for map in store.calendars.values() {
for task in map.values() {
if task.status.is_done()
|| task.calendar_href == crate::storage::LOCAL_TRASH_HREF
|| task.calendar_href == "local://recovery"
{
continue;
}
if let Some(ts) = task.next_trigger_timestamp()
&& (earliest.is_none() || ts < earliest.unwrap())
{
earliest = Some(ts);
}
}
}
earliest
}
pub async fn delete_all_calendar_events(&self) -> Result<u32, MobileError> {
let client = {
self.controller
.client
.lock()
.await
.as_ref()
.ok_or(MobileError::from(rust_i18n::t!("offline").to_string()))?
.clone()
};
let cals: Vec<String> = {
self.controller
.store
.lock()
.await
.calendars
.keys()
.filter(|h| !h.starts_with("local://"))
.cloned()
.collect()
};
let mut total = 0;
for cal_href in cals {
if let Ok(count) = client.delete_all_companion_events(&cal_href).await {
total += count as u32;
}
}
Ok(total)
}
pub async fn create_missing_calendar_events(&self) -> Result<u32, MobileError> {
let all_tasks: Vec<_> = {
self.controller
.store
.lock()
.await
.calendars
.values()
.flat_map(|m| m.values())
.cloned()
.collect()
};
let client = {
self.controller
.client
.lock()
.await
.as_ref()
.ok_or(MobileError::from(rust_i18n::t!("offline").to_string()))?
.clone()
};
let config = Config::load(self.ctx.as_ref()).unwrap_or_default();
let count = client
.sync_multiple_companion_events(&all_tasks, true, config.delete_events_on_completion)
.await
.unwrap_or(0);
Ok(count as u32)
}
pub async fn should_keep_notification(
&self,
task_uid: String,
notif_type: String,
alarm_uid: Option<String>,
) -> bool {
let store = self.controller.store.lock().await;
let task = match store.get_task_ref(&task_uid) {
Some(t) => t,
None => return false,
};
if task.status.is_done()
|| task.calendar_href == crate::storage::LOCAL_TRASH_HREF
|| task.calendar_href == "local://recovery"
{
return false;
}
if notif_type == "ongoing" {
let config = crate::config::Config::load(self.ctx.as_ref()).unwrap_or_default();
if !config.show_ongoing_notifications {
return false;
}
return task.status == crate::model::TaskStatus::InProcess;
}
if notif_type == "alarm" && task.status == crate::model::TaskStatus::InProcess {
return false;
}
if notif_type == "alarm"
&& let Some(a_uid) = alarm_uid
{
if let Some(alarm) = task.alarms.iter().find(|a| a.uid == a_uid) {
if alarm.acknowledged.is_some() {
return false;
}
let now = chrono::Utc::now();
let trigger_dt = match alarm.trigger {
crate::model::AlarmTrigger::Absolute(dt) => dt,
crate::model::AlarmTrigger::Relative(mins) => {
let anchor = if let Some(crate::model::DateType::Specific(d)) = task.due {
d
} else if let Some(crate::model::DateType::Specific(s)) = task.dtstart {
s
} else {
return false;
};
anchor + chrono::Duration::minutes(mins as i64)
}
};
if trigger_dt > now + chrono::Duration::minutes(5) {
return false;
}
} else if a_uid.starts_with("implicit_") {
let parts: Vec<&str> = a_uid.split('|').collect();
if parts.len() >= 2 {
let type_key_with_colon = parts[0];
let expected_ts = parts[1];
let config = crate::config::Config::load(self.ctx.as_ref()).unwrap_or_default();
let default_time =
chrono::NaiveTime::parse_from_str(&config.default_reminder_time, "%H:%M")
.unwrap_or_else(|_| chrono::NaiveTime::from_hms_opt(9, 0, 0).unwrap());
let mut current_ts = None;
if type_key_with_colon == "implicit_due:" {
if let Some(due) = &task.due {
current_ts =
Some(due.to_utc_with_default_time(default_time).to_rfc3339());
}
} else if type_key_with_colon == "implicit_start:"
&& let Some(start) = &task.dtstart
{
current_ts =
Some(start.to_utc_with_default_time(default_time).to_rfc3339());
}
if current_ts.as_deref() != Some(expected_ts) {
return false;
}
if let Ok(dt) = chrono::DateTime::parse_from_rfc3339(expected_ts)
&& task.has_alarm_at(dt.with_timezone(&chrono::Utc))
{
return false;
}
}
} else {
return false;
}
}
true
}
}
impl CfaitMobile {
async fn apply_store_mutation<F>(&self, uid: &str, mutator: F) -> Result<(), MobileError>
where
F: FnOnce(&mut TaskStore, &str) -> Option<Task>,
{
let mut store = self.controller.store.lock().await;
let task_to_save = mutator(&mut store, uid).ok_or(MobileError::from(
rust_i18n::t!("error_task_not_found").to_string(),
))?;
drop(store);
self.controller
.update_task(task_to_save)
.await
.map_err(MobileError::from)?;
self.rebuild_alarm_index().await;
Ok(())
}
fn load_all_local_calendars(&self) -> Vec<(String, Vec<Task>)> {
let mut out = Vec::new();
if let Ok(locals) = LocalCalendarRegistry::load(self.ctx.as_ref()) {
for loc in locals {
match LocalStorage::load_for_href(self.ctx.as_ref(), &loc.href) {
Ok(mut tasks) => {
crate::journal::Journal::apply_to_tasks(
self.ctx.as_ref(),
&mut tasks,
&loc.href,
);
out.push((loc.href, tasks));
}
Err(e) => {
#[cfg(target_os = "android")]
log::error!("Failed to load {} - data corruption: {}", loc.href, e);
#[cfg(not(target_os = "android"))]
eprintln!("Failed to load {} - data corruption: {}", loc.href, e);
}
}
}
}
out
}
fn load_cached_remote_into_store(&self, store: &mut TaskStore, href: &str) {
if let Ok((mut cached, _)) = Cache::load(self.ctx.as_ref(), href) {
crate::journal::Journal::apply_to_tasks(self.ctx.as_ref(), &mut cached, href);
store.insert(href.to_string(), cached);
}
}
async fn apply_inline_metadata(
&self,
input: &str,
config: &mut Config,
) -> Result<(String, Option<String>), MobileError> {
let (clean_input_1, new_goals) = crate::model::extract_inline_goals(input);
let (clean_input, new_aliases) = crate::model::extract_inline_aliases(&clean_input_1);
let config_changed = !new_goals.is_empty() || !new_aliases.is_empty();
if !new_goals.is_empty() {
config.goals.extend(new_goals);
}
if !new_aliases.is_empty() {
for (k, v) in &new_aliases {
crate::model::validate_alias_integrity(k, v, &config.tag_aliases)
.map_err(MobileError::from)?;
}
config.tag_aliases.extend(new_aliases.clone());
let mut store = self.controller.store.lock().await;
let all_modified: Vec<_> = new_aliases
.iter()
.flat_map(|(key, tags)| store.apply_alias_retroactively(key, tags))
.collect();
drop(store);
for t in all_modified {
self.controller
.update_task(t)
.await
.map_err(MobileError::from)?;
}
}
if config_changed {
let old_config = Config::load(self.ctx.as_ref()).unwrap_or_default();
config.update_sync_timestamp_if_changed(&old_config);
config.save(self.ctx.as_ref()).map_err(MobileError::from)?;
let trimmed = clean_input.trim();
if trimmed.is_empty()
|| (!trimmed.contains(' ')
&& (trimmed.starts_with('#')
|| trimmed.starts_with("@@")
|| trimmed.to_lowercase().starts_with("loc:")))
{
return Ok((clean_input, Some("ALIAS_UPDATED".to_string())));
}
}
if clean_input.trim().is_empty() {
return Ok((clean_input, Some(String::new())));
}
Ok((clean_input, None))
}
async fn resolve_task_calendar(&self, task: &mut Task, config: &Config) {
let mobile_calendars = self.get_calendars();
let calendars = mobile_calendars_to_model(&mobile_calendars);
let active_cal = self.session.lock().await.active_calendar_href.clone();
let inherited_href = active_cal
.or(config.default_calendar.clone())
.unwrap_or_else(|| {
mobile_calendars
.iter()
.find(|c| c.is_visible && !c.is_disabled)
.map(|c| c.href.clone())
.unwrap_or_else(|| LOCAL_CALENDAR_HREF.to_string())
});
task.calendar_href = if let Some(target) = task.target_collection.take() {
crate::model::resolve_collection(&target, &calendars, &inherited_href)
} else {
inherited_href
};
}
async fn apply_connection(&self, config: Config) -> Result<String, MobileError> {
let (client, cals, _, _, warning) =
RustyClient::connect_with_fallback(self.ctx.clone(), config, Some("Android"))
.await
.map_err(MobileError::from)?;
*self.controller.client.lock().await = Some(client.clone());
let fetch_result = client.get_all_tasks(&cals).await;
let mut store = self.controller.store.lock().await;
store.clear();
for (href, tasks) in self.load_all_local_calendars() {
store.insert(href, tasks);
}
match fetch_result {
Ok(results) => {
let mut fetched_hrefs = HashSet::new();
for (href, mut tasks) in results {
crate::journal::Journal::apply_to_tasks(self.ctx.as_ref(), &mut tasks, &href);
store.insert(href.clone(), tasks);
fetched_hrefs.insert(href);
}
for cal in &cals {
if !cal.href.starts_with("local://") && !fetched_hrefs.contains(&cal.href) {
self.load_cached_remote_into_store(&mut store, &cal.href);
}
}
}
Err(e) => {
for cal in &cals {
if !cal.href.starts_with("local://") && !store.calendars.contains_key(&cal.href)
{
self.load_cached_remote_into_store(&mut store, &cal.href);
}
}
drop(store);
self.rebuild_alarm_index().await;
if let Some(w) = warning {
return Err(MobileError::from(w));
} else {
return Err(MobileError::from(e));
}
}
}
drop(store);
self.rebuild_alarm_index().await;
Ok(warning.unwrap_or_else(|| rust_i18n::t!("status_connected").to_string()))
}
async fn rebuild_alarm_index(&self) {
let config = Config::load(self.ctx.as_ref()).unwrap_or_default();
let index = {
let store = self.controller.store.lock().await;
AlarmIndex::rebuild_from_tasks(
&store.calendars,
config.auto_reminders,
&config.default_reminder_time,
)
};
match index.save(self.ctx.as_ref()) {
Ok(_) => {
#[cfg(target_os = "android")]
log::debug!("Alarm index rebuilt with {} alarms", index.len());
*self.alarm_index_cache.lock().await = Some(index);
}
Err(e) => {
#[cfg(target_os = "android")]
log::warn!("Failed to save alarm index: {}", e);
#[cfg(not(target_os = "android"))]
let _ = e;
}
}
}
fn create_debug_export_internal(&self) -> Result<String, MobileError> {
#[cfg(target_os = "android")]
{
log::logger().flush();
let data_dir = self
.ctx
.get_data_dir()
.map_err(|e| MobileError::from(e.to_string()))?;
let cache_dir = self
.ctx
.get_cache_dir()
.map_err(|e| MobileError::from(e.to_string()))?;
let config_dir = self
.ctx
.get_config_dir()
.map_err(|e| MobileError::from(e.to_string()))?;
let export_path = cache_dir.join("cfait_debug_export.zip");
let file = std::fs::File::create(&export_path)
.map_err(|e| MobileError::from(e.to_string()))?;
let mut zip = zip::ZipWriter::new(file);
let options = zip::write::SimpleFileOptions::default()
.compression_method(zip::CompressionMethod::Deflated)
.unix_permissions(0o755);
let mut add_dir = |dir: &std::path::Path, prefix: &str| -> Result<(), MobileError> {
if let Ok(entries) = std::fs::read_dir(dir) {
for entry in entries.flatten() {
let path = entry.path();
if path.is_file() {
let file_name = path.file_name().unwrap().to_string_lossy();
if file_name.ends_with(".lock") || file_name == "cfait_debug_export.zip"
{
continue;
}
zip.start_file(format!("{}{}", prefix, file_name), options)
.map_err(|e| MobileError::from(e.to_string()))?;
if file_name == "config.toml" {
let mut config =
Config::load(self.ctx.as_ref()).unwrap_or_default();
config.username = "[REDACTED]".to_string();
config.password = "[REDACTED]".to_string();
use std::io::Write;
zip.write_all(
toml::to_string_pretty(&config)
.unwrap_or_default()
.as_bytes(),
)
.map_err(|e| MobileError::from(e.to_string()))?;
} else {
let mut f = std::fs::File::open(&path)
.map_err(|e| MobileError::from(e.to_string()))?;
let mut buffer = Vec::new();
use std::io::Read;
f.read_to_end(&mut buffer)
.map_err(|e| MobileError::from(e.to_string()))?;
use std::io::Write;
zip.write_all(&buffer)
.map_err(|e| MobileError::from(e.to_string()))?;
}
}
}
}
Ok(())
};
add_dir(&data_dir, "data/")?;
add_dir(&config_dir, "config/")?;
add_dir(&cache_dir, "cache/")?;
zip.finish().map_err(|e| MobileError::from(e.to_string()))?;
return Ok(export_path.to_string_lossy().to_string());
}
#[cfg(not(target_os = "android"))]
{
Err(MobileError::from(
rust_i18n::t!("debug_export_android_only").to_string(),
))
}
}
}
#[cfg(test)]
mod tests {
use super::apply_mobile_credentials_update;
use crate::config::Config;
#[test]
fn preserves_existing_password_when_android_ui_leaves_password_blank() {
let mut config = Config {
username: "alice".to_string(),
password: "secret".to_string(),
..Config::default()
};
apply_mobile_credentials_update(&mut config, "alice", "");
assert_eq!(config.username, "alice");
assert_eq!(config.password, "secret");
}
#[test]
fn clears_password_when_username_changes_without_new_password() {
let mut config = Config {
username: "alice".to_string(),
password: "secret".to_string(),
..Config::default()
};
apply_mobile_credentials_update(&mut config, "bob", "");
assert_eq!(config.username, "bob");
assert!(config.password.is_empty());
}
#[test]
fn replaces_password_when_user_enters_a_new_one() {
let mut config = Config {
username: "alice".to_string(),
password: "secret".to_string(),
..Config::default()
};
apply_mobile_credentials_update(&mut config, "alice", "new-secret");
assert_eq!(config.username, "alice");
assert_eq!(config.password, "new-secret");
}
}