[web] Start using new Exif library during date modifications (#2604)
This commit is contained in:
@@ -7,16 +7,13 @@
|
||||
"@/base": "*",
|
||||
"@/media": "*",
|
||||
"@/new": "*",
|
||||
"@date-io/date-fns": "^2.14.0",
|
||||
"@ente/eslint-config": "*",
|
||||
"@ente/shared": "*",
|
||||
"@mui/x-date-pickers": "^5.0.0-alpha.6",
|
||||
"@stripe/stripe-js": "^1.13.2",
|
||||
"@xmldom/xmldom": "^0.8.10",
|
||||
"bip39": "^3.0.4",
|
||||
"bs58": "^5.0.0",
|
||||
"chrono-node": "^2.2.6",
|
||||
"date-fns": "^2",
|
||||
"debounce": "^2.0.0",
|
||||
"exifr": "^7.1.3",
|
||||
"exifreader": "^4",
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { useState } from "react";
|
||||
|
||||
import {
|
||||
LocalizationProvider,
|
||||
MobileDateTimePicker,
|
||||
} from "@mui/x-date-pickers";
|
||||
import { AdapterDateFns } from "@mui/x-date-pickers/AdapterDateFns";
|
||||
|
||||
const MIN_EDITED_CREATION_TIME = new Date(1800, 0, 1);
|
||||
const MAX_EDITED_CREATION_TIME = new Date();
|
||||
|
||||
interface Props {
|
||||
initialValue?: Date;
|
||||
disabled?: boolean;
|
||||
label?: string;
|
||||
onSubmit: (date: Date) => void;
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
const EnteDateTimePicker = ({
|
||||
initialValue,
|
||||
disabled,
|
||||
onSubmit,
|
||||
onClose,
|
||||
}: Props) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [value, setValue] = useState(initialValue ?? new Date());
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
onClose?.();
|
||||
};
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterDateFns}>
|
||||
<MobileDateTimePicker
|
||||
value={value}
|
||||
onChange={setValue}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onOpen={() => setOpen(true)}
|
||||
maxDateTime={MAX_EDITED_CREATION_TIME}
|
||||
minDateTime={MIN_EDITED_CREATION_TIME}
|
||||
disabled={disabled}
|
||||
onAccept={onSubmit}
|
||||
DialogProps={{
|
||||
sx: {
|
||||
zIndex: "1502",
|
||||
".MuiPickersToolbar-penIconButton": {
|
||||
display: "none",
|
||||
},
|
||||
".MuiDialog-paper": { width: "320px" },
|
||||
".MuiClockPicker-root": {
|
||||
position: "relative",
|
||||
minHeight: "292px",
|
||||
},
|
||||
".PrivatePickersSlideTransition-root": {
|
||||
minHeight: "200px",
|
||||
},
|
||||
},
|
||||
}}
|
||||
renderInput={() => <></>}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
export default EnteDateTimePicker;
|
||||
@@ -1,4 +1,11 @@
|
||||
import log from "@/base/log";
|
||||
import type { ParsedMetadataDate } from "@/media/file-metadata";
|
||||
import { FileType } from "@/media/file-type";
|
||||
import { PhotoDateTimePicker } from "@/new/photos/components/PhotoDateTimePicker";
|
||||
import downloadManager from "@/new/photos/services/download";
|
||||
import { extractExifDates } from "@/new/photos/services/exif";
|
||||
import { EnteFile } from "@/new/photos/types/file";
|
||||
import { fileLogID } from "@/new/photos/utils/file";
|
||||
import DialogBox from "@ente/shared/components/DialogBox/";
|
||||
import {
|
||||
Button,
|
||||
@@ -14,41 +21,42 @@ import { useFormik } from "formik";
|
||||
import { t } from "i18next";
|
||||
import { GalleryContext } from "pages/gallery";
|
||||
import React, { useContext, useEffect, useState } from "react";
|
||||
import { updateCreationTimeWithExif } from "services/fix-exif";
|
||||
import EnteDateTimePicker from "./EnteDateTimePicker";
|
||||
import {
|
||||
changeFileCreationTime,
|
||||
updateExistingFilePubMetadata,
|
||||
} from "utils/file";
|
||||
|
||||
export interface FixCreationTimeAttributes {
|
||||
files: EnteFile[];
|
||||
}
|
||||
|
||||
type Step = "running" | "completed" | "completed-with-errors";
|
||||
/** The current state of the fixing process. */
|
||||
type Status = "running" | "completed" | "completed-with-errors";
|
||||
|
||||
export type FixOption =
|
||||
| "date-time-original"
|
||||
| "date-time-digitized"
|
||||
| "metadata-date"
|
||||
| "custom-time";
|
||||
| "custom";
|
||||
|
||||
interface FormValues {
|
||||
option: FixOption;
|
||||
/**
|
||||
* Date.toISOString()
|
||||
*
|
||||
* Formik doesn't have native support for JS dates, so we instead keep the
|
||||
* corresponding date's ISO string representation as the form state.
|
||||
*/
|
||||
customTimeString: string;
|
||||
/* Only valid when {@link option} is "custom-time". */
|
||||
customDate: ParsedMetadataDate | undefined;
|
||||
}
|
||||
|
||||
export interface FixCreationTimeAttributes {
|
||||
files: EnteFile[];
|
||||
}
|
||||
|
||||
interface FixCreationTimeProps {
|
||||
isOpen: boolean;
|
||||
show: () => void;
|
||||
hide: () => void;
|
||||
attributes: FixCreationTimeAttributes;
|
||||
}
|
||||
|
||||
const FixCreationTime: React.FC<FixCreationTimeProps> = (props) => {
|
||||
const [step, setStep] = useState<Step | undefined>();
|
||||
const FixCreationTime: React.FC<FixCreationTimeProps> = ({
|
||||
isOpen,
|
||||
hide,
|
||||
attributes,
|
||||
}) => {
|
||||
const [status, setStatus] = useState<Status | undefined>();
|
||||
const [progressTracker, setProgressTracker] = useState({
|
||||
current: 0,
|
||||
total: 0,
|
||||
@@ -58,39 +66,36 @@ const FixCreationTime: React.FC<FixCreationTimeProps> = (props) => {
|
||||
|
||||
useEffect(() => {
|
||||
// TODO (MR): Not sure why this is needed
|
||||
if (props.attributes && props.isOpen && step !== "running") {
|
||||
setStep(undefined);
|
||||
}
|
||||
}, [props.isOpen]);
|
||||
if (attributes && isOpen && status !== "running") setStatus(undefined);
|
||||
}, [isOpen]);
|
||||
|
||||
const onSubmit = async (values: FormValues) => {
|
||||
console.log({ values });
|
||||
setStep("running");
|
||||
const completedWithErrors = await updateCreationTimeWithExif(
|
||||
props.attributes.files,
|
||||
setStatus("running");
|
||||
const completedWithErrors = await updateFiles(
|
||||
attributes.files,
|
||||
values.option,
|
||||
new Date(values.customTimeString),
|
||||
values.customDate,
|
||||
setProgressTracker,
|
||||
);
|
||||
setStep(completedWithErrors ? "completed-with-errors" : "completed");
|
||||
setStatus(completedWithErrors ? "completed-with-errors" : "completed");
|
||||
await galleryContext.syncWithRemote();
|
||||
};
|
||||
|
||||
const title =
|
||||
step === "running"
|
||||
status == "running"
|
||||
? t("FIX_CREATION_TIME_IN_PROGRESS")
|
||||
: t("FIX_CREATION_TIME");
|
||||
|
||||
const message = messageForStep(step);
|
||||
const message = messageForStatus(status);
|
||||
|
||||
if (!props.attributes) {
|
||||
if (!attributes) {
|
||||
return <></>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DialogBox
|
||||
open={props.isOpen}
|
||||
onClose={props.hide}
|
||||
open={isOpen}
|
||||
onClose={hide}
|
||||
attributes={{ title, nonClosable: true }}
|
||||
>
|
||||
<div
|
||||
@@ -98,16 +103,12 @@ const FixCreationTime: React.FC<FixCreationTimeProps> = (props) => {
|
||||
marginBottom: "10px",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
...(step === "running" ? { alignItems: "center" } : {}),
|
||||
...(status == "running" ? { alignItems: "center" } : {}),
|
||||
}}
|
||||
>
|
||||
{message && <div>{message}</div>}
|
||||
|
||||
{step === "running" && (
|
||||
<FixCreationTimeRunning {...{ progressTracker }} />
|
||||
)}
|
||||
|
||||
<OptionsForm {...{ step, onSubmit }} hide={props.hide} />
|
||||
{status === "running" && <Progress {...{ progressTracker }} />}
|
||||
<OptionsForm {...{ step: status, onSubmit }} hide={hide} />
|
||||
</div>
|
||||
</DialogBox>
|
||||
);
|
||||
@@ -115,7 +116,7 @@ const FixCreationTime: React.FC<FixCreationTimeProps> = (props) => {
|
||||
|
||||
export default FixCreationTime;
|
||||
|
||||
const messageForStep = (step?: Step) => {
|
||||
const messageForStatus = (step?: Status) => {
|
||||
switch (step) {
|
||||
case undefined:
|
||||
return undefined;
|
||||
@@ -128,113 +129,7 @@ const messageForStep = (step?: Step) => {
|
||||
}
|
||||
};
|
||||
|
||||
interface OptionsFormProps {
|
||||
step?: Step;
|
||||
onSubmit: (values: FormValues) => void | Promise<any>;
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
const OptionsForm: React.FC<OptionsFormProps> = ({ step, onSubmit, hide }) => {
|
||||
const { values, handleChange, handleSubmit } = useFormik({
|
||||
initialValues: {
|
||||
option: "date-time-original",
|
||||
customTimeString: new Date().toISOString(),
|
||||
},
|
||||
validateOnBlur: false,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{(step === undefined || step === "completed-with-errors") && (
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FormControl>
|
||||
<FormLabel>
|
||||
{t("UPDATE_CREATION_TIME_NOT_STARTED")}
|
||||
</FormLabel>
|
||||
</FormControl>
|
||||
<RadioGroup name={"option"} onChange={handleChange}>
|
||||
<FormControlLabel
|
||||
value={"date-time-original"}
|
||||
control={<Radio size="small" />}
|
||||
label={t("DATE_TIME_ORIGINAL")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value={"date-time-digitized"}
|
||||
control={<Radio size="small" />}
|
||||
label={t("DATE_TIME_DIGITIZED")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value={"metadata-date"}
|
||||
control={<Radio size="small" />}
|
||||
label={t("METADATA_DATE")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value={"custom-time"}
|
||||
control={<Radio size="small" />}
|
||||
label={t("CUSTOM_TIME")}
|
||||
/>
|
||||
</RadioGroup>
|
||||
{values.option === "custom-time" && (
|
||||
<EnteDateTimePicker
|
||||
onSubmit={(d: Date) =>
|
||||
handleChange("customTimeString")(
|
||||
d.toISOString(),
|
||||
)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
<Footer step={step} startFix={handleSubmit} hide={hide} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Footer = ({ step, startFix, ...props }) => {
|
||||
return (
|
||||
step !== "running" && (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
marginTop: "30px",
|
||||
justifyContent: "space-around",
|
||||
}}
|
||||
>
|
||||
{(step === undefined || step === "completed-with-errors") && (
|
||||
<Button
|
||||
color="secondary"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
props.hide();
|
||||
}}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
)}
|
||||
{step === "completed" && (
|
||||
<Button color="primary" size="large" onClick={props.hide}>
|
||||
{t("CLOSE")}
|
||||
</Button>
|
||||
)}
|
||||
{(step === undefined || step === "completed-with-errors") && (
|
||||
<>
|
||||
<div style={{ width: "30px" }} />
|
||||
|
||||
<Button color="accent" size="large" onClick={startFix}>
|
||||
{t("FIX_CREATION_TIME")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
const FixCreationTimeRunning = ({ progressTracker }) => {
|
||||
const Progress = ({ progressTracker }) => {
|
||||
const progress = Math.round(
|
||||
(progressTracker.current * 100) / progressTracker.total,
|
||||
);
|
||||
@@ -262,3 +157,198 @@ const FixCreationTimeRunning = ({ progressTracker }) => {
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface OptionsFormProps {
|
||||
step?: Status;
|
||||
onSubmit: (values: FormValues) => Promise<void>;
|
||||
hide: () => void;
|
||||
}
|
||||
|
||||
const OptionsForm: React.FC<OptionsFormProps> = ({ step, onSubmit, hide }) => {
|
||||
const { values, handleChange, setValues, handleSubmit } =
|
||||
useFormik<FormValues>({
|
||||
initialValues: {
|
||||
option: "date-time-original",
|
||||
customDate: undefined,
|
||||
},
|
||||
validateOnBlur: false,
|
||||
onSubmit,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{(step === undefined || step === "completed-with-errors") && (
|
||||
<div style={{ marginTop: "10px" }}>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<FormControl>
|
||||
<FormLabel>
|
||||
{t("UPDATE_CREATION_TIME_NOT_STARTED")}
|
||||
</FormLabel>
|
||||
</FormControl>
|
||||
<RadioGroup
|
||||
name={"option"}
|
||||
value={values.option}
|
||||
onChange={handleChange}
|
||||
>
|
||||
<FormControlLabel
|
||||
value={"date-time-original"}
|
||||
control={<Radio size="small" />}
|
||||
label={t("DATE_TIME_ORIGINAL")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value={"date-time-digitized"}
|
||||
control={<Radio size="small" />}
|
||||
label={t("DATE_TIME_DIGITIZED")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value={"metadata-date"}
|
||||
control={<Radio size="small" />}
|
||||
label={t("METADATA_DATE")}
|
||||
/>
|
||||
<FormControlLabel
|
||||
value={"custom"}
|
||||
control={<Radio size="small" />}
|
||||
label={t("CUSTOM_TIME")}
|
||||
/>
|
||||
</RadioGroup>
|
||||
{values.option == "custom" && (
|
||||
<PhotoDateTimePicker
|
||||
onAccept={(customDate) =>
|
||||
setValues({ option: "custom", customDate })
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
<Footer step={step} startFix={handleSubmit} hide={hide} />
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const Footer = ({ step, startFix, ...props }) => {
|
||||
return (
|
||||
step != "running" && (
|
||||
<div
|
||||
style={{
|
||||
width: "100%",
|
||||
display: "flex",
|
||||
marginTop: "30px",
|
||||
justifyContent: "space-around",
|
||||
}}
|
||||
>
|
||||
{(!step || step == "completed-with-errors") && (
|
||||
<Button
|
||||
color="secondary"
|
||||
size="large"
|
||||
onClick={() => {
|
||||
props.hide();
|
||||
}}
|
||||
>
|
||||
{t("cancel")}
|
||||
</Button>
|
||||
)}
|
||||
{step == "completed" && (
|
||||
<Button color="primary" size="large" onClick={props.hide}>
|
||||
{t("CLOSE")}
|
||||
</Button>
|
||||
)}
|
||||
{(!step || step == "completed-with-errors") && (
|
||||
<>
|
||||
<div style={{ width: "30px" }} />
|
||||
|
||||
<Button color="accent" size="large" onClick={startFix}>
|
||||
{t("FIX_CREATION_TIME")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
type SetProgressTracker = React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
current: number;
|
||||
total: number;
|
||||
}>
|
||||
>;
|
||||
|
||||
const updateFiles = async (
|
||||
enteFiles: EnteFile[],
|
||||
fixOption: FixOption,
|
||||
customDate: ParsedMetadataDate,
|
||||
setProgressTracker: SetProgressTracker,
|
||||
) => {
|
||||
setProgressTracker({ current: 0, total: enteFiles.length });
|
||||
let hadErrors = false;
|
||||
for (const [i, enteFile] of enteFiles.entries()) {
|
||||
try {
|
||||
await updateEnteFileDate(enteFile, fixOption, customDate);
|
||||
} catch (e) {
|
||||
log.error(`Failed to update date of ${fileLogID(enteFile)}`, e);
|
||||
hadErrors = true;
|
||||
} finally {
|
||||
setProgressTracker({ current: i + 1, total: enteFiles.length });
|
||||
}
|
||||
}
|
||||
return hadErrors;
|
||||
};
|
||||
|
||||
/**
|
||||
* Update the date associated with a given {@link enteFile}.
|
||||
*
|
||||
* This is generally treated as the creation date of the underlying asset
|
||||
* (photo, video, live photo) that this file stores.
|
||||
*
|
||||
* - For images, this function allows us to update this date from the Exif and
|
||||
* other metadata embedded in the file.
|
||||
*
|
||||
* - For all types of files (including images), this function allows us to
|
||||
* update this date to an explicitly provided value.
|
||||
*
|
||||
* If an Exif-involving {@link fixOption} is passed for an non-image file, then
|
||||
* that file is just skipped over. Similarly, if an Exif-involving
|
||||
* {@link fixOption} is provided, but the given underlying image for the given
|
||||
* {@link enteFile} does not have a corresponding Exif (or related) value, then
|
||||
* that file is skipped.
|
||||
*
|
||||
* Note that metadata associated with an {@link EnteFile} is immutable, and we
|
||||
* instead modify the mutable metadata section associated with the file. See
|
||||
* [Note: Metadatum] for more details.
|
||||
*/
|
||||
export const updateEnteFileDate = async (
|
||||
enteFile: EnteFile,
|
||||
fixOption: FixOption,
|
||||
customDate: ParsedMetadataDate,
|
||||
) => {
|
||||
let newDate: ParsedMetadataDate | undefined;
|
||||
if (fixOption === "custom") {
|
||||
newDate = customDate;
|
||||
} else if (enteFile.metadata.fileType == FileType.image) {
|
||||
const stream = await downloadManager.getFile(enteFile);
|
||||
const blob = await new Response(stream).blob();
|
||||
const file = new File([blob], enteFile.metadata.title);
|
||||
const { DateTimeOriginal, DateTimeDigitized, MetadataDate, DateTime } =
|
||||
await extractExifDates(file);
|
||||
switch (fixOption) {
|
||||
case "date-time-original":
|
||||
newDate = DateTimeOriginal ?? DateTime;
|
||||
break;
|
||||
case "date-time-digitized":
|
||||
newDate = DateTimeDigitized;
|
||||
break;
|
||||
case "metadata-date":
|
||||
newDate = MetadataDate;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (newDate && newDate.timestamp !== enteFile.metadata.creationTime) {
|
||||
const updatedFile = await changeFileCreationTime(
|
||||
enteFile,
|
||||
newDate.timestamp,
|
||||
);
|
||||
updateExistingFilePubMetadata(enteFile, updatedFile);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import log from "@/base/log";
|
||||
import type { ParsedMetadataDate } from "@/media/file-metadata";
|
||||
import { PhotoDateTimePicker } from "@/new/photos/components/PhotoDateTimePicker";
|
||||
import { EnteFile } from "@/new/photos/types/file";
|
||||
import { FlexWrapper } from "@ente/shared/components/Container";
|
||||
import { formatDate, formatTime } from "@ente/shared/time/format";
|
||||
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
|
||||
import EnteDateTimePicker from "components/EnteDateTimePicker";
|
||||
import { useState } from "react";
|
||||
import {
|
||||
changeFileCreationTime,
|
||||
@@ -27,11 +28,11 @@ export function RenderCreationTime({
|
||||
const openEditMode = () => setIsInEditMode(true);
|
||||
const closeEditMode = () => setIsInEditMode(false);
|
||||
|
||||
const saveEdits = async (pickedTime: Date) => {
|
||||
const saveEdits = async (pickedTime: ParsedMetadataDate) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (isInEditMode && file) {
|
||||
const unixTimeInMicroSec = pickedTime.getTime() * 1000;
|
||||
const unixTimeInMicroSec = pickedTime.timestamp;
|
||||
if (unixTimeInMicroSec === file?.metadata.creationTime) {
|
||||
closeEditMode();
|
||||
return;
|
||||
@@ -63,10 +64,10 @@ export function RenderCreationTime({
|
||||
hideEditOption={shouldDisableEdits || isInEditMode}
|
||||
/>
|
||||
{isInEditMode && (
|
||||
<EnteDateTimePicker
|
||||
<PhotoDateTimePicker
|
||||
initialValue={originalCreationTime}
|
||||
disabled={loading}
|
||||
onSubmit={saveEdits}
|
||||
onAccept={saveEdits}
|
||||
onClose={closeEditMode}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -4,6 +4,7 @@ import { nameAndExtension } from "@/base/file";
|
||||
import type { ParsedMetadata } from "@/media/file-metadata";
|
||||
import { FileType } from "@/media/file-type";
|
||||
import { UnidentifiedFaces } from "@/new/photos/components/PeopleList";
|
||||
import { photoSwipeZIndex } from "@/new/photos/components/PhotoViewer";
|
||||
import { tagNumericValue, type RawExifTags } from "@/new/photos/services/exif";
|
||||
import { isMLEnabled } from "@/new/photos/services/ml";
|
||||
import { EnteFile } from "@/new/photos/types/file";
|
||||
@@ -339,7 +340,7 @@ const parseExifInfo = (
|
||||
const FileInfoSidebar = styled((props: DialogProps) => (
|
||||
<EnteDrawer {...props} anchor="right" />
|
||||
))({
|
||||
zIndex: 1501,
|
||||
zIndex: photoSwipeZIndex + 1,
|
||||
"& .MuiPaper-root": {
|
||||
padding: 8,
|
||||
},
|
||||
|
||||
@@ -1066,7 +1066,6 @@ export default function Gallery() {
|
||||
<FixCreationTime
|
||||
isOpen={fixCreationTimeView}
|
||||
hide={() => setFixCreationTimeView(false)}
|
||||
show={() => setFixCreationTimeView(true)}
|
||||
attributes={fixCreationTimeAttributes}
|
||||
/>
|
||||
<GalleryNavbar
|
||||
|
||||
@@ -1,105 +0,0 @@
|
||||
import log from "@/base/log";
|
||||
import { FileType } from "@/media/file-type";
|
||||
import downloadManager from "@/new/photos/services/download";
|
||||
import { EnteFile } from "@/new/photos/types/file";
|
||||
import { detectFileTypeInfo } from "@/new/photos/utils/detect-type";
|
||||
import { validateAndGetCreationUnixTimeInMicroSeconds } from "@ente/shared/time";
|
||||
import { getParsedExifData } from "@ente/shared/utils/exif-old";
|
||||
import type { FixOption } from "components/FixCreationTime";
|
||||
import {
|
||||
changeFileCreationTime,
|
||||
updateExistingFilePubMetadata,
|
||||
} from "utils/file";
|
||||
|
||||
const EXIF_TIME_TAGS = [
|
||||
"DateTimeOriginal",
|
||||
"CreateDate",
|
||||
"ModifyDate",
|
||||
"DateCreated",
|
||||
"MetadataDate",
|
||||
];
|
||||
|
||||
export type SetProgressTracker = React.Dispatch<
|
||||
React.SetStateAction<{
|
||||
current: number;
|
||||
total: number;
|
||||
}>
|
||||
>;
|
||||
|
||||
export async function updateCreationTimeWithExif(
|
||||
filesToBeUpdated: EnteFile[],
|
||||
fixOption: FixOption,
|
||||
customTime: Date,
|
||||
setProgressTracker: SetProgressTracker,
|
||||
) {
|
||||
let completedWithError = false;
|
||||
try {
|
||||
if (filesToBeUpdated.length === 0) {
|
||||
return completedWithError;
|
||||
}
|
||||
setProgressTracker({ current: 0, total: filesToBeUpdated.length });
|
||||
for (const [index, file] of filesToBeUpdated.entries()) {
|
||||
try {
|
||||
let correctCreationTime: number;
|
||||
if (fixOption === "custom-time") {
|
||||
correctCreationTime = customTime.getTime() * 1000;
|
||||
} else {
|
||||
if (file.metadata.fileType !== FileType.image) {
|
||||
continue;
|
||||
}
|
||||
const fileStream = await downloadManager.getFile(file);
|
||||
const fileBlob = await new Response(fileStream).blob();
|
||||
const fileObject = new File(
|
||||
[fileBlob],
|
||||
file.metadata.title,
|
||||
);
|
||||
const fileTypeInfo = await detectFileTypeInfo(fileObject);
|
||||
const exifData = await getParsedExifData(
|
||||
fileObject,
|
||||
fileTypeInfo,
|
||||
EXIF_TIME_TAGS,
|
||||
);
|
||||
if (fixOption === "date-time-original") {
|
||||
correctCreationTime =
|
||||
validateAndGetCreationUnixTimeInMicroSeconds(
|
||||
exifData?.DateTimeOriginal ??
|
||||
exifData?.DateCreated,
|
||||
);
|
||||
} else if (fixOption === "date-time-digitized") {
|
||||
correctCreationTime =
|
||||
validateAndGetCreationUnixTimeInMicroSeconds(
|
||||
exifData?.CreateDate,
|
||||
);
|
||||
} else if (fixOption === "metadata-date") {
|
||||
correctCreationTime =
|
||||
validateAndGetCreationUnixTimeInMicroSeconds(
|
||||
exifData?.MetadataDate,
|
||||
);
|
||||
}
|
||||
}
|
||||
if (
|
||||
correctCreationTime &&
|
||||
correctCreationTime !== file.metadata.creationTime
|
||||
) {
|
||||
const updatedFile = await changeFileCreationTime(
|
||||
file,
|
||||
correctCreationTime,
|
||||
);
|
||||
updateExistingFilePubMetadata(file, updatedFile);
|
||||
}
|
||||
} catch (e) {
|
||||
log.error("failed to updated a CreationTime With Exif", e);
|
||||
completedWithError = true;
|
||||
} finally {
|
||||
setProgressTracker({
|
||||
current: index + 1,
|
||||
total: filesToBeUpdated.length,
|
||||
});
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
log.error("update CreationTime With Exif failed", e);
|
||||
completedWithError = true;
|
||||
}
|
||||
return completedWithError;
|
||||
}
|
||||
@@ -148,6 +148,15 @@ specifying the import map as mentioned
|
||||
but that disables the SWC integration altogether, so we live with this
|
||||
infelicity for now.
|
||||
|
||||
### Date pickers
|
||||
|
||||
[@mui/x-date-pickers](https://mui.com/x/react-date-pickers/getting-started/) is
|
||||
used to get a date/time picker component. This is the community version of the
|
||||
DateTimePicker component provided by MUI.
|
||||
|
||||
[dayjs](https://github.com/iamkun/dayjs) is used as the date library that that
|
||||
`@mui/x-date-pickers` will internally use to manipulate dates.
|
||||
|
||||
### Translations
|
||||
|
||||
For showing the app's UI in multiple languages, we use the
|
||||
|
||||
@@ -6,6 +6,8 @@
|
||||
"@/base": "*",
|
||||
"@/utils": "*",
|
||||
"@ente/shared": "*",
|
||||
"@mui/x-date-pickers": "^7.12.0",
|
||||
"dayjs": "^1.11.12",
|
||||
"formik": "^2.4",
|
||||
"idb": "^8",
|
||||
"zod": "^3"
|
||||
|
||||
167
web/packages/new/photos/components/PhotoDateTimePicker.tsx
Normal file
167
web/packages/new/photos/components/PhotoDateTimePicker.tsx
Normal file
@@ -0,0 +1,167 @@
|
||||
import type { ParsedMetadataDate } from "@/media/file-metadata";
|
||||
import { photoSwipeZIndex } from "@/new/photos/components/PhotoViewer";
|
||||
import {
|
||||
LocalizationProvider,
|
||||
MobileDateTimePicker,
|
||||
} from "@mui/x-date-pickers";
|
||||
import { AdapterDayjs } from "@mui/x-date-pickers/AdapterDayjs";
|
||||
import dayjs, { Dayjs } from "dayjs";
|
||||
import React, { useState } from "react";
|
||||
|
||||
interface PhotoDateTimePickerProps {
|
||||
/**
|
||||
* The initial date to preselect in the date/time picker.
|
||||
*
|
||||
* If not provided, the current date/time is used.
|
||||
*/
|
||||
initialValue?: Date;
|
||||
/**
|
||||
* If true, then the picker shows provided date/time but doesn't allow
|
||||
* editing it.
|
||||
*/
|
||||
disabled?: boolean;
|
||||
/**
|
||||
* Callback invoked when the user makes and confirms a date/time.
|
||||
*/
|
||||
onAccept: (date: ParsedMetadataDate) => void;
|
||||
/**
|
||||
* Optional callback invoked when the picker has been closed.
|
||||
*/
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* A customized version of MUI DateTimePicker suitable for use in selecting and
|
||||
* modifying the date/time for a photo.
|
||||
*
|
||||
* On success, it returns a {@link ParsedMetadataDate} which contains the a
|
||||
* local date/time string representation of the selected date, the current UTC
|
||||
* offset, and an epoch timestamp. The idea is that the user is picking a
|
||||
* date/time in the hypothetical timezone of where the photo was taken.
|
||||
*
|
||||
* We return local (current) UTC offset, but this might be different from what
|
||||
* the user is imagining when they're picking a date. So it should be taken as
|
||||
* an advisory, and only used if the photo does not already have an associated
|
||||
* UTC offset. For more discussion of the caveats and nuances around this, see
|
||||
* [Note: Photos are always in local date/time].
|
||||
*/
|
||||
export const PhotoDateTimePicker: React.FC<PhotoDateTimePickerProps> = ({
|
||||
initialValue,
|
||||
disabled,
|
||||
onAccept,
|
||||
onClose,
|
||||
}) => {
|
||||
const [open, setOpen] = useState(true);
|
||||
const [value, setValue] = useState<Dayjs | null>(dayjs(initialValue));
|
||||
|
||||
const handleAccept = (d: Dayjs | null) => {
|
||||
if (!dayjs.isDayjs(d))
|
||||
throw new Error(`Unexpected non-dayjs result ${typeof d}`);
|
||||
onAccept(parseMetadataDateFromDayjs(d));
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
setOpen(false);
|
||||
onClose?.();
|
||||
};
|
||||
|
||||
return (
|
||||
<LocalizationProvider dateAdapter={AdapterDayjs}>
|
||||
<MobileDateTimePicker
|
||||
value={value}
|
||||
onChange={(d) => setValue(d)}
|
||||
open={open}
|
||||
onClose={handleClose}
|
||||
onOpen={() => setOpen(true)}
|
||||
disabled={disabled}
|
||||
disableFuture={true}
|
||||
/* The dialog grows too big on the default portrait mode with
|
||||
our theme customizations. So we instead use the landscape
|
||||
layout. This works great on desktop since we have sufficient
|
||||
width. MUI omits the sidebar on mobile devices (using the
|
||||
pointer:fine media query), so it remains functional on mobile
|
||||
devices too. */
|
||||
orientation="landscape"
|
||||
onAccept={handleAccept}
|
||||
slots={{ field: EmptyField }}
|
||||
slotProps={{
|
||||
/* The time picker has a smaller height than the calendar,
|
||||
which causes an ungainly layout shift. Prevent this by
|
||||
giving a minimum height to the picker.
|
||||
|
||||
The constant 336px will likely change in the future when
|
||||
MUI gets updated, so this solution is fragile. However
|
||||
MUI is anyways intending to replace the TimeClock with a
|
||||
DigitalTimePicker that has a better UX. */
|
||||
layout: {
|
||||
sx: {
|
||||
".MuiTimeClock-root": {
|
||||
minHeight: "336px",
|
||||
},
|
||||
},
|
||||
},
|
||||
/* We also get opened from the info drawer in the photo
|
||||
viewer, so give our dialog a higher z-index than both the
|
||||
photo viewer and the info drawer */
|
||||
dialog: {
|
||||
sx: {
|
||||
zIndex: photoSwipeZIndex + 2,
|
||||
},
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</LocalizationProvider>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* We don't wish to render any UI for the MUI DateTimePicker when it is closed,
|
||||
* and instead only wish to use it as a dialog that we trigger ourselves.
|
||||
*
|
||||
* To achieve this we provide this nop-DOM element as the "field" slot to the
|
||||
* date/time picker.
|
||||
*
|
||||
* See: https://mui.com/x/react-date-pickers/custom-field/
|
||||
*/
|
||||
const EmptyField: React.FC = () => <></>;
|
||||
|
||||
/**
|
||||
* A variant of {@link parseMetadataDate} that does the same thing, but for
|
||||
* {@link Dayjs} instances.
|
||||
*/
|
||||
const parseMetadataDateFromDayjs = (d: Dayjs): ParsedMetadataDate => {
|
||||
// `Dayjs.format` returns an ISO 8601 string of the form
|
||||
// 2020-04-02T08:02:17-05:00'.
|
||||
//
|
||||
// https://day.js.org/docs/en/display/format
|
||||
//
|
||||
// This is different from the JavaScript `Date.toISOString` which also
|
||||
// returns an ISO 8601 string, but with the time zone descriptor always set
|
||||
// to UTC Zulu ("Z").
|
||||
//
|
||||
// The behaviour of Dayjs.format is more convenient for us, since it does
|
||||
// both things we wish for:
|
||||
// - Display the date in the local timezone
|
||||
// - Include the timezone offset.
|
||||
|
||||
const s = d.format();
|
||||
|
||||
let dateTime: string;
|
||||
let offsetTime: string | undefined;
|
||||
|
||||
// Check to see if there is a time-zone descriptor of the form "Z" or
|
||||
// "±05:30" or "±0530" at the end of s.
|
||||
const m = s.match(/Z|[+-]\d\d:?\d\d$/);
|
||||
if (m?.index) {
|
||||
dateTime = s.substring(0, m.index);
|
||||
offsetTime = s.substring(m.index);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Dayjs.format returned a string "${s}" without a timezone offset`,
|
||||
);
|
||||
}
|
||||
|
||||
const timestamp = d.valueOf() * 1000;
|
||||
|
||||
return { dateTime, offsetTime, timestamp };
|
||||
};
|
||||
5
web/packages/new/photos/components/PhotoViewer.tsx
Normal file
5
web/packages/new/photos/components/PhotoViewer.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* PhotoSwipe sets the zIndex of its "pswp" class to 1500. We need to go higher
|
||||
* than that for our drawers and dialogs to show above it.
|
||||
*/
|
||||
export const photoSwipeZIndex = 1500;
|
||||
152
web/yarn.lock
152
web/yarn.lock
@@ -183,7 +183,7 @@
|
||||
dependencies:
|
||||
"@babel/helper-plugin-utils" "^7.24.7"
|
||||
|
||||
"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.0", "@babel/runtime@^7.18.9":
|
||||
"@babel/runtime@^7.0.0", "@babel/runtime@^7.12.0":
|
||||
version "7.23.9"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.23.9.tgz#47791a15e4603bb5f905bc0753801cf21d6345f7"
|
||||
integrity sha512-0CX6F+BI2s9dkUqr08KFrAIZgNFj75rdBU/DjCyYLIaV/quFjkk6T+EJ2LkZHyZTbEV4L5p97mNkUsHl2wLFAw==
|
||||
@@ -204,6 +204,13 @@
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/runtime@^7.25.0":
|
||||
version "7.25.0"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.25.0.tgz#3af9a91c1b739c569d5d80cc917280919c544ecb"
|
||||
integrity sha512-7dRy4DwXwtzBrPbZflqxnvfxLF8kdZXPkhymtDeFoFqE6ldzjQFgYTtYIFARcLEYDrqfBfYcZt1WqFxRoyC9Rw==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.14.0"
|
||||
|
||||
"@babel/template@^7.24.7":
|
||||
version "7.24.7"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.24.7.tgz#02efcee317d0609d2c07117cb70ef8fb17ab7315"
|
||||
@@ -238,39 +245,6 @@
|
||||
"@babel/helper-validator-identifier" "^7.24.7"
|
||||
to-fast-properties "^2.0.0"
|
||||
|
||||
"@date-io/core@^2.15.0", "@date-io/core@^2.17.0":
|
||||
version "2.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@date-io/core/-/core-2.17.0.tgz#360a4d0641f069776ed22e457876e8a8a58c205e"
|
||||
integrity sha512-+EQE8xZhRM/hsY0CDTVyayMDDY5ihc4MqXCrPxooKw19yAzUIC6uUqsZeaOFNL9YKTNxYKrJP5DFgE8o5xRCOw==
|
||||
|
||||
"@date-io/date-fns@^2.14.0", "@date-io/date-fns@^2.15.0":
|
||||
version "2.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@date-io/date-fns/-/date-fns-2.17.0.tgz#1d9d0a02e0137524331819c9576a4e8e19a6142b"
|
||||
integrity sha512-L0hWZ/mTpy3Gx/xXJ5tq5CzHo0L7ry6KEO9/w/JWiFWFLZgiNVo3ex92gOl3zmzjHqY/3Ev+5sehAr8UnGLEng==
|
||||
dependencies:
|
||||
"@date-io/core" "^2.17.0"
|
||||
|
||||
"@date-io/dayjs@^2.15.0":
|
||||
version "2.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@date-io/dayjs/-/dayjs-2.17.0.tgz#ec3e2384136c028971ca2f78800a6877b9fdbe62"
|
||||
integrity sha512-Iq1wjY5XzBh0lheFA0it6Dsyv94e8mTiNR8vuTai+KopxDkreL3YjwTmZHxkgB7/vd0RMIACStzVgWvPATnDCA==
|
||||
dependencies:
|
||||
"@date-io/core" "^2.17.0"
|
||||
|
||||
"@date-io/luxon@^2.15.0":
|
||||
version "2.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@date-io/luxon/-/luxon-2.17.0.tgz#76e1f001aaa38fe7f0049f010fe356db1bb517d2"
|
||||
integrity sha512-l712Vdm/uTddD2XWt9TlQloZUiTiRQtY5TCOG45MQ/8u0tu8M17BD6QYHar/3OrnkGybALAMPzCy1r5D7+0HBg==
|
||||
dependencies:
|
||||
"@date-io/core" "^2.17.0"
|
||||
|
||||
"@date-io/moment@^2.15.0":
|
||||
version "2.17.0"
|
||||
resolved "https://registry.yarnpkg.com/@date-io/moment/-/moment-2.17.0.tgz#04d2487d9d15d468b2e7903b87268fa1c89b56cb"
|
||||
integrity sha512-e4nb4CDZU4k0WRVhz1Wvl7d+hFsedObSauDHKtZwU9kt7gdYEAzKgnrSCTHsEaXrDumdrkCYTeZ0Tmyk7uV4tw==
|
||||
dependencies:
|
||||
"@date-io/core" "^2.17.0"
|
||||
|
||||
"@discoveryjs/json-ext@0.5.7":
|
||||
version "0.5.7"
|
||||
resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70"
|
||||
@@ -683,6 +657,15 @@
|
||||
"@mui/utils" "^5.16.0"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/private-theming@^5.16.6":
|
||||
version "5.16.6"
|
||||
resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.16.6.tgz#547671e7ae3f86b68d1289a0b90af04dfcc1c8c9"
|
||||
integrity sha512-rAk+Rh8Clg7Cd7shZhyt2HGTTE5wYKNSJ5sspf28Fqm/PZ69Er9o6KX25g03/FG2dfpg5GCwZh/xOojiTfm3hw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.23.9"
|
||||
"@mui/utils" "^5.16.6"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/styled-engine@^5.15.14":
|
||||
version "5.15.14"
|
||||
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.15.14.tgz#168b154c4327fa4ccc1933a498331d53f61c0de2"
|
||||
@@ -693,6 +676,16 @@
|
||||
csstype "^3.1.3"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/styled-engine@^5.16.6":
|
||||
version "5.16.6"
|
||||
resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.16.6.tgz#60110c106dd482dfdb7e2aa94fd6490a0a3f8852"
|
||||
integrity sha512-zaThmS67ZmtHSWToTiHslbI8jwrmITcN93LQaR2lKArbvS7Z3iLkwRoiikNWutx9MBs8Q6okKvbZq1RQYB3v7g==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.23.9"
|
||||
"@emotion/cache" "^11.11.0"
|
||||
csstype "^3.1.3"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/system@^5.16.0":
|
||||
version "5.16.0"
|
||||
resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.16.0.tgz#e5b4cfbdfbc0ee9859f6b168e8b07d750303b7a0"
|
||||
@@ -707,20 +700,29 @@
|
||||
csstype "^3.1.3"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/system@^5.16.5":
|
||||
version "5.16.6"
|
||||
resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.16.6.tgz#2dabe63d2e45816ce611c40d6e3f79b9c2ccbcd7"
|
||||
integrity sha512-5xgyJjBIMPw8HIaZpfbGAaFYPwImQn7Nyh+wwKWhvkoIeDosQ1ZMVrbTclefi7G8hNmqhip04duYwYpbBFnBgw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.23.9"
|
||||
"@mui/private-theming" "^5.16.6"
|
||||
"@mui/styled-engine" "^5.16.6"
|
||||
"@mui/types" "^7.2.15"
|
||||
"@mui/utils" "^5.16.6"
|
||||
clsx "^2.1.0"
|
||||
csstype "^3.1.3"
|
||||
prop-types "^15.8.1"
|
||||
|
||||
"@mui/types@^7.2.14":
|
||||
version "7.2.14"
|
||||
resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.14.tgz#8a02ac129b70f3d82f2f9b76ded2c8d48e3fc8c9"
|
||||
integrity sha512-MZsBZ4q4HfzBsywtXgM1Ksj6HDThtiwmOKUXH1pKYISI9gAVXCNHNpo7TlGoGrBaYWZTdNoirIN7JsQcQUjmQQ==
|
||||
|
||||
"@mui/utils@^5.10.3":
|
||||
version "5.15.11"
|
||||
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.15.11.tgz#a71804d6d6025783478fd1aca9afbf83d9b789c7"
|
||||
integrity sha512-D6bwqprUa9Stf8ft0dcMqWyWDKEo7D+6pB1k8WajbqlYIRA8J8Kw9Ra7PSZKKePGBGWO+/xxrX1U8HpG/aXQCw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.23.9"
|
||||
"@types/prop-types" "^15.7.11"
|
||||
prop-types "^15.8.1"
|
||||
react-is "^18.2.0"
|
||||
"@mui/types@^7.2.15":
|
||||
version "7.2.15"
|
||||
resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.15.tgz#dadd232fe9a70be0d526630675dff3b110f30b53"
|
||||
integrity sha512-nbo7yPhtKJkdf9kcVOF8JZHPZTmqXjJ/tI0bdWgHg5tp9AnIN4Y7f7wm9T+0SyGYJk76+GYZ8Q5XaTYAsUHN0Q==
|
||||
|
||||
"@mui/utils@^5.15.14", "@mui/utils@^5.16.0":
|
||||
version "5.16.0"
|
||||
@@ -732,23 +734,30 @@
|
||||
prop-types "^15.8.1"
|
||||
react-is "^18.2.0"
|
||||
|
||||
"@mui/x-date-pickers@^5.0.0-alpha.6":
|
||||
version "5.0.20"
|
||||
resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-5.0.20.tgz#7b4e5b5a214a8095937ba7d82bb82acd6f270d72"
|
||||
integrity sha512-ERukSeHIoNLbI1C2XRhF9wRhqfsr+Q4B1SAw2ZlU7CWgcG8UBOxgqRKDEOVAIoSWL+DWT6GRuQjOKvj6UXZceA==
|
||||
"@mui/utils@^5.16.5", "@mui/utils@^5.16.6":
|
||||
version "5.16.6"
|
||||
resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.16.6.tgz#905875bbc58d3dcc24531c3314a6807aba22a711"
|
||||
integrity sha512-tWiQqlhxAt3KENNiSRL+DIn9H5xNVK6Jjf70x3PnfQPz1MPBdh7yyIcAyVBT9xiw7hP3SomRhPR7hzBMBCjqEA==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.18.9"
|
||||
"@date-io/core" "^2.15.0"
|
||||
"@date-io/date-fns" "^2.15.0"
|
||||
"@date-io/dayjs" "^2.15.0"
|
||||
"@date-io/luxon" "^2.15.0"
|
||||
"@date-io/moment" "^2.15.0"
|
||||
"@mui/utils" "^5.10.3"
|
||||
"@types/react-transition-group" "^4.4.5"
|
||||
clsx "^1.2.1"
|
||||
prop-types "^15.7.2"
|
||||
"@babel/runtime" "^7.23.9"
|
||||
"@mui/types" "^7.2.15"
|
||||
"@types/prop-types" "^15.7.12"
|
||||
clsx "^2.1.1"
|
||||
prop-types "^15.8.1"
|
||||
react-is "^18.3.1"
|
||||
|
||||
"@mui/x-date-pickers@^7.12.0":
|
||||
version "7.12.0"
|
||||
resolved "https://registry.yarnpkg.com/@mui/x-date-pickers/-/x-date-pickers-7.12.0.tgz#189acc9e3d2a5be260fab9faf5e6add516e59b09"
|
||||
integrity sha512-WU5C7QNfSpJ9cP8vl2sY7q35NW+0TUMgEy+sl98fcPhLckq3cgV1wnVxoZnQZ3BxVQAtx+7ag/MpefU03vJcVw==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.25.0"
|
||||
"@mui/system" "^5.16.5"
|
||||
"@mui/utils" "^5.16.5"
|
||||
"@types/react-transition-group" "^4.4.10"
|
||||
clsx "^2.1.1"
|
||||
prop-types "^15.8.1"
|
||||
react-transition-group "^4.4.5"
|
||||
rifm "^0.12.1"
|
||||
|
||||
"@next/bundle-analyzer@^14.1":
|
||||
version "14.2.4"
|
||||
@@ -1076,7 +1085,7 @@
|
||||
resolved "https://registry.yarnpkg.com/@types/photoswipe/-/photoswipe-4.1.6.tgz#41d1e0a54c1b27628688e8abf9b95baab472a247"
|
||||
integrity sha512-6kN4KYjNF4sg79fSwZ46s4Pron4+YJxoW0DQOcHveUZc/3cWd8Q4B9OLlDmEYw9iI6fODU8kyyq8ZBy+8F/+zQ==
|
||||
|
||||
"@types/prop-types@*", "@types/prop-types@^15.7.11":
|
||||
"@types/prop-types@*", "@types/prop-types@^15.7.11", "@types/prop-types@^15.7.12":
|
||||
version "15.7.12"
|
||||
resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.12.tgz#12bb1e2be27293c1406acb6af1c3f3a1481d98c6"
|
||||
integrity sha512-5zvhXYtRNRluoE/jAp4GVsSduVUzNWKkOZrCDBWYtE7biZywwdC2AcEzg+cSMLFRfVgeAFqpfNabiPjxFddV1Q==
|
||||
@@ -1105,7 +1114,7 @@
|
||||
"@types/react-dom" "*"
|
||||
"@types/react-transition-group" "*"
|
||||
|
||||
"@types/react-transition-group@*", "@types/react-transition-group@^4.4.10", "@types/react-transition-group@^4.4.5":
|
||||
"@types/react-transition-group@*", "@types/react-transition-group@^4.4.10":
|
||||
version "4.4.10"
|
||||
resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.10.tgz#6ee71127bdab1f18f11ad8fb3322c6da27c327ac"
|
||||
integrity sha512-hT/+s0VQs2ojCX823m60m5f0sL5idt9SO6Tj6Dg+rdphGPIeJbJ6CxvBYkgkGKrYeDjvIpKTR38UzmtHJOGW3Q==
|
||||
@@ -1684,12 +1693,7 @@ cliui@^8.0.1:
|
||||
strip-ansi "^6.0.1"
|
||||
wrap-ansi "^7.0.0"
|
||||
|
||||
clsx@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12"
|
||||
integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg==
|
||||
|
||||
clsx@^2.1.0:
|
||||
clsx@^2.1.0, clsx@^2.1.1:
|
||||
version "2.1.1"
|
||||
resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999"
|
||||
integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==
|
||||
@@ -1827,7 +1831,7 @@ data-view-byte-offset@^1.0.0:
|
||||
es-errors "^1.3.0"
|
||||
is-data-view "^1.0.1"
|
||||
|
||||
date-fns@^2, date-fns@^2.30.0:
|
||||
date-fns@^2.30.0:
|
||||
version "2.30.0"
|
||||
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.30.0.tgz#f367e644839ff57894ec6ac480de40cae4b0f4d0"
|
||||
integrity sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==
|
||||
@@ -1839,6 +1843,11 @@ dayjs@^1.10.0:
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.10.tgz#68acea85317a6e164457d6d6947564029a6a16a0"
|
||||
integrity sha512-vjAczensTgRcqDERK0SR2XMwsF/tSvnvlv6VcF2GIhg6Sx4yOIt/irsr1RDJsKiIyBzJDpCoXiWWq28MqH2cnQ==
|
||||
|
||||
dayjs@^1.11.12:
|
||||
version "1.11.12"
|
||||
resolved "https://registry.yarnpkg.com/dayjs/-/dayjs-1.11.12.tgz#5245226cc7f40a15bf52e0b99fd2a04669ccac1d"
|
||||
integrity sha512-Rt2g+nTbLlDWZTwwrIXjy9MeiZmSDI375FvZs72ngxx8PDC6YXOeR3q5LAuPzjZQxhiWdRKac7RKV+YyQYfYIg==
|
||||
|
||||
debounce@^1.2.1:
|
||||
version "1.2.1"
|
||||
resolved "https://registry.yarnpkg.com/debounce/-/debounce-1.2.1.tgz#38881d8f4166a5c5848020c11827b834bcb3e0a5"
|
||||
@@ -3895,7 +3904,7 @@ process-nextick-args@~2.0.0:
|
||||
resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2"
|
||||
integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==
|
||||
|
||||
prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.7.2, prop-types@^15.8.1:
|
||||
prop-types@^15.5.8, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.8.1:
|
||||
version "15.8.1"
|
||||
resolved "https://registry.yarnpkg.com/prop-types/-/prop-types-15.8.1.tgz#67d87bf1a694f48435cf332c24af10214a3140b5"
|
||||
integrity sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==
|
||||
@@ -3977,7 +3986,7 @@ react-is@^16.13.1, react-is@^16.7.0:
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4"
|
||||
integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==
|
||||
|
||||
react-is@^18.2.0:
|
||||
react-is@^18.2.0, react-is@^18.3.1:
|
||||
version "18.3.1"
|
||||
resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.3.1.tgz#e83557dc12eae63a99e003a46388b1dcbb44db7e"
|
||||
integrity sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==
|
||||
@@ -4140,11 +4149,6 @@ reusify@^1.0.4:
|
||||
resolved "https://registry.yarnpkg.com/reusify/-/reusify-1.0.4.tgz#90da382b1e126efc02146e90845a88db12925d76"
|
||||
integrity sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==
|
||||
|
||||
rifm@^0.12.1:
|
||||
version "0.12.1"
|
||||
resolved "https://registry.yarnpkg.com/rifm/-/rifm-0.12.1.tgz#8fa77f45b7f1cda2a0068787ac821f0593967ac4"
|
||||
integrity sha512-OGA1Bitg/dSJtI/c4dh90svzaUPt228kzFsUkJbtA2c964IqEAwWXeL9ZJi86xWv3j5SMqRvGULl7bA6cK0Bvg==
|
||||
|
||||
rimraf@^3.0.2:
|
||||
version "3.0.2"
|
||||
resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-3.0.2.tgz#f1a5402ba6220ad52cc1282bac1ae3aa49fd061a"
|
||||
|
||||
Reference in New Issue
Block a user