Photo: save path, is-local, content bytes, metadata and EXIF getters (closes #141) #142
@@ -722,8 +722,9 @@ Four async methods may download:
|
||||
UTC. Only a JPEG's EXIF is read: any other original gives `{}`, and a video
|
||||
gives `{}` without being downloaded.
|
||||
|
||||
They serve from the on-disk content cache, or the backup, when the bytes are
|
||||
present and otherwise fetch through the pools; `opts.onProgress` reports
|
||||
They serve from the on-disk content cache when the bytes are present and
|
||||
otherwise fetch through the pools; `original()`, `content()` and `exif()` also
|
||||
serve an original the backup has already stored. `opts.onProgress` reports
|
||||
per-file progress. They throw when the library was opened without a content
|
||||
source. An original that `content()` or `exif()` downloads lands in the cache,
|
||||
which does not make `isLocal` true; only `lib.backup()` does.
|
||||
@@ -741,8 +742,7 @@ The GUI-facing records hold no key material and no binary, so they survive
|
||||
- `PhotoRecord`: `fileID`, `albumIDs`, `title`, `takenAt` and `modifiedAt`
|
||||
(milliseconds), `fileType`, optional `caption` / `width` / `height` /
|
||||
`latitude` / `longitude`, optional `hash` (the content hash recorded at
|
||||
upload; very old files have none) and `fileSize` (the original's size in
|
||||
bytes, as the server reports it), `isArchived`, `isHidden`, and
|
||||
upload; very old files have none), `isArchived`, `isHidden`, and
|
||||
`thumbnailPath` / `originalPath` once the bytes are cached (for a live photo,
|
||||
`originalPath` is its image).
|
||||
- `AlbumRecord`: `collectionID`, `name`, `type`, `isShared`, `updationTime`, and
|
||||
|
||||
@@ -26,13 +26,13 @@ declares one.
|
||||
# Completed Steps
|
||||
|
||||
- 2026-10-01: A `Photo` has `savePath`, `isLocal`, `content()`, `exif()`,
|
||||
`modifiedAt`, `hash`, `fileSize` and `year` (issue 141). `savePath` is where
|
||||
`modifiedAt`, `hash` and `year` (issue 141). `savePath` is where
|
||||
`lib.backup()` writes the original under the library's download directory, and
|
||||
`isLocal` says whether the whole original is there; both look only at the
|
||||
disk. `content()` returns the original's bytes and `exif()` the common EXIF
|
||||
fields of a JPEG; both may download the original, and `exif()` downloads no
|
||||
video. `PhotoRecord` gains `modifiedAt`, `hash` and `fileSize`, and the JPEG
|
||||
EXIF scan moved to `src/exif.ts`.
|
||||
video. `PhotoRecord` gains `modifiedAt` and `hash`, and the JPEG EXIF scan
|
||||
moved to `src/exif.ts`.
|
||||
|
||||
- 2026-09-29: The "Workflow" list at the top of this file now says to branch
|
||||
from `next` and open a pull request that targets `next`, that the repository
|
||||
|
||||
+3
-4
@@ -82,9 +82,6 @@ export class Photo {
|
||||
get hash(): string | undefined {
|
||||
return this.rec.hash;
|
||||
}
|
||||
get fileSize(): number | undefined {
|
||||
return this.rec.fileSize;
|
||||
}
|
||||
get isArchived(): boolean {
|
||||
return this.rec.isArchived;
|
||||
}
|
||||
@@ -132,8 +129,10 @@ export class Photo {
|
||||
|
||||
// The common EXIF fields of the original, read from `content()`, so this
|
||||
// may download it. Only a JPEG's EXIF is read; any other file gives `{}`,
|
||||
// and a video gives it without fetching anything.
|
||||
// and a video gives it without fetching anything. Like the other content
|
||||
// methods, it throws when there is no content cache, video or not.
|
||||
async exif(opts?: ContentOptions): Promise<PhotoExif> {
|
||||
this.cacheOrThrow();
|
||||
if (this.rec.fileType === "video") return {};
|
||||
return readPhotoExif(await this.content(opts));
|
||||
}
|
||||
|
||||
@@ -45,8 +45,6 @@ export interface PhotoRecord {
|
||||
// The content hash the uploader recorded (`FileMetadata.hash`); files from
|
||||
// very old clients have none.
|
||||
hash?: string;
|
||||
// The original's size in bytes, as the server reports it.
|
||||
fileSize?: number;
|
||||
isArchived: boolean;
|
||||
isHidden: boolean;
|
||||
// Local cache paths, set once a later phase caches the bytes; unset here.
|
||||
@@ -150,7 +148,6 @@ const toPhotoRecord = (
|
||||
if (rep.metadata.longitude !== undefined)
|
||||
record.longitude = rep.metadata.longitude;
|
||||
if (rep.metadata.hash !== undefined) record.hash = rep.metadata.hash;
|
||||
if (rep.file.size !== undefined) record.fileSize = rep.file.size;
|
||||
|
||||
return record;
|
||||
};
|
||||
|
||||
@@ -296,6 +296,16 @@ const JPEG_WITH_EXIF = new Uint8Array([
|
||||
...[0xff, 0xda, 0x00, 0x02], // start of scan
|
||||
]);
|
||||
|
||||
// A JPEG whose EXIF segment is laid out correctly but holds "XX" where the TIFF
|
||||
// byte order belongs, so exif-reader cannot parse it.
|
||||
const JPEG_WITH_BAD_EXIF = new Uint8Array([
|
||||
...[0xff, 0xd8], // start of image
|
||||
...[0xff, 0xe1, ...u16(2 + 6 + 2)], // APP1 and its length
|
||||
...[...ascii("Exif"), 0], // "Exif\0\0"
|
||||
...[0x58, 0x58], // "XX"
|
||||
...[0xff, 0xda, 0x00, 0x02], // start of scan
|
||||
]);
|
||||
|
||||
describe("Photo save path, local copy, content and EXIF", () => {
|
||||
// The same account, with `files` in its album instead.
|
||||
class FilesClient extends MockClient {
|
||||
@@ -407,6 +417,27 @@ describe("Photo save path, local copy, content and EXIF", () => {
|
||||
await lib.close();
|
||||
});
|
||||
|
||||
it("returns no EXIF fields for a JPEG whose EXIF cannot be parsed", async () => {
|
||||
const lib = await open({
|
||||
contentSource: stubSource(JPEG_WITH_BAD_EXIF),
|
||||
});
|
||||
expect(await lib.photos.byID({ fileID: 1 })!.exif()).toStrictEqual({});
|
||||
await lib.close();
|
||||
});
|
||||
|
||||
it("throws from exif() on a video without a content source, as the other content methods do", async () => {
|
||||
const video = file(1, 1);
|
||||
video.metadata.fileType = "video";
|
||||
const lib = await open({
|
||||
client: new FilesClient([video]),
|
||||
contentSource: undefined,
|
||||
});
|
||||
await expect(lib.photos.byID({ fileID: 1 })!.exif()).rejects.toThrow(
|
||||
/content cache/i,
|
||||
);
|
||||
await lib.close();
|
||||
});
|
||||
|
||||
it("returns no EXIF fields for a video, without fetching it", async () => {
|
||||
const video = file(1, 1);
|
||||
video.metadata.fileType = "video";
|
||||
|
||||
@@ -209,7 +209,7 @@ describe("lib.photos", () => {
|
||||
expect("key" in photo.record()).toBe(false);
|
||||
});
|
||||
|
||||
it("byID exposes modifiedAt, hash, fileSize and the year taken", () => {
|
||||
it("byID exposes modifiedAt, hash and the year taken", () => {
|
||||
// Mid-July, so the year is 2021 in every time zone.
|
||||
const takenAt = Date.UTC(2021, 6, 15, 12);
|
||||
const records = deriveRecords(
|
||||
@@ -223,14 +223,12 @@ describe("lib.photos", () => {
|
||||
modificationTime: micros(1_700_000_123_456),
|
||||
hash: "aGFzaA==",
|
||||
},
|
||||
file: { decryptionHeader: "aGVhZGVy", size: 2_048_000 },
|
||||
}),
|
||||
],
|
||||
);
|
||||
const photo = apis(records).photos.byID({ fileID: 1001 })!;
|
||||
expect(photo.modifiedAt).toBe(ms(1_700_000_123_456));
|
||||
expect(photo.hash).toBe("aGFzaA==");
|
||||
expect(photo.fileSize).toBe(2_048_000);
|
||||
expect(photo.year).toBe(2021);
|
||||
});
|
||||
|
||||
|
||||
@@ -160,7 +160,6 @@ describe("deriveRecords: photo mapping", () => {
|
||||
expect("height" in rec).toBe(false);
|
||||
expect("latitude" in rec).toBe(false);
|
||||
expect("hash" in rec).toBe(false);
|
||||
expect("fileSize" in rec).toBe(false);
|
||||
});
|
||||
|
||||
it("reads archived and hidden from private magicMetadata.visibility", () => {
|
||||
|
||||
Reference in New Issue
Block a user