feat(transcripts): migration v5 meta — starred, tags, template, language, segments persisted
This commit is contained in:
@@ -88,7 +88,7 @@ pub async fn insert_transcript(
|
|||||||
|
|
||||||
pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<TranscriptRow>> {
|
pub async fn get_transcript(pool: &SqlitePool, id: &str) -> Result<Option<TranscriptRow>> {
|
||||||
let row = sqlx::query(
|
let row = sqlx::query(
|
||||||
"SELECT id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at FROM transcripts WHERE id = ?",
|
"SELECT id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json FROM transcripts WHERE id = ?",
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(pool)
|
.fetch_optional(pool)
|
||||||
@@ -110,7 +110,7 @@ pub async fn list_transcripts_paged(
|
|||||||
offset: i64,
|
offset: i64,
|
||||||
) -> Result<Vec<TranscriptRow>> {
|
) -> Result<Vec<TranscriptRow>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at FROM transcripts ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
"SELECT id, text, source, title, audio_path, duration, engine, model_id, inference_ms, sample_rate, audio_channels, format_mode, remove_fillers, british_english, anti_hallucination, created_at, starred, manual_tags, template, language, segments_json FROM transcripts ORDER BY created_at DESC LIMIT ? OFFSET ?",
|
||||||
)
|
)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
@@ -179,6 +179,49 @@ pub async fn update_transcript(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Patch-style update for the Task 2.5 transcripts_meta columns (starred,
|
||||||
|
/// manual_tags, template, language, segments_json). Follows the same
|
||||||
|
/// COALESCE pattern as `update_task`: each `Option::Some` overwrites,
|
||||||
|
/// each `None` preserves the existing column value. Returns the refreshed
|
||||||
|
/// row or errors if `id` does not exist after the UPDATE.
|
||||||
|
///
|
||||||
|
/// Keeping this separate from `update_transcript` (text / title) means the
|
||||||
|
/// existing command surface stays stable — the viewer and history store
|
||||||
|
/// keep calling `update_transcript` for content edits, and call this for
|
||||||
|
/// UI-metadata persistence.
|
||||||
|
pub async fn update_transcript_meta(
|
||||||
|
pool: &SqlitePool,
|
||||||
|
id: &str,
|
||||||
|
starred: Option<bool>,
|
||||||
|
manual_tags: Option<&str>,
|
||||||
|
template: Option<&str>,
|
||||||
|
language: Option<&str>,
|
||||||
|
segments_json: Option<&str>,
|
||||||
|
) -> Result<TranscriptRow> {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE transcripts SET \
|
||||||
|
starred = COALESCE(?, starred), \
|
||||||
|
manual_tags = COALESCE(?, manual_tags), \
|
||||||
|
template = COALESCE(?, template), \
|
||||||
|
language = COALESCE(?, language), \
|
||||||
|
segments_json = COALESCE(?, segments_json) \
|
||||||
|
WHERE id = ?",
|
||||||
|
)
|
||||||
|
.bind(starred.map(|b| b as i64))
|
||||||
|
.bind(manual_tags)
|
||||||
|
.bind(template)
|
||||||
|
.bind(language)
|
||||||
|
.bind(segments_json)
|
||||||
|
.bind(id)
|
||||||
|
.execute(pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| KonError::StorageError(format!("update_transcript_meta: {e}")))?;
|
||||||
|
|
||||||
|
get_transcript(pool, id)
|
||||||
|
.await?
|
||||||
|
.ok_or_else(|| KonError::StorageError(format!("update_transcript_meta: transcript {id} not found")))
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
pub async fn delete_transcript(pool: &SqlitePool, id: &str) -> Result<()> {
|
||||||
sqlx::query("DELETE FROM transcripts WHERE id = ?")
|
sqlx::query("DELETE FROM transcripts WHERE id = ?")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
@@ -198,7 +241,7 @@ pub async fn search_transcripts(
|
|||||||
limit: i64,
|
limit: i64,
|
||||||
) -> Result<Vec<TranscriptRow>> {
|
) -> Result<Vec<TranscriptRow>> {
|
||||||
let rows = sqlx::query(
|
let rows = sqlx::query(
|
||||||
"SELECT t.id, t.text, t.source, t.title, t.audio_path, t.duration, t.engine, t.model_id, t.inference_ms, t.sample_rate, t.audio_channels, t.format_mode, t.remove_fillers, t.british_english, t.anti_hallucination, t.created_at \
|
"SELECT t.id, t.text, t.source, t.title, t.audio_path, t.duration, t.engine, t.model_id, t.inference_ms, t.sample_rate, t.audio_channels, t.format_mode, t.remove_fillers, t.british_english, t.anti_hallucination, t.created_at, t.starred, t.manual_tags, t.template, t.language, t.segments_json \
|
||||||
FROM transcripts t \
|
FROM transcripts t \
|
||||||
JOIN transcripts_fts fts ON fts.rowid = t.rowid \
|
JOIN transcripts_fts fts ON fts.rowid = t.rowid \
|
||||||
WHERE transcripts_fts MATCH ? \
|
WHERE transcripts_fts MATCH ? \
|
||||||
@@ -512,6 +555,13 @@ pub struct TranscriptRow {
|
|||||||
pub british_english: bool,
|
pub british_english: bool,
|
||||||
pub anti_hallucination: bool,
|
pub anti_hallucination: bool,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
// Task 2.5 — transcripts_meta (migration v5). Persists the UI metadata
|
||||||
|
// that previously lived in the removed localStorage `kon_history` cache.
|
||||||
|
pub starred: bool,
|
||||||
|
pub manual_tags: String,
|
||||||
|
pub template: String,
|
||||||
|
pub language: String,
|
||||||
|
pub segments_json: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -547,6 +597,12 @@ fn transcript_row_from(r: &sqlx::sqlite::SqliteRow) -> TranscriptRow {
|
|||||||
british_english: r.get("british_english"),
|
british_english: r.get("british_english"),
|
||||||
anti_hallucination: r.get("anti_hallucination"),
|
anti_hallucination: r.get("anti_hallucination"),
|
||||||
created_at: r.get("created_at"),
|
created_at: r.get("created_at"),
|
||||||
|
// Task 2.5 (migration v5). INTEGER 0/1 → bool via `!= 0`.
|
||||||
|
starred: r.get::<i64, _>("starred") != 0,
|
||||||
|
manual_tags: r.get("manual_tags"),
|
||||||
|
template: r.get("template"),
|
||||||
|
language: r.get("language"),
|
||||||
|
segments_json: r.get("segments_json"),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -743,6 +799,107 @@ mod tests {
|
|||||||
assert!(parent.done, "parent should auto-complete when all children done");
|
assert!(parent.done, "parent should auto-complete when all children done");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_transcript_meta_happy_path() {
|
||||||
|
// Task 2.5 — insert a transcript, update starred=true, read it back.
|
||||||
|
let pool = test_pool().await;
|
||||||
|
insert_transcript(
|
||||||
|
&pool,
|
||||||
|
&InsertTranscriptParams {
|
||||||
|
id: "tm1",
|
||||||
|
text: "body",
|
||||||
|
source: "microphone",
|
||||||
|
title: Some("Meta happy"),
|
||||||
|
audio_path: None,
|
||||||
|
duration: 0.0,
|
||||||
|
engine: None,
|
||||||
|
model_id: None,
|
||||||
|
inference_ms: None,
|
||||||
|
sample_rate: None,
|
||||||
|
audio_channels: None,
|
||||||
|
format_mode: None,
|
||||||
|
remove_fillers: false,
|
||||||
|
british_english: false,
|
||||||
|
anti_hallucination: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let row = update_transcript_meta(&pool, "tm1", Some(true), None, None, None, None)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(row.starred, "starred must flip true after update");
|
||||||
|
|
||||||
|
let fetched = get_transcript(&pool, "tm1").await.unwrap().unwrap();
|
||||||
|
assert!(fetched.starred, "starred must persist via SELECT");
|
||||||
|
assert_eq!(fetched.manual_tags, "");
|
||||||
|
assert_eq!(fetched.template, "");
|
||||||
|
assert_eq!(fetched.language, "");
|
||||||
|
assert_eq!(fetched.segments_json, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn update_transcript_meta_partial_leaves_others_unchanged() {
|
||||||
|
// Task 2.5 — partial update: only segments_json changes; the other
|
||||||
|
// meta columns must be preserved by COALESCE.
|
||||||
|
let pool = test_pool().await;
|
||||||
|
insert_transcript(
|
||||||
|
&pool,
|
||||||
|
&InsertTranscriptParams {
|
||||||
|
id: "tm2",
|
||||||
|
text: "body",
|
||||||
|
source: "microphone",
|
||||||
|
title: None,
|
||||||
|
audio_path: None,
|
||||||
|
duration: 0.0,
|
||||||
|
engine: None,
|
||||||
|
model_id: None,
|
||||||
|
inference_ms: None,
|
||||||
|
sample_rate: None,
|
||||||
|
audio_channels: None,
|
||||||
|
format_mode: None,
|
||||||
|
remove_fillers: false,
|
||||||
|
british_english: false,
|
||||||
|
anti_hallucination: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Seed starred + tags + template + language first.
|
||||||
|
update_transcript_meta(
|
||||||
|
&pool,
|
||||||
|
"tm2",
|
||||||
|
Some(true),
|
||||||
|
Some("urgent,review"),
|
||||||
|
Some("Meeting"),
|
||||||
|
Some("en-GB"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Now update only segments_json.
|
||||||
|
let row = update_transcript_meta(
|
||||||
|
&pool,
|
||||||
|
"tm2",
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
Some(r#"[{"start":0.0,"end":1.0,"text":"hi"}]"#),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
assert!(row.starred, "starred must survive partial update");
|
||||||
|
assert_eq!(row.manual_tags, "urgent,review");
|
||||||
|
assert_eq!(row.template, "Meeting");
|
||||||
|
assert_eq!(row.language, "en-GB");
|
||||||
|
assert_eq!(row.segments_json, r#"[{"start":0.0,"end":1.0,"text":"hi"}]"#);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn update_task_overwrites_provided_fields() {
|
async fn update_task_overwrites_provided_fields() {
|
||||||
// Task 2.6 — happy path: insert, update bucket + effort, read back.
|
// Task 2.6 — happy path: insert, update bucket + effort, read back.
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ pub use database::{
|
|||||||
delete_dictionary_entry, delete_task, delete_transcript, get_setting, get_task_by_id, uncomplete_task,
|
delete_dictionary_entry, delete_task, delete_transcript, get_setting, get_task_by_id, uncomplete_task,
|
||||||
get_transcript, init, insert_subtask, insert_task, insert_transcript, list_dictionary,
|
get_transcript, init, insert_subtask, insert_task, insert_transcript, list_dictionary,
|
||||||
list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged,
|
list_recent_errors, list_subtasks, list_tasks, list_transcripts, list_transcripts_paged,
|
||||||
log_error, search_transcripts, set_setting, update_task, update_transcript, DictionaryEntry,
|
log_error, search_transcripts, set_setting, update_task, update_transcript,
|
||||||
ErrorLogRow, InsertTranscriptParams, TaskRow, TranscriptRow,
|
update_transcript_meta, DictionaryEntry, ErrorLogRow, InsertTranscriptParams, TaskRow,
|
||||||
|
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};
|
||||||
|
|||||||
@@ -115,6 +115,17 @@ const MIGRATIONS: &[(i64, &str, &str)] = &[
|
|||||||
(4, "tasks_meta: notes column for persisted free-text", r#"
|
(4, "tasks_meta: notes column for persisted free-text", r#"
|
||||||
ALTER TABLE tasks ADD COLUMN notes TEXT NOT NULL DEFAULT ''
|
ALTER TABLE tasks ADD COLUMN notes TEXT NOT NULL DEFAULT ''
|
||||||
"#),
|
"#),
|
||||||
|
(
|
||||||
|
5,
|
||||||
|
"transcripts_meta",
|
||||||
|
r#"
|
||||||
|
ALTER TABLE transcripts ADD COLUMN starred INTEGER NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE transcripts ADD COLUMN manual_tags TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE transcripts ADD COLUMN template TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE transcripts ADD COLUMN language TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE transcripts ADD COLUMN segments_json TEXT NOT NULL DEFAULT '';
|
||||||
|
"#,
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
/// Split SQL into individual statements, respecting BEGIN...END trigger blocks.
|
||||||
@@ -215,7 +226,7 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 4);
|
assert_eq!(count, 5);
|
||||||
|
|
||||||
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
sqlx::query("INSERT INTO settings (key, value) VALUES ('test', 'value')")
|
||||||
.execute(&pool)
|
.execute(&pool)
|
||||||
@@ -237,7 +248,7 @@ mod tests {
|
|||||||
.fetch_one(&pool)
|
.fetch_one(&pool)
|
||||||
.await
|
.await
|
||||||
.unwrap();
|
.unwrap();
|
||||||
assert_eq!(count, 4);
|
assert_eq!(count, 5);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -268,6 +279,34 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn migration_transcripts_meta_adds_columns() {
|
||||||
|
// Task 2.5 — verify starred / manual_tags / template / language /
|
||||||
|
// segments_json are all present on the transcripts table after
|
||||||
|
// migrations run. All five are added by v5.
|
||||||
|
let pool = SqlitePoolOptions::new()
|
||||||
|
.max_connections(1)
|
||||||
|
.connect("sqlite::memory:")
|
||||||
|
.await
|
||||||
|
.expect("pool");
|
||||||
|
run_migrations(&pool).await.expect("migrate");
|
||||||
|
|
||||||
|
let info = sqlx::query("PRAGMA table_info(transcripts)")
|
||||||
|
.fetch_all(&pool)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let names: Vec<String> = info
|
||||||
|
.iter()
|
||||||
|
.map(|r| r.get::<String, _>("name"))
|
||||||
|
.collect();
|
||||||
|
for col in ["starred", "manual_tags", "template", "language", "segments_json"] {
|
||||||
|
assert!(
|
||||||
|
names.contains(&col.to_string()),
|
||||||
|
"transcripts must have {col}; got {names:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn test_parent_task_id_cascade_delete() {
|
async fn test_parent_task_id_cascade_delete() {
|
||||||
let pool = SqlitePoolOptions::new()
|
let pool = SqlitePoolOptions::new()
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ use kon_storage::{
|
|||||||
delete_transcript as db_delete_transcript, get_transcript as db_get_transcript,
|
delete_transcript as db_delete_transcript, get_transcript as db_get_transcript,
|
||||||
insert_transcript as db_insert_transcript, list_dictionary,
|
insert_transcript as db_insert_transcript, list_dictionary,
|
||||||
list_transcripts_paged, search_transcripts as db_search_transcripts,
|
list_transcripts_paged, search_transcripts as db_search_transcripts,
|
||||||
update_transcript as db_update_transcript, DictionaryEntry, InsertTranscriptParams,
|
update_transcript as db_update_transcript,
|
||||||
|
update_transcript_meta as db_update_transcript_meta, DictionaryEntry, InsertTranscriptParams,
|
||||||
TranscriptRow,
|
TranscriptRow,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -22,6 +23,10 @@ use crate::AppState;
|
|||||||
/// Frontend-facing shape of a stored transcript. Mirrors `TranscriptRow`
|
/// Frontend-facing shape of a stored transcript. Mirrors `TranscriptRow`
|
||||||
/// from the storage crate but drops fields the UI does not need yet
|
/// from the storage crate but drops fields the UI does not need yet
|
||||||
/// (sample_rate, audio_channels, format flags) and renames to camelCase.
|
/// (sample_rate, audio_channels, format flags) and renames to camelCase.
|
||||||
|
///
|
||||||
|
/// Task 2.5 — `starred`, `manualTags`, `template`, `language`, `segmentsJson`
|
||||||
|
/// were added to back the viewer metadata that previously lived only in the
|
||||||
|
/// removed `kon_history` localStorage cache.
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
#[serde(rename_all = "camelCase")]
|
#[serde(rename_all = "camelCase")]
|
||||||
pub struct TranscriptDto {
|
pub struct TranscriptDto {
|
||||||
@@ -34,6 +39,11 @@ pub struct TranscriptDto {
|
|||||||
pub engine: Option<String>,
|
pub engine: Option<String>,
|
||||||
pub model_id: Option<String>,
|
pub model_id: Option<String>,
|
||||||
pub created_at: String,
|
pub created_at: String,
|
||||||
|
pub starred: bool,
|
||||||
|
pub manual_tags: String,
|
||||||
|
pub template: String,
|
||||||
|
pub language: String,
|
||||||
|
pub segments_json: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<TranscriptRow> for TranscriptDto {
|
impl From<TranscriptRow> for TranscriptDto {
|
||||||
@@ -48,6 +58,11 @@ impl From<TranscriptRow> for TranscriptDto {
|
|||||||
engine: r.engine,
|
engine: r.engine,
|
||||||
model_id: r.model_id,
|
model_id: r.model_id,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
|
starred: r.starred,
|
||||||
|
manual_tags: r.manual_tags,
|
||||||
|
template: r.template,
|
||||||
|
language: r.language,
|
||||||
|
segments_json: r.segments_json,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -179,6 +194,45 @@ pub async fn search_transcripts(
|
|||||||
.map_err(|e| e.to_string())
|
.map_err(|e| e.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Task 2.5 — patch-style update for the transcripts_meta columns. Each
|
||||||
|
/// field is optional: `Some` overwrites, omitted / `None` preserves via
|
||||||
|
/// COALESCE server-side. Separate from `update_transcript` (text / title)
|
||||||
|
/// so the existing command surface stays untouched.
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct UpdateTranscriptMetaRequest {
|
||||||
|
#[serde(default)]
|
||||||
|
pub starred: Option<bool>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub manual_tags: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub template: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub language: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub segments_json: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn update_transcript_meta_cmd(
|
||||||
|
state: tauri::State<'_, AppState>,
|
||||||
|
id: String,
|
||||||
|
patch: UpdateTranscriptMetaRequest,
|
||||||
|
) -> Result<TranscriptDto, String> {
|
||||||
|
let row = db_update_transcript_meta(
|
||||||
|
&state.db,
|
||||||
|
&id,
|
||||||
|
patch.starred,
|
||||||
|
patch.manual_tags.as_deref(),
|
||||||
|
patch.template.as_deref(),
|
||||||
|
patch.language.as_deref(),
|
||||||
|
patch.segments_json.as_deref(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
Ok(TranscriptDto::from(row))
|
||||||
|
}
|
||||||
|
|
||||||
// --- Dictionary commands (custom vocabulary) ---
|
// --- Dictionary commands (custom vocabulary) ---
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize)]
|
#[derive(Debug, Clone, Serialize)]
|
||||||
|
|||||||
@@ -271,6 +271,7 @@ pub fn run() {
|
|||||||
commands::transcripts::count_transcripts_command,
|
commands::transcripts::count_transcripts_command,
|
||||||
commands::transcripts::get_transcript,
|
commands::transcripts::get_transcript,
|
||||||
commands::transcripts::update_transcript,
|
commands::transcripts::update_transcript,
|
||||||
|
commands::transcripts::update_transcript_meta_cmd,
|
||||||
commands::transcripts::delete_transcript,
|
commands::transcripts::delete_transcript,
|
||||||
commands::transcripts::search_transcripts,
|
commands::transcripts::search_transcripts,
|
||||||
commands::transcripts::list_dictionary_command,
|
commands::transcripts::list_dictionary_command,
|
||||||
|
|||||||
@@ -94,10 +94,30 @@ import { toasts } from "$lib/stores/toasts.svelte.js";
|
|||||||
export const history = $state([]);
|
export const history = $state([]);
|
||||||
|
|
||||||
/** Shape a TranscriptDto row from `list_transcripts` into the UI entry shape
|
/** Shape a TranscriptDto row from `list_transcripts` into the UI entry shape
|
||||||
* that HistoryPage expects. UI-only fields that were never persisted to
|
* that HistoryPage expects.
|
||||||
* SQLite (segments, starred, manualTags, language, template) are absent. */
|
*
|
||||||
|
* Task 2.5 — the meta columns (starred, manualTags, template, language,
|
||||||
|
* segments_json) are now carried by SQLite. `manualTags` is stored comma-
|
||||||
|
* joined in a single TEXT column (empty string = no tags); `segmentsJson`
|
||||||
|
* is a serialised JSON array of segment objects (empty string = no
|
||||||
|
* segments).
|
||||||
|
*/
|
||||||
function mapTranscriptRow(row) {
|
function mapTranscriptRow(row) {
|
||||||
const text = row.text ?? "";
|
const text = row.text ?? "";
|
||||||
|
// manual_tags is a single TEXT column. Empty string ⇒ empty array.
|
||||||
|
const rawTags = row.manualTags ?? "";
|
||||||
|
const manualTags = rawTags ? rawTags.split(",").filter(Boolean) : [];
|
||||||
|
// segments_json is a serialised array. Empty string ⇒ empty array.
|
||||||
|
const rawSegments = row.segmentsJson ?? "";
|
||||||
|
let segments = [];
|
||||||
|
if (rawSegments) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(rawSegments);
|
||||||
|
if (Array.isArray(parsed)) segments = parsed;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("mapTranscriptRow: segmentsJson parse failed", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
text,
|
text,
|
||||||
@@ -110,6 +130,11 @@ function mapTranscriptRow(row) {
|
|||||||
createdAt: row.createdAt ?? null,
|
createdAt: row.createdAt ?? null,
|
||||||
date: row.createdAt ? new Date(row.createdAt).toLocaleString("en-GB") : "",
|
date: row.createdAt ? new Date(row.createdAt).toLocaleString("en-GB") : "",
|
||||||
preview: text.slice(0, 120),
|
preview: text.slice(0, 120),
|
||||||
|
starred: !!row.starred,
|
||||||
|
manualTags,
|
||||||
|
template: row.template ?? "",
|
||||||
|
language: row.language ?? "",
|
||||||
|
segments,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,9 +163,9 @@ if (typeof window !== "undefined" && hasTauriRuntime()) {
|
|||||||
// `saveHistory` is retained as a no-op for callers in HistoryPage that use
|
// `saveHistory` is retained as a no-op for callers in HistoryPage that use
|
||||||
// it as a mutation hook (tag add/remove, clear-all). SQLite writes happen
|
// it as a mutation hook (tag add/remove, clear-all). SQLite writes happen
|
||||||
// via the specific `add_transcript` / `update_transcript` /
|
// via the specific `add_transcript` / `update_transcript` /
|
||||||
// `delete_transcript` commands in the functions below; ad-hoc UI-only
|
// `update_transcript_meta_cmd` / `delete_transcript` commands in the
|
||||||
// mutations (e.g. manualTags) are session-scoped because they are not
|
// functions below. HistoryPage's manualTags mutations will need to call
|
||||||
// backed by a SQLite column.
|
// `saveTranscriptMeta` directly (Task 2.5) to persist past reload.
|
||||||
export function saveHistory() {}
|
export function saveHistory() {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -211,6 +236,47 @@ export async function renameHistoryEntry(id, updates) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Task 2.5 — persist viewer metadata to SQLite via update_transcript_meta_cmd.
|
||||||
|
* `patch` may contain any of:
|
||||||
|
* - `starred: boolean`
|
||||||
|
* - `manualTags: string[] | string` (array joined on ',' server-side)
|
||||||
|
* - `template: string`
|
||||||
|
* - `language: string`
|
||||||
|
* - `segments: Array<object>` (JSON.stringify'd before the wire hop)
|
||||||
|
* Omitted fields are preserved by COALESCE server-side. The in-memory
|
||||||
|
* history row (if present) is refreshed from the returned canonical row
|
||||||
|
* so the main-window list stays consistent.
|
||||||
|
*
|
||||||
|
* Returns nothing — callers needing the new row should read `history`.
|
||||||
|
*
|
||||||
|
* @param {string} id
|
||||||
|
* @param {Record<string, any>} patch
|
||||||
|
*/
|
||||||
|
export async function saveTranscriptMeta(id, patch) {
|
||||||
|
if (!hasTauriRuntime()) return;
|
||||||
|
try {
|
||||||
|
const payload = {
|
||||||
|
starred: patch.starred,
|
||||||
|
manualTags: patch.manualTags == null
|
||||||
|
? undefined
|
||||||
|
: (Array.isArray(patch.manualTags)
|
||||||
|
? patch.manualTags.join(",")
|
||||||
|
: String(patch.manualTags)),
|
||||||
|
template: patch.template,
|
||||||
|
language: patch.language,
|
||||||
|
segmentsJson: patch.segments ? JSON.stringify(patch.segments) : undefined,
|
||||||
|
};
|
||||||
|
const row = await invoke("update_transcript_meta_cmd", { id: String(id), patch: payload });
|
||||||
|
const idx = history.findIndex((h) => String(h.id) === String(id));
|
||||||
|
if (idx !== -1) {
|
||||||
|
history[idx] = mapTranscriptRow(row);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
toasts.error("Failed to save transcript metadata", String(err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function deleteFromHistory(index) {
|
export function deleteFromHistory(index) {
|
||||||
const entry = history[index];
|
const entry = history[index];
|
||||||
history.splice(index, 1);
|
history.splice(index, 1);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
import { convertFileSrc, invoke } from "@tauri-apps/api/core";
|
||||||
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
import { formatTime, formatDuration } from "$lib/utils/time.js";
|
||||||
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
|
import { PLAYBACK_SPEEDS } from "$lib/utils/constants.js";
|
||||||
|
import { saveTranscriptMeta } from "$lib/stores/page.svelte.js";
|
||||||
|
|
||||||
let item = $state(null);
|
let item = $state(null);
|
||||||
let audioEl = $state(null);
|
let audioEl = $state(null);
|
||||||
@@ -184,6 +185,10 @@
|
|||||||
// Update full text
|
// Update full text
|
||||||
item.text = item.segments.map((s) => s.text).join(" ").trim();
|
item.text = item.segments.map((s) => s.text).join(" ").trim();
|
||||||
saveItemToHistory();
|
saveItemToHistory();
|
||||||
|
// Task 2.5 — persist segment boundaries (text + starred flags) to
|
||||||
|
// SQLite via segments_json. update_transcript above only covers text
|
||||||
|
// and title; the segment array needs the dedicated meta command.
|
||||||
|
persistSegments();
|
||||||
}
|
}
|
||||||
editingIdx = -1;
|
editingIdx = -1;
|
||||||
editingText = "";
|
editingText = "";
|
||||||
@@ -194,20 +199,35 @@
|
|||||||
item.segments.splice(idx, 1);
|
item.segments.splice(idx, 1);
|
||||||
item.text = item.segments.map((s) => s.text).join(" ").trim();
|
item.text = item.segments.map((s) => s.text).join(" ").trim();
|
||||||
saveItemToHistory();
|
saveItemToHistory();
|
||||||
|
// Task 2.5 — persist the shrunk segment array.
|
||||||
|
persistSegments();
|
||||||
if (editingIdx === idx) { editingIdx = -1; }
|
if (editingIdx === idx) { editingIdx = -1; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleStar(idx) {
|
function toggleStar(idx) {
|
||||||
if (item?.segments) {
|
if (item?.segments) {
|
||||||
// TODO: Phase 2 remaining Task 1.5 — starred is not a SQLite column
|
|
||||||
// on the transcripts table yet, so this mutation is in-memory only
|
|
||||||
// and will not survive a reload. Task 1.5 will extend update_transcript
|
|
||||||
// (and the transcripts schema) to carry starred segment state.
|
|
||||||
item.segments[idx].starred = !item.segments[idx].starred;
|
item.segments[idx].starred = !item.segments[idx].starred;
|
||||||
|
// Task 2.5 — persist the updated segments array (incl. starred flags)
|
||||||
|
// via the new update_transcript_meta_cmd. Before this, starring a
|
||||||
|
// segment was in-memory only and vanished on reload.
|
||||||
|
persistSegments();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Task 2.5 — single entry point for segments_json persistence. Callers
|
||||||
|
// already guard with `if (item?.segments)` so the snapshot here is safe;
|
||||||
|
// the @ts-ignore matches the file-wide pattern where `item` inferred as
|
||||||
|
// `null` from `$state(null)` narrows to `never` after guards. Replacing
|
||||||
|
// the annotation needs a full viewer re-typing pass — out of scope for
|
||||||
|
// Task 2.5, tracked by the file's 80-odd pre-existing TS errors.
|
||||||
|
function persistSegments() {
|
||||||
|
if (!item) return;
|
||||||
|
/** @type {any} */
|
||||||
|
const snap = item;
|
||||||
|
saveTranscriptMeta(snap.id, { segments: snap.segments });
|
||||||
|
}
|
||||||
|
|
||||||
function copySegment(idx) {
|
function copySegment(idx) {
|
||||||
if (item?.segments?.[idx]) {
|
if (item?.segments?.[idx]) {
|
||||||
const text = item.segments[idx].text.trim();
|
const text = item.segments[idx].text.trim();
|
||||||
@@ -217,13 +237,11 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Persist edits back to SQLite via update_transcript (the canonical store).
|
// Persist edits back to SQLite. `update_transcript` covers `text` / `title`;
|
||||||
// Only `text` and `title` are accepted by update_transcript today. Segment
|
// segment structure, per-segment starred flags, manualTags, template and
|
||||||
// structure, segment-level starred flags, and manualTags are not SQLite
|
// language go through `update_transcript_meta_cmd` (Task 2.5) via the
|
||||||
// columns yet — Phase 2 remaining Task 1.5 will extend the command and the
|
// store's `saveTranscriptMeta` helper — callers in this file wire those
|
||||||
// transcripts schema to cover those. Until then, segment-level edits that
|
// up alongside the plain-text update below.
|
||||||
// change `item.text` (the joined full transcript) survive reload; the
|
|
||||||
// segment array itself does not.
|
|
||||||
//
|
//
|
||||||
// The cross-window `kon_viewer_item` localStorage payload stays in sync so
|
// The cross-window `kon_viewer_item` localStorage payload stays in sync so
|
||||||
// a second viewer window hydrating from it reflects the latest edit.
|
// a second viewer window hydrating from it reflects the latest edit.
|
||||||
|
|||||||
Reference in New Issue
Block a user