@gpc-cli/core 0.9.76 → 0.9.78

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/index.js CHANGED
@@ -3843,6 +3843,255 @@ async function convertRegionPrices(client, packageName, currencyCode, amount) {
3843
3843
  });
3844
3844
  }
3845
3845
 
3846
+ // src/reports/errors.ts
3847
+ function reportAccessDeniedError(bucket) {
3848
+ return new GpcError(
3849
+ `Access denied reading the Play reports bucket "${bucket}".`,
3850
+ "REPORT_ACCESS_DENIED",
3851
+ 4,
3852
+ "Grant the service account access: Play Console -> Users & permissions -> the service account -> Account permissions -> enable 'View app information and download bulk reports (read-only)', then wait a few minutes for it to propagate."
3853
+ );
3854
+ }
3855
+ function reportAuthRejectedError(bucket) {
3856
+ return new GpcError(
3857
+ `Authentication was rejected reading the Play reports bucket "${bucket}".`,
3858
+ "REPORT_AUTH_REJECTED",
3859
+ 3,
3860
+ "The access token was not accepted. Check the service account key is valid (gpc auth status) and that your system clock is accurate, then retry."
3861
+ );
3862
+ }
3863
+ function reportBucketNotFoundError(bucket) {
3864
+ return new GpcError(
3865
+ `Play reports bucket "${bucket}" was not found.`,
3866
+ "REPORT_BUCKET_NOT_FOUND",
3867
+ 4,
3868
+ "Confirm the bucket name. Copy the exact Cloud Storage URI from Play Console -> Download reports, then set it with --bucket or the 'reports.bucket' config key. The default is pubsite_prod_<developerId>."
3869
+ );
3870
+ }
3871
+ function reportObjectNotFoundError(opts) {
3872
+ const { reportType, month, dimension, available } = opts;
3873
+ const dimPart = dimension ? ` dimension "${dimension}"` : "";
3874
+ const availPart = available && available.length ? ` Available: ${available.join(", ")}.` : " No matching report objects were found for that month.";
3875
+ return new GpcError(
3876
+ `No "${reportType}" report found for ${month}${dimPart}.`,
3877
+ "REPORT_OBJECT_NOT_FOUND",
3878
+ 4,
3879
+ `Check the month is complete and the report type/dimension exist.${availPart} Reports for the current month may not be published until it ends.`
3880
+ );
3881
+ }
3882
+
3883
+ // src/reports/gcs.ts
3884
+ var GCS_API_BASE = "https://storage.googleapis.com/storage/v1/b";
3885
+ var LIST_TIMEOUT_MS = 6e4;
3886
+ var DOWNLOAD_TIMEOUT_MS = 12e4;
3887
+ function networkError(bucket, cause) {
3888
+ const detail = cause instanceof Error ? cause.message : String(cause);
3889
+ return new GpcError(
3890
+ `Network error reading the Play reports bucket "${bucket}": ${detail}`,
3891
+ "NETWORK_ERROR",
3892
+ 5,
3893
+ "Check your internet connection and retry. Google Cloud Storage may also be briefly unavailable."
3894
+ );
3895
+ }
3896
+ function maxRetries() {
3897
+ const parsed = Number(process.env["GPC_MAX_RETRIES"]);
3898
+ return Number.isInteger(parsed) && parsed >= 0 && parsed <= 10 ? parsed : 3;
3899
+ }
3900
+ var RETRYABLE_STATUSES = /* @__PURE__ */ new Set([429, 500, 502, 503, 504]);
3901
+ async function gcsFetch(auth, bucket, url, timeoutMs) {
3902
+ const retries = maxRetries();
3903
+ let lastError;
3904
+ for (let attempt = 0; attempt <= retries; attempt++) {
3905
+ if (attempt > 0) {
3906
+ await new Promise((r) => setTimeout(r, Math.min(1e3 * 2 ** (attempt - 1), 8e3)));
3907
+ }
3908
+ const token = await auth.getAccessToken();
3909
+ let res;
3910
+ try {
3911
+ res = await fetch(url, {
3912
+ headers: { authorization: `Bearer ${token}` },
3913
+ signal: AbortSignal.timeout(timeoutMs)
3914
+ });
3915
+ } catch (err) {
3916
+ lastError = err;
3917
+ continue;
3918
+ }
3919
+ if (RETRYABLE_STATUSES.has(res.status) && attempt < retries) {
3920
+ continue;
3921
+ }
3922
+ if (res.status === 401) {
3923
+ throw reportAuthRejectedError(bucket);
3924
+ }
3925
+ if (res.status === 403) {
3926
+ throw reportAccessDeniedError(bucket);
3927
+ }
3928
+ return res;
3929
+ }
3930
+ throw networkError(bucket, lastError ?? new Error("retries exhausted"));
3931
+ }
3932
+ async function listReportObjects(auth, bucket, options) {
3933
+ const params = new URLSearchParams({ prefix: options.prefix });
3934
+ if (options.maxResults) params.set("maxResults", String(options.maxResults));
3935
+ if (options.pageToken) params.set("pageToken", options.pageToken);
3936
+ const url = `${GCS_API_BASE}/${encodeURIComponent(bucket)}/o?${params.toString()}`;
3937
+ const res = await gcsFetch(auth, bucket, url, LIST_TIMEOUT_MS);
3938
+ if (res.status === 404) {
3939
+ throw reportBucketNotFoundError(bucket);
3940
+ }
3941
+ if (!res.ok) {
3942
+ throw new GpcError(
3943
+ `Google Cloud Storage returned HTTP ${res.status} listing the reports bucket "${bucket}".`,
3944
+ "REPORT_LIST_FAILED",
3945
+ 4,
3946
+ "Retry in a moment. If the error persists, verify the bucket name and the service account's report access."
3947
+ );
3948
+ }
3949
+ let body;
3950
+ try {
3951
+ body = await res.json();
3952
+ } catch (err) {
3953
+ throw networkError(bucket, err);
3954
+ }
3955
+ const objects = (body.items ?? []).filter((item) => typeof item.name === "string").map((item) => ({
3956
+ name: item.name,
3957
+ size: Number(item.size ?? 0),
3958
+ updated: item.updated
3959
+ }));
3960
+ return { objects, nextPageToken: body.nextPageToken };
3961
+ }
3962
+ async function downloadReportObject(auth, bucket, objectName) {
3963
+ const url = `${GCS_API_BASE}/${encodeURIComponent(bucket)}/o/${encodeURIComponent(objectName)}?alt=media`;
3964
+ const res = await gcsFetch(auth, bucket, url, DOWNLOAD_TIMEOUT_MS);
3965
+ if (res.status === 404) {
3966
+ throw new GpcError(
3967
+ `Report object "${objectName}" was not found in bucket "${bucket}".`,
3968
+ "REPORT_OBJECT_NOT_FOUND",
3969
+ 4,
3970
+ "List available reports first: gpc reports list <report-type> --month <YYYY-MM>"
3971
+ );
3972
+ }
3973
+ if (!res.ok) {
3974
+ throw new GpcError(
3975
+ `Google Cloud Storage returned HTTP ${res.status} downloading "${objectName}".`,
3976
+ "REPORT_DOWNLOAD_FAILED",
3977
+ 4,
3978
+ "Retry in a moment. If the error persists, list the reports to confirm the object still exists."
3979
+ );
3980
+ }
3981
+ try {
3982
+ return Buffer.from(await res.arrayBuffer());
3983
+ } catch (err) {
3984
+ throw networkError(bucket, err);
3985
+ }
3986
+ }
3987
+
3988
+ // src/reports/decode.ts
3989
+ import { gunzipSync } from "zlib";
3990
+ import { fromBuffer } from "yauzl";
3991
+ var GZIP_MAGIC = [31, 139];
3992
+ var ZIP_MAGIC = [80, 75];
3993
+ function isGzip(buf) {
3994
+ return buf.length >= 2 && buf[0] === GZIP_MAGIC[0] && buf[1] === GZIP_MAGIC[1];
3995
+ }
3996
+ function isZip(buf) {
3997
+ return buf.length >= 2 && buf[0] === ZIP_MAGIC[0] && buf[1] === ZIP_MAGIC[1];
3998
+ }
3999
+ var MAX_INFLATED_BYTES = 512 * 1024 * 1024;
4000
+ function gunzipIfNeeded(buf) {
4001
+ return isGzip(buf) ? gunzipSync(buf, { maxOutputLength: MAX_INFLATED_BYTES }) : buf;
4002
+ }
4003
+ function decodeCsvText(buf) {
4004
+ if (buf.length >= 2 && buf[0] === 255 && buf[1] === 254) {
4005
+ return buf.subarray(2).toString("utf16le");
4006
+ }
4007
+ if (buf.length >= 2 && buf[0] === 254 && buf[1] === 255) {
4008
+ const swapped = Buffer.from(buf.subarray(2));
4009
+ swapped.swap16();
4010
+ return swapped.toString("utf16le");
4011
+ }
4012
+ if (buf.length >= 3 && buf[0] === 239 && buf[1] === 187 && buf[2] === 191) {
4013
+ return buf.subarray(3).toString("utf8");
4014
+ }
4015
+ return buf.toString("utf8");
4016
+ }
4017
+ function decodeStatsCsv(buf) {
4018
+ return decodeCsvText(gunzipIfNeeded(buf));
4019
+ }
4020
+ var ZIP_READ_TIMEOUT_MS = 3e4;
4021
+ function extractCsvEntriesFromZip(buffer) {
4022
+ return new Promise((resolve2, reject) => {
4023
+ let settled = false;
4024
+ const timer = setTimeout(() => {
4025
+ settleReject(new Error("Timed out reading report archive"));
4026
+ }, ZIP_READ_TIMEOUT_MS);
4027
+ timer.unref?.();
4028
+ function settleResolve(v) {
4029
+ if (settled) return;
4030
+ settled = true;
4031
+ clearTimeout(timer);
4032
+ resolve2(v);
4033
+ }
4034
+ function settleReject(e) {
4035
+ if (settled) return;
4036
+ settled = true;
4037
+ clearTimeout(timer);
4038
+ reject(e);
4039
+ }
4040
+ try {
4041
+ fromBuffer(buffer, { lazyEntries: true }, (err, openedZip) => {
4042
+ if (err || !openedZip) {
4043
+ settleReject(err ?? new Error("Failed to open report archive"));
4044
+ return;
4045
+ }
4046
+ const zipfile = openedZip;
4047
+ const entries = [];
4048
+ let pending = 0;
4049
+ let entriesDone = false;
4050
+ function fail(e) {
4051
+ try {
4052
+ zipfile.close();
4053
+ } catch {
4054
+ }
4055
+ settleReject(e);
4056
+ }
4057
+ function maybeResolve() {
4058
+ if (!settled && entriesDone && pending === 0) settleResolve(entries);
4059
+ }
4060
+ zipfile.on("entry", (entry) => {
4061
+ if (/\/$/.test(entry.fileName) || !/\.csv$/i.test(entry.fileName)) {
4062
+ zipfile.readEntry();
4063
+ return;
4064
+ }
4065
+ pending++;
4066
+ zipfile.openReadStream(entry, (streamErr, stream) => {
4067
+ if (streamErr || !stream) {
4068
+ fail(streamErr ?? new Error(`Failed to read ${entry.fileName}`));
4069
+ return;
4070
+ }
4071
+ const chunks = [];
4072
+ stream.on("data", (c) => chunks.push(c));
4073
+ stream.on("error", fail);
4074
+ stream.on("end", () => {
4075
+ entries.push({ name: entry.fileName, text: decodeStatsCsv(Buffer.concat(chunks)) });
4076
+ pending--;
4077
+ zipfile.readEntry();
4078
+ maybeResolve();
4079
+ });
4080
+ });
4081
+ });
4082
+ zipfile.on("end", () => {
4083
+ entriesDone = true;
4084
+ maybeResolve();
4085
+ });
4086
+ zipfile.on("error", fail);
4087
+ zipfile.readEntry();
4088
+ });
4089
+ } catch (e) {
4090
+ settleReject(e instanceof Error ? e : new Error(String(e)));
4091
+ }
4092
+ });
4093
+ }
4094
+
3846
4095
  // src/commands/reports.ts
3847
4096
  var FINANCIAL_REPORT_TYPES = /* @__PURE__ */ new Set([
3848
4097
  "earnings",
@@ -3865,6 +4114,7 @@ var VALID_DIMENSIONS = /* @__PURE__ */ new Set([
3865
4114
  "device",
3866
4115
  "app_version",
3867
4116
  "carrier",
4117
+ "traffic_source",
3868
4118
  "overview"
3869
4119
  ]);
3870
4120
  function isFinancialReportType(type) {
@@ -3901,22 +4151,131 @@ function parseMonth(monthStr) {
3901
4151
  }
3902
4152
  return { year, month };
3903
4153
  }
3904
- async function listReports(_client, _packageName, reportType, _year, _month) {
3905
- throw new GpcError(
3906
- `Report listing for "${reportType}" is not available through the Google Play Developer API.`,
3907
- "REPORT_NOT_SUPPORTED",
3908
- 1,
3909
- isFinancialReportType(reportType) ? "Financial reports are delivered via Google Cloud Storage. Access them from Play Console \u2192 Download reports \u2192 Financial." : "Stats reports are delivered via Google Cloud Storage. For real-time metrics, use 'gpc vitals' commands."
3910
- );
4154
+ function monthToken(parsed) {
4155
+ return `${parsed.year}${String(parsed.month).padStart(2, "0")}`;
4156
+ }
4157
+ function nameHasMonth(name, token) {
4158
+ return new RegExp(`(?<![0-9])${token}(?![0-9])`).test(name);
3911
4159
  }
3912
- async function downloadReport(_client, _packageName, reportType, _year, _month) {
4160
+ function resolveReportsBucket(config) {
4161
+ const explicit = config.reports?.bucket?.trim();
4162
+ if (explicit) {
4163
+ const reduced = explicit.replace(/^gs:\/\//, "").replace(/\/.*$/, "");
4164
+ if (!/^[a-z0-9][a-z0-9._-]{1,221}$/.test(reduced)) {
4165
+ throw new GpcError(
4166
+ `Invalid reports bucket name "${reduced}".`,
4167
+ "REPORT_BUCKET_INVALID",
4168
+ 2,
4169
+ "Copy the exact Cloud Storage URI from Play Console -> Download reports. The bucket name looks like pubsite_prod_<developerId>."
4170
+ );
4171
+ }
4172
+ return reduced;
4173
+ }
4174
+ if (config.developerId) {
4175
+ return `pubsite_prod_${config.developerId}`;
4176
+ }
3913
4177
  throw new GpcError(
3914
- `Report download for "${reportType}" is not available through the Google Play Developer API.`,
3915
- "REPORT_NOT_SUPPORTED",
3916
- 1,
3917
- isFinancialReportType(reportType) ? "Financial reports are delivered via Google Cloud Storage. Access them from Play Console \u2192 Download reports \u2192 Financial." : "Stats reports are delivered via Google Cloud Storage. For real-time metrics, use 'gpc vitals' commands."
4178
+ "Cannot determine the Play reports bucket: no bucket configured and no developer id set.",
4179
+ "REPORT_BUCKET_UNKNOWN",
4180
+ 2,
4181
+ "Set your developer id (gpc config set developerId <id> or GPC_DEVELOPER_ID) so the default pubsite_prod_<developerId> bucket can be derived, or pass the bucket directly with --bucket / GPC_REPORTS_BUCKET / the reports.bucket config key. The exact URI is shown in Play Console -> Download reports."
3918
4182
  );
3919
4183
  }
4184
+ function reportPrefix(reportType, packageName) {
4185
+ switch (reportType) {
4186
+ case "earnings":
4187
+ return "earnings/";
4188
+ case "sales":
4189
+ case "estimated_sales":
4190
+ return "sales/";
4191
+ case "play_balance":
4192
+ return "play_balance/";
4193
+ case "reviews":
4194
+ return packageName ? `reviews/reviews_${packageName}_` : "reviews/";
4195
+ case "subscriptions":
4196
+ return packageName ? `financial-stats/subscriptions/subscriptions_${packageName}_` : "financial-stats/subscriptions/";
4197
+ default:
4198
+ return packageName ? `stats/${reportType}/${reportType}_${packageName}_` : `stats/${reportType}/`;
4199
+ }
4200
+ }
4201
+ async function listReports(auth, bucket, reportType, options = {}) {
4202
+ const packageName = isStatsReportType(reportType) ? options.packageName : void 0;
4203
+ const basePrefix = reportPrefix(reportType, packageName);
4204
+ const serverSideMonth = Boolean(packageName && options.month);
4205
+ const prefix = serverSideMonth ? `${basePrefix}${monthToken(options.month)}` : basePrefix;
4206
+ const { objects, nextPageToken } = await listReportObjects(auth, bucket, {
4207
+ prefix,
4208
+ maxResults: options.maxResults,
4209
+ pageToken: options.pageToken
4210
+ });
4211
+ const reports = options.month && !serverSideMonth ? objects.filter((o) => nameHasMonth(o.name, monthToken(options.month))) : objects;
4212
+ return { reports, nextPageToken };
4213
+ }
4214
+ async function downloadStatsReport(auth, bucket, packageName, reportType, month, dimension) {
4215
+ const token = monthToken(month);
4216
+ const prefix = `${reportPrefix(reportType, packageName)}${token}`;
4217
+ const { objects } = await listReportObjects(auth, bucket, { prefix });
4218
+ const wanted = reportType === "reviews" ? objects.find((o) => o.name.endsWith(`${token}.csv`)) : objects.find((o) => o.name.endsWith(`${token}_${dimension}.csv`));
4219
+ if (!wanted) {
4220
+ const available = objects.map((o) => new RegExp(`(?<![0-9])${token}_([a-z_]+)\\.csv$`).exec(o.name)?.[1]).filter((d) => Boolean(d));
4221
+ throw reportObjectNotFoundError({
4222
+ reportType,
4223
+ month: `${month.year}-${String(month.month).padStart(2, "0")}`,
4224
+ dimension: reportType === "reviews" ? void 0 : dimension,
4225
+ available: [...new Set(available)]
4226
+ });
4227
+ }
4228
+ const bytes = await downloadReportObject(auth, bucket, wanted.name);
4229
+ try {
4230
+ return { objectName: wanted.name, csv: decodeStatsCsv(bytes) };
4231
+ } catch (err) {
4232
+ throw new GpcError(
4233
+ `Could not decode the report "${wanted.name}": ${err instanceof Error ? err.message : String(err)}`,
4234
+ "REPORT_DECODE_FAILED",
4235
+ 4,
4236
+ "Re-download in case the transfer was corrupted. If the error persists, the object may be malformed; download it from Play Console -> Download reports to compare."
4237
+ );
4238
+ }
4239
+ }
4240
+ async function downloadFinancialReport(auth, bucket, reportType, month, options = {}) {
4241
+ const token = monthToken(month);
4242
+ const { objects } = await listReportObjects(auth, bucket, {
4243
+ prefix: reportPrefix(reportType)
4244
+ });
4245
+ const matches = objects.filter((o) => nameHasMonth(o.name, token));
4246
+ if (matches.length === 0) {
4247
+ const availableMonths = [
4248
+ ...new Set(
4249
+ objects.map((o) => /(?<![0-9])(\d{4})(\d{2})(?![0-9])/.exec(o.name)).filter((m) => m !== null).map((m) => `${m[1]}-${m[2]}`)
4250
+ )
4251
+ ].slice(-6);
4252
+ throw reportObjectNotFoundError({
4253
+ reportType,
4254
+ month: `${month.year}-${String(month.month).padStart(2, "0")}`,
4255
+ available: availableMonths
4256
+ });
4257
+ }
4258
+ const chosen = matches[matches.length - 1];
4259
+ const bytes = await downloadReportObject(auth, bucket, chosen.name);
4260
+ if (isZip(bytes)) {
4261
+ if (options.extractEntries === false) {
4262
+ return { objectName: chosen.name, kind: "zip", entries: [], raw: bytes };
4263
+ }
4264
+ let entries;
4265
+ try {
4266
+ entries = await extractCsvEntriesFromZip(bytes);
4267
+ } catch (err) {
4268
+ throw new GpcError(
4269
+ `Could not read the report archive "${chosen.name}": ${err instanceof Error ? err.message : String(err)}`,
4270
+ "REPORT_ARCHIVE_UNREADABLE",
4271
+ 4,
4272
+ "Save the raw archive instead (--output-file report.zip) and open it locally, or re-download in case the transfer was corrupted."
4273
+ );
4274
+ }
4275
+ return { objectName: chosen.name, kind: "zip", entries, raw: bytes };
4276
+ }
4277
+ return { objectName: chosen.name, kind: "csv", text: decodeStatsCsv(bytes) };
4278
+ }
3920
4279
 
3921
4280
  // src/commands/users.ts
3922
4281
  import { paginateAll as paginateAll5 } from "@gpc-cli/api";
@@ -5376,7 +5735,7 @@ async function loadPreflightConfig(configPath) {
5376
5735
 
5377
5736
  // src/preflight/aab-reader.ts
5378
5737
  import { readFile as readFile11 } from "fs/promises";
5379
- import { fromBuffer } from "yauzl";
5738
+ import { fromBuffer as fromBuffer2 } from "yauzl";
5380
5739
 
5381
5740
  // src/preflight/manifest-parser.ts
5382
5741
  import protobuf from "protobufjs";
@@ -5637,7 +5996,7 @@ function openAndScan(buffer, manifestPath = AAB_MANIFEST_PATH) {
5637
5996
  reject(error);
5638
5997
  }
5639
5998
  try {
5640
- fromBuffer(buffer, { lazyEntries: true }, (err, zipfile) => {
5999
+ fromBuffer2(buffer, { lazyEntries: true }, (err, zipfile) => {
5641
6000
  if (err || !zipfile) {
5642
6001
  settleReject(err ?? new Error("Failed to open AAB file"));
5643
6002
  return;
@@ -8925,7 +9284,7 @@ async function sha256File(filePath) {
8925
9284
  }
8926
9285
 
8927
9286
  // src/commands/image-sync.ts
8928
- import { readdir as readdir7 } from "fs/promises";
9287
+ import { readdir as readdir7, stat as stat10 } from "fs/promises";
8929
9288
  import { join as join12, extname as extname5 } from "path";
8930
9289
  import { PlayApiError } from "@gpc-cli/api";
8931
9290
  var IMAGE_EXTENSIONS = /* @__PURE__ */ new Set([".png", ".jpg", ".jpeg"]);
@@ -8955,6 +9314,13 @@ async function scanLanguages(dir) {
8955
9314
  return [];
8956
9315
  }
8957
9316
  }
9317
+ async function dirExists(path) {
9318
+ try {
9319
+ return (await stat10(path)).isDirectory();
9320
+ } catch {
9321
+ return false;
9322
+ }
9323
+ }
8958
9324
  async function syncImages(client, packageName, dir, options) {
8959
9325
  const details = [];
8960
9326
  let uploaded = 0;
@@ -8992,23 +9358,59 @@ async function syncImages(client, packageName, dir, options) {
8992
9358
  const hash = await sha256File(join12(localDir, file));
8993
9359
  localHashes.set(file, hash);
8994
9360
  }
8995
- const localSha256Set = new Set(localHashes.values());
9361
+ const uploadLocal = async (file, reason) => {
9362
+ if (!options?.dryRun) {
9363
+ const filePath = join12(localDir, file);
9364
+ const check = await validateImage(filePath, imageType);
9365
+ if (!check.valid) {
9366
+ throw new GpcError(
9367
+ `Image validation failed for ${language}/${imageType}/${file}: ${check.errors.join("; ")}`,
9368
+ "IMAGE_INVALID",
9369
+ 2,
9370
+ "Check image dimensions, file size, and format."
9371
+ );
9372
+ }
9373
+ await client.images.upload(packageName, edit.id, language, imageType, filePath);
9374
+ }
9375
+ uploaded++;
9376
+ details.push({ language, imageType, file, action: "upload", reason });
9377
+ };
8996
9378
  if (options?.delete) {
8997
- for (const img of remoteImages) {
8998
- if (!localSha256Set.has(img.sha256.toLowerCase())) {
8999
- if (!options?.dryRun) {
9000
- await client.images.delete(packageName, edit.id, language, imageType, img.id);
9001
- }
9002
- deleted++;
9379
+ if (localFiles.length === 0 && remoteImages.length > 0 && !await dirExists(localDir)) {
9380
+ for (const img of remoteImages) {
9003
9381
  details.push({
9004
9382
  language,
9005
9383
  imageType,
9006
9384
  file: img.id,
9007
- action: "delete",
9008
- reason: "not in local"
9385
+ action: "skip",
9386
+ reason: "no local directory; remote left untouched"
9009
9387
  });
9010
9388
  }
9389
+ continue;
9390
+ }
9391
+ const localSeq = localFiles.map((file) => localHashes.get(file));
9392
+ const remoteSeq = remoteImages.map((img) => img.sha256.toLowerCase());
9393
+ const inOrder = localSeq.length === remoteSeq.length && localSeq.every((hash, i) => hash === remoteSeq[i]);
9394
+ if (inOrder) {
9395
+ for (const file of localFiles) {
9396
+ skipped++;
9397
+ details.push({ language, imageType, file, action: "skip", reason: "already in order" });
9398
+ }
9399
+ continue;
9011
9400
  }
9401
+ if (remoteImages.length > 0) {
9402
+ if (!options?.dryRun) {
9403
+ await client.images.deleteAll(packageName, edit.id, language, imageType);
9404
+ }
9405
+ for (const img of remoteImages) {
9406
+ deleted++;
9407
+ details.push({ language, imageType, file: img.id, action: "delete", reason: "reordering" });
9408
+ }
9409
+ }
9410
+ for (const file of localFiles) {
9411
+ await uploadLocal(file, "reordering");
9412
+ }
9413
+ continue;
9012
9414
  }
9013
9415
  for (const file of localFiles) {
9014
9416
  const hash = localHashes.get(file);
@@ -9016,21 +9418,7 @@ async function syncImages(client, packageName, dir, options) {
9016
9418
  skipped++;
9017
9419
  details.push({ language, imageType, file, action: "skip", reason: "sha256 match" });
9018
9420
  } else {
9019
- if (!options?.dryRun) {
9020
- const filePath = join12(localDir, file);
9021
- const check = await validateImage(filePath, imageType);
9022
- if (!check.valid) {
9023
- throw new GpcError(
9024
- `Image validation failed for ${language}/${imageType}/${file}: ${check.errors.join("; ")}`,
9025
- "IMAGE_INVALID",
9026
- 2,
9027
- "Check image dimensions, file size, and format."
9028
- );
9029
- }
9030
- await client.images.upload(packageName, edit.id, language, imageType, filePath);
9031
- }
9032
- uploaded++;
9033
- details.push({ language, imageType, file, action: "upload", reason: "new or changed" });
9421
+ await uploadLocal(file, "new or changed");
9034
9422
  }
9035
9423
  }
9036
9424
  }
@@ -9415,7 +9803,9 @@ export {
9415
9803
  createTranslator,
9416
9804
  deactivateBasePlan,
9417
9805
  deactivateOffer,
9806
+ decodeCsvText,
9418
9807
  decodeNotification,
9808
+ decodeStatsCsv,
9419
9809
  defaultGitRunner,
9420
9810
  deferSubscriptionPurchase,
9421
9811
  deferSubscriptionV2,
@@ -9443,10 +9833,13 @@ export {
9443
9833
  diffSubscription,
9444
9834
  discoverPlugins,
9445
9835
  dispatchWebhook,
9836
+ downloadFinancialReport,
9446
9837
  downloadGeneratedApk,
9447
- downloadReport,
9838
+ downloadReportObject,
9839
+ downloadStatsReport,
9448
9840
  exportImages,
9449
9841
  exportReviews,
9842
+ extractCsvEntriesFromZip,
9450
9843
  fetchAggregateCost,
9451
9844
  fetchChangelog,
9452
9845
  fetchReleaseNotes,
@@ -9505,6 +9898,7 @@ export {
9505
9898
  getVitalsRendering,
9506
9899
  getVitalsStartup,
9507
9900
  gradeFromPercent,
9901
+ gunzipIfNeeded,
9508
9902
  handleBreach,
9509
9903
  importDataSafety,
9510
9904
  importTestersFromCsv,
@@ -9512,11 +9906,13 @@ export {
9512
9906
  initProject,
9513
9907
  inviteUser,
9514
9908
  isFinancialReportType,
9909
+ isGzip,
9515
9910
  isStatsReportType,
9516
9911
  isValidBcp47,
9517
9912
  isValidReportType,
9518
9913
  isValidStatsDimension,
9519
9914
  isVersionedNotesDir,
9915
+ isZip,
9520
9916
  lintListing,
9521
9917
  lintListings,
9522
9918
  lintLocalListings,
@@ -9535,6 +9931,7 @@ export {
9535
9931
  listOneTimeOffers,
9536
9932
  listOneTimeProducts,
9537
9933
  listRecoveryActions,
9934
+ listReportObjects,
9538
9935
  listReports,
9539
9936
  listReviews,
9540
9937
  listSubscriptions,
@@ -9546,6 +9943,7 @@ export {
9546
9943
  loadStatusCache,
9547
9944
  maybePaginate,
9548
9945
  migratePrices,
9946
+ monthToken,
9549
9947
  moreResultsFooter,
9550
9948
  normalizeFingerprint,
9551
9949
  parseAppfile,
@@ -9587,8 +9985,13 @@ export {
9587
9985
  renderPlayStorePrompt,
9588
9986
  renderPrompt,
9589
9987
  replyToReview,
9988
+ reportAccessDeniedError,
9989
+ reportBucketNotFoundError,
9990
+ reportObjectNotFoundError,
9991
+ reportPrefix,
9590
9992
  resolveAiConfig,
9591
9993
  resolveLocales,
9994
+ resolveReportsBucket,
9592
9995
  retryOnUploadNotComplete,
9593
9996
  revokeSubscriptionPurchase,
9594
9997
  runPreflight,