diff --git a/.github/workflows/web-crowdin-update.yml b/.github/workflows/web-crowdin-update.yml
new file mode 100644
index 0000000000000000000000000000000000000000..63a643cfcfc342aebdd3c6e056d37f86e24f95c0
--- /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 00a9fdc72df2ce05d8c96ad5ef4206e0247768fd..bc0f6253dbfd327f926a6f790e1f21b0aa9dd756 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 4803995d4f7f9e77b7899cfea81f9e0c63d26964..5ac6b263edaa583208828af453d0896aff30b15a 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 39905118550f5441322458995a740372e0c2780f..e9e27d55e8e585bc1c0c392b8be52d7d41af28c3 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 4b0ce31b042df5c1e431118c75e884b81d96b4e3..8975941ad50f790ca4d655617555ef0c47311ec6 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 6143044f0d8f3bb8584d1ee31fd5117a4d9974b3..78a367797222683c961784b7b399ddb9ef117e92 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 4ef76a491ff1888e582ca4446c4c8051e10b67fa..0ef4b15947d67ae4656039a6ad33d835ad16199c 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 8b0ce7bd5f6e42517e045eda617f864df56e1ab6..5f7e13deb8be4081bcdbda67b7ba8b025f0248b1 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 3dfde5384bb1d0eee95bf948c207d958c6fe2710..d2e593e9e1a26df6abfe632e47740cfe6ef67d5b 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 bb212ff21ef979c64bd13823744c9f358065ff3d..98a8dd94813db813801c422041903562e86930e8 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 0000000000000000000000000000000000000000..4cb875b4dcbf96c8b5bd012527e52fab23995b58
--- /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}`)}`;
+};