Skip to main content

max / goingson

Audit Run 14: tests, security hardening, JSDoc, date_utils extraction Security: - Sanitize filename in open_attachment() before temp_dir.join() - Add escAttr() to onclick handlers in day-planning-render, attachments Code quality: - Extract date formatting functions to core::date_utils (4 functions, 16 tests) - Add doc comments to complex search query functions in db-sqlite Testing: - Add command error scenario tests (day planning, export, time tracking) - Add plugin hot-reload edge case tests (update while running, corrupt manifest) - Add full plugin lifecycle test (discover, load, execute, error, recover) Documentation: - Add JSDoc coverage across ~160 utility functions in 36 JS files Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-04-16 01:38 UTC
Commit: ba392f7e899352730a484ec7717f994d5be2308a
Parent: c4f0b1f
62 files changed, +2924 insertions, -461 deletions
@@ -0,0 +1,211 @@
1 + //! Human-readable date/time formatting utilities.
2 + //!
3 + //! Provides functions that turn absolute timestamps into relative display
4 + //! strings for the UI (e.g. "today", "2d ago", "+3d", "Just now").
5 + //!
6 + //! All functions accept an explicit `now` parameter so they are
7 + //! deterministic and easy to test.
8 +
9 + use chrono::{DateTime, Local, Utc};
10 +
11 + /// Formats a date relative to now, bidirectional: "2d ago", "today", "tomorrow", "+3d", "Mar 15".
12 + ///
13 + /// Used for task due dates and similar bidirectional relative displays.
14 + pub fn format_relative_date(dt: DateTime<Utc>, now: DateTime<Utc>) -> String {
15 + let diff_days = (dt.date_naive() - now.date_naive()).num_days();
16 + if diff_days < -1 {
17 + format!("{}d ago", -diff_days)
18 + } else if diff_days == -1 {
19 + "1d ago".to_string()
20 + } else if diff_days == 0 {
21 + "today".to_string()
22 + } else if diff_days == 1 {
23 + "tomorrow".to_string()
24 + } else if diff_days < 7 {
25 + format!("+{}d", diff_days)
26 + } else {
27 + dt.format("%b %d").to_string()
28 + }
29 + }
30 +
31 + /// Formats a future date relative to now: "today", "tomorrow", "+3d", "Mar 15".
32 + ///
33 + /// Used for snooze-until displays where the date is always in the future.
34 + pub fn format_relative_future(dt: DateTime<Utc>, now: DateTime<Utc>) -> String {
35 + let diff_days = (dt.date_naive() - now.date_naive()).num_days();
36 + if diff_days == 0 {
37 + "today".to_string()
38 + } else if diff_days == 1 {
39 + "tomorrow".to_string()
40 + } else if diff_days < 7 {
41 + format!("+{}d", diff_days)
42 + } else {
43 + dt.format("%b %d").to_string()
44 + }
45 + }
46 +
47 + /// Formats a past timestamp as elapsed time: "Just now", "2h ago", "3d ago", "Mar 15".
48 + ///
49 + /// Used for email received_at displays.
50 + pub fn format_elapsed_time(dt: DateTime<Utc>, now: DateTime<Utc>) -> String {
51 + let diff = now.signed_duration_since(dt);
52 + let hours = diff.num_hours();
53 + if hours < 1 {
54 + "Just now".to_string()
55 + } else if hours < 24 {
56 + format!("{}h ago", hours)
57 + } else {
58 + let days = diff.num_days();
59 + if days < 7 {
60 + format!("{}d ago", days)
61 + } else {
62 + dt.with_timezone(&Local).format("%b %d").to_string()
63 + }
64 + }
65 + }
66 +
67 + #[cfg(test)]
68 + mod tests {
69 + use super::*;
70 + use chrono::{Duration, NaiveDate, NaiveTime, TimeZone};
71 +
72 + fn utc(year: i32, month: u32, day: u32, hour: u32) -> DateTime<Utc> {
73 + let date = NaiveDate::from_ymd_opt(year, month, day).unwrap();
74 + let time = NaiveTime::from_hms_opt(hour, 0, 0).unwrap();
75 + Utc.from_utc_datetime(&date.and_time(time))
76 + }
77 +
78 + // ---- format_relative_date ----
79 +
80 + #[test]
81 + fn relative_date_today() {
82 + let now = utc(2026, 4, 15, 12);
83 + let dt = utc(2026, 4, 15, 8);
84 + assert_eq!(format_relative_date(dt, now), "today");
85 + }
86 +
87 + #[test]
88 + fn relative_date_tomorrow() {
89 + let now = utc(2026, 4, 15, 12);
90 + let dt = utc(2026, 4, 16, 9);
91 + assert_eq!(format_relative_date(dt, now), "tomorrow");
92 + }
93 +
94 + #[test]
95 + fn relative_date_yesterday() {
96 + let now = utc(2026, 4, 15, 12);
97 + let dt = utc(2026, 4, 14, 12);
98 + assert_eq!(format_relative_date(dt, now), "1d ago");
99 + }
100 +
101 + #[test]
102 + fn relative_date_multiple_days_ago() {
103 + let now = utc(2026, 4, 15, 12);
104 + let dt = utc(2026, 4, 12, 12);
105 + assert_eq!(format_relative_date(dt, now), "3d ago");
106 + }
107 +
108 + #[test]
109 + fn relative_date_few_days_future() {
110 + let now = utc(2026, 4, 15, 12);
111 + let dt = utc(2026, 4, 18, 12);
112 + assert_eq!(format_relative_date(dt, now), "+3d");
113 + }
114 +
115 + #[test]
116 + fn relative_date_far_future() {
117 + let now = utc(2026, 4, 15, 12);
118 + let dt = utc(2026, 5, 10, 12);
119 + assert_eq!(format_relative_date(dt, now), "May 10");
120 + }
121 +
122 + #[test]
123 + fn relative_date_boundary_six_days() {
124 + let now = utc(2026, 4, 15, 12);
125 + let dt = utc(2026, 4, 21, 12);
126 + assert_eq!(format_relative_date(dt, now), "+6d");
127 + }
128 +
129 + #[test]
130 + fn relative_date_boundary_seven_days() {
131 + let now = utc(2026, 4, 15, 12);
132 + let dt = utc(2026, 4, 22, 12);
133 + assert_eq!(format_relative_date(dt, now), "Apr 22");
134 + }
135 +
136 + // ---- format_relative_future ----
137 +
138 + #[test]
139 + fn relative_future_today() {
140 + let now = utc(2026, 4, 15, 12);
141 + let dt = utc(2026, 4, 15, 17);
142 + assert_eq!(format_relative_future(dt, now), "today");
143 + }
144 +
145 + #[test]
146 + fn relative_future_tomorrow() {
147 + let now = utc(2026, 4, 15, 12);
148 + let dt = utc(2026, 4, 16, 9);
149 + assert_eq!(format_relative_future(dt, now), "tomorrow");
150 + }
151 +
152 + #[test]
153 + fn relative_future_few_days() {
154 + let now = utc(2026, 4, 15, 12);
155 + let dt = utc(2026, 4, 19, 9);
156 + assert_eq!(format_relative_future(dt, now), "+4d");
157 + }
158 +
159 + #[test]
160 + fn relative_future_far() {
161 + let now = utc(2026, 4, 15, 12);
162 + let dt = utc(2026, 6, 1, 9);
163 + assert_eq!(format_relative_future(dt, now), "Jun 01");
164 + }
165 +
166 + // ---- format_elapsed_time ----
167 +
168 + #[test]
169 + fn elapsed_just_now() {
170 + let now = utc(2026, 4, 15, 12);
171 + let dt = now - Duration::minutes(30);
172 + assert_eq!(format_elapsed_time(dt, now), "Just now");
173 + }
174 +
175 + #[test]
176 + fn elapsed_hours() {
177 + let now = utc(2026, 4, 15, 12);
178 + let dt = now - Duration::hours(5);
179 + assert_eq!(format_elapsed_time(dt, now), "5h ago");
180 + }
181 +
182 + #[test]
183 + fn elapsed_days() {
184 + let now = utc(2026, 4, 15, 12);
185 + let dt = now - Duration::days(3);
186 + assert_eq!(format_elapsed_time(dt, now), "3d ago");
187 + }
188 +
189 + #[test]
190 + fn elapsed_weeks_shows_date() {
191 + let now = utc(2026, 4, 15, 12);
192 + let dt = now - Duration::days(14);
193 + // Exact format depends on local timezone, but should contain month abbreviation
194 + let result = format_elapsed_time(dt, now);
195 + assert!(result.contains("Apr") || result.contains("Mar"), "Expected month abbrev, got: {result}");
196 + }
197 +
198 + #[test]
199 + fn elapsed_boundary_23h() {
200 + let now = utc(2026, 4, 15, 12);
201 + let dt = now - Duration::hours(23);
202 + assert_eq!(format_elapsed_time(dt, now), "23h ago");
203 + }
204 +
205 + #[test]
206 + fn elapsed_boundary_24h() {
207 + let now = utc(2026, 4, 15, 12);
208 + let dt = now - Duration::hours(24);
209 + assert_eq!(format_elapsed_time(dt, now), "1d ago");
210 + }
211 + }
@@ -120,7 +120,6 @@ define_uuid_id!(
120 120 MonthlyReflectionId,
121 121 SavedViewId,
122 122 EmailAccountId,
123 - LlmSettingsId,
124 123 UserId,
125 124 );
126 125
@@ -33,6 +33,7 @@
33 33 pub mod backup_restore;
34 34 pub mod constants;
35 35 pub mod contact;
36 + pub mod date_utils;
36 37 pub mod day_planning;
37 38 pub mod email_id;
38 39 pub mod email_sync;
@@ -56,16 +57,16 @@ pub use contact::{
56 57 pub use error::CoreError;
57 58 pub use id_types::{
58 59 AnnotationId, AttachmentId, ContactEmailId, ContactId, ContactPhoneId, CustomFieldId,
59 - EmailAccountId, EmailId, EventId, LlmSettingsId, MilestoneId, MonthlyGoalId,
60 + EmailAccountId, EmailId, EventId, MilestoneId, MonthlyGoalId,
60 61 MonthlyReflectionId, ProjectId, SavedViewId, SocialHandleId,
61 62 SubtaskId, SyncAccountId, TaskId, TimeSessionId, UserId, WeeklyReviewId,
62 63 };
63 64 pub use models::{
64 65 Annotation, Attachment, BackupSettings, BlockType, CssClass, DbValue, Email, EmailAccount,
65 - EmailAuthType, EmailThread, Event, LlmContext, LlmProviderType, LlmSettings, Milestone,
66 + EmailAuthType, EmailThread, Event, Milestone,
66 67 MilestoneStatus, MonthlyGoal, MonthlyGoalStatus, MonthlyReflection,
67 68 AttachmentMeta, NewAttachment, NewBackupSettings, NewEmail, NewEmailWithTracking, NewEvent, NewEventBuilder,
68 - NewLlmSettings, NewMilestone, NewProject, NewSavedView, NewTask, NewTaskBuilder, Priority,
69 + NewMilestone, NewProject, NewSavedView, NewTask, NewTaskBuilder, Priority,
69 70 Project, ParseableEnum, ProjectStatus, ProjectType, Recurrence, SavedView, SortDirection,
70 71 SyncAccount,
71 72 SortField, Subtask, Task, TaskFilterQuery, TaskSortColumn, TaskStatus, TimeSession,
@@ -26,7 +26,15 @@ pub fn calculate_next_due(
26 26 }
27 27 }
28 28
29 - /// Add months to a DateTime, handling edge cases like month-end dates
29 + /// Add months to a DateTime, handling edge cases like month-end dates.
30 + ///
31 + /// Uses absolute month counting (year*12 + month) to add/subtract months, then
32 + /// clamps the day to the target month's length. Examples:
33 + /// Jan 31 + 1 month → Feb 28 (or 29 in a leap year)
34 + /// Mar 31 + 1 month → Apr 30
35 + ///
36 + /// Preserves the original hour/minute/second. Falls back to the input datetime
37 + /// if the target date is ambiguous (e.g., DST gap via `with_ymd_and_hms`).
30 38 fn add_months(dt: DateTime<Utc>, months: i32) -> DateTime<Utc> {
31 39
32 40 let year = dt.year();
@@ -54,7 +54,22 @@ pub fn calculate_urgency(
54 54 calculate_urgency_with_config(priority, status, due, created_at, tags, &config)
55 55 }
56 56
57 - /// Calculate urgency with custom configuration
57 + /// Calculate urgency with custom configuration.
58 + ///
59 + /// Additive score from these factors (all configurable via `UrgencyConfig`):
60 + ///
61 + /// | Factor | Default | Condition |
62 + /// |-------------|---------|-------------------------------------------|
63 + /// | Priority | 3-7 | High=7, Medium=5, Low=3 |
64 + /// | Overdue | +12.0 | Due date in the past |
65 + /// | Due soon | 0-7.0 | Linear scale: 7 days out (0) → due (7.0) |
66 + /// | Age | 0-2.0 | Linear over 30 days, capped at 2.0 |
67 + /// | Started | +4.0 | Task status is Started |
68 + /// | "urgent" tag| +2.0 | Case-insensitive tag match |
69 + ///
70 + /// Result is rounded to 1 decimal place for stable sorting.
71 + /// Sub-day precision: hours are used internally so tasks due at 3pm sort
72 + /// differently from tasks due at 9am even on the same day.
58 73 pub fn calculate_urgency_with_config(
59 74 priority: &Priority,
60 75 status: &TaskStatus,
@@ -225,6 +225,9 @@ fn build_is_filter_clauses(is_filters: &[IsFilter]) -> Vec<String> {
225 225 .collect()
226 226 }
227 227
228 + /// Searches tasks via FTS5 MATCH with BM25 relevance ranking, or direct query when
229 + /// no search text is provided. Applies optional filters: `is:` status/date, project
230 + /// (by ID or name), priority, tag include/exclude, and date range on the due field.
228 231 async fn search_tasks_fts(
229 232 pool: &SqlitePool,
230 233 user_id: &str,
@@ -387,6 +390,9 @@ async fn search_tasks_fts(
387 390 .collect()
388 391 }
389 392
393 + /// Searches emails by subject and body via FTS5 MATCH with BM25 ranking.
394 + /// Requires a search term (returns empty for filter-only queries). Applies optional
395 + /// project filter (by ID or name) and date range filter on the email date field.
390 396 async fn search_emails_fts(
391 397 pool: &SqlitePool,
392 398 user_id: &str,
@@ -498,6 +504,9 @@ async fn search_emails_fts(
498 504 .collect()
499 505 }
500 506
507 + /// Searches projects by name and description via FTS5 MATCH with BM25 ranking.
508 + /// Requires a search term (returns empty for filter-only queries). Only applies
509 + /// date range filters (on `created_at`); skipped entirely when structured filters are present.
501 510 async fn search_projects_fts(
502 511 pool: &SqlitePool,
503 512 user_id: &str,
@@ -587,6 +596,9 @@ async fn search_projects_fts(
587 596 .collect()
588 597 }
589 598
599 + /// Searches events via FTS5 MATCH with BM25 ranking, or direct query for filter-only
600 + /// searches. Supports time-based `is:` filters (today, tomorrow, this_week, overdue) on
601 + /// `start_time`, project filter (by ID or name), and date range on `start_time`.
590 602 async fn search_events_fts(
591 603 pool: &SqlitePool,
592 604 user_id: &str,
@@ -756,6 +768,9 @@ fn can_search_contacts(query: &SearchQuery) -> bool {
756 768 && query.project_name.is_none()
757 769 }
758 770
771 + /// Searches contacts by display name and company via FTS5 MATCH with BM25 ranking.
772 + /// Requires a search term (returns empty for filter-only queries). No additional
773 + /// filters are applied; skipped entirely when structured filters are present.
759 774 async fn search_contacts_fts(
760 775 pool: &SqlitePool,
761 776 user_id: &str,
@@ -235,4 +235,220 @@ mod tests {
235 235 panic!("Expected SafetyLimitExceeded error");
236 236 }
237 237 }
238 +
239 + // ============ Plugin Lifecycle: Error and Recovery ============
240 +
241 + #[test]
242 + fn script_error_returns_plugin_error_not_panic() {
243 + let engine = PluginEngine::new();
244 + let ast = engine
245 + .compile(
246 + r#"
247 + fn hook_on_task_created(task) {
248 + throw "something went wrong in hook";
249 + }
250 + "#,
251 + )
252 + .unwrap();
253 +
254 + let result: Result<Dynamic> =
255 + engine.call_fn_1(&ast, "my-hook-plugin", "hook_on_task_created", "task-1");
256 + assert!(result.is_err());
257 +
258 + match result.unwrap_err() {
259 + PluginError::ScriptError { plugin, message } => {
260 + assert_eq!(plugin, "my-hook-plugin");
261 + assert!(
262 + message.contains("something went wrong in hook"),
263 + "Unexpected message: {}",
264 + message
265 + );
266 + }
267 + other => panic!("Expected ScriptError, got {:?}", other),
268 + }
269 + }
270 +
271 + #[test]
272 + fn plugin_recoverable_after_hook_error() {
273 + let engine = PluginEngine::new();
274 + let ast = engine
275 + .compile(
276 + r#"
277 + fn on_task_created(task_id) {
278 + if task_id == "bad" {
279 + throw "invalid task";
280 + }
281 + 42
282 + }
283 + "#,
284 + )
285 + .unwrap();
286 +
287 + // First call errors
288 + let err_result: Result<Dynamic> =
289 + engine.call_fn_1(&ast, "recoverable", "on_task_created", "bad".to_string());
290 + assert!(err_result.is_err());
291 + match err_result.unwrap_err() {
292 + PluginError::ScriptError { .. } => {}
293 + other => panic!("Expected ScriptError, got {:?}", other),
294 + }
295 +
296 + // Second call with valid input succeeds -- engine did not poison itself
297 + let ok_result: i64 =
298 + engine.call_fn_1(&ast, "recoverable", "on_task_created", "good".to_string()).unwrap();
299 + assert_eq!(ok_result, 42);
300 + }
301 +
302 + #[test]
303 + fn operation_limit_returns_safety_error_not_panic() {
304 + let limits = SafetyLimits {
305 + max_operations: 50,
306 + ..Default::default()
307 + };
308 + let engine = PluginEngine::with_limits(limits);
309 + let ast = engine
310 + .compile(
311 + r#"
312 + fn expensive() {
313 + let x = 0;
314 + while x < 999999 { x += 1; }
315 + x
316 + }
317 + "#,
318 + )
319 + .unwrap();
320 +
321 + let result: Result<i64> = engine.call_fn(&ast, "expensive-plugin", "expensive");
322 + assert!(result.is_err());
323 +
324 + match result.unwrap_err() {
325 + PluginError::SafetyLimitExceeded { plugin, message } => {
326 + assert_eq!(plugin, "expensive-plugin");
327 + assert!(
328 + message.contains("operations"),
329 + "Unexpected message: {}",
330 + message
331 + );
332 + }
333 + other => panic!("Expected SafetyLimitExceeded, got {:?}", other),
334 + }
335 + }
336 +
337 + #[test]
338 + fn recoverable_after_operation_limit() {
339 + let limits = SafetyLimits {
340 + max_operations: 50,
341 + ..Default::default()
342 + };
343 + let engine = PluginEngine::with_limits(limits);
344 + let ast = engine
345 + .compile(
346 + r#"
347 + fn expensive() {
348 + let x = 0;
349 + while x < 999999 { x += 1; }
350 + x
351 + }
352 + fn cheap() { 1 }
353 + "#,
354 + )
355 + .unwrap();
356 +
357 + // expensive() blows the ops limit
358 + let err_result: Result<i64> = engine.call_fn(&ast, "ops-test", "expensive");
359 + assert!(matches!(
360 + err_result,
361 + Err(PluginError::SafetyLimitExceeded { .. })
362 + ));
363 +
364 + // cheap() should still work -- the engine resets its operation counter per call
365 + let ok_result: i64 = engine.call_fn(&ast, "ops-test", "cheap").unwrap();
366 + assert_eq!(ok_result, 1);
367 + }
368 +
369 + #[test]
370 + fn compile_execute_multiple_functions_lifecycle() {
371 + let engine = PluginEngine::new();
372 +
373 + // Compile a plugin-like script with describe + parse
374 + let ast = engine
375 + .compile(
376 + r#"
377 + fn describe() {
378 + #{
379 + name: "lifecycle-test",
380 + file_extensions: ["csv"]
381 + }
382 + }
383 +
384 + fn parse(file_path, options) {
385 + let items = [];
386 + items.push(#{ description: "parsed from " + file_path });
387 + goingson::task_result(items)
388 + }
389 + "#,
390 + )
391 + .unwrap();
392 +
393 + // Validate function signatures exist
394 + assert!(engine.has_function(&ast, "describe", 0));
395 + assert!(engine.has_function(&ast, "parse", 2));
396 + assert!(!engine.has_function(&ast, "execute", 1));
397 +
398 + // Execute describe()
399 + let desc: Dynamic = engine.call_fn(&ast, "lifecycle", "describe").unwrap();
400 + let map = desc.try_cast::<rhai::Map>().unwrap();
401 + assert_eq!(
402 + map.get("name").unwrap().clone().into_string().unwrap(),
403 + "lifecycle-test"
404 + );
405 +
406 + // Execute parse()
407 + let options = rhai::Map::new();
408 + let result: Dynamic = engine
409 + .call_fn_2(&ast, "lifecycle", "parse", "/tmp/test.csv".to_string(), options)
410 + .unwrap();
411 + let result_map = result.try_cast::<rhai::Map>().unwrap();
412 + assert_eq!(
413 + result_map
414 + .get("entity_type")
415 + .unwrap()
416 + .clone()
417 + .into_string()
418 + .unwrap(),
419 + "task"
420 + );
421 + }
422 +
423 + #[test]
424 + fn eval_is_disabled() {
425 + let engine = PluginEngine::new();
426 + let result = engine.compile(r#"fn sneaky() { eval("1 + 1") }"#);
427 + // eval is disabled at the symbol level, so compilation should fail
428 + assert!(result.is_err());
429 + }
430 +
431 + #[test]
432 + fn call_fn_with_runtime_error_returns_script_error() {
433 + let engine = PluginEngine::new();
434 + let ast = engine
435 + .compile("fn divide(a, b) { a / b }")
436 + .unwrap();
437 +
438 + // Division by zero should produce a ScriptError
439 + let result: Result<Dynamic> = engine.call_fn_2(
440 + &ast,
441 + "type-test",
442 + "divide",
443 + 42_i64,
444 + 0_i64,
445 + );
446 + assert!(result.is_err());
447 + match result.unwrap_err() {
448 + PluginError::ScriptError { plugin, .. } => {
449 + assert_eq!(plugin, "type-test");
450 + }
451 + other => panic!("Expected ScriptError, got {:?}", other),
452 + }
453 + }
238 454 }
@@ -14,7 +14,7 @@ use crate::manifest::PluginManifest;
14 14 use goingson_core::PluginMeta;
15 15
16 16 /// A loaded plugin with its compiled script.
17 - #[derive(Clone)]
17 + #[derive(Debug, Clone)]
18 18 pub struct LoadedPlugin {
19 19 /// Plugin metadata from manifest.
20 20 pub meta: PluginMeta,
@@ -362,6 +362,7 @@ impl PluginLoader {
362 362 #[cfg(test)]
363 363 mod tests {
364 364 use super::*;
365 + use crate::engine::SafetyLimits;
365 366 use tempfile::TempDir;
366 367
367 368 fn create_test_plugin(dir: &Path, name: &str) {
@@ -439,4 +440,529 @@ fn parse(file_path, options) {
439 440 loader.disable_plugin("test-plugin").unwrap();
440 441 assert!(!loader.is_enabled("test-plugin"));
441 442 }
443 +
444 + // ============ Discovery: Non-Plugin Files ============
445 +
446 + #[test]
447 + fn discover_available_skips_plain_files() {
448 + let temp_dir = TempDir::new().unwrap();
449 + create_test_plugin(temp_dir.path(), "real-plugin");
450 +
451 + // Drop non-plugin files and dirs into available/
452 + let available = temp_dir.path().join("available");
453 + std::fs::write(available.join("README.md"), "# Plugins directory").unwrap();
454 + std::fs::write(available.join(".DS_Store"), "").unwrap();
455 + std::fs::write(available.join("notes.txt"), "some notes").unwrap();
456 +
457 + let loader = PluginLoader::new(temp_dir.path()).unwrap();
458 + let plugins = loader.discover_available().unwrap();
459 +
460 + // Only the real plugin directory should be discovered
461 + assert_eq!(plugins.len(), 1);
462 + assert_eq!(plugins[0].id, "real-plugin");
463 + }
464 +
465 + #[test]
466 + fn discover_available_skips_dirs_without_manifest() {
467 + let temp_dir = TempDir::new().unwrap();
468 + create_test_plugin(temp_dir.path(), "good-plugin");
469 +
470 + // Create a directory that looks like a plugin but has no plugin.toml
471 + let no_manifest = temp_dir.path().join("available").join("incomplete");
472 + std::fs::create_dir_all(&no_manifest).unwrap();
473 + std::fs::write(no_manifest.join("main.rhai"), "fn describe() {}").unwrap();
474 +
475 + let loader = PluginLoader::new(temp_dir.path()).unwrap();
476 + let plugins = loader.discover_available().unwrap();
477 +
478 + assert_eq!(plugins.len(), 1);
479 + assert_eq!(plugins[0].id, "good-plugin");
480 + }
481 +
482 + #[test]
483 + fn discover_enabled_skips_regular_files_in_enabled_dir() {
484 + let temp_dir = TempDir::new().unwrap();
485 + create_test_plugin(temp_dir.path(), "real-plugin");
486 +
487 + // Enable the real plugin
488 + let loader = PluginLoader::new(temp_dir.path()).unwrap();
489 + loader.enable_plugin("real-plugin").unwrap();
490 +
491 + // Drop a stray file in the enabled/ directory
492 + let enabled_dir = temp_dir.path().join("enabled");
493 + std::fs::write(enabled_dir.join("stray-file.txt"), "not a plugin").unwrap();
494 +
495 + // Re-create loader to force fresh discovery
496 + let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
497 + let enabled = loader.discover_enabled().unwrap();
498 +
499 + // Only the real symlinked plugin should be found
500 + assert_eq!(enabled.len(), 1);
501 + assert_eq!(enabled[0].id, "real-plugin");
502 + }
503 +
504 + // ============ Corrupt Manifest During Load ============
505 +
506 + #[test]
507 + fn load_plugin_with_corrupt_manifest_returns_error() {
508 + let temp_dir = TempDir::new().unwrap();
509 + let plugin_dir = temp_dir.path().join("available").join("corrupt");
510 + std::fs::create_dir_all(&plugin_dir).unwrap();
511 +
512 + // Write invalid TOML as the manifest
513 + std::fs::write(plugin_dir.join("plugin.toml"), "{{{{ garbage !@#$").unwrap();
514 + std::fs::write(plugin_dir.join("main.rhai"), "fn describe() {}").unwrap();
515 +
516 + let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
517 + let result = loader.load_plugin("corrupt", &plugin_dir);
518 + match result {
519 + Err(PluginError::InvalidManifest(msg)) => {
520 + assert!(msg.contains("TOML parse error"), "Unexpected: {}", msg);
521 + }
522 + Err(other) => panic!("Expected InvalidManifest, got {:?}", other),
523 + Ok(_) => panic!("Expected error, got Ok"),
524 + }
525 + }
526 +
527 + #[test]
528 + fn load_plugin_missing_script_returns_error() {
529 + let temp_dir = TempDir::new().unwrap();
530 + let plugin_dir = temp_dir.path().join("available").join("no-script");
531 + std::fs::create_dir_all(&plugin_dir).unwrap();
532 +
533 + // Valid manifest but no main.rhai
534 + let manifest = r#"
535 + [plugin]
536 + name = "no-script"
537 + version = "1.0.0"
538 + description = "Missing script"
539 +
540 + [plugin.type]
541 + kind = "command"
542 +
543 + [plugin.capabilities]
544 + "#;
545 + std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
546 +
547 + let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
548 + let result = loader.load_plugin("no-script", &plugin_dir);
549 + match result {
550 + Err(PluginError::FileError(msg)) => {
551 + assert!(msg.contains("missing main.rhai"), "Unexpected: {}", msg);
552 + }
553 + Err(other) => panic!("Expected FileError, got {:?}", other),
554 + Ok(_) => panic!("Expected error, got Ok"),
555 + }
556 + }
557 +
558 + // ============ Reload (Hot-Reload) Edge Cases ============
559 +
560 + #[test]
561 + fn reload_picks_up_modified_script() {
562 + let temp_dir = TempDir::new().unwrap();
563 + create_test_plugin(temp_dir.path(), "hot-reload");
564 +
565 + let plugin_dir = temp_dir.path().join("available").join("hot-reload");
566 + let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
567 + loader.load_plugin("hot-reload", &plugin_dir).unwrap();
568 +
569 + // Verify original script has the parse function
570 + let original = loader.get_plugin("hot-reload").unwrap();
571 + assert!(loader.engine().has_function(&original.ast, "parse", 2));
572 +
573 + // Update the script with different content (still valid)
574 + let updated_script = r#"
575 + fn describe() {
576 + #{
577 + name: "Updated",
578 + file_extensions: ["csv"]
579 + }
580 + }
581 +
582 + fn parse(file_path, options) {
583 + goingson::task_result([#{description: "updated item"}])
584 + }
585 + "#;
586 + std::fs::write(plugin_dir.join("main.rhai"), updated_script).unwrap();
587 +
588 + // Reload and verify the AST is fresh (new script compiled)
589 + let reloaded = loader.reload_plugin("hot-reload").unwrap();
590 + assert_eq!(reloaded.meta.name, "hot-reload");
591 +
592 + // Call describe() on the reloaded AST to verify the new code runs
593 + let engine = loader.engine();
594 + let desc: rhai::Dynamic = engine.call_fn(&reloaded.ast, "hot-reload", "describe").unwrap();
595 + let map = desc.try_cast::<rhai::Map>().unwrap();
596 + assert_eq!(
597 + map.get("name").unwrap().clone().into_string().unwrap(),
598 + "Updated"
599 + );
600 + }
601 +
602 + #[test]
603 + fn reload_with_now_invalid_script_returns_error() {
604 + let temp_dir = TempDir::new().unwrap();
605 + create_test_plugin(temp_dir.path(), "break-later");
606 +
607 + let plugin_dir = temp_dir.path().join("available").join("break-later");
608 + let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
609 + loader.load_plugin("break-later", &plugin_dir).unwrap();
610 +
611 + // Overwrite with a syntactically invalid script
612 + std::fs::write(plugin_dir.join("main.rhai"), "fn broken( { }").unwrap();
613 +
614 + let result = loader.reload_plugin("break-later");
615 + assert!(result.is_err());
616 + }
617 +
618 + #[test]
619 + fn reload_with_removed_required_function_returns_error() {
620 + let temp_dir = TempDir::new().unwrap();
621 + create_test_plugin(temp_dir.path(), "lose-fn");
622 +
623 + let plugin_dir = temp_dir.path().join("available").join("lose-fn");
624 + let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
625 + loader.load_plugin("lose-fn", &plugin_dir).unwrap();
626 +
627 + // Replace script with one missing the required parse() function
628 + let no_parse = r#"
629 + fn describe() {
630 + #{ name: "Broken", file_extensions: ["csv"] }
631 + }
632 + "#;
633 + std::fs::write(plugin_dir.join("main.rhai"), no_parse).unwrap();
634 +
635 + let result = loader.reload_plugin("lose-fn");
636 + match result {
637 + Err(PluginError::MissingFunction { plugin, function }) => {
638 + assert_eq!(plugin, "lose-fn");
639 + assert_eq!(function, "parse");
640 + }
641 + Err(other) => panic!("Expected MissingFunction, got {:?}", other),
642 + Ok(_) => panic!("Expected error, got Ok"),
643 + }
644 + }
645 +
646 + #[test]
647 + fn reload_nonexistent_plugin_returns_not_found() {
648 + let temp_dir = TempDir::new().unwrap();
649 + let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
650 +
651 + let result = loader.reload_plugin("no-such-plugin");
652 + match result {
653 + Err(PluginError::PluginNotFound(id)) => assert_eq!(id, "no-such-plugin"),
654 + Err(other) => panic!("Expected PluginNotFound, got {:?}", other),
655 + Ok(_) => panic!("Expected error, got Ok"),
656 + }
657 + }
658 +
659 + #[test]
660 + fn reload_updates_manifest_metadata() {
661 + let temp_dir = TempDir::new().unwrap();
662 + create_test_plugin(temp_dir.path(), "version-bump");
663 +
664 + let plugin_dir = temp_dir.path().join("available").join("version-bump");
665 + let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
666 + loader.load_plugin("version-bump", &plugin_dir).unwrap();
667 +
668 + let original = loader.get_plugin("version-bump").unwrap();
669 + assert_eq!(original.meta.version, "1.0.0");
670 +
671 + // Bump version in manifest on disk
672 + let updated_manifest = r#"
673 + [plugin]
674 + name = "version-bump"
675 + version = "2.0.0"
676 + description = "Updated description"
677 +
678 + [plugin.type]
679 + kind = "import"
680 +
681 + [plugin.import]
682 + file_extensions = ["csv"]
683 + entity_types = ["task"]
684 +
685 + [plugin.capabilities]
686 + file_read = true
687 + "#;
688 + std::fs::write(plugin_dir.join("plugin.toml"), updated_manifest).unwrap();
689 +
690 + let reloaded = loader.reload_plugin("version-bump").unwrap();
691 + assert_eq!(reloaded.meta.version, "2.0.0");
692 + assert_eq!(reloaded.meta.description, "Updated description");
693 + }
694 +
695 + // ============ Cache Bypass on Reload ============
696 +
697 + #[test]
698 + fn load_returns_cached_but_reload_bypasses_cache() {
699 + let temp_dir = TempDir::new().unwrap();
700 + create_test_plugin(temp_dir.path(), "cache-test");
701 +
702 + let plugin_dir = temp_dir.path().join("available").join("cache-test");
703 + let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
704 +
705 + // First load
706 + loader.load_plugin("cache-test", &plugin_dir).unwrap();
707 + let v1 = loader.get_plugin("cache-test").unwrap().meta.version.clone();
708 +
709 + // Update manifest on disk
710 + let updated = r#"
711 + [plugin]
712 + name = "cache-test"
713 + version = "9.9.9"
714 + description = "Test plugin"
715 +
716 + [plugin.type]
717 + kind = "import"
718 +
719 + [plugin.import]
720 + file_extensions = ["csv"]
721 + entity_types = ["task"]
722 +
723 + [plugin.capabilities]
724 + file_read = true
725 + "#;
726 + std::fs::write(plugin_dir.join("plugin.toml"), updated).unwrap();
727 +
728 + // load_plugin should return cached (stale) version
729 + let cached = loader.load_plugin("cache-test", &plugin_dir).unwrap();
730 + assert_eq!(cached.meta.version, v1);
731 +
732 + // reload_plugin should pick up the new version
733 + let reloaded = loader.reload_plugin("cache-test").unwrap();
734 + assert_eq!(reloaded.meta.version, "9.9.9");
735 + }
736 +
737 + // ============ Full Plugin Lifecycle ============
738 +
739 + /// Exercises the full plugin lifecycle: discover -> load -> execute -> error -> recover.
740 + ///
741 + /// This test uses a hook plugin with two functions: one that succeeds and one
742 + /// that throws. It verifies:
743 + /// 1. discover_available() finds the plugin on disk
744 + /// 2. load_plugin() compiles the script and validates functions
745 + /// 3. Calling a successful function produces the expected result
746 + /// 4. Calling a throwing function returns PluginError, does not panic
747 + /// 5. After the error, the plugin is still callable (recovery)
748 + #[test]
749 + fn full_plugin_lifecycle_discover_load_execute_error_recover() {
750 + let temp_dir = TempDir::new().unwrap();
751 +
752 + // -- set up a hook plugin on disk --
753 + let plugin_dir = temp_dir.path().join("available").join("task-hook");
754 + std::fs::create_dir_all(&plugin_dir).unwrap();
755 +
756 + let manifest = r#"
757 + [plugin]
758 + name = "Task Hook"
759 + version = "1.0.0"
760 + description = "Reacts to task lifecycle events"
761 +
762 + [plugin.type]
763 + kind = "hook"
764 +
765 + [plugin.capabilities]
766 + "#;
767 + std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
768 +
769 + let script = r#"
770 + fn describe() {
771 + #{
772 + name: "Task Hook",
773 + hooks: ["on_task_created", "on_task_completed"]
774 + }
775 + }
776 +
777 + fn on_task_created(task_id) {
778 + if task_id == "" {
779 + throw "task_id must not be empty";
780 + }
781 + "created:" + task_id
782 + }
783 +
784 + fn on_task_completed(task_id) {
785 + "completed:" + task_id
786 + }
787 + "#;
788 + std::fs::write(plugin_dir.join("main.rhai"), script).unwrap();
789 +
790 + // -- 1. Discover --
791 + let mut loader = PluginLoader::new(temp_dir.path()).unwrap();
792 + let available = loader.discover_available().unwrap();
793 + assert_eq!(available.len(), 1);
794 + assert_eq!(available[0].id, "task-hook");
795 + assert_eq!(available[0].name, "Task Hook");
796 + assert!(matches!(
797 + available[0].plugin_type,
798 + goingson_core::PluginType::Hook
799 + ));
800 +
801 + // -- 2. Load --
802 + let loaded = loader.load_plugin("task-hook", &plugin_dir).unwrap();
803 + assert_eq!(loaded.meta.version, "1.0.0");
804 +
805 + let engine = loader.engine();
806 + assert!(engine.has_function(&loaded.ast, "describe", 0));
807 + assert!(engine.has_function(&loaded.ast, "on_task_created", 1));
808 + assert!(engine.has_function(&loaded.ast, "on_task_completed", 1));
809 +
810 + // -- 3. Execute (success) --
811 + let result: String = engine
812 + .call_fn_1(&loaded.ast, "task-hook", "on_task_created", "t-42".to_string())
813 + .unwrap();
814 + assert_eq!(result, "created:t-42");
815 +
816 + let result2: String = engine
817 + .call_fn_1(&loaded.ast, "task-hook", "on_task_completed", "t-42".to_string())
818 + .unwrap();
819 + assert_eq!(result2, "completed:t-42");
820 +
821 + // -- 4. Execute (error) -- empty task_id triggers throw
822 + let err_result: crate::error::Result<String> = engine.call_fn_1(
823 + &loaded.ast,
824 + "task-hook",
825 + "on_task_created",
826 + "".to_string(),
827 + );
828 + assert!(err_result.is_err());
829 + match err_result.unwrap_err() {
830 + PluginError::ScriptError { plugin, message } => {
831 + assert_eq!(plugin, "task-hook");
832 + assert!(
833 + message.contains("task_id must not be empty"),
834 + "Unexpected error message: {}",
835 + message
836 + );
837 + }
838 + other => panic!("Expected ScriptError, got {:?}", other),
839 + }
840 +
841 + // -- 5. Recover -- plugin is still callable after the error
842 + let recovered: String = engine
843 + .call_fn_1(&loaded.ast, "task-hook", "on_task_created", "t-99".to_string())
844 + .unwrap();
845 + assert_eq!(recovered, "created:t-99");
846 + }
847 +
848 + /// Verifies that a plugin hitting the operation limit errors gracefully and
849 + /// does not prevent subsequent calls to cheaper functions.
850 + #[test]
851 + fn full_lifecycle_operation_limit_and_recovery() {
852 + let temp_dir = TempDir::new().unwrap();
853 +
854 + let plugin_dir = temp_dir.path().join("available").join("ops-hook");
855 + std::fs::create_dir_all(&plugin_dir).unwrap();
856 +
857 + let manifest = r#"
858 + [plugin]
859 + name = "Ops Hook"
860 + version = "1.0.0"
861 + description = "Hook with expensive and cheap paths"
862 +
863 + [plugin.type]
864 + kind = "hook"
865 +
866 + [plugin.capabilities]
867 + "#;
868 + std::fs::write(plugin_dir.join("plugin.toml"), manifest).unwrap();
869 +
870 + let script = r#"
871 + fn describe() {
872 + #{ name: "Ops Hook" }
873 + }
874 +
875 + fn on_task_created(task_id) {
876 + if task_id == "spin" {
877 + let x = 0;
878 + loop { x += 1; }
879 + }
880 + "ok:" + task_id
881 + }
882 + "#;
883 + std::fs::write(plugin_dir.join("main.rhai"), script).unwrap();
884 +
885 + // Use a tight ops limit to trigger the safety check quickly
886 + let limits = SafetyLimits {
887 + max_operations: 200,
888 + ..Default::default()
889 + };
890 + let engine = Arc::new(PluginEngine::with_limits(limits));
891 + let mut loader = PluginLoader::with_engine(temp_dir.path(), engine).unwrap();
892 +
893 + // Discover + load
894 + let available = loader.discover_available().unwrap();
895 + assert_eq!(available.len(), 1);
896 +
897 + let loaded = loader.load_plugin("ops-hook", &plugin_dir).unwrap();
898 + let engine = loader.engine();
899 +
900 + // Trigger ops limit
901 + let err: crate::error::Result<String> = engine.call_fn_1(
902 + &loaded.ast,
903 + "ops-hook",
904 + "on_task_created",
905 + "spin".to_string(),
906 + );
907 + assert!(err.is_err());
908 + match err.unwrap_err() {
909 + PluginError::SafetyLimitExceeded { plugin, .. } => {
910 + assert_eq!(plugin, "ops-hook");
911 + }
912 + other => panic!("Expected SafetyLimitExceeded, got {:?}", other),
913 + }
914 +
915 + // Recover -- cheap path should still work
916 + let ok: String = engine
917 + .call_fn_1(&loaded.ast, "ops-hook", "on_task_created", "t-1".to_string())
918 + .unwrap();
919 + assert_eq!(ok, "ok:t-1");
920 + }
921 +
922 + /// Ensures that hot-reloading a broken plugin does not corrupt the loader,
923 + /// and a subsequent reload with a fixed script succeeds.
924 + #[test]
Lines truncated
@@ -223,4 +223,118 @@ kind = "import"
223 223 let result = manifest.to_meta("bad-plugin".to_string());
224 224 assert!(result.is_err());
225 225 }
226 +
227 + // ============ Corrupt / Invalid Manifest ============
228 +
229 + #[test]
230 + fn corrupt_toml_returns_error_not_panic() {
231 + let garbage = "{{{{ not valid toml at all !@#$";
232 + let result = PluginManifest::parse(garbage);
233 + assert!(result.is_err());
234 + match result.unwrap_err() {
235 + PluginError::InvalidManifest(msg) => {
236 + assert!(msg.contains("TOML parse error"), "Unexpected message: {}", msg);
237 + }
238 + other => panic!("Expected InvalidManifest, got {:?}", other),
239 + }
240 + }
241 +
242 + #[test]
243 + fn empty_string_manifest_returns_error() {
244 + let result = PluginManifest::parse("");
245 + assert!(result.is_err());
246 + match result.unwrap_err() {
247 + PluginError::InvalidManifest(_) => {}
248 + other => panic!("Expected InvalidManifest, got {:?}", other),
249 + }
250 + }
251 +
252 + #[test]
253 + fn manifest_missing_required_fields_returns_error() {
254 + // Valid TOML but missing required plugin fields (name, version, description)
255 + let toml = r#"
256 + [plugin]
257 + name = "incomplete"
258 + "#;
259 + let result = PluginManifest::parse(toml);
260 + assert!(result.is_err());
261 + }
262 +
263 + #[test]
264 + fn manifest_with_truncated_toml_returns_error() {
265 + // Simulates a file that was partially written (e.g. crash mid-update)
266 + let truncated = r#"
267 + [plugin]
268 + name = "half-written"
269 + version = "1.0.0"
270 + description = "Truncated during write"
271 +
272 + [plugin.type]
273 + kind = "import"
274 +
275 + [plugin.import
276 + "#;
277 + let result = PluginManifest::parse(truncated);
278 + assert!(result.is_err());
279 + match result.unwrap_err() {
280 + PluginError::InvalidManifest(msg) => {
281 + assert!(msg.contains("TOML parse error"), "Unexpected message: {}", msg);
282 + }
283 + other => panic!("Expected InvalidManifest, got {:?}", other),
284 + }
285 + }
286 +
287 + // ============ Unsupported Plugin Kind ============
288 +
289 + #[test]
290 + fn unsupported_plugin_kind_returns_error() {
291 + let toml = r#"
292 + [plugin]
293 + name = "bad-kind"
294 + version = "1.0.0"
295 + description = "Uses a kind that does not exist"
296 +
297 + [plugin.type]
298 + kind = "transformer"
299 +
300 + [plugin.capabilities]
301 + "#;
302 + let manifest = PluginManifest::parse(toml).unwrap();
303 + let result = manifest.to_meta("bad-kind".to_string());
304 + assert!(result.is_err());
305 + match result.unwrap_err() {
306 + PluginError::InvalidManifest(msg) => {
307 + assert!(
308 + msg.contains("Unknown plugin kind: transformer"),
309 + "Unexpected message: {}",
310 + msg
311 + );
312 + }
313 + other => panic!("Expected InvalidManifest, got {:?}", other),
314 + }
315 + }
316 +
317 + #[test]
318 + fn empty_plugin_kind_returns_error() {
319 + let toml = r#"
320 + [plugin]
321 + name = "empty-kind"
322 + version = "1.0.0"
323 + description = "Kind field is empty"
324 +
325 + [plugin.type]
326 + kind = ""
327 +
328 + [plugin.capabilities]
329 + "#;
330 + let manifest = PluginManifest::parse(toml).unwrap();
331 + let result = manifest.to_meta("empty-kind".to_string());
332 + assert!(result.is_err());
333 + match result.unwrap_err() {
334 + PluginError::InvalidManifest(msg) => {
335 + assert!(msg.contains("Unknown plugin kind"), "Unexpected message: {}", msg);
336 + }
337 + other => panic!("Expected InvalidManifest, got {:?}", other),
338 + }
339 + }
226 340 }
@@ -874,6 +874,234 @@ fn describe() {
874 874 assert!(map.contains_key("has_header"));
875 875 }
876 876
877 + // ============ User Plugin Overrides Bundled ============
878 +
879 + /// Simulates the case where a user installs a plugin with the same ID as
880 + /// a previously-loaded one (e.g. a bundled default). Loading the same ID
881 + /// twice should allow reload to replace the original.
882 + #[test]
883 + fn user_plugin_overrides_same_id_via_reload() {
884 + let temp_dir = TempDir::new().unwrap();
885 + create_csv_import_plugin(temp_dir.path());
886 +
887 + let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
888 + registry.enable_plugin("csv-import").unwrap();
889 +
890 + // Verify the original
891 + let meta = registry.loader().get_plugin("csv-import").unwrap().meta.clone();
892 + assert_eq!(meta.version, "1.0.0");
893 + assert_eq!(meta.description, "Import tasks from CSV files");
894 +
895 + // User "updates" the same plugin on disk (new version, new description)
896 + let plugin_dir = temp_dir.path().join("available").join("csv-import");
897 + let user_manifest = r#"
898 + [plugin]
899 + name = "CSV Import"
900 + version = "2.0.0"
901 + description = "User-customized CSV import"
902 +
903 + [plugin.type]
904 + kind = "import"
905 +
906 + [plugin.import]
907 + file_extensions = ["csv", "tsv"]
908 + entity_types = ["task"]
909 +
910 + [plugin.capabilities]
911 + file_read = true
912 + database_write = true
913 + "#;
914 + std::fs::write(plugin_dir.join("plugin.toml"), user_manifest).unwrap();
915 +
916 + let user_script = r#"
917 + fn describe() {
918 + #{
919 + name: "CSV Import (User)",
920 + file_extensions: ["csv", "tsv"]
921 + }
922 + }
923 +
924 + fn parse(file_path, options) {
925 + goingson::task_result([])
926 + }
927 + "#;
928 + std::fs::write(plugin_dir.join("main.rhai"), user_script).unwrap();
929 +
930 + // Reload to pick up user version
931 + let reloaded = registry.reload_plugin("csv-import").unwrap();
932 + assert_eq!(reloaded.version, "2.0.0");
933 + assert_eq!(reloaded.description, "User-customized CSV import");
934 +
935 + // The updated plugin should handle tsv now
936 + let tsv_plugins = registry.get_plugins_for_extension("tsv");
937 + assert_eq!(tsv_plugins.len(), 1);
938 + assert_eq!(tsv_plugins[0].version, "2.0.0");
939 + }
940 +
941 + // ============ Reload While Running (Sequential Safety) ============
942 +
943 + /// Verifies that after a reload, the old AST is no longer returned by
944 + /// the loader, and the new AST produces different results.
945 + #[test]
946 + fn reload_replaces_ast_atomically() {
947 + let temp_dir = TempDir::new().unwrap();
948 + create_csv_import_plugin(temp_dir.path());
949 +
950 + let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
951 + registry.enable_plugin("csv-import").unwrap();
952 +
953 + // Capture the Arc<AST> from the first load
954 + let old_ast = {
955 + let plugin = registry.loader().get_plugin("csv-import").unwrap();
956 + plugin.ast.clone()
957 + };
958 +
959 + // Modify the script to return a different describe name
960 + let plugin_dir = temp_dir.path().join("available").join("csv-import");
961 + let new_script = r#"
962 + fn describe() {
963 + #{
964 + name: "CSV Import V2",
965 + file_extensions: ["csv"]
966 + }
967 + }
968 +
969 + fn parse(file_path, options) {
970 + goingson::task_result([])
971 + }
972 + "#;
973 + std::fs::write(plugin_dir.join("main.rhai"), new_script).unwrap();
974 +
975 + registry.reload_plugin("csv-import").unwrap();
976 +
977 + // The registry now holds a different AST
978 + let new_ast = {
979 + let plugin = registry.loader().get_plugin("csv-import").unwrap();
980 + plugin.ast.clone()
981 + };
982 +
983 + // Verify new AST produces different output
984 + let engine = registry.loader().engine();
985 + let old_desc: Dynamic = engine.call_fn(&old_ast, "csv-import", "describe").unwrap();
986 + let new_desc: Dynamic = engine.call_fn(&new_ast, "csv-import", "describe").unwrap();
987 +
988 + let old_name = old_desc
989 + .try_cast::<Map>()
990 + .unwrap()
991 + .get("name")
992 + .unwrap()
993 + .clone()
994 + .into_string()
995 + .unwrap();
996 + let new_name = new_desc
997 + .try_cast::<Map>()
998 + .unwrap()
999 + .get("name")
1000 + .unwrap()
1001 + .clone()
1002 + .into_string()
1003 + .unwrap();
1004 +
1005 + assert_eq!(old_name, "CSV Import");
1006 + assert_eq!(new_name, "CSV Import V2");
1007 + }
1008 +
1009 + // ============ Corrupt Manifest After Initial Load ============
1010 +
1011 + #[test]
1012 + fn reload_with_corrupt_manifest_returns_error() {
1013 + let temp_dir = TempDir::new().unwrap();
1014 + create_csv_import_plugin(temp_dir.path());
1015 +
1016 + let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
1017 + registry.enable_plugin("csv-import").unwrap();
1018 +
1019 + // Corrupt the manifest on disk
1020 + let manifest_path = temp_dir.path().join("available/csv-import/plugin.toml");
1021 + std::fs::write(&manifest_path, "{{{{ not valid TOML !@#$").unwrap();
1022 +
1023 + let result = registry.reload_plugin("csv-import");
1024 + assert!(result.is_err());
1025 + match result.unwrap_err() {
1026 + PluginError::InvalidManifest(msg) => {
1027 + assert!(msg.contains("TOML parse error"), "Unexpected: {}", msg);
1028 + }
1029 + other => panic!("Expected InvalidManifest, got {:?}", other),
1030 + }
1031 + }
1032 +
1033 + // ============ Permission Escalation ============
1034 +
1035 + /// A plugin that initially has no capabilities should not gain them on
1036 + /// reload even if the manifest changes, because the host checks the
1037 + /// PluginMeta.capabilities at execution time. This test verifies the
1038 + /// capabilities are faithfully read from the new manifest.
1039 + #[test]
1040 + fn reload_reflects_changed_capabilities() {
1041 + let temp_dir = TempDir::new().unwrap();
1042 +
1043 + // Create a plugin with minimal capabilities
1044 + let plugin_dir = temp_dir.path().join("available").join("sneaky");
1045 + std::fs::create_dir_all(&plugin_dir).unwrap();
1046 +
1047 + let safe_manifest = r#"
1048 + [plugin]
1049 + name = "Sneaky Plugin"
1050 + version = "1.0.0"
1051 + description = "Starts safe"
1052 +
1053 + [plugin.type]
1054 + kind = "command"
1055 +
1056 + [plugin.capabilities]
1057 + file_read = false
1058 + database_write = false
1059 + network = false
1060 + "#;
1061 + std::fs::write(plugin_dir.join("plugin.toml"), safe_manifest).unwrap();
1062 + let script = r#"
1063 + fn describe() { #{ name: "Sneaky" } }
1064 + fn execute(args) { 42 }
1065 + "#;
1066 + std::fs::write(plugin_dir.join("main.rhai"), script).unwrap();
1067 +
1068 + let mut registry = PluginRegistry::new(temp_dir.path()).unwrap();
1069 + registry.enable_plugin("sneaky").unwrap();
1070 +
1071 + // Verify initial capabilities are restricted
1072 + let meta = registry.loader().get_plugin("sneaky").unwrap().meta.clone();
1073 + assert!(!meta.capabilities.file_read);
1074 + assert!(!meta.capabilities.database_write);
1075 + assert!(!meta.capabilities.network);
1076 +
1077 + // Attacker modifies manifest to escalate permissions
1078 + let escalated_manifest = r#"
1079 + [plugin]
1080 + name = "Sneaky Plugin"
1081 + version = "1.0.1"
1082 + description = "Now wants everything"
1083 +
1084 + [plugin.type]
1085 + kind = "command"
1086 +
1087 + [plugin.capabilities]
1088 + file_read = true
1089 + database_write = true
1090 + network = true
1091 + "#;
1092 + std::fs::write(plugin_dir.join("plugin.toml"), escalated_manifest).unwrap();
1093 +
1094 + // Reload picks up the new manifest -- the host is responsible for
1095 + // checking whether the user approved the new capabilities. The
1096 + // important thing is that the capabilities field is accurate (not
1097 + // stale from the old manifest).
1098 + let reloaded = registry.reload_plugin("sneaky").unwrap();
1099 + assert!(reloaded.capabilities.file_read);
1100 + assert!(reloaded.capabilities.database_write);
1101 + assert!(reloaded.capabilities.network);
1102 + assert_eq!(reloaded.version, "1.0.1");
1103 + }
1104 +
877 1105 // --- with_engine constructor ---
878 1106
879 1107 #[test]
@@ -182,15 +182,6 @@ const api = {
182 182 getOptions: () => invoke('get_snooze_options'),
183 183 },
184 184
185 - // LLM — settings and template evaluation for AI-assisted features
186 - llm: {
187 - getSettings: () => invoke('get_llm_settings'),
188 - saveSettings: (input) => invoke('save_llm_settings', { input }),
189 - testConnection: () => invoke('test_llm_connection'),
190 - evaluate: (input) => invoke('evaluate_llm_template', { input }), // Run a prompt template against the LLM
191 - clearCache: () => invoke('clear_llm_cache'),
192 - },
193 -
194 185 // OAuth — provider-based auth flow (Google, Microsoft, Yahoo, Fastmail)
195 186 oauth: {
196 187 listProviders: () => invoke('list_oauth_providers'),
@@ -125,7 +125,7 @@ if (window.__TAURI__) {
125 125 listen('menu:keyboard_shortcuts', () => GoingsOn.keyboard.toggleShortcuts());
126 126 listen('menu:about', () => GoingsOn.app.openAboutModal());
127 127
128 - // Database external change detection (e.g., from MCP server)
128 + // Database external change detection
129 129 listen('db:external-change', () => {
130 130 console.log('External database change detected, refreshing view');
131 131 refreshCurrentViewData();
@@ -158,7 +158,7 @@ if (window.__TAURI__) {
158 158
159 159 /**
160 160 * Refresh the current view's data without full navigation.
161 - * Used when external changes are detected (e.g., MCP server modified the database).
161 + * Used when external changes are detected (e.g., an external process modified the database).
162 162 */
163 163 async function refreshCurrentViewData() {
164 164 // Don't refresh if a modal is open (user is editing something)
@@ -19,6 +19,11 @@
19 19 'application/gzip': '\uD83D\uDCE6',
20 20 };
21 21
22 + /**
23 + * Get the emoji icon for a MIME type.
24 + * @param {string} mimeType - MIME type string
25 + * @returns {string} Emoji character for the file type
26 + */
22 27 function getIcon(mimeType) {
23 28 if (MIME_ICONS[mimeType]) return MIME_ICONS[mimeType];
24 29 for (const [prefix, icon] of Object.entries(MIME_ICONS)) {
@@ -27,6 +32,11 @@
27 32 return '\uD83D\uDCCE';
28 33 }
29 34
35 + /**
36 + * Open the attachments panel modal for a task or project.
37 + * @param {string|null} taskId - Task ID, or null for project-only
38 + * @param {string|null} projectId - Project ID, or null for task-only
39 + */
30 40 async function openPanel(taskId, projectId) {
31 41 GoingsOn.ui.closeModal();
32 42 try {
@@ -78,6 +88,11 @@
78 88 GoingsOn.ui.openModal('Attachments', content);
79 89 }
80 90
91 + /**
92 + * Open the native file picker and attach the selected file.
93 + * @param {string|null} taskId - Task ID to attach to
94 + * @param {string|null} projectId - Project ID to attach to
95 + */
81 96 async function pickAndAttach(taskId, projectId) {
82 97 try {
83 98 const { open } = window.__TAURI__.dialog;
@@ -104,6 +119,10 @@
104 119 }
105 120 }
106 121
122 + /**
123 + * Open an attachment file using the system default application.
124 + * @param {string} id - Attachment ID
125 + */
107 126 async function openAttachment(id) {
108 127 try {
109 128 await GoingsOn.api.attachments.open(id);
@@ -112,6 +131,11 @@
112 131 }
113 132 }
114 133
134 + /**
135 + * Save an attachment to a user-chosen location.
136 + * @param {string} id - Attachment ID
137 + * @param {string} filename - Default filename for the save dialog
138 + */
115 139 async function saveAs(id, filename) {
116 140 try {
117 141 const { save } = window.__TAURI__.dialog;
@@ -129,6 +153,12 @@
129 153 }
130 154 }
131 155
156 + /**
157 + * Delete an attachment after confirmation, then refresh the panel.
158 + * @param {string} id - Attachment ID to delete
159 + * @param {string} taskId - Parent task ID for panel refresh
160 + * @param {string} projectId - Parent project ID for panel refresh
161 + */
132 162 async function remove(id, taskId, projectId) {
133 163 if (!confirm('Delete this attachment?')) return;
134 164
@@ -143,6 +173,11 @@
143 173 }
144 174
145 175 // Render a compact attachment count badge for task rows
176 + /**
177 + * Render a compact attachment count badge for task rows.
178 + * @param {number} attachmentCount - Number of attachments
179 + * @returns {string} HTML string for the badge, or empty string if no attachments
180 + */
146 181 function renderBadge(attachmentCount) {
147 182 if (!attachmentCount || attachmentCount === 0) return '';
148 183 return `<span class="task-badge has-items">Files: ${attachmentCount}</span>`;
@@ -10,6 +10,16 @@
10 10
11 11 // ============ Generic Bulk Action Helper ============
12 12
13 + /**
14 + * Run an API call for each selected item in parallel.
15 + * @param {Object} opts
16 + * @param {Set<string>} opts.selectedIds - IDs to act on
17 + * @param {Function} opts.apiCall - (id) => Promise for each item
18 + * @param {string} opts.successMessage - Toast message ({count} is replaced)
19 + * @param {string} opts.errorMessage - Prefix for error toast
20 + * @param {Function} opts.reloadFn - Called after success to refresh the view
21 + * @param {boolean} [opts.closeModalAfter=false] - Close modal on success
22 + */
13 23 async function executeBulkAction({ selectedIds, apiCall, successMessage, errorMessage, reloadFn, closeModalAfter = false }) {
14 24 if (selectedIds.size === 0) return;
15 25
@@ -27,6 +37,9 @@
27 37 }
28 38 }
29 39
40 + /**
41 + * Show or hide the bulk actions bars based on current selection state.
42 + */
30 43 function updateBulkActionsBar() {
31 44 const taskBar = document.getElementById('task-bulk-actions');
32 45 const emailBar = document.getElementById('email-bulk-actions');
@@ -54,6 +67,12 @@
54 67
55 68 // ============ Bulk Snooze Modal ============
56 69
70 + /**
71 + * Open the snooze modal for a set of selected items.
72 + * @param {string} itemType - 'tasks' or 'emails'
73 + * @param {Set<string>} selectedIds - IDs of items to snooze
74 + * @param {Function} snoozeCallback - (until: string) => Promise called with the chosen snooze time
75 + */
57 76 async function openBulkSnoozeModal(itemType, selectedIds, snoozeCallback) {
58 77 if (selectedIds.size === 0) return;
59 78
@@ -10,12 +10,22 @@
10 10
11 11 // ============ Helpers ============
12 12
13 + /**
14 + * Extract up to 2 initials from a display name.
15 + * @param {string} name - Full display name
16 + * @returns {string} Uppercase initials (e.g. "JD")
17 + */
13 18 function getInitials(name) {
14 19 return name.split(/\s+/).filter(Boolean).map(w => w[0]).slice(0, 2).join('').toUpperCase();
15 20 }
16 21
17 22 // ============ Card Rendering ============
18 23
24 + /**
25 + * Render a contact card for the contacts grid.
26 + * @param {Object} c - Contact object
27 + * @returns {string} HTML string for the contact card
28 + */
19 29 function renderCard(c) {
20 30 const initials = c.initials || getInitials(c.displayName);
21 31 const primaryEmail = c.primaryEmail || c.emails?.find(e => e.isPrimary)?.address || c.emails?.[0]?.address || '';
@@ -46,6 +56,10 @@
46 56
47 57 // ============ Detail Modal Rendering ============
48 58
59 + /**
60 + * Show the full contact detail modal with all sub-collections.
61 + * @param {Object} contact - Full contact object with emails, phones, socialHandles, customFields
62 + */
49 63 function showDetailModal(contact) {
50 64 const initials = contact.initials || getInitials(contact.displayName);
51 65
@@ -105,6 +105,12 @@
105 105
106 106 // ============ Generic Sub-Collection Functions ============
107 107
108 + /**
109 + * Build the HTML form for adding a sub-collection item (email, phone, etc.).
110 + * @param {string} type - Sub-collection type key from SUB_COLLECTIONS
111 + * @param {string} contactId - Parent contact ID
112 + * @returns {string} HTML string for the form
113 + */
108 114 function buildSubCollectionFormHtml(type, contactId) {
109 115 const config = SUB_COLLECTIONS[type];
110 116 const fieldHtml = config.fields.map(f => {
@@ -144,12 +150,22 @@
144 150 `;
145 151 }
146 152
153 + /**
154 + * Open a modal to add a sub-collection item to a contact.
155 + * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField')
156 + * @param {string} contactId - Parent contact ID
157 + */
147 158 function openAddSubCollection(type, contactId) {
148 159 const config = SUB_COLLECTIONS[type];
149 160 const content = buildSubCollectionFormHtml(type, contactId);
150 161 GoingsOn.ui.openModal(config.modalTitle, content);
151 162 }
152 163
164 + /**
165 + * Validate and submit a sub-collection add form.
166 + * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField')
167 + * @param {string} contactId - Parent contact ID
168 + */
153 169 async function submitSubCollection(type, contactId) {
154 170 const config = SUB_COLLECTIONS[type];
155 171 const form = document.getElementById(config.formId);
@@ -170,6 +186,12 @@
170 186 });
171 187 }
172 188
189 + /**
190 + * Remove a sub-collection item from a contact with confirmation.
191 + * @param {string} type - Sub-collection type ('email', 'phone', 'social', 'customField')
192 + * @param {string} contactId - Parent contact ID
193 + * @param {string} itemId - Sub-collection item ID to remove
194 + */
173 195 async function removeSubCollection(type, contactId, itemId) {
174 196 const config = SUB_COLLECTIONS[type];
175 197 if (!await GoingsOn.ui.confirmDelete(config.deleteLabel || `this ${config.entityLabel}`)) return;
@@ -200,6 +222,11 @@
200 222
201 223 // ============ Form Field Definitions ============
202 224
225 + /**
226 + * Build form field definitions for the contact create/edit modal.
227 + * @param {Object|null} contact - Existing contact for edit mode, or null for create
228 + * @returns {FormField[]} Array of form field definitions
229 + */
203 230 function getContactFormFields(contact = null) {
204 231 return [
205 232 {
@@ -268,6 +295,11 @@
268 295 return GoingsOn.utils.normalizeTags(tagString);
269 296 }
270 297
298 + /**
299 + * Extract all unique tags from a list of contacts, sorted alphabetically.
300 + * @param {Array<Object>} contacts - Contact objects with optional tags arrays
301 + * @returns {string[]} Sorted array of unique tag strings
302 + */
271 303 function getAllTags(contacts) {
272 304 const tagSet = new Set();
273 305 contacts.forEach(c => (c.tags || []).forEach(t => tagSet.add(t)));
@@ -455,6 +487,10 @@
455 487
456 488 // ============ Detail Modal ============
457 489
490 + /**
491 + * Fetch a contact by ID and open its detail modal.
492 + * @param {string} id - Contact ID to open
493 + */
458 494 async function open(id) {
459 495 try {
460 496 const contact = await GoingsOn.api.contacts.get(id);
@@ -474,11 +510,19 @@
474 510
475 511 // ============ Filtering ============
476 512
513 + /**
514 + * Filter contacts by search query (server-side filtering).
515 + * @param {string} query - Search text to filter by
516 + */
477 517 function filterBySearch(query) {
478 518 GoingsOn.state.set('contactsSearchQuery', query.trim());
479 519 load();
480 520 }
481 521
522 + /**
523 + * Filter contacts by tag (server-side filtering).
524 + * @param {string} tag - Tag to filter by, or empty string for all
525 + */
482 526 function filterByTag(tag) {
483 527 GoingsOn.state.set('contactsTagFilter', tag);
484 528 load();
@@ -8,6 +8,11 @@
8 8
9 9 // ============ Context Menu Handlers ============
10 10
11 + /**
12 + * Show the right-click context menu for a task.
13 + * @param {MouseEvent} e - Right-click event
14 + * @param {string} taskId - Task ID
15 + */
11 16 function showTaskContextMenu(e, taskId) {
12 17 e.preventDefault();
13 18 e.stopPropagation();
@@ -15,6 +20,11 @@
15 20 showContextMenu(e.clientX, e.clientY, items);
16 21 }
17 22
23 + /**
24 + * Show the right-click context menu for an email.
25 + * @param {MouseEvent} e - Right-click event
26 + * @param {string} emailId - Email ID
27 + */
18 28 function showEmailContextMenu(e, emailId) {
19 29 e.preventDefault();
20 30 e.stopPropagation();
@@ -29,6 +39,11 @@
29 39 showContextMenu(e.clientX, e.clientY, items);
30 40 }
31 41
42 + /**
43 + * Show the right-click context menu for an event.
44 + * @param {MouseEvent} e - Right-click event
45 + * @param {string} eventId - Event ID
46 + */
32 47 function showEventContextMenu(e, eventId) {
33 48 e.preventDefault();
34 49 e.stopPropagation();
@@ -36,6 +51,11 @@
36 51 showContextMenu(e.clientX, e.clientY, items);
37 52 }
38 53
54 + /**
55 + * Show the right-click context menu for a project.
56 + * @param {MouseEvent} e - Right-click event
57 + * @param {string} projectId - Project ID
58 + */
39 59 function showProjectContextMenu(e, projectId) {
40 60 e.preventDefault();
41 61 e.stopPropagation();
@@ -10,6 +10,11 @@
10 10
11 11 // ============ Utility ============
12 12
13 + /**
14 + * Convert a 15-minute slot index (0-95) to a Date object on the current day plan date.
15 + * @param {number} slotIndex - Slot index (0 = 00:00, 95 = 23:45)
16 + * @returns {Date} Date object for the slot
17 + */
13 18 function slotToTime(slotIndex) {
14 19 const hour = Math.floor(slotIndex / 4);
15 20 const minute = (slotIndex % 4) * 15;
@@ -22,6 +27,12 @@
22 27
23 28 // ============ Painting to Create Events ============
24 29
30 + /**
31 + * Begin painting a time range on mousedown.
32 + * @param {MouseEvent} event
33 + * @param {number} slotIndex - Starting slot index
34 + * @param {string} slotTime - ISO timestamp of the slot
35 + */
25 36 function onPaintStart(event, slotIndex, slotTime) {
26 37 if (event.button !== 0) return;
27 38 if (event.target.closest('.timeline-item')) return;
@@ -47,6 +58,12 @@
47 58 document.addEventListener('mouseup', onPaintEnd);
48 59 }
49 60
61 + /**
62 + * Extend the paint selection on mousemove.
63 + * @param {MouseEvent} event
64 + * @param {number} slotIndex - Current slot index
65 + * @param {string} slotTime - ISO timestamp of the slot
66 + */
50 67 function onPaintMove(event, slotIndex, slotTime) {
51 68 if (!GoingsOn.state.paintingState) return;
52 69 GoingsOn.state.paintingState.endSlot = slotIndex;
@@ -82,6 +99,11 @@
82 99 GoingsOn.state.paintingState.preview.style.height = `${(endSlot - startSlot + 1) * slotHeight}px`;
83 100 }
84 101
102 + /**
103 + * Open the create modal for a painted time range (event, block, or task link).
104 + * @param {Date} startTime - Start of the painted range
105 + * @param {Date} endTime - End of the painted range
106 + */
85 107 function openPaintedEventModal(startTime, endTime) {
86 108 const startISO = toLocalISOString(startTime);
87 109 const endISO = toLocalISOString(endTime);
@@ -160,6 +182,10 @@
160 182 GoingsOn.ui.openModal('Create Event', content);
161 183 }
162 184
185 + /**
186 + * Toggle visibility of form fields based on the selected paint mode.
187 + * @param {HTMLSelectElement} select - The mode selector element
188 + */
163 189 function togglePaintMode(select) {
164 190 const mode = select.value;
165 191 const taskFields = document.getElementById('paint-task-fields');
@@ -19,6 +19,11 @@
19 19
20 20 // ============ Timeline Rendering ============
21 21
22 + /**
23 + * Render the day timeline with 15-minute slots and positioned items.
24 + * @param {Date} dayPlanDate - The date being displayed
25 + * @param {Object|null} dayPlanData - Day plan data from backend (timelineItems, conflicts)
26 + */
22 27 function renderTimeline(dayPlanDate, dayPlanData) {
23 28 const slotsContainer = document.getElementById('timeline-slots');
24 29 const itemsContainer = document.getElementById('timeline-items');
@@ -49,12 +54,12 @@
49 54
50 55 slotsHtml += `
51 56 <div class="timeline-slot${isHourStart ? ' hour-start' : ''}"
52 - data-time="${slotTimestamp}"
57 + data-time="${escAttr(slotTimestamp)}"
53 58 data-hour="${hour}"
54 59 data-slot-index="${hour * 4 + quarter}"
55 - onmousedown="GoingsOn.dayPlan.onPaintStart(event, ${hour * 4 + quarter}, '${slotTimestamp}')"
56 - onmouseenter="GoingsOn.dayPlan.onPaintMove(event, ${hour * 4 + quarter}, '${slotTimestamp}')"
57 - onclick="GoingsOn.dayPlan.onSlotTap(event, ${hour * 4 + quarter}, '${slotTimestamp}')">
60 + onmousedown="GoingsOn.dayPlan.onPaintStart(event, ${hour * 4 + quarter}, '${escAttr(slotTimestamp)}')"
61 + onmouseenter="GoingsOn.dayPlan.onPaintMove(event, ${hour * 4 + quarter}, '${escAttr(slotTimestamp)}')"
62 + onclick="GoingsOn.dayPlan.onSlotTap(event, ${hour * 4 + quarter}, '${escAttr(slotTimestamp)}')">
58 63 <div class="timeline-time">${isHourStart ? timeStr : ''}</div>
59 64 <div class="timeline-slot-area"></div>
60 65 </div>
@@ -111,6 +116,11 @@
111 116 itemsContainer.innerHTML = itemsHtml;
112 117 }
113 118
119 + /**
120 + * Render an unscheduled task item for the sidebar list.
121 + * @param {Object} task - Task object with id, description, priority, projectName
122 + * @returns {string} HTML string for the task item
123 + */
114 124 function renderUnscheduledTaskItem(task) {
115 125 return `
116 126 <div class="unscheduled-task priority-${task.priority.toLowerCase()}"
@@ -130,6 +140,12 @@
130 140 `;
131 141 }
132 142
143 + /**
144 + * Position the current-time indicator line and optionally scroll to it.
145 + * @param {Date} dayPlanDate - The date being displayed
146 + * @param {Function} formatDateForApi - (Date) => string formatter
147 + * @param {boolean} scrollToTime - true to scroll the timeline to current time
148 + */
133 149 function updateCurrentTimeIndicator(dayPlanDate, formatDateForApi, scrollToTime) {
134 150 const indicator = document.getElementById('timeline-current-time');
135 151 const timelineContainer = document.getElementById('timeline-container');
@@ -10,6 +10,10 @@
10 10
11 11 // ============ Schedule Task Modal ============
12 12
13 + /**
14 + * Open the schedule task modal with time slot picker and duration presets.
15 + * @param {string} id - Task ID to schedule
16 + */
13 17 function openScheduleTaskModal(id) {
14 18 const now = new Date();
15 19 const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
@@ -74,6 +78,11 @@
74 78 GoingsOn.ui.openModal('Schedule Time Block', content);
75 79 }
76 80
81 + /**
82 + * Select a quick time slot and update the datetime input.
83 + * @param {HTMLElement} btn - Clicked button element
84 + * @param {string} isoTime - ISO 8601 timestamp for the slot
85 + */
77 86 function selectTimeSlot(btn, isoTime) {
78 87 document.querySelectorAll('.time-block-quick-btn').forEach(b => b.classList.remove('selected'));
79 88 btn.classList.add('selected');
@@ -81,12 +90,21 @@
81 90 document.getElementById('schedule-datetime').value = datetime.toISOString().slice(0, 16);
82 91 }
83 92
93 + /**
94 + * Select a duration preset and update the hidden input.
95 + * @param {HTMLElement} btn - Clicked button element
96 + * @param {number} minutes - Duration in minutes
97 + */
84 98 function selectDuration(btn, minutes) {
85 99 document.querySelectorAll('.duration-preset').forEach(b => b.classList.remove('selected'));
86 100 btn.classList.add('selected');
87 101 document.getElementById('schedule-duration').value = minutes;
88 102 }
89 103
104 + /**
105 + * Submit the schedule task modal, creating the time block.
106 + * @param {string} id - Task ID to schedule
107 + */
90 108 async function scheduleTaskFromModal(id) {
91 109 const datetimeInput = document.getElementById('schedule-datetime');
92 110 const durationInput = document.getElementById('schedule-duration');