1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
//! palmrs-conduit-todotxt: Palm OS "Tasks" app <-> `todo.txt` sync conduit

use std::{
	fs,
	io::{BufRead, Cursor},
	path::Path,
	str,
};

use byteorder::{BigEndian, ReadBytesExt};
use palmrs::{
	database::{
		info::ExtraInfoRecord,
		record::DatabaseRecord,
		PalmDatabase,
		PdbWithCategoriesDatabase,
	},
	sync::{
		conduit::{ConduitRequirements, WithinConduit},
		SyncMode,
	},
};
use stable_eyre::eyre::{eyre, Report, WrapErr};

/// A single to-do task
#[derive(Debug, Clone, PartialEq)]
pub struct ToDoTask {
	/// Task body
	pub task_text: String,

	/// Extended task note text
	pub note_text: Option<String>,

	/// Category name
	///
	/// Uses the "unfiled" category if None.
	pub category: Option<String>,

	/// Task priority (between 1 and 5)
	pub priority: u8,

	/// Has the task been completed?
	pub completed: bool,

	/// Task due date, if defined, as a `(year, month, day)` tuple
	pub due_date: Option<(u16, u8, u8)>,
}

impl ToDoTask {
	/// Return this task as a `todo.txt` formatted String
	pub fn as_todotxt_entry(&self) -> String {
		let mut s = String::new();

		// completed?
		if self.completed {
			s.push_str("x ");
		}

		// priority
		s.push('(');
		s.push((self.priority + 64) as char);
		s.push(')');
		s.push(' ');

		// due date?
		if let Some((year, month, day)) = &self.due_date {
			s.push_str(&format!("{:04}-{:02}-{:02} ", year, month, day));
		}

		// task text
		s.push_str(&self.task_text.replace("\n", " "));

		// category?
		if let Some(category) = &self.category {
			s.push_str(&format!(" @{}", category.replace(" ", "_")));
		}

		// note text?
		if let Some(note_text) = &self.note_text {
			s.push_str(&format!(" note={:?}", note_text));
		}

		s
	}
}

/// Parse tasks out of a `ToDoDB` database
pub fn device_database_parse(db_path: &Path) -> Result<Vec<ToDoTask>, Report> {
	let db_content =
		fs::read(db_path).wrap_err_with(|| eyre!("Failed to read database: {:?}", db_path))?;
	let database = PalmDatabase::<PdbWithCategoriesDatabase>::from_bytes(&db_content)
		.wrap_err_with(|| eyre!("Failed to parse device ToDoDB: {:?}", db_path))?;

	let categories: Vec<(u8, String)> = {
		let mut categories = Vec::new();
		if let Some(db_cats) = database.app_info.data_item_categories() {
			for cat in db_cats.iter() {
				categories.push((
					cat.category_id,
					String::from(cat.name_try_str().unwrap_or("ErrCategory")),
				));
			}
		}

		categories
	};

	let mut tasks = Vec::new();
	'dbtaskparse: for (idx, (rec_hdr, rec_data)) in
		(0..).zip(database.list_records_resources().iter())
	{
		log::trace!("records[{}].rec_hdr = {:?}", idx, &rec_hdr);
		if rec_hdr.data_len().unwrap_or(0) == 0 {
			break 'dbtaskparse;
		}

		// Get category from record attributes
		let rec_attributes = rec_hdr.attributes().unwrap_or_default();
		let category = {
			let mut catname: Option<String> = None;
			let catid = rec_attributes.category;
			'catsearch: for (x_catid, x_catname) in categories.iter() {
				if catid == *x_catid {
					catname = Some(x_catname.clone());
					break 'catsearch;
				}
			}

			catname
		};

		// Parse out the due date, priority, and completion flag
		let mut cursor = Cursor::new(&rec_data);
		let due_date = {
			let mut due_date: Option<(u16, u8, u8)> = None;
			let raw_date = cursor.read_u16::<BigEndian>()?;
			if raw_date != 0xFFFF {
				due_date = Some((
					((raw_date >> 9) & 0x007F) + 1904,
					((raw_date >> 5) & 0x000F) as u8,
					(raw_date & 0x001F) as u8,
				));
			}

			due_date
		};

		let priority = cursor.read_u8()?;
		let completed = priority & 0x80 != 0;
		let priority = priority & 0x7F;

		// Read out the task text
		let task_text = {
			let mut buf: Vec<u8> = Vec::new();
			let count = cursor
				.read_until(0x00, &mut buf)
				.expect("cursor read fail?");

			str::from_utf8(&buf[0..(count - 1)])
				.wrap_err("UTF-8 conversion failed")?
				.to_string()
		};

		// Read out the note text
		let note_text = {
			let mut buf: Vec<u8> = Vec::new();
			let count = cursor
				.read_until(0x00, &mut buf)
				.expect("cursor read fail?");

			if count > 1 {
				let s = str::from_utf8(&buf[0..(count - 1)])
					.wrap_err("UTF-8 conversion failed")?
					.to_string();

				Some(s)
			} else {
				None
			}
		};

		// And construct our task object!
		tasks.push(ToDoTask {
			task_text,
			note_text,
			category,
			priority,
			completed,
			due_date,
		});
	}

	Ok(tasks)
}

/// Perform a one-way conversion from the Palm OS `ToDoDB` to the `todo.txt` format
///
/// If `conduit.config.environment["SINGLE_FILE"]` is set to `"1"`, this method will store both
/// incomplete and complete tasks in the same file (named `todo.txt`). If it is set to any other
/// value (the default is `"0"`), this method will store incomplete tasks in `todo.txt`, and
/// complete tasks in `done.txt`.
pub fn palm_to_todotxt(conduit: &WithinConduit) -> Result<(), Report> {
	log::info!("palm_to_todotxt: parsing Palm OS tasks database");

	let mut device_database = conduit.config.path_device.clone();
	device_database.push("ToDoDB.pdb");
	let device_tasks = device_database_parse(&device_database)?;
	log::trace!("device_tasks = {:#?}", &device_tasks);

	// Collate todo.txt entries by completion status
	let mut todotxt_incomplete: Vec<String> = Vec::new();
	let mut todotxt_completed: Vec<String> = Vec::new();
	for task in device_tasks.iter() {
		if task.completed {
			todotxt_completed.push(task.as_todotxt_entry());
		} else {
			todotxt_incomplete.push(task.as_todotxt_entry());
		}
	}

	log::info!(
		"palm_to_todotxt: {} incomplete, {} complete tasks",
		todotxt_incomplete.len(),
		todotxt_completed.len(),
	);

	// Get paths to `todo.txt` and `done.txt`
	let mut todotxt_path = conduit.config.path_local.clone();
	todotxt_path.push("todo.txt");
	log::debug!("todotxt_path = {:?}", &todotxt_path);
	let mut donetxt_path = conduit.config.path_local.clone();
	donetxt_path.push("done.txt");
	log::debug!("donetxt_path = {:?}", &donetxt_path);

	// Are we in single-file mode?
	let single_file = conduit
		.config
		.environment
		.get("SINGLE_FILE")
		.unwrap_or(&String::from("0"))
		.as_str()
		== "1";

	if single_file {
		// If we're in single file mode, write incomplete _then_ completed to `todo.txt`
		log::info!("palm_to_todotxt: operating in single-file mode");

		fs::write(&todotxt_path, {
			let mut v = Vec::new();
			for task in todotxt_incomplete.iter() {
				v.push(task.clone());
			}
			for task in todotxt_completed.iter() {
				v.push(task.clone());
			}

			v.join("\n")
		})?;
	} else {
		// If we're not in single-file mode, write incomplete to `todo.txt`, and completed to
		// `done.txt`
		log::info!("palm_to_todotxt: operating in dual-file mode");

		fs::write(&todotxt_path, todotxt_incomplete.join("\n"))?;
		fs::write(&donetxt_path, todotxt_completed.join("\n"))?;
	}

	log::info!("palm_to_todotxt: wrote our todo.txt file(s) :)");
	Ok(())
}

/// Main entrypoint
pub fn main() -> Result<(), Report> {
	env_logger::init();
	stable_eyre::install()?;

	let conduit = WithinConduit::new(
		"palmrs-conduit-todotxt",
		ConduitRequirements::new()
			.with_databases(&["ToDoDB"])
			.finish(),
	)
	.from_env()
	.wrap_err("WithinConduit build failed")?;
	log::trace!("conduit = {:#?}", &conduit);

	match conduit.config.sync_mode {
		SyncMode::KeepDevice => palm_to_todotxt(&conduit),
		_ => unimplemented!(),
	}
}