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>,
|
library: Vec<library::Channel>,
|
||||||
sidebar_view: SidebarView,
|
sidebar_view: SidebarView,
|
||||||
selected_video: Option<String>,
|
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,
|
search: String,
|
||||||
downloader: Downloader,
|
downloader: Downloader,
|
||||||
show_downloads: bool,
|
show_downloads: bool,
|
||||||
|
|
@ -351,6 +357,8 @@ impl App {
|
||||||
library,
|
library,
|
||||||
sidebar_view: SidebarView::All,
|
sidebar_view: SidebarView::All,
|
||||||
selected_video: None,
|
selected_video: None,
|
||||||
|
note_buffer: String::new(),
|
||||||
|
note_target: None,
|
||||||
search: String::new(),
|
search: String::new(),
|
||||||
downloader: Downloader::new(channels_root, browser, max_concurrent, use_bundled_ytdlp, use_pot_provider),
|
downloader: Downloader::new(channels_root, browser, max_concurrent, use_bundled_ytdlp, use_pot_provider),
|
||||||
show_downloads: false,
|
show_downloads: false,
|
||||||
|
|
@ -2715,6 +2723,35 @@ impl App {
|
||||||
|
|
||||||
ui.separator();
|
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);
|
let description = self.description(&video);
|
||||||
egui::ScrollArea::vertical()
|
egui::ScrollArea::vertical()
|
||||||
.auto_shrink([false, false])
|
.auto_shrink([false, false])
|
||||||
|
|
@ -3322,9 +3359,9 @@ impl eframe::App for App {
|
||||||
// Rescan so channel rows pick up new folder assignments.
|
// Rescan so channel rows pick up new folder assignments.
|
||||||
self.rescan();
|
self.rescan();
|
||||||
self.status = format!(
|
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.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}"),
|
Err(e) => self.status = format!("Restore failed: {e}"),
|
||||||
|
|
|
||||||
153
src/database.rs
153
src/database.rs
|
|
@ -170,6 +170,19 @@ impl Database {
|
||||||
duration_secs REAL,
|
duration_secs REAL,
|
||||||
has_chapters INTEGER NOT NULL DEFAULT 0,
|
has_chapters INTEGER NOT NULL DEFAULT 0,
|
||||||
upload_date TEXT
|
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(())
|
Ok(())
|
||||||
|
|
@ -350,6 +363,58 @@ impl Database {
|
||||||
Ok(bundle)
|
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<Option<String>> {
|
||||||
|
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<HashMap<(String, String), String>> {
|
||||||
|
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
|
/// Bulk fetch of every channel's options, returned as
|
||||||
/// `((platform, handle) → options_json)`. Used by the library scanner to
|
/// `((platform, handle) → options_json)`. Used by the library scanner to
|
||||||
/// attach options to each scanned [`crate::library::Channel`] without
|
/// attach options to each scanned [`crate::library::Channel`] without
|
||||||
|
|
@ -700,6 +765,39 @@ impl Database {
|
||||||
"SELECT count(*) FROM channel_assignments", [], |r| r.get(0))?;
|
"SELECT count(*) FROM channel_assignments", [], |r| r.get(0))?;
|
||||||
let assignments_added = (assignments_after - assignments_before).max(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 {
|
Ok(RestoreSummary {
|
||||||
watched_added: watched_added as u64,
|
watched_added: watched_added as u64,
|
||||||
positions_added: positions_added as u64,
|
positions_added: positions_added as u64,
|
||||||
|
|
@ -707,6 +805,7 @@ impl Database {
|
||||||
flags_added: flags_added as u64,
|
flags_added: flags_added as u64,
|
||||||
folders_added: folders_added as u64,
|
folders_added: folders_added as u64,
|
||||||
assignments_added: assignments_added as u64,
|
assignments_added: assignments_added as u64,
|
||||||
|
notes_added: notes_added as u64,
|
||||||
})
|
})
|
||||||
})();
|
})();
|
||||||
match summary {
|
match summary {
|
||||||
|
|
@ -738,6 +837,7 @@ pub struct RestoreSummary {
|
||||||
pub flags_added: u64,
|
pub flags_added: u64,
|
||||||
pub folders_added: u64,
|
pub folders_added: u64,
|
||||||
pub assignments_added: u64,
|
pub assignments_added: u64,
|
||||||
|
pub notes_added: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
|
|
@ -851,6 +951,59 @@ mod restore_tests {
|
||||||
let msg = format!("{err}");
|
let msg = format!("{err}");
|
||||||
assert!(msg.contains("missing required table"), "{msg}");
|
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
|
/// Translate an `r2d2::Error` from `Pool::build()` into a `rusqlite::Error` so
|
||||||
|
|
|
||||||
36
src/web.rs
36
src/web.rs
|
|
@ -1985,6 +1985,9 @@ async fn post_assign_folder(
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct FlagToggleBody { value: bool }
|
struct FlagToggleBody { value: bool }
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct NoteBody { body: String }
|
||||||
|
|
||||||
/// `POST /api/videos/:id/flags/:flag` — set or clear a single per-video
|
/// `POST /api/videos/:id/flags/:flag` — set or clear a single per-video
|
||||||
/// flag (`bookmark` / `favourite` / `waiting` / `archive`). The watched
|
/// flag (`bookmark` / `favourite` / `waiting` / `archive`). The watched
|
||||||
/// flag is handled by the separate `POST /api/watched/:id` endpoint for
|
/// 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()
|
(StatusCode::OK, "ok").into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET /api/notes` — return every note as a map of
|
||||||
|
/// `"<kind>:<id>" → 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<Arc<WebState>>) -> 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<String, String> = 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<Arc<WebState>>,
|
||||||
|
Path((kind, id)): Path<(String, String)>,
|
||||||
|
Json(body): Json<NoteBody>,
|
||||||
|
) -> 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
|
/// `GET /api/channels/:platform/:handle/options` — fetch the stored
|
||||||
/// download-option overrides for a single channel. Returns the default
|
/// download-option overrides for a single channel. Returns the default
|
||||||
/// (empty) options when nothing is stored.
|
/// (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/download", post(post_download))
|
||||||
.route("/api/watched/:id", post(post_watched))
|
.route("/api/watched/:id", post(post_watched))
|
||||||
.route("/api/videos/:id/flags/:flag", post(post_video_flag))
|
.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", post(post_create_folder))
|
||||||
.route("/api/folders/:id/rename", post(post_rename_folder))
|
.route("/api/folders/:id/rename", post(post_rename_folder))
|
||||||
.route("/api/folders/:id/check", post(post_check_folder))
|
.route("/api/folders/:id/check", post(post_check_folder))
|
||||||
|
|
|
||||||
|
|
@ -235,6 +235,17 @@ let library=[], channelUrls=[], activeChannelIdx=null, activePlaylistIdx=null, s
|
||||||
// Folder list from the most-recent /api/library response; populated by
|
// Folder list from the most-recent /api/library response; populated by
|
||||||
// loadLibrary() and read by renderSidebar() to draw the Folders group.
|
// loadLibrary() and read by renderSidebar() to draw the Folders group.
|
||||||
let librarySnapshotFolders=[];
|
let librarySnapshotFolders=[];
|
||||||
|
// Notes map: "video:<id>" or "channel:<platform>/<handle>" → 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.
|
// Smart-folder view selectors — at most one is true at a time.
|
||||||
let showFavourites=false, showBookmarks=false, showWaiting=false;
|
let showFavourites=false, showBookmarks=false, showWaiting=false;
|
||||||
// Filter chips above the grid. Each is independent; the grid filters by
|
// Filter chips above the grid. Each is independent; the grid filters by
|
||||||
|
|
@ -320,6 +331,8 @@ function renderSidebar(){
|
||||||
s+=`<div class="ch-sub" style="color:var(--accent)" onclick="downloadChannelByIdx(${i})">⬇ Check for new videos</div>`;
|
s+=`<div class="ch-sub" style="color:var(--accent)" onclick="downloadChannelByIdx(${i})">⬇ Check for new videos</div>`;
|
||||||
s+=`<div class="ch-sub" onclick="openChannelOptions(${i})">⚙ Channel options…</div>`;
|
s+=`<div class="ch-sub" onclick="openChannelOptions(${i})">⚙ Channel options…</div>`;
|
||||||
s+=`<div class="ch-sub" onclick="openMoveToFolder(${i})">📁 Move to folder…</div>`;
|
s+=`<div class="ch-sub" onclick="openMoveToFolder(${i})">📁 Move to folder…</div>`;
|
||||||
|
const hasNote=!!channelNote(ch.platform,ch.name);
|
||||||
|
s+=`<div class="ch-sub" onclick="editChannelNote(${i})"${hasNote?' style="color:var(--accent)"':''}>📝 ${hasNote?'Edit note…':'Add note…'}</div>`;
|
||||||
}
|
}
|
||||||
return s;
|
return s;
|
||||||
};
|
};
|
||||||
|
|
@ -588,6 +601,7 @@ function renderGrid(){
|
||||||
<button onclick="toggleFlag('${v.id}','favourite',this)" title="Favourite" style="${v.favourite?'color:#facc15':''}">${v.favourite?'★':'☆'}</button>
|
<button onclick="toggleFlag('${v.id}','favourite',this)" title="Favourite" style="${v.favourite?'color:#facc15':''}">${v.favourite?'★':'☆'}</button>
|
||||||
<button onclick="toggleFlag('${v.id}','bookmark',this)" title="Bookmark" style="${v.bookmark?'color:var(--accent)':''}">🔖</button>
|
<button onclick="toggleFlag('${v.id}','bookmark',this)" title="Bookmark" style="${v.bookmark?'color:var(--accent)':''}">🔖</button>
|
||||||
<button onclick="toggleFlag('${v.id}','waiting',this)" title="Waiting list" style="${v.waiting?'color:var(--accent)':''}">⏳</button>
|
<button onclick="toggleFlag('${v.id}','waiting',this)" title="Waiting list" style="${v.waiting?'color:var(--accent)':''}">⏳</button>
|
||||||
|
<button onclick="editNote('video','${v.id}',${JSON.stringify(v.title).replace(/"/g,'"')})" title="${videoNote(v.id)?'Edit note':'Add note'}" style="${videoNote(v.id)?'color:var(--accent)':''}">📝</button>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|
@ -618,6 +632,56 @@ async function toggleFlag(videoId,flag,btn){
|
||||||
setStatus('Error: '+e.message);
|
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
|
||||||
|
// '<platform>/<handle>'. `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=`<div class="modal" style="max-width:480px;width:100%">
|
||||||
|
<div class="modal-hdr"><h2>📝 Note — ${esc(title||id)}</h2><button onclick="this.closest('.modal-bg').remove()">✕</button></div>
|
||||||
|
<textarea id="note-text" style="width:100%;min-height:160px;background:var(--bg);color:var(--text);border:1px solid var(--border);border-radius:4px;padding:8px;font-size:13px;resize:vertical" placeholder="Anything you want to remember about this…">${esc(current)}</textarea>
|
||||||
|
<div class="settings-hint">Notes are searchable from the filter box. Leave empty to delete.</div>
|
||||||
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
||||||
|
<button onclick="this.closest('.modal-bg').remove()">Cancel</button>
|
||||||
|
<button class="primary" onclick="saveNote('${esc(kind)}','${esc(id)}',this)">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
document.body.appendChild(bg);
|
||||||
|
setTimeout(()=>document.getElementById('note-text')?.focus(),0);
|
||||||
|
}
|
||||||
|
// Channel note: keyed by "<platform>/<handle>". 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(){
|
function renderChannelGrid(){
|
||||||
const q=(document.getElementById('search')?.value||'').toLowerCase();
|
const q=(document.getElementById('search')?.value||'').toLowerCase();
|
||||||
const grid=document.getElementById('grid');
|
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});
|
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())}
|
if(!r.ok){throw new Error(await r.text())}
|
||||||
const s=await r.json();
|
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');
|
setStatus('Library snapshot imported');
|
||||||
// Refresh the sidebar / grid so the new flags / folders show up.
|
// Refresh the sidebar / grid so the new flags / folders show up.
|
||||||
loadLibrary();
|
loadLibrary();
|
||||||
|
|
@ -1906,7 +1972,10 @@ function matchesSearch(v,chName,q){
|
||||||
const t=(v.title||'').toLowerCase();
|
const t=(v.title||'').toLowerCase();
|
||||||
const id=(v.id||'').toLowerCase();
|
const id=(v.id||'').toLowerCase();
|
||||||
const ch=(chName||'').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 p(n){return String(n).padStart(2,'0')}
|
||||||
function esc(s){return String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
function esc(s){return String(s??'').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"')}
|
||||||
|
|
@ -1944,7 +2013,7 @@ applyTheme(localStorage.getItem('theme')||'dark');
|
||||||
// before we can resolve `channel:<name>` back to an index — do that
|
// before we can resolve `channel:<name>` back to an index — do that
|
||||||
// after loadLibrary() resolves.
|
// after loadLibrary() resolves.
|
||||||
(async()=>{
|
(async()=>{
|
||||||
await loadLibrary();
|
await Promise.all([loadLibrary(), loadNotes()]);
|
||||||
restoreUiState();
|
restoreUiState();
|
||||||
renderSidebar();
|
renderSidebar();
|
||||||
renderGrid();
|
renderGrid();
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue