storemeta 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.release.example +8 -0
- package/LICENSE +21 -0
- package/README.md +94 -0
- package/dist/auth/apple/credential-state.js +14 -0
- package/dist/auth/apple/jwt.js +56 -0
- package/dist/auth/apple/load-credentials.js +36 -0
- package/dist/auth/credential-state.js +6 -0
- package/dist/auth/google/credential-state.js +10 -0
- package/dist/auth/google/load-credentials.js +18 -0
- package/dist/auth/google/service-account-auth.js +25 -0
- package/dist/auth/redact.js +17 -0
- package/dist/cli/auth-check.js +46 -0
- package/dist/cli/config-doctor.js +40 -0
- package/dist/cli/context.js +23 -0
- package/dist/cli/errors.js +10 -0
- package/dist/cli/exit-code.js +6 -0
- package/dist/cli/init.js +60 -0
- package/dist/cli/locales-list.js +37 -0
- package/dist/cli/metadata-diff.js +57 -0
- package/dist/cli/metadata-pull.js +52 -0
- package/dist/cli/metadata-push.js +95 -0
- package/dist/cli/render.js +57 -0
- package/dist/cli/result-types.js +1 -0
- package/dist/cli/scaffold.js +76 -0
- package/dist/cli/screenshots-diff.js +65 -0
- package/dist/cli/screenshots-pull.js +72 -0
- package/dist/cli/screenshots-push.js +96 -0
- package/dist/cli/validate.js +74 -0
- package/dist/cli.js +222 -0
- package/dist/config/apps.js +21 -0
- package/dist/config/load-config.js +30 -0
- package/dist/config/schema.js +97 -0
- package/dist/config/select-app.js +10 -0
- package/dist/config/select-platforms.js +17 -0
- package/dist/config/single-app.js +16 -0
- package/dist/config/types.js +1 -0
- package/dist/config/validate-base-dirs.js +33 -0
- package/dist/config/validate-credential-env-names.js +32 -0
- package/dist/config/validate-platform-identifiers.js +24 -0
- package/dist/formats/load-metadata.js +38 -0
- package/dist/formats/metadata-types.js +1 -0
- package/dist/formats/screenshot-types.js +1 -0
- package/dist/formats/serialize-metadata.js +20 -0
- package/dist/locales/groups.js +12 -0
- package/dist/locales/map.js +15 -0
- package/dist/locales/normalize.js +22 -0
- package/dist/locales/types.js +1 -0
- package/dist/locales/validate-groups.js +14 -0
- package/dist/platforms/apple/client.js +109 -0
- package/dist/platforms/apple/metadata/pull.js +120 -0
- package/dist/platforms/apple/metadata/push.js +272 -0
- package/dist/platforms/apple/metadata/types.js +1 -0
- package/dist/platforms/apple/screenshots/pull.js +160 -0
- package/dist/platforms/apple/screenshots/push.js +418 -0
- package/dist/platforms/google/client.js +42 -0
- package/dist/platforms/google/edits.js +38 -0
- package/dist/platforms/google/metadata/pull.js +23 -0
- package/dist/platforms/google/metadata/push.js +73 -0
- package/dist/platforms/google/metadata/types.js +1 -0
- package/dist/platforms/google/screenshots/pull.js +129 -0
- package/dist/platforms/google/screenshots/push.js +252 -0
- package/dist/platforms/google/screenshots/types.js +7 -0
- package/dist/validation/metadata/apple.js +31 -0
- package/dist/validation/metadata/files.js +58 -0
- package/dist/validation/metadata/google.js +46 -0
- package/dist/validation/screenshots/extensions.js +17 -0
- package/dist/validation/screenshots/files.js +55 -0
- package/dist/validation/screenshots/folders.js +41 -0
- package/dist/validation/screenshots/order.js +25 -0
- package/dist/writers/ensure-directory.js +16 -0
- package/dist/writers/order-screenshots.js +19 -0
- package/dist/writers/resolve-screenshot-path.js +8 -0
- package/dist/writers/resolve-within-base-dir.js +17 -0
- package/dist/writers/write-metadata.js +17 -0
- package/docs/API_EDITABLE_METADATA_SURFACE.md +319 -0
- package/docs/AUTH_SETUP.md +67 -0
- package/docs/DOCUMENTATION.md +285 -0
- package/docs/PROJECT_PLAN.md +687 -0
- package/docs/RELEASE_VERIFICATION.md +70 -0
- package/docs/TODO.md +308 -0
- package/examples/README.md +8 -0
- package/examples/metadata/apple/en-US.yml +11 -0
- package/examples/metadata/apple/tr.yml +11 -0
- package/examples/metadata/google/en-US.yml +6 -0
- package/examples/metadata/google/tr.yml +6 -0
- package/examples/screenshots/apple/en-US/APP_IPHONE_65/1.png +1 -0
- package/examples/screenshots/apple/tr/APP_IPHONE_65/1.png +1 -0
- package/examples/screenshots/google/en-US/phoneScreenshots/1.png +1 -0
- package/examples/screenshots/google/tr/phoneScreenshots/1.png +1 -0
- package/examples/storemeta.yml +29 -0
- package/package.json +55 -0
- package/storemeta.release.example.yml +27 -0
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { StoremetaError } from "../../cli/errors.js";
|
|
2
|
+
import { createAppleJwtToken } from "../../auth/apple/jwt.js";
|
|
3
|
+
const APP_STORE_CONNECT_API_BASE_URL = "https://api.appstoreconnect.apple.com/v1";
|
|
4
|
+
async function createAppStoreConnectErrorMessage(response) {
|
|
5
|
+
const fallbackMessage = `App Store Connect API request failed with ${response.status} ${response.statusText}`;
|
|
6
|
+
try {
|
|
7
|
+
const errorDocument = (await response.json());
|
|
8
|
+
const firstError = errorDocument.errors?.[0];
|
|
9
|
+
if (firstError === undefined) {
|
|
10
|
+
return fallbackMessage;
|
|
11
|
+
}
|
|
12
|
+
const detailParts = [
|
|
13
|
+
firstError.code,
|
|
14
|
+
firstError.title,
|
|
15
|
+
firstError.detail,
|
|
16
|
+
].filter((part) => part !== undefined && part.trim().length > 0);
|
|
17
|
+
if (detailParts.length === 0) {
|
|
18
|
+
return fallbackMessage;
|
|
19
|
+
}
|
|
20
|
+
return `${fallbackMessage}: ${detailParts.join(" - ")}`;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return fallbackMessage;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
export class AppStoreConnectClient {
|
|
27
|
+
credentials;
|
|
28
|
+
env;
|
|
29
|
+
constructor(credentials, env = process.env) {
|
|
30
|
+
this.credentials = credentials;
|
|
31
|
+
this.env = env;
|
|
32
|
+
}
|
|
33
|
+
async request(path, options = {}) {
|
|
34
|
+
const jwt = await createAppleJwtToken(this.credentials, this.env);
|
|
35
|
+
const requestUrl = path.startsWith("https://") || path.startsWith("http://")
|
|
36
|
+
? path
|
|
37
|
+
: `${APP_STORE_CONNECT_API_BASE_URL}${path}`;
|
|
38
|
+
let response;
|
|
39
|
+
try {
|
|
40
|
+
response = await fetch(requestUrl, {
|
|
41
|
+
method: options.method ?? "GET",
|
|
42
|
+
headers: {
|
|
43
|
+
Authorization: `Bearer ${jwt}`,
|
|
44
|
+
...options.headers,
|
|
45
|
+
},
|
|
46
|
+
body: options.body,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (cause) {
|
|
50
|
+
throw new StoremetaError("API_ERROR", "App Store Connect API request failed before a response was received", { cause });
|
|
51
|
+
}
|
|
52
|
+
if (!response.ok) {
|
|
53
|
+
throw new StoremetaError("API_ERROR", await createAppStoreConnectErrorMessage(response));
|
|
54
|
+
}
|
|
55
|
+
return response;
|
|
56
|
+
}
|
|
57
|
+
async requestJson(path, options = {}) {
|
|
58
|
+
const response = await this.request(path, {
|
|
59
|
+
...options,
|
|
60
|
+
headers: {
|
|
61
|
+
Accept: "application/json",
|
|
62
|
+
...options.headers,
|
|
63
|
+
},
|
|
64
|
+
});
|
|
65
|
+
return (await response.json());
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function createAppStoreConnectClient(credentials, env = process.env) {
|
|
69
|
+
return new AppStoreConnectClient(credentials, env);
|
|
70
|
+
}
|
|
71
|
+
export async function postAppStoreConnectJson(client, path, body) {
|
|
72
|
+
return client.requestJson(path, {
|
|
73
|
+
method: "POST",
|
|
74
|
+
headers: {
|
|
75
|
+
"Content-Type": "application/json",
|
|
76
|
+
},
|
|
77
|
+
body: JSON.stringify(body),
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
export async function patchAppStoreConnectJson(client, path, body) {
|
|
81
|
+
return client.requestJson(path, {
|
|
82
|
+
method: "PATCH",
|
|
83
|
+
headers: {
|
|
84
|
+
"Content-Type": "application/json",
|
|
85
|
+
},
|
|
86
|
+
body: JSON.stringify(body),
|
|
87
|
+
});
|
|
88
|
+
}
|
|
89
|
+
export async function requestAllAppStoreConnectPages(client, path) {
|
|
90
|
+
const resources = [];
|
|
91
|
+
const seenPaths = new Set();
|
|
92
|
+
let currentPath = path;
|
|
93
|
+
while (currentPath !== undefined) {
|
|
94
|
+
if (seenPaths.has(currentPath)) {
|
|
95
|
+
throw new StoremetaError("API_ERROR", `App Store Connect pagination loop detected at ${currentPath}`);
|
|
96
|
+
}
|
|
97
|
+
seenPaths.add(currentPath);
|
|
98
|
+
const response = await client.requestJson(currentPath);
|
|
99
|
+
resources.push(...response.data);
|
|
100
|
+
if (response.data.length === 0) {
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
currentPath =
|
|
104
|
+
response.links?.next !== undefined && response.links.next.length > 0
|
|
105
|
+
? response.links.next
|
|
106
|
+
: undefined;
|
|
107
|
+
}
|
|
108
|
+
return resources;
|
|
109
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { normalizeLocaleCode } from "../../../locales/normalize.js";
|
|
2
|
+
import { writeMetadataFile } from "../../../writers/write-metadata.js";
|
|
3
|
+
import { requestAllAppStoreConnectPages } from "../client.js";
|
|
4
|
+
const EDITABLE_APP_STORE_STATES = [
|
|
5
|
+
"PREPARE_FOR_SUBMISSION",
|
|
6
|
+
"READY_FOR_REVIEW",
|
|
7
|
+
"INVALID_BINARY",
|
|
8
|
+
"WAITING_FOR_REVIEW",
|
|
9
|
+
"IN_REVIEW",
|
|
10
|
+
"ACCEPTED",
|
|
11
|
+
"WAITING_FOR_EXPORT_COMPLIANCE",
|
|
12
|
+
"PENDING_DEVELOPER_RELEASE",
|
|
13
|
+
"REJECTED",
|
|
14
|
+
"METADATA_REJECTED",
|
|
15
|
+
"DEVELOPER_REJECTED",
|
|
16
|
+
];
|
|
17
|
+
function getAppleVersionCreatedTimestamp(version) {
|
|
18
|
+
const createdDate = version.attributes?.createdDate;
|
|
19
|
+
if (createdDate === undefined) {
|
|
20
|
+
return Number.NEGATIVE_INFINITY;
|
|
21
|
+
}
|
|
22
|
+
const timestamp = Date.parse(createdDate);
|
|
23
|
+
return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp;
|
|
24
|
+
}
|
|
25
|
+
function sortAppleVersionsByPriority(versions) {
|
|
26
|
+
return [...versions].sort((left, right) => {
|
|
27
|
+
const createdDateOrder = getAppleVersionCreatedTimestamp(right) - getAppleVersionCreatedTimestamp(left);
|
|
28
|
+
if (createdDateOrder !== 0) {
|
|
29
|
+
return createdDateOrder;
|
|
30
|
+
}
|
|
31
|
+
return right.id.localeCompare(left.id);
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
export function selectPreferredAppleAppStoreVersion(versions) {
|
|
35
|
+
const sortedVersions = sortAppleVersionsByPriority(versions);
|
|
36
|
+
const iosVersions = sortedVersions.filter((version) => version.attributes?.platform === "IOS");
|
|
37
|
+
const candidateVersions = iosVersions.length > 0 ? iosVersions : sortedVersions;
|
|
38
|
+
const editableVersion = candidateVersions.find((version) => {
|
|
39
|
+
const state = version.attributes?.appStoreState;
|
|
40
|
+
return state !== undefined && EDITABLE_APP_STORE_STATES.includes(state);
|
|
41
|
+
});
|
|
42
|
+
if (editableVersion !== undefined) {
|
|
43
|
+
return editableVersion;
|
|
44
|
+
}
|
|
45
|
+
const liveVersion = candidateVersions.find((version) => version.attributes?.appStoreState === "READY_FOR_SALE");
|
|
46
|
+
if (liveVersion !== undefined) {
|
|
47
|
+
return liveVersion;
|
|
48
|
+
}
|
|
49
|
+
return candidateVersions[0];
|
|
50
|
+
}
|
|
51
|
+
export async function fetchAppleAppInfoLocalizations(client, appId) {
|
|
52
|
+
const appInfos = await requestAllAppStoreConnectPages(client, `/apps/${encodeURIComponent(appId)}/appInfos`);
|
|
53
|
+
if (appInfos.length === 0) {
|
|
54
|
+
return [];
|
|
55
|
+
}
|
|
56
|
+
return requestAllAppStoreConnectPages(client, `/appInfos/${encodeURIComponent(appInfos[0].id)}/appInfoLocalizations`);
|
|
57
|
+
}
|
|
58
|
+
export async function fetchAppleAppStoreVersionLocalizations(client, appId) {
|
|
59
|
+
const appStoreVersions = await requestAllAppStoreConnectPages(client, `/apps/${encodeURIComponent(appId)}/appStoreVersions`);
|
|
60
|
+
const selectedVersion = selectPreferredAppleAppStoreVersion(appStoreVersions);
|
|
61
|
+
if (selectedVersion === undefined) {
|
|
62
|
+
return [];
|
|
63
|
+
}
|
|
64
|
+
return requestAllAppStoreConnectPages(client, `/appStoreVersions/${encodeURIComponent(selectedVersion.id)}/appStoreVersionLocalizations`);
|
|
65
|
+
}
|
|
66
|
+
export function mergeAppleLocalizations(appInfoLocalizations, appStoreVersionLocalizations) {
|
|
67
|
+
const mergedLocalizations = new Map();
|
|
68
|
+
for (const localization of appInfoLocalizations) {
|
|
69
|
+
const locale = localization.attributes.locale;
|
|
70
|
+
if (locale === undefined || locale.trim().length === 0) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
mergedLocalizations.set(locale, {
|
|
74
|
+
locale,
|
|
75
|
+
...mergedLocalizations.get(locale),
|
|
76
|
+
appInfoLocalization: localization,
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
for (const localization of appStoreVersionLocalizations) {
|
|
80
|
+
const locale = localization.attributes.locale;
|
|
81
|
+
if (locale === undefined || locale.trim().length === 0) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
mergedLocalizations.set(locale, {
|
|
85
|
+
locale,
|
|
86
|
+
...mergedLocalizations.get(locale),
|
|
87
|
+
appStoreVersionLocalization: localization,
|
|
88
|
+
});
|
|
89
|
+
}
|
|
90
|
+
return [...mergedLocalizations.values()].sort((left, right) => left.locale.localeCompare(right.locale));
|
|
91
|
+
}
|
|
92
|
+
export function normalizeMergedAppleLocalization(localization) {
|
|
93
|
+
const appInfoAttributes = localization.appInfoLocalization?.attributes;
|
|
94
|
+
const versionAttributes = localization.appStoreVersionLocalization?.attributes;
|
|
95
|
+
return {
|
|
96
|
+
locale: normalizeLocaleCode(localization.locale),
|
|
97
|
+
app_name: appInfoAttributes?.name,
|
|
98
|
+
subtitle: appInfoAttributes?.subtitle,
|
|
99
|
+
privacy_policy_url: appInfoAttributes?.privacyPolicyUrl,
|
|
100
|
+
description: versionAttributes?.description,
|
|
101
|
+
keywords: versionAttributes?.keywords,
|
|
102
|
+
marketing_url: versionAttributes?.marketingUrl,
|
|
103
|
+
promotional_text: versionAttributes?.promotionalText,
|
|
104
|
+
support_url: versionAttributes?.supportUrl,
|
|
105
|
+
whats_new: versionAttributes?.whatsNew,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
export function normalizeMergedAppleLocalizations(localizations) {
|
|
109
|
+
return localizations.map(normalizeMergedAppleLocalization);
|
|
110
|
+
}
|
|
111
|
+
export async function writeAppleMetadataDocument(metadataBaseDir, document) {
|
|
112
|
+
const normalizedLocale = normalizeLocaleCode(document.locale);
|
|
113
|
+
return writeMetadataFile(metadataBaseDir, `apple/${normalizedLocale}.yml`, {
|
|
114
|
+
...document,
|
|
115
|
+
locale: normalizedLocale,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
export async function writeAppleMetadataDocuments(metadataBaseDir, documents) {
|
|
119
|
+
return Promise.all(documents.map((document) => writeAppleMetadataDocument(metadataBaseDir, document)));
|
|
120
|
+
}
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
import { readdir } from "node:fs/promises";
|
|
2
|
+
import { extname, join, resolve } from "node:path";
|
|
3
|
+
import { StoremetaError } from "../../../cli/errors.js";
|
|
4
|
+
import { loadMarkdownMetadataFile, loadYamlMetadataFile, loadYmlMetadataFile, } from "../../../formats/load-metadata.js";
|
|
5
|
+
import { patchAppStoreConnectJson, postAppStoreConnectJson, requestAllAppStoreConnectPages, } from "../client.js";
|
|
6
|
+
import { validateAppleMetadataDocument } from "../../../validation/metadata/apple.js";
|
|
7
|
+
async function listAppleMetadataFiles(metadataBaseDir) {
|
|
8
|
+
const appleMetadataDirectory = resolve(metadataBaseDir, "apple");
|
|
9
|
+
try {
|
|
10
|
+
const entries = await readdir(appleMetadataDirectory, { withFileTypes: true });
|
|
11
|
+
return entries
|
|
12
|
+
.filter((entry) => entry.isFile())
|
|
13
|
+
.map((entry) => join(appleMetadataDirectory, entry.name))
|
|
14
|
+
.sort((left, right) => left.localeCompare(right));
|
|
15
|
+
}
|
|
16
|
+
catch (cause) {
|
|
17
|
+
if (typeof cause === "object" &&
|
|
18
|
+
cause !== null &&
|
|
19
|
+
"code" in cause &&
|
|
20
|
+
cause.code === "ENOENT") {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
throw new StoremetaError("FILESYSTEM_ERROR", `Failed to read Apple metadata directory at ${appleMetadataDirectory}`, { cause });
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
async function loadAppleMetadataDocument(filePath) {
|
|
27
|
+
const extension = extname(filePath).toLowerCase();
|
|
28
|
+
if (extension === ".yml") {
|
|
29
|
+
return validateAppleMetadataDocument((await loadYmlMetadataFile(filePath)).parsed);
|
|
30
|
+
}
|
|
31
|
+
if (extension === ".yaml") {
|
|
32
|
+
return validateAppleMetadataDocument((await loadYamlMetadataFile(filePath)).parsed);
|
|
33
|
+
}
|
|
34
|
+
if (extension === ".md") {
|
|
35
|
+
return validateAppleMetadataDocument((await loadMarkdownMetadataFile(filePath)).parsed);
|
|
36
|
+
}
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
export async function loadAppleMetadataDocuments(metadataBaseDir) {
|
|
40
|
+
const documents = [];
|
|
41
|
+
for (const filePath of await listAppleMetadataFiles(metadataBaseDir)) {
|
|
42
|
+
const document = await loadAppleMetadataDocument(filePath);
|
|
43
|
+
if (document !== undefined) {
|
|
44
|
+
documents.push(document);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return documents.sort((left, right) => left.locale.localeCompare(right.locale));
|
|
48
|
+
}
|
|
49
|
+
export function selectAppleAppInfoResource(appInfos) {
|
|
50
|
+
const iosAppInfo = appInfos.find((appInfo) => appInfo.attributes?.platform === "IOS");
|
|
51
|
+
return iosAppInfo ?? appInfos[0];
|
|
52
|
+
}
|
|
53
|
+
export async function resolveAppleAppInfoResource(client, appId) {
|
|
54
|
+
const appInfos = await requestAllAppStoreConnectPages(client, `/apps/${encodeURIComponent(appId)}/appInfos`);
|
|
55
|
+
const selectedAppInfo = selectAppleAppInfoResource(appInfos);
|
|
56
|
+
if (selectedAppInfo === undefined) {
|
|
57
|
+
throw new StoremetaError("API_ERROR", `App Store Connect returned no app info resource for app ${appId}`);
|
|
58
|
+
}
|
|
59
|
+
return selectedAppInfo;
|
|
60
|
+
}
|
|
61
|
+
const EDITABLE_APP_STORE_STATES = new Set([
|
|
62
|
+
"PREPARE_FOR_SUBMISSION",
|
|
63
|
+
"READY_FOR_REVIEW",
|
|
64
|
+
"INVALID_BINARY",
|
|
65
|
+
"WAITING_FOR_REVIEW",
|
|
66
|
+
"IN_REVIEW",
|
|
67
|
+
"ACCEPTED",
|
|
68
|
+
"WAITING_FOR_EXPORT_COMPLIANCE",
|
|
69
|
+
"PENDING_DEVELOPER_RELEASE",
|
|
70
|
+
"REJECTED",
|
|
71
|
+
"METADATA_REJECTED",
|
|
72
|
+
"DEVELOPER_REJECTED",
|
|
73
|
+
]);
|
|
74
|
+
function getAppleVersionCreatedTimestamp(version) {
|
|
75
|
+
const createdDate = version.attributes?.createdDate;
|
|
76
|
+
if (createdDate === undefined) {
|
|
77
|
+
return Number.NEGATIVE_INFINITY;
|
|
78
|
+
}
|
|
79
|
+
const timestamp = Date.parse(createdDate);
|
|
80
|
+
return Number.isNaN(timestamp) ? Number.NEGATIVE_INFINITY : timestamp;
|
|
81
|
+
}
|
|
82
|
+
export function selectEditableAppleAppStoreVersion(versions) {
|
|
83
|
+
const sortedVersions = [...versions].sort((left, right) => {
|
|
84
|
+
if (left.attributes?.platform === "IOS" && right.attributes?.platform !== "IOS") {
|
|
85
|
+
return -1;
|
|
86
|
+
}
|
|
87
|
+
if (left.attributes?.platform !== "IOS" && right.attributes?.platform === "IOS") {
|
|
88
|
+
return 1;
|
|
89
|
+
}
|
|
90
|
+
const createdDateOrder = getAppleVersionCreatedTimestamp(right) - getAppleVersionCreatedTimestamp(left);
|
|
91
|
+
if (createdDateOrder !== 0) {
|
|
92
|
+
return createdDateOrder;
|
|
93
|
+
}
|
|
94
|
+
return right.id.localeCompare(left.id);
|
|
95
|
+
});
|
|
96
|
+
return sortedVersions.find((version) => {
|
|
97
|
+
const state = version.attributes?.appStoreState;
|
|
98
|
+
return state !== undefined && EDITABLE_APP_STORE_STATES.has(state);
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
export async function resolveEditableAppleAppStoreVersionResource(client, appId) {
|
|
102
|
+
const appStoreVersions = await requestAllAppStoreConnectPages(client, `/apps/${encodeURIComponent(appId)}/appStoreVersions`);
|
|
103
|
+
const selectedVersion = selectEditableAppleAppStoreVersion(appStoreVersions);
|
|
104
|
+
if (selectedVersion === undefined) {
|
|
105
|
+
throw new StoremetaError("API_ERROR", `App Store Connect returned no editable app store version for app ${appId}`);
|
|
106
|
+
}
|
|
107
|
+
return selectedVersion;
|
|
108
|
+
}
|
|
109
|
+
function mapAppleMetadataToAppInfoLocalizationPayload(document, localizationId) {
|
|
110
|
+
return {
|
|
111
|
+
data: {
|
|
112
|
+
id: localizationId,
|
|
113
|
+
type: "appInfoLocalizations",
|
|
114
|
+
attributes: {
|
|
115
|
+
locale: document.locale,
|
|
116
|
+
name: document.app_name,
|
|
117
|
+
subtitle: document.subtitle,
|
|
118
|
+
privacyPolicyUrl: document.privacy_policy_url,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
function mapAppleMetadataToAppInfoLocalizationCreatePayload(document, appInfoId) {
|
|
124
|
+
return {
|
|
125
|
+
data: {
|
|
126
|
+
type: "appInfoLocalizations",
|
|
127
|
+
attributes: {
|
|
128
|
+
locale: document.locale,
|
|
129
|
+
name: document.app_name,
|
|
130
|
+
subtitle: document.subtitle,
|
|
131
|
+
privacyPolicyUrl: document.privacy_policy_url,
|
|
132
|
+
},
|
|
133
|
+
relationships: {
|
|
134
|
+
appInfo: {
|
|
135
|
+
data: {
|
|
136
|
+
type: "appInfos",
|
|
137
|
+
id: appInfoId,
|
|
138
|
+
},
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
async function listAppleAppInfoLocalizations(client, appInfoId) {
|
|
145
|
+
return requestAllAppStoreConnectPages(client, `/appInfos/${encodeURIComponent(appInfoId)}/appInfoLocalizations`);
|
|
146
|
+
}
|
|
147
|
+
export async function updateExistingAppleAppInfoLocalizations(client, appInfoId, documents) {
|
|
148
|
+
const localizations = await listAppleAppInfoLocalizations(client, appInfoId);
|
|
149
|
+
const localizationsByLocale = new Map(localizations
|
|
150
|
+
.filter((localization) => localization.id !== undefined)
|
|
151
|
+
.map((localization) => [localization.attributes.locale, localization]));
|
|
152
|
+
const results = [];
|
|
153
|
+
for (const document of documents) {
|
|
154
|
+
const localization = localizationsByLocale.get(document.locale);
|
|
155
|
+
if (localization?.id === undefined) {
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
await patchAppStoreConnectJson(client, `/appInfoLocalizations/${encodeURIComponent(localization.id)}`, mapAppleMetadataToAppInfoLocalizationPayload(document, localization.id));
|
|
159
|
+
results.push({
|
|
160
|
+
locale: document.locale,
|
|
161
|
+
localizationId: localization.id,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
return results.sort((left, right) => left.locale.localeCompare(right.locale));
|
|
165
|
+
}
|
|
166
|
+
export async function createMissingAppleAppInfoLocalizations(client, appInfoId, documents) {
|
|
167
|
+
const localizations = await listAppleAppInfoLocalizations(client, appInfoId);
|
|
168
|
+
const existingLocales = new Set(localizations
|
|
169
|
+
.map((localization) => localization.attributes.locale)
|
|
170
|
+
.filter((locale) => locale !== undefined));
|
|
171
|
+
const results = [];
|
|
172
|
+
for (const document of documents) {
|
|
173
|
+
if (existingLocales.has(document.locale)) {
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
const response = await postAppStoreConnectJson(client, "/appInfoLocalizations", mapAppleMetadataToAppInfoLocalizationCreatePayload(document, appInfoId));
|
|
177
|
+
if (response.data.id === undefined) {
|
|
178
|
+
throw new StoremetaError("API_ERROR", `App Store Connect did not return an app info localization id for locale ${document.locale}`);
|
|
179
|
+
}
|
|
180
|
+
results.push({
|
|
181
|
+
locale: document.locale,
|
|
182
|
+
localizationId: response.data.id,
|
|
183
|
+
});
|
|
184
|
+
existingLocales.add(document.locale);
|
|
185
|
+
}
|
|
186
|
+
return results.sort((left, right) => left.locale.localeCompare(right.locale));
|
|
187
|
+
}
|
|
188
|
+
function mapAppleMetadataToAppStoreVersionLocalizationPayload(document, localizationId) {
|
|
189
|
+
return {
|
|
190
|
+
data: {
|
|
191
|
+
id: localizationId,
|
|
192
|
+
type: "appStoreVersionLocalizations",
|
|
193
|
+
attributes: {
|
|
194
|
+
locale: document.locale,
|
|
195
|
+
description: document.description,
|
|
196
|
+
keywords: document.keywords,
|
|
197
|
+
marketingUrl: document.marketing_url,
|
|
198
|
+
promotionalText: document.promotional_text,
|
|
199
|
+
supportUrl: document.support_url,
|
|
200
|
+
whatsNew: document.whats_new,
|
|
201
|
+
},
|
|
202
|
+
},
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
function mapAppleMetadataToAppStoreVersionLocalizationCreatePayload(document, appStoreVersionId) {
|
|
206
|
+
return {
|
|
207
|
+
data: {
|
|
208
|
+
type: "appStoreVersionLocalizations",
|
|
209
|
+
attributes: {
|
|
210
|
+
locale: document.locale,
|
|
211
|
+
description: document.description,
|
|
212
|
+
keywords: document.keywords,
|
|
213
|
+
marketingUrl: document.marketing_url,
|
|
214
|
+
promotionalText: document.promotional_text,
|
|
215
|
+
supportUrl: document.support_url,
|
|
216
|
+
whatsNew: document.whats_new,
|
|
217
|
+
},
|
|
218
|
+
relationships: {
|
|
219
|
+
appStoreVersion: {
|
|
220
|
+
data: {
|
|
221
|
+
type: "appStoreVersions",
|
|
222
|
+
id: appStoreVersionId,
|
|
223
|
+
},
|
|
224
|
+
},
|
|
225
|
+
},
|
|
226
|
+
},
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
async function listAppleAppStoreVersionLocalizations(client, appStoreVersionId) {
|
|
230
|
+
return requestAllAppStoreConnectPages(client, `/appStoreVersions/${encodeURIComponent(appStoreVersionId)}/appStoreVersionLocalizations`);
|
|
231
|
+
}
|
|
232
|
+
export async function updateExistingAppleAppStoreVersionLocalizations(client, appStoreVersionId, documents) {
|
|
233
|
+
const localizations = await listAppleAppStoreVersionLocalizations(client, appStoreVersionId);
|
|
234
|
+
const localizationsByLocale = new Map(localizations
|
|
235
|
+
.filter((localization) => localization.id !== undefined)
|
|
236
|
+
.map((localization) => [localization.attributes.locale, localization]));
|
|
237
|
+
const results = [];
|
|
238
|
+
for (const document of documents) {
|
|
239
|
+
const localization = localizationsByLocale.get(document.locale);
|
|
240
|
+
if (localization?.id === undefined) {
|
|
241
|
+
continue;
|
|
242
|
+
}
|
|
243
|
+
await patchAppStoreConnectJson(client, `/appStoreVersionLocalizations/${encodeURIComponent(localization.id)}`, mapAppleMetadataToAppStoreVersionLocalizationPayload(document, localization.id));
|
|
244
|
+
results.push({
|
|
245
|
+
locale: document.locale,
|
|
246
|
+
localizationId: localization.id,
|
|
247
|
+
});
|
|
248
|
+
}
|
|
249
|
+
return results.sort((left, right) => left.locale.localeCompare(right.locale));
|
|
250
|
+
}
|
|
251
|
+
export async function createMissingAppleAppStoreVersionLocalizations(client, appStoreVersionId, documents) {
|
|
252
|
+
const localizations = await listAppleAppStoreVersionLocalizations(client, appStoreVersionId);
|
|
253
|
+
const existingLocales = new Set(localizations
|
|
254
|
+
.map((localization) => localization.attributes.locale)
|
|
255
|
+
.filter((locale) => locale !== undefined));
|
|
256
|
+
const results = [];
|
|
257
|
+
for (const document of documents) {
|
|
258
|
+
if (existingLocales.has(document.locale)) {
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
const response = await postAppStoreConnectJson(client, "/appStoreVersionLocalizations", mapAppleMetadataToAppStoreVersionLocalizationCreatePayload(document, appStoreVersionId));
|
|
262
|
+
if (response.data.id === undefined) {
|
|
263
|
+
throw new StoremetaError("API_ERROR", `App Store Connect did not return an app store version localization id for locale ${document.locale}`);
|
|
264
|
+
}
|
|
265
|
+
results.push({
|
|
266
|
+
locale: document.locale,
|
|
267
|
+
localizationId: response.data.id,
|
|
268
|
+
});
|
|
269
|
+
existingLocales.add(document.locale);
|
|
270
|
+
}
|
|
271
|
+
return results.sort((left, right) => left.locale.localeCompare(right.locale));
|
|
272
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { normalizeLocaleCode } from "../../../locales/normalize.js";
|
|
2
|
+
import { writeFile } from "node:fs/promises";
|
|
3
|
+
import { extname } from "node:path";
|
|
4
|
+
import { StoremetaError } from "../../../cli/errors.js";
|
|
5
|
+
import { ensureParentDirectory } from "../../../writers/ensure-directory.js";
|
|
6
|
+
import { orderScreenshotsForWrite } from "../../../writers/order-screenshots.js";
|
|
7
|
+
import { resolveScreenshotFilePath } from "../../../writers/resolve-screenshot-path.js";
|
|
8
|
+
import { requestAllAppStoreConnectPages } from "../client.js";
|
|
9
|
+
import { fetchAppleAppStoreVersionLocalizations, } from "../metadata/pull.js";
|
|
10
|
+
export async function fetchAppleScreenshotLocalizations(client, appId) {
|
|
11
|
+
return fetchAppleAppStoreVersionLocalizations(client, appId);
|
|
12
|
+
}
|
|
13
|
+
export function filterAppleScreenshotLocalizationsByLocale(localizations, locale) {
|
|
14
|
+
if (locale === undefined) {
|
|
15
|
+
return localizations;
|
|
16
|
+
}
|
|
17
|
+
const normalizedLocale = normalizeLocaleCode(locale);
|
|
18
|
+
return localizations.filter((localization) => normalizeLocaleCode(localization.attributes.locale ?? "") ===
|
|
19
|
+
normalizedLocale);
|
|
20
|
+
}
|
|
21
|
+
export async function fetchAppleScreenshotSetsForLocalizations(client, localizations) {
|
|
22
|
+
const results = [];
|
|
23
|
+
for (const localization of localizations) {
|
|
24
|
+
if (localization.id === undefined) {
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const screenshotSets = await requestAllAppStoreConnectPages(client, `/appStoreVersionLocalizations/${encodeURIComponent(localization.id)}/appScreenshotSets`);
|
|
28
|
+
results.push({
|
|
29
|
+
localizationId: localization.id,
|
|
30
|
+
locale: localization.attributes.locale,
|
|
31
|
+
screenshotSets,
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
return results.sort((left, right) => (left.locale ?? left.localizationId).localeCompare(right.locale ?? right.localizationId));
|
|
35
|
+
}
|
|
36
|
+
export async function fetchAppleScreenshotsForSets(client, screenshotSetsByLocalization) {
|
|
37
|
+
const results = [];
|
|
38
|
+
for (const localization of screenshotSetsByLocalization) {
|
|
39
|
+
for (const screenshotSet of localization.screenshotSets) {
|
|
40
|
+
const screenshots = await requestAllAppStoreConnectPages(client, `/appScreenshotSets/${encodeURIComponent(screenshotSet.id)}/appScreenshots`);
|
|
41
|
+
results.push({
|
|
42
|
+
localizationId: localization.localizationId,
|
|
43
|
+
locale: localization.locale,
|
|
44
|
+
screenshotSet,
|
|
45
|
+
screenshots,
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return results.sort((left, right) => {
|
|
50
|
+
const localeOrder = (left.locale ?? left.localizationId).localeCompare(right.locale ?? right.localizationId);
|
|
51
|
+
if (localeOrder !== 0) {
|
|
52
|
+
return localeOrder;
|
|
53
|
+
}
|
|
54
|
+
return left.screenshotSet.id.localeCompare(right.screenshotSet.id);
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
function resolveAppleScreenshotExtension(screenshot, contentType) {
|
|
58
|
+
if (contentType?.includes("png")) {
|
|
59
|
+
return ".png";
|
|
60
|
+
}
|
|
61
|
+
if (contentType?.includes("jpeg") || contentType?.includes("jpg")) {
|
|
62
|
+
return ".jpg";
|
|
63
|
+
}
|
|
64
|
+
const fileNameExtension = extname(screenshot.attributes?.fileName ?? "").toLowerCase();
|
|
65
|
+
if (fileNameExtension.length > 0) {
|
|
66
|
+
return fileNameExtension;
|
|
67
|
+
}
|
|
68
|
+
return ".png";
|
|
69
|
+
}
|
|
70
|
+
function replaceAppleImageAssetTemplateValue(templateUrl, token, value) {
|
|
71
|
+
if (!templateUrl.includes(token)) {
|
|
72
|
+
return templateUrl;
|
|
73
|
+
}
|
|
74
|
+
if (value === undefined) {
|
|
75
|
+
throw new StoremetaError("API_ERROR", `Apple screenshot image asset is missing ${token} data for template expansion`);
|
|
76
|
+
}
|
|
77
|
+
return templateUrl.replaceAll(token, String(value));
|
|
78
|
+
}
|
|
79
|
+
function resolveAppleScreenshotDownloadUrl(screenshot) {
|
|
80
|
+
const imageAsset = screenshot.attributes?.imageAsset;
|
|
81
|
+
if (imageAsset?.url !== undefined && imageAsset.url.trim().length > 0) {
|
|
82
|
+
return imageAsset.url;
|
|
83
|
+
}
|
|
84
|
+
if (imageAsset?.templateUrl !== undefined &&
|
|
85
|
+
imageAsset.templateUrl.trim().length > 0) {
|
|
86
|
+
const extension = resolveAppleScreenshotExtension(screenshot).replace(/^\./u, "");
|
|
87
|
+
let downloadUrl = imageAsset.templateUrl;
|
|
88
|
+
downloadUrl = replaceAppleImageAssetTemplateValue(downloadUrl, "{w}", imageAsset.width);
|
|
89
|
+
downloadUrl = replaceAppleImageAssetTemplateValue(downloadUrl, "{h}", imageAsset.height);
|
|
90
|
+
downloadUrl = replaceAppleImageAssetTemplateValue(downloadUrl, "{f}", extension);
|
|
91
|
+
return downloadUrl;
|
|
92
|
+
}
|
|
93
|
+
throw new StoremetaError("API_ERROR", `Apple screenshot ${screenshot.id} does not include a downloadable image asset`);
|
|
94
|
+
}
|
|
95
|
+
export async function downloadAppleScreenshot(screenshot) {
|
|
96
|
+
const response = await fetch(resolveAppleScreenshotDownloadUrl(screenshot));
|
|
97
|
+
if (!response.ok) {
|
|
98
|
+
throw new StoremetaError("API_ERROR", `Failed to download Apple screenshot ${screenshot.id} with ${response.status} ${response.statusText}`);
|
|
99
|
+
}
|
|
100
|
+
return {
|
|
101
|
+
buffer: new Uint8Array(await response.arrayBuffer()),
|
|
102
|
+
extension: resolveAppleScreenshotExtension(screenshot, response.headers.get("content-type")),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
function resolveAppleScreenshotFileName(screenshot, extension) {
|
|
106
|
+
const sourceFileName = screenshot.attributes?.fileName;
|
|
107
|
+
if (sourceFileName !== undefined && sourceFileName.trim().length > 0) {
|
|
108
|
+
return sourceFileName;
|
|
109
|
+
}
|
|
110
|
+
return `${screenshot.id}${extension}`;
|
|
111
|
+
}
|
|
112
|
+
export async function downloadAppleScreenshotSet(screenshotsBaseDir, screenshotSet) {
|
|
113
|
+
const locale = normalizeLocaleCode(screenshotSet.locale ?? "en-US");
|
|
114
|
+
const assetType = screenshotSet.screenshotSet.attributes?.screenshotDisplayType ??
|
|
115
|
+
screenshotSet.screenshotSet.id;
|
|
116
|
+
const downloadedScreenshots = [];
|
|
117
|
+
for (const [index, screenshot] of screenshotSet.screenshots.entries()) {
|
|
118
|
+
const position = index + 1;
|
|
119
|
+
const downloadedScreenshot = await downloadAppleScreenshot(screenshot);
|
|
120
|
+
const fileName = resolveAppleScreenshotFileName(screenshot, downloadedScreenshot.extension);
|
|
121
|
+
downloadedScreenshots.push({
|
|
122
|
+
descriptor: {
|
|
123
|
+
platform: "apple",
|
|
124
|
+
locale,
|
|
125
|
+
assetType,
|
|
126
|
+
filePath: "",
|
|
127
|
+
fileName,
|
|
128
|
+
position,
|
|
129
|
+
},
|
|
130
|
+
buffer: downloadedScreenshot.buffer,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
const orderedFiles = orderScreenshotsForWrite(downloadedScreenshots.map((screenshot) => screenshot.descriptor));
|
|
134
|
+
const files = [];
|
|
135
|
+
for (const [index, orderedFile] of orderedFiles.entries()) {
|
|
136
|
+
const filePath = resolveScreenshotFilePath(screenshotsBaseDir, {
|
|
137
|
+
platform: "apple",
|
|
138
|
+
locale,
|
|
139
|
+
assetType,
|
|
140
|
+
fileName: orderedFile.fileName,
|
|
141
|
+
});
|
|
142
|
+
await ensureParentDirectory(filePath);
|
|
143
|
+
try {
|
|
144
|
+
await writeFile(filePath, downloadedScreenshots[index].buffer);
|
|
145
|
+
}
|
|
146
|
+
catch (cause) {
|
|
147
|
+
throw new StoremetaError("FILESYSTEM_ERROR", `Failed to write Apple screenshot ${locale}/${assetType}/${orderedFile.fileName}`, { cause });
|
|
148
|
+
}
|
|
149
|
+
files.push({
|
|
150
|
+
...orderedFile,
|
|
151
|
+
filePath,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
platform: "apple",
|
|
156
|
+
locale,
|
|
157
|
+
assetType,
|
|
158
|
+
files,
|
|
159
|
+
};
|
|
160
|
+
}
|