import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { BatchedArray } from 'array-batcher'; import { action, computed, IReactionDisposer, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import { extname } from 'path'; import Measure, { ContentRect } from 'react-measure'; import { Doc, DocListCast, DocListCastAsync, Opt } from '../../../fields/Doc'; import { Id } from '../../../fields/FieldSymbols'; import { List } from '../../../fields/List'; import { listSpec } from '../../../fields/Schema'; import { SchemaHeaderField } from '../../../fields/SchemaHeaderField'; import { BoolCast, Cast, NumCast } from '../../../fields/Types'; import { AcceptableMedia, Upload } from '../../../server/SharedMediaTypes'; import { Utils } from '../../../Utils'; import { GooglePhotos } from '../../apis/google_docs/GooglePhotosClientUtils'; import { Docs, DocumentOptions, DocUtils } from '../../documents/Documents'; import { Networking } from '../../Network'; import { FieldView, FieldViewProps } from '../../views/nodes/FieldView'; import { DocumentManager } from '../DocumentManager'; import './DirectoryImportBox.scss'; import ImportMetadataEntry, { keyPlaceholder, valuePlaceholder } from './ImportMetadataEntry'; import React = require('react'); const unsupported = ['text/html', 'text/plain']; @observer export class DirectoryImportBox extends React.Component { private selector = React.createRef(); @observable private top = 0; @observable private left = 0; private dimensions = 50; @observable private phase = ''; private disposer: Opt; @observable private entries: ImportMetadataEntry[] = []; @observable private quota = 1; @observable private completed = 0; @observable private uploading = false; @observable private removeHover = false; public static LayoutString(fieldKey: string) { return FieldView.LayoutString(DirectoryImportBox, fieldKey); } constructor(props: FieldViewProps) { super(props); const doc = this.props.Document; this.editingMetadata = this.editingMetadata || false; this.persistent = this.persistent || false; !Cast(doc.data, listSpec(Doc)) && (doc.data = new List()); } @computed private get editingMetadata() { return BoolCast(this.props.Document.editingMetadata); } private set editingMetadata(value: boolean) { this.props.Document.editingMetadata = value; } @computed private get persistent() { return BoolCast(this.props.Document.persistent); } private set persistent(value: boolean) { this.props.Document.persistent = value; } handleSelection = async (e: React.ChangeEvent) => { runInAction(() => { this.uploading = true; this.phase = 'Initializing download...'; }); const docs: Doc[] = []; const files = e.target.files; if (!files || files.length === 0) return; const directory = (files.item(0) as any).webkitRelativePath.split('/', 1)[0]; const validated: File[] = []; for (let i = 0; i < files.length; i++) { const file = files.item(i); if (file && !unsupported.includes(file.type)) { const ext = extname(file.name).toLowerCase(); if (AcceptableMedia.imageFormats.includes(ext)) { validated.push(file); } } } runInAction(() => { this.quota = validated.length; this.completed = 0; }); const sizes: number[] = []; const modifiedDates: number[] = []; runInAction(() => (this.phase = `Internal: uploading ${this.quota - this.completed} files to Dash...`)); const batched = BatchedArray.from(validated, { batchSize: 15 }); const uploads = await batched.batchedMapAsync>(async (batch, collector) => { batch.forEach(file => { sizes.push(file.size); modifiedDates.push(file.lastModified); }); collector.push(...(await Networking.UploadFilesToServer(batch))); runInAction(() => (this.completed += batch.length)); }); await Promise.all( uploads.map(async response => { const { source: { type }, result, } = response; if (result instanceof Error) { return; } const { accessPaths, exifData } = result; const path = Utils.prepend(accessPaths.agnostic.client); const document = type && (await DocUtils.DocumentFromType(type, path, { _width: 300 })); const { data, error } = exifData; if (document) { Doc.GetProto(document).exif = error || Doc.Get.FromJson({ data }); docs.push(document); } }) ); for (let i = 0; i < docs.length; i++) { const doc = docs[i]; doc.size = sizes[i]; doc.modified = modifiedDates[i]; this.entries.forEach(entry => { const target = entry.onDataDoc ? Doc.GetProto(doc) : doc; target[entry.key] = entry.value; }); } const doc = this.props.Document; const height: number = NumCast(doc.height) || 0; const offset: number = this.persistent ? (height === 0 ? 0 : height + 30) : 0; const options: DocumentOptions = { title: `Import of ${directory}`, _width: 1105, _height: 500, _chromeHidden: true, x: NumCast(doc.x), y: NumCast(doc.y) + offset, }; const parent = this.props.ContainingCollectionView; if (parent) { let importContainer: Doc; if (docs.length < 50) { importContainer = Docs.Create.MasonryDocument(docs, options); } else { const headers = [new SchemaHeaderField('title'), new SchemaHeaderField('size')]; importContainer = Docs.Create.SchemaDocument(headers, docs, options); } runInAction(() => (this.phase = 'External: uploading files to Google Photos...')); await GooglePhotos.Export.CollectionToAlbum({ collection: importContainer }); Doc.AddDocToList(Doc.GetProto(parent.props.Document), 'data', importContainer); !this.persistent && this.props.removeDocument && this.props.removeDocument(doc); DocumentManager.Instance.jumpToDocument(importContainer, { willPanZoom: true }, undefined, []); } runInAction(() => { this.uploading = false; this.quota = 1; this.completed = 0; }); }; componentDidMount() { this.selector.current!.setAttribute('directory', ''); this.selector.current!.setAttribute('webkitdirectory', ''); this.disposer = reaction( () => this.completed, completed => runInAction(() => (this.phase = `Internal: uploading ${this.quota - completed} files to Dash...`)) ); } componentWillUnmount() { this.disposer && this.disposer(); } @action preserveCentering = (rect: ContentRect) => { const bounds = rect.offset!; if (bounds.width === 0 || bounds.height === 0) { return; } const offset = this.dimensions / 2; this.left = bounds.width / 2 - offset; this.top = bounds.height / 2 - offset; }; @action addMetadataEntry = async () => { const entryDoc = new Doc(); entryDoc.checked = false; entryDoc.key = keyPlaceholder; entryDoc.value = valuePlaceholder; Doc.AddDocToList(this.props.Document, 'data', entryDoc); }; @action remove = async (entry: ImportMetadataEntry) => { const metadata = await DocListCastAsync(this.props.Document.data); if (metadata) { let index = this.entries.indexOf(entry); if (index !== -1) { runInAction(() => this.entries.splice(index, 1)); index = metadata.indexOf(entry.props.Document); if (index !== -1) { metadata.splice(index, 1); } } } }; render() { const dimensions = 50; const entries = DocListCast(this.props.Document.data); const isEditing = this.editingMetadata; const completed = this.completed; const quota = this.quota; const uploading = this.uploading; const showRemoveLabel = this.removeHover; const persistent = this.persistent; let percent = `${(completed / quota) * 100}`; percent = percent.split('.')[0]; percent = percent.startsWith('100') ? '99' : percent; const marginOffset = (percent.length === 1 ? 5 : 0) - 1.6; const message = {this.phase}; const centerPiece = this.phase.includes('Google Photos') ? ( ) : (
{percent}%
); return ( {({ measureRef }) => (
{message}