diff --git a/beetsplug/titlecase.py b/beetsplug/titlecase.py index 2482e1c34..e7003fd28 100644 --- a/beetsplug/titlecase.py +++ b/beetsplug/titlecase.py @@ -47,10 +47,12 @@ class TitlecasePlugin(BeetsPlugin): "preserve": [], "fields": [], "replace": [], - "seperators": [], + "separators": [], "force_lowercase": False, "small_first_last": True, "the_artist": True, + "all_caps": False, + "all_lowercase": False, "after_choice": False, } ) @@ -60,14 +62,16 @@ class TitlecasePlugin(BeetsPlugin): preserve - Provide a list of strings with specific case requirements. fields - Fields to apply titlecase to. replace - List of pairs, first is the target, second is the replacement - seperators - Other characters to treat like periods. - force_lowercase - Lowercases the string before titlecasing. + separators - Other characters to treat like periods. + force_lowercase - Lowercase the string before titlecase. small_first_last - If small characters should be cased at the start of strings. the_artist - If the plugin infers the field to be an artist field (e.g. the field contains "artist") It will capitalize a lowercase The, helpful for the artist names that start with 'The', like 'The Who' or 'The Talking Heads' when - they are not at the start of a string. Superceded by preserved phrases. + they are not at the start of a string. Superseded by preserved phrases. + all_caps - If the alphabet in the string is all uppercase, do not modify. + all_lowercase - If the alphabet in the string is all lowercase, do not modify. """ # Register template function self.template_funcs["titlecase"] = self.titlecase @@ -121,17 +125,25 @@ class TitlecasePlugin(BeetsPlugin): return preserved @cached_property - def seperators(self) -> re.Pattern[str] | None: - if seperators := "".join( - dict.fromkeys(self.config["seperators"].as_str_seq()) + def separators(self) -> re.Pattern[str] | None: + if separators := "".join( + dict.fromkeys(self.config["separators"].as_str_seq()) ): - return re.compile(rf"(.*?[{re.escape(seperators)}]+)(\s*)(?=.)") + return re.compile(rf"(.*?[{re.escape(separators)}]+)(\s*)(?=.)") return None @cached_property def small_first_last(self) -> bool: return self.config["small_first_last"].get(bool) + @cached_property + def all_caps(self) -> bool: + return self.config["all_caps"].get(bool) + + @cached_property + def all_lowercase(self) -> bool: + return self.config["all_lowercase"].get(bool) + @cached_property def the_artist_regexp(self) -> re.Pattern[str]: return re.compile(r"\bthe\b") @@ -180,7 +192,7 @@ class TitlecasePlugin(BeetsPlugin): ] if cased_list != init_field: setattr(item, field, cased_list) - self._log.info( + self._log.debug( f"{field}: {', '.join(init_field)} ->", f"{', '.join(cased_list)}", ) @@ -188,7 +200,7 @@ class TitlecasePlugin(BeetsPlugin): cased: str = self.titlecase(init_field, field) if cased != init_field: setattr(item, field, cased) - self._log.info(f"{field}: {init_field} -> {cased}") + self._log.debug(f"{field}: {init_field} -> {cased}") else: self._log.debug(f"{field}: no string present") else: @@ -197,8 +209,8 @@ class TitlecasePlugin(BeetsPlugin): def titlecase(self, text: str, field: str = "") -> str: """Titlecase the given text.""" # Check we should split this into two substrings. - if self.seperators: - if len(splits := self.seperators.findall(text)): + if self.separators: + if len(splits := self.separators.findall(text)): split_cased = "".join( [self.titlecase(s[0], field) + s[1] for s in splits] ) @@ -206,6 +218,11 @@ class TitlecasePlugin(BeetsPlugin): return split_cased + self.titlecase( text[len(split_cased) :], field ) + # Check if A-Z is all uppercase or all lowercase + if self.all_lowercase and text.islower(): + return text + elif self.all_caps and text.isupper(): + return text # Any necessary replacements go first, mainly punctuation. titlecased = text.lower() if self.force_lowercase else text for pair in self.replace: diff --git a/docs/plugins/titlecase.rst b/docs/plugins/titlecase.rst index c35bc10a4..e2861f0ac 100644 --- a/docs/plugins/titlecase.rst +++ b/docs/plugins/titlecase.rst @@ -57,10 +57,12 @@ Default fields: [] preserve: [] replace: [] - seperators: [] + separators: [] force_lowercase: no small_first_last: yes the_artist: yes + all_lowercase: no + all_caps: no after_choice: no .. conf:: auto @@ -120,7 +122,7 @@ Default - "“": '"' - "”": '"' -.. conf:: seperators +.. conf:: separators :default: [] A list of characters to treat as markers of new sentences. Helpful for split titles @@ -146,6 +148,19 @@ Default capitalized. Useful for bands with `The` as part of the proper name, like ``Amyl and The Sniffers``. +.. conf:: all_caps + :default: no + + If the letters a-Z in a string are all caps, do not modify the string. Useful + if you encounter a lot of acronyms. + +.. conf:: all_lowercase + :default: no + + If the letters a-Z in a string are all lowercase, do not modify the string. + Useful if you encounter a lot of stylized lowercase spellings, but otherwise + want titlecase applied. + .. conf:: after_choice :default: no diff --git a/test/plugins/test_titlecase.py b/test/plugins/test_titlecase.py index 44058780c..c25661bbf 100644 --- a/test/plugins/test_titlecase.py +++ b/test/plugins/test_titlecase.py @@ -112,7 +112,7 @@ class TestTitlecasePlugin(PluginTestCase): assert TitlecasePlugin().titlecase(word.upper()) == word assert TitlecasePlugin().titlecase(word.lower()) == word - def test_seperators(self): + def test_separators(self): testcases = [ ([], "it / a / in / of / to / the", "It / a / in / of / to / The"), (["/"], "it / the test", "It / The Test"), @@ -129,8 +129,34 @@ class TestTitlecasePlugin(PluginTestCase): ), ] for testcase in testcases: - seperators, given, expected = testcase - with self.configure_plugin({"seperators": seperators}): + separators, given, expected = testcase + with self.configure_plugin({"separators": separators}): + assert TitlecasePlugin().titlecase(given) == expected + + def test_all_caps(self): + testcases = [ + (True, "Unaffected", "Unaffected"), + (True, "RBMK1000", "RBMK1000"), + (False, "RBMK1000", "Rbmk1000"), + (True, "P A R I S!", "P A R I S!"), + (True, "pillow dub...", "Pillow Dub..."), + (False, "P A R I S!", "P a R I S!"), + ] + for testcase in testcases: + all_caps, given, expected = testcase + with self.configure_plugin({"all_caps": all_caps}): + assert TitlecasePlugin().titlecase(given) == expected + + def test_all_lowercase(self): + testcases = [ + (True, "Unaffected", "Unaffected"), + (True, "RBMK1000", "Rbmk1000"), + (True, "pillow dub...", "pillow dub..."), + (False, "pillow dub...", "Pillow Dub..."), + ] + for testcase in testcases: + all_lowercase, given, expected = testcase + with self.configure_plugin({"all_lowercase": all_lowercase}): assert TitlecasePlugin().titlecase(given) == expected def test_received_info_handler(self):