| 2 |
2 |
|
|
| 3 |
3 |
|
use crate::db::Database;
|
| 4 |
4 |
|
use crate::error::{CoreError, Result};
|
| 5 |
|
- |
use crate::util::escape_like;
|
| 6 |
5 |
|
use tracing::instrument;
|
| 7 |
6 |
|
|
|
7 |
+ |
/// Tag rules for audiofiles: deep hierarchy, no required semantic prefix.
|
|
8 |
+ |
const TAG_CONFIG: tagtree::TagConfig = tagtree::TagConfig {
|
|
9 |
+ |
max_depth: 5,
|
|
10 |
+ |
max_length: 100,
|
|
11 |
+ |
semantic_depth: 0,
|
|
12 |
+ |
};
|
|
13 |
+ |
|
| 8 |
14 |
|
/// Validate a dot-notation tag.
|
| 9 |
15 |
|
///
|
| 10 |
16 |
|
/// Rules:
|
| 12 |
18 |
|
/// - No spaces, no consecutive dots, no leading/trailing dots
|
| 13 |
19 |
|
/// - Max 5 levels (4 dots), max 100 chars
|
| 14 |
20 |
|
pub fn validate_tag(tag: &str) -> Result<()> {
|
| 15 |
|
- |
if tag.is_empty() {
|
| 16 |
|
- |
return Err(CoreError::TagInvalid("tag cannot be empty".into()));
|
| 17 |
|
- |
}
|
| 18 |
|
- |
if tag.len() > 100 {
|
| 19 |
|
- |
return Err(CoreError::TagInvalid(
|
| 20 |
|
- |
"tag exceeds 100 characters".into(),
|
| 21 |
|
- |
));
|
| 22 |
|
- |
}
|
| 23 |
|
- |
if tag.starts_with('.') || tag.ends_with('.') {
|
| 24 |
|
- |
return Err(CoreError::TagInvalid(
|
| 25 |
|
- |
"tag cannot start or end with a dot".into(),
|
| 26 |
|
- |
));
|
| 27 |
|
- |
}
|
| 28 |
|
- |
if tag.contains("..") {
|
| 29 |
|
- |
return Err(CoreError::TagInvalid(
|
| 30 |
|
- |
"tag cannot contain consecutive dots".into(),
|
| 31 |
|
- |
));
|
| 32 |
|
- |
}
|
| 33 |
|
- |
let dot_count = tag.chars().filter(|&c| c == '.').count();
|
| 34 |
|
- |
if dot_count > 4 {
|
| 35 |
|
- |
return Err(CoreError::TagInvalid(
|
| 36 |
|
- |
"tag cannot exceed 5 levels (4 dots)".into(),
|
| 37 |
|
- |
));
|
| 38 |
|
- |
}
|
| 39 |
|
- |
for ch in tag.chars() {
|
| 40 |
|
- |
if !(ch.is_ascii_lowercase() || ch.is_ascii_digit() || ch == '-' || ch == '.') {
|
| 41 |
|
- |
return Err(CoreError::TagInvalid(format!(
|
| 42 |
|
- |
"invalid character '{ch}': only lowercase alphanumeric, hyphens, and dots allowed"
|
| 43 |
|
- |
)));
|
| 44 |
|
- |
}
|
| 45 |
|
- |
}
|
| 46 |
|
- |
Ok(())
|
|
21 |
+ |
tagtree::validate_with(tag, &TAG_CONFIG).map_err(|e| CoreError::TagInvalid(e.0))
|
| 47 |
22 |
|
}
|
| 48 |
23 |
|
|
| 49 |
24 |
|
/// Add a tag to a sample. Validates format first. Idempotent.
|
| 72 |
47 |
|
pub fn remove_tags_by_prefix(db: &Database, hash: &str, prefix: &str) -> Result<()> {
|
| 73 |
48 |
|
db.conn().execute(
|
| 74 |
49 |
|
"DELETE FROM tags WHERE sample_hash = ?1 AND (tag = ?2 OR tag LIKE ?3 ESCAPE '\\')",
|
| 75 |
|
- |
rusqlite::params![hash, prefix, format!("{}.%", escape_like(prefix))],
|
|
50 |
+ |
rusqlite::params![hash, prefix, tagtree::like_descendant_pattern(prefix)],
|
| 76 |
51 |
|
)?;
|
| 77 |
52 |
|
Ok(())
|
| 78 |
53 |
|
}
|
| 104 |
79 |
|
"SELECT DISTINCT sample_hash FROM tags WHERE tag = ?1 OR tag LIKE ?2 ESCAPE '\\' ORDER BY sample_hash",
|
| 105 |
80 |
|
)?;
|
| 106 |
81 |
|
let rows = stmt.query_map(
|
| 107 |
|
- |
rusqlite::params![prefix, format!("{}.%", escape_like(prefix))],
|
|
82 |
+ |
rusqlite::params![prefix, tagtree::like_descendant_pattern(prefix)],
|
| 108 |
83 |
|
|row| row.get(0),
|
| 109 |
84 |
|
)?;
|
| 110 |
85 |
|
Ok(rows.collect::<std::result::Result<Vec<_>, _>>()?)
|
| 166 |
141 |
|
let mut stmt = db
|
| 167 |
142 |
|
.conn()
|
| 168 |
143 |
|
.prepare("SELECT DISTINCT tag FROM tags WHERE tag LIKE ?1 ESCAPE '\\' ORDER BY tag")?;
|
| 169 |
|
- |
let rows = stmt.query_map([format!("{}.%", escape_like(prefix))], |row| {
|
|
144 |
+ |
let rows = stmt.query_map([tagtree::like_descendant_pattern(prefix)], |row| {
|
| 170 |
145 |
|
row.get::<_, String>(0)
|
| 171 |
146 |
|
})?;
|
| 172 |
147 |
|
rows.collect::<std::result::Result<Vec<_>, _>>()?
|
| 173 |
148 |
|
};
|
| 174 |
149 |
|
|
| 175 |
|
- |
let strip_depth = if prefix.is_empty() {
|
| 176 |
|
- |
0
|
| 177 |
|
- |
} else {
|
| 178 |
|
- |
prefix.len() + 1 // skip the prefix + the dot
|
| 179 |
|
- |
};
|
| 180 |
|
- |
|
| 181 |
|
- |
let mut children: Vec<String> = Vec::new();
|
| 182 |
|
- |
for tag in &tags {
|
| 183 |
|
- |
if tag.len() <= strip_depth {
|
| 184 |
|
- |
continue;
|
| 185 |
|
- |
}
|
| 186 |
|
- |
let suffix = &tag[strip_depth..];
|
| 187 |
|
- |
let child = match suffix.find('.') {
|
| 188 |
|
- |
Some(pos) => &suffix[..pos],
|
| 189 |
|
- |
None => suffix,
|
| 190 |
|
- |
};
|
| 191 |
|
- |
if !child.is_empty()
|
| 192 |
|
- |
&& children.last().is_none_or(|last| last != child)
|
| 193 |
|
- |
{
|
| 194 |
|
- |
children.push(child.to_string());
|
| 195 |
|
- |
}
|
| 196 |
|
- |
}
|
| 197 |
|
- |
children.sort();
|
| 198 |
|
- |
children.dedup();
|
| 199 |
|
- |
Ok(children)
|
|
150 |
+ |
Ok(tagtree::children_at_prefix(prefix, &tags))
|
| 200 |
151 |
|
}
|
| 201 |
152 |
|
|
| 202 |
153 |
|
#[cfg(test)]
|