[web] Use the new date/time persistence format for edits (#2628)
This commit is contained in:
@@ -1,77 +0,0 @@
|
||||
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 { useState } from "react";
|
||||
import {
|
||||
changeFileCreationTime,
|
||||
updateExistingFilePubMetadata,
|
||||
} from "utils/file";
|
||||
import InfoItem from "./InfoItem";
|
||||
|
||||
export function RenderCreationTime({
|
||||
shouldDisableEdits,
|
||||
file,
|
||||
scheduleUpdate,
|
||||
}: {
|
||||
shouldDisableEdits: boolean;
|
||||
file: EnteFile;
|
||||
scheduleUpdate: () => void;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const originalCreationTime = new Date(file?.metadata.creationTime / 1000);
|
||||
const [isInEditMode, setIsInEditMode] = useState(false);
|
||||
|
||||
const openEditMode = () => setIsInEditMode(true);
|
||||
const closeEditMode = () => setIsInEditMode(false);
|
||||
|
||||
const saveEdits = async (pickedTime: ParsedMetadataDate) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (isInEditMode && file) {
|
||||
const unixTimeInMicroSec = pickedTime.timestamp;
|
||||
if (unixTimeInMicroSec === file?.metadata.creationTime) {
|
||||
closeEditMode();
|
||||
return;
|
||||
}
|
||||
const updatedFile = await changeFileCreationTime(
|
||||
file,
|
||||
unixTimeInMicroSec,
|
||||
);
|
||||
updateExistingFilePubMetadata(file, updatedFile);
|
||||
scheduleUpdate();
|
||||
}
|
||||
} catch (e) {
|
||||
log.error("failed to update creationTime", e);
|
||||
} finally {
|
||||
closeEditMode();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlexWrapper>
|
||||
<InfoItem
|
||||
icon={<CalendarTodayIcon />}
|
||||
title={formatDate(originalCreationTime)}
|
||||
caption={formatTime(originalCreationTime)}
|
||||
openEditor={openEditMode}
|
||||
loading={loading}
|
||||
hideEditOption={shouldDisableEdits || isInEditMode}
|
||||
/>
|
||||
{isInEditMode && (
|
||||
<PhotoDateTimePicker
|
||||
initialValue={originalCreationTime}
|
||||
disabled={loading}
|
||||
onAccept={saveEdits}
|
||||
onClose={closeEditMode}
|
||||
/>
|
||||
)}
|
||||
</FlexWrapper>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,16 @@
|
||||
import { EnteDrawer } from "@/base/components/EnteDrawer";
|
||||
import { Titlebar } from "@/base/components/Titlebar";
|
||||
import { nameAndExtension } from "@/base/file";
|
||||
import log from "@/base/log";
|
||||
import type { ParsedMetadata } from "@/media/file-metadata";
|
||||
import {
|
||||
getUICreationDate,
|
||||
updateRemotePublicMagicMetadata,
|
||||
type ParsedMetadataDate,
|
||||
} from "@/media/file-metadata";
|
||||
import { FileType } from "@/media/file-type";
|
||||
import { UnidentifiedFaces } from "@/new/photos/components/PeopleList";
|
||||
import { PhotoDateTimePicker } from "@/new/photos/components/PhotoDateTimePicker";
|
||||
import { photoSwipeZIndex } from "@/new/photos/components/PhotoViewer";
|
||||
import { tagNumericValue, type RawExifTags } from "@/new/photos/services/exif";
|
||||
import { isMLEnabled } from "@/new/photos/services/ml";
|
||||
@@ -12,8 +19,11 @@ import { formattedByteSize } from "@/new/photos/utils/units";
|
||||
import CopyButton from "@ente/shared/components/CodeBlock/CopyButton";
|
||||
import { FlexWrapper } from "@ente/shared/components/Container";
|
||||
import EnteSpinner from "@ente/shared/components/EnteSpinner";
|
||||
import ComlinkCryptoWorker from "@ente/shared/crypto";
|
||||
import { getPublicMagicMetadataMTSync } from "@ente/shared/file-metadata";
|
||||
import { formatDate, formatTime } from "@ente/shared/time/format";
|
||||
import BackupOutlined from "@mui/icons-material/BackupOutlined";
|
||||
import CalendarTodayIcon from "@mui/icons-material/CalendarToday";
|
||||
import CameraOutlined from "@mui/icons-material/CameraOutlined";
|
||||
import FolderOutlined from "@mui/icons-material/FolderOutlined";
|
||||
import LocationOnOutlined from "@mui/icons-material/LocationOnOutlined";
|
||||
@@ -44,7 +54,6 @@ import { FileNameEditDialog } from "./FileNameEditDialog";
|
||||
import InfoItem from "./InfoItem";
|
||||
import MapBox from "./MapBox";
|
||||
import { RenderCaption } from "./RenderCaption";
|
||||
import { RenderCreationTime } from "./RenderCreationTime";
|
||||
|
||||
export interface FileInfoExif {
|
||||
tags: RawExifTags | undefined;
|
||||
@@ -140,8 +149,8 @@ export const FileInfo: React.FC<FileInfoProps> = ({
|
||||
}}
|
||||
/>
|
||||
|
||||
<RenderCreationTime
|
||||
{...{ file, shouldDisableEdits, scheduleUpdate }}
|
||||
<CreationTime
|
||||
{...{ enteFile: file, shouldDisableEdits, scheduleUpdate }}
|
||||
/>
|
||||
|
||||
<RenderFileName
|
||||
@@ -347,6 +356,87 @@ const FileInfoSidebar = styled((props: DialogProps) => (
|
||||
},
|
||||
});
|
||||
|
||||
interface CreationTimeProps {
|
||||
enteFile: EnteFile;
|
||||
shouldDisableEdits: boolean;
|
||||
scheduleUpdate: () => void;
|
||||
}
|
||||
|
||||
export const CreationTime: React.FC<CreationTimeProps> = ({
|
||||
enteFile,
|
||||
shouldDisableEdits,
|
||||
scheduleUpdate,
|
||||
}) => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [isInEditMode, setIsInEditMode] = useState(false);
|
||||
|
||||
const openEditMode = () => setIsInEditMode(true);
|
||||
const closeEditMode = () => setIsInEditMode(false);
|
||||
|
||||
const publicMagicMetadata = getPublicMagicMetadataMTSync(enteFile);
|
||||
const originalDate = getUICreationDate(enteFile, publicMagicMetadata);
|
||||
|
||||
const saveEdits = async (pickedTime: ParsedMetadataDate) => {
|
||||
try {
|
||||
setLoading(true);
|
||||
if (isInEditMode && enteFile) {
|
||||
// Use the updated date time (both in its canonical dateTime
|
||||
// form, and also as the legacy timestamp). But don't use the
|
||||
// offset. The offset here will be the offset of the computer
|
||||
// where this user is making this edit, not the offset of the
|
||||
// place where the photo was taken. In a future iteration of the
|
||||
// date time editor, we can provide functionality for the user
|
||||
// to edit the associated offset, but right now it is not even
|
||||
// surfaced, so don't also potentially overwrite it.
|
||||
const { dateTime, timestamp } = pickedTime;
|
||||
if (timestamp == originalDate.getTime()) {
|
||||
// Same as before.
|
||||
closeEditMode();
|
||||
return;
|
||||
}
|
||||
|
||||
const cryptoWorker = await ComlinkCryptoWorker.getInstance();
|
||||
await updateRemotePublicMagicMetadata(
|
||||
enteFile,
|
||||
{ dateTime, editedTime: timestamp },
|
||||
cryptoWorker.encryptMetadata,
|
||||
cryptoWorker.decryptMetadata,
|
||||
);
|
||||
|
||||
scheduleUpdate();
|
||||
}
|
||||
} catch (e) {
|
||||
log.error("failed to update creationTime", e);
|
||||
} finally {
|
||||
closeEditMode();
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<FlexWrapper>
|
||||
<InfoItem
|
||||
icon={<CalendarTodayIcon />}
|
||||
title={formatDate(originalDate)}
|
||||
caption={formatTime(originalDate)}
|
||||
openEditor={openEditMode}
|
||||
loading={loading}
|
||||
hideEditOption={shouldDisableEdits || isInEditMode}
|
||||
/>
|
||||
{isInEditMode && (
|
||||
<PhotoDateTimePicker
|
||||
initialValue={originalDate}
|
||||
disabled={loading}
|
||||
onAccept={saveEdits}
|
||||
onClose={closeEditMode}
|
||||
/>
|
||||
)}
|
||||
</FlexWrapper>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
interface RenderFileNameProps {
|
||||
file: EnteFile;
|
||||
shouldDisableEdits: boolean;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import { encryptMetadata } from "@/base/crypto/ente";
|
||||
import { encryptMetadata, type decryptMetadata } from "@/base/crypto/ente";
|
||||
import { authenticatedRequestHeaders, ensureOk } from "@/base/http";
|
||||
import { apiURL } from "@/base/origins";
|
||||
import { type EnteFile } from "@/new/photos/types/file";
|
||||
import { mergeMetadata1 } from "@/new/photos/utils/file";
|
||||
import { ensure } from "@/utils/ensure";
|
||||
import { z } from "zod";
|
||||
import { FileType } from "./file-type";
|
||||
|
||||
/**
|
||||
@@ -134,8 +137,42 @@ export enum ItemVisibility {
|
||||
* with whom the file has been shared.
|
||||
*
|
||||
* For more details, see [Note: Metadatum].
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* [Note: Optional magic metadata keys]
|
||||
*
|
||||
* Remote does not support nullish (`undefined` or `null`) values for the keys
|
||||
* in the magic metadata associated with a file. All of the keys themselves are
|
||||
* optional though.
|
||||
*
|
||||
* That is, all magic metadata properties are of the form:
|
||||
*
|
||||
* foo?: T
|
||||
*
|
||||
* And never like:
|
||||
*
|
||||
* foo: T | undefined
|
||||
*
|
||||
* Also see: [Note: Zod doesn't work with `exactOptionalPropertyTypes` yet].
|
||||
*/
|
||||
export interface PublicMagicMetadata {
|
||||
/**
|
||||
* A ISO 8601 date time string without a timezone, indicating the local time
|
||||
* where the photo was taken.
|
||||
*
|
||||
* e.g. "2022-01-26T13:08:20".
|
||||
*
|
||||
* See: [Note: Photos are always in local date/time].
|
||||
*/
|
||||
dateTime?: string;
|
||||
/**
|
||||
* When available, a "±HH:mm" string indicating the UTC offset of the place
|
||||
* where the photo was taken.
|
||||
*
|
||||
* e.g. "+02:00".
|
||||
*/
|
||||
offsetTime?: string;
|
||||
/**
|
||||
* Modified value of the date time associated with an {@link EnteFile}.
|
||||
*
|
||||
@@ -164,6 +201,191 @@ export interface PublicMagicMetadata {
|
||||
h?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zod schema for the {@link PublicMagicMetadata} type.
|
||||
*
|
||||
* See: [Note: Duplicated Zod schema and TypeScript type]
|
||||
*
|
||||
* ---
|
||||
*
|
||||
* [Note: Use passthrough for metadata Zod schemas]
|
||||
*
|
||||
* It is important to (recursively) use the {@link passthrough} option when
|
||||
* definining Zod schemas for the various metadata types (the plaintext JSON
|
||||
* objects) because we want to retain all the fields we get from remote. There
|
||||
* might be other, newer, clients out there adding fields that the current
|
||||
* client might not we aware of, and we don't want to overwrite them.
|
||||
*/
|
||||
const PublicMagicMetadata = z
|
||||
.object({
|
||||
// [Note: Zod doesn't work with `exactOptionalPropertyTypes` yet]
|
||||
//
|
||||
// Using `optional` is accurate here. The key is optional, but the value
|
||||
// itself is not optional. Zod doesn't work with
|
||||
// `exactOptionalPropertyTypes` yet, but it seems to be on the roadmap so we
|
||||
// suppress these mismatches.
|
||||
//
|
||||
// See: https://github.com/colinhacks/zod/issues/635#issuecomment-2196579063
|
||||
editedTime: z.number().optional(),
|
||||
})
|
||||
.passthrough();
|
||||
|
||||
/**
|
||||
* A function that can be used to encrypt the contents of a metadata field
|
||||
* associated with a file.
|
||||
*
|
||||
* This is parameterized to allow us to use either the regular
|
||||
* {@link encryptMetadata} (if we're already running in a web worker) or its web
|
||||
* worker wrapper (if we're running on the main thread).
|
||||
*/
|
||||
export type EncryptMetadataF = typeof encryptMetadata;
|
||||
|
||||
/**
|
||||
* A function that can be used to decrypt the contents of a metadata field
|
||||
* associated with a file.
|
||||
*
|
||||
* This is parameterized to allow us to use either the regular
|
||||
* {@link encryptMetadata} (if we're already running in a web worker) or its web
|
||||
* worker wrapper (if we're running on the main thread).
|
||||
*/
|
||||
export type DecryptMetadataF = typeof decryptMetadata;
|
||||
|
||||
/**
|
||||
* Return the public magic metadata for the given {@link enteFile}.
|
||||
*
|
||||
* The file we persist in our local db has the metadata in the encrypted form
|
||||
* that we get it from remote. We decrypt when we read it, and also hang the
|
||||
* decrypted version to the in-memory {@link EnteFile} as a cache.
|
||||
*
|
||||
* If the file doesn't have any public magic metadata attached to it, return
|
||||
* `undefined`.
|
||||
*/
|
||||
export const decryptPublicMagicMetadata = async (
|
||||
enteFile: EnteFile,
|
||||
decryptMetadataF: DecryptMetadataF,
|
||||
): Promise<PublicMagicMetadata | undefined> => {
|
||||
const envelope = enteFile.pubMagicMetadata;
|
||||
// TODO: The underlying types need auditing.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (!envelope) return undefined;
|
||||
|
||||
// TODO: This function can be optimized to directly return the cached value
|
||||
// instead of reparsing it using Zod. But that requires us (a) first fix the
|
||||
// types, and (b) guarantee that we're the only ones putting that parsed
|
||||
// data there, so that it is in a known good state (currently we exist in
|
||||
// parallel with other functions that do the similar things).
|
||||
|
||||
const jsonValue =
|
||||
typeof envelope.data == "string"
|
||||
? await decryptMetadataF(
|
||||
envelope.data,
|
||||
envelope.header,
|
||||
enteFile.key,
|
||||
)
|
||||
: envelope.data;
|
||||
const result = PublicMagicMetadata.parse(
|
||||
// TODO: Can we avoid this cast?
|
||||
withoutNullAndUndefinedValues(jsonValue as object),
|
||||
);
|
||||
|
||||
// -@ts-expect-error [Note: Zod doesn't work with `exactOptionalPropertyTypes` yet]
|
||||
// We can't use -@ts-expect-error since this code is also included in the
|
||||
// packages which don't have strict mode enabled (and thus don't error).
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error
|
||||
// @ts-ignore
|
||||
envelope.data = result;
|
||||
|
||||
// -@ts-expect-error [Note: Zod doesn't work with `exactOptionalPropertyTypes` yet]
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment, @typescript-eslint/prefer-ts-expect-error
|
||||
// @ts-ignore
|
||||
return result;
|
||||
};
|
||||
|
||||
const withoutNullAndUndefinedValues = (o: object) =>
|
||||
Object.fromEntries(
|
||||
Object.entries(o).filter(([, v]) => v !== null && v !== undefined),
|
||||
);
|
||||
|
||||
/**
|
||||
* Return the file's creation date in a form suitable for using in the UI.
|
||||
*
|
||||
* For all the details and nuance, see {@link toUIDate}.
|
||||
*/
|
||||
export const getUICreationDate = (
|
||||
enteFile: EnteFile,
|
||||
publicMagicMetadata: PublicMagicMetadata,
|
||||
) =>
|
||||
toUIDate(
|
||||
publicMagicMetadata.dateTime ??
|
||||
publicMagicMetadata.editedTime ??
|
||||
enteFile.metadata.creationTime,
|
||||
);
|
||||
|
||||
/**
|
||||
* Update the public magic metadata associated with a file on remote.
|
||||
*
|
||||
* This function updates the public magic metadata on remote, and as a
|
||||
* convenience also modifies the provided {@link EnteFile} object in place with
|
||||
* the updated values, but it does not update the state of the local databases.
|
||||
*
|
||||
* The caller needs to ensure that we subsequently sync with remote to fetch the
|
||||
* updates as part of the diff and update the {@link EnteFile} that is persisted
|
||||
* in our local db.
|
||||
*
|
||||
* @param enteFile The {@link EnteFile} whose public magic metadata we want to
|
||||
* update.
|
||||
*
|
||||
* @param metadataUpdates A subset of {@link PublicMagicMetadata} containing the
|
||||
* fields that we want to add or update.
|
||||
*
|
||||
* @param encryptMetadataF A function that is used to encrypt the updated
|
||||
* metadata.
|
||||
*
|
||||
* @param decryptMetadataF A function that is used to decrypt the existing
|
||||
* metadata.
|
||||
*/
|
||||
export const updateRemotePublicMagicMetadata = async (
|
||||
enteFile: EnteFile,
|
||||
metadataUpdates: Partial<PublicMagicMetadata>,
|
||||
encryptMetadataF: EncryptMetadataF,
|
||||
decryptMetadataF: DecryptMetadataF,
|
||||
) => {
|
||||
const existingMetadata = await decryptPublicMagicMetadata(
|
||||
enteFile,
|
||||
decryptMetadataF,
|
||||
);
|
||||
|
||||
const updatedMetadata = { ...(existingMetadata ?? {}), ...metadataUpdates };
|
||||
|
||||
// The underlying types of enteFile.pubMagicMetadata are incorrect
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
const metadataVersion = enteFile.pubMagicMetadata?.version ?? 1;
|
||||
|
||||
const updateRequest = await updateMagicMetadataRequest(
|
||||
enteFile,
|
||||
updatedMetadata,
|
||||
metadataVersion,
|
||||
encryptMetadataF,
|
||||
);
|
||||
|
||||
const updatedEnvelope = ensure(updateRequest.metadataList[0]).magicMetadata;
|
||||
|
||||
await putFilesPublicMagicMetadata(updateRequest);
|
||||
|
||||
// Modify the in-memory object to use the updated envelope. This steps are
|
||||
// quite ad-hoc, as is the concept of updating the object in place.
|
||||
enteFile.pubMagicMetadata =
|
||||
updatedEnvelope as typeof enteFile.pubMagicMetadata;
|
||||
// The correct version will come in the updated EnteFile we get in the
|
||||
// response of the /diff. Temporarily bump it for the in place edits.
|
||||
enteFile.pubMagicMetadata.version = enteFile.pubMagicMetadata.version + 1;
|
||||
// Re-read the data.
|
||||
await decryptPublicMagicMetadata(enteFile, decryptMetadataF);
|
||||
// Re-jig the other bits of EnteFile that depend on its public magic
|
||||
// metadata.
|
||||
mergeMetadata1(enteFile);
|
||||
};
|
||||
|
||||
/**
|
||||
* Magic metadata, either public and private, as persisted and used by remote.
|
||||
*
|
||||
@@ -178,8 +400,8 @@ interface RemoteMagicMetadata {
|
||||
/**
|
||||
* Monotonically increasing iteration of this metadata object.
|
||||
*
|
||||
* The version starts at 1. Each time a client updates the underlying magic
|
||||
* metadata JSONs for a file, it increments this version number.
|
||||
* The version starts at 1. Remote increments this version number each time
|
||||
* a client updates the corresponding magic metadata field for the file.
|
||||
*/
|
||||
version: number;
|
||||
/**
|
||||
@@ -221,27 +443,19 @@ interface UpdateMagicMetadataRequest {
|
||||
}[];
|
||||
}
|
||||
|
||||
/**
|
||||
* A function that can be used to encrypt the contents of a metadata field
|
||||
* associated with a file.
|
||||
*
|
||||
* This is parameterized to allow us to use either the regular
|
||||
* {@link encryptMetadata} or the web worker wrapper for it.
|
||||
*/
|
||||
export type EncryptMetadataF = typeof encryptMetadata;
|
||||
|
||||
/**
|
||||
* Construct an remote update request payload from the public or private magic
|
||||
* metadata JSON object for an {@link enteFile}, using the provided
|
||||
* {@link encryptMetadataF} function to encrypt the JSON.
|
||||
*/
|
||||
export const updateMagicMetadataRequest = async (
|
||||
const updateMagicMetadataRequest = async (
|
||||
enteFile: EnteFile,
|
||||
metadata: PrivateMagicMetadata | PublicMagicMetadata,
|
||||
metadataVersion: number,
|
||||
encryptMetadataF: EncryptMetadataF,
|
||||
): Promise<UpdateMagicMetadataRequest> => {
|
||||
// Drop all null or undefined values to obtain the syncable entries.
|
||||
// See: [Note: Optional magic metadata keys].
|
||||
const validEntries = Object.entries(metadata).filter(
|
||||
([, v]) => v !== null && v !== undefined,
|
||||
);
|
||||
@@ -272,6 +486,7 @@ export const updateMagicMetadataRequest = async (
|
||||
* @param request The list of file ids and the updated encrypted magic metadata
|
||||
* associated with each of them.
|
||||
*/
|
||||
// TODO: Remove export once this is used.
|
||||
export const putFilesMagicMetadata = async (
|
||||
request: UpdateMagicMetadataRequest,
|
||||
) =>
|
||||
@@ -289,7 +504,7 @@ export const putFilesMagicMetadata = async (
|
||||
* @param request The list of file ids and the updated encrypted magic metadata
|
||||
* associated with each of them.
|
||||
*/
|
||||
export const putFilesPublicMagicMetadata = async (
|
||||
const putFilesPublicMagicMetadata = async (
|
||||
request: UpdateMagicMetadataRequest,
|
||||
) =>
|
||||
ensureOk(
|
||||
@@ -400,13 +615,13 @@ export interface ParsedMetadataDate {
|
||||
* This is an optional UTC offset string of the form "±HH:mm" or "Z",
|
||||
* specifying the timezone offset for {@link dateTime} when available.
|
||||
*/
|
||||
offsetTime: string | undefined;
|
||||
offset: string | undefined;
|
||||
/**
|
||||
* UTC epoch microseconds derived from {@link dateTime} and
|
||||
* {@link offsetTime}.
|
||||
* {@link offset}.
|
||||
*
|
||||
* When the {@link offsetTime} is present, this will accurately reflect a
|
||||
* UTC timestamp. When the {@link offsetTime} is not present it convert to a
|
||||
* When the {@link offset} is present, this will accurately reflect a
|
||||
* UTC timestamp. When the {@link offset} is not present it convert to a
|
||||
* UTC timestamp by assuming that the given {@link dateTime} is in the local
|
||||
* time where this code is running. This is a good assumption but not always
|
||||
* correct (e.g. vacation photos).
|
||||
@@ -451,7 +666,7 @@ export const parseMetadataDate = (
|
||||
// Now we try to massage s into two parts - the local date/time string, and
|
||||
// an UTC offset string.
|
||||
|
||||
let offsetTime: string | undefined;
|
||||
let offset: string | undefined;
|
||||
let sWithoutOffset: string;
|
||||
|
||||
// Check to see if there is a time-zone descriptor of the form "Z" or
|
||||
@@ -459,7 +674,7 @@ export const parseMetadataDate = (
|
||||
const m = s.match(/Z|[+-]\d\d:?\d\d$/);
|
||||
if (m?.index) {
|
||||
sWithoutOffset = s.substring(0, m.index);
|
||||
offsetTime = s.substring(m.index);
|
||||
offset = s.substring(m.index);
|
||||
} else {
|
||||
sWithoutOffset = s;
|
||||
}
|
||||
@@ -499,7 +714,44 @@ export const parseMetadataDate = (
|
||||
// any time zone descriptor.
|
||||
const dateTime = dropLast(date.toISOString());
|
||||
|
||||
return { dateTime, offsetTime, timestamp };
|
||||
return { dateTime, offset, timestamp };
|
||||
};
|
||||
|
||||
const dropLast = (s: string) => (s ? s.substring(0, s.length - 1) : s);
|
||||
|
||||
/**
|
||||
* Return a date that can be used on the UI from a {@link ParsedMetadataDate},
|
||||
* or its {@link dateTime} component, or the legacy epoch timestamps.
|
||||
*
|
||||
* These dates are all hypothetically in the timezone of the place where the
|
||||
* photo was taken. Different photos might've been taken in different timezones,
|
||||
* which is why it is hypothetical, so concretely these are all mapped to the
|
||||
* current timezone.
|
||||
*
|
||||
* The difference is subtle, but we should not think of these as absolute points
|
||||
* on the UTC timeline. They are instead better thought of as dates without an
|
||||
* associated timezone. For the purpose of mapping them all to a comparable
|
||||
* dimension them we all contingently use the current timezone - this makes it
|
||||
* easy to use JavaScript Date constructor which assumes that any date/time
|
||||
* string without an associated timezone is in the current timezone.
|
||||
*
|
||||
* Whenever we're surfacing them in the UI, or using them for grouping (say by
|
||||
* day), we should use their current timezone representation, not the UTC one.
|
||||
*
|
||||
* See also: [Note: Photos are always in local date/time].
|
||||
*/
|
||||
export const toUIDate = (dateLike: ParsedMetadataDate | string | number) => {
|
||||
switch (typeof dateLike) {
|
||||
case "object":
|
||||
// A ISO 8601 string without a timezone. The Date constructor will
|
||||
// assume the timezone to be the current timezone.
|
||||
return new Date(dateLike.dateTime);
|
||||
case "string":
|
||||
// This is expected to be a string with the same meaning as
|
||||
// `ParsedMetadataDate.dateTime`.
|
||||
return new Date(dateLike);
|
||||
case "number":
|
||||
// A legacy epoch microseconds value.
|
||||
return new Date(dateLike / 1000);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -147,14 +147,14 @@ const parseMetadataDateFromDayjs = (d: Dayjs): ParsedMetadataDate => {
|
||||
const s = d.format();
|
||||
|
||||
let dateTime: string;
|
||||
let offsetTime: string | undefined;
|
||||
let offset: 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);
|
||||
offset = s.substring(m.index);
|
||||
} else {
|
||||
throw new Error(
|
||||
`Dayjs.format returned a string "${s}" without a timezone offset`,
|
||||
@@ -163,5 +163,5 @@ const parseMetadataDateFromDayjs = (d: Dayjs): ParsedMetadataDate => {
|
||||
|
||||
const timestamp = d.valueOf() * 1000;
|
||||
|
||||
return { dateTime, offsetTime, timestamp };
|
||||
return { dateTime, offset, timestamp };
|
||||
};
|
||||
|
||||
@@ -46,8 +46,18 @@ export interface EnteFile
|
||||
> {
|
||||
metadata: Metadata;
|
||||
magicMetadata: FileMagicMetadata;
|
||||
/**
|
||||
* The envelope containing the public magic metadata associated with this
|
||||
* file.
|
||||
*/
|
||||
pubMagicMetadata: FilePublicMagicMetadata;
|
||||
isTrashed?: boolean;
|
||||
/**
|
||||
* The base64 encoded encryption key associated with this file.
|
||||
*
|
||||
* This key is used to encrypt both the file's contents, and any associated
|
||||
* data (e.g., metadatum, thumbnail) for the file.
|
||||
*/
|
||||
key: string;
|
||||
src?: string;
|
||||
srcURLs?: SourceURLs;
|
||||
|
||||
@@ -39,20 +39,22 @@ export const fileLogID = (enteFile: EnteFile) =>
|
||||
* its filename.
|
||||
*/
|
||||
export function mergeMetadata(files: EnteFile[]): EnteFile[] {
|
||||
return files.map((file) => {
|
||||
// TODO: Until the types reflect reality
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (file.pubMagicMetadata?.data.editedTime) {
|
||||
file.metadata.creationTime = file.pubMagicMetadata.data.editedTime;
|
||||
}
|
||||
// TODO: Until the types reflect reality
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (file.pubMagicMetadata?.data.editedName) {
|
||||
file.metadata.title = file.pubMagicMetadata.data.editedName;
|
||||
}
|
||||
return files.map((file) => mergeMetadata1(file));
|
||||
}
|
||||
|
||||
return file;
|
||||
});
|
||||
export function mergeMetadata1(file: EnteFile): EnteFile {
|
||||
// TODO: Until the types reflect reality
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (file.pubMagicMetadata?.data.editedTime) {
|
||||
file.metadata.creationTime = file.pubMagicMetadata.data.editedTime;
|
||||
}
|
||||
// TODO: Until the types reflect reality
|
||||
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
|
||||
if (file.pubMagicMetadata?.data.editedName) {
|
||||
file.metadata.title = file.pubMagicMetadata.data.editedName;
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -8,12 +8,16 @@ import type { StateAddress } from "libsodium-wrappers";
|
||||
* specific layer (base/crypto/ente.ts) or the internal libsodium layer
|
||||
* (internal/libsodium.ts).
|
||||
*
|
||||
* Running these in a web worker allows us to use potentially CPU-intensive
|
||||
* crypto operations from the main thread without stalling the UI.
|
||||
* Use these when running on the main thread, since running these in a web
|
||||
* worker allows us to use potentially CPU-intensive crypto operations from the
|
||||
* main thread without stalling the UI.
|
||||
*
|
||||
* If the code that needs this functionality is already running in the context
|
||||
* of a web worker, then use the underlying functions directly.
|
||||
*
|
||||
* See: [Note: Crypto code hierarchy].
|
||||
*
|
||||
* Note: Keep these methods logic free. They should just act as trivial proxies.
|
||||
* Note: Keep these methods logic free. They are meant to be trivial proxies.
|
||||
*/
|
||||
export class DedicatedCryptoWorker {
|
||||
async decryptThumbnail(
|
||||
|
||||
48
web/packages/shared/file-metadata.ts
Normal file
48
web/packages/shared/file-metadata.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { decryptMetadata } from "@/base/crypto/ente";
|
||||
import { isDevBuild } from "@/base/env";
|
||||
import {
|
||||
decryptPublicMagicMetadata,
|
||||
type PublicMagicMetadata,
|
||||
} from "@/media/file-metadata";
|
||||
import { EnteFile } from "@/new/photos/types/file";
|
||||
import { fileLogID } from "@/new/photos/utils/file";
|
||||
import ComlinkCryptoWorker from "@ente/shared/crypto";
|
||||
|
||||
/**
|
||||
* On-demand decrypt the public magic metadata for an {@link EnteFile} for code
|
||||
* running on the main thread.
|
||||
*
|
||||
* It both modifies the given file object, and also returns the decrypted
|
||||
* metadata.
|
||||
*/
|
||||
export const getPublicMagicMetadataMT = async (enteFile: EnteFile) =>
|
||||
decryptPublicMagicMetadata(
|
||||
enteFile,
|
||||
(await ComlinkCryptoWorker.getInstance()).decryptMetadata,
|
||||
);
|
||||
|
||||
/**
|
||||
* On-demand decrypt the public magic metadata for an {@link EnteFile} for code
|
||||
* running on the main thread, but do it synchronously.
|
||||
*
|
||||
* It both modifies the given file object, and also returns the decrypted
|
||||
* metadata.
|
||||
*
|
||||
* We are not expected to be in a scenario where the file gets to the UI without
|
||||
* having its public magic metadata decrypted, so this function is a sanity
|
||||
* check and should be a no-op in usually. On debug builds it'll throw if it
|
||||
* finds its assumptions broken.
|
||||
*/
|
||||
export const getPublicMagicMetadataMTSync = (enteFile: EnteFile) => {
|
||||
if (!enteFile.pubMagicMetadata) return undefined;
|
||||
if (typeof enteFile.pubMagicMetadata.data == "string") {
|
||||
if (isDevBuild)
|
||||
throw new Error(
|
||||
`Public magic metadata for ${fileLogID(enteFile)} had not been decrypted even when the file reached the UI layer`,
|
||||
);
|
||||
decryptPublicMagicMetadata(enteFile, decryptMetadata);
|
||||
}
|
||||
// This cast is unavoidable in the current setup. We need to refactor the
|
||||
// types so that this cast in not needed.
|
||||
return enteFile.pubMagicMetadata.data as PublicMagicMetadata;
|
||||
};
|
||||
Reference in New Issue
Block a user