1#![allow(dead_code)]
9
10use std::collections::{HashMap, HashSet};
11
12use crate::clip::ClipId;
13
14#[derive(Debug, Clone, PartialEq, Eq, Hash)]
16pub struct ColorLabel {
17 pub name: String,
19 pub color: String,
21 pub shortcut: Option<u8>,
23}
24
25impl ColorLabel {
26 #[must_use]
28 pub fn new(name: impl Into<String>, color: impl Into<String>) -> Self {
29 Self {
30 name: name.into(),
31 color: color.into(),
32 shortcut: None,
33 }
34 }
35
36 #[must_use]
38 pub fn with_shortcut(mut self, shortcut: u8) -> Self {
39 self.shortcut = Some(shortcut.min(9));
40 self
41 }
42
43 #[must_use]
45 pub fn rgb(&self) -> Option<(u8, u8, u8)> {
46 let hex = self.color.trim_start_matches('#');
47 if hex.len() != 6 {
48 return None;
49 }
50 let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
51 let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
52 let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
53 Some((r, g, b))
54 }
55}
56
57pub struct StandardLabels;
59
60impl StandardLabels {
61 #[must_use]
63 pub fn production() -> Vec<ColorLabel> {
64 vec![
65 ColorLabel::new("Interview", "#4A90D9").with_shortcut(1),
66 ColorLabel::new("B-Roll", "#7ED321").with_shortcut(2),
67 ColorLabel::new("Music", "#BD10E0").with_shortcut(3),
68 ColorLabel::new("SFX", "#F5A623").with_shortcut(4),
69 ColorLabel::new("Graphics", "#D0021B").with_shortcut(5),
70 ColorLabel::new("Voiceover", "#50E3C2").with_shortcut(6),
71 ColorLabel::new("Approved", "#417505").with_shortcut(7),
72 ColorLabel::new("Rejected", "#9B9B9B").with_shortcut(8),
73 ColorLabel::new("Review", "#F8E71C").with_shortcut(9),
74 ]
75 }
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Hash)]
80pub struct Tag {
81 pub key: String,
83 pub value: String,
85}
86
87impl Tag {
88 #[must_use]
90 pub fn new(key: impl Into<String>, value: impl Into<String>) -> Self {
91 Self {
92 key: key.into(),
93 value: value.into(),
94 }
95 }
96
97 #[must_use]
99 pub fn simple(key: impl Into<String>) -> Self {
100 Self {
101 key: key.into(),
102 value: String::new(),
103 }
104 }
105}
106
107#[derive(Debug, Default)]
109pub struct LabelManager {
110 labels: Vec<ColorLabel>,
112 clip_labels: HashMap<ClipId, String>,
114 clip_tags: HashMap<ClipId, Vec<Tag>>,
116 known_tag_keys: HashSet<String>,
118}
119
120impl LabelManager {
121 #[must_use]
123 pub fn new() -> Self {
124 Self {
125 labels: Vec::new(),
126 clip_labels: HashMap::new(),
127 clip_tags: HashMap::new(),
128 known_tag_keys: HashSet::new(),
129 }
130 }
131
132 #[must_use]
134 pub fn with_standard_labels() -> Self {
135 let mut mgr = Self::new();
136 mgr.labels = StandardLabels::production();
137 mgr
138 }
139
140 pub fn add_label(&mut self, label: ColorLabel) {
142 if !self.labels.iter().any(|l| l.name == label.name) {
143 self.labels.push(label);
144 }
145 }
146
147 pub fn remove_label(&mut self, name: &str) -> bool {
149 let len_before = self.labels.len();
150 self.labels.retain(|l| l.name != name);
151 self.clip_labels.retain(|_, v| v != name);
153 self.labels.len() < len_before
154 }
155
156 #[must_use]
158 pub fn all_labels(&self) -> &[ColorLabel] {
159 &self.labels
160 }
161
162 #[must_use]
164 pub fn get_label(&self, name: &str) -> Option<&ColorLabel> {
165 self.labels.iter().find(|l| l.name == name)
166 }
167
168 pub fn set_clip_label(&mut self, clip_id: ClipId, label_name: &str) -> bool {
170 if self.labels.iter().any(|l| l.name == label_name) {
171 self.clip_labels.insert(clip_id, label_name.to_string());
172 true
173 } else {
174 false
175 }
176 }
177
178 pub fn remove_clip_label(&mut self, clip_id: ClipId) -> Option<String> {
180 self.clip_labels.remove(&clip_id)
181 }
182
183 #[must_use]
185 pub fn get_clip_label(&self, clip_id: ClipId) -> Option<&ColorLabel> {
186 let label_name = self.clip_labels.get(&clip_id)?;
187 self.get_label(label_name)
188 }
189
190 pub fn add_clip_tag(&mut self, clip_id: ClipId, tag: Tag) {
192 self.known_tag_keys.insert(tag.key.clone());
193 let tags = self.clip_tags.entry(clip_id).or_default();
194 if !tags.contains(&tag) {
196 tags.push(tag);
197 }
198 }
199
200 pub fn remove_clip_tag(&mut self, clip_id: ClipId, key: &str) -> bool {
202 if let Some(tags) = self.clip_tags.get_mut(&clip_id) {
203 let len_before = tags.len();
204 tags.retain(|t| t.key != key);
205 tags.len() < len_before
206 } else {
207 false
208 }
209 }
210
211 #[must_use]
213 pub fn get_clip_tags(&self, clip_id: ClipId) -> &[Tag] {
214 self.clip_tags
215 .get(&clip_id)
216 .map(Vec::as_slice)
217 .unwrap_or(&[])
218 }
219
220 #[must_use]
222 pub fn clips_with_label(&self, label_name: &str) -> Vec<ClipId> {
223 self.clip_labels
224 .iter()
225 .filter(|(_, v)| v.as_str() == label_name)
226 .map(|(&k, _)| k)
227 .collect()
228 }
229
230 #[must_use]
232 pub fn clips_with_tag_key(&self, key: &str) -> Vec<ClipId> {
233 self.clip_tags
234 .iter()
235 .filter(|(_, tags)| tags.iter().any(|t| t.key == key))
236 .map(|(&id, _)| id)
237 .collect()
238 }
239
240 #[must_use]
242 pub fn clips_with_tag(&self, key: &str, value: &str) -> Vec<ClipId> {
243 self.clip_tags
244 .iter()
245 .filter(|(_, tags)| tags.iter().any(|t| t.key == key && t.value == value))
246 .map(|(&id, _)| id)
247 .collect()
248 }
249
250 #[must_use]
252 pub fn known_tag_keys(&self) -> Vec<&str> {
253 self.known_tag_keys.iter().map(String::as_str).collect()
254 }
255
256 pub fn remove_clip(&mut self, clip_id: ClipId) {
258 self.clip_labels.remove(&clip_id);
259 self.clip_tags.remove(&clip_id);
260 }
261
262 pub fn clear(&mut self) {
264 self.clip_labels.clear();
265 self.clip_tags.clear();
266 }
267}
268
269#[cfg(test)]
274mod tests {
275 use super::*;
276
277 #[test]
278 fn test_color_label_rgb() {
279 let label = ColorLabel::new("Test", "#FF8800");
280 let rgb = label.rgb();
281 assert_eq!(rgb, Some((255, 136, 0)));
282 }
283
284 #[test]
285 fn test_color_label_invalid_rgb() {
286 let label = ColorLabel::new("Bad", "not-a-color");
287 assert!(label.rgb().is_none());
288 }
289
290 #[test]
291 fn test_color_label_shortcut() {
292 let label = ColorLabel::new("Test", "#000000").with_shortcut(5);
293 assert_eq!(label.shortcut, Some(5));
294 let label2 = ColorLabel::new("Test2", "#000000").with_shortcut(15);
296 assert_eq!(label2.shortcut, Some(9));
297 }
298
299 #[test]
300 fn test_standard_labels() {
301 let labels = StandardLabels::production();
302 assert_eq!(labels.len(), 9);
303 assert_eq!(labels[0].name, "Interview");
304 assert!(labels[0].rgb().is_some());
305 }
306
307 #[test]
308 fn test_tag_creation() {
309 let tag = Tag::new("scene", "Scene 1");
310 assert_eq!(tag.key, "scene");
311 assert_eq!(tag.value, "Scene 1");
312
313 let simple = Tag::simple("favorite");
314 assert_eq!(simple.key, "favorite");
315 assert!(simple.value.is_empty());
316 }
317
318 #[test]
319 fn test_label_manager_add_remove() {
320 let mut mgr = LabelManager::new();
321 mgr.add_label(ColorLabel::new("Test", "#FF0000"));
322 assert_eq!(mgr.all_labels().len(), 1);
323
324 mgr.add_label(ColorLabel::new("Test", "#00FF00"));
326 assert_eq!(mgr.all_labels().len(), 1);
327
328 assert!(mgr.remove_label("Test"));
329 assert_eq!(mgr.all_labels().len(), 0);
330 }
331
332 #[test]
333 fn test_label_manager_clip_label() {
334 let mut mgr = LabelManager::with_standard_labels();
335
336 assert!(mgr.set_clip_label(1, "Interview"));
338 assert!(!mgr.set_clip_label(2, "NonExistent"));
339
340 let label = mgr.get_clip_label(1);
342 assert!(label.is_some());
343 assert_eq!(label.expect("should exist").name, "Interview");
344
345 assert!(mgr.get_clip_label(2).is_none());
346
347 assert!(mgr.remove_clip_label(1).is_some());
349 assert!(mgr.get_clip_label(1).is_none());
350 }
351
352 #[test]
353 fn test_label_manager_clip_tags() {
354 let mut mgr = LabelManager::new();
355
356 mgr.add_clip_tag(1, Tag::new("scene", "1"));
357 mgr.add_clip_tag(1, Tag::new("take", "3"));
358 mgr.add_clip_tag(1, Tag::new("scene", "1")); assert_eq!(mgr.get_clip_tags(1).len(), 2);
361 assert_eq!(mgr.get_clip_tags(999).len(), 0);
362
363 assert!(mgr.remove_clip_tag(1, "scene"));
364 assert_eq!(mgr.get_clip_tags(1).len(), 1);
365 assert!(!mgr.remove_clip_tag(1, "nonexistent"));
366 }
367
368 #[test]
369 fn test_label_manager_find_clips() {
370 let mut mgr = LabelManager::with_standard_labels();
371 mgr.set_clip_label(1, "Interview");
372 mgr.set_clip_label(2, "Interview");
373 mgr.set_clip_label(3, "B-Roll");
374
375 let interviews = mgr.clips_with_label("Interview");
376 assert_eq!(interviews.len(), 2);
377
378 let broll = mgr.clips_with_label("B-Roll");
379 assert_eq!(broll.len(), 1);
380 }
381
382 #[test]
383 fn test_label_manager_find_by_tag() {
384 let mut mgr = LabelManager::new();
385 mgr.add_clip_tag(1, Tag::new("scene", "1"));
386 mgr.add_clip_tag(2, Tag::new("scene", "2"));
387 mgr.add_clip_tag(3, Tag::new("take", "1"));
388
389 assert_eq!(mgr.clips_with_tag_key("scene").len(), 2);
390 assert_eq!(mgr.clips_with_tag("scene", "1").len(), 1);
391 }
392
393 #[test]
394 fn test_label_manager_known_keys() {
395 let mut mgr = LabelManager::new();
396 mgr.add_clip_tag(1, Tag::new("scene", "1"));
397 mgr.add_clip_tag(2, Tag::new("take", "1"));
398 let keys = mgr.known_tag_keys();
399 assert_eq!(keys.len(), 2);
400 }
401
402 #[test]
403 fn test_label_manager_remove_clip() {
404 let mut mgr = LabelManager::with_standard_labels();
405 mgr.set_clip_label(1, "Interview");
406 mgr.add_clip_tag(1, Tag::new("scene", "1"));
407 mgr.remove_clip(1);
408 assert!(mgr.get_clip_label(1).is_none());
409 assert!(mgr.get_clip_tags(1).is_empty());
410 }
411
412 #[test]
413 fn test_label_manager_clear() {
414 let mut mgr = LabelManager::with_standard_labels();
415 mgr.set_clip_label(1, "Interview");
416 mgr.add_clip_tag(1, Tag::new("scene", "1"));
417 mgr.clear();
418 assert!(mgr.get_clip_label(1).is_none());
419 assert!(mgr.get_clip_tags(1).is_empty());
420 }
421
422 #[test]
423 fn test_removing_label_definition_removes_from_clips() {
424 let mut mgr = LabelManager::new();
425 mgr.add_label(ColorLabel::new("Custom", "#AABBCC"));
426 mgr.set_clip_label(1, "Custom");
427 assert!(mgr.get_clip_label(1).is_some());
428 mgr.remove_label("Custom");
429 assert!(mgr.get_clip_label(1).is_none());
430 }
431
432 #[test]
435 fn test_color_label_without_shortcut_is_none() {
436 let label = ColorLabel::new("NoShortcut", "#123456");
437 assert!(label.shortcut.is_none());
438 }
439
440 #[test]
441 fn test_color_label_shortcut_clamp_zero() {
442 let label = ColorLabel::new("Zero", "#000000").with_shortcut(0);
444 assert_eq!(label.shortcut, Some(0));
445 }
446
447 #[test]
448 fn test_color_label_rgb_black() {
449 let label = ColorLabel::new("Black", "#000000");
450 assert_eq!(label.rgb(), Some((0, 0, 0)));
451 }
452
453 #[test]
454 fn test_color_label_rgb_white() {
455 let label = ColorLabel::new("White", "#FFFFFF");
456 assert_eq!(label.rgb(), Some((255, 255, 255)));
457 }
458
459 #[test]
460 fn test_color_label_rgb_lowercase_hex() {
461 let label = ColorLabel::new("Lower", "#aabbcc");
463 assert_eq!(label.rgb(), Some((0xAA, 0xBB, 0xCC)));
464 }
465
466 #[test]
467 fn test_color_label_rgb_short_hex_invalid() {
468 let label = ColorLabel::new("Short", "#ABC");
470 assert!(label.rgb().is_none());
471 }
472
473 #[test]
474 fn test_standard_labels_all_have_shortcuts() {
475 let labels = StandardLabels::production();
476 for label in &labels {
477 assert!(
478 label.shortcut.is_some(),
479 "Label '{}' has no shortcut",
480 label.name
481 );
482 }
483 }
484
485 #[test]
486 fn test_standard_labels_all_have_valid_rgb() {
487 let labels = StandardLabels::production();
488 for label in &labels {
489 assert!(
490 label.rgb().is_some(),
491 "Label '{}' has invalid color '{}'",
492 label.name,
493 label.color
494 );
495 }
496 }
497
498 #[test]
499 fn test_tag_equality() {
500 let a = Tag::new("scene", "1");
501 let b = Tag::new("scene", "1");
502 let c = Tag::new("scene", "2");
503 assert_eq!(a, b);
504 assert_ne!(a, c);
505 }
506
507 #[test]
508 fn test_label_manager_multiple_tags_same_key_different_values() {
509 let mut mgr = LabelManager::new();
511 mgr.add_clip_tag(1, Tag::new("actor", "Alice"));
512 mgr.add_clip_tag(1, Tag::new("actor", "Bob"));
513 assert_eq!(mgr.get_clip_tags(1).len(), 2);
514 }
515
516 #[test]
517 fn test_label_manager_clips_with_tag_value_exact_match() {
518 let mut mgr = LabelManager::new();
519 mgr.add_clip_tag(1, Tag::new("rating", "5"));
520 mgr.add_clip_tag(2, Tag::new("rating", "3"));
521 mgr.add_clip_tag(3, Tag::new("rating", "5"));
522
523 let five_star = mgr.clips_with_tag("rating", "5");
524 assert_eq!(five_star.len(), 2);
525 assert!(!five_star.contains(&2));
526 }
527
528 #[test]
529 fn test_label_manager_remove_clip_clears_both_label_and_tags() {
530 let mut mgr = LabelManager::with_standard_labels();
531 mgr.set_clip_label(42, "Music");
532 mgr.add_clip_tag(42, Tag::new("key", "value"));
533 mgr.remove_clip(42);
534 assert!(mgr.get_clip_label(42).is_none());
535 assert!(mgr.get_clip_tags(42).is_empty());
536 }
537
538 #[test]
539 fn test_label_manager_known_tag_keys_deduplicated() {
540 let mut mgr = LabelManager::new();
541 mgr.add_clip_tag(1, Tag::new("scene", "A"));
543 mgr.add_clip_tag(2, Tag::new("scene", "B"));
544 mgr.add_clip_tag(3, Tag::new("take", "1"));
545 let keys = mgr.known_tag_keys();
546 assert_eq!(keys.len(), 2, "Should deduplicate 'scene' key");
547 }
548
549 #[test]
550 fn test_label_manager_set_clip_label_updates_existing() {
551 let mut mgr = LabelManager::with_standard_labels();
552 mgr.set_clip_label(1, "Interview");
553 mgr.set_clip_label(1, "B-Roll"); let label = mgr.get_clip_label(1).expect("should exist");
555 assert_eq!(label.name, "B-Roll");
556 }
557
558 #[test]
559 fn test_label_manager_clips_with_label_empty_when_none_assigned() {
560 let mgr = LabelManager::with_standard_labels();
561 let clips = mgr.clips_with_label("Interview");
562 assert!(clips.is_empty());
563 }
564}