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>
194 lines
6.1 KiB
Rust
194 lines
6.1 KiB
Rust
// 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())
|
|
}
|