From eefc69731c4eca6061db96a2cb88eaae562fc5b9 Mon Sep 17 00:00:00 2001 From: zetavg Date: Fri, 8 Dec 2023 23:46:41 +0800 Subject: [PATCH] support uploading images from Airtable --- App/app/data/images/getImageDatum.ts | 62 ++++++ App/app/data/images/processAssets.ts | 180 ++++++++++++++++++ App/app/data/images/useImageSelector.ts | 179 +---------------- .../screens/AirtableIntegrationScreen.tsx | 60 ++++++ .../inventory/components/EditImagesUI.tsx | 56 +----- .../integration-airtable/lib/conversions.ts | 36 ++-- .../lib/syncWithAirtable.ts | 18 +- 7 files changed, 355 insertions(+), 236 deletions(-) create mode 100644 App/app/data/images/getImageDatum.ts create mode 100644 App/app/data/images/processAssets.ts diff --git a/App/app/data/images/getImageDatum.ts b/App/app/data/images/getImageDatum.ts new file mode 100644 index 00000000..120d727c --- /dev/null +++ b/App/app/data/images/getImageDatum.ts @@ -0,0 +1,62 @@ +import { AttachAttachmentToDatum, GetData, SaveDatum } from '../types'; +import { onlyValid } from '../utils'; + +import processAssets from './processAssets'; + +export default async function getImageDatum( + image: Awaited>[number], + { + getData, + attachAttachmentToDatum, + saveDatum, + }: { + getData: GetData; + attachAttachmentToDatum: AttachAttachmentToDatum; + saveDatum: SaveDatum; + }, +) { + const image1440Digest = image.image1440.digest; + if (image1440Digest) { + // Reuse already-saved image if possible + const existingImage = await getData( + 'image', + { + image_1440_digest: image1440Digest, + }, + { limit: 1 }, + ); + + const validExistingImage = onlyValid(existingImage); + if (validExistingImage.length >= 1) { + return validExistingImage[0]; + } + } + + const imageToSave = { + __type: 'image', + filename: image.fileName, + } as const; + + await attachAttachmentToDatum( + imageToSave, + 'thumbnail-128', + image.thumbnail128.contentType, + image.thumbnail128.data, + ); + // await attachAttachmentToDatum( + // imageToSave, + // 'thumbnail-1024', + // itemImageDataItem.thumbnail1024.contentType, + // itemImageDataItem.thumbnail1024.data, + // ); + await attachAttachmentToDatum( + imageToSave, + 'image-1440', + image.image1440.contentType, + image.image1440.data, + ); + + return await saveDatum(imageToSave, { + ignoreConflict: true, + }); +} diff --git a/App/app/data/images/processAssets.ts b/App/app/data/images/processAssets.ts new file mode 100644 index 00000000..3cd3ebc8 --- /dev/null +++ b/App/app/data/images/processAssets.ts @@ -0,0 +1,180 @@ +import { Image } from 'react-native'; +import RNFS from 'react-native-fs'; +import ImageResizer from 'react-native-image-resizer'; +import crypto from 'react-native-quick-crypto'; + +import ImageEditor from '@react-native-community/image-editor'; + +export type ImageAsset = { + base64?: string; + uri?: string; + width?: number; + height?: number; + fileSize?: number; + type?: string; + fileName?: string; + duration?: number; + bitrate?: number; + timestamp?: string; + id?: string; +}; + +export type ImageD = { + contentType: 'image/jpeg' | 'image/png'; + data: string; + digest?: string; +}; + +export async function processAssets(assets: ReadonlyArray) { + const imageData = []; + for (const asset of assets) { + if (!asset) continue; + const assetUri = asset.uri; + if (!assetUri) continue; + + const fileName = asset.fileName; + const isPng = + asset.type === 'image/png' || assetUri.toLowerCase().endsWith('.png'); + const isJpg = + asset.type === 'image/jpeg' || + assetUri.toLowerCase().endsWith('.jpg') || + assetUri.toLowerCase().endsWith('.jpeg'); + + const imageDimensions = await new Promise<{ + width: number; + height: number; + }>((resolve, reject) => { + Image.getSize( + assetUri, + (width, height) => { + resolve({ width, height }); + }, + reject, + ); + }); + + const imageMinDimension = Math.min( + imageDimensions.width, + imageDimensions.height, + ); + const imageMaxDimension = Math.max( + imageDimensions.width, + imageDimensions.height, + ); + + const [thumbnail128ContentType, thumbnail128Uri] = await (async () => { + // Do not crop small PNG files since they might be a transparent-background icon + if (isPng && imageMaxDimension <= 1024) { + const { uri } = await ImageResizer.createResizedImage( + assetUri, + 128, + 128, + 'PNG', + 80, // quality + 0, + undefined, + false, + { mode: 'contain', onlyScaleDown: true }, + ); + + return ['image/png' as const, uri]; + } + + const croppedImageUri = await ImageEditor.cropImage(assetUri, { + size: { width: imageMinDimension, height: imageMinDimension }, + offset: { + x: + imageDimensions.width === imageMinDimension + ? 0 + : (imageDimensions.width - imageMinDimension) / 2, + y: + imageDimensions.height === imageMinDimension + ? 0 + : (imageDimensions.height - imageMinDimension) / 2, + }, + }); + + const { uri } = await ImageResizer.createResizedImage( + croppedImageUri, + 128, + 128, + 'JPEG', + 80, // quality + 0, + undefined, + false, + { mode: 'contain', onlyScaleDown: true }, + ); + + return ['image/jpeg' as const, uri]; + })(); + const thumbnail128: ImageD = { + contentType: thumbnail128ContentType, + data: await base64FromFile(thumbnail128Uri), + }; + + // const { uri: thumbnail1024Uri } = await ImageResizer.createResizedImage( + // croppedImageUri || assetUri, + // 1024, + // 1024, + // isPng ? 'PNG' : 'JPEG', + // 80, // quality + // 0, + // undefined, + // false, + // { mode: 'contain', onlyScaleDown: true }, + // ); + // const thumbnail1024: ImageD = { + // contentType: isPng ? 'image/png' : 'image/jpeg', + // data: await base64FromFile(thumbnail1024Uri), + // }; + + const [image1440ContentType, image1440Uri] = await (async () => { + if ((isJpg || isPng) && imageMaxDimension <= 1024) { + return [ + isPng ? ('image/png' as const) : ('image/jpeg' as const), + assetUri, + ]; + } + + const { uri } = await ImageResizer.createResizedImage( + assetUri, + 1440, + 1440, + 'JPEG', + 80, // quality + 0, + undefined, + false, + { mode: 'contain', onlyScaleDown: true }, + ); + + return ['image/jpeg' as const, uri]; + })(); + const image1440: ImageD = { + contentType: image1440ContentType, + data: await base64FromFile(image1440Uri), + }; + image1440.digest = + 'md5-' + + crypto + .createHash('md5') + .update(image1440.data, 'base64') + .digest('base64'); + + imageData.push({ + fileName, + thumbnail128, + // thumbnail1024, + image1440, + }); + } + + return imageData; +} + +async function base64FromFile(uri: string) { + return await RNFS.readFile(decodeURIComponent(uri), 'base64'); +} + +export default processAssets; diff --git a/App/app/data/images/useImageSelector.ts b/App/app/data/images/useImageSelector.ts index 10e234c0..de7abb29 100644 --- a/App/app/data/images/useImageSelector.ts +++ b/App/app/data/images/useImageSelector.ts @@ -1,43 +1,20 @@ import { useCallback } from 'react'; -import { Alert, Image, Platform } from 'react-native'; +import { Alert, Platform } from 'react-native'; import DocumentPicker from 'react-native-document-picker'; -import RNFS from 'react-native-fs'; import { launchCamera, launchImageLibrary } from 'react-native-image-picker'; -import ImageResizer from 'react-native-image-resizer'; -import crypto from 'react-native-quick-crypto'; - -import ImageEditor from '@react-native-community/image-editor'; import humanFileSize from '@app/utils/humanFileSize'; import useActionSheet from '@app/hooks/useActionSheet'; +import processAssets, { ImageAsset, ImageD } from './processAssets'; + type Options = { selectionLimit?: number; onUserSelectStart?: () => void; onUserSelected?: () => void; }; -export type ImageAsset = { - base64?: string; - uri?: string; - width?: number; - height?: number; - fileSize?: number; - type?: string; - fileName?: string; - duration?: number; - bitrate?: number; - timestamp?: string; - id?: string; -}; - -type ImageD = { - contentType: 'image/jpeg' | 'image/png'; - data: string; - digest?: string; -}; - export type ImageData = { fileName?: string; thumbnail128: ImageD; @@ -243,153 +220,3 @@ export default function useImageSelector() { return selectImage; } - -async function processAssets(assets: ReadonlyArray) { - const imageData = []; - for (const asset of assets) { - if (!asset) continue; - const assetUri = asset.uri; - if (!assetUri) continue; - - const fileName = asset.fileName; - const isPng = assetUri.toLowerCase().endsWith('.png'); - const isJpg = - assetUri.toLowerCase().endsWith('.jpg') || - assetUri.toLowerCase().endsWith('.jpeg'); - - const imageDimensions = await new Promise<{ - width: number; - height: number; - }>((resolve, reject) => { - Image.getSize( - assetUri, - (width, height) => { - resolve({ width, height }); - }, - reject, - ); - }); - - const imageMinDimension = Math.min( - imageDimensions.width, - imageDimensions.height, - ); - const imageMaxDimension = Math.max( - imageDimensions.width, - imageDimensions.height, - ); - - const [thumbnail128ContentType, thumbnail128Uri] = await (async () => { - // Do not crop small PNG files since they might be a transparent-background icon - if (isPng && imageMaxDimension <= 1024) { - const { uri } = await ImageResizer.createResizedImage( - assetUri, - 128, - 128, - 'PNG', - 80, // quality - 0, - undefined, - false, - { mode: 'contain', onlyScaleDown: true }, - ); - - return ['image/png' as const, uri]; - } - - const croppedImageUri = await ImageEditor.cropImage(assetUri, { - size: { width: imageMinDimension, height: imageMinDimension }, - offset: { - x: - imageDimensions.width === imageMinDimension - ? 0 - : (imageDimensions.width - imageMinDimension) / 2, - y: - imageDimensions.height === imageMinDimension - ? 0 - : (imageDimensions.height - imageMinDimension) / 2, - }, - }); - - const { uri } = await ImageResizer.createResizedImage( - croppedImageUri, - 128, - 128, - 'JPEG', - 80, // quality - 0, - undefined, - false, - { mode: 'contain', onlyScaleDown: true }, - ); - - return ['image/jpeg' as const, uri]; - })(); - const thumbnail128: ImageD = { - contentType: thumbnail128ContentType, - data: await base64FromFile(thumbnail128Uri), - }; - - // const { uri: thumbnail1024Uri } = await ImageResizer.createResizedImage( - // croppedImageUri || assetUri, - // 1024, - // 1024, - // isPng ? 'PNG' : 'JPEG', - // 80, // quality - // 0, - // undefined, - // false, - // { mode: 'contain', onlyScaleDown: true }, - // ); - // const thumbnail1024: ImageD = { - // contentType: isPng ? 'image/png' : 'image/jpeg', - // data: await base64FromFile(thumbnail1024Uri), - // }; - - const [image1440ContentType, image1440Uri] = await (async () => { - if ((isJpg || isPng) && imageMaxDimension <= 1024) { - return [ - isPng ? ('image/png' as const) : ('image/jpeg' as const), - assetUri, - ]; - } - - const { uri } = await ImageResizer.createResizedImage( - assetUri, - 1440, - 1440, - 'JPEG', - 80, // quality - 0, - undefined, - false, - { mode: 'contain', onlyScaleDown: true }, - ); - - return ['image/jpeg' as const, uri]; - })(); - const image1440: ImageD = { - contentType: image1440ContentType, - data: await base64FromFile(image1440Uri), - }; - image1440.digest = - 'md5-' + - crypto - .createHash('md5') - .update(image1440.data, 'base64') - .digest('base64'); - - imageData.push({ - fileName, - thumbnail128, - // thumbnail1024, - image1440, - }); - } - - return imageData; -} - -async function base64FromFile(uri: string) { - return await RNFS.readFile(decodeURIComponent(uri), 'base64'); -} diff --git a/App/app/features/integrations/screens/AirtableIntegrationScreen.tsx b/App/app/features/integrations/screens/AirtableIntegrationScreen.tsx index b3847533..414c60c0 100644 --- a/App/app/features/integrations/screens/AirtableIntegrationScreen.tsx +++ b/App/app/features/integrations/screens/AirtableIntegrationScreen.tsx @@ -10,6 +10,7 @@ import { View, } from 'react-native'; import type { StackScreenProps } from '@react-navigation/stack'; +import RNFS from 'react-native-fs'; import RNSInfo from 'react-native-sensitive-info'; import { z } from 'zod'; @@ -28,12 +29,15 @@ import ItemListItem from '@app/features/inventory/components/ItemListItem'; import { onlyValid, useData } from '@app/data'; import { + getAttachAttachmentToDatum, getGetAttachmentInfoFromDatum, getGetData, getGetDataCount, getGetDatum, getSaveDatum, } from '@app/data/functions'; +import getImageDatum from '@app/data/images/getImageDatum'; +import processAssets from '@app/data/images/processAssets'; import { useDB } from '@app/db'; @@ -239,6 +243,7 @@ function AirtableIntegrationScreen({ db, logger, }); + const attachAttachmentToDatum = getAttachAttachmentToDatum({ db }); for await (const p of syncWithAirtable( { @@ -253,6 +258,61 @@ function AirtableIntegrationScreen({ getDataCount, saveDatum, getAttachmentInfoFromDatum, + getImageFromAirtableImage: async airtableImage => { + if (!airtableImage || typeof airtableImage !== 'object') { + throw new Error( + `Expect airtableImage to be a non-null object, got ${typeof typeof airtableImage}.`, + ); + } + + const { id, url, filename, type } = airtableImage as any; + if (typeof id !== 'string') { + throw new Error( + `Expect airtableImage.id to be a string, got ${typeof typeof id}.`, + ); + } + if (typeof url !== 'string') { + throw new Error( + `Expect airtableImage.url to be a string, got ${typeof typeof url}.`, + ); + } + if (filename && typeof filename !== 'string') { + throw new Error( + `Expect airtableImage.filename to be undefined or a string, got ${typeof typeof filename}.`, + ); + } + if (typeof type !== 'string') { + throw new Error( + `Expect airtableImage.type to be a string, got ${typeof typeof type}.`, + ); + } + + const fileUri = `${RNFS.TemporaryDirectoryPath.replace( + /\/$/, + '', + )}/airtable_image_${id}.${type.split('/')[1]}`; + await RNFS.downloadFile({ + fromUrl: url, + toFile: fileUri, + }).promise; + + const images = await processAssets([ + { uri: fileUri, fileName: filename, type }, + ]); + + const image = images[0]; + if (!image) { + throw new Error('processAssets returned no images.'); + } + + const imageDatum = await getImageDatum(image, { + getData, + attachAttachmentToDatum, + saveDatum, + }); + + return imageDatum; + }, }, )) { if (!gotAirtableBaseSchema) { diff --git a/App/app/features/inventory/components/EditImagesUI.tsx b/App/app/features/inventory/components/EditImagesUI.tsx index c10502c0..b021d2e7 100644 --- a/App/app/features/inventory/components/EditImagesUI.tsx +++ b/App/app/features/inventory/components/EditImagesUI.tsx @@ -40,6 +40,7 @@ import { getGetDatum, getSaveDatum, } from '@app/data/functions'; +import getImageDatum from '@app/data/images/getImageDatum'; import useImageSelector, { ImageData } from '@app/data/images/useImageSelector'; import { useDB } from '@app/db'; @@ -256,53 +257,14 @@ export function EditImagesUI({ itemImageDataItem.__id || '', ); if (itemImageDataItem.__type === 'unsaved_image_data') { - // Reuse already-saved image if possible - const imageDatum = await (async () => { - const image1440Digest = itemImageDataItem.image1440.digest; - if (image1440Digest) { - const existingImage = await getData( - 'image', - { - image_1440_digest: image1440Digest, - }, - { limit: 1 }, - ); - - const validExistingImage = onlyValid(existingImage); - if (validExistingImage.length >= 1) { - return validExistingImage[0]; - } - } - - const imageToSave = { - __id: itemImageDataItem.__id, - __type: 'image', - filename: itemImageDataItem.fileName, - } as const; - - await attachAttachmentToDatum( - imageToSave, - 'thumbnail-128', - itemImageDataItem.thumbnail128.contentType, - itemImageDataItem.thumbnail128.data, - ); - // await attachAttachmentToDatum( - // imageToSave, - // 'thumbnail-1024', - // itemImageDataItem.thumbnail1024.contentType, - // itemImageDataItem.thumbnail1024.data, - // ); - await attachAttachmentToDatum( - imageToSave, - 'image-1440', - itemImageDataItem.image1440.contentType, - itemImageDataItem.image1440.data, - ); - - return await saveDatum(imageToSave, { - ignoreConflict: true, - }); - })(); + const imageDatum = await getImageDatum( + { ...itemImageDataItem, fileName: itemImageDataItem.fileName }, + { + getData, + attachAttachmentToDatum, + saveDatum, + }, + ); await saveDatum( { diff --git a/packages/integration-airtable/lib/conversions.ts b/packages/integration-airtable/lib/conversions.ts index c90273a0..557a1308 100644 --- a/packages/integration-airtable/lib/conversions.ts +++ b/packages/integration-airtable/lib/conversions.ts @@ -116,19 +116,33 @@ export async function itemToAirtableRecord( filename, }; }); - // Check if all image URL works + // Check if all image URL works, since Airtable will simply ignore URLs that cannot be accessed. for (const image of images) { - try { - const resp = await fetch(image.url, { method: 'HEAD' }); - if (resp.status !== 200) { - throw new Error(`HEAD request to ${image.url} returns ${resp.status}`); - } - } catch (e) { - if (e instanceof Error) { - e.message = `Image URL ${image.url} for item "${item.name}" (ID: ${item.__id}) does not work: ${e.message}`; - } + let retries = 0; + while (true) { + try { + const resp = await fetch(image.url, { method: 'HEAD' }); + if (resp.status !== 200) { + if (resp.status === 404 && retries < 10) { + // Maybe the image is not uploaded yet. Wait for 1 second and try again. + await new Promise(resolve => setTimeout(resolve, 1000)); + retries += 1; + continue; + } + + throw new Error( + `HEAD request to ${image.url} returns ${resp.status}`, + ); + } else { + break; + } + } catch (e) { + if (e instanceof Error) { + e.message = `Image URL ${image.url} for item "${item.name}" (ID: ${item.__id}) does not work: ${e.message}`; + } - throw e; + throw e; + } } } diff --git a/packages/integration-airtable/lib/syncWithAirtable.ts b/packages/integration-airtable/lib/syncWithAirtable.ts index 0dc402d2..e945637e 100644 --- a/packages/integration-airtable/lib/syncWithAirtable.ts +++ b/packages/integration-airtable/lib/syncWithAirtable.ts @@ -1,5 +1,6 @@ import { DataMeta, + DataTypeWithID, GetAttachmentInfoFromDatum, GetData, GetDataConditions, @@ -113,6 +114,7 @@ export default async function* syncWithAirtable( getDataCount, saveDatum, getAttachmentInfoFromDatum, + getImageFromAirtableImage, }: // batchSize = 10, { fetch: Fetch; @@ -121,6 +123,9 @@ export default async function* syncWithAirtable( getDataCount: GetDataCount; saveDatum: SaveDatum; getAttachmentInfoFromDatum: GetAttachmentInfoFromDatum; + getImageFromAirtableImage: ( + airtableImage: unknown, + ) => Promise>; // batchSize?: number; }, ) { @@ -1174,8 +1179,17 @@ export default async function* syncWithAirtable( .map(ri => ri?.filename) .filter(f => typeof f === 'string'); const recordImageIds = recordImageFilenames.map(f => f.split('.')[0]); - const shouldHaveImages = onlyValid( - await getData('image', recordImageIds), + const shouldHaveImages = await Promise.all( + ( + await getData('image', recordImageIds) + ).map(async (image, i) => { + if (image.__valid) return image; + + const recordImage = (recordImages as Array)[i]; + const img = await getImageFromAirtableImage(recordImage); + + return img; + }), ); const shouldHaveImageIdsSet = new Set( shouldHaveImages