feat: save readable channel handles with stable URL mappings
This commit is contained in:
+151
-18
@@ -35,15 +35,40 @@ fn resolve_path(xdg: Option<OsString>, home: Option<OsString>) -> io::Result<Pat
|
||||
|
||||
pub fn load(path: &Path) -> io::Result<Config> {
|
||||
let (_, document) = read_document(path)?;
|
||||
parse_categories(&document)
|
||||
parse_categories(&document, &parse_channels(&document)?)
|
||||
}
|
||||
|
||||
pub fn add_channel(path: &Path, category: &str, channel: &str) -> io::Result<Config> {
|
||||
pub fn add_channel(path: &Path, category: &str, handle: &str, channel: &str) -> io::Result<Config> {
|
||||
let category = category_name(category)?;
|
||||
let handle = normalize_handle(handle)?;
|
||||
let channel = normalize_channel(channel)?;
|
||||
if !channel.starts_with("https://www.youtube.com/channel/") {
|
||||
return Err(invalid(
|
||||
"Channel mappings require a canonical UC channel ID",
|
||||
));
|
||||
}
|
||||
let (original, mut document) = read_document(path)?;
|
||||
// Validate every category before touching the document or creating directories.
|
||||
let mut config = parse_categories(&document)?;
|
||||
let mut mappings = parse_channels(&document)?;
|
||||
// Validate the whole file before changing it or creating directories.
|
||||
parse_categories(&document, &mappings)?;
|
||||
match mappings.get(&handle) {
|
||||
Some(existing) if existing != &channel => {
|
||||
return Err(invalid(format!(
|
||||
"Handle {handle:?} already maps to another channel"
|
||||
)));
|
||||
}
|
||||
Some(_) => {}
|
||||
None => {
|
||||
if !document.contains_key("channels") {
|
||||
document.insert("channels", Item::Table(Table::new()));
|
||||
}
|
||||
document["channels"]
|
||||
.as_table_like_mut()
|
||||
.expect("channel mappings were validated")
|
||||
.insert(&handle, value(&channel));
|
||||
mappings.insert(handle.clone(), channel.clone());
|
||||
}
|
||||
}
|
||||
if !document.contains_key("categories") {
|
||||
document.insert("categories", Item::Table(Table::new()));
|
||||
}
|
||||
@@ -63,12 +88,22 @@ pub fn add_channel(path: &Path, category: &str, channel: &str) -> io::Result<Con
|
||||
.get_mut(&key)
|
||||
.and_then(Item::as_array_mut)
|
||||
.expect("category was validated");
|
||||
let saved_channels = config.categories.entry(category.to_owned()).or_default();
|
||||
// Preserve manually written entries and their comments; do not append an equivalent URL.
|
||||
if !saved_channels.contains(&channel) {
|
||||
channels.push(channel.clone());
|
||||
saved_channels.push(channel);
|
||||
let mut found = false;
|
||||
for entry in channels.iter_mut() {
|
||||
if resolve_channel(entry.as_str().expect("channel was validated"), &mappings)? == channel {
|
||||
// Replace old URLs/aliases with the current handle, preserving entry comments.
|
||||
if entry.as_str() != Some(&handle) {
|
||||
let decor = entry.decor().clone();
|
||||
*entry = handle.as_str().into();
|
||||
*entry.decor_mut() = decor;
|
||||
}
|
||||
found = true;
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
channels.push(handle);
|
||||
}
|
||||
let config = parse_categories(&document, &mappings)?;
|
||||
save(path, original.as_deref(), document.to_string().as_bytes())?;
|
||||
Ok(config)
|
||||
}
|
||||
@@ -130,6 +165,54 @@ fn normalize_channel(input: &str) -> io::Result<String> {
|
||||
Ok(format!("https://www.youtube.com/channel/{id}"))
|
||||
}
|
||||
|
||||
fn normalize_handle(input: &str) -> io::Result<String> {
|
||||
let mut address = normalize_channel(input)?;
|
||||
let prefix = "https://www.youtube.com/";
|
||||
if !address[prefix.len()..].starts_with('@') {
|
||||
return Err(invalid("Expected a YouTube @handle"));
|
||||
}
|
||||
address.drain(..prefix.len());
|
||||
Ok(address)
|
||||
}
|
||||
|
||||
fn parse_channels(document: &DocumentMut) -> io::Result<BTreeMap<String, String>> {
|
||||
let mut mappings = BTreeMap::new();
|
||||
let Some(item) = document.get("channels") else {
|
||||
return Ok(mappings);
|
||||
};
|
||||
let channels = item
|
||||
.as_table_like()
|
||||
.ok_or_else(|| invalid("channels must be a TOML table"))?;
|
||||
for (handle, address) in channels.iter() {
|
||||
let handle = normalize_handle(handle)?;
|
||||
let address = normalize_channel(
|
||||
address
|
||||
.as_str()
|
||||
.ok_or_else(|| invalid("Channel mappings must be strings"))?,
|
||||
)?;
|
||||
if !address.starts_with("https://www.youtube.com/channel/") {
|
||||
return Err(invalid(
|
||||
"Channel mappings require a canonical UC channel ID",
|
||||
));
|
||||
}
|
||||
if mappings.insert(handle, address).is_some() {
|
||||
return Err(invalid("Duplicate channel handle after normalization"));
|
||||
}
|
||||
}
|
||||
Ok(mappings)
|
||||
}
|
||||
|
||||
fn resolve_channel(input: &str, mappings: &BTreeMap<String, String>) -> io::Result<String> {
|
||||
let address = normalize_channel(input)?;
|
||||
match address
|
||||
.strip_prefix("https://www.youtube.com/")
|
||||
.and_then(|handle| mappings.get(handle))
|
||||
{
|
||||
Some(mapped) => Ok(mapped.clone()),
|
||||
None => Ok(address),
|
||||
}
|
||||
}
|
||||
|
||||
fn read_bytes(path: &Path) -> io::Result<Option<Vec<u8>>> {
|
||||
match fs::read(path) {
|
||||
Ok(bytes) => Ok(Some(bytes)),
|
||||
@@ -150,7 +233,10 @@ fn read_document(path: &Path) -> io::Result<(Option<Vec<u8>>, DocumentMut)> {
|
||||
Ok((bytes, document))
|
||||
}
|
||||
|
||||
fn parse_categories(document: &DocumentMut) -> io::Result<Config> {
|
||||
fn parse_categories(
|
||||
document: &DocumentMut,
|
||||
mappings: &BTreeMap<String, String>,
|
||||
) -> io::Result<Config> {
|
||||
let mut config = Config::default();
|
||||
let Some(item) = document.get("categories") else {
|
||||
return Ok(config);
|
||||
@@ -165,10 +251,11 @@ fn parse_categories(document: &DocumentMut) -> io::Result<Config> {
|
||||
.ok_or_else(|| invalid(format!("Category {name:?} must be an array of channels")))?;
|
||||
let mut normalized = Vec::with_capacity(channels.len());
|
||||
for channel in channels.iter() {
|
||||
let channel = normalize_channel(
|
||||
let channel = resolve_channel(
|
||||
channel
|
||||
.as_str()
|
||||
.ok_or_else(|| invalid("Channels must be strings"))?,
|
||||
mappings,
|
||||
)?;
|
||||
if !normalized.contains(&channel) {
|
||||
normalized.push(channel);
|
||||
@@ -278,22 +365,22 @@ mod tests {
|
||||
fn edits_preserve_manual_config_and_refuse_invalid_or_changed_files() {
|
||||
let directory = TestDirectory::new();
|
||||
let path = directory.0.join("nested/config.toml");
|
||||
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
||||
assert!(load(&path).unwrap().categories.is_empty());
|
||||
assert!(!path.parent().unwrap().exists());
|
||||
let config = add_channel(&path, " Music ", "@example").unwrap();
|
||||
let config = add_channel(&path, " Music ", "@example", id).unwrap();
|
||||
assert_eq!(
|
||||
config.categories["Music"],
|
||||
["https://www.youtube.com/@example"]
|
||||
[format!("https://www.youtube.com/channel/{id}")]
|
||||
);
|
||||
|
||||
let manual = "# manual preferences\nplayer = 'mpv' # keep me\n[other]\nvalue = 42\n[categories]\n# favorites\n' Music ' = ['@example', 'https://www.youtube.com/@example'] # channel note\nEmpty = []\n";
|
||||
fs::write(&path, manual).unwrap();
|
||||
let config = add_channel(&path, "Music", "https://youtube.com/@example").unwrap();
|
||||
let config = add_channel(&path, "Music", "https://youtube.com/@example", id).unwrap();
|
||||
assert_eq!(config.categories["Music"].len(), 1);
|
||||
assert!(config.categories["Empty"].is_empty());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
||||
let id = "UCabcdefghijklmnopqrstuv";
|
||||
let config = add_channel(&path, "Music", id).unwrap();
|
||||
let config = add_channel(&path, "Music", "@another", id).unwrap();
|
||||
assert_eq!(
|
||||
config.categories["Music"][1],
|
||||
format!("https://www.youtube.com/channel/{id}")
|
||||
@@ -318,20 +405,66 @@ mod tests {
|
||||
"[categories]\nMusic = [3]\n",
|
||||
"[categories]\nMusic = ['https://example.com/@example']\n",
|
||||
"[categories]\nMusic = []\n' Music ' = []\n",
|
||||
"channels = 3\n",
|
||||
"[channels]\n'@example' = 3\n",
|
||||
"[channels]\n'@example' = 'https://example.com/channel/UCabcdefghijklmnopqrstuv'\n",
|
||||
"[channels]\n'@example' = '@another'\n",
|
||||
"[channels]\n'not-a-handle' = 'UCabcdefghijklmnopqrstuv'\n",
|
||||
] {
|
||||
fs::write(&path, malformed).unwrap();
|
||||
assert!(load(&path).is_err());
|
||||
assert!(add_channel(&path, "Music", "@example").is_err());
|
||||
assert!(add_channel(&path, "Music", "@example", id).is_err());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), malformed);
|
||||
}
|
||||
fs::write(&path, manual).unwrap();
|
||||
assert!(add_channel(&path, "Music", "https://www.youtube.com/@example/videos").is_err());
|
||||
assert!(
|
||||
add_channel(
|
||||
&path,
|
||||
"Music",
|
||||
"https://www.youtube.com/@example/videos",
|
||||
id
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
||||
assert!(save(&path, Some(b"previous contents"), b"replacement").is_err());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), manual);
|
||||
assert_eq!(fs::read_dir(path.parent().unwrap()).unwrap().count(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn handles_resolve_to_stable_ids_and_resaving_replaces_opaque_entries() {
|
||||
let directory = TestDirectory::new();
|
||||
let path = directory.0.join("config.toml");
|
||||
let id = "UC4QobU6STFB0P71PMvOGN5A";
|
||||
let url = format!("https://www.youtube.com/channel/{id}");
|
||||
let manual = format!(
|
||||
"[channels]\n'@old' = '{url}'\n[categories]\nJapanese = [\n '{url}', # keep this entry comment\n '@old',\n]\nOther = ['@old']\nDirect = ['@RustVideos']\n"
|
||||
);
|
||||
fs::write(&path, manual).unwrap();
|
||||
let config = load(&path).unwrap();
|
||||
assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url));
|
||||
assert_eq!(
|
||||
config.categories["Direct"],
|
||||
["https://www.youtube.com/@RustVideos"]
|
||||
);
|
||||
let config = add_channel(&path, "Japanese", "@jawed", id).unwrap();
|
||||
assert_eq!(config.categories["Japanese"], std::slice::from_ref(&url));
|
||||
assert_eq!(config.categories["Other"], std::slice::from_ref(&url));
|
||||
let saved = fs::read_to_string(&path).unwrap();
|
||||
let document = saved.parse::<DocumentMut>().unwrap();
|
||||
let entries = document["categories"]["Japanese"].as_array().unwrap();
|
||||
assert!(entries.iter().all(|entry| entry.as_str() == Some("@jawed")));
|
||||
assert_eq!(document["channels"]["@jawed"].as_str(), Some(url.as_str()));
|
||||
assert_eq!(document["channels"]["@old"].as_str(), Some(url.as_str()));
|
||||
assert!(saved.contains("# keep this entry comment"));
|
||||
add_channel(&path, "Japanese", "@jawed", id).unwrap();
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
|
||||
assert!(add_channel(&path, "Japanese", "@jawed", "UCabcdefghijklmnopqrstuv").is_err());
|
||||
assert_eq!(fs::read_to_string(&path).unwrap(), saved);
|
||||
assert_eq!(load(&path).unwrap().categories["Japanese"], [url]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn paths_and_channel_addresses_are_strict() {
|
||||
let home = Some(OsString::from("/home/test"));
|
||||
|
||||
Reference in New Issue
Block a user