@gpc-cli/core 0.9.58 → 0.9.59
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/dist/{chunk-QIYAOW6R.js → chunk-IZKB6GBS.js} +40 -29
- package/dist/chunk-IZKB6GBS.js.map +1 -0
- package/dist/index.d.ts +38 -2
- package/dist/index.js +218 -37
- package/dist/index.js.map +1 -1
- package/dist/{releases-I5MYFNCV.js → releases-VFDJ6IX2.js} +2 -2
- package/package.json +3 -3
- package/dist/chunk-QIYAOW6R.js.map +0 -1
- /package/dist/{releases-I5MYFNCV.js.map → releases-VFDJ6IX2.js.map} +0 -0
package/dist/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import {
|
|
|
4
4
|
GpcError,
|
|
5
5
|
NetworkError,
|
|
6
6
|
applyReleaseNotes,
|
|
7
|
+
commitWithRescue,
|
|
7
8
|
createTrack,
|
|
8
9
|
diffReleases,
|
|
9
10
|
fetchReleaseNotes,
|
|
@@ -14,9 +15,10 @@ import {
|
|
|
14
15
|
updateTrackConfig,
|
|
15
16
|
uploadExternallyHosted,
|
|
16
17
|
uploadRelease,
|
|
18
|
+
validateAndCommit,
|
|
17
19
|
validateUploadFile,
|
|
18
20
|
waitForBundleProcessing
|
|
19
|
-
} from "./chunk-
|
|
21
|
+
} from "./chunk-IZKB6GBS.js";
|
|
20
22
|
|
|
21
23
|
// src/output.ts
|
|
22
24
|
import process2 from "process";
|
|
@@ -915,10 +917,7 @@ async function updateListing(client, packageName, language, data, commitOptions)
|
|
|
915
917
|
const edit = await client.edits.insert(packageName);
|
|
916
918
|
try {
|
|
917
919
|
const listing = await client.listings.patch(packageName, edit.id, language, data);
|
|
918
|
-
|
|
919
|
-
await client.edits.validate(packageName, edit.id);
|
|
920
|
-
}
|
|
921
|
-
await client.edits.commit(packageName, edit.id, commitOptions);
|
|
920
|
+
await validateAndCommit(client, packageName, edit.id, commitOptions);
|
|
922
921
|
return listing;
|
|
923
922
|
} catch (error) {
|
|
924
923
|
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
@@ -931,10 +930,7 @@ async function deleteListing(client, packageName, language, commitOptions) {
|
|
|
931
930
|
const edit = await client.edits.insert(packageName);
|
|
932
931
|
try {
|
|
933
932
|
await client.listings.delete(packageName, edit.id, language);
|
|
934
|
-
|
|
935
|
-
await client.edits.validate(packageName, edit.id);
|
|
936
|
-
}
|
|
937
|
-
await client.edits.commit(packageName, edit.id, commitOptions);
|
|
933
|
+
await validateAndCommit(client, packageName, edit.id, commitOptions);
|
|
938
934
|
} catch (error) {
|
|
939
935
|
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
940
936
|
});
|
|
@@ -1057,10 +1053,7 @@ ${details}`,
|
|
|
1057
1053
|
const { language, ...data } = listing;
|
|
1058
1054
|
await client.listings.update(packageName, edit.id, language, data);
|
|
1059
1055
|
}
|
|
1060
|
-
|
|
1061
|
-
await client.edits.validate(packageName, edit.id);
|
|
1062
|
-
}
|
|
1063
|
-
await client.edits.commit(packageName, edit.id, options?.commitOptions);
|
|
1056
|
+
await validateAndCommit(client, packageName, edit.id, options?.commitOptions);
|
|
1064
1057
|
return {
|
|
1065
1058
|
updated: localListings.length,
|
|
1066
1059
|
languages: localListings.map((l) => l.language)
|
|
@@ -1101,10 +1094,7 @@ async function uploadImage(client, packageName, language, imageType, filePath, c
|
|
|
1101
1094
|
const edit = await client.edits.insert(packageName);
|
|
1102
1095
|
try {
|
|
1103
1096
|
const image = await client.images.upload(packageName, edit.id, language, imageType, filePath);
|
|
1104
|
-
|
|
1105
|
-
await client.edits.validate(packageName, edit.id);
|
|
1106
|
-
}
|
|
1107
|
-
await client.edits.commit(packageName, edit.id, commitOptions);
|
|
1097
|
+
await validateAndCommit(client, packageName, edit.id, commitOptions);
|
|
1108
1098
|
return image;
|
|
1109
1099
|
} catch (error) {
|
|
1110
1100
|
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
@@ -1117,10 +1107,7 @@ async function deleteImage(client, packageName, language, imageType, imageId, co
|
|
|
1117
1107
|
const edit = await client.edits.insert(packageName);
|
|
1118
1108
|
try {
|
|
1119
1109
|
await client.images.delete(packageName, edit.id, language, imageType, imageId);
|
|
1120
|
-
|
|
1121
|
-
await client.edits.validate(packageName, edit.id);
|
|
1122
|
-
}
|
|
1123
|
-
await client.edits.commit(packageName, edit.id, commitOptions);
|
|
1110
|
+
await validateAndCommit(client, packageName, edit.id, commitOptions);
|
|
1124
1111
|
} catch (error) {
|
|
1125
1112
|
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
1126
1113
|
});
|
|
@@ -1164,7 +1151,7 @@ var ALL_IMAGE_TYPES = [
|
|
|
1164
1151
|
];
|
|
1165
1152
|
async function exportImages(client, packageName, dir, options) {
|
|
1166
1153
|
const { mkdir: mkdir8, writeFile: writeFile9 } = await import("fs/promises");
|
|
1167
|
-
const { join:
|
|
1154
|
+
const { join: join13 } = await import("path");
|
|
1168
1155
|
const edit = await client.edits.insert(packageName);
|
|
1169
1156
|
try {
|
|
1170
1157
|
let languages;
|
|
@@ -1195,7 +1182,7 @@ async function exportImages(client, packageName, dir, options) {
|
|
|
1195
1182
|
const batch = tasks.slice(i, i + concurrency);
|
|
1196
1183
|
const results = await Promise.all(
|
|
1197
1184
|
batch.map(async (task) => {
|
|
1198
|
-
const dirPath =
|
|
1185
|
+
const dirPath = join13(dir, task.language, task.imageType);
|
|
1199
1186
|
await mkdir8(dirPath, { recursive: true });
|
|
1200
1187
|
const response = await fetch(task.url);
|
|
1201
1188
|
if (!response.ok) {
|
|
@@ -1207,7 +1194,7 @@ async function exportImages(client, packageName, dir, options) {
|
|
|
1207
1194
|
);
|
|
1208
1195
|
}
|
|
1209
1196
|
const buffer = Buffer.from(await response.arrayBuffer());
|
|
1210
|
-
const filePath =
|
|
1197
|
+
const filePath = join13(dirPath, `${task.index}.png`);
|
|
1211
1198
|
await writeFile9(filePath, buffer);
|
|
1212
1199
|
return buffer.length;
|
|
1213
1200
|
})
|
|
@@ -1233,10 +1220,7 @@ async function updateAppDetails(client, packageName, details, commitOptions) {
|
|
|
1233
1220
|
const edit = await client.edits.insert(packageName);
|
|
1234
1221
|
try {
|
|
1235
1222
|
const result = await client.details.patch(packageName, edit.id, details);
|
|
1236
|
-
|
|
1237
|
-
await client.edits.validate(packageName, edit.id);
|
|
1238
|
-
}
|
|
1239
|
-
await client.edits.commit(packageName, edit.id, commitOptions);
|
|
1223
|
+
await validateAndCommit(client, packageName, edit.id, commitOptions);
|
|
1240
1224
|
return result;
|
|
1241
1225
|
} catch (error) {
|
|
1242
1226
|
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
@@ -3238,7 +3222,7 @@ async function handleBreach(event, config, client) {
|
|
|
3238
3222
|
}
|
|
3239
3223
|
case "halt": {
|
|
3240
3224
|
try {
|
|
3241
|
-
const { updateRollout: updateRollout2 } = await import("./releases-
|
|
3225
|
+
const { updateRollout: updateRollout2 } = await import("./releases-VFDJ6IX2.js");
|
|
3242
3226
|
await updateRollout2(client, config.packageName, config.track, "halt");
|
|
3243
3227
|
halted = true;
|
|
3244
3228
|
} catch {
|
|
@@ -3822,10 +3806,7 @@ async function addTesters(client, packageName, track, groupEmails, commitOptions
|
|
|
3822
3806
|
const updated = await client.testers.update(packageName, edit.id, track, {
|
|
3823
3807
|
googleGroups: [...existing]
|
|
3824
3808
|
});
|
|
3825
|
-
|
|
3826
|
-
await client.edits.validate(packageName, edit.id);
|
|
3827
|
-
}
|
|
3828
|
-
await client.edits.commit(packageName, edit.id, commitOptions);
|
|
3809
|
+
await validateAndCommit(client, packageName, edit.id, commitOptions);
|
|
3829
3810
|
return updated;
|
|
3830
3811
|
} catch (error) {
|
|
3831
3812
|
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
@@ -3842,10 +3823,7 @@ async function removeTesters(client, packageName, track, groupEmails, commitOpti
|
|
|
3842
3823
|
const updated = await client.testers.update(packageName, edit.id, track, {
|
|
3843
3824
|
googleGroups: filtered
|
|
3844
3825
|
});
|
|
3845
|
-
|
|
3846
|
-
await client.edits.validate(packageName, edit.id);
|
|
3847
|
-
}
|
|
3848
|
-
await client.edits.commit(packageName, edit.id, commitOptions);
|
|
3826
|
+
await validateAndCommit(client, packageName, edit.id, commitOptions);
|
|
3849
3827
|
return updated;
|
|
3850
3828
|
} catch (error) {
|
|
3851
3829
|
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
@@ -8371,6 +8349,202 @@ function compareFingerprints(a, b) {
|
|
|
8371
8349
|
return normalizeFingerprint(a) === normalizeFingerprint(b);
|
|
8372
8350
|
}
|
|
8373
8351
|
|
|
8352
|
+
// src/utils/hash.ts
|
|
8353
|
+
import { createHash } from "crypto";
|
|
8354
|
+
import { stat as stat9 } from "fs/promises";
|
|
8355
|
+
async function sha256File(filePath) {
|
|
8356
|
+
const hash = createHash("sha256");
|
|
8357
|
+
const { size } = await stat9(filePath);
|
|
8358
|
+
if (size === 0) return hash.digest("hex");
|
|
8359
|
+
const { createReadStream } = await import("fs");
|
|
8360
|
+
const stream = createReadStream(filePath);
|
|
8361
|
+
await new Promise((resolve2, reject) => {
|
|
8362
|
+
stream.on("data", (chunk) => hash.update(chunk));
|
|
8363
|
+
stream.on("end", resolve2);
|
|
8364
|
+
stream.on("error", reject);
|
|
8365
|
+
});
|
|
8366
|
+
return hash.digest("hex");
|
|
8367
|
+
}
|
|
8368
|
+
|
|
8369
|
+
// src/commands/image-sync.ts
|
|
8370
|
+
import { readdir as readdir7 } from "fs/promises";
|
|
8371
|
+
import { join as join12, extname as extname5 } from "path";
|
|
8372
|
+
import { PlayApiError } from "@gpc-cli/api";
|
|
8373
|
+
var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg"]);
|
|
8374
|
+
var ALL_IMAGE_TYPES2 = [
|
|
8375
|
+
"icon",
|
|
8376
|
+
"featureGraphic",
|
|
8377
|
+
"tvBanner",
|
|
8378
|
+
"phoneScreenshots",
|
|
8379
|
+
"sevenInchScreenshots",
|
|
8380
|
+
"tenInchScreenshots",
|
|
8381
|
+
"tvScreenshots",
|
|
8382
|
+
"wearScreenshots"
|
|
8383
|
+
];
|
|
8384
|
+
async function scanLocalImages(dir) {
|
|
8385
|
+
try {
|
|
8386
|
+
const entries = await readdir7(dir, { withFileTypes: true });
|
|
8387
|
+
return entries.filter((e) => e.isFile() && IMAGE_EXTENSIONS.has(extname5(e.name).toLowerCase())).map((e) => e.name).sort();
|
|
8388
|
+
} catch {
|
|
8389
|
+
return [];
|
|
8390
|
+
}
|
|
8391
|
+
}
|
|
8392
|
+
async function scanLanguages(dir) {
|
|
8393
|
+
try {
|
|
8394
|
+
const entries = await readdir7(dir, { withFileTypes: true });
|
|
8395
|
+
return entries.filter((e) => e.isDirectory()).map((e) => e.name).sort();
|
|
8396
|
+
} catch {
|
|
8397
|
+
return [];
|
|
8398
|
+
}
|
|
8399
|
+
}
|
|
8400
|
+
async function syncImages(client, packageName, dir, options) {
|
|
8401
|
+
const details = [];
|
|
8402
|
+
let uploaded = 0;
|
|
8403
|
+
let skipped = 0;
|
|
8404
|
+
let deleted = 0;
|
|
8405
|
+
const languages = options?.lang ? [options.lang] : await scanLanguages(dir);
|
|
8406
|
+
if (languages.length === 0) {
|
|
8407
|
+
throw new GpcError(
|
|
8408
|
+
`No language directories found in "${dir}"`,
|
|
8409
|
+
"IMAGE_SYNC_EMPTY",
|
|
8410
|
+
1,
|
|
8411
|
+
"The directory should contain subdirectories named by language code (e.g., en-US/) with image type subdirectories inside."
|
|
8412
|
+
);
|
|
8413
|
+
}
|
|
8414
|
+
const imageTypes = options?.type ? [options.type] : ALL_IMAGE_TYPES2;
|
|
8415
|
+
const edit = await client.edits.insert(packageName);
|
|
8416
|
+
try {
|
|
8417
|
+
for (const language of languages) {
|
|
8418
|
+
for (const imageType of imageTypes) {
|
|
8419
|
+
const localDir = join12(dir, language, imageType);
|
|
8420
|
+
const localFiles = await scanLocalImages(localDir);
|
|
8421
|
+
let remoteImages;
|
|
8422
|
+
try {
|
|
8423
|
+
remoteImages = await client.images.list(packageName, edit.id, language, imageType);
|
|
8424
|
+
} catch (error) {
|
|
8425
|
+
if (error instanceof PlayApiError && error.statusCode === 404) {
|
|
8426
|
+
remoteImages = [];
|
|
8427
|
+
} else {
|
|
8428
|
+
throw error;
|
|
8429
|
+
}
|
|
8430
|
+
}
|
|
8431
|
+
const remoteSha256Set = new Set(remoteImages.map((img) => img.sha256.toLowerCase()));
|
|
8432
|
+
const localHashes = /* @__PURE__ */ new Map();
|
|
8433
|
+
for (const file of localFiles) {
|
|
8434
|
+
const hash = await sha256File(join12(localDir, file));
|
|
8435
|
+
localHashes.set(file, hash);
|
|
8436
|
+
}
|
|
8437
|
+
const localSha256Set = new Set(localHashes.values());
|
|
8438
|
+
if (options?.delete) {
|
|
8439
|
+
for (const img of remoteImages) {
|
|
8440
|
+
if (!localSha256Set.has(img.sha256.toLowerCase())) {
|
|
8441
|
+
if (!options?.dryRun) {
|
|
8442
|
+
await client.images.delete(packageName, edit.id, language, imageType, img.id);
|
|
8443
|
+
}
|
|
8444
|
+
deleted++;
|
|
8445
|
+
details.push({
|
|
8446
|
+
language,
|
|
8447
|
+
imageType,
|
|
8448
|
+
file: img.id,
|
|
8449
|
+
action: "delete",
|
|
8450
|
+
reason: "not in local"
|
|
8451
|
+
});
|
|
8452
|
+
}
|
|
8453
|
+
}
|
|
8454
|
+
}
|
|
8455
|
+
for (const file of localFiles) {
|
|
8456
|
+
const hash = localHashes.get(file);
|
|
8457
|
+
if (remoteSha256Set.has(hash)) {
|
|
8458
|
+
skipped++;
|
|
8459
|
+
details.push({ language, imageType, file, action: "skip", reason: "sha256 match" });
|
|
8460
|
+
} else {
|
|
8461
|
+
if (!options?.dryRun) {
|
|
8462
|
+
const filePath = join12(localDir, file);
|
|
8463
|
+
const check = await validateImage(filePath, imageType);
|
|
8464
|
+
if (!check.valid) {
|
|
8465
|
+
throw new GpcError(
|
|
8466
|
+
`Image validation failed for ${language}/${imageType}/${file}: ${check.errors.join("; ")}`,
|
|
8467
|
+
"IMAGE_INVALID",
|
|
8468
|
+
2,
|
|
8469
|
+
"Check image dimensions, file size, and format."
|
|
8470
|
+
);
|
|
8471
|
+
}
|
|
8472
|
+
await client.images.upload(packageName, edit.id, language, imageType, filePath);
|
|
8473
|
+
}
|
|
8474
|
+
uploaded++;
|
|
8475
|
+
details.push({ language, imageType, file, action: "upload", reason: "new or changed" });
|
|
8476
|
+
}
|
|
8477
|
+
}
|
|
8478
|
+
}
|
|
8479
|
+
}
|
|
8480
|
+
if (options?.dryRun || uploaded === 0 && deleted === 0) {
|
|
8481
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
8482
|
+
});
|
|
8483
|
+
} else {
|
|
8484
|
+
await validateAndCommit(client, packageName, edit.id, options?.commitOptions);
|
|
8485
|
+
}
|
|
8486
|
+
return {
|
|
8487
|
+
uploaded,
|
|
8488
|
+
skipped,
|
|
8489
|
+
deleted,
|
|
8490
|
+
total: uploaded + skipped + deleted,
|
|
8491
|
+
details
|
|
8492
|
+
};
|
|
8493
|
+
} catch (error) {
|
|
8494
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
8495
|
+
});
|
|
8496
|
+
throw error;
|
|
8497
|
+
}
|
|
8498
|
+
}
|
|
8499
|
+
|
|
8500
|
+
// src/commands/bundles.ts
|
|
8501
|
+
import { PlayApiError as PlayApiError2 } from "@gpc-cli/api";
|
|
8502
|
+
async function listBundles(client, packageName) {
|
|
8503
|
+
const edit = await client.edits.insert(packageName);
|
|
8504
|
+
try {
|
|
8505
|
+
const bundles = await client.bundles.list(packageName, edit.id);
|
|
8506
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
8507
|
+
});
|
|
8508
|
+
return bundles;
|
|
8509
|
+
} catch (error) {
|
|
8510
|
+
await client.edits.delete(packageName, edit.id).catch(() => {
|
|
8511
|
+
});
|
|
8512
|
+
throw error;
|
|
8513
|
+
}
|
|
8514
|
+
}
|
|
8515
|
+
async function findBundle(client, packageName, versionCode) {
|
|
8516
|
+
const bundles = await listBundles(client, packageName);
|
|
8517
|
+
return bundles.find((b) => b.versionCode === versionCode) ?? null;
|
|
8518
|
+
}
|
|
8519
|
+
function isRetryableError(error) {
|
|
8520
|
+
if (!(error instanceof PlayApiError2)) return false;
|
|
8521
|
+
const status = error.statusCode;
|
|
8522
|
+
return status === 429 || status === 500 || status === 503 || status === 409;
|
|
8523
|
+
}
|
|
8524
|
+
async function waitForBundle(client, packageName, versionCode, options) {
|
|
8525
|
+
const timeout = options?.timeout ?? 6e5;
|
|
8526
|
+
const interval = options?.interval ?? 15e3;
|
|
8527
|
+
const deadline = Date.now() + timeout;
|
|
8528
|
+
while (Date.now() < deadline) {
|
|
8529
|
+
try {
|
|
8530
|
+
const bundles = await listBundles(client, packageName);
|
|
8531
|
+
const match = bundles.find((b) => b.versionCode === versionCode);
|
|
8532
|
+
if (match) return match;
|
|
8533
|
+
} catch (error) {
|
|
8534
|
+
if (!isRetryableError(error)) throw error;
|
|
8535
|
+
}
|
|
8536
|
+
const remaining = deadline - Date.now();
|
|
8537
|
+
if (remaining <= 0) break;
|
|
8538
|
+
await new Promise((r) => setTimeout(r, Math.min(interval, remaining)));
|
|
8539
|
+
}
|
|
8540
|
+
throw new GpcError(
|
|
8541
|
+
`Bundle version code ${versionCode} not found after ${Math.round(timeout / 1e3)}s`,
|
|
8542
|
+
"BUNDLE_WAIT_TIMEOUT",
|
|
8543
|
+
4,
|
|
8544
|
+
"The bundle may still be processing. Try again with a longer --timeout, or check the Play Console."
|
|
8545
|
+
);
|
|
8546
|
+
}
|
|
8547
|
+
|
|
8374
8548
|
// src/signing-consistency.ts
|
|
8375
8549
|
async function checkSigningConsistency(accessToken, packageName, apiHost = "androidpublisher.googleapis.com") {
|
|
8376
8550
|
const baseUrl = `https://${apiHost}/androidpublisher/v3/applications/${encodeURIComponent(packageName)}`;
|
|
@@ -8613,6 +8787,7 @@ export {
|
|
|
8613
8787
|
checkThreshold,
|
|
8614
8788
|
classifyError,
|
|
8615
8789
|
clearAuditLog,
|
|
8790
|
+
commitWithRescue,
|
|
8616
8791
|
compareBundles,
|
|
8617
8792
|
compareFingerprints,
|
|
8618
8793
|
compareVersionVitals,
|
|
@@ -8667,6 +8842,7 @@ export {
|
|
|
8667
8842
|
fetchAggregateCost,
|
|
8668
8843
|
fetchChangelog,
|
|
8669
8844
|
fetchReleaseNotes,
|
|
8845
|
+
findBundle,
|
|
8670
8846
|
formatChangelogEntry,
|
|
8671
8847
|
formatCustomPayload,
|
|
8672
8848
|
formatDiscordPayload,
|
|
@@ -8733,6 +8909,7 @@ export {
|
|
|
8733
8909
|
lintLocalListings,
|
|
8734
8910
|
listAchievements,
|
|
8735
8911
|
listAuditEvents,
|
|
8912
|
+
listBundles,
|
|
8736
8913
|
listDeviceTiers,
|
|
8737
8914
|
listEvents,
|
|
8738
8915
|
listGeneratedApks,
|
|
@@ -8801,9 +8978,11 @@ export {
|
|
|
8801
8978
|
searchVitalsErrors,
|
|
8802
8979
|
sendNotification,
|
|
8803
8980
|
sendWebhook,
|
|
8981
|
+
sha256File,
|
|
8804
8982
|
sortResults,
|
|
8805
8983
|
startTrain,
|
|
8806
8984
|
statusHasBreach,
|
|
8985
|
+
syncImages,
|
|
8807
8986
|
syncInAppProducts,
|
|
8808
8987
|
topFiles,
|
|
8809
8988
|
trackBreachState,
|
|
@@ -8824,6 +9003,7 @@ export {
|
|
|
8824
9003
|
uploadImage,
|
|
8825
9004
|
uploadInternalSharing,
|
|
8826
9005
|
uploadRelease,
|
|
9006
|
+
validateAndCommit,
|
|
8827
9007
|
validateBundleForApply,
|
|
8828
9008
|
validateImage,
|
|
8829
9009
|
validateLanguageCode,
|
|
@@ -8834,6 +9014,7 @@ export {
|
|
|
8834
9014
|
validateTrackName,
|
|
8835
9015
|
validateUploadFile,
|
|
8836
9016
|
validateVersionCode,
|
|
9017
|
+
waitForBundle,
|
|
8837
9018
|
waitForBundleProcessing,
|
|
8838
9019
|
watchVitalsWithAutoHalt,
|
|
8839
9020
|
wordDiff,
|