[web] Create package for sharing code between photos and cast (#1470)

This commit is contained in:
Manav Rathi 2024-04-17 12:42:14 +05:30 committed by GitHub
commit 626321b6d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 248 additions and 184 deletions

View file

@ -3,11 +3,11 @@
"version": "0.0.0",
"private": true,
"dependencies": {
"@/media": "*",
"@/next": "*",
"@ente/accounts": "*",
"@ente/eslint-config": "*",
"@ente/shared": "*",
"jszip": "3.10.1",
"mime-types": "^2.1.35"
}
}

View file

@ -1,32 +0,0 @@
import JSZip from "jszip";
import { EnteFile } from "types/file";
import {
getFileExtensionWithDot,
getFileNameWithoutExtension,
} from "utils/file";
class LivePhoto {
image: Uint8Array;
video: Uint8Array;
imageNameTitle: string;
videoNameTitle: string;
}
export const decodeLivePhoto = async (file: EnteFile, zipBlob: Blob) => {
const originalName = getFileNameWithoutExtension(file.metadata.title);
const zip = await JSZip.loadAsync(zipBlob, { createFolders: true });
const livePhoto = new LivePhoto();
for (const zipFilename in zip.files) {
if (zipFilename.startsWith("image")) {
livePhoto.imageNameTitle =
originalName + getFileExtensionWithDot(zipFilename);
livePhoto.image = await zip.files[zipFilename].async("uint8array");
} else if (zipFilename.startsWith("video")) {
livePhoto.videoNameTitle =
originalName + getFileExtensionWithDot(zipFilename);
livePhoto.video = await zip.files[zipFilename].async("uint8array");
}
}
return livePhoto;
};

View file

@ -1,8 +1,8 @@
import { decodeLivePhoto } from "@/media/live-photo";
import log from "@/next/log";
import ComlinkCryptoWorker from "@ente/shared/crypto";
import { FILE_TYPE, RAW_FORMATS } from "constants/file";
import CastDownloadManager from "services/castDownloadManager";
import { decodeLivePhoto } from "services/livePhotoService";
import { getFileType } from "services/typeDetectionService";
import {
EncryptedEnteFile,
@ -85,18 +85,6 @@ export async function decryptFile(
}
}
export function getFileNameWithoutExtension(filename: string) {
const lastDotPosition = filename.lastIndexOf(".");
if (lastDotPosition === -1) return filename;
else return filename.slice(0, lastDotPosition);
}
export function getFileExtensionWithDot(filename: string) {
const lastDotPosition = filename.lastIndexOf(".");
if (lastDotPosition === -1) return "";
else return filename.slice(lastDotPosition);
}
export function generateStreamFromArrayBuffer(data: Uint8Array) {
return new ReadableStream({
async start(controller: ReadableStreamDefaultController) {
@ -115,6 +103,18 @@ export function isRawFileFromFileName(fileName: string) {
return false;
}
/**
* [Note: File name for local EnteFile objects]
*
* The title property in a file's metadata is the original file's name. The
* metadata of a file cannot be edited. So if later on the file's name is
* changed, then the edit is stored in the `editedName` property of the public
* metadata of the file.
*
* This function merges these edits onto the file object that we use locally.
* Effectively, post this step, the file's metadata.title can be used in lieu of
* its filename.
*/
export function mergeMetadata(files: EnteFile[]): EnteFile[] {
return files.map((file) => {
if (file.pubMagicMetadata?.data.editedTime) {
@ -137,8 +137,11 @@ export const getPreviewableImage = async (
await CastDownloadManager.downloadFile(castToken, file),
).blob();
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
const livePhoto = await decodeLivePhoto(file, fileBlob);
fileBlob = new Blob([livePhoto.image]);
const { imageData } = await decodeLivePhoto(
file.metadata.title,
fileBlob,
);
fileBlob = new Blob([imageData]);
}
const fileType = await getFileType(
new File([fileBlob], file.metadata.title),

View file

@ -3,6 +3,7 @@
"version": "0.0.0",
"private": true,
"dependencies": {
"@/media": "*",
"@/next": "*",
"@date-io/date-fns": "^2.14.0",
"@ente/accounts": "*",
@ -25,7 +26,6 @@
"hdbscan": "0.0.1-alpha.5",
"heic-convert": "^2.0.0",
"idb": "^7.1.1",
"jszip": "3.10.1",
"leaflet": "^1.9.4",
"leaflet-defaulticon-compatibility": "^0.1.1",
"localforage": "^1.9.0",

View file

@ -1,3 +1,4 @@
import { nameAndExtension } from "@/next/file";
import log from "@/next/log";
import { FlexWrapper } from "@ente/shared/components/Container";
import PhotoOutlined from "@mui/icons-material/PhotoOutlined";
@ -7,11 +8,7 @@ import { FILE_TYPE } from "constants/file";
import { useEffect, useState } from "react";
import { EnteFile } from "types/file";
import { makeHumanReadableStorage } from "utils/billing";
import {
changeFileName,
splitFilenameAndExtension,
updateExistingFilePubMetadata,
} from "utils/file";
import { changeFileName, updateExistingFilePubMetadata } from "utils/file";
import { FileNameEditDialog } from "./FileNameEditDialog";
import InfoItem from "./InfoItem";
@ -65,9 +62,7 @@ export function RenderFileName({
const [extension, setExtension] = useState<string>();
useEffect(() => {
const [filename, extension] = splitFilenameAndExtension(
file.metadata.title,
);
const [filename, extension] = nameAndExtension(file.metadata.title);
setFilename(filename);
setExtension(extension);
}, [file]);

View file

@ -1,3 +1,4 @@
import { decodeLivePhoto } from "@/media/live-photo";
import { ensureElectron } from "@/next/electron";
import log from "@/next/log";
import { CustomError } from "@ente/shared/error";
@ -38,7 +39,6 @@ import { writeStream } from "utils/native-stream";
import { getAllLocalCollections } from "../collectionService";
import downloadManager from "../download";
import { getAllLocalFiles } from "../fileService";
import { decodeLivePhoto } from "../livePhotoService";
import { migrateExport } from "./migration";
/** Name of the JSON file in which we keep the state of the export. */
@ -1015,18 +1015,18 @@ class ExportService {
fileStream: ReadableStream<any>,
file: EnteFile,
) {
const electron = ensureElectron();
const fs = ensureElectron().fs;
const fileBlob = await new Response(fileStream).blob();
const livePhoto = await decodeLivePhoto(file, fileBlob);
const livePhoto = await decodeLivePhoto(file.metadata.title, fileBlob);
const imageExportName = await safeFileName(
collectionExportPath,
livePhoto.imageNameTitle,
electron.fs.exists,
livePhoto.imageFileName,
fs.exists,
);
const videoExportName = await safeFileName(
collectionExportPath,
livePhoto.videoNameTitle,
electron.fs.exists,
livePhoto.videoFileName,
fs.exists,
);
const livePhotoExportName = getLivePhotoExportName(
imageExportName,
@ -1038,7 +1038,9 @@ class ExportService {
livePhotoExportName,
);
try {
const imageStream = generateStreamFromArrayBuffer(livePhoto.image);
const imageStream = generateStreamFromArrayBuffer(
livePhoto.imageData,
);
await this.saveMetadataFile(
collectionExportPath,
imageExportName,
@ -1049,7 +1051,9 @@ class ExportService {
imageStream,
);
const videoStream = generateStreamFromArrayBuffer(livePhoto.video);
const videoStream = generateStreamFromArrayBuffer(
livePhoto.videoData,
);
await this.saveMetadataFile(
collectionExportPath,
videoExportName,
@ -1061,9 +1065,7 @@ class ExportService {
videoStream,
);
} catch (e) {
await electron.fs.rm(
`${collectionExportPath}/${imageExportName}`,
);
await fs.rm(`${collectionExportPath}/${imageExportName}`);
throw e;
}
} catch (e) {

View file

@ -1,3 +1,4 @@
import { decodeLivePhoto } from "@/media/live-photo";
import { ensureElectron } from "@/next/electron";
import log from "@/next/log";
import { LS_KEYS, getData } from "@ente/shared/storage/localStorage";
@ -7,7 +8,6 @@ import { FILE_TYPE } from "constants/file";
import { getLocalCollections } from "services/collectionService";
import downloadManager from "services/download";
import { getAllLocalFiles } from "services/fileService";
import { decodeLivePhoto } from "services/livePhotoService";
import { Collection } from "types/collection";
import {
CollectionExportNames,
@ -21,11 +21,11 @@ import {
} from "types/export";
import { EnteFile } from "types/file";
import { getNonEmptyPersonalCollections } from "utils/collection";
import { splitFilenameAndExtension } from "utils/ffmpeg";
import {
getIDBasedSortedFiles,
getPersonalFiles,
mergeMetadata,
splitFilenameAndExtension,
} from "utils/file";
import {
safeDirectoryName,
@ -318,15 +318,18 @@ async function getFileExportNamesFromExportedFiles(
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
const fileStream = await downloadManager.getFile(file);
const fileBlob = await new Response(fileStream).blob();
const livePhoto = await decodeLivePhoto(file, fileBlob);
const { imageFileName, videoFileName } = await decodeLivePhoto(
file.metadata.title,
fileBlob,
);
const imageExportName = getUniqueFileExportNameForMigration(
collectionPath,
livePhoto.imageNameTitle,
imageFileName,
usedFilePaths,
);
const videoExportName = getUniqueFileExportNameForMigration(
collectionPath,
livePhoto.videoNameTitle,
videoFileName,
usedFilePaths,
);
fileExportName = getLivePhotoExportName(

View file

@ -1,45 +0,0 @@
import JSZip from "jszip";
import { EnteFile } from "types/file";
import {
getFileExtensionWithDot,
getFileNameWithoutExtension,
} from "utils/file";
class LivePhoto {
image: Uint8Array;
video: Uint8Array;
imageNameTitle: string;
videoNameTitle: string;
}
export const decodeLivePhoto = async (file: EnteFile, zipBlob: Blob) => {
const originalName = getFileNameWithoutExtension(file.metadata.title);
const zip = await JSZip.loadAsync(zipBlob, { createFolders: true });
const livePhoto = new LivePhoto();
for (const zipFilename in zip.files) {
if (zipFilename.startsWith("image")) {
livePhoto.imageNameTitle =
originalName + getFileExtensionWithDot(zipFilename);
livePhoto.image = await zip.files[zipFilename].async("uint8array");
} else if (zipFilename.startsWith("video")) {
livePhoto.videoNameTitle =
originalName + getFileExtensionWithDot(zipFilename);
livePhoto.video = await zip.files[zipFilename].async("uint8array");
}
}
return livePhoto;
};
export const encodeLivePhoto = async (livePhoto: LivePhoto) => {
const zip = new JSZip();
zip.file(
"image" + getFileExtensionWithDot(livePhoto.imageNameTitle),
livePhoto.image,
);
zip.file(
"video" + getFileExtensionWithDot(livePhoto.videoNameTitle),
livePhoto.video,
);
return await zip.generateAsync({ type: "uint8array" });
};

View file

@ -1,10 +1,10 @@
import { encodeLivePhoto } from "@/media/live-photo";
import log from "@/next/log";
import { DedicatedCryptoWorker } from "@ente/shared/crypto/internal/crypto.worker";
import { CustomError } from "@ente/shared/error";
import { Remote } from "comlink";
import { FILE_TYPE } from "constants/file";
import { LIVE_PHOTO_ASSET_SIZE_LIMIT } from "constants/upload";
import { encodeLivePhoto } from "services/livePhotoService";
import { getFileType } from "services/typeDetectionService";
import {
ElectronFile,
@ -14,12 +14,6 @@ import {
LivePhotoAssets,
ParsedMetadataJSONMap,
} from "types/upload";
import {
getFileExtensionWithDot,
getFileNameWithoutExtension,
isImageOrVideo,
splitFilenameAndExtension,
} from "utils/file";
import { getFileTypeFromExtensionForLivePhotoClustering } from "utils/file/livePhoto";
import { getUint8ArrayView } from "../readerService";
import { extractFileMetadata } from "./fileService";
@ -107,16 +101,16 @@ export async function readLivePhoto(
},
);
const image = await getUint8ArrayView(livePhotoAssets.image);
const imageData = await getUint8ArrayView(livePhotoAssets.image);
const video = await getUint8ArrayView(livePhotoAssets.video);
const videoData = await getUint8ArrayView(livePhotoAssets.video);
return {
filedata: await encodeLivePhoto({
image,
video,
imageNameTitle: livePhotoAssets.image.name,
videoNameTitle: livePhotoAssets.video.name,
imageFileName: livePhotoAssets.image.name,
imageData,
videoFileName: livePhotoAssets.video.name,
videoData,
}),
thumbnail,
hasStaticThumbnail,
@ -304,3 +298,28 @@ function removePotentialLivePhotoSuffix(
return filenameWithoutExtension;
}
}
function getFileNameWithoutExtension(filename: string) {
const lastDotPosition = filename.lastIndexOf(".");
if (lastDotPosition === -1) return filename;
else return filename.slice(0, lastDotPosition);
}
function getFileExtensionWithDot(filename: string) {
const lastDotPosition = filename.lastIndexOf(".");
if (lastDotPosition === -1) return "";
else return filename.slice(lastDotPosition);
}
function splitFilenameAndExtension(filename: string): [string, string] {
const lastDotPosition = filename.lastIndexOf(".");
if (lastDotPosition === -1) return [filename, null];
else
return [
filename.slice(0, lastDotPosition),
filename.slice(lastDotPosition + 1),
];
}
const isImageOrVideo = (fileType: FILE_TYPE) =>
[FILE_TYPE.IMAGE, FILE_TYPE.VIDEO].includes(fileType);

View file

@ -24,6 +24,11 @@ export function isDataStream(object: any): object is DataStream {
export type Logger = (message: string) => void;
export interface Metadata {
/**
* The file name.
*
* See: [Note: File name for local EnteFile objects]
*/
title: string;
creationTime: number;
modificationTime: number;

View file

@ -65,13 +65,3 @@ function parseCreationTime(creationTime: string) {
}
return dateTime;
}
export function splitFilenameAndExtension(filename: string): [string, string] {
const lastDotPosition = filename.lastIndexOf(".");
if (lastDotPosition === -1) return [filename, null];
else
return [
filename.slice(0, lastDotPosition),
filename.slice(lastDotPosition + 1),
];
}

View file

@ -1,3 +1,4 @@
import { decodeLivePhoto } from "@/media/live-photo";
import { convertBytesToHumanReadable } from "@/next/file";
import log from "@/next/log";
import type { Electron } from "@/next/types/ipc";
@ -32,7 +33,6 @@ import {
updateFilePublicMagicMetadata,
} from "services/fileService";
import heicConversionService from "services/heicConversionService";
import { decodeLivePhoto } from "services/livePhotoService";
import { getFileType } from "services/typeDetectionService";
import { updateFileCreationDateInEXIF } from "services/upload/exifService";
import {
@ -97,19 +97,20 @@ export async function downloadFile(file: EnteFile) {
await DownloadManager.getFile(file),
).blob();
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
const livePhoto = await decodeLivePhoto(file, fileBlob);
const image = new File([livePhoto.image], livePhoto.imageNameTitle);
const { imageFileName, imageData, videoFileName, videoData } =
await decodeLivePhoto(file.metadata.title, fileBlob);
const image = new File([imageData], imageFileName);
const imageType = await getFileType(image);
const tempImageURL = URL.createObjectURL(
new Blob([livePhoto.image], { type: imageType.mimeType }),
new Blob([imageData], { type: imageType.mimeType }),
);
const video = new File([livePhoto.video], livePhoto.videoNameTitle);
const video = new File([videoData], videoFileName);
const videoType = await getFileType(video);
const tempVideoURL = URL.createObjectURL(
new Blob([livePhoto.video], { type: videoType.mimeType }),
new Blob([videoData], { type: videoType.mimeType }),
);
downloadUsingAnchor(tempImageURL, livePhoto.imageNameTitle);
downloadUsingAnchor(tempVideoURL, livePhoto.videoNameTitle);
downloadUsingAnchor(tempImageURL, imageFileName);
downloadUsingAnchor(tempVideoURL, videoFileName);
} else {
const fileType = await getFileType(
new File([fileBlob], file.metadata.title),
@ -247,18 +248,6 @@ export async function decryptFile(
}
}
export function getFileNameWithoutExtension(filename: string) {
const lastDotPosition = filename.lastIndexOf(".");
if (lastDotPosition === -1) return filename;
else return filename.slice(0, lastDotPosition);
}
export function getFileExtensionWithDot(filename: string) {
const lastDotPosition = filename.lastIndexOf(".");
if (lastDotPosition === -1) return "";
else return filename.slice(lastDotPosition);
}
export function splitFilenameAndExtension(filename: string): [string, string] {
const lastDotPosition = filename.lastIndexOf(".");
if (lastDotPosition === -1) return [filename, null];
@ -355,13 +344,13 @@ async function getRenderableLivePhotoURL(
fileBlob: Blob,
forceConvert: boolean,
): Promise<LivePhotoSourceURL> {
const livePhoto = await decodeLivePhoto(file, fileBlob);
const livePhoto = await decodeLivePhoto(file.metadata.title, fileBlob);
const getRenderableLivePhotoImageURL = async () => {
try {
const imageBlob = new Blob([livePhoto.image]);
const imageBlob = new Blob([livePhoto.imageData]);
const convertedImageBlob = await getRenderableImage(
livePhoto.imageNameTitle,
livePhoto.imageFileName,
imageBlob,
);
@ -374,10 +363,9 @@ async function getRenderableLivePhotoURL(
const getRenderableLivePhotoVideoURL = async () => {
try {
const videoBlob = new Blob([livePhoto.video]);
const videoBlob = new Blob([livePhoto.videoData]);
const convertedVideoBlob = await getPlayableVideo(
livePhoto.videoNameTitle,
livePhoto.videoFileName,
videoBlob,
forceConvert,
true,
@ -813,21 +801,22 @@ async function downloadFileDesktop(
if (file.metadata.fileType === FILE_TYPE.LIVE_PHOTO) {
const fileBlob = await new Response(updatedStream).blob();
const livePhoto = await decodeLivePhoto(file, fileBlob);
const { imageFileName, imageData, videoFileName, videoData } =
await decodeLivePhoto(file.metadata.title, fileBlob);
const imageExportName = await safeFileName(
downloadDir,
livePhoto.imageNameTitle,
imageFileName,
fs.exists,
);
const imageStream = generateStreamFromArrayBuffer(livePhoto.image);
const imageStream = generateStreamFromArrayBuffer(imageData);
await writeStream(`${downloadDir}/${imageExportName}`, imageStream);
try {
const videoExportName = await safeFileName(
downloadDir,
livePhoto.videoNameTitle,
videoFileName,
fs.exists,
);
const videoStream = generateStreamFromArrayBuffer(livePhoto.video);
const videoStream = generateStreamFromArrayBuffer(videoData);
await writeStream(`${downloadDir}/${videoExportName}`, videoStream);
} catch (e) {
await fs.rm(`${downloadDir}/${imageExportName}`);

View file

@ -1,9 +1,9 @@
import { decodeLivePhoto } from "@/media/live-photo";
import log from "@/next/log";
import { FILE_TYPE } from "constants/file";
import PQueue from "p-queue";
import DownloadManager from "services/download";
import { getLocalFiles } from "services/fileService";
import { decodeLivePhoto } from "services/livePhotoService";
import { EnteFile } from "types/file";
import { Dimensions } from "types/image";
import {
@ -134,11 +134,11 @@ async function getOriginalConvertedFile(file: EnteFile, queue?: PQueue) {
if (file.metadata.fileType === FILE_TYPE.IMAGE) {
return await getRenderableImage(file.metadata.title, fileBlob);
} else {
const livePhoto = await decodeLivePhoto(file, fileBlob);
return await getRenderableImage(
livePhoto.imageNameTitle,
new Blob([livePhoto.image]),
const { imageFileName, imageData } = await decodeLivePhoto(
file.metadata.title,
fileBlob,
);
return await getRenderableImage(imageFileName, new Blob([imageData]));
}
}

View file

@ -110,7 +110,7 @@ with Next.js.
For more details, see [translations.md](translations.md).
## Meta Frameworks
## Meta frameworks
### Next.js
@ -131,7 +131,12 @@ It is more lower level than Next, but the bells and whistles it doesn't have are
the bells and whistles (and the accompanying complexity) that we don't need in
some cases.
## Photos
## Media
- "jszip" is used for reading zip files in JavaScript. Live photos are zip
files under the hood.
## Photos app specific
### Misc

View file

@ -0,0 +1,3 @@
module.exports = {
extends: ["@/build-config/eslintrc-next"],
};

View file

@ -0,0 +1,11 @@
## @/media
A package for sharing code between our apps that show media (photos, videos).
Specifically, this is the intersection of code required by both the photos and
cast apps.
### Packaging
This (internal) package exports a React TypeScript library. We rely on the
importing project to transpile and bundle it.

View file

@ -0,0 +1,87 @@
import { fileNameFromComponents, nameAndExtension } from "@/next/file";
import JSZip from "jszip";
/**
* An in-memory representation of a live photo.
*/
interface LivePhoto {
imageFileName: string;
imageData: Uint8Array;
videoFileName: string;
videoData: Uint8Array;
}
/**
* Convert a binary serialized representation of a live photo to an in-memory
* {@link LivePhoto}.
*
* A live photo is a zip file containing two files - an image and a video. This
* functions reads that zip file (blob), and return separate bytes (and
* filenames) for the image and video parts.
*
* @param fileName The name of the overall live photo. Both the image and video
* parts of the decompressed live photo use this as their name, combined with
* their original extensions.
*
* @param zipBlob A blob contained the zipped data (i.e. the binary serialized
* live photo).
*/
export const decodeLivePhoto = async (
fileName: string,
zipBlob: Blob,
): Promise<LivePhoto> => {
let imageFileName, videoFileName: string | undefined;
let imageData, videoData: Uint8Array | undefined;
const [name] = nameAndExtension(fileName);
const zip = await JSZip.loadAsync(zipBlob, { createFolders: true });
for (const zipFileName in zip.files) {
if (zipFileName.startsWith("image")) {
const [, imageExt] = nameAndExtension(zipFileName);
imageFileName = fileNameFromComponents([name, imageExt]);
imageData = await zip.files[zipFileName]?.async("uint8array");
} else if (zipFileName.startsWith("video")) {
const [, videoExt] = nameAndExtension(zipFileName);
videoFileName = fileNameFromComponents([name, videoExt]);
videoData = await zip.files[zipFileName]?.async("uint8array");
}
}
if (!imageFileName || !imageData)
throw new Error(
`Decoded live photo ${fileName} does not have an image`,
);
if (!videoFileName || !videoData)
throw new Error(
`Decoded live photo ${fileName} does not have an image`,
);
return { imageFileName, imageData, videoFileName, videoData };
};
/**
* Return a binary serialized representation of a live photo.
*
* This function takes the (in-memory) image and video data from the
* {@link livePhoto} object, writes them to a zip file (using the respective
* filenames), and returns the {@link Uint8Array} that represent the bytes of
* this zip file.
*
* @param livePhoto The in-mem photo to serialized.
*/
export const encodeLivePhoto = async ({
imageFileName,
imageData,
videoFileName,
videoData,
}: LivePhoto) => {
const [, imageExt] = nameAndExtension(imageFileName);
const [, videoExt] = nameAndExtension(videoFileName);
const zip = new JSZip();
zip.file(fileNameFromComponents(["image", imageExt]), imageData);
zip.file(fileNameFromComponents(["video", videoExt]), videoData);
return await zip.generateAsync({ type: "uint8array" });
};

View file

@ -0,0 +1,9 @@
{
"name": "@/media",
"version": "0.0.0",
"private": true,
"dependencies": {
"@/next": "*",
"jszip": "^3.10"
}
}

View file

@ -0,0 +1,5 @@
{
"extends": "@/build-config/tsconfig-typecheck.json",
/* Typecheck all files with the given extensions (here or in subfolders) */
"include": ["**/*.ts", "**/*.tsx"]
}

View file

@ -1,19 +1,34 @@
import type { ElectronFile } from "./types/file";
/**
* The two parts of a file name - the name itself, and an (optional) extension.
*
* The extension does not include the dot.
*/
type FileNameComponents = [name: string, extension: string | undefined];
/**
* Split a filename into its components - the name itself, and the extension (if
* any) - returning both. The dot is not included in either.
*
* For example, `foo-bar.png` will be split into ["foo-bar", "png"].
*
* See {@link fileNameFromComponents} for the inverse operation.
*/
export const nameAndExtension = (
fileName: string,
): [string, string | undefined] => {
export const nameAndExtension = (fileName: string): FileNameComponents => {
const i = fileName.lastIndexOf(".");
if (i == -1) return [fileName, undefined];
else return [fileName.slice(0, i), fileName.slice(i + 1)];
};
/**
* Construct a file name from its components (name and extension).
*
* Inverse of {@link nameAndExtension}.
*/
export const fileNameFromComponents = (components: FileNameComponents) =>
components.filter((x) => !!x).join(".");
export function getFileNameSize(file: File | ElectronFile) {
return `${file.name}_${convertBytesToHumanReadable(file.size)}`;
}

View file

@ -3252,7 +3252,7 @@ jssha@~3.3.1:
object.assign "^4.1.4"
object.values "^1.1.6"
jszip@3.10.1:
jszip@^3.10:
version "3.10.1"
resolved "https://registry.yarnpkg.com/jszip/-/jszip-3.10.1.tgz#34aee70eb18ea1faec2f589208a157d1feb091c2"
integrity sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==