max / goingson
14 files changed,
+156 insertions,
-37 deletions
| @@ -633,6 +633,16 @@ dependencies = [ | |||
| 633 | 633 | ] | |
| 634 | 634 | ||
| 635 | 635 | [[package]] | |
| 636 | + | name = "chrono-tz" | |
| 637 | + | version = "0.10.4" | |
| 638 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 639 | + | checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" | |
| 640 | + | dependencies = [ | |
| 641 | + | "chrono", | |
| 642 | + | "phf 0.12.1", | |
| 643 | + | ] | |
| 644 | + | ||
| 645 | + | [[package]] | |
| 636 | 646 | name = "cipher" | |
| 637 | 647 | version = "0.4.4" | |
| 638 | 648 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1897,6 +1907,7 @@ dependencies = [ | |||
| 1897 | 1907 | "async-trait", | |
| 1898 | 1908 | "base64 0.22.1", | |
| 1899 | 1909 | "chrono", | |
| 1910 | + | "chrono-tz", | |
| 1900 | 1911 | "csv", | |
| 1901 | 1912 | "dirs", | |
| 1902 | 1913 | "docengine", | |
| @@ -3680,6 +3691,15 @@ dependencies = [ | |||
| 3680 | 3691 | ||
| 3681 | 3692 | [[package]] | |
| 3682 | 3693 | name = "phf" | |
| 3694 | + | version = "0.12.1" | |
| 3695 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3696 | + | checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" | |
| 3697 | + | dependencies = [ | |
| 3698 | + | "phf_shared 0.12.1", | |
| 3699 | + | ] | |
| 3700 | + | ||
| 3701 | + | [[package]] | |
| 3702 | + | name = "phf" | |
| 3683 | 3703 | version = "0.13.1" | |
| 3684 | 3704 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3685 | 3705 | checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" | |
| @@ -3828,6 +3848,15 @@ dependencies = [ | |||
| 3828 | 3848 | ||
| 3829 | 3849 | [[package]] | |
| 3830 | 3850 | name = "phf_shared" | |
| 3851 | + | version = "0.12.1" | |
| 3852 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3853 | + | checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" | |
| 3854 | + | dependencies = [ | |
| 3855 | + | "siphasher 1.0.2", | |
| 3856 | + | ] | |
| 3857 | + | ||
| 3858 | + | [[package]] | |
| 3859 | + | name = "phf_shared" | |
| 3831 | 3860 | version = "0.13.1" | |
| 3832 | 3861 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3833 | 3862 | checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" |
| @@ -16,6 +16,7 @@ license-file = "LICENSE" | |||
| 16 | 16 | [workspace.dependencies] | |
| 17 | 17 | # Core dependencies | |
| 18 | 18 | chrono = { version = "0.4.43", features = ["serde"] } | |
| 19 | + | chrono-tz = "0.10" | |
| 19 | 20 | uuid = { version = "1.16", features = ["v4", "v5", "serde"] } | |
| 20 | 21 | serde = { version = "1.0.228", features = ["derive"] } | |
| 21 | 22 | serde_json = "1.0.149" |
| @@ -146,6 +146,17 @@ mod goingson_api { | |||
| 146 | 146 | return Err("No import file set for this execution".into()); | |
| 147 | 147 | } | |
| 148 | 148 | ||
| 149 | + | // Check file size before reading to prevent OOM on large files | |
| 150 | + | let metadata = std::fs::metadata(path) | |
| 151 | + | .map_err(|e| -> Box<EvalAltResult> { format!("Failed to stat file '{}': {}", path, e).into() })?; | |
| 152 | + | let max_size = 10 * 1024 * 1024; // 10 MB | |
| 153 | + | if metadata.len() > max_size { | |
| 154 | + | return Err(format!( | |
| 155 | + | "File '{}' is {} bytes, exceeding {} byte limit", | |
| 156 | + | path, metadata.len(), max_size | |
| 157 | + | ).into()); | |
| 158 | + | } | |
| 159 | + | ||
| 149 | 160 | std::fs::read_to_string(path) | |
| 150 | 161 | .map_err(|e| format!("Failed to read file '{}': {}", path, e).into()) | |
| 151 | 162 | }) | |
| @@ -253,7 +264,7 @@ mod goingson_api { | |||
| 253 | 264 | #[tracing::instrument(skip_all)] | |
| 254 | 265 | pub fn report_progress(current: i64, total: i64, message: &str) { | |
| 255 | 266 | CONTEXT.with(|c| { | |
| 256 | - | c.borrow_mut().progress = Some((current as usize, total as usize, message.to_string())); | |
| 267 | + | c.borrow_mut().progress = Some((current.max(0) as usize, total.max(0) as usize, message.to_string())); | |
| 257 | 268 | }); | |
| 258 | 269 | } | |
| 259 | 270 |
| @@ -127,6 +127,15 @@ impl PluginRegistry { | |||
| 127 | 127 | }; | |
| 128 | 128 | PluginApiContext::set(ctx); | |
| 129 | 129 | ||
| 130 | + | // Guard ensures context is cleared even if call_fn_2 errors | |
| 131 | + | struct ContextGuard; | |
| 132 | + | impl Drop for ContextGuard { | |
| 133 | + | fn drop(&mut self) { | |
| 134 | + | PluginApiContext::clear(); | |
| 135 | + | } | |
| 136 | + | } | |
| 137 | + | let _guard = ContextGuard; | |
| 138 | + | ||
| 130 | 139 | // Convert options to Rhai map | |
| 131 | 140 | let options_map = options_to_rhai_map(&options); | |
| 132 | 141 | ||
| @@ -151,9 +160,6 @@ impl PluginRegistry { | |||
| 151 | 160 | } | |
| 152 | 161 | } | |
| 153 | 162 | ||
| 154 | - | // Clean up context | |
| 155 | - | PluginApiContext::clear(); | |
| 156 | - | ||
| 157 | 163 | // Convert result | |
| 158 | 164 | dynamic_to_import_result(result, plugin_id) | |
| 159 | 165 | } |
| @@ -37,6 +37,7 @@ serde_json = { workspace = true } | |||
| 37 | 37 | ||
| 38 | 38 | # Utilities | |
| 39 | 39 | chrono = { workspace = true } | |
| 40 | + | chrono-tz = { workspace = true } | |
| 40 | 41 | uuid = { workspace = true } | |
| 41 | 42 | ||
| 42 | 43 |
| @@ -335,7 +335,7 @@ pub async fn open_email_in_browser(state: State<'_, Arc<AppState>>, id: EmailId) | |||
| 335 | 335 | }; | |
| 336 | 336 | ||
| 337 | 337 | let temp_dir = std::env::temp_dir(); | |
| 338 | - | let file_name = format!("goingson_email_{}.html", id); | |
| 338 | + | let file_name = format!("goingson_email_{}_{}.html", id, uuid::Uuid::new_v4().simple()); | |
| 339 | 339 | let file_path = temp_dir.join(file_name); | |
| 340 | 340 | ||
| 341 | 341 | tokio::fs::write(&file_path, html_content).await |
| @@ -243,6 +243,12 @@ pub async fn create_email_account(state: State<'_, Arc<AppState>>, input: EmailA | |||
| 243 | 243 | return Err(ApiError::validation("smtpServer", "SMTP server is required")); | |
| 244 | 244 | } | |
| 245 | 245 | ||
| 246 | + | if let Some(ref folder) = input.archive_folder_name { | |
| 247 | + | if folder.contains('\r') || folder.contains('\n') || folder.chars().any(|c| c.is_control()) { | |
| 248 | + | return Err(ApiError::validation("archiveFolderName", "Folder name contains invalid characters")); | |
| 249 | + | } | |
| 250 | + | } | |
| 251 | + | ||
| 246 | 252 | let account = state.email_accounts | |
| 247 | 253 | .create( | |
| 248 | 254 | DESKTOP_USER_ID, | |
| @@ -273,6 +279,12 @@ pub async fn update_email_account(state: State<'_, Arc<AppState>>, id: EmailAcco | |||
| 273 | 279 | return Err(ApiError::validation("emailAddress", "Invalid email address")); | |
| 274 | 280 | } | |
| 275 | 281 | ||
| 282 | + | if let Some(ref folder) = input.archive_folder_name { | |
| 283 | + | if folder.contains('\r') || folder.contains('\n') || folder.chars().any(|c| c.is_control()) { | |
| 284 | + | return Err(ApiError::validation("archiveFolderName", "Folder name contains invalid characters")); | |
| 285 | + | } | |
| 286 | + | } | |
| 287 | + | ||
| 276 | 288 | state.email_accounts | |
| 277 | 289 | .update( | |
| 278 | 290 | id, |
| @@ -432,6 +432,15 @@ pub async fn delete_backup( | |||
| 432 | 432 | )); | |
| 433 | 433 | } | |
| 434 | 434 | ||
| 435 | + | // Verify it's actually a backup file | |
| 436 | + | if !canonical_path.extension().is_some_and(|ext| ext == "gz") | |
| 437 | + | || !canonical_path.to_string_lossy().ends_with(".json.gz") | |
| 438 | + | { | |
| 439 | + | return Err(ApiError::bad_request( | |
| 440 | + | "Can only delete .json.gz backup files", | |
| 441 | + | )); | |
| 442 | + | } | |
| 443 | + | ||
| 435 | 444 | std::fs::remove_file(&canonical_path) | |
| 436 | 445 | .map_api_err("Failed to delete backup", ApiError::internal)?; | |
| 437 | 446 |
| @@ -13,6 +13,23 @@ use goingson_core::{ | |||
| 13 | 13 | NewEvent, NewSocialHandle, Recurrence, | |
| 14 | 14 | }; | |
| 15 | 15 | ||
| 16 | + | /// Maximum import file size (50 MB). | |
| 17 | + | const MAX_IMPORT_FILE_SIZE: u64 = 50 * 1024 * 1024; | |
| 18 | + | ||
| 19 | + | fn read_import_file(path: &str) -> Result<String, ApiError> { | |
| 20 | + | let metadata = std::fs::metadata(path) | |
| 21 | + | .map_err(|e| ApiError::internal(format!("Failed to stat file: {}", e)))?; | |
| 22 | + | if metadata.len() > MAX_IMPORT_FILE_SIZE { | |
| 23 | + | return Err(ApiError::validation_msg(format!( | |
| 24 | + | "File is too large ({} bytes, max {} bytes)", | |
| 25 | + | metadata.len(), | |
| 26 | + | MAX_IMPORT_FILE_SIZE | |
| 27 | + | ))); | |
| 28 | + | } | |
| 29 | + | std::fs::read_to_string(path) | |
| 30 | + | .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e))) | |
| 31 | + | } | |
| 32 | + | ||
| 16 | 33 | use crate::external_sync::{ical, vcard}; | |
| 17 | 34 | use crate::state::{AppState, DESKTOP_USER_ID}; | |
| 18 | 35 | use super::ApiError; | |
| @@ -55,8 +72,7 @@ pub struct IcsPreview { | |||
| 55 | 72 | #[tauri::command] | |
| 56 | 73 | #[instrument(skip_all)] | |
| 57 | 74 | pub async fn preview_vcf(file_path: String) -> Result<Vec<VCardPreview>, ApiError> { | |
| 58 | - | let content = std::fs::read_to_string(&file_path) | |
| 59 | - | .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e)))?; | |
| 75 | + | let content = read_import_file(&file_path)?; | |
| 60 | 76 | ||
| 61 | 77 | let cards = vcard::parse_vcf(&content) | |
| 62 | 78 | .map_err(|e| ApiError::internal(format!("Failed to parse vCard: {}", e)))?; | |
| @@ -76,8 +92,7 @@ pub async fn preview_vcf(file_path: String) -> Result<Vec<VCardPreview>, ApiErro | |||
| 76 | 92 | #[tauri::command] | |
| 77 | 93 | #[instrument(skip_all)] | |
| 78 | 94 | pub async fn preview_ics(file_path: String) -> Result<Vec<IcsPreview>, ApiError> { | |
| 79 | - | let content = std::fs::read_to_string(&file_path) | |
| 80 | - | .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e)))?; | |
| 95 | + | let content = read_import_file(&file_path)?; | |
| 81 | 96 | ||
| 82 | 97 | let events = ical::parse_ics(&content) | |
| 83 | 98 | .map_err(|e| ApiError::internal(format!("Failed to parse ICS: {}", e)))?; | |
| @@ -108,8 +123,7 @@ pub async fn import_vcf( | |||
| 108 | 123 | state: State<'_, Arc<AppState>>, | |
| 109 | 124 | file_path: String, | |
| 110 | 125 | ) -> Result<ImportResult, ApiError> { | |
| 111 | - | let content = std::fs::read_to_string(&file_path) | |
| 112 | - | .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e)))?; | |
| 126 | + | let content = read_import_file(&file_path)?; | |
| 113 | 127 | ||
| 114 | 128 | let cards = vcard::parse_vcf(&content) | |
| 115 | 129 | .map_err(|e| ApiError::internal(format!("Failed to parse vCard: {}", e)))?; | |
| @@ -155,18 +169,21 @@ pub async fn import_vcf( | |||
| 155 | 169 | match state.contacts.create(DESKTOP_USER_ID, new_contact).await { | |
| 156 | 170 | Ok(contact) => { | |
| 157 | 171 | // Set external source/id for dedup on re-import | |
| 158 | - | let _ = sqlx::query( | |
| 172 | + | if let Err(e) = sqlx::query( | |
| 159 | 173 | "UPDATE contacts SET external_source = ?, external_id = ? WHERE id = ?", | |
| 160 | 174 | ) | |
| 161 | 175 | .bind("vcf") | |
| 162 | 176 | .bind(&ext_id) | |
| 163 | 177 | .bind(contact.id.to_string()) | |
| 164 | 178 | .execute(&state.pool) | |
| 165 | - | .await; | |
| 179 | + | .await | |
| 180 | + | { | |
| 181 | + | tracing::warn!(contact = %card.display_name, "Failed to set external source: {}", e); | |
| 182 | + | } | |
| 166 | 183 | ||
| 167 | - | // Add sub-collections | |
| 184 | + | // Add sub-collections, collecting any errors | |
| 168 | 185 | for email in card.emails { | |
| 169 | - | let _ = state | |
| 186 | + | if let Err(e) = state | |
| 170 | 187 | .contacts | |
| 171 | 188 | .add_email( | |
| 172 | 189 | contact.id, | |
| @@ -177,10 +194,13 @@ pub async fn import_vcf( | |||
| 177 | 194 | is_primary: email.is_primary, | |
| 178 | 195 | }, | |
| 179 | 196 | ) | |
| 180 | - | .await; | |
| 197 | + | .await | |
| 198 | + | { | |
| 199 | + | tracing::warn!(contact = %card.display_name, "Failed to add email: {}", e); | |
| 200 | + | } | |
| 181 | 201 | } | |
| 182 | 202 | for phone in card.phones { | |
| 183 | - | let _ = state | |
| 203 | + | if let Err(e) = state | |
| 184 | 204 | .contacts | |
| 185 | 205 | .add_phone( | |
| 186 | 206 | contact.id, | |
| @@ -191,10 +211,13 @@ pub async fn import_vcf( | |||
| 191 | 211 | is_primary: phone.is_primary, | |
| 192 | 212 | }, | |
| 193 | 213 | ) | |
| 194 | - | .await; | |
| 214 | + | .await | |
| 215 | + | { | |
| 216 | + | tracing::warn!(contact = %card.display_name, "Failed to add phone: {}", e); | |
| 217 | + | } | |
| 195 | 218 | } | |
| 196 | 219 | for social in card.social_handles { | |
| 197 | - | let _ = state | |
| 220 | + | if let Err(e) = state | |
| 198 | 221 | .contacts | |
| 199 | 222 | .add_social_handle( | |
| 200 | 223 | contact.id, | |
| @@ -205,10 +228,13 @@ pub async fn import_vcf( | |||
| 205 | 228 | url: social.url, | |
| 206 | 229 | }, | |
| 207 | 230 | ) | |
| 208 | - | .await; | |
| 231 | + | .await | |
| 232 | + | { | |
| 233 | + | tracing::warn!(contact = %card.display_name, "Failed to add social handle: {}", e); | |
| 234 | + | } | |
| 209 | 235 | } | |
| 210 | 236 | for field in card.custom_fields { | |
| 211 | - | let _ = state | |
| 237 | + | if let Err(e) = state | |
| 212 | 238 | .contacts | |
| 213 | 239 | .add_custom_field( | |
| 214 | 240 | contact.id, | |
| @@ -219,7 +245,10 @@ pub async fn import_vcf( | |||
| 219 | 245 | url: field.url, | |
| 220 | 246 | }, | |
| 221 | 247 | ) | |
| 222 | - | .await; | |
| 248 | + | .await | |
| 249 | + | { | |
| 250 | + | tracing::warn!(contact = %card.display_name, "Failed to add custom field: {}", e); | |
| 251 | + | } | |
| 223 | 252 | } | |
| 224 | 253 | ||
| 225 | 254 | imported += 1; | |
| @@ -244,8 +273,7 @@ pub async fn import_ics( | |||
| 244 | 273 | state: State<'_, Arc<AppState>>, | |
| 245 | 274 | file_path: String, | |
| 246 | 275 | ) -> Result<ImportResult, ApiError> { | |
| 247 | - | let content = std::fs::read_to_string(&file_path) | |
| 248 | - | .map_err(|e| ApiError::internal(format!("Failed to read file: {}", e)))?; | |
| 276 | + | let content = read_import_file(&file_path)?; | |
| 249 | 277 | ||
| 250 | 278 | let parsed_events = ical::parse_ics(&content) | |
| 251 | 279 | .map_err(|e| ApiError::internal(format!("Failed to parse ICS: {}", e)))?; |
| @@ -98,10 +98,12 @@ pub fn write_backup<P: AsRef<Path>>(export: &FullExport, path: P) -> Result<u64, | |||
| 98 | 98 | /// The parsed export data. | |
| 99 | 99 | pub fn read_backup<P: AsRef<Path>>(path: P) -> Result<FullExport, BackupError> { | |
| 100 | 100 | let file = File::open(path.as_ref())?; | |
| 101 | - | let mut decoder = GzDecoder::new(file); | |
| 101 | + | let decoder = GzDecoder::new(file); | |
| 102 | + | // Limit decompressed size to 500 MB to prevent decompression bombs | |
| 103 | + | let mut limited = decoder.take(500 * 1024 * 1024); | |
| 102 | 104 | ||
| 103 | 105 | let mut json = String::new(); | |
| 104 | - | decoder.read_to_string(&mut json)?; | |
| 106 | + | limited.read_to_string(&mut json)?; | |
| 105 | 107 | ||
| 106 | 108 | let export: FullExport = serde_json::from_str(&json)?; | |
| 107 | 109 |
| @@ -76,7 +76,7 @@ pub fn write_tasks_csv<W: Write>( | |||
| 76 | 76 | csv_writer.write_record([ | |
| 77 | 77 | task.id.to_string(), | |
| 78 | 78 | project_name.to_string(), | |
| 79 | - | task.description.clone(), | |
| 79 | + | sanitize_csv_field(&task.description), | |
| 80 | 80 | task.status.as_str().to_string(), | |
| 81 | 81 | task.priority.as_str().to_string(), | |
| 82 | 82 | due, | |
| @@ -92,6 +92,15 @@ pub fn write_tasks_csv<W: Write>( | |||
| 92 | 92 | Ok(tasks.len()) | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | + | /// Prefix cell values that spreadsheets would interpret as formulas. | |
| 96 | + | fn sanitize_csv_field(value: &str) -> String { | |
| 97 | + | if value.starts_with('=') || value.starts_with('+') || value.starts_with('-') || value.starts_with('@') { | |
| 98 | + | format!("'{}", value) | |
| 99 | + | } else { | |
| 100 | + | value.to_string() | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 95 | 104 | #[cfg(test)] | |
| 96 | 105 | mod tests { | |
| 97 | 106 | use super::*; |
| @@ -4,6 +4,7 @@ | |||
| 4 | 4 | //! to GO's Event model. | |
| 5 | 5 | ||
| 6 | 6 | use chrono::{DateTime, NaiveDate, NaiveDateTime, TimeZone, Utc}; | |
| 7 | + | use chrono_tz::Tz; | |
| 7 | 8 | use goingson_core::Recurrence; | |
| 8 | 9 | use ical::parser::ical::component::IcalEvent; | |
| 9 | 10 | use ical::property::Property; | |
| @@ -120,12 +121,15 @@ fn parse_datetime_property(properties: &[Property], name: &str) -> Option<DateTi | |||
| 120 | 121 | }); | |
| 121 | 122 | ||
| 122 | 123 | // Try parsing with timezone | |
| 123 | - | if let Some(_tz_name) = tzid { | |
| 124 | - | // Parse local datetime then convert (simplified: treat as UTC offset) | |
| 125 | - | // Full IANA timezone handling would need chrono-tz, but for import | |
| 126 | - | // we use a best-effort approach | |
| 124 | + | if let Some(tz_name) = tzid { | |
| 127 | 125 | if let Some(ndt) = parse_ical_datetime(value) { | |
| 128 | - | // For now, if we can't resolve the timezone, treat as UTC | |
| 126 | + | // Resolve IANA timezone and convert local time to UTC | |
| 127 | + | if let Ok(tz) = tz_name.parse::<Tz>() { | |
| 128 | + | if let Some(local_dt) = tz.from_local_datetime(&ndt).earliest() { | |
| 129 | + | return Some(local_dt.with_timezone(&Utc)); | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | // Fall back to treating as UTC if timezone can't be resolved | |
| 129 | 133 | return Some(Utc.from_utc_datetime(&ndt)); | |
| 130 | 134 | } | |
| 131 | 135 | } |
| @@ -334,7 +334,7 @@ fn decode_value(value: &str, params: &[String]) -> String { | |||
| 334 | 334 | ||
| 335 | 335 | /// Decode quoted-printable encoded text. | |
| 336 | 336 | fn decode_quoted_printable(input: &str) -> String { | |
| 337 | - | let mut result = String::new(); | |
| 337 | + | let mut decoded_bytes = Vec::new(); | |
| 338 | 338 | let bytes = input.as_bytes(); | |
| 339 | 339 | let mut i = 0; | |
| 340 | 340 | while i < bytes.len() { | |
| @@ -343,15 +343,15 @@ fn decode_quoted_printable(input: &str) -> String { | |||
| 343 | 343 | hex_val(bytes[i + 1]), | |
| 344 | 344 | hex_val(bytes[i + 2]), | |
| 345 | 345 | ) { | |
| 346 | - | result.push((h << 4 | l) as char); | |
| 346 | + | decoded_bytes.push(h << 4 | l); | |
| 347 | 347 | i += 3; | |
| 348 | 348 | continue; | |
| 349 | 349 | } | |
| 350 | 350 | } | |
| 351 | - | result.push(bytes[i] as char); | |
| 351 | + | decoded_bytes.push(bytes[i]); | |
| 352 | 352 | i += 1; | |
| 353 | 353 | } | |
| 354 | - | result | |
| 354 | + | String::from_utf8_lossy(&decoded_bytes).into_owned() | |
| 355 | 355 | } | |
| 356 | 356 | ||
| 357 | 357 | fn hex_val(b: u8) -> Option<u8> { |
| @@ -230,7 +230,14 @@ fn truncate_text(text: &str, max_len: usize) -> String { | |||
| 230 | 230 | if text.len() <= max_len { | |
| 231 | 231 | text.to_string() | |
| 232 | 232 | } else { | |
| 233 | - | format!("{}...", &text[..max_len.saturating_sub(3)]) | |
| 233 | + | let truncate_to = max_len.saturating_sub(3); | |
| 234 | + | let end = text | |
| 235 | + | .char_indices() | |
| 236 | + | .map(|(i, _)| i) | |
| 237 | + | .take_while(|&i| i <= truncate_to) | |
| 238 | + | .last() | |
| 239 | + | .unwrap_or(0); | |
| 240 | + | format!("{}...", &text[..end]) | |
| 234 | 241 | } | |
| 235 | 242 | } | |
| 236 | 243 |