mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 19:10:30 +01:00
feat(naming): add {FirstAuthor} template token (#1322)
Closes #930. ## What New `{FirstAuthor}` naming-template token. It renders only the first author when metadata lists several ("Author1, Author2, Author3"), so multi-author books can be filed alongside the rest of that author's work instead of getting their own "Author1, Author2, ..." folder. ``` {Author} -> Terry Pratchett, Neil Gaiman {FirstAuthor} -> Terry Pratchett ``` ## How - Added to `KNOWN_TOKENS` in `shelfmark/core/naming.py`, positioned before `author` so `{FirstAuthor}` isn't parsed as literal `First` + `{Author}`. - Derived inside `parse_naming_template` from the existing `Author` value (split on `,` / `;`), so every caller — folder transfer, rename, the settings preview — picks it up with no extra wiring. An explicit `FirstAuthor` key in the metadata still wins if one is ever passed. - `{Author}` behaviour is unchanged. - Frontend `namingTemplatePreview.ts` token list + `KNOWN_TOKENS` kept in lockstep (there's a test enforcing that), with a matching `firstAuthor` helper. - Settings field descriptions + `docs/environment-variables.md` list the new token. ## Known limitation A lone author written `Last, First` is split on the comma too and renders as `Last` — the source metadata doesn't mark which form it is. Called out in the token help text and covered by a test. `{Author}` remains available for anyone who wants the raw string. ## Checks - `make python-test` — 2963 passed - `make python-lint` / `make python-format` / `make python-typecheck` / vulture — clean - `make frontend-test` — 187 passed · `frontend-lint` / `frontend-format` / `frontend-typecheck` — clean 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
265da07d7f
commit
c576003319
@@ -434,8 +434,8 @@ The release source tab to open by default in the release modal for audiobooks. U
|
||||
| `BOOKS_OUTPUT_MODE` | Choose where completed book files are sent. | string (choice) | `folder` |
|
||||
| `INGEST_DIR` | Directory where downloaded files are saved. Use {User} for per-user folders (e.g. /books/{User}). | string | `/books` |
|
||||
| `FILE_ORGANIZATION` | Choose how downloaded book files are named and organized. | string (choice) | `rename` |
|
||||
| `TEMPLATE_RENAME` | Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
|
||||
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
|
||||
| `TEMPLATE_RENAME` | Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title} ({Year})` |
|
||||
| `TEMPLATE_ORGANIZE` | Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title} ({Year})` |
|
||||
| `HARDLINK_TORRENTS` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `false` |
|
||||
| `BOOKLORE_HOST` | Base URL of your Grimmory instance | string | _none_ |
|
||||
| `BOOKLORE_USERNAME` | Grimmory account username | string | _none_ |
|
||||
@@ -456,8 +456,8 @@ The release source tab to open by default in the release modal for audiobooks. U
|
||||
| `EMAIL_ALLOW_UNVERIFIED_TLS` | Disable TLS certificate verification (not recommended). | boolean | `false` |
|
||||
| `DESTINATION_AUDIOBOOK` | Directory where downloaded audiobook files are saved. Leave empty to use the Books destination. | string | _none_ |
|
||||
| `FILE_ORGANIZATION_AUDIOBOOK` | Choose how downloaded audiobook files are named and organized. | string (choice) | `rename` |
|
||||
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}/{Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_RENAME` | Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads. | string | `{Author} - {Title}` |
|
||||
| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. | string | `{Author}/{Title}/{Title}` |
|
||||
| `HARDLINK_TORRENTS_AUDIOBOOK` | Create hardlinks instead of copying. Preserves seeding but archives won't be extracted. Don't use if destination is a library ingest folder. | boolean | `true` |
|
||||
| `AUTO_OPEN_DOWNLOADS_SIDEBAR` | Automatically open the downloads sidebar when a new download is queued. | boolean | `false` |
|
||||
| `DOWNLOAD_TO_BROWSER_CONTENT_TYPES` | Automatically download completed files to your browser for the selected content types. | string (comma-separated) | _empty list_ |
|
||||
@@ -501,7 +501,7 @@ Choose how downloaded book files are named and organized.
|
||||
|
||||
**Naming Template**
|
||||
|
||||
Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author} - {Title} ({Year})`
|
||||
@@ -510,7 +510,7 @@ Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source
|
||||
|
||||
**Path Template**
|
||||
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension). Universal adds: {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author}/{Title} ({Year})`
|
||||
@@ -711,7 +711,7 @@ Choose how downloaded audiobook files are named and organized.
|
||||
|
||||
**Naming Template**
|
||||
|
||||
Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. Rename templates are filename-only (no '/' or '\'); use Organize for folders. Applies to single-file downloads.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author} - {Title}`
|
||||
@@ -720,7 +720,7 @@ Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source
|
||||
|
||||
**Path Template**
|
||||
|
||||
Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} (source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: {Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty.
|
||||
|
||||
- **Type:** string
|
||||
- **Default:** `{Author}/{Title}/{Title}`
|
||||
|
||||
@@ -1028,7 +1028,7 @@ def download_settings() -> list[SettingsField]:
|
||||
key="TEMPLATE_RENAME",
|
||||
label="Naming Template",
|
||||
description=(
|
||||
"Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
|
||||
"Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} "
|
||||
"(source filename without extension). Universal adds: {Series}, "
|
||||
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
|
||||
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
|
||||
@@ -1047,7 +1047,7 @@ def download_settings() -> list[SettingsField]:
|
||||
key="TEMPLATE_ORGANIZE",
|
||||
label="Path Template",
|
||||
description=(
|
||||
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, "
|
||||
"Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, "
|
||||
"{OriginalName} (source filename without extension). Universal adds: {Series}, "
|
||||
"{SeriesPosition}, {Subtitle}, {PrimaryTitle}. Use arbitrary prefix/suffix: "
|
||||
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
|
||||
@@ -1315,7 +1315,7 @@ def download_settings() -> list[SettingsField]:
|
||||
key="TEMPLATE_AUDIOBOOK_RENAME",
|
||||
label="Naming Template",
|
||||
description=(
|
||||
"Variables: {Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
|
||||
"Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, {OriginalName} "
|
||||
"(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, "
|
||||
"{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
|
||||
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty. "
|
||||
@@ -1335,7 +1335,7 @@ def download_settings() -> list[SettingsField]:
|
||||
key="TEMPLATE_AUDIOBOOK_ORGANIZE",
|
||||
label="Path Template",
|
||||
description=(
|
||||
"Use / to create folders. Variables: {Author}, {Title}, {Year}, {Language}, {User}, "
|
||||
"Use / to create folders. Variables: {Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, {User}, "
|
||||
"{OriginalName} (source filename without extension), {Series}, {SeriesPosition}, "
|
||||
"{Subtitle}, {PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix: "
|
||||
"{Vol. SeriesPosition - } outputs 'Vol. 2 - ' when set, nothing when empty."
|
||||
|
||||
@@ -14,11 +14,12 @@ logger = setup_logger(__name__)
|
||||
|
||||
|
||||
# Known variable tokens, sorted longest-first to avoid partial matches
|
||||
# e.g., "SeriesPosition" must match before "Series"
|
||||
# e.g., "SeriesPosition" must match before "Series", "FirstAuthor" before "Author"
|
||||
KNOWN_TOKENS = [
|
||||
"seriesposition",
|
||||
"primarytitle",
|
||||
"originalname",
|
||||
"firstauthor",
|
||||
"partnumber",
|
||||
"language",
|
||||
"subtitle",
|
||||
@@ -29,6 +30,12 @@ KNOWN_TOKENS = [
|
||||
"user",
|
||||
]
|
||||
|
||||
# Authors reach naming already joined as "First Author, Second Author, ...".
|
||||
# {FirstAuthor} keeps only the first entry. A single name written "Last, First"
|
||||
# is split on the comma too and renders as "Last" -- the source metadata does
|
||||
# not mark which form it is (see #930).
|
||||
AUTHOR_LIST_SEPARATOR = re.compile(r"\s*[,;]\s*")
|
||||
|
||||
# Match any {...} block for template parsing
|
||||
BRACE_PATTERN = re.compile(r"\{([^}]+)\}")
|
||||
|
||||
@@ -56,6 +63,14 @@ def sanitize_filename(name: str | None, max_length: int = 245) -> str:
|
||||
sanitize_path_component = sanitize_filename
|
||||
|
||||
|
||||
def first_author(value: object) -> str:
|
||||
"""Return the first entry from an author string joined with ',' or ';'."""
|
||||
text = " ".join(str(value or "").split())
|
||||
if not text:
|
||||
return ""
|
||||
return AUTHOR_LIST_SEPARATOR.split(text, maxsplit=1)[0].strip()
|
||||
|
||||
|
||||
def format_series_position(position: str | float | None) -> str:
|
||||
"""Format a series position for naming templates."""
|
||||
if position is None:
|
||||
@@ -163,6 +178,8 @@ def parse_naming_template(
|
||||
value = normalized.get(placeholder_name)
|
||||
if placeholder_name == "seriesposition":
|
||||
value = format_series_position(value)
|
||||
elif placeholder_name == "firstauthor" and not value:
|
||||
value = first_author(normalized.get("author"))
|
||||
if value is None:
|
||||
return ""
|
||||
return str(value).strip()
|
||||
|
||||
@@ -40,6 +40,23 @@ describe('namingTemplatePreview', () => {
|
||||
expect(preview.value).toBe('Arthur Conan Doyle/The Hound of the Baskervilles');
|
||||
});
|
||||
|
||||
it('keeps only the first of several authors for FirstAuthor', () => {
|
||||
const preview = renderNamingTemplate(
|
||||
'{FirstAuthor}/{Year}',
|
||||
{ ...SAMPLE_NAMING_METADATA, Author: 'Terry Pratchett, Neil Gaiman', FirstAuthor: '' },
|
||||
{ allowPathSeparators: true },
|
||||
);
|
||||
|
||||
expect(preview.value).toBe('Terry Pratchett/1902');
|
||||
});
|
||||
|
||||
it('offers FirstAuthor as a core variable', () => {
|
||||
const token = NAMING_TEMPLATE_TOKENS.find((t) => t.token === 'FirstAuthor');
|
||||
|
||||
expect(token?.group).toBe('Core');
|
||||
expect(token?.audiobookOnly).toBeFalsy();
|
||||
});
|
||||
|
||||
it('reports unknown bare variables', () => {
|
||||
const preview = renderNamingTemplate('{Author}/{NotAThing}', SAMPLE_NAMING_METADATA, {
|
||||
allowPathSeparators: true,
|
||||
|
||||
@@ -27,6 +27,13 @@ export const NAMING_TEMPLATE_TOKENS: NamingTemplateToken[] = [
|
||||
value: 'Arthur Conan Doyle',
|
||||
group: 'Core',
|
||||
},
|
||||
{
|
||||
token: 'FirstAuthor',
|
||||
label: 'First author',
|
||||
description: 'First author only, when metadata lists several',
|
||||
value: 'Arthur Conan Doyle',
|
||||
group: 'Core',
|
||||
},
|
||||
{
|
||||
token: 'Title',
|
||||
label: 'Full title',
|
||||
@@ -104,6 +111,7 @@ const KNOWN_TOKENS = [
|
||||
'seriesposition',
|
||||
'primarytitle',
|
||||
'originalname',
|
||||
'firstauthor',
|
||||
'partnumber',
|
||||
'language',
|
||||
'subtitle',
|
||||
@@ -114,6 +122,10 @@ const KNOWN_TOKENS = [
|
||||
'user',
|
||||
];
|
||||
|
||||
// Mirrors AUTHOR_LIST_SEPARATOR in shelfmark/core/naming.py: authors arrive
|
||||
// pre-joined with ',' or ';' and {FirstAuthor} keeps only the first entry.
|
||||
const firstAuthor = (value: string): string => value.split(/\s*[,;]\s*/)[0]?.trim() ?? '';
|
||||
|
||||
const BRACE_PATTERN = /\{([^}]+)\}/g;
|
||||
const INVALID_CHARS_PATTERN = /[\\/:*?"<>|]/g;
|
||||
|
||||
@@ -163,6 +175,9 @@ export const renderNamingTemplate = (
|
||||
const unknownTokens: string[] = [];
|
||||
|
||||
const placeholderValue = (placeholderName: string): string => {
|
||||
if (placeholderName === 'firstauthor' && !normalized['firstauthor']) {
|
||||
return firstAuthor(normalized['author'] ?? '');
|
||||
}
|
||||
return (normalized[placeholderName] ?? '').trim();
|
||||
};
|
||||
|
||||
|
||||
@@ -53,7 +53,8 @@ def test_generated_env_docs_include_custom_component_value_fields() -> None:
|
||||
|
||||
assert (
|
||||
"| `TEMPLATE_AUDIOBOOK_ORGANIZE` | Use / to create folders. Variables: "
|
||||
"{Author}, {Title}, {Year}, {Language}, {User}, {OriginalName} "
|
||||
"{Author}, {FirstAuthor} (first of several authors), {Title}, {Year}, {Language}, "
|
||||
"{User}, {OriginalName} "
|
||||
"(source filename without extension), {Series}, {SeriesPosition}, {Subtitle}, "
|
||||
"{PrimaryTitle}, {PartNumber}. Use arbitrary prefix/suffix:"
|
||||
) in docs
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
"""Tests for the {FirstAuthor} template variable support (issue #930)."""
|
||||
|
||||
import pytest
|
||||
|
||||
from shelfmark.core.naming import KNOWN_TOKENS, first_author, parse_naming_template
|
||||
|
||||
|
||||
class TestFirstAuthorInKnownTokens:
|
||||
def test_first_author_in_known_tokens(self):
|
||||
assert "firstauthor" in KNOWN_TOKENS
|
||||
|
||||
def test_first_author_is_matched_before_author(self):
|
||||
# "firstauthor" contains "author"; the longest-first ordering must keep
|
||||
# {FirstAuthor} from being parsed as literal "First" + {Author}.
|
||||
assert KNOWN_TOKENS.index("firstauthor") < KNOWN_TOKENS.index("author")
|
||||
|
||||
|
||||
class TestFirstAuthorHelper:
|
||||
@pytest.mark.parametrize(
|
||||
("value", "expected"),
|
||||
[
|
||||
("Arthur Conan Doyle", "Arthur Conan Doyle"),
|
||||
("Terry Pratchett, Neil Gaiman", "Terry Pratchett"),
|
||||
("Terry Pratchett; Neil Gaiman", "Terry Pratchett"),
|
||||
(" Ursula K. Le Guin , Someone Else ", "Ursula K. Le Guin"),
|
||||
("", ""),
|
||||
(None, ""),
|
||||
],
|
||||
)
|
||||
def test_first_author(self, value, expected):
|
||||
assert first_author(value) == expected
|
||||
|
||||
def test_last_comma_first_is_split_too(self):
|
||||
# Documented limitation: a lone "Last, First" name is indistinguishable
|
||||
# from a two-author list, so it collapses to "Last" (see #930).
|
||||
assert first_author("Doyle, Arthur Conan") == "Doyle"
|
||||
|
||||
|
||||
class TestFirstAuthorTemplateRendering:
|
||||
def test_single_author_matches_author(self):
|
||||
metadata = {"Author": "Arthur Conan Doyle", "Title": "A Study in Scarlet"}
|
||||
assert parse_naming_template("{FirstAuthor} - {Title}", metadata) == (
|
||||
"Arthur Conan Doyle - A Study in Scarlet"
|
||||
)
|
||||
|
||||
def test_multiple_authors_keep_only_the_first(self):
|
||||
metadata = {"Author": "Terry Pratchett, Neil Gaiman", "Title": "Good Omens"}
|
||||
assert parse_naming_template("{FirstAuthor}/{Title}", metadata) == (
|
||||
"Terry Pratchett/Good Omens"
|
||||
)
|
||||
|
||||
def test_case_insensitive(self):
|
||||
assert parse_naming_template("{firstauthor}", {"Author": "A, B"}) == "A"
|
||||
|
||||
def test_explicit_first_author_key_wins_over_derivation(self):
|
||||
metadata = {"Author": "A, B", "FirstAuthor": "Custom"}
|
||||
assert parse_naming_template("{FirstAuthor}", metadata) == "Custom"
|
||||
|
||||
def test_empty_author_renders_nothing(self):
|
||||
assert parse_naming_template("{FirstAuthor}", {"Author": ""}) == ""
|
||||
|
||||
def test_author_token_is_unaffected(self):
|
||||
metadata = {"Author": "Terry Pratchett, Neil Gaiman"}
|
||||
assert parse_naming_template("{Author}", metadata) == "Terry Pratchett, Neil Gaiman"
|
||||
Reference in New Issue
Block a user