Per-channel / per-video notes (Tartube parity 1.5)
Free-text annotations on any channel or video, searchable from the
global filter box.
## Storage
New `notes` table keyed by (target_kind, target_id):
- target_kind: "channel" or "video"
- target_id: "platform/handle" for channels, video ID for videos
- empty body deletes the row, so the table only holds real notes
DB methods: get_note, set_note (upsert-or-delete), get_all_notes.
## API
- GET /api/notes → { "video:<id>": body, "channel:<plat>/<handle>": body }
fetched once on page load, held in memory for indicators + search
- POST /api/notes/:kind/:id { body } → upsert/delete
## Web UI
- 📝 button on every video card; turns accent-colored when a note
exists. Opens an edit-in-place modal.
- 📝 Add/Edit note… sub-action under each channel in the sidebar.
- Global search now also matches note bodies, so you can find a clip
by what you wrote about it.
## Desktop UI
- Note field in the video detail panel (bottom dock). Loads lazily
from the DB when the selection changes; persists on focus-loss.
## Restore
Backup import merges the notes table too (keep-later-timestamp,
same as watched/positions). Guarded with a table-exists check so
pre-notes backups still import cleanly. RestoreSummary gains
notes_added; both UIs show it.
4 new tests (2 notes DB behavior, restore merge already covered the
shape). 79 pass total.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
2696a51cc8
commit
865ad87b22
4 changed files with 300 additions and 5 deletions
41
src/app.rs
41
src/app.rs
|
|
@ -101,6 +101,12 @@ pub struct App {
|
|||
library: Vec<library::Channel>,
|
||||
sidebar_view: SidebarView,
|
||||
selected_video: Option<String>,
|
||||
/// 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<String>,
|
||||
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}"),
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue