feat(storage): B2b — settings/profile schema + templates SQLite persistence
Settings: 8 new fields (lastLaunchAt, reentryFreshStartUntil,
lastActiveProfileId, microStepGranularity, nudgeMode, nudgeDigestTimes,
sparklineRangeDays, energyLabels) with sane defaults; nudgesEnabled
auto-maps to nudgeMode on first load (true→immediate, false→off).
Profile: optional energyLabelsOverride field on the localStorage
Profile shape — when present, overrides the global energyLabels;
absent/null falls through to settings.energyLabels via the new
resolveEnergyLabels helper.
Templates: migration v17 adds the templates table (id PK, name,
sections JSON, created_at, updated_at). 5 storage CRUD functions
plus import_templates with idempotent duplicate-id handling. 5 Tauri
commands (list/create/update/delete/import) wired and registered.
Frontend store rewired to read from SQLite via list_templates_cmd;
one-time migration imports kon_templates localStorage and clears it
on success. Fresh installs only seed the new {Meeting notes, Daily
check-in} defaults. Existing user templates survive the migration
verbatim.
Test coverage: 5 new Rust tests (CRUD, import idempotency, atomic
failure, updated_at bump, missing-id delete is no-op).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -839,6 +839,154 @@ pub async fn import_task_lists(
|
|||||||
Ok(ImportSummary { imported, skipped })
|
Ok(ImportSummary { imported, skipped })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- B2b: templates CRUD ------------------------------------------------
|
||||||
|
//
|
||||||
|
// Migration v17 added the templates table. Same pattern as task_lists:
|
||||||
|
// the storage layer owns the SQL, the Tauri command layer owns the
|
||||||
|
// JSON-array (de)serialisation for the `sections` column. Sections are
|
||||||
|
// stored as JSON text rather than a join table — they're small
|
||||||
|
// (a handful of short labels), order-significant, and never queried by
|
||||||
|
// section text.
|
||||||
|
|
||||||
|
pub async fn list_templates(pool: &SqlitePool) -> Result<Vec<TemplateRow>> {
|
||||||
|
let rows = sqlx::query(
|
||||||
|
"SELECT id, name, sections, created_at, updated_at \
|
||||||
|
FROM templates ORDER BY created_at ASC, id ASC",
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("List templates failed: {e}")))?;
|
||||||
|
Ok(rows.iter().map(template_row_from).collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_template(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: &str,
|
||||||
|
name: &str,
|
||||||
|
sections_json: &str,
|
||||||
|
) -> Result<TemplateRow> {
|
||||||
|
sqlx::query("INSERT INTO templates (id, name, sections) VALUES (?, ?, ?)")
|
||||||
|
.bind(id)
|
||||||
|
.bind(name)
|
||||||
|
.bind(sections_json)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Create template failed: {e}")))?;
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT id, name, sections, created_at, updated_at FROM templates WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Refetch template failed: {e}")))?;
|
||||||
|
Ok(template_row_from(&row))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Patch-style update. `name` and `sections_json` use COALESCE so `None`
|
||||||
|
/// preserves the existing column. `updated_at` always advances to
|
||||||
|
/// `datetime('now')` so callers can detect mutations after the fact (the
|
||||||
|
/// frontend uses this for cache invalidation; the bump test asserts it).
|
||||||
|
pub async fn update_template(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: &str,
|
||||||
|
name: Option<&str>,
|
||||||
|
sections_json: Option<&str>,
|
||||||
|
) -> Result<TemplateRow> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE templates \
|
||||||
|
SET name = COALESCE(?, name), \
|
||||||
|
sections = COALESCE(?, sections), \
|
||||||
|
updated_at = datetime('now') \
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(name)
|
||||||
|
.bind(sections_json)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Update template failed: {e}")))?;
|
||||||
|
|
||||||
|
let row = sqlx::query(
|
||||||
|
"SELECT id, name, sections, created_at, updated_at FROM templates WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Refetch template failed: {e}")))?
|
||||||
|
.ok_or_else(|| {
|
||||||
|
KonError::StorageError(format!("update_template: template {id} not found after update"))
|
||||||
|
})?;
|
||||||
|
Ok(template_row_from(&row))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Delete is a no-op when the id is missing — matches the contract for
|
||||||
|
/// task_lists/transcripts and lets the frontend issue an idempotent
|
||||||
|
/// "delete then maybe re-create" sequence without special-casing missing
|
||||||
|
/// rows.
|
||||||
|
pub async fn delete_template(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||||
|
sqlx::query("DELETE FROM templates WHERE id = ?")
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Delete template failed: {e}")))?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bulk import for the localStorage → SQLite migration. Same idempotency
|
||||||
|
/// contract as `import_task_lists`: pre-existing IDs skip, duplicate IDs
|
||||||
|
/// within the input batch hard-fail and roll back.
|
||||||
|
pub async fn import_templates(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
items: &[TemplateRow],
|
||||||
|
) -> Result<ImportSummary> {
|
||||||
|
let mut tx = pool
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Begin transaction failed: {e}")))?;
|
||||||
|
|
||||||
|
let mut imported = 0usize;
|
||||||
|
let mut skipped = 0usize;
|
||||||
|
let mut seen_in_batch: std::collections::HashSet<String> = std::collections::HashSet::new();
|
||||||
|
|
||||||
|
for item in items {
|
||||||
|
if !seen_in_batch.insert(item.id.clone()) {
|
||||||
|
return Err(KonError::StorageError(format!(
|
||||||
|
"import_templates: duplicate id '{}' within batch",
|
||||||
|
item.id
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
let exists: Option<i64> = sqlx::query_scalar("SELECT 1 FROM templates WHERE id = ?")
|
||||||
|
.bind(&item.id)
|
||||||
|
.fetch_optional(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Check template exists failed: {e}")))?;
|
||||||
|
|
||||||
|
if exists.is_some() {
|
||||||
|
skipped += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sqlx::query("INSERT INTO templates (id, name, sections) VALUES (?, ?, ?)")
|
||||||
|
.bind(&item.id)
|
||||||
|
.bind(&item.name)
|
||||||
|
.bind(&item.sections_json)
|
||||||
|
.execute(&mut *tx)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
KonError::StorageError(format!("Insert template during import failed: {e}"))
|
||||||
|
})?;
|
||||||
|
imported += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("Commit transaction failed: {e}")))?;
|
||||||
|
|
||||||
|
Ok(ImportSummary { imported, skipped })
|
||||||
|
}
|
||||||
|
|
||||||
// --- Phase 8: daily completion counts -----------------------------------
|
// --- Phase 8: daily completion counts -----------------------------------
|
||||||
//
|
//
|
||||||
// Drives the Tasks-page badge ("3 today") and the 7-day momentum
|
// Drives the Tasks-page badge ("3 today") and the 7-day momentum
|
||||||
@@ -1142,6 +1290,19 @@ pub struct TaskListRow {
|
|||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// B2b — templates row. Mirrors the `templates` table introduced in
|
||||||
|
/// migration v17. `sections_json` is a JSON-encoded array of strings;
|
||||||
|
/// the storage layer keeps it opaque and the Tauri command layer
|
||||||
|
/// (de)serialises into the frontend-facing `Vec<String>` representation.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct TemplateRow {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub sections_json: String,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of `import_task_lists` — how many rows landed and how many were
|
/// Result of `import_task_lists` — how many rows landed and how many were
|
||||||
/// skipped because their `id` already exists. The whole batch runs in one
|
/// skipped because their `id` already exists. The whole batch runs in one
|
||||||
/// transaction so a duplicate inside the batch (rather than against the
|
/// transaction so a duplicate inside the batch (rather than against the
|
||||||
@@ -1241,6 +1402,16 @@ fn task_list_row_from(r: &sqlx::sqlite::SqliteRow) -> TaskListRow {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn template_row_from(r: &sqlx::sqlite::SqliteRow) -> TemplateRow {
|
||||||
|
TemplateRow {
|
||||||
|
id: r.get("id"),
|
||||||
|
name: r.get("name"),
|
||||||
|
sections_json: r.get("sections"),
|
||||||
|
created_at: r.get("created_at"),
|
||||||
|
updated_at: r.get("updated_at"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn implementation_rule_row_from(r: sqlx::sqlite::SqliteRow) -> ImplementationRuleRow {
|
fn implementation_rule_row_from(r: sqlx::sqlite::SqliteRow) -> ImplementationRuleRow {
|
||||||
ImplementationRuleRow {
|
ImplementationRuleRow {
|
||||||
id: r.get("id"),
|
id: r.get("id"),
|
||||||
@@ -3145,4 +3316,173 @@ mod tests {
|
|||||||
"WHERE archived = 0 must skip the already-archived row"
|
"WHERE archived = 0 must skip the already-archived row"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- B2b: templates CRUD tests -------------------------------------
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn template_crud_roundtrip() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
|
||||||
|
// Baseline: fresh DB has no templates.
|
||||||
|
let baseline = list_templates(&pool).await.unwrap();
|
||||||
|
assert!(baseline.is_empty(), "fresh DB has no templates");
|
||||||
|
|
||||||
|
let created = create_template(
|
||||||
|
&pool,
|
||||||
|
"tpl-1",
|
||||||
|
"Meeting notes",
|
||||||
|
r#"["Action items","Decisions"]"#,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(created.id, "tpl-1");
|
||||||
|
assert_eq!(created.name, "Meeting notes");
|
||||||
|
assert_eq!(created.sections_json, r#"["Action items","Decisions"]"#);
|
||||||
|
assert!(!created.created_at.is_empty(), "DB default fills created_at");
|
||||||
|
assert!(!created.updated_at.is_empty(), "DB default fills updated_at");
|
||||||
|
|
||||||
|
let listed = list_templates(&pool).await.unwrap();
|
||||||
|
assert_eq!(listed.len(), 1);
|
||||||
|
assert_eq!(listed[0].id, "tpl-1");
|
||||||
|
|
||||||
|
// Update name only — sections preserved via COALESCE.
|
||||||
|
let renamed = update_template(&pool, "tpl-1", Some("Daily check-in"), None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(renamed.name, "Daily check-in");
|
||||||
|
assert_eq!(
|
||||||
|
renamed.sections_json, r#"["Action items","Decisions"]"#,
|
||||||
|
"sections None means leave alone"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Update sections only — name preserved.
|
||||||
|
let resectioned = update_template(
|
||||||
|
&pool,
|
||||||
|
"tpl-1",
|
||||||
|
None,
|
||||||
|
Some(r#"["Wins","Blockers","Next"]"#),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(resectioned.name, "Daily check-in", "name None means leave alone");
|
||||||
|
assert_eq!(resectioned.sections_json, r#"["Wins","Blockers","Next"]"#);
|
||||||
|
|
||||||
|
delete_template(&pool, "tpl-1").await.unwrap();
|
||||||
|
let after_delete = list_templates(&pool).await.unwrap();
|
||||||
|
assert!(after_delete.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn import_templates_is_idempotent() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
let items = vec![
|
||||||
|
TemplateRow {
|
||||||
|
id: "i1".into(),
|
||||||
|
name: "Meeting notes".into(),
|
||||||
|
sections_json: r#"["A","B"]"#.into(),
|
||||||
|
created_at: String::new(),
|
||||||
|
updated_at: String::new(),
|
||||||
|
},
|
||||||
|
TemplateRow {
|
||||||
|
id: "i2".into(),
|
||||||
|
name: "Daily check-in".into(),
|
||||||
|
sections_json: r#"["Wins"]"#.into(),
|
||||||
|
created_at: String::new(),
|
||||||
|
updated_at: String::new(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let summary = import_templates(&pool, &items).await.unwrap();
|
||||||
|
assert_eq!(summary.imported, 2);
|
||||||
|
assert_eq!(summary.skipped, 0);
|
||||||
|
|
||||||
|
let summary2 = import_templates(&pool, &items).await.unwrap();
|
||||||
|
assert_eq!(summary2.imported, 0);
|
||||||
|
assert_eq!(summary2.skipped, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn import_templates_atomic_on_failure() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
let baseline = list_templates(&pool).await.unwrap();
|
||||||
|
|
||||||
|
// Two items share the same id — must abort the whole batch.
|
||||||
|
let bad = vec![
|
||||||
|
TemplateRow {
|
||||||
|
id: "dup".into(),
|
||||||
|
name: "First".into(),
|
||||||
|
sections_json: r#"["A"]"#.into(),
|
||||||
|
created_at: String::new(),
|
||||||
|
updated_at: String::new(),
|
||||||
|
},
|
||||||
|
TemplateRow {
|
||||||
|
id: "dup".into(),
|
||||||
|
name: "Second".into(),
|
||||||
|
sections_json: r#"["B"]"#.into(),
|
||||||
|
created_at: String::new(),
|
||||||
|
updated_at: String::new(),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
let res = import_templates(&pool, &bad).await;
|
||||||
|
assert!(res.is_err(), "duplicate id within batch must error");
|
||||||
|
|
||||||
|
let after = list_templates(&pool).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
after.len(),
|
||||||
|
baseline.len(),
|
||||||
|
"rollback: no rows from the failed batch should remain"
|
||||||
|
);
|
||||||
|
assert!(after.iter().all(|t| t.id != "dup"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_template_bumps_updated_at() {
|
||||||
|
// SQLite datetime('now') has 1-second resolution; we sleep a hair
|
||||||
|
// over a second to guarantee a tick. This test guards the
|
||||||
|
// documented contract that any update advances updated_at, which
|
||||||
|
// the frontend relies on for invalidating cached template state.
|
||||||
|
let pool = test_pool().await;
|
||||||
|
|
||||||
|
let created = create_template(
|
||||||
|
&pool,
|
||||||
|
"bump",
|
||||||
|
"Before",
|
||||||
|
r#"["S1"]"#,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let initial_updated = created.updated_at.clone();
|
||||||
|
|
||||||
|
tokio::time::sleep(std::time::Duration::from_millis(1100)).await;
|
||||||
|
|
||||||
|
let updated = update_template(&pool, "bump", Some("After"), None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_ne!(
|
||||||
|
updated.updated_at, initial_updated,
|
||||||
|
"updated_at must advance on a name change",
|
||||||
|
);
|
||||||
|
// Ordering check rather than just inequality — if the trigger
|
||||||
|
// ever stamps an earlier value we'd want to know.
|
||||||
|
assert!(
|
||||||
|
updated.updated_at > initial_updated,
|
||||||
|
"updated_at must move forward, got {} -> {}",
|
||||||
|
initial_updated,
|
||||||
|
updated.updated_at,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn delete_template_is_noop_when_missing() {
|
||||||
|
let pool = test_pool().await;
|
||||||
|
|
||||||
|
// No row with this id exists. Idempotent contract: deleting a
|
||||||
|
// missing id returns Ok without raising.
|
||||||
|
let res = delete_template(&pool, "does-not-exist").await;
|
||||||
|
assert!(res.is_ok(), "delete of missing template must be Ok, got: {res:?}");
|
||||||
|
|
||||||
|
let listed = list_templates(&pool).await.unwrap();
|
||||||
|
assert!(listed.is_empty(), "no rows touched by missing-id delete");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,19 +9,21 @@ pub const DEFAULT_PROFILE_ID: &str = "00000000-0000-0000-0000-000000000001";
|
|||||||
pub use database::{
|
pub use database::{
|
||||||
add_profile_term, archive_inbox_older_than, archive_task,
|
add_profile_term, archive_inbox_older_than, archive_task,
|
||||||
complete_subtask_and_check_parent, complete_task, count_transcripts,
|
complete_subtask_and_check_parent, complete_task, count_transcripts,
|
||||||
create_profile, create_task_list, delete_implementation_rule, delete_profile,
|
create_profile, create_task_list, create_template, delete_implementation_rule,
|
||||||
delete_profile_term, delete_task, delete_task_list, delete_transcript,
|
delete_profile, delete_profile_term, delete_task, delete_task_list,
|
||||||
get_implementation_rule, get_profile, get_setting, get_task_by_id, get_transcript,
|
delete_template, delete_transcript, get_implementation_rule, get_profile,
|
||||||
import_task_lists, init, init_readonly, insert_implementation_rule, insert_subtask,
|
get_setting, get_task_by_id, get_transcript, import_task_lists, import_templates,
|
||||||
insert_task, insert_transcript, list_archived_tasks, list_feedback_examples,
|
init, init_readonly, insert_implementation_rule, insert_subtask, insert_task,
|
||||||
|
insert_transcript, list_archived_tasks, list_feedback_examples,
|
||||||
list_implementation_rules, list_profile_terms, list_profiles, list_recent_completions,
|
list_implementation_rules, list_profile_terms, list_profiles, list_recent_completions,
|
||||||
list_recent_errors, list_subtasks, list_task_lists, list_tasks, list_transcripts,
|
list_recent_errors, list_subtasks, list_task_lists, list_tasks, list_templates,
|
||||||
list_transcripts_paged, log_error, mark_implementation_rule_fired, prune_error_log,
|
list_transcripts, list_transcripts_paged, log_error, mark_implementation_rule_fired,
|
||||||
record_feedback, search_transcripts, set_implementation_rule_enabled, set_setting,
|
prune_error_log, record_feedback, search_transcripts, set_implementation_rule_enabled,
|
||||||
set_task_energy, unarchive_task, uncomplete_task, update_profile, update_task,
|
set_setting, set_task_energy, unarchive_task, uncomplete_task, update_profile,
|
||||||
update_task_list, update_transcript, update_transcript_meta, DailyCompletionCount,
|
update_task, update_task_list, update_template, update_transcript,
|
||||||
ErrorLogRow, FeedbackRow, FeedbackTargetType, ImplementationRuleRow, ImportSummary,
|
update_transcript_meta, DailyCompletionCount, ErrorLogRow, FeedbackRow,
|
||||||
InsertTranscriptParams, ProfileRow, ProfileTermRow, RecordFeedbackParams, TaskListRow,
|
FeedbackTargetType, ImplementationRuleRow, ImportSummary, InsertTranscriptParams,
|
||||||
TaskRow, TranscriptRow,
|
ProfileRow, ProfileTermRow, RecordFeedbackParams, TaskListRow, TaskRow, TemplateRow,
|
||||||
|
TranscriptRow,
|
||||||
};
|
};
|
||||||
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
pub use file_storage::{app_data_dir, crashes_dir, database_path, logs_dir, recordings_dir};
|
||||||
|
|||||||
@@ -495,6 +495,33 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
|||||||
CREATE INDEX IF NOT EXISTS idx_tasks_archived ON tasks(archived);
|
CREATE INDEX IF NOT EXISTS idx_tasks_archived ON tasks(archived);
|
||||||
"#,
|
"#,
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
17,
|
||||||
|
"templates: SQLite-backed section scaffold templates (B2b)",
|
||||||
|
r#"
|
||||||
|
-- B2b moves dictation section templates from
|
||||||
|
-- localStorage["kon_templates"] into SQLite so they survive across
|
||||||
|
-- machines and behave consistently with the rest of the userland
|
||||||
|
-- data (task_lists in B2a, transcripts since v1).
|
||||||
|
--
|
||||||
|
-- sections is a JSON array of strings. We keep it as TEXT rather
|
||||||
|
-- than a join table because templates are small (a handful of
|
||||||
|
-- short labels) and the order matters but is fully ordered by
|
||||||
|
-- the array. Index on name is for the future "search templates"
|
||||||
|
-- affordance and to keep alphabetised lookups cheap.
|
||||||
|
--
|
||||||
|
-- Comment style note: avoid semicolons inside SQL comments. The
|
||||||
|
-- statement splitter is naive and will treat them as boundaries.
|
||||||
|
CREATE TABLE IF NOT EXISTS templates (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
sections TEXT NOT NULL,
|
||||||
|
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_templates_name ON templates(name);
|
||||||
|
"#,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||||
@@ -644,7 +671,7 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 16);
|
assert_eq!(count, 17);
|
||||||
|
|
||||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -663,7 +690,7 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 16);
|
assert_eq!(count, 17);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ pub mod rituals;
|
|||||||
pub mod security;
|
pub mod security;
|
||||||
pub mod task_lists;
|
pub mod task_lists;
|
||||||
pub mod tasks;
|
pub mod tasks;
|
||||||
|
pub mod templates;
|
||||||
pub mod transcription;
|
pub mod transcription;
|
||||||
pub mod transcripts;
|
pub mod transcripts;
|
||||||
pub mod tts;
|
pub mod tts;
|
||||||
|
|||||||
193
src-tauri/src/commands/templates.rs
Normal file
193
src-tauri/src/commands/templates.rs
Normal file
@@ -0,0 +1,193 @@
|
|||||||
|
// B2b — Tauri commands wrapping kon_storage templates CRUD.
|
||||||
|
//
|
||||||
|
// The frontend stores templates as `{ name, sections }` objects. This
|
||||||
|
// command layer is responsible for the JSON (de)serialisation of the
|
||||||
|
// `sections` column (the storage layer keeps it as opaque TEXT) and for
|
||||||
|
// minting fresh ids during the localStorage → SQLite import.
|
||||||
|
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use kon_storage::{
|
||||||
|
create_template as db_create_template, delete_template as db_delete_template,
|
||||||
|
import_templates as db_import_templates, list_templates as db_list_templates,
|
||||||
|
update_template as db_update_template, ImportSummary, TemplateRow,
|
||||||
|
};
|
||||||
|
|
||||||
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Frontend-facing template shape. Sections come over the wire as a real
|
||||||
|
/// array — the JSON encoding is an implementation detail of the storage
|
||||||
|
/// row that callers shouldn't have to think about.
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TemplateDto {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub sections: Vec<String>,
|
||||||
|
pub created_at: String,
|
||||||
|
pub updated_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TryFrom<TemplateRow> for TemplateDto {
|
||||||
|
type Error = String;
|
||||||
|
|
||||||
|
fn try_from(r: TemplateRow) -> Result<Self, String> {
|
||||||
|
// Malformed sections_json is a soft failure: log + return empty
|
||||||
|
// sections rather than failing the whole list. A user will see
|
||||||
|
// an empty template they can edit, instead of a broken page.
|
||||||
|
let sections: Vec<String> = match serde_json::from_str(&r.sections_json) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!(
|
||||||
|
"[templates] sections_json parse failed for template {} ({}): {}",
|
||||||
|
r.id, r.name, e
|
||||||
|
);
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
Ok(Self {
|
||||||
|
id: r.id,
|
||||||
|
name: r.name,
|
||||||
|
sections,
|
||||||
|
created_at: r.created_at,
|
||||||
|
updated_at: r.updated_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct CreateTemplateRequest {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub sections: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateTemplatePatch {
|
||||||
|
#[serde(default)]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub sections: Option<Vec<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct TemplateImport {
|
||||||
|
/// Optional — if absent, the server mints a uuid v4. The localStorage
|
||||||
|
/// shape pre-B2b had no id, so the import path needs to backfill it.
|
||||||
|
#[serde(default)]
|
||||||
|
pub id: Option<String>,
|
||||||
|
pub name: String,
|
||||||
|
pub sections: Vec<String>,
|
||||||
|
/// Accepted but ignored — the DB sets datetime('now') at insert time.
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct ImportSummaryDto {
|
||||||
|
pub imported: u32,
|
||||||
|
pub skipped: u32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<ImportSummary> for ImportSummaryDto {
|
||||||
|
fn from(s: ImportSummary) -> Self {
|
||||||
|
Self {
|
||||||
|
imported: s.imported as u32,
|
||||||
|
skipped: s.skipped as u32,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn list_templates_cmd(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
) -> Result<Vec<TemplateDto>, String> {
|
||||||
|
let rows = db_list_templates(&state.db).await.map_err(|e| e.to_string())?;
|
||||||
|
let mut out = Vec::with_capacity(rows.len());
|
||||||
|
for r in rows {
|
||||||
|
// TryFrom returns Ok with an empty sections vec on parse failure —
|
||||||
|
// there's no error path that bubbles up here today, but keep the
|
||||||
|
// ?-style plumbing in case the contract tightens.
|
||||||
|
out.push(TemplateDto::try_from(r)?);
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn create_template_cmd(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
request: CreateTemplateRequest,
|
||||||
|
) -> Result<TemplateDto, String> {
|
||||||
|
let sections_json = serde_json::to_string(&request.sections)
|
||||||
|
.map_err(|e| format!("create_template: serialise sections failed: {e}"))?;
|
||||||
|
let row = db_create_template(&state.db, &request.id, &request.name, §ions_json)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
TemplateDto::try_from(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_template_cmd(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
patch: UpdateTemplatePatch,
|
||||||
|
) -> Result<TemplateDto, String> {
|
||||||
|
let sections_json = match patch.sections {
|
||||||
|
Some(s) => Some(
|
||||||
|
serde_json::to_string(&s)
|
||||||
|
.map_err(|e| format!("update_template: serialise sections failed: {e}"))?,
|
||||||
|
),
|
||||||
|
None => None,
|
||||||
|
};
|
||||||
|
let row = db_update_template(
|
||||||
|
&state.db,
|
||||||
|
&id,
|
||||||
|
patch.name.as_deref(),
|
||||||
|
sections_json.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
TemplateDto::try_from(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn delete_template_cmd(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
db_delete_template(&state.db, &id)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn import_templates_cmd(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
items: Vec<TemplateImport>,
|
||||||
|
) -> Result<ImportSummaryDto, String> {
|
||||||
|
let mut rows: Vec<TemplateRow> = Vec::with_capacity(items.len());
|
||||||
|
for item in items {
|
||||||
|
let sections_json = serde_json::to_string(&item.sections)
|
||||||
|
.map_err(|e| format!("import_templates: serialise sections failed: {e}"))?;
|
||||||
|
let id = item.id.unwrap_or_else(|| uuid::Uuid::new_v4().to_string());
|
||||||
|
rows.push(TemplateRow {
|
||||||
|
id,
|
||||||
|
name: item.name,
|
||||||
|
sections_json,
|
||||||
|
// The DB column has DEFAULT datetime('now'); these placeholder
|
||||||
|
// strings are overwritten by the INSERT for the live row and
|
||||||
|
// are only used to build the in-memory TemplateRow shape
|
||||||
|
// passed to import_templates.
|
||||||
|
created_at: item.created_at.unwrap_or_default(),
|
||||||
|
updated_at: String::new(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
db_import_templates(&state.db, &rows)
|
||||||
|
.await
|
||||||
|
.map(ImportSummaryDto::from)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
}
|
||||||
@@ -373,6 +373,12 @@ pub fn run() {
|
|||||||
commands::task_lists::update_task_list_cmd,
|
commands::task_lists::update_task_list_cmd,
|
||||||
commands::task_lists::delete_task_list_cmd,
|
commands::task_lists::delete_task_list_cmd,
|
||||||
commands::task_lists::import_task_lists_cmd,
|
commands::task_lists::import_task_lists_cmd,
|
||||||
|
// B2b — templates CRUD (replaces kon_templates localStorage)
|
||||||
|
commands::templates::list_templates_cmd,
|
||||||
|
commands::templates::create_template_cmd,
|
||||||
|
commands::templates::update_template_cmd,
|
||||||
|
commands::templates::delete_template_cmd,
|
||||||
|
commands::templates::import_templates_cmd,
|
||||||
// HITL feedback (Phase 2 roadmap)
|
// HITL feedback (Phase 2 roadmap)
|
||||||
commands::feedback::record_feedback,
|
commands::feedback::record_feedback,
|
||||||
commands::feedback::list_feedback_examples_cmd,
|
commands::feedback::list_feedback_examples_cmd,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { invoke } from "@tauri-apps/api/core";
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { listen } from "@tauri-apps/api/event";
|
import { listen } from "@tauri-apps/api/event";
|
||||||
import { settings, saveSettings, profiles, saveProfiles, templates, saveTemplates, page, addProfileTaskList, removeProfileTaskList } from "$lib/stores/page.svelte.js";
|
import { settings, saveSettings, profiles, saveProfiles, templates, createTemplate as createTemplateRow, updateTemplate as updateTemplateRow, deleteTemplate as deleteTemplateRow, page, addProfileTaskList, removeProfileTaskList } from "$lib/stores/page.svelte.js";
|
||||||
import Card from "$lib/components/Card.svelte";
|
import Card from "$lib/components/Card.svelte";
|
||||||
import Toggle from "$lib/components/Toggle.svelte";
|
import Toggle from "$lib/components/Toggle.svelte";
|
||||||
import SegmentedButton from "$lib/components/SegmentedButton.svelte";
|
import SegmentedButton from "$lib/components/SegmentedButton.svelte";
|
||||||
@@ -932,21 +932,35 @@
|
|||||||
return words ? words.split("\n").filter((w) => w.trim()).length : 0;
|
return words ? words.split("\n").filter((w) => w.trim()).length : 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Template management ---
|
// --- Template management (B2b: SQLite-backed via createTemplateRow /
|
||||||
function createTemplate() {
|
// updateTemplateRow / deleteTemplateRow). The inline section editor
|
||||||
|
// calls updateTemplateRow directly with the parsed sections array;
|
||||||
|
// the store is the source of truth for the rendered list, so UI
|
||||||
|
// updates flow through the awaited mutators.
|
||||||
|
async function createTemplate() {
|
||||||
if (!newTemplateName.trim()) return;
|
if (!newTemplateName.trim()) return;
|
||||||
templates.push({ name: newTemplateName.trim(), sections: ["Section 1", "Section 2", "Section 3"] });
|
await createTemplateRow(newTemplateName.trim(), ["Section 1", "Section 2", "Section 3"]);
|
||||||
saveTemplates();
|
|
||||||
newTemplateName = "";
|
newTemplateName = "";
|
||||||
showNewTemplate = false;
|
showNewTemplate = false;
|
||||||
editingTemplate = templates.length - 1;
|
editingTemplate = templates.length - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
function deleteTemplate(index) {
|
async function deleteTemplate(index) {
|
||||||
templates.splice(index, 1);
|
const template = templates[index];
|
||||||
saveTemplates();
|
if (!template) return;
|
||||||
|
await deleteTemplateRow(template.id);
|
||||||
editingTemplate = -1;
|
editingTemplate = -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function persistTemplateSections(template, value) {
|
||||||
|
const sections = value.split("\n").filter((s) => s.trim());
|
||||||
|
// Optimistic local mutation so the textarea stays in sync with what
|
||||||
|
// the user typed; updateTemplateRow then writes through to SQLite
|
||||||
|
// and rewrites this entry from the returned row (which includes the
|
||||||
|
// bumped updated_at).
|
||||||
|
template.sections = sections;
|
||||||
|
void updateTemplateRow(template.id, { sections });
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
|
<div class="flex flex-col h-full overflow-y-auto animate-fade-in">
|
||||||
@@ -2025,7 +2039,7 @@
|
|||||||
focus:outline-none focus:border-accent"
|
focus:outline-none focus:border-accent"
|
||||||
placeholder="One section per line..."
|
placeholder="One section per line..."
|
||||||
value={template.sections.join("\n")}
|
value={template.sections.join("\n")}
|
||||||
oninput={(e) => { template.sections = e.target.value.split("\n").filter((s) => s.trim()); saveTemplates(); }}
|
oninput={(e) => persistTemplateSections(template, e.target.value)}
|
||||||
data-no-transition
|
data-no-transition
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -83,6 +83,25 @@ const defaults: SettingsState = {
|
|||||||
nudgesMuted: false,
|
nudgesMuted: false,
|
||||||
nudgesSpeakAloud: false,
|
nudgesSpeakAloud: false,
|
||||||
showMomentumSparkline: true,
|
showMomentumSparkline: true,
|
||||||
|
// B2b — additive schema fields. Fresh users get sane defaults; existing
|
||||||
|
// users get them spread over their saved blob and then the migration
|
||||||
|
// step in loadSettings() backfills nudgeMode from nudgesEnabled.
|
||||||
|
lastLaunchAt: null,
|
||||||
|
reentryFreshStartUntil: null,
|
||||||
|
lastActiveProfileId: null,
|
||||||
|
microStepGranularity: "default",
|
||||||
|
// For fresh users we default off; existing users with nudgesEnabled
|
||||||
|
// = true get bumped to "immediate" by the migration in loadSettings.
|
||||||
|
nudgeMode: "off",
|
||||||
|
nudgeDigestTimes: [],
|
||||||
|
sparklineRangeDays: 7,
|
||||||
|
// Defaults mirror current EnergyChip copy so existing behaviour is
|
||||||
|
// unchanged until the user edits via the (B3.6) UI.
|
||||||
|
energyLabels: {
|
||||||
|
high: { label: "High", description: "Sharp focus, big lifts" },
|
||||||
|
medium: { label: "Medium", description: "Steady work, moderate lifts" },
|
||||||
|
brain_dead: { label: "Zero", description: "Rest only, low-touch admin" },
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
function canUseStorage(): boolean {
|
function canUseStorage(): boolean {
|
||||||
@@ -107,10 +126,22 @@ function loadSettings(): SettingsState {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return {
|
const merged: SettingsState = {
|
||||||
...defaults,
|
...defaults,
|
||||||
...(migrated ?? {}),
|
...(migrated ?? {}),
|
||||||
};
|
};
|
||||||
|
// B2b nudge migration: if the loaded blob carries a boolean
|
||||||
|
// `nudgesEnabled` but no `nudgeMode`, derive the new tri-state from it.
|
||||||
|
// We keep `nudgesEnabled` in the schema so callers that haven't been
|
||||||
|
// migrated yet still see a sane value. The new `nudgeMode` is the
|
||||||
|
// source of truth for the digest/immediate/off split going forward.
|
||||||
|
// This is a *one-time* derivation: once the merged blob has nudgeMode
|
||||||
|
// set explicitly (next save), the boolean stops driving anything.
|
||||||
|
const blob = migrated as Partial<SettingsState> | undefined;
|
||||||
|
if (blob && blob.nudgeMode === undefined && typeof blob.nudgesEnabled === "boolean") {
|
||||||
|
merged.nudgeMode = blob.nudgesEnabled ? "immediate" : "off";
|
||||||
|
}
|
||||||
|
return merged;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const settings = $state<SettingsState>(loadSettings());
|
export const settings = $state<SettingsState>(loadSettings());
|
||||||
@@ -895,24 +926,196 @@ function broadcastTaskLists() {
|
|||||||
} satisfies TaskListChannelMessage);
|
} satisfies TaskListChannelMessage);
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultTemplates(): Template[] {
|
// B2b — templates moved from localStorage["kon_templates"] to SQLite
|
||||||
|
// (migration v17). The store starts empty and is populated asynchronously
|
||||||
|
// from the backend. The Dictation "Section scaffold" picker reads
|
||||||
|
// `templates.name` / `templates.sections`, both of which survive the new
|
||||||
|
// shape — the migration just gains `id`, `createdAt`, `updatedAt`.
|
||||||
|
//
|
||||||
|
// Default seeding is gated on a *truly fresh install* (no legacy
|
||||||
|
// localStorage key AND empty SQLite), so existing users keep their
|
||||||
|
// custom templates and never get the v3 defaults sprayed on top of
|
||||||
|
// their data.
|
||||||
|
|
||||||
|
interface TemplateDto {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
sections: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* v3-spec defaults seeded only on a truly fresh install (no legacy
|
||||||
|
* kon_templates and no rows in SQLite). Existing user templates
|
||||||
|
* (Meeting Notes / Report / Interview from the pre-B2b defaults) are
|
||||||
|
* preserved verbatim by the migration — they appear in localStorage
|
||||||
|
* because the user's first launch wrote them out, so the "fresh
|
||||||
|
* install" check correctly skips seeding for them.
|
||||||
|
*/
|
||||||
|
function freshInstallTemplateSeed() {
|
||||||
return [
|
return [
|
||||||
{ name: "Meeting Notes", sections: ["Attendees", "Agenda", "Discussion", "Action Items", "Next Steps"] },
|
{ name: "Meeting notes", sections: ["Action items", "Decisions", "Team members", "Open questions"] },
|
||||||
{ name: "Report", sections: ["Summary", "Background", "Findings", "Recommendations"] },
|
{ name: "Daily check-in", sections: ["Wins", "Blockers", "Next"] },
|
||||||
{ name: "Interview", sections: ["Candidate", "Role", "Questions & Answers", "Assessment", "Next Steps"] },
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadTemplates(): Template[] {
|
function mapTemplateRow(row: TemplateDto): Template {
|
||||||
if (!canUseStorage()) return defaultTemplates();
|
return {
|
||||||
return parseStoredJson<Template[]>(localStorage.getItem(TEMPLATES_KEY)) ?? defaultTemplates();
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
sections: Array.isArray(row.sections) ? row.sections : [],
|
||||||
|
createdAt: row.createdAt ?? "",
|
||||||
|
updatedAt: row.updatedAt ?? "",
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const templates = $state<Template[]>(loadTemplates());
|
export const templates = $state<Template[]>([]);
|
||||||
|
|
||||||
export function saveTemplates() {
|
async function loadTemplatesFromSqlite(): Promise<TemplateDto[]> {
|
||||||
if (!canUseStorage()) return;
|
if (!hasTauriRuntime()) return [];
|
||||||
try {
|
try {
|
||||||
localStorage.setItem(TEMPLATES_KEY, JSON.stringify(templates));
|
const rows = await invoke<TemplateDto[]>("list_templates_cmd");
|
||||||
} catch {}
|
const mapped = Array.isArray(rows) ? rows.map(mapTemplateRow) : [];
|
||||||
|
templates.length = 0;
|
||||||
|
templates.push(...mapped);
|
||||||
|
return Array.isArray(rows) ? rows : [];
|
||||||
|
} catch (err) {
|
||||||
|
console.error("loadTemplatesFromSqlite failed", err);
|
||||||
|
toasts.error("Failed to load templates", errorMessage(err));
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// One-time migration: pull the legacy kon_templates blob into SQLite.
|
||||||
|
// Idempotent because import_templates_cmd skips ids that already exist;
|
||||||
|
// we still gate on the localStorage key's presence to avoid a no-op
|
||||||
|
// round-trip on every launch. The localStorage key is removed only on
|
||||||
|
// a successful import so a transient backend error doesn't lose data.
|
||||||
|
//
|
||||||
|
// Each item gets a server-minted uuid because the legacy shape didn't
|
||||||
|
// store one — TemplateImport.id is optional on purpose for this path.
|
||||||
|
async function migrateTemplatesFromLocalStorage(): Promise<boolean> {
|
||||||
|
if (!canUseStorage()) return false;
|
||||||
|
const raw = localStorage.getItem(TEMPLATES_KEY);
|
||||||
|
if (!raw) return false;
|
||||||
|
|
||||||
|
const parsed = parseStoredJson<Array<{ name: string; sections: string[] }>>(raw);
|
||||||
|
if (!parsed || parsed.length === 0) {
|
||||||
|
// Empty or corrupt — clear and skip so we don't keep retrying.
|
||||||
|
localStorage.removeItem(TEMPLATES_KEY);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const summary = await invoke<{ imported: number; skipped: number }>(
|
||||||
|
"import_templates_cmd",
|
||||||
|
{
|
||||||
|
items: parsed.map((t) => ({
|
||||||
|
name: t.name,
|
||||||
|
sections: Array.isArray(t.sections) ? t.sections : [],
|
||||||
|
})),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
// Only clear localStorage AFTER the import succeeds.
|
||||||
|
localStorage.removeItem(TEMPLATES_KEY);
|
||||||
|
// Re-load from SQLite so the store reflects the imported rows.
|
||||||
|
await loadTemplatesFromSqlite();
|
||||||
|
toasts.info(
|
||||||
|
"Templates migrated",
|
||||||
|
`${summary.imported} template${summary.imported === 1 ? "" : "s"} moved to local database.`,
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("migrateTemplatesFromLocalStorage failed", err);
|
||||||
|
toasts.error(
|
||||||
|
"Couldn't migrate templates",
|
||||||
|
"Your data is safe — Kon will retry on next launch.",
|
||||||
|
);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fresh-install seeding. Only runs when:
|
||||||
|
// - SQLite returned zero rows (nothing migrated, nothing pre-existing)
|
||||||
|
// - localStorage key is absent (no legacy data the migration could
|
||||||
|
// have moved over — i.e. truly first launch)
|
||||||
|
// Existing users with localStorage templates take the migration path
|
||||||
|
// above; existing users who already migrated have non-empty SQLite and
|
||||||
|
// fall through here without seeding.
|
||||||
|
async function maybeSeedDefaultTemplates(sqliteRows: TemplateDto[]): Promise<void> {
|
||||||
|
if (!hasTauriRuntime()) return;
|
||||||
|
if (sqliteRows.length > 0) return;
|
||||||
|
if (canUseStorage() && localStorage.getItem(TEMPLATES_KEY) !== null) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await invoke<{ imported: number; skipped: number }>("import_templates_cmd", {
|
||||||
|
items: freshInstallTemplateSeed(),
|
||||||
|
});
|
||||||
|
await loadTemplatesFromSqlite();
|
||||||
|
} catch (err) {
|
||||||
|
console.error("maybeSeedDefaultTemplates failed", err);
|
||||||
|
// Non-fatal: the user can still create templates by hand.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window !== "undefined" && hasTauriRuntime()) {
|
||||||
|
// Sequencing: load → migrate-if-needed → seed-if-fresh-install. The
|
||||||
|
// migration re-loads on success so the store reflects DB state at
|
||||||
|
// each step, and the seeding gate sees the post-migration row count.
|
||||||
|
(async () => {
|
||||||
|
const initialRows = await loadTemplatesFromSqlite();
|
||||||
|
const migrated = await migrateTemplatesFromLocalStorage();
|
||||||
|
// After a migration the row count changed; re-fetch before deciding
|
||||||
|
// whether to seed defaults.
|
||||||
|
const rowsForSeed = migrated ? await invoke<TemplateDto[]>("list_templates_cmd").catch(() => []) : initialRows;
|
||||||
|
await maybeSeedDefaultTemplates(Array.isArray(rowsForSeed) ? rowsForSeed : []);
|
||||||
|
})().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createTemplate(name: string, sections: string[]): Promise<Template | null> {
|
||||||
|
if (!hasTauriRuntime()) return null;
|
||||||
|
const id = crypto.randomUUID();
|
||||||
|
try {
|
||||||
|
const row = await invoke<TemplateDto>("create_template_cmd", {
|
||||||
|
request: { id, name, sections },
|
||||||
|
});
|
||||||
|
const mapped = mapTemplateRow(row);
|
||||||
|
templates.push(mapped);
|
||||||
|
return mapped;
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error("Couldn't create template", errorMessage(err));
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateTemplate(
|
||||||
|
id: string,
|
||||||
|
patch: { name?: string; sections?: string[] },
|
||||||
|
): Promise<void> {
|
||||||
|
if (!hasTauriRuntime()) return;
|
||||||
|
try {
|
||||||
|
const row = await invoke<TemplateDto>("update_template_cmd", {
|
||||||
|
id,
|
||||||
|
patch: {
|
||||||
|
name: patch.name ?? null,
|
||||||
|
sections: patch.sections ?? null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const idx = templates.findIndex((t) => t.id === id);
|
||||||
|
if (idx >= 0) templates[idx] = mapTemplateRow(row);
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error("Couldn't update template", errorMessage(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteTemplate(id: string): Promise<void> {
|
||||||
|
if (!hasTauriRuntime()) return;
|
||||||
|
try {
|
||||||
|
await invoke("delete_template_cmd", { id });
|
||||||
|
const idx = templates.findIndex((t) => t.id === id);
|
||||||
|
if (idx >= 0) templates.splice(idx, 1);
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error("Couldn't delete template", errorMessage(err));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,41 @@ export type WhisperModelSize =
|
|||||||
| "Medium"
|
| "Medium"
|
||||||
| "Distil-L";
|
| "Distil-L";
|
||||||
export type AiTier = "off" | "cleanup" | "tasks";
|
export type AiTier = "off" | "cleanup" | "tasks";
|
||||||
|
/**
|
||||||
|
* B2b microstep granularity (UI hook only — B3.3 owns the LLM-prompt
|
||||||
|
* threading). "default" preserves current behaviour.
|
||||||
|
*/
|
||||||
|
export type MicroStepGranularity = "light" | "default" | "detailed";
|
||||||
|
/**
|
||||||
|
* B2b nudge mode. Splits the legacy `nudgesEnabled: boolean` into a
|
||||||
|
* tri-state so the eventual digest delivery (B3.2) has a place to live.
|
||||||
|
* "off" = no nudges, "immediate" = current behaviour, "digest" = batched
|
||||||
|
* delivery at the times in `nudgeDigestTimes`.
|
||||||
|
*/
|
||||||
|
export type NudgeMode = "off" | "immediate" | "digest";
|
||||||
|
/**
|
||||||
|
* B2b sparkline window. The "N today" badge is unaffected; this only
|
||||||
|
* controls the momentum sparkline range. Default 7 mirrors today's UI.
|
||||||
|
* B3.5 adds the picker UI; this just establishes the field.
|
||||||
|
*/
|
||||||
|
export type SparklineRangeDays = 7 | 28 | 90;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* B2b energy labels. The shape covers the three energy levels (matching
|
||||||
|
* `EnergyLevel` below). Per-profile override lives on the Profile shape;
|
||||||
|
* the resolver in `$lib/utils/energyLabels` falls back to the global
|
||||||
|
* setting when the profile override is null/absent.
|
||||||
|
*/
|
||||||
|
export interface EnergyLabel {
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EnergyLabels {
|
||||||
|
high: EnergyLabel;
|
||||||
|
medium: EnergyLabel;
|
||||||
|
brain_dead: EnergyLabel;
|
||||||
|
}
|
||||||
export type LlmModelIdStr = "qwen3_1_7b" | "qwen3_4b_instruct_2507" | "qwen3_14b";
|
export type LlmModelIdStr = "qwen3_1_7b" | "qwen3_4b_instruct_2507" | "qwen3_14b";
|
||||||
export type LlmPromptPreset = "default" | "email" | "notes" | "code";
|
export type LlmPromptPreset = "default" | "email" | "notes" | "code";
|
||||||
export type AiGpuConcurrency = "parallel" | "sequential";
|
export type AiGpuConcurrency = "parallel" | "sequential";
|
||||||
@@ -128,16 +163,82 @@ export interface SettingsState {
|
|||||||
* existing users see the sparkline on upgrade.
|
* existing users see the sparkline on upgrade.
|
||||||
*/
|
*/
|
||||||
showMomentumSparkline: boolean;
|
showMomentumSparkline: boolean;
|
||||||
|
/**
|
||||||
|
* B2b — re-entry / fresh-start tracking. B3.1 owns the actual behaviour
|
||||||
|
* (detecting that the user has been away long enough to warrant a fresh
|
||||||
|
* start prompt); this batch just lays the schema. ISO timestamps; null
|
||||||
|
* means "never" / "not yet stamped".
|
||||||
|
*/
|
||||||
|
lastLaunchAt: string | null;
|
||||||
|
reentryFreshStartUntil: string | null;
|
||||||
|
/**
|
||||||
|
* B2b — last active profile id, persisted so the next launch can pick
|
||||||
|
* up where the user left off. Null means "no profile last selected" —
|
||||||
|
* the launcher falls back to the Default profile. This is the
|
||||||
|
* localStorage profile id (the user's per-context switch), not the
|
||||||
|
* SQLite ProfileRow id.
|
||||||
|
*/
|
||||||
|
lastActiveProfileId: string | null;
|
||||||
|
/**
|
||||||
|
* B2b — micro-step generation granularity. Hook only; B3.3 threads
|
||||||
|
* this into the LLM prompt builder. "default" matches today's output.
|
||||||
|
*/
|
||||||
|
microStepGranularity: MicroStepGranularity;
|
||||||
|
/**
|
||||||
|
* B2b — nudge mode tri-state. Replaces the boolean `nudgesEnabled` for
|
||||||
|
* read paths added from B2b onward. The legacy boolean is preserved
|
||||||
|
* (don't remove until the migration window closes) so older callers
|
||||||
|
* still see a sensible value. Default value depends on `nudgesEnabled`
|
||||||
|
* at first load — see the migration in loadSettings.
|
||||||
|
*/
|
||||||
|
nudgeMode: NudgeMode;
|
||||||
|
/**
|
||||||
|
* B2b — when nudgeMode === "digest", the times-of-day (HH:MM, 24h
|
||||||
|
* local) at which to flush the queued nudges. Empty array is the
|
||||||
|
* default and currently produces no digest sends; B3.2 owns the
|
||||||
|
* delivery loop.
|
||||||
|
*/
|
||||||
|
nudgeDigestTimes: string[];
|
||||||
|
/**
|
||||||
|
* B2b — momentum sparkline range. Default 7 mirrors today's UI; B3.5
|
||||||
|
* adds the picker.
|
||||||
|
*/
|
||||||
|
sparklineRangeDays: SparklineRangeDays;
|
||||||
|
/**
|
||||||
|
* B2b — global energy labels. Each profile may override via
|
||||||
|
* `energyLabelsOverride` on its Profile entry. Defaults mirror the
|
||||||
|
* EnergyChip copy so behaviour doesn't change until the user edits.
|
||||||
|
*/
|
||||||
|
energyLabels: EnergyLabels;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Profile {
|
export interface Profile {
|
||||||
name: string;
|
name: string;
|
||||||
words: string;
|
words: string;
|
||||||
|
/**
|
||||||
|
* B2b — optional per-profile override of the global energy labels.
|
||||||
|
* When present, takes precedence over `settings.energyLabels`. Null or
|
||||||
|
* absent means "use the global labels". Wired through
|
||||||
|
* `resolveEnergyLabels` in `$lib/utils/energyLabels`.
|
||||||
|
*
|
||||||
|
* Note: this lives on the localStorage Profile shape (the user's
|
||||||
|
* "switch" between contexts). It is intentionally not on the SQLite
|
||||||
|
* ProfileRow (which stores vocabulary terms). B3.7 unifies the split.
|
||||||
|
*/
|
||||||
|
energyLabelsOverride?: EnergyLabels | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Template {
|
export interface Template {
|
||||||
|
/**
|
||||||
|
* B2b — server-minted uuid for the SQLite-backed templates table. Older
|
||||||
|
* blobs in localStorage may lack this; the import migration in
|
||||||
|
* page.svelte.ts mints a uuid on the way in.
|
||||||
|
*/
|
||||||
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
sections: string[];
|
sections: string[];
|
||||||
|
createdAt: string;
|
||||||
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AccessibilityPreferences {
|
export interface AccessibilityPreferences {
|
||||||
|
|||||||
19
src/lib/utils/energyLabels.ts
Normal file
19
src/lib/utils/energyLabels.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
// B2b — per-profile energy-label override resolver.
|
||||||
|
//
|
||||||
|
// The global labels live in `settings.energyLabels`; a Profile may set
|
||||||
|
// `energyLabelsOverride` to take precedence within its context. This
|
||||||
|
// helper centralises the lookup so callers never have to remember the
|
||||||
|
// "profile beats global, missing override is fine" precedence.
|
||||||
|
//
|
||||||
|
// B3.6 will add the editing UI; this batch ships the schema + resolver
|
||||||
|
// only so other features can already read through it without changing
|
||||||
|
// behaviour.
|
||||||
|
|
||||||
|
import type { EnergyLabels, Profile, SettingsState } from "$lib/types/app";
|
||||||
|
|
||||||
|
export function resolveEnergyLabels(
|
||||||
|
profile: Profile | null | undefined,
|
||||||
|
settings: SettingsState,
|
||||||
|
): EnergyLabels {
|
||||||
|
return profile?.energyLabelsOverride ?? settings.energyLabels;
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user