Titlecase Plugin Improvements (#6220)

- Add preserving strings that are all lowercase or all upper case
- Fix spelling of 'separator' in config, docs and code
- Move most of the logging for the plugin to debug to keep log cleaner.

Improvements I found a need for in my daily use with the plugin.

- [x] Documentation. (If you've added a new command-line flag, for
example, find the appropriate page under `docs/` to describe it.)
- [x] Changelog. (Skipping as the plugin has not been released yet)
- [x] Tests. (Very much encouraged but not strictly required.)
This commit is contained in:
henry 2025-12-17 16:10:16 -08:00 committed by GitHub
commit 09476bdad9
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 75 additions and 17 deletions

View file

@ -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:

View file

@ -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

View file

@ -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):