diff --git a/.github/workflows/web-crowdin-update.yml b/.github/workflows/web-crowdin-update.yml
new file mode 100644
index 000000000..63a643cfc
--- /dev/null
+++ b/.github/workflows/web-crowdin-update.yml
@@ -0,0 +1,39 @@
+name: "Update Crowdin translations (web)"
+
+# This is a variant of web-crowdin.yml that also uploads the translated strings
+# (in addition to the source strings). This allows us to change the strings in
+# our source code for an automated refactoring (e.g. renaming a key), and then
+# run this workflow to update the data in Crowdin taking our source code as the
+# source of truth.
+
+on:
+ # Only allow running manually.
+ workflow_dispatch:
+
+jobs:
+ synchronize-with-crowdin:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Crowdin's action
+ uses: crowdin/github-action@v1
+ with:
+ base_path: "web/"
+ config: "web/crowdin.yml"
+ upload_sources: true
+ # This is what differs from web-crowdin.yml
+ upload_translations: true
+ download_translations: true
+ localization_branch_name: translations/web
+ create_pull_request: true
+ skip_untranslated_strings: true
+ pull_request_title: "[web] Updated translations"
+ pull_request_body: "Updated translations from [Crowdin](https://crowdin.com/project/ente-photos-web)"
+ pull_request_base_branch_name: "main"
+ project_id: 569613
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ CROWDIN_PERSONAL_TOKEN: ${{ secrets.CROWDIN_PERSONAL_TOKEN }}
diff --git a/web/apps/cast/src/pages/index.tsx b/web/apps/cast/src/pages/index.tsx
index 00a9fdc72..bc0f6253d 100644
--- a/web/apps/cast/src/pages/index.tsx
+++ b/web/apps/cast/src/pages/index.tsx
@@ -83,7 +83,8 @@ export default function Index() {
fontWeight: "normal",
}}
>
- Enter this code on Ente Photos to pair this screen
+ Enter this code on Ente Photos to pair this
+ screen
{listItem.fileCount} {t("FILES")},{" "}
- {convertBytesToHumanReadable(listItem.fileSize || 0)}{" "}
- {t("EACH")}
+ {formattedByteSize(listItem.fileSize || 0)} {t("EACH")}
);
case ITEM_TYPE.FILE: {
diff --git a/web/apps/photos/src/components/PhotoList/index.tsx b/web/apps/photos/src/components/PhotoList/index.tsx
index 4803995d4..5ac6b263e 100644
--- a/web/apps/photos/src/components/PhotoList/index.tsx
+++ b/web/apps/photos/src/components/PhotoList/index.tsx
@@ -22,9 +22,9 @@ import {
areEqual,
} from "react-window";
import { EnteFile } from "types/file";
-import { convertBytesToHumanReadable } from "utils/file";
import { handleSelectCreator } from "utils/photoFrame";
import { PublicCollectionGalleryContext } from "utils/publicCollectionGallery";
+import { formattedByteSize } from "utils/units";
const A_DAY = 24 * 60 * 60 * 1000;
const FOOTER_HEIGHT = 90;
@@ -829,8 +829,7 @@ export function PhotoList({
return (
{listItem.fileCount} {t("FILES")},{" "}
- {convertBytesToHumanReadable(listItem.fileSize || 0)}{" "}
- {t("EACH")}
+ {formattedByteSize(listItem.fileSize || 0)} {t("EACH")}
);
case ITEM_TYPE.FILE: {
diff --git a/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderFileName.tsx b/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderFileName.tsx
index 399051185..e9e27d55e 100644
--- a/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderFileName.tsx
+++ b/web/apps/photos/src/components/PhotoViewer/FileInfo/RenderFileName.tsx
@@ -7,8 +7,8 @@ import VideocamOutlined from "@mui/icons-material/VideocamOutlined";
import Box from "@mui/material/Box";
import { useEffect, useState } from "react";
import { EnteFile } from "types/file";
-import { makeHumanReadableStorage } from "utils/billing";
import { changeFileName, updateExistingFilePubMetadata } from "utils/file";
+import { formattedByteSize } from "utils/units";
import { FileNameEditDialog } from "./FileNameEditDialog";
import InfoItem from "./InfoItem";
@@ -33,7 +33,7 @@ const getCaption = (file: EnteFile, parsedExifData) => {
captionParts.push(resolution);
}
if (fileSize) {
- captionParts.push(makeHumanReadableStorage(fileSize));
+ captionParts.push(formattedByteSize(fileSize));
}
return (
diff --git a/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/individual/usageSection.tsx b/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/individual/usageSection.tsx
index 4b0ce31b0..8975941ad 100644
--- a/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/individual/usageSection.tsx
+++ b/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/individual/usageSection.tsx
@@ -1,7 +1,7 @@
import { SpaceBetweenFlex } from "@ente/shared/components/Container";
import { Box, Typography } from "@mui/material";
import { t } from "i18next";
-import { makeHumanReadableStorage } from "utils/billing";
+import { formattedStorageByteSize } from "utils/units";
import { Progressbar } from "../../styledComponents";
@@ -19,7 +19,7 @@ export function IndividualUsageSection({ usage, storage, fileCount }: Iprops) {
marginTop: 1.5,
}}
>
- {`${makeHumanReadableStorage(
+ {`${formattedStorageByteSize(
storage - usage,
)} ${t("FREE")}`}
diff --git a/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/storageSection.tsx b/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/storageSection.tsx
index 6143044f0..78a367797 100644
--- a/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/storageSection.tsx
+++ b/web/apps/photos/src/components/Sidebar/SubscriptionCard/contentOverlay/storageSection.tsx
@@ -1,6 +1,6 @@
import { Box, styled, Typography } from "@mui/material";
import { t } from "i18next";
-import { convertBytesToGBs, makeHumanReadableStorage } from "utils/billing";
+import { bytesInGB, formattedStorageByteSize } from "utils/units";
const MobileSmallBox = styled(Box)`
display: none;
@@ -30,9 +30,9 @@ export default function StorageSection({ usage, storage }: Iprops) {
fontWeight={"bold"}
sx={{ fontSize: "24px", lineHeight: "30px" }}
>
- {`${makeHumanReadableStorage(usage, { roundUp: true })} ${t(
+ {`${formattedStorageByteSize(usage, { round: true })} ${t(
"OF",
- )} ${makeHumanReadableStorage(storage)} ${t("USED")}`}
+ )} ${formattedStorageByteSize(storage)} ${t("USED")}`}
@@ -40,9 +40,7 @@ export default function StorageSection({ usage, storage }: Iprops) {
fontWeight={"bold"}
sx={{ fontSize: "24px", lineHeight: "30px" }}
>
- {`${convertBytesToGBs(usage)} / ${convertBytesToGBs(
- storage,
- )} ${t("GB")} ${t("USED")}`}
+ {`${bytesInGB(usage)} / ${bytesInGB(storage)} ${t("GB")} ${t("USED")}`}
diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector/card/paid.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector/card/paid.tsx
index 4ef76a491..0ef4b1594 100644
--- a/web/apps/photos/src/components/pages/gallery/PlanSelector/card/paid.tsx
+++ b/web/apps/photos/src/components/pages/gallery/PlanSelector/card/paid.tsx
@@ -5,11 +5,8 @@ import Box from "@mui/material/Box";
import Typography from "@mui/material/Typography";
import { t } from "i18next";
import { Trans } from "react-i18next";
-import {
- convertBytesToGBs,
- hasAddOnBonus,
- isSubscriptionCancelled,
-} from "utils/billing";
+import { hasAddOnBonus, isSubscriptionCancelled } from "utils/billing";
+import { bytesInGB } from "utils/units";
import { ManageSubscription } from "../manageSubscription";
import { PeriodToggler } from "../periodToggler";
import Plans from "../plans";
@@ -35,8 +32,7 @@ export default function PaidSubscriptionPlanSelectorCard({
{t("SUBSCRIPTION")}
- {convertBytesToGBs(subscription.storage, 2)}{" "}
- {t("GB")}
+ {bytesInGB(subscription.storage, 2)} {t("GB")}
@@ -50,7 +46,7 @@ export default function PaidSubscriptionPlanSelectorCard({
diff --git a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/BfAddOnRow.tsx b/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/BfAddOnRow.tsx
index 8b0ce7bd5..5f7e13deb 100644
--- a/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/BfAddOnRow.tsx
+++ b/web/apps/photos/src/components/pages/gallery/PlanSelector/plans/BfAddOnRow.tsx
@@ -2,7 +2,7 @@ import { SpaceBetweenFlex } from "@ente/shared/components/Container";
import { Box, styled, Typography } from "@mui/material";
import { Trans } from "react-i18next";
-import { makeHumanReadableStorage } from "utils/billing";
+import { formattedStorageByteSize } from "utils/units";
const RowContainer = styled(SpaceBetweenFlex)(({ theme }) => ({
// gap: theme.spacing(1.5),
@@ -24,7 +24,7 @@ export function BFAddOnRow({ bonusData, closeModal }) {
- {convertBytesToGBs(plan.storage)}
+ {bytesInGB(plan.storage)}
diff --git a/web/apps/photos/src/utils/billing/index.ts b/web/apps/photos/src/utils/billing/index.ts
index 3dfde5384..d2e593e9e 100644
--- a/web/apps/photos/src/utils/billing/index.ts
+++ b/web/apps/photos/src/utils/billing/index.ts
@@ -31,44 +31,6 @@ enum RESPONSE_STATUS {
fail = "fail",
}
-const StorageUnits = ["B", "KB", "MB", "GB", "TB"];
-
-const ONE_GB = 1024 * 1024 * 1024;
-
-export function convertBytesToGBs(bytes: number, precision = 0): string {
- return (bytes / (1024 * 1024 * 1024)).toFixed(precision);
-}
-
-export function makeHumanReadableStorage(
- bytes: number,
- { roundUp } = { roundUp: false },
-): string {
- if (bytes <= 0) {
- return `0 ${t("STORAGE_UNITS.MB")}`;
- }
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
-
- let quantity = bytes / Math.pow(1024, i);
- let unit = StorageUnits[i];
-
- if (quantity > 100 && unit !== "GB") {
- quantity /= 1024;
- unit = StorageUnits[i + 1];
- }
-
- quantity = Number(quantity.toFixed(1));
-
- if (bytes >= 10 * ONE_GB) {
- if (roundUp) {
- quantity = Math.ceil(quantity);
- } else {
- quantity = Math.round(quantity);
- }
- }
-
- return `${quantity} ${t(`STORAGE_UNITS.${unit}`)}`;
-}
-
export function hasPaidSubscription(subscription: Subscription) {
return (
subscription &&
@@ -160,9 +122,8 @@ export function isSubscriptionPastDue(subscription: Subscription) {
);
}
-export function isPopularPlan(plan: Plan) {
- return plan.storage === 100 * ONE_GB;
-}
+export const isPopularPlan = (plan: Plan) =>
+ plan.storage === 100 * 1024 * 1024 * 1024; /* 100 GB */
export async function updateSubscription(
plan: Plan,
diff --git a/web/apps/photos/src/utils/file/index.ts b/web/apps/photos/src/utils/file/index.ts
index bb212ff21..98a8dd948 100644
--- a/web/apps/photos/src/utils/file/index.ts
+++ b/web/apps/photos/src/utils/file/index.ts
@@ -103,19 +103,6 @@ export async function getUpdatedEXIFFileForDownload(
}
}
-export function convertBytesToHumanReadable(
- bytes: number,
- precision = 2,
-): string {
- if (bytes === 0 || isNaN(bytes)) {
- return "0 MB";
- }
-
- const i = Math.floor(Math.log(bytes) / Math.log(1024));
- const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
- return (bytes / Math.pow(1024, i)).toFixed(precision) + " " + sizes[i];
-}
-
export async function downloadFile(file: EnteFile) {
try {
const fileReader = new FileReader();
diff --git a/web/apps/photos/src/utils/units.ts b/web/apps/photos/src/utils/units.ts
new file mode 100644
index 000000000..4cb875b4d
--- /dev/null
+++ b/web/apps/photos/src/utils/units.ts
@@ -0,0 +1,85 @@
+import { t } from "i18next";
+
+const StorageUnits = ["B", "KB", "MB", "GB", "TB"];
+
+/**
+ * Convert the given number of {@link bytes} to their equivalent GB string with
+ * {@link precision}.
+ *
+ * The returned string does not have the GB suffix.
+ */
+export const bytesInGB = (bytes: number, precision = 0): string =>
+ (bytes / (1024 * 1024 * 1024)).toFixed(precision);
+
+/**
+ * Convert the given number of {@link bytes} to a user visible string in an
+ * appropriately sized unit.
+ *
+ * The returned string includes the (localized) unit suffix, e.g. "TB".
+ *
+ * @param precision Modify the number of digits after the decimal point.
+ * Defaults to 2.
+ */
+export function formattedByteSize(bytes: number, precision = 2): string {
+ if (bytes === 0 || isNaN(bytes)) {
+ return "0 MB";
+ }
+
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+ const sizes = ["B", "KB", "MB", "GB", "TB", "PB", "EB", "ZB", "YB"];
+ return (bytes / Math.pow(1024, i)).toFixed(precision) + " " + sizes[i];
+}
+
+interface FormattedStorageByteSizeOptions {
+ /**
+ * If `true` then round up the fractional quantity we obtain when dividing
+ * the number of bytes by the number of bytes in the unit that got chosen.
+ *
+ * The default behaviour is to take the ceiling.
+ */
+ round?: boolean;
+}
+
+/**
+ * Convert the given number of storage {@link bytes} to a user visible string in
+ * an appropriately sized unit.
+ *
+ * This differs from {@link formattedByteSize} in that while
+ * {@link formattedByteSize} is meant for arbitrary byte sizes, this function
+ * has a few additional beautification heuristics that we want to apply when
+ * displaying the "storage size" (in different contexts) as opposed to, say, a
+ * generic "file size".
+ *
+ * @param options
+ *
+ * @return A user visible string, including the localized unit suffix.
+ */
+export const formattedStorageByteSize = (
+ bytes: number,
+ options?: FormattedStorageByteSizeOptions,
+): string => {
+ if (bytes <= 0) {
+ return `0 ${t("STORAGE_UNITS.MB")}`;
+ }
+ const i = Math.floor(Math.log(bytes) / Math.log(1024));
+
+ let quantity = bytes / Math.pow(1024, i);
+ let unit = StorageUnits[i];
+
+ if (quantity > 100 && unit !== "GB") {
+ quantity /= 1024;
+ unit = StorageUnits[i + 1];
+ }
+
+ quantity = Number(quantity.toFixed(1));
+
+ if (bytes >= 10 * 1024 * 1024 * 1024 /* 10 GB */) {
+ if (options?.round) {
+ quantity = Math.ceil(quantity);
+ } else {
+ quantity = Math.round(quantity);
+ }
+ }
+
+ return `${quantity} ${t(`STORAGE_UNITS.${unit}`)}`;
+};