use anyhow::{Context as _, Result};
use std::{
collections::HashSet,
path::{Path, PathBuf},
};
const README_FILE_NAME: &str = "README-lr2oxytable.txt";
pub fn validate_good_playlists_folder(path: &Path) -> Result<()> {
anyhow::ensure!(
!path.as_os_str().is_empty(),
"path to playlists folder must not be empty"
);
if !path.exists() {
anyhow::ensure!(
path.parent().is_some_and(std::path::Path::exists),
"path does not exists and neither does it's parent: path={}",
path.display()
);
return Ok(());
}
let entries = std::fs::read_dir(path).context("failed to read dir")?;
let mut empty = true;
for entry in entries {
empty = false;
let entry = entry.context("failed to read dir entry")?;
if entry.file_name() == README_FILE_NAME {
return Ok(());
}
}
anyhow::ensure!(
empty,
"directory isn't empty and doesn't contain {README_FILE_NAME}"
);
Ok(())
}
fn file_path_for_writing(s: &str) -> &str {
s.trim_matches(' ')
}
#[expect(clippy::too_many_lines)]
pub fn write_files(
outputs: &[crate::OutputFolder],
tables: &[crate::table::Table],
playlist_ids: &[crate::db::TableId],
) -> Result<()> {
use anyhow::ensure;
let forbidden_in_fs = ['\n', '\r', '\'', '/', '\\'];
let forbidden_in_db = ['\n', '\r'];
ensure!(
tables.len() == playlist_ids.len(),
"Programmer error: mismatching playlist and playlist ID counts"
);
for crate::OutputFolder(_key, playlists_folder) in outputs {
validate_good_playlists_folder(playlists_folder)?;
std::fs::create_dir_all(playlists_folder)?;
}
let has_updates = |t: &crate::table::Table| {
t.1.entry_diff_to_save_to_db.iter().any(|diff| {
!diff.new.is_empty() || !diff.changed.is_empty() || !diff.deleted.is_empty()
})
};
for crate::OutputFolder(key, playlists_folder) in outputs {
let any_updates = tables
.iter()
.filter(|t| t.1.output == *key)
.any(has_updates);
for entry in std::fs::read_dir(playlists_folder).context("failed to read dir")? {
let entry = entry.context("failed to stat")?;
let path = entry.path();
if path.is_file() {
ensure!(
path.file_name().is_some_and(|f| f == README_FILE_NAME),
"Non-readme file found: {}",
path.display()
);
continue;
}
if !any_updates
&& tables
.iter()
.map(|t| t.1.user_name.as_deref().unwrap_or(&t.0.name))
.map(file_path_for_writing)
.any(|fp| Some(std::ffi::OsStr::new(fp)) == path.file_name())
{
log::debug!("Elided emptying folder {}", path.display());
continue;
}
for file in std::fs::read_dir(&path).context("failed to read lr2folder dir")? {
let path = file.context("failed to stat file")?.path();
ensure!(
path.extension().is_some_and(|ext| ext == "lr2folder"),
"Non-.lr2folder file found in a level: {}",
path.display()
);
std::fs::remove_file(path).context("failed to remove file")?;
}
log::debug!("Emptied {}", path.display());
}
}
for crate::OutputFolder(_key, playlists_folder) in outputs {
let mut path = PathBuf::new();
path.push(playlists_folder);
path.push(README_FILE_NAME);
std::fs::write(
&path,
"This folder is now managed by lr2oxytable.
Do NOT put anything here you don't want removed by lr2oxytable.
If you've pressed F8 (song update) on a playlist in-game,
and now the playlist doesn't open, the easiest fix is to delete this folder altogether, then
save playlists again in lr2oxytable.
You may need to set song reload type in LR2's launcher to 'Manual reload'.",
)?;
}
let mut table_name_in_fs = std::collections::BTreeMap::<String, usize>::new();
for crate::table::Table(table, add_data) in tables.iter().filter(|t| !t.0.entries.is_empty()) {
*table_name_in_fs
.entry(
file_path_for_writing(add_data.user_name.as_ref().unwrap_or(&table.name))
.to_string(),
)
.or_insert(0) += 1;
}
table_name_in_fs.retain(|_k, v| *v > 1);
let table_name_in_fs = table_name_in_fs
.keys()
.map(String::as_str)
.collect::<Vec<_>>()
.join(",");
ensure!(
table_name_in_fs.is_empty(),
"must not have duplicate table names: {table_name_in_fs}",
);
for (crate::table::Table(table, add_data), playlist_id) in tables.iter().zip(playlist_ids) {
let effective_name = add_data.user_name.as_ref().unwrap_or(&table.name);
ensure!(!effective_name.is_empty(), "table name must not be empty");
ensure!(
!forbidden_in_fs.iter().any(|c| effective_name.contains(*c)),
"table name must not contain forbidden symbols: found one of {forbidden_in_fs:?} in '{effective_name}'",
);
let effective_symbol = add_data.user_symbol.as_ref().unwrap_or(&table.symbol);
ensure!(
!forbidden_in_db
.iter()
.any(|c| effective_symbol.contains(*c)),
"table symbol must not contain forbidden symbols: found one of {forbidden_in_db:?} in '{effective_symbol}'",
);
if table.entries.is_empty() {
log::info!("Table {effective_name} is empty, skipping folder creation");
continue;
}
let playlists_folder = &outputs
.iter()
.find(|o| o.0 == add_data.output)
.with_context(|| {
format!(
"missing output '{}' referenced by table, out of outputs: {}",
add_data.output.0,
outputs
.iter()
.map(|o| o.0.0.clone())
.collect::<Vec<_>>()
.join(",")
)
})?
.1;
let mut path = PathBuf::new();
path.push(playlists_folder);
path.push(file_path_for_writing(effective_name));
std::fs::create_dir_all(&path)
.with_context(|| format!("failed to create dir {}", path.display()))?;
for (i, folder) in {
let cmp_by_folder_order = |a: &String, b: &String| -> std::cmp::Ordering {
let key = |e: &str| {
table
.folder_order
.iter()
.position(|o| *o == *e)
.unwrap_or(usize::MAX)
};
key(a).cmp(&key(b))
};
let mut levels = table
.entries
.iter()
.map(|e| e.level.clone())
.collect::<HashSet<String>>()
.into_iter()
.collect::<Vec<String>>();
levels.sort_by(|a, b| {
cmp_by_folder_order(a, b).then_with(|| alphanumeric_sort::compare_str(a, b))
});
levels
}
.into_iter()
.enumerate()
{
ensure!(
!forbidden_in_fs.iter().any(|c| folder.contains(*c)),
"folder name must not contain forbidden symbols: found one of {forbidden_in_fs:?} in '{folder}'",
);
let contents = format!(
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = {} AND folder = '{}')
#CATEGORY {}
#TITLE {}{}
#INFORMATION_A {}
#INFORMATION_B {}", playlist_id.0, folder, effective_name, effective_symbol, folder, "", "");
let (contents_cp932, _, had_errors) = encoding_rs::SHIFT_JIS.encode(&contents);
if had_errors {
log::warn!(
"{effective_name}: failed to encode .lr2folder file to CP932, this will result in ugly transformations",
);
log::debug!("Contents we failed to encode: {contents}");
}
debug_assert!(path.exists()); path.push(format!("{i:0>4}.lr2folder"));
std::fs::write(&path, contents_cp932).context("failed to write lr2folder file")?;
path.pop();
}
log::debug!("Filled {}", path.display());
}
for crate::OutputFolder(_key, playlists_folder) in outputs {
for entry in std::fs::read_dir(playlists_folder).context("failed to read dir")? {
let path = entry.context("failed to stat")?.path();
if path.is_dir()
&& std::fs::read_dir(&path)
.context("failed to read lr2folder dir")?
.next()
.is_none()
{
std::fs::remove_dir(&path).context("failed to remove empty playlist directory")?;
log::info!("Removed empty playlist directory: {}", path.display());
}
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use test_log::test;
fn write_files(
outputs: &[crate::OutputFolder],
tables: &[crate::table::Table],
) -> anyhow::Result<()> {
super::write_files(
outputs,
tables,
&tables
.iter()
.map(|t| t.1.playlist_id.unwrap())
.collect::<Vec<_>>(),
)
}
pub fn write_files_with_empty_output(
playlists_folder: &std::path::Path,
tables: &[crate::table::Table],
) -> anyhow::Result<()> {
write_files(
&[crate::OutputFolder(
crate::OutputFolderKey(String::new()),
playlists_folder.to_path_buf(),
)],
tables,
)
}
#[must_use]
fn ls_files<P: AsRef<std::path::Path>>(path: P) -> Vec<String> {
let mut entries = std::fs::read_dir(path)
.unwrap()
.map(|res| res.unwrap().file_name().to_str().unwrap().to_string())
.collect::<Vec<_>>();
entries.sort();
entries
}
#[must_use]
fn read_to_string_cp932<P: AsRef<std::path::Path>>(path: P) -> String {
let contents = std::fs::read(path).unwrap();
let (out, _, had_errors) = encoding_rs::SHIFT_JIS.decode(&contents);
assert!(!had_errors);
out.to_string()
}
#[test]
fn writes_files() {
use crate::{db::TableId, table::Table};
let tables = &[
Table::empty()
.with_id(TableId(0))
.with_url("http://sp")
.with_name(" sp-name ")
.with_user_name(" sp-user-name ")
.with_symbol(" sp-symbol ")
.with_user_symbol(" sp-user-symbol ")
.with_entry(0, " 1 ")
.with_output("SP"),
Table::empty()
.with_id(TableId(1))
.with_url("http://dp")
.with_name("dp-name")
.with_symbol("dp-symbol")
.with_entry(0, "1")
.with_output("DP"),
];
let tmp1 = tempfile::tempdir().unwrap();
let tmp2 = tempfile::tempdir().unwrap();
write_files(
&[
crate::OutputFolder(
crate::OutputFolderKey("SP".into()),
tmp1.path().to_path_buf(),
),
crate::OutputFolder(
crate::OutputFolderKey("DP".into()),
tmp2.path().to_path_buf(),
),
],
tables,
)
.unwrap();
assert_eq!(
ls_files(tmp1.path()),
&["README-lr2oxytable.txt", "sp-user-name"]
);
assert_eq!(
ls_files(tmp1.path().join("sp-user-name")),
&["0000.lr2folder"]
);
assert_eq!(
std::fs::read_to_string(tmp1.path().join("sp-user-name").join("0000.lr2folder")).unwrap(),
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 0 AND folder = ' 1 ')
#CATEGORY sp-user-name
#TITLE sp-user-symbol 1
#INFORMATION_A
#INFORMATION_B "
);
assert_eq!(
ls_files(tmp2.path()),
&["README-lr2oxytable.txt", "dp-name"]
);
assert_eq!(ls_files(tmp2.path().join("dp-name")), &["0000.lr2folder"]);
assert_eq!(
std::fs::read_to_string(tmp2.path().join("dp-name").join("0000.lr2folder")).unwrap(),
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 1 AND folder = '1')
#CATEGORY dp-name
#TITLE dp-symbol1
#INFORMATION_A
#INFORMATION_B "
);
}
#[test]
fn follows_folder_order() {
use crate::{db::TableId, table::Table};
let tmp = tempfile::tempdir().unwrap();
let outputs = &[crate::OutputFolder(
crate::OutputFolderKey(String::new()),
tmp.path().to_path_buf(),
)];
let tables = &mut [Table::empty()
.with_id(TableId(0))
.with_url("http://")
.with_name("name")
.with_entry(0, "1")
.with_entry(0, "2")
.with_entry(0, "3")];
let read_all_first_lines = || {
let p = tmp.path().join("name");
ls_files(&p)
.iter()
.map(|f| std::fs::read_to_string(p.join(f)))
.map(|s| match s {
Ok(mut s) => {
if let Some(idx) = s.find('\n') {
s.drain(idx..);
}
Ok(s)
}
Err(e) => Err(e),
})
.collect::<std::io::Result<Vec<_>>>()
.unwrap()
};
tables[0].0.folder_order.reserve_exact(3);
tables[0].0.folder_order.clear();
write_files(outputs, tables).unwrap();
assert_eq!(
read_all_first_lines(),
&[
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 0 AND folder = '1')",
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 0 AND folder = '2')",
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 0 AND folder = '3')",
]
);
tables[0].0.folder_order.clear();
tables[0].0.folder_order.push("3".to_string());
write_files(outputs, tables).unwrap();
assert_eq!(
read_all_first_lines(),
&[
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 0 AND folder = '3')",
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 0 AND folder = '1')",
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 0 AND folder = '2')",
]
);
tables[0].0.folder_order.clear();
tables[0].0.folder_order.push("2".to_string());
tables[0].0.folder_order.push("1".to_string());
tables[0].0.folder_order.push("3".to_string());
write_files(outputs, tables).unwrap();
assert_eq!(
read_all_first_lines(),
&[
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 0 AND folder = '2')",
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 0 AND folder = '1')",
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 0 AND folder = '3')",
]
);
}
#[test]
fn writing_files_removes_old_tables() {
use crate::{db::TableId, table::Table};
let tmp = tempfile::tempdir().unwrap();
let old_table = Table::empty()
.with_id(TableId(0))
.with_url("http://url".to_string())
.with_name("oldname".to_string())
.with_entry(0, "1");
let new_table = old_table.clone().with_name("newname");
let deleted_table = Table::empty()
.with_id(TableId(1))
.with_url("http://del".to_string())
.with_name("del".to_string())
.with_entry(0, "1");
write_files_with_empty_output(tmp.path(), &[old_table, deleted_table]).unwrap();
assert_eq!(
ls_files(tmp.path()),
&["README-lr2oxytable.txt", "del", "oldname"]
);
write_files_with_empty_output(tmp.path(), &[new_table]).unwrap();
assert_eq!(ls_files(tmp.path()), &["README-lr2oxytable.txt", "newname"]);
assert_eq!(ls_files(tmp.path().join("newname")), &["0000.lr2folder"]);
assert_eq!(
std::fs::read_to_string(tmp.path().join("newname").join("0000.lr2folder")).unwrap(),
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 0 AND folder = '1')
#CATEGORY newname
#TITLE 1
#INFORMATION_A
#INFORMATION_B "
);
}
#[test]
fn writing_files_ignores_encoding_errors() {
use crate::{db::TableId, table::Table};
let tmp = tempfile::tempdir().unwrap();
let tables = &[Table::empty()
.with_id(TableId(0))
.with_url("http://url")
.with_name("Solomon難易度表")
.with_symbol("✡") .with_entry(0, "1")];
write_files_with_empty_output(tmp.path(), tables).unwrap();
assert_eq!(
ls_files(tmp.path()),
&["README-lr2oxytable.txt", "Solomon難易度表"]
);
assert_eq!(
ls_files(tmp.path().join("Solomon難易度表")),
&["0000.lr2folder"]
);
assert_eq!(
read_to_string_cp932(tmp.path().join("Solomon難易度表").join("0000.lr2folder")),
"#COMMAND song.hash in (SELECT md5 FROM lr2oxytabler_playlist_entry WHERE playlist_id = 0 AND folder = '1')
#CATEGORY Solomon難易度表
#TITLE ✡1
#INFORMATION_A
#INFORMATION_B "
);
}
#[test]
fn file_writing_disallows_bad_things() {
use crate::{db::TableId, table::Table};
assert_eq!(
write_files_with_empty_output(&std::path::PathBuf::new(), &[])
.unwrap_err()
.to_string(),
"path to playlists folder must not be empty"
);
{
let tmp = tempfile::tempdir().unwrap();
std::fs::write(tmp.path().join("some-file"), "").unwrap();
assert_eq!(
write_files_with_empty_output(tmp.path(), &[])
.unwrap_err()
.to_string(),
"directory isn't empty and doesn't contain README-lr2oxytable.txt"
);
}
{
let tmp = tempfile::tempdir().unwrap();
let nest1 = tmp.path().join("not-existing");
let nest2 = nest1.join("not-existing-nested");
let e = write_files_with_empty_output(&nest2, &[])
.unwrap_err()
.to_string();
assert!(
e.starts_with("path does not exists and neither does it's parent: path="),
"e={e}"
);
assert!(!std::fs::exists(&nest1).unwrap());
write_files_with_empty_output(&nest1, &[]).unwrap();
assert!(std::fs::exists(&nest1).unwrap());
write_files_with_empty_output(&nest2, &[]).unwrap();
assert!(std::fs::exists(&nest2).unwrap());
}
let tmp = tempfile::tempdir().unwrap();
let outputs = &[
crate::OutputFolder(
crate::OutputFolderKey(String::new()),
tmp.path().to_path_buf(),
),
crate::OutputFolder(crate::OutputFolderKey("2".into()), tmp.path().to_path_buf()),
crate::OutputFolder(crate::OutputFolderKey("3".into()), tmp.path().to_path_buf()),
];
let run = |table| write_files(outputs, &[table]).unwrap_err().to_string();
assert_eq!(
run(Table::empty().with_id(TableId(0))),
"table name must not be empty"
);
assert_eq!(
run(Table::empty().with_id(TableId(0)).with_name("bad\n")),
"table name must not contain forbidden symbols: found one of ['\\n', '\\r', '\\'', '/', '\\\\'] in 'bad\n'"
);
assert_eq!(
run(Table::empty().with_id(TableId(0)).with_user_name("bad\n")),
"table name must not contain forbidden symbols: found one of ['\\n', '\\r', '\\'', '/', '\\\\'] in 'bad\n'"
);
assert_eq!(
run(Table::empty()
.with_id(TableId(0))
.with_name("good")
.with_symbol("bad\n")),
"table symbol must not contain forbidden symbols: found one of ['\\n', '\\r'] in 'bad\n'"
);
assert_eq!(
run(Table::empty()
.with_id(TableId(0))
.with_name("good")
.with_user_symbol("bad\n")),
"table symbol must not contain forbidden symbols: found one of ['\\n', '\\r'] in 'bad\n'"
);
assert_eq!(
run(Table::empty()
.with_id(TableId(0))
.with_entry(0, "bad\n")
.with_name("good")),
"folder name must not contain forbidden symbols: found one of ['\\n', '\\r', '\\'', '/', '\\\\'] in 'bad\n'"
);
assert_eq!(
run(Table::empty()
.with_id(TableId(0))
.with_name("good")
.with_entry(0, "0")
.with_output("no-such-output")),
"missing output 'no-such-output' referenced by table, out of outputs: ,2,3"
);
assert_eq!(
write_files(
outputs,
&[
Table::empty()
.with_id(TableId(0))
.with_name(" name")
.with_entry(0, ""),
Table::empty()
.with_id(TableId(1))
.with_name("name ")
.with_entry(0, ""),
Table::empty()
.with_id(TableId(2))
.with_name("name2")
.with_entry(0, ""),
Table::empty()
.with_id(TableId(3))
.with_user_name("name2")
.with_entry(0, ""),
Table::empty().with_id(TableId(4)).with_name("empty"),
Table::empty().with_id(TableId(5)).with_name("empty"),
]
)
.unwrap_err()
.to_string(),
"must not have duplicate table names: name,name2"
);
}
}