Skip to main content

max / goingson

Remove Rhai plugin runtime; native CSV import (Security axis) The plugin system existed only to run one first-party Rhai script (CSV import). That sandbox carried three serious findings — an unenforced database_write capability, no execution timeout (DoS), and no plugin provenance — for a single importer. Removing it deletes the entire attack surface instead of hardening it. - Delete crates/plugin-runtime (the Rhai engine, loader, registry, api), commands/plugin.rs, the rhai dependency, and plugins/csv-import. - Reimplement CSV/TSV import natively in commands/import.rs: parse via the csv crate, auto-detect entity type from headers, map fields to task/project/event, with the same preview/execute flow. CSV is the import interchange format; platform-specific converters live as separate tools. - Make read_import_file TOCTOU-safe and size-bounded, shared by the CSV and vCard/iCal importers (fixes S-IMP: import skipped the path/size validation export enforces). - Rename core module plugin -> import and drop the now-dead manifest types (PluginMeta, PluginType, ImportPluginConfig, ExportPluginConfig, PluginCapabilities, ImportProgress). - Rewrite the frontend import flow (choose file -> preview -> import; no plugin selection) and remove the Settings plugin manager. Fixes latent bugs in the old flow: it omitted the `input` arg wrapper and read snake_case keys, so it was broken end to end. - Update README/architecture/competition docs to drop the Rhai extensibility claim in favor of native CSV/vCard/iCal import. Resolves S-CAP, S-DOS, S-PROV by deletion; S-IMP by the shared safe reader. Workspace tests green (incl. 8 new CSV importer tests); new code clippy-clean.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-22 02:05 UTC
Signed with PGP, not checked
Commit: f75520064d8a49bd54987120a6907f034382c572
Parent: 278da60
29 files changed, +794 insertions, -5708 deletions
M Cargo.lock +2 -163
@@ -18,20 +18,6 @@
18 18 "generic-array",
19 19 ]
20 20
21 - [[package]]
22 - name = "ahash"
23 - version = "0.8.12"
24 - source = "registry+https://github.com/rust-lang/crates.io-index"
25 - checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75"
26 - dependencies = [
27 - "cfg-if",
28 - "const-random",
29 - "getrandom 0.3.4",
30 - "once_cell",
31 - "version_check",
32 - "zerocopy",
33 - ]
34 -
35 21 [[package]]
36 22 name = "aho-corasick"
37 23 version = "1.1.4"
@@ -694,26 +680,6 @@
694 680 source = "registry+https://github.com/rust-lang/crates.io-index"
695 681 checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
696 682
697 - [[package]]
698 - name = "const-random"
699 - version = "0.1.18"
700 - source = "registry+https://github.com/rust-lang/crates.io-index"
701 - checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359"
702 - dependencies = [
703 - "const-random-macro",
704 - ]
705 -
706 - [[package]]
707 - name = "const-random-macro"
708 - version = "0.1.16"
709 - source = "registry+https://github.com/rust-lang/crates.io-index"
710 - checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e"
711 - dependencies = [
712 - "getrandom 0.2.17",
713 - "once_cell",
714 - "tiny-keccak",
715 - ]
716 -
717 683 [[package]]
718 684 name = "convert_case"
719 685 version = "0.4.0"
@@ -837,12 +803,6 @@
837 803 source = "registry+https://github.com/rust-lang/crates.io-index"
838 804 checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
839 805
840 - [[package]]
841 - name = "crunchy"
842 - version = "0.2.4"
843 - source = "registry+https://github.com/rust-lang/crates.io-index"
844 - checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5"
845 -
846 806 [[package]]
847 807 name = "crypto-common"
848 808 version = "0.1.7"
@@ -1401,7 +1361,7 @@
1401 1361 dependencies = [
1402 1362 "futures-core",
1403 1363 "futures-sink",
1404 - "spin 0.9.8",
1364 + "spin",
1405 1365 ]
1406 1366
1407 1367 [[package]]
@@ -1922,7 +1882,6 @@
1922 1882 "futures-util",
1923 1883 "goingson-core",
1924 1884 "goingson-db-sqlite",
1925 - "goingson-plugin-runtime",
1926 1885 "iana-time-zone",
1927 1886 "ical",
1928 1887 "icalendar",
@@ -1962,26 +1921,6 @@
1962 1921 "uuid",
1963 1922 ]
1964 1923
1965 - [[package]]
1966 - name = "goingson-plugin-runtime"
1967 - version = "0.4.0"
1968 - dependencies = [
1969 - "async-trait",
1970 - "chrono",
1971 - "csv",
1972 - "goingson-core",
1973 - "notify",
1974 - "rhai",
1975 - "serde",
1976 - "serde_json",
1977 - "tempfile",
1978 - "thiserror 2.0.18",
1979 - "tokio",
1980 - "toml 0.8.2",
1981 - "tracing",
1982 - "uuid",
1983 - ]
1984 -
1985 1924 [[package]]
1986 1925 name = "gtk"
1987 1926 version = "0.18.2"
@@ -2771,7 +2710,7 @@
2771 2710 source = "registry+https://github.com/rust-lang/crates.io-index"
2772 2711 checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
2773 2712 dependencies = [
2774 - "spin 0.9.8",
2713 + "spin",
2775 2714 ]
2776 2715
2777 2716 [[package]]
@@ -3191,15 +3130,6 @@
3191 3130 source = "registry+https://github.com/rust-lang/crates.io-index"
3192 3131 checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
3193 3132
3194 - [[package]]
3195 - name = "no-std-compat"
3196 - version = "0.4.1"
3197 - source = "registry+https://github.com/rust-lang/crates.io-index"
3198 - checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c"
3199 - dependencies = [
3200 - "spin 0.5.2",
3201 - ]
3202 -
3203 3133 [[package]]
3204 3134 name = "nodrop"
3205 3135 version = "0.1.14"
@@ -3502,9 +3432,6 @@
3502 3432 version = "1.21.4"
3503 3433 source = "registry+https://github.com/rust-lang/crates.io-index"
3504 3434 checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
3505 - dependencies = [
3506 - "portable-atomic",
3507 - ]
3508 3435
3509 3436 [[package]]
3510 3437 name = "opaque-debug"
@@ -4049,12 +3976,6 @@
4049 3976 "universal-hash",
4050 3977 ]
4051 3978
4052 - [[package]]
4053 - name = "portable-atomic"
4054 - version = "1.13.1"
4055 - source = "registry+https://github.com/rust-lang/crates.io-index"
4056 - checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49"
4057 -
4058 3979 [[package]]
4059 3980 name = "potential_utf"
4060 3981 version = "0.1.4"
@@ -4539,36 +4460,6 @@
4539 4460 "windows-sys 0.60.2",
4540 4461 ]
4541 4462
4542 - [[package]]
4543 - name = "rhai"
4544 - version = "1.24.0"
4545 - source = "registry+https://github.com/rust-lang/crates.io-index"
4546 - checksum = "1f9ef5dabe4c0b43d8f1187dc6beb67b53fe607fff7e30c5eb7f71b814b8c2c1"
4547 - dependencies = [
4548 - "ahash",
4549 - "bitflags 2.11.0",
4550 - "no-std-compat",
4551 - "num-traits",
4552 - "once_cell",
4553 - "rhai_codegen",
4554 - "serde",
4555 - "smallvec",
4556 - "smartstring",
4557 - "thin-vec",
4558 - "web-time",
4559 - ]
4560 -
4561 - [[package]]
4562 - name = "rhai_codegen"
4563 - version = "3.1.0"
4564 - source = "registry+https://github.com/rust-lang/crates.io-index"
4565 - checksum = "d4322a2a4e8cf30771dd9f27f7f37ca9ac8fe812dddd811096a98483080dabe6"
4566 - dependencies = [
4567 - "proc-macro2",
4568 - "quote",
4569 - "syn 2.0.117",
4570 - ]
4571 -
4572 4463 [[package]]
4573 4464 name = "ring"
4574 4465 version = "0.17.14"
@@ -5202,18 +5093,6 @@
5202 5093 "serde",
5203 5094 ]
5204 5095
5205 - [[package]]
5206 - name = "smartstring"
5207 - version = "1.0.1"
5208 - source = "registry+https://github.com/rust-lang/crates.io-index"
5209 - checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29"
5210 - dependencies = [
5211 - "autocfg",
5212 - "serde",
5213 - "static_assertions",
5214 - "version_check",
5215 - ]
5216 -
5217 5096 [[package]]
5218 5097 name = "socket2"
5219 5098 version = "0.6.3"
@@ -5272,12 +5151,6 @@
5272 5151 "system-deps",
5273 5152 ]
5274 5153
5275 - [[package]]
5276 - name = "spin"
5277 - version = "0.5.2"
5278 - source = "registry+https://github.com/rust-lang/crates.io-index"
5279 - checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d"
5280 -
5281 5154 [[package]]
5282 5155 name = "spin"
5283 5156 version = "0.9.8"
@@ -5499,12 +5372,6 @@
5499 5372 source = "registry+https://github.com/rust-lang/crates.io-index"
5500 5373 checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
5501 5374
5502 - [[package]]
5503 - name = "static_assertions"
5504 - version = "1.1.0"
5505 - source = "registry+https://github.com/rust-lang/crates.io-index"
5506 - checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
5507 -
5508 5375 [[package]]
5509 5376 name = "stop-token"
5510 5377 version = "0.7.0"
@@ -6215,15 +6082,6 @@
6215 6082 "toml 0.8.2",
6216 6083 ]
6217 6084
6218 - [[package]]
6219 - name = "thin-vec"
6220 - version = "0.2.14"
6221 - source = "registry+https://github.com/rust-lang/crates.io-index"
6222 - checksum = "144f754d318415ac792f9d69fc87abbbfc043ce2ef041c60f16ad828f638717d"
6223 - dependencies = [
6224 - "serde",
6225 - ]
6226 -
6227 6085 [[package]]
6228 6086 name = "thiserror"
6229 6087 version = "1.0.69"
@@ -6304,15 +6162,6 @@
6304 6162 "time-core",
6305 6163 ]
6306 6164
6307 - [[package]]
6308 - name = "tiny-keccak"
6309 - version = "2.0.2"
6310 - source = "registry+https://github.com/rust-lang/crates.io-index"
6311 - checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237"
6312 - dependencies = [
6313 - "crunchy",
6314 - ]
6315 -
6316 6165 [[package]]
6317 6166 name = "tinystr"
6318 6167 version = "0.8.2"
@@ -7046,16 +6895,6 @@
7046 6895 "wasm-bindgen",
7047 6896 ]
7048 6897
7049 - [[package]]
7050 - name = "web-time"
7051 - version = "1.1.0"
7052 - source = "registry+https://github.com/rust-lang/crates.io-index"
7053 - checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb"
7054 - dependencies = [
7055 - "js-sys",
7056 - "wasm-bindgen",
7057 - ]
7058 -
7059 6898 [[package]]
7060 6899 name = "web_atoms"
7061 6900 version = "0.1.3"
M Cargo.toml +1 -4
@@ -2,7 +2,6 @@
2 2 members = [
3 3 "crates/core",
4 4 "crates/db-sqlite",
5 - "crates/plugin-runtime",
6 5 "src-tauri",
7 6 ]
8 7 default-members = ["src-tauri"]
@@ -61,8 +60,7 @@
61 60 tauri-plugin-updater = "2"
62 61 tauri-plugin-process = "2"
63 62
64 - # Plugin system
65 - rhai = { version = "1.17", features = ["sync", "serde"] }
63 + # Filesystem watching (db change notifications)
66 64 notify = "6.0"
67 65 notify-debouncer-mini = "0.4"
68 66 theme-common = { path = "../../MNW/shared/theme-common" }
@@ -98,4 +96,3 @@
98 96 # Internal crates
99 97 goingson-core = { path = "crates/core" }
100 98 goingson-db-sqlite = { path = "crates/db-sqlite" }
101 - goingson-plugin-runtime = { path = "crates/plugin-runtime" }
M README.md +3 -4
@@ -50,10 +50,9 @@
50 50 |-------|------|------|
51 51 | `goingson-core` | `crates/core/` | Domain models, repository traits, error types, business logic. No database dependency (optional sqlx feature for type derives). |
52 52 | `goingson-db-sqlite` | `crates/db-sqlite/` | SQLite persistence via sqlx. Repository implementations, FTS5 full-text search, migrations. |
53 - | `goingson-plugin-runtime` | `crates/plugin-runtime/` | Rhai scripting engine for import plugins (CSV, custom formats). File watching for hot-reload. |
54 - | `goingson-desktop` | `src-tauri/` | Tauri 2 desktop shell. Commands (thin wrappers over library crates), frontend (vanilla HTML/CSS/JS), OAuth flows, email sync, SyncKit integration. |
53 + | `goingson-desktop` | `src-tauri/` | Tauri 2 desktop shell. Commands (thin wrappers over library crates), native CSV/vCard/iCal import, frontend (vanilla HTML/CSS/JS), OAuth flows, email sync, SyncKit integration. |
55 54
56 - Dependency flow: `core` is leaf -> `db-sqlite` and `plugin-runtime` depend on `core` -> `src-tauri` depends on all three plus `synckit-client`.
55 + Dependency flow: `core` is leaf -> `db-sqlite` depends on `core` -> `src-tauri` depends on both plus `synckit-client`.
57 56
58 57 ## Features
59 58
@@ -64,7 +63,7 @@
64 63 - **Projects** -- per-project dashboards (tasks + events + emails), milestones
65 64 - **Search** -- FTS5 full-text search across all entity types
66 65 - **Cloud sync** -- SyncKit integration with E2E encryption
67 - - **Plugins** -- Rhai scripting for CSV/data import
66 + - **Import** -- native CSV/TSV import (entity type auto-detected), plus vCard and iCalendar
68 67 - **Themes** -- built-in light and dark themes with system auto-detection (see `src-tauri/frontend/themes/helix/`)
69 68 - **Keyboard shortcuts** -- vim-style navigation throughout
70 69 - **Platforms** -- macOS (primary), Windows, Linux; iOS in development
@@ -2,7 +2,7 @@
2 2
3 3 Email, calendar, tasks in one place. Project management for individuals and small teams.
4 4
5 - A Rust-based productivity application built with Tauri 2 (Rust backend + Vanilla JS frontend), SQLite with sqlx 0.8, and a "Neobrute" design aesthetic. 3 crates: `core` (domain models), `db-sqlite` (repository), `plugin-runtime` (Rhai plugins).
5 + A Rust-based productivity application built with Tauri 2 (Rust backend + Vanilla JS frontend), SQLite with sqlx 0.8, and a "Neobrute" design aesthetic. 2 crates: `core` (domain models), `db-sqlite` (repository).
6 6
7 7 ## High-Level Overview
8 8
@@ -31,9 +31,6 @@
31 31 │ sqlite │
32 32 │ (Desktop) │
33 33 └──────────────────┘
34 -
35 - Additional crates:
36 - plugin-runtime Rhai plugin system (import plugins)
37 34 ```
38 35
39 36 ## Workspace Structure
@@ -42,8 +39,7 @@
42 39 goingson/
43 40 ├── crates/
44 41 │ ├── core/ # Domain models, traits, business logic
45 - │ ├── db-sqlite/ # SQLite repository implementations
46 - │ └── plugin-runtime/ # Rhai plugin system
42 + │ └── db-sqlite/ # SQLite repository implementations
47 43 ├── src-tauri/ # Tauri desktop app (single-user)
48 44 └── migrations/
49 45 └── sqlite/ # SQLite schema migrations (33 files)
@@ -53,10 +49,8 @@
53 49
54 50 ```
55 51 goingson-desktop (src-tauri)
56 - ├── goingson-db-sqlite
57 - │ └── goingson-core
58 - └── plugin-runtime
59 - └── goingson-core (for shared types)
52 + └── goingson-db-sqlite
53 + └── goingson-core
60 54 ```
61 55
62 56 ## Core Crate (`crates/core/`)
@@ -165,7 +159,7 @@
165 159 ├── weekly_review.rs # Weekly review workflow
166 160 ├── saved_views.rs # Custom filter views
167 161 ├── milestone.rs # Milestone CRUD and reordering
168 - ├── plugin.rs # Plugin registry, hot-reload, import
162 + ├── import.rs # Native CSV/TSV import (preview + execute)
169 163 ├── oauth.rs # OAuth2 flows (Fastmail, Google, Microsoft)
170 164 ├── export.rs # JSON, CSV, ICS export; backup/restore
171 165 ├── sync.rs # Cloud sync via SyncKit
@@ -18,7 +18,6 @@
18 18 [dependencies]
19 19 goingson-core = { workspace = true }
20 20 goingson-db-sqlite = { workspace = true }
21 - goingson-plugin-runtime = { workspace = true }
22 21 synckit-client = { path = "../../../MNW/shared/synckit-client" }
23 22
24 23 # Tauri
@@ -567,7 +567,6 @@
567 567 <button class="settings-nav-item" data-section="notifications" onclick="GoingsOn.settings.showSection('notifications')">Notifications</button>
568 568 <button class="settings-nav-item" data-section="email" onclick="GoingsOn.settings.showSection('email')">Email Accounts</button>
569 569 <button class="settings-nav-item" data-section="planning" onclick="GoingsOn.settings.showSection('planning')">Planning &amp; Review</button>
570 - <button class="settings-nav-item" data-section="plugins" onclick="GoingsOn.settings.showSection('plugins')">Plugins</button>
571 570 <button class="settings-nav-item" data-section="sync" onclick="GoingsOn.settings.showSection('sync')">Cloud Sync</button>
572 571 <button class="settings-nav-item" data-section="data" onclick="GoingsOn.settings.showSection('data')">Import &amp; Export</button>
573 572 <button class="settings-nav-item" data-section="about" onclick="GoingsOn.settings.showSection('about')">About</button>
@@ -263,15 +263,9 @@
263 263 $crate::commands::get_custom_themes_dir,
264 264 $crate::commands::import_theme,
265 265 $crate::commands::export_theme,
266 - // Plugins
267 - $crate::commands::list_import_plugins,
268 - $crate::commands::list_enabled_import_plugins,
269 - $crate::commands::get_plugins_for_extension,
266 + // Import (CSV/TSV)
270 267 $crate::commands::preview_import,
271 268 $crate::commands::execute_import,
272 - $crate::commands::enable_plugin,
273 - $crate::commands::disable_plugin,
274 - $crate::commands::reload_plugin,
275 269 // Import (VCF/ICS)
276 270 $crate::commands::preview_vcf,
277 271 $crate::commands::import_vcf,
@@ -295,8 +289,6 @@
295 289 /// Build the Tauri app with all commands registered.
296 290 /// Shared between desktop `main()` and mobile entry point.
297 291 pub fn build_mobile_app() -> tauri::Builder<tauri::Wry> {
298 - use commands::PluginState;
299 - use goingson_plugin_runtime::PluginRegistry;
300 292 use state::AppState;
301 293 use std::sync::Arc;
302 294 use tauri::Manager;
@@ -327,17 +319,6 @@
327 319 app_handle.manage(Arc::new(state));
328 320 });
329 321
330 - // Initialize plugin system
331 - let config_dir = app.path().app_config_dir()
332 - .expect("Failed to get config dir");
333 - let plugins_dir = config_dir.join("plugins");
334 - let mut plugin_registry = PluginRegistry::new(&plugins_dir)
335 - .expect("Failed to initialize plugin registry");
336 - if let Err(e) = plugin_registry.initialize() {
337 - tracing::warn!("Failed to load some plugins: {}", e);
338 - }
339 - app.manage(PluginState::new(plugin_registry));
340 -
341 322 // Background services that work on all platforms
342 323 let cancel_token = CancellationToken::new();
343 324 app.manage(cancel_token.clone());
@@ -7,9 +7,7 @@
7 7 use goingson_desktop::commands;
8 8 use goingson_desktop::email_sync_scheduler;
9 9 use goingson_desktop::sync_scheduler;
10 - use goingson_desktop::commands::PluginState;
11 10 use goingson_desktop::state::AppState;
12 - use goingson_plugin_runtime::PluginRegistry;
13 11 use std::sync::Arc;
14 12 use std::sync::atomic::{AtomicBool, Ordering};
15 13 use tauri::menu::{Menu, MenuItem, PredefinedMenuItem, Submenu};
@@ -312,20 +310,6 @@
312 310 app_handle.manage(Arc::new(state));
313 311 });
314 312
315 - // Initialize plugin system
316 - let config_dir = app.path().app_config_dir()
317 - .expect("Failed to get config dir");
318 - let plugins_dir = config_dir.join("plugins");
319 - let mut plugin_registry = PluginRegistry::new(&plugins_dir)
320 - .expect("Failed to initialize plugin registry");
321 -
322 - // Load enabled plugins
323 - if let Err(e) = plugin_registry.initialize() {
324 - tracing::warn!("Failed to load some plugins: {}", e);
325 - }
326 -
327 - app.manage(PluginState::new(plugin_registry));
328 -
329 313 // Create shutdown coordination handles
330 314 let cancel_token = CancellationToken::new();
331 315 let db_watcher_shutdown = Arc::new(AtomicBool::new(false));
@@ -42,7 +42,7 @@
42 42 pub mod id_types;
43 43 pub mod models;
44 44 pub mod parser;
45 - pub mod plugin;
45 + pub mod import;
46 46 pub mod recurrence;
47 47 pub mod repository;
48 48 pub mod search_parser;
@@ -83,10 +83,9 @@
83 83 pub use recurrence::{calculate_next_due, calculate_next_due_in_tz, calculate_next_due_with_day, calculate_next_due_with_day_in_tz, calculate_next_due_rich, calculate_next_due_rich_in_tz, expand_recurrence, expand_recurrence_in_tz, should_recur};
84 84 pub use repository::*;
85 85 pub use urgency::calculate_urgency;
86 - pub use plugin::{
87 - ExportPluginConfig, ImportEntityType, ImportExecuteResult, ImportFailure, ImportItem,
88 - ImportItemData, ImportOptions, ImportParseResult, ImportPluginConfig, ImportProgress,
89 - ImportProjectData, ImportTaskData, ImportEventData, PluginCapabilities, PluginMeta, PluginType,
86 + pub use import::{
87 + ImportEntityType, ImportEventData, ImportExecuteResult, ImportFailure, ImportItem,
88 + ImportItemData, ImportOptions, ImportParseResult, ImportProjectData, ImportTaskData,
90 89 };
91 90 pub use email_id::deterministic_email_id;
92 91 pub use validation::Validate;
@@ -328,16 +328,11 @@
328 328 setUpdateCheckOnLaunch: (enabled) => invoke('set_update_check_on_launch', { enabled }),
329 329 },
330 330
331 - // Plugins — Rhai-based import plugin management
332 - plugins: {
333 - listImport: () => invoke('list_import_plugins'), // All available (enabled + disabled)
334 - listEnabled: () => invoke('list_enabled_import_plugins'),
335 - getForExtension: (extension) => invoke('get_plugins_for_extension', { extension }), // Match file → compatible plugins
336 - preview: (pluginId, filePath, options = {}) => invoke('preview_import', { pluginId, filePath, options }), // Dry-run parse
337 - execute: (pluginId, filePath, options = {}) => invoke('execute_import', { pluginId, filePath, options }), // Create entities in DB
338 - enable: (pluginId) => invoke('enable_plugin', { pluginId }), // Symlink available/ → enabled/
339 - disable: (pluginId) => invoke('disable_plugin', { pluginId }),
340 - reload: (pluginId) => invoke('reload_plugin', { pluginId }), // Re-read + recompile from disk
331 + // Import — native CSV/TSV import (entity type auto-detected from headers)
332 + import: {
333 + preview: (filePath, options = {}) => invoke('preview_import', { input: { filePath, options } }), // Dry-run parse
334 + execute: (filePath, options = {}, selectedIndices = []) =>
335 + invoke('execute_import', { input: { filePath, options, selectedIndices } }), // Create entities in DB
341 336 },
342 337
343 338 // Attachments — file attachments on tasks and projects
@@ -1,55 +1,36 @@
1 1 /**
2 2 * GoingsOn - Import Module
3 - * Handles importing data from files using plugins.
3 + *
4 + * Imports tasks, projects, or events from a CSV/TSV file. The entity type is
5 + * auto-detected from the header columns by the native importer (Rust). CSV is
6 + * GoingsOn's import interchange format; convert other tools' exports to CSV
7 + * with a separate converter, then bring them in here.
4 8 */
5 9
6 10 (function() {
7 11 'use strict';
8 12 const esc = GoingsOn.utils.escapeHtml;
9 - const escAttr = GoingsOn.utils.escapeAttrValue;
10 13 const escAttrVal = GoingsOn.utils.escapeAttrValue;
11 14
12 15 // ============ State ============
13 16
14 - let availablePlugins = [];
15 - let selectedPlugin = null;
16 17 let selectedFilePath = null;
17 18 let previewData = null;
18 19
19 20 // ============ Import Wizard ============
20 21
21 22 /**
22 - * Opens the import wizard modal.
23 + * Opens the import modal (choose file, then preview and confirm).
23 24 */
24 - async function openImportModal() {
25 - // Reset state
26 - selectedPlugin = null;
25 + function openImportModal() {
27 26 selectedFilePath = null;
28 27 previewData = null;
29 28
30 - // Load available plugins
31 - try {
32 - availablePlugins = await GoingsOn.api.plugins.listEnabled();
33 - } catch (err) {
34 - GoingsOn.ui.showToast('Failed to load import plugins: ' + GoingsOn.utils.getErrorMessage(err), 'error');
35 - return;
36 - }
37 -
38 - if (availablePlugins.length === 0) {
39 - GoingsOn.ui.showToast('No import plugins available. Enable plugins in Settings.', 'info');
40 - return;
41 - }
42 -
43 29 const content = `
44 30 <div class="import-wizard">
45 - <div class="import-step" id="import-step-1">
46 - <h3>1. Select Import Source</h3>
47 - <div class="plugin-selector">
48 - ${availablePlugins.map(p => renderPluginOption(p)).join('')}
49 - </div>
50 - </div>
51 - <div class="import-step hidden" id="import-step-2">
52 - <h3>2. Select File</h3>
31 + <div class="import-step" id="import-step-file">
32 + <h3>1. Choose a CSV or TSV file</h3>
33 + <p class="import-hint">Columns are matched by name (description, due, priority, project, tags for tasks; start/end for events; name/type for projects).</p>
53 34 <div class="file-selector">
54 35 <button class="btn btn-primary" id="select-file-btn" onclick="GoingsOn.import.selectFile()">
55 36 Choose File...
@@ -57,8 +38,8 @@
57 38 <span id="selected-file-name" class="selected-file-name"></span>
58 39 </div>
59 40 </div>
60 - <div class="import-step hidden" id="import-step-3">
61 - <h3>3. Preview</h3>
41 + <div class="import-step hidden" id="import-step-preview">
42 + <h3>2. Preview</h3>
62 43 <div id="import-preview-container" class="import-preview-container">
63 44 <div class="loading">Loading preview...</div>
64 45 </div>
@@ -72,74 +53,18 @@
72 53 </div>
73 54 `;
74 55
75 - GoingsOn.ui.openModal('Import Data', content);
56 + GoingsOn.ui.openModal('Import from CSV', content);
76 57 }
77 58
78 59 /**
79 - * Render a plugin option button for the import wizard.
80 - * @param {Object} plugin - Plugin object with id, name, description, plugin_type
81 - * @returns {string} HTML string for the plugin option
82 - */
83 - function renderPluginOption(plugin) {
84 - const config = plugin.plugin_type?.import || {};
85 - const extensions = config.file_extensions || [];
86 - const entityTypes = config.entity_types || [];
87 -
88 - return `
89 - <button class="plugin-option" data-plugin-id="${escAttr(plugin.id)}" onclick="GoingsOn.import.selectPlugin('${escAttr(plugin.id)}')">
90 - <span class="plugin-name">${esc(plugin.name)}</span>
91 - <span class="plugin-meta">
92 - ${extensions.length > 0 ? `<span class="plugin-extensions">.${extensions.join(', .')}</span>` : ''}
93 - ${entityTypes.length > 0 ? `<span class="plugin-types">${entityTypes.join(', ')}</span>` : ''}
94 - </span>
95 - <span class="plugin-description">${esc(plugin.description)}</span>
96 - </button>
97 - `;
98 - }
99 -
100 - /**
101 - * Select a plugin in the import wizard and advance to file selection.
102 - * @param {string} pluginId - Plugin ID to select
103 - */
104 - function selectPlugin(pluginId) {
105 - selectedPlugin = availablePlugins.find(p => p.id === pluginId);
106 - if (!selectedPlugin) return;
107 -
108 - // Update UI - highlight selected plugin
109 - document.querySelectorAll('.plugin-option').forEach(el => {
110 - el.classList.remove('selected');
111 - });
112 - document.querySelector(`.plugin-option[data-plugin-id="${pluginId}"]`)?.classList.add('selected');
113 -
114 - // Show step 2
115 - document.getElementById('import-step-2').classList.remove('hidden');
116 -
117 - // Reset file selection
118 - selectedFilePath = null;
119 - previewData = null;
120 - document.getElementById('selected-file-name').textContent = '';
121 - document.getElementById('import-step-3').classList.add('hidden');
122 - document.getElementById('import-confirm-btn').disabled = true;
123 - }
124 -
125 - /**
126 - * Opens the file dialog to select a file.
60 + * Opens the file dialog, then loads a preview.
127 61 */
128 62 async function selectFile() {
129 - if (!selectedPlugin) return;
130 -
131 - const config = selectedPlugin.plugin_type?.import || {};
132 - const extensions = config.file_extensions || ['*'];
133 -
134 63 try {
135 - // Use Tauri's file dialog
136 64 const { open } = window.__TAURI__.dialog;
137 65 const filePath = await open({
138 66 multiple: false,
139 - filters: [{
140 - name: selectedPlugin.name,
141 - extensions: extensions,
142 - }],
67 + filters: [{ name: 'CSV / TSV', extensions: ['csv', 'tsv'] }],
143 68 });
144 69
145 70 if (!filePath) return; // User cancelled
@@ -147,8 +72,7 @@
147 72 selectedFilePath = filePath;
148 73 document.getElementById('selected-file-name').textContent = getFileName(filePath);
149 74
150 - // Show step 3 and load preview
151 - document.getElementById('import-step-3').classList.remove('hidden');
75 + document.getElementById('import-step-preview').classList.remove('hidden');
152 76 await loadPreview();
153 77 } catch (err) {
154 78 GoingsOn.ui.showToast('Failed to select file: ' + GoingsOn.utils.getErrorMessage(err), 'error');
@@ -170,10 +94,14 @@
170 94 container.innerHTML = '<div class="loading">Parsing file...</div>';
171 95
172 96 try {
173 - previewData = await GoingsOn.api.plugins.preview(selectedPlugin.id, selectedFilePath, {});
97 + previewData = await GoingsOn.api.import.preview(selectedFilePath);
174 98
175 99 if (!previewData.items || previewData.items.length === 0) {
176 - container.innerHTML = '<p class="import-empty">No items found in file.</p>';
100 + const warnings = previewData.warnings || [];
101 + container.innerHTML = `<p class="import-empty">No items found in file.</p>`;
102 + if (warnings.length > 0) {
103 + container.insertAdjacentHTML('beforeend', renderWarnings(warnings));
104 + }
177 105 document.getElementById('import-confirm-btn').disabled = true;
178 106 return;
179 107 }
@@ -181,15 +109,8 @@
181 109 container.innerHTML = renderPreviewTable(previewData);
182 110 document.getElementById('import-confirm-btn').disabled = false;
183 111
184 - // Show warnings if any
185 112 if (previewData.warnings && previewData.warnings.length > 0) {
186 - const warningsHtml = `
187 - <div class="import-warnings">
188 - <strong>Warnings:</strong>
189 - <ul>${previewData.warnings.map(w => `<li>${esc(w)}</li>`).join('')}</ul>
190 - </div>
191 - `;
192 - container.insertAdjacentHTML('beforeend', warningsHtml);
113 + container.insertAdjacentHTML('beforeend', renderWarnings(previewData.warnings));
193 114 }
194 115 } catch (err) {
195 116 container.innerHTML = `<p class="import-error">Failed to parse file: ${esc(GoingsOn.utils.getErrorMessage(err))}</p>`;
@@ -197,29 +118,37 @@
197 118 }
198 119 }
199 120
121 + function renderWarnings(warnings) {
122 + return `
123 + <div class="import-warnings">
124 + <strong>Warnings:</strong>
125 + <ul>${warnings.map(w => `<li>${esc(w)}</li>`).join('')}</ul>
126 + </div>
127 + `;
128 + }
129 +
200 130 /**
201 131 * Renders the preview table.
202 132 */
203 133 function renderPreviewTable(data) {
204 - const entityType = data.entity_type || 'item';
134 + const entityType = data.entityType || 'item';
205 135 const items = data.items || [];
206 136 const maxPreview = 25;
207 137 const displayItems = items.slice(0, maxPreview);
208 138
209 - // Determine columns based on entity type
210 139 const columns = getColumnsForEntityType(entityType);
211 140
212 141 return `
213 142 <p class="import-summary">
214 - <strong>${items.length}</strong> ${entityType}${items.length !== 1 ? 's' : ''} found
143 + <strong>${items.length}</strong> ${esc(entityType)}${items.length !== 1 ? 's' : ''} found
215 144 </p>
216 145 <div class="import-preview-table-wrapper">
217 146 <table class="data-table import-preview-table">
218 147 <thead>
219 - <tr>${columns.map(c => `<th>${c.label}</th>`).join('')}</tr>
148 + <tr>${columns.map(c => `<th>${esc(c.label)}</th>`).join('')}</tr>
220 149 </thead>
221 150 <tbody>
222 - ${displayItems.map((item, idx) => renderPreviewRow(item, columns, idx)).join('')}
151 + ${displayItems.map(item => renderPreviewRow(item, columns)).join('')}
223 152 </tbody>
224 153 </table>
225 154 </div>
@@ -228,14 +157,15 @@
228 157 }
229 158
230 159 /**
231 - * Gets the columns to display based on entity type.
160 + * Columns to display per entity type. Keys are the camelCase fields the
161 + * native importer emits on each item's `data` object.
232 162 */
233 163 function getColumnsForEntityType(entityType) {
234 164 switch (entityType) {
235 165 case 'task':
236 166 return [
237 167 { key: 'description', label: 'Description' },
238 - { key: 'project_name', label: 'Project' },
168 + { key: 'projectName', label: 'Project' },
239 169 { key: 'priority', label: 'Priority' },
240 170 { key: 'due', label: 'Due' },
241 171 ];
@@ -243,7 +173,7 @@
243 173 return [
244 174 { key: 'name', label: 'Name' },
245 175 { key: 'description', label: 'Description' },
246 - { key: 'project_type', label: 'Type' },
176 + { key: 'projectType', label: 'Type' },
247 177 { key: 'status', label: 'Status' },
248 178 ];
249 179 case 'event':
@@ -254,16 +184,14 @@
254 184 { key: 'location', label: 'Location' },
255 185 ];
256 186 default:
257 - return [
258 - { key: 'description', label: 'Description' },
259 - ];
187 + return [{ key: 'description', label: 'Description' }];
260 188 }
261 189 }
262 190
263 191 /**
264 192 * Renders a preview table row.
265 193 */
266 - function renderPreviewRow(item, columns, index) {
194 + function renderPreviewRow(item, columns) {
267 195 const data = item.data || {};
268 196 return `
269 197 <tr>
@@ -275,9 +203,6 @@
275 203 `;
276 204 }
277 205
278 - /**
279 - * Truncates a string to a maximum length.
280 - */
281 206 function truncate(str, maxLen) {
282 207 if (!str) return '';
283 208 str = String(str);
@@ -288,25 +213,24 @@
288 213 * Executes the import.
289 214 */
290 215 async function executeImport() {
291 - if (!selectedPlugin || !selectedFilePath || !previewData) return;
216 + if (!selectedFilePath || !previewData) return;
292 217
293 218 const btn = document.getElementById('import-confirm-btn');
294 219 btn.disabled = true;
295 220 btn.textContent = 'Importing...';
296 221
297 222 try {
298 - const result = await GoingsOn.api.plugins.execute(selectedPlugin.id, selectedFilePath, {});
223 + const result = await GoingsOn.api.import.execute(selectedFilePath);
299 224
300 225 GoingsOn.ui.closeModal();
301 226
302 - const entityType = previewData.entity_type || 'item';
303 - if (result.failed_count > 0) {
304 - GoingsOn.ui.showToast(`Imported ${result.imported_count} ${entityType}(s), ${result.failed_count} failed`, 'warning');
227 + const entityType = previewData.entityType || 'item';
228 + if (result.failedCount > 0) {
229 + GoingsOn.ui.showToast(`Imported ${result.importedCount} ${entityType}(s), ${result.failedCount} failed`, 'warning');
305 230 } else {
306 - GoingsOn.ui.showToast(`Successfully imported ${result.imported_count} ${entityType}(s)!`, 'success');
231 + GoingsOn.ui.showToast(`Successfully imported ${result.importedCount} ${entityType}(s)!`, 'success');
307 232 }
308 233
309 - // Refresh the current view
310 234 if (typeof GoingsOn.navigation !== 'undefined' && GoingsOn.navigation.reloadCurrentView) {
311 235 GoingsOn.navigation.reloadCurrentView();
312 236 }
@@ -321,7 +245,6 @@
321 245
322 246 GoingsOn.import = {
323 247 openModal: openImportModal,
324 - selectPlugin,
325 248 selectFile,
326 249 executeImport,
327 250 };