loadMetadata function
Loads and fully validates the tree rooted at path.
Implementation
AppStoreMetadata loadMetadata(String path) {
final root = Directory(path);
if (!root.existsSync()) {
throw MetadataException('no such metadata directory: $path');
}
final metadata = AppStoreMetadata();
final separator = Platform.pathSeparator;
final info = Directory('${root.path}${separator}info');
if (info.existsSync()) {
const categoryFields = {
'primary_category': 'primaryCategory',
'secondary_category': 'secondaryCategory',
};
for (final field in categoryFields.entries) {
final value = _readField(info, field.key);
if (value != null && value.isNotEmpty) {
// Apple's ids are SCREAMING_SNAKE_CASE and a lowercase one is silently
// ignored rather than rejected, which is the worst of both.
if (value != value.toUpperCase()) {
throw MetadataException(
'info/${field.key}.txt is "$value"; App Store category ids are '
'upper case, e.g. HEALTH_AND_FITNESS',
);
}
metadata.categories[field.value] = value;
}
}
final contentRights = _readField(info, 'content_rights');
if (contentRights != null && contentRights.isNotEmpty) {
if (!contentRightsDeclarations.contains(contentRights)) {
throw MetadataException(
'info/content_rights.txt is "$contentRights"; Apple accepts '
'${contentRightsDeclarations.join(" or ")}',
);
}
metadata.contentRights = contentRights;
}
final copyright = _readField(info, 'copyright');
if (copyright != null && copyright.isNotEmpty) {
// Apple's own guidance is "the year the rights were obtained, then the
// name of the person or entity" — it renders the © itself, so a string
// that carries one ends up showing two.
if (copyright.contains('©')) {
throw MetadataException(
'info/copyright.txt contains a © symbol; Apple adds one itself, so '
'write just "2026 Example Inc."',
);
}
_checkLength('info', 'copyright.txt', copyright, 200);
metadata.copyright = copyright;
}
}
final ageRating = File('${root.path}${separator}age-rating.json');
if (ageRating.existsSync()) {
metadata.ageRating = _loadAgeRating(ageRating);
}
final reviewNotes = File('${root.path}${separator}review-notes.md');
if (reviewNotes.existsSync()) {
metadata.reviewNotes = readReviewNotes(reviewNotes);
}
final listings = Directory('${root.path}${separator}listings');
if (listings.existsSync()) {
final localeDirs = listings.listSync().whereType<Directory>().toList()
..sort((a, b) => a.path.compareTo(b.path));
for (final dir in localeDirs) {
final locale = _loadLocale(dir);
if (!locale.isEmpty) {
metadata.locales.add(locale);
}
}
}
if (metadata.categories.isEmpty &&
metadata.locales.isEmpty &&
metadata.ageRating == null &&
metadata.reviewNotes == null &&
metadata.contentRights == null &&
metadata.copyright == null) {
throw MetadataException(
'$path holds no info/, no listings/ and no age-rating.json — nothing to '
'publish',
);
}
return metadata;
}