sanity
Version:
Sanity is a real-time content infrastructure with a scalable, hosted backend featuring a Graph Oriented Query Language (GROQ), asset pipelines and fast edge caches
148 lines (131 loc) • 4.53 kB
text/typescript
import {type ProgressEvent, type SanityAssetDocument, type SanityClient} from '@sanity/client'
import {type FileAsset, type ImageAsset} from '@sanity/types'
import {Observable, of as observableOf} from 'rxjs'
import {catchError, map, mergeMap} from 'rxjs/operators'
import {type DocumentPreviewStore} from '../../../../preview'
import {type UploadOptions} from '../../uploads/types'
import {withMaxConcurrency} from '../../utils'
const MAX_CONCURRENT_UPLOADS = 4
type UploadEvent = ProgressEvent | {type: 'complete'; id: string; asset: SanityAssetDocument}
function uploadSanityAsset(
client: SanityClient,
assetType: 'file' | 'image',
file: File | Blob,
options: UploadOptions = {},
): Observable<UploadEvent> {
const extract = options.metadata
const preserveFilename = options.storeOriginalFilename
const {label, title, description, creditLine, source} = options
return hashFile(file).pipe(
catchError(() =>
// ignore if hashing fails for some reason
observableOf(null),
),
mergeMap((hash) =>
// note: the sanity api will still dedupe unique files, but this saves us from uploading the asset file entirely
hash ? fetchExisting(client, `sanity.${assetType}Asset`, hash) : observableOf(null),
),
mergeMap((existing: SanityAssetDocument | null) => {
if (existing) {
return observableOf({
// complete with the existing asset document
type: 'complete' as const,
id: existing._id,
asset: existing,
})
}
return client.observable.assets
.upload(assetType, file, {
tag: 'asset.upload',
extract,
preserveFilename,
label,
title,
description,
creditLine,
source,
})
.pipe(
map((event) =>
event.type === 'response'
? {
// rewrite to a 'complete' event
type: 'complete' as const,
id: event.body.document._id,
asset: event.body.document,
}
: event,
),
)
}),
)
}
const uploadAsset = withMaxConcurrency(uploadSanityAsset, MAX_CONCURRENT_UPLOADS)
export const uploadImageAsset = (
client: SanityClient,
file: File | Blob,
options?: UploadOptions,
) => uploadAsset(client, 'image', file, options)
export const uploadFileAsset = (client: SanityClient, file: File | Blob, options?: UploadOptions) =>
uploadAsset(client, 'file', file, options)
// note: there's currently 100% overlap between the ImageAsset document and the FileAsset documents as per interface required by the image and file input
function observeAssetDoc(documentPreviewStore: DocumentPreviewStore, id: string) {
return documentPreviewStore.observePaths({_type: 'reference', _ref: id}, [
'originalFilename',
'url',
'metadata',
'label',
'title',
'description',
'creditLine',
'source',
'size',
])
}
export function observeImageAsset(documentPreviewStore: DocumentPreviewStore, id: string) {
return observeAssetDoc(documentPreviewStore, id) as Observable<ImageAsset>
}
export function observeFileAsset(documentPreviewStore: DocumentPreviewStore, id: string) {
return observeAssetDoc(documentPreviewStore, id) as Observable<FileAsset>
}
function fetchExisting(
client: SanityClient,
type: string,
hash: string,
): Observable<ImageAsset | FileAsset | null> {
return client.observable.fetch(
'*[_type == $documentType && sha1hash == $hash][0]',
{documentType: type, hash},
{tag: 'asset.find-duplicate'},
)
}
function readFile(file: Blob | File): Observable<ArrayBuffer> {
return new Observable((subscriber) => {
const reader = new FileReader()
reader.onload = () => {
subscriber.next(reader.result as ArrayBuffer)
subscriber.complete()
}
reader.onerror = (err) => {
subscriber.error(err)
}
reader.readAsArrayBuffer(file)
return () => {
reader.abort()
}
})
}
function hashFile(file: File | Blob): Observable<string | null> {
if (!window.crypto || !window.crypto.subtle || !window.FileReader) {
return observableOf(null)
}
return readFile(file).pipe(
mergeMap((arrayBuffer) => crypto.subtle.digest('SHA-1', arrayBuffer)),
map(hexFromBuffer),
)
}
function hexFromBuffer(buffer: ArrayBuffer): string {
return Array.prototype.map
.call(new Uint8Array(buffer), (x) => `00${x.toString(16)}`.slice(-2))
.join('')
}