diff --git a/src/app.rs b/src/app.rs index b24d436..a474299 100644 --- a/src/app.rs +++ b/src/app.rs @@ -101,6 +101,12 @@ pub struct App { library: Vec, sidebar_view: SidebarView, selected_video: Option, + /// Edit buffer for the selected video's note, plus the id it belongs + /// to. When `note_target` != `selected_video` we (re)load the note + /// from the DB into `note_buffer`. Keeps the textarea editable + /// without a DB round-trip per keystroke. + note_buffer: String, + note_target: Option, search: String, downloader: Downloader, show_downloads: bool, @@ -351,6 +357,8 @@ impl App { library, sidebar_view: SidebarView::All, selected_video: None, + note_buffer: String::new(), + note_target: None, search: String::new(), downloader: Downloader::new(channels_root, browser, max_concurrent, use_bundled_ytdlp, use_pot_provider), show_downloads: false, @@ -2715,6 +2723,35 @@ impl App { ui.separator(); + // ── Note editor ────────────────────────────────────── + // Lazy-load the note from the DB the first time we render + // for a given video. Subsequent frames edit the in-memory + // buffer; we persist on focus-loss / explicit Save. + if self.note_target.as_deref() != Some(selected_id.as_str()) { + self.note_buffer = self.db + .get_note("video", &selected_id) + .ok() + .flatten() + .unwrap_or_default(); + self.note_target = Some(selected_id.clone()); + } + ui.horizontal(|ui| { + ui.label(egui::RichText::new("📝 Note").small().strong()); + let resp = ui.add( + egui::TextEdit::singleline(&mut self.note_buffer) + .desired_width(f32::INFINITY) + .hint_text("Anything you want to remember — searchable"), + ); + // Persist when the field loses focus (matches how the + // web UI saves on modal close) so we don't write per + // keystroke. + if resp.lost_focus() { + let _ = self.db.set_note("video", &selected_id, &self.note_buffer); + } + }); + + ui.separator(); + let description = self.description(&video); egui::ScrollArea::vertical() .auto_shrink([false, false]) @@ -3322,9 +3359,9 @@ impl eframe::App for App { // Rescan so channel rows pick up new folder assignments. self.rescan(); self.status = format!( - "Imported: {}W · {}P · {}F · {}flags · {}folders · {}assigns", + "Imported: {}W · {}P · {}F · {}flags · {}folders · {}assigns · {}notes", s.watched_added, s.positions_added, s.options_added, - s.flags_added, s.folders_added, s.assignments_added, + s.flags_added, s.folders_added, s.assignments_added, s.notes_added, ); } Err(e) => self.status = format!("Restore failed: {e}"), diff --git a/src/database.rs b/src/database.rs index 55c43af..ef6ccf9 100644 --- a/src/database.rs +++ b/src/database.rs @@ -170,6 +170,19 @@ impl Database { duration_secs REAL, has_chapters INTEGER NOT NULL DEFAULT 0, upload_date TEXT + ); + -- Free-text user annotations on a channel or a video. + -- `target_kind` is 'channel' or 'video'; `target_id` is + -- 'platform/handle' for channels or the video ID for videos. + -- An empty body is treated as 'no note' and deleted rather + -- than stored, so the table only holds rows the user cares + -- about. + CREATE TABLE IF NOT EXISTS notes ( + target_kind TEXT NOT NULL, + target_id TEXT NOT NULL, + body TEXT NOT NULL, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (target_kind, target_id) );", )?; Ok(()) @@ -350,6 +363,58 @@ impl Database { Ok(bundle) } + /// Fetch a single note body. `target_kind` is `"channel"` or + /// `"video"`; `target_id` is `"platform/handle"` or the video ID. + /// Returns `None` when no note exists. + pub fn get_note(&self, target_kind: &str, target_id: &str) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare( + "SELECT body FROM notes WHERE target_kind = ?1 AND target_id = ?2", + )?; + let mut rows = stmt.query([target_kind, target_id])?; + Ok(rows.next()?.map(|r| r.get(0)).transpose()?) + } + + /// Upsert (or delete) a note. An empty / whitespace-only body deletes + /// the row so we never store blank notes — that keeps `get_all_notes` + /// and the search index free of noise. + pub fn set_note(&self, target_kind: &str, target_id: &str, body: &str) -> Result<()> { + let conn = self.conn(); + if body.trim().is_empty() { + conn.execute( + "DELETE FROM notes WHERE target_kind = ?1 AND target_id = ?2", + [target_kind, target_id], + )?; + } else { + conn.execute( + "INSERT INTO notes (target_kind, target_id, body, updated_at) \ + VALUES (?1, ?2, ?3, CURRENT_TIMESTAMP) \ + ON CONFLICT(target_kind, target_id) \ + DO UPDATE SET body = excluded.body, updated_at = CURRENT_TIMESTAMP", + rusqlite::params![target_kind, target_id, body], + )?; + } + Ok(()) + } + + /// Bulk fetch of every note as `((target_kind, target_id) → body)`. + /// Hydrated into memory at startup so the UI can render note + /// indicators + search bodies without per-item SQL. + pub fn get_all_notes(&self) -> Result> { + let conn = self.conn(); + let mut stmt = conn.prepare("SELECT target_kind, target_id, body FROM notes")?; + let map = stmt + .query_map([], |row| { + Ok(( + (row.get::<_, String>(0)?, row.get::<_, String>(1)?), + row.get::<_, String>(2)?, + )) + })? + .filter_map(std::result::Result::ok) + .collect(); + Ok(map) + } + /// Bulk fetch of every channel's options, returned as /// `((platform, handle) → options_json)`. Used by the library scanner to /// attach options to each scanned [`crate::library::Channel`] without @@ -700,6 +765,39 @@ impl Database { "SELECT count(*) FROM channel_assignments", [], |r| r.get(0))?; let assignments_added = (assignments_after - assignments_before).max(0); + // notes: keep the later updated_at, same pattern as watched. + // The notes table is newer than the others, so a backup from + // before it existed won't have it — guard with a table check + // and skip the merge silently when it's absent. + let mut notes_added: i64 = 0; + let has_notes: i64 = conn.query_row( + "SELECT count(*) FROM bk.sqlite_master \ + WHERE type = 'table' AND name = 'notes'", + [], |r| r.get(0))?; + if has_notes > 0 { + let notes_before: i64 = conn.query_row( + "SELECT count(*) FROM notes", [], |r| r.get(0))?; + conn.execute( + "INSERT OR IGNORE INTO notes (target_kind, target_id, body, updated_at) \ + SELECT target_kind, target_id, body, updated_at FROM bk.notes", [])?; + conn.execute( + "UPDATE notes SET \ + body = (SELECT bk.notes.body FROM bk.notes \ + WHERE bk.notes.target_kind = main.notes.target_kind \ + AND bk.notes.target_id = main.notes.target_id), \ + updated_at = (SELECT bk.notes.updated_at FROM bk.notes \ + WHERE bk.notes.target_kind = main.notes.target_kind \ + AND bk.notes.target_id = main.notes.target_id) \ + WHERE EXISTS (\ + SELECT 1 FROM bk.notes \ + WHERE bk.notes.target_kind = main.notes.target_kind \ + AND bk.notes.target_id = main.notes.target_id \ + AND bk.notes.updated_at > main.notes.updated_at)", [])?; + let notes_after: i64 = conn.query_row( + "SELECT count(*) FROM notes", [], |r| r.get(0))?; + notes_added = (notes_after - notes_before).max(0); + } + Ok(RestoreSummary { watched_added: watched_added as u64, positions_added: positions_added as u64, @@ -707,6 +805,7 @@ impl Database { flags_added: flags_added as u64, folders_added: folders_added as u64, assignments_added: assignments_added as u64, + notes_added: notes_added as u64, }) })(); match summary { @@ -738,6 +837,7 @@ pub struct RestoreSummary { pub flags_added: u64, pub folders_added: u64, pub assignments_added: u64, + pub notes_added: u64, } #[cfg(test)] @@ -851,6 +951,59 @@ mod restore_tests { let msg = format!("{err}"); assert!(msg.contains("missing required table"), "{msg}"); } + + #[test] + fn notes_upsert_get_and_empty_delete() { + let dir = ScratchDir::new("notes"); + let db = Database::open(&dir.join("notes.db")).unwrap(); + + // No note yet. + assert_eq!(db.get_note("video", "v1").unwrap(), None); + + // Set + read back. + db.set_note("video", "v1", "remember this clip").unwrap(); + assert_eq!(db.get_note("video", "v1").unwrap().as_deref(), Some("remember this clip")); + + // Overwrite. + db.set_note("video", "v1", "updated text").unwrap(); + assert_eq!(db.get_note("video", "v1").unwrap().as_deref(), Some("updated text")); + + // Channel note keyed separately. + db.set_note("channel", "youtube/Andrewism", "great anarchist channel").unwrap(); + assert_eq!( + db.get_note("channel", "youtube/Andrewism").unwrap().as_deref(), + Some("great anarchist channel"), + ); + + // get_all_notes returns both. + let all = db.get_all_notes().unwrap(); + assert_eq!(all.len(), 2); + assert_eq!(all.get(&("video".into(), "v1".into())).map(String::as_str), Some("updated text")); + + // Empty body deletes the row. + db.set_note("video", "v1", " ").unwrap(); + assert_eq!(db.get_note("video", "v1").unwrap(), None); + assert_eq!(db.get_all_notes().unwrap().len(), 1); + } + + #[test] + fn notes_merge_keeps_later_on_restore() { + let dir = ScratchDir::new("notes-merge"); + let live = Database::open(&dir.join("live.db")).unwrap(); + let backup = Database::open(&dir.join("backup.db")).unwrap(); + + // A note only in the backup gets pulled in; a note newer on the + // backup side wins the conflict. + backup.set_note("video", "only-backup", "from backup").unwrap(); + live.set_note("video", "shared", "old live text").unwrap(); + std::thread::sleep(std::time::Duration::from_millis(1100)); + backup.set_note("video", "shared", "newer backup text").unwrap(); + + let summary = live.restore_from_backup(&dir.join("backup.db")).unwrap(); + assert_eq!(summary.notes_added, 1); // only-backup is the new row + assert_eq!(live.get_note("video", "only-backup").unwrap().as_deref(), Some("from backup")); + assert_eq!(live.get_note("video", "shared").unwrap().as_deref(), Some("newer backup text")); + } } /// Translate an `r2d2::Error` from `Pool::build()` into a `rusqlite::Error` so diff --git a/src/web.rs b/src/web.rs index b99a612..f593000 100644 --- a/src/web.rs +++ b/src/web.rs @@ -1985,6 +1985,9 @@ async fn post_assign_folder( #[derive(Deserialize)] struct FlagToggleBody { value: bool } +#[derive(Deserialize)] +struct NoteBody { body: String } + /// `POST /api/videos/:id/flags/:flag` — set or clear a single per-video /// flag (`bookmark` / `favourite` / `waiting` / `archive`). The watched /// flag is handled by the separate `POST /api/watched/:id` endpoint for @@ -2018,6 +2021,37 @@ async fn post_video_flag( (StatusCode::OK, "ok").into_response() } +/// `GET /api/notes` — return every note as a map of +/// `":" → body`. The UI fetches this once on load and keeps it +/// in memory to render note indicators + include note bodies in the +/// global search. Small table, so a single bulk fetch is cheap. +async fn get_notes(State(state): State>) -> impl IntoResponse { + let map = state.db.get_all_notes().unwrap_or_default(); + // Flatten the (kind, id) tuple key into "kind:id" for JSON object keys. + let flat: HashMap = map + .into_iter() + .map(|((kind, id), body)| (format!("{kind}:{id}"), body)) + .collect(); + Json(flat).into_response() +} + +/// `POST /api/notes/:kind/:id` — upsert a note. An empty body deletes it. +/// `kind` must be `channel` or `video`; `id` is `platform/handle` for +/// channels (URL-encoded `/` becomes `%2F`) or the bare video ID. +async fn post_note( + State(state): State>, + Path((kind, id)): Path<(String, String)>, + Json(body): Json, +) -> impl IntoResponse { + if kind != "channel" && kind != "video" { + return (StatusCode::BAD_REQUEST, "kind must be channel or video").into_response(); + } + if let Err(e) = state.db.set_note(&kind, &id, &body.body) { + return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(); + } + (StatusCode::OK, "ok").into_response() +} + /// `GET /api/channels/:platform/:handle/options` — fetch the stored /// download-option overrides for a single channel. Returns the default /// (empty) options when nothing is stored. @@ -2365,6 +2399,8 @@ async fn serve(config: Config, shutdown_rx: std::sync::mpsc::Receiver<()>) { .route("/api/download", post(post_download)) .route("/api/watched/:id", post(post_watched)) .route("/api/videos/:id/flags/:flag", post(post_video_flag)) + .route("/api/notes", get(get_notes)) + .route("/api/notes/:kind/:id", post(post_note)) .route("/api/folders", post(post_create_folder)) .route("/api/folders/:id/rename", post(post_rename_folder)) .route("/api/folders/:id/check", post(post_check_folder)) diff --git a/src/web_ui/index.html b/src/web_ui/index.html index 7451368..1968b74 100644 --- a/src/web_ui/index.html +++ b/src/web_ui/index.html @@ -235,6 +235,17 @@ let library=[], channelUrls=[], activeChannelIdx=null, activePlaylistIdx=null, s // Folder list from the most-recent /api/library response; populated by // loadLibrary() and read by renderSidebar() to draw the Folders group. let librarySnapshotFolders=[]; +// Notes map: "video:" or "channel:/" → body text. +// Fetched once on load via /api/notes; updated locally on edit. Drives +// the 📝 indicator on cards/channels and feeds the global search. +let notes={}; +async function loadNotes(){ + try{ notes=await(await api('/api/notes')).json(); } + catch{ notes={}; } +} +// Convenience accessors keyed the same way the server stores them. +function videoNote(id){ return notes['video:'+id]||''; } +function channelNote(platform,handle){ return notes['channel:'+platform+'/'+handle]||''; } // Smart-folder view selectors — at most one is true at a time. let showFavourites=false, showBookmarks=false, showWaiting=false; // Filter chips above the grid. Each is independent; the grid filters by @@ -320,6 +331,8 @@ function renderSidebar(){ s+=`
⬇ Check for new videos
`; s+=`
⚙ Channel options…
`; s+=`
📁 Move to folder…
`; + const hasNote=!!channelNote(ch.platform,ch.name); + s+=`
📝 ${hasNote?'Edit note…':'Add note…'}
`; } return s; }; @@ -588,6 +601,7 @@ function renderGrid(){ + `; }).join(''); @@ -618,6 +632,56 @@ async function toggleFlag(videoId,flag,btn){ setStatus('Error: '+e.message); } } + +/* ── Notes ──────────────────────────────────────────────────────── */ +// Open a small modal to edit a free-text note on a video or channel. +// `kind` is 'video' or 'channel'; `id` is the video ID or +// '/'. `title` is shown in the header for context. +function editNote(kind,id,title){ + const key=kind+':'+id; + const current=notes[key]||''; + const bg=document.createElement('div');bg.className='modal-bg'; + bg.onclick=e=>{if(e.target===bg)bg.remove()}; + bg.innerHTML=``; + document.body.appendChild(bg); + setTimeout(()=>document.getElementById('note-text')?.focus(),0); +} +// Channel note: keyed by "/". Convenience wrapper so +// the sidebar sub-action doesn't have to build the composite id inline. +function editChannelNote(idx){ + const ch=library[idx]; if(!ch)return; + editNote('channel',ch.platform+'/'+ch.name,ch.name); +} +async function saveNote(kind,id,btn){ + const body=document.getElementById('note-text')?.value||''; + btn.disabled=true; + try{ + const r=await api(`/api/notes/${encodeURIComponent(kind)}/${encodeURIComponent(id)}`,{ + method:'POST', + headers:{'Content-Type':'application/json'}, + body:JSON.stringify({body}), + }); + if(!r.ok)throw new Error(await r.text()); + // Update the local map so indicators + search reflect immediately. + const key=kind+':'+id; + if(body.trim()) notes[key]=body; else delete notes[key]; + btn.closest('.modal-bg').remove(); + setStatus('Note saved'); + renderGrid(); + if(activeChannelIdx!==null||true)renderSidebar(); + }catch(e){ + setStatus('Note error: '+e.message); + btn.disabled=false; + } +} function renderChannelGrid(){ const q=(document.getElementById('search')?.value||'').toLowerCase(); const grid=document.getElementById('grid'); @@ -1396,7 +1460,9 @@ async function uploadRestoreDb(input){ const r=await fetch('/api/restore/db',{method:'POST',headers:{'Content-Type':'application/x-sqlite3'},body:f}); if(!r.ok){throw new Error(await r.text())} const s=await r.json(); - status.textContent=`Imported: ${s.watched_added}W · ${s.positions_added}P · ${s.options_added}opts · ${s.flags_added}flags · ${s.folders_added}folders · ${s.assignments_added}assigns`; + status.textContent=`Imported: ${s.watched_added}W · ${s.positions_added}P · ${s.options_added}opts · ${s.flags_added}flags · ${s.folders_added}folders · ${s.assignments_added}assigns · ${s.notes_added||0}notes`; + // Pull in any imported notes so indicators + search reflect them. + loadNotes(); setStatus('Library snapshot imported'); // Refresh the sidebar / grid so the new flags / folders show up. loadLibrary(); @@ -1906,7 +1972,10 @@ function matchesSearch(v,chName,q){ const t=(v.title||'').toLowerCase(); const id=(v.id||'').toLowerCase(); const ch=(chName||'').toLowerCase(); - return t.includes(q)||id.includes(q)||ch.includes(q); + // Search also hits the per-video note body so a user can find a clip + // by something they wrote about it, not just its title. + const note=videoNote(v.id).toLowerCase(); + return t.includes(q)||id.includes(q)||ch.includes(q)||note.includes(q); } function p(n){return String(n).padStart(2,'0')} function esc(s){return String(s??'').replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"')} @@ -1944,7 +2013,7 @@ applyTheme(localStorage.getItem('theme')||'dark'); // before we can resolve `channel:` back to an index — do that // after loadLibrary() resolves. (async()=>{ - await loadLibrary(); + await Promise.all([loadLibrary(), loadNotes()]); restoreUiState(); renderSidebar(); renderGrid();