feat: file backups (#1024)
This commit is contained in:
@@ -23,8 +23,20 @@ type Props = {
|
||||
onClickPreprocessing?: () => Promise<void>
|
||||
}
|
||||
|
||||
const isHandlingFileDrag = (event: DragEvent) =>
|
||||
event.dataTransfer?.items && Array.from(event.dataTransfer.items).some((item) => item.kind === 'file')
|
||||
const isHandlingFileDrag = (event: DragEvent, application: WebApplication) => {
|
||||
const items = event.dataTransfer?.items
|
||||
|
||||
if (!items) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Array.from(items).some((item) => {
|
||||
const isFile = item.kind === 'file'
|
||||
const fileName = item.getAsFile()?.name || ''
|
||||
const isBackupMetadataFile = application.files.isFileNameFileBackupMetadataFile(fileName)
|
||||
return isFile && !isBackupMetadataFile
|
||||
})
|
||||
}
|
||||
|
||||
export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
({ application, appState, onClickPreprocessing }) => {
|
||||
@@ -234,16 +246,19 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
const [isDraggingFiles, setIsDraggingFiles] = useState(false)
|
||||
const dragCounter = useRef(0)
|
||||
|
||||
const handleDrag = (event: DragEvent) => {
|
||||
if (isHandlingFileDrag(event)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
}
|
||||
const handleDrag = useCallback(
|
||||
(event: DragEvent) => {
|
||||
if (isHandlingFileDrag(event, application)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
},
|
||||
[application],
|
||||
)
|
||||
|
||||
const handleDragIn = useCallback(
|
||||
(event: DragEvent) => {
|
||||
if (!isHandlingFileDrag(event)) {
|
||||
if (!isHandlingFileDrag(event, application)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -268,29 +283,32 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
}
|
||||
}
|
||||
},
|
||||
[open, toggleAttachedFilesMenu],
|
||||
[open, toggleAttachedFilesMenu, application],
|
||||
)
|
||||
|
||||
const handleDragOut = (event: DragEvent) => {
|
||||
if (!isHandlingFileDrag(event)) {
|
||||
return
|
||||
}
|
||||
const handleDragOut = useCallback(
|
||||
(event: DragEvent) => {
|
||||
if (!isHandlingFileDrag(event, application)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
dragCounter.current = dragCounter.current - 1
|
||||
dragCounter.current = dragCounter.current - 1
|
||||
|
||||
if (dragCounter.current > 0) {
|
||||
return
|
||||
}
|
||||
if (dragCounter.current > 0) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsDraggingFiles(false)
|
||||
}
|
||||
setIsDraggingFiles(false)
|
||||
},
|
||||
[application],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(event: DragEvent) => {
|
||||
if (!isHandlingFileDrag(event)) {
|
||||
if (!isHandlingFileDrag(event, application)) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -330,7 +348,7 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
dragCounter.current = 0
|
||||
}
|
||||
},
|
||||
[appState.files, appState.features.hasFiles, attachFileToNote, currentTab],
|
||||
[appState.files, appState.features.hasFiles, attachFileToNote, currentTab, application],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -345,7 +363,7 @@ export const AttachedFilesButton: FunctionComponent<Props> = observer(
|
||||
window.removeEventListener('dragover', handleDrag)
|
||||
window.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [handleDragIn, handleDrop])
|
||||
}, [handleDragIn, handleDrop, handleDrag, handleDragOut])
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
|
||||
@@ -90,7 +90,7 @@ export const ChallengeModal: FunctionComponent<Props> = ({
|
||||
const valuesToProcess: ChallengeValue[] = []
|
||||
for (const inputValue of Object.values(validatedValues)) {
|
||||
const rawValue = inputValue.value
|
||||
const value = new ChallengeValue(inputValue.prompt, rawValue)
|
||||
const value = { prompt: inputValue.prompt, value: rawValue }
|
||||
valuesToProcess.push(value)
|
||||
}
|
||||
const processingPrompts = valuesToProcess.map((v) => v.prompt)
|
||||
|
||||
@@ -76,7 +76,7 @@ export class Footer extends PureComponent<Props, State> {
|
||||
|
||||
override componentDidMount(): void {
|
||||
super.componentDidMount()
|
||||
this.application.getStatusManager().onStatusChange((message) => {
|
||||
this.application.status.addEventObserver((_event, message) => {
|
||||
this.setState({
|
||||
arbitraryStatusMessage: message,
|
||||
})
|
||||
@@ -124,7 +124,7 @@ export class Footer extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
override onAppStateEvent(eventName: AppStateEvent, data: any) {
|
||||
const statusService = this.application.getStatusManager()
|
||||
const statusService = this.application.status
|
||||
switch (eventName) {
|
||||
case AppStateEvent.EditorFocused:
|
||||
if (data.eventSource === EventSource.UserInteraction) {
|
||||
@@ -172,7 +172,7 @@ export class Footer extends PureComponent<Props, State> {
|
||||
break
|
||||
case ApplicationEvent.CompletedFullSync:
|
||||
if (!this.completedInitialSync) {
|
||||
this.application.getStatusManager().setMessage('')
|
||||
this.application.status.setMessage('')
|
||||
this.completedInitialSync = true
|
||||
}
|
||||
if (!this.didCheckForOffline) {
|
||||
@@ -202,7 +202,7 @@ export class Footer extends PureComponent<Props, State> {
|
||||
break
|
||||
case ApplicationEvent.WillSync:
|
||||
if (!this.completedInitialSync) {
|
||||
this.application.getStatusManager().setMessage('Syncing…')
|
||||
this.application.status.setMessage('Syncing…')
|
||||
}
|
||||
break
|
||||
}
|
||||
@@ -213,7 +213,7 @@ export class Footer extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
updateSyncStatus() {
|
||||
const statusManager = this.application.getStatusManager()
|
||||
const statusManager = this.application.status
|
||||
const syncStatus = this.application.sync.getSyncStatus()
|
||||
const stats = syncStatus.getStats()
|
||||
if (syncStatus.hasError()) {
|
||||
@@ -243,7 +243,7 @@ export class Footer extends PureComponent<Props, State> {
|
||||
}
|
||||
|
||||
updateLocalDataStatus() {
|
||||
const statusManager = this.application.getStatusManager()
|
||||
const statusManager = this.application.status
|
||||
const syncStatus = this.application.sync.getSyncStatus()
|
||||
const stats = syncStatus.getStats()
|
||||
const encryption = this.application.isEncryptionAvailable()
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { WebApplication } from '@/UIModels/Application'
|
||||
import { observer } from 'mobx-react-lite'
|
||||
import {
|
||||
PreferencesGroup,
|
||||
PreferencesSegment,
|
||||
Title,
|
||||
Text,
|
||||
Subtitle,
|
||||
} from '@/Components/Preferences/PreferencesComponents'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'preact/hooks'
|
||||
import { Button } from '@/Components/Button/Button'
|
||||
import { FileBackupMetadataFile, FileContent, FileHandleRead } from '@standardnotes/snjs'
|
||||
import { Switch } from '@/Components/Switch'
|
||||
import { HorizontalSeparator } from '@/Components/Shared/HorizontalSeparator'
|
||||
import { EncryptionStatusItem } from '../Security/Encryption'
|
||||
import { Icon } from '@/Components/Icon'
|
||||
import { StreamingFileApi } from '@standardnotes/filepicker'
|
||||
import { FunctionComponent } from 'preact'
|
||||
|
||||
type Props = {
|
||||
application: WebApplication
|
||||
}
|
||||
|
||||
export const FileBackups = observer(({ application }: Props) => {
|
||||
const [backupsEnabled, setBackupsEnabled] = useState(false)
|
||||
const [backupsLocation, setBackupsLocation] = useState('')
|
||||
const backupsService = useMemo(() => application.fileBackups, [application.fileBackups])
|
||||
|
||||
if (!backupsService) {
|
||||
return (
|
||||
<>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>File Backups</Title>
|
||||
<Subtitle>Automatically save encrypted backups of files uploaded to any device to this computer.</Subtitle>
|
||||
<Text className="mt-3">To enable file backups, use the Standard Notes desktop application.</Text>
|
||||
</PreferencesSegment>
|
||||
<PreferencesSegment>
|
||||
<BackupsDropZone application={application} />
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void backupsService.isFilesBackupsEnabled().then(setBackupsEnabled)
|
||||
}, [backupsService])
|
||||
|
||||
useEffect(() => {
|
||||
if (backupsEnabled) {
|
||||
void backupsService.getFilesBackupsLocation().then(setBackupsLocation)
|
||||
}
|
||||
}, [backupsService, backupsEnabled])
|
||||
|
||||
const changeBackupsLocation = useCallback(async () => {
|
||||
await backupsService.changeFilesBackupsLocation()
|
||||
|
||||
setBackupsLocation(await backupsService.getFilesBackupsLocation())
|
||||
}, [backupsService])
|
||||
|
||||
const openBackupsLocation = useCallback(async () => {
|
||||
await backupsService.openFilesBackupsLocation()
|
||||
}, [backupsService])
|
||||
|
||||
const toggleBackups = useCallback(async () => {
|
||||
if (backupsEnabled) {
|
||||
await backupsService.disableFilesBackups()
|
||||
} else {
|
||||
await backupsService.enableFilesBackups()
|
||||
}
|
||||
|
||||
setBackupsEnabled(await backupsService.isFilesBackupsEnabled())
|
||||
}, [backupsService, backupsEnabled])
|
||||
|
||||
return (
|
||||
<>
|
||||
<PreferencesGroup>
|
||||
<PreferencesSegment>
|
||||
<Title>File Backups</Title>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col mr-10">
|
||||
<Subtitle>
|
||||
Automatically save encrypted backups of files uploaded on any device to this computer.
|
||||
</Subtitle>
|
||||
</div>
|
||||
<Switch onChange={toggleBackups} checked={backupsEnabled} />
|
||||
</div>
|
||||
|
||||
{!backupsEnabled && (
|
||||
<>
|
||||
<HorizontalSeparator classes="mt-5 mb-4" />
|
||||
<Text>File backups are not enabled. Enable to choose where your files are backed up.</Text>
|
||||
</>
|
||||
)}
|
||||
</PreferencesSegment>
|
||||
|
||||
{backupsEnabled && (
|
||||
<>
|
||||
<PreferencesSegment>
|
||||
<>
|
||||
<Text className="mb-3">
|
||||
Files backups are enabled. When you upload a new file on any device and open this application, files
|
||||
will be downloaded in encrypted form to:
|
||||
</Text>
|
||||
|
||||
<EncryptionStatusItem
|
||||
status={backupsLocation}
|
||||
icon={[<Icon type="attachment-file" className="min-w-5 min-h-5" />]}
|
||||
checkmark={false}
|
||||
/>
|
||||
<div className="flex flex-row mt-5">
|
||||
<Button
|
||||
variant="normal"
|
||||
label="Open Backups Location"
|
||||
className={'mr-3 text-xs'}
|
||||
onClick={openBackupsLocation}
|
||||
/>
|
||||
<Button
|
||||
variant="normal"
|
||||
label="Change Backups Location"
|
||||
className={'mr-3 text-xs'}
|
||||
onClick={changeBackupsLocation}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
</PreferencesSegment>
|
||||
</>
|
||||
)}
|
||||
|
||||
<PreferencesSegment>
|
||||
<BackupsDropZone application={application} />
|
||||
</PreferencesSegment>
|
||||
</PreferencesGroup>
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
const isHandlingBackupDrag = (event: DragEvent, application: WebApplication) => {
|
||||
const items = event.dataTransfer?.items
|
||||
|
||||
if (!items) {
|
||||
return false
|
||||
}
|
||||
|
||||
return Array.from(items).every((item) => {
|
||||
const isFile = item.kind === 'file'
|
||||
const fileName = item.getAsFile()?.name || ''
|
||||
const isBackupMetadataFile = application.files.isFileNameFileBackupMetadataFile(fileName)
|
||||
return isFile && isBackupMetadataFile
|
||||
})
|
||||
}
|
||||
|
||||
export const BackupsDropZone: FunctionComponent<Props> = ({ application }) => {
|
||||
const [droppedFile, setDroppedFile] = useState<FileBackupMetadataFile | undefined>(undefined)
|
||||
const [decryptedFileContent, setDecryptedFileContent] = useState<FileContent | undefined>(undefined)
|
||||
const [binaryFile, setBinaryFile] = useState<FileHandleRead | undefined>(undefined)
|
||||
const [isSavingAsDecrypted, setIsSavingAsDecrypted] = useState(false)
|
||||
|
||||
const fileSystem = useMemo(() => new StreamingFileApi(), [])
|
||||
|
||||
useEffect(() => {
|
||||
if (droppedFile) {
|
||||
void application.files.decryptBackupMetadataFile(droppedFile).then(setDecryptedFileContent)
|
||||
} else {
|
||||
setDecryptedFileContent(undefined)
|
||||
}
|
||||
}, [droppedFile, application])
|
||||
|
||||
const chooseRelatedBinaryFile = useCallback(async () => {
|
||||
const selection = await application.files.selectFile(fileSystem)
|
||||
|
||||
if (selection === 'aborted' || selection === 'failed') {
|
||||
return
|
||||
}
|
||||
|
||||
setBinaryFile(selection)
|
||||
}, [application, fileSystem])
|
||||
|
||||
const downloadBinaryFileAsDecrypted = useCallback(async () => {
|
||||
if (!decryptedFileContent || !binaryFile) {
|
||||
return
|
||||
}
|
||||
|
||||
setIsSavingAsDecrypted(true)
|
||||
|
||||
const result = await application.files.readBackupFileAndSaveDecrypted(binaryFile, decryptedFileContent, fileSystem)
|
||||
|
||||
if (result === 'success') {
|
||||
void application.alertService.alert(
|
||||
`<strong>${decryptedFileContent.name}</strong> has been successfully decrypted and saved to your chosen directory.`,
|
||||
)
|
||||
setBinaryFile(undefined)
|
||||
setDecryptedFileContent(undefined)
|
||||
setDroppedFile(undefined)
|
||||
} else if (result === 'failed') {
|
||||
void application.alertService.alert(
|
||||
'Unable to save file to local directory. This may be caused by failure to decrypt, or failure to save the file locally.',
|
||||
)
|
||||
}
|
||||
|
||||
setIsSavingAsDecrypted(false)
|
||||
}, [decryptedFileContent, application, binaryFile, fileSystem])
|
||||
|
||||
const handleDrag = useCallback(
|
||||
(event: DragEvent) => {
|
||||
if (isHandlingBackupDrag(event, application)) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
}
|
||||
},
|
||||
[application],
|
||||
)
|
||||
|
||||
const handleDragIn = useCallback(
|
||||
(event: DragEvent) => {
|
||||
if (!isHandlingBackupDrag(event, application)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
},
|
||||
[application],
|
||||
)
|
||||
|
||||
const handleDragOut = useCallback(
|
||||
(event: DragEvent) => {
|
||||
if (!isHandlingBackupDrag(event, application)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
},
|
||||
[application],
|
||||
)
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (event: DragEvent) => {
|
||||
if (!isHandlingBackupDrag(event, application)) {
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
const items = event.dataTransfer?.items
|
||||
|
||||
if (!items || items.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const item = items[0]
|
||||
const file = item.getAsFile()
|
||||
|
||||
if (!file) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = await file.text()
|
||||
|
||||
try {
|
||||
const metadata = JSON.parse(text) as FileBackupMetadataFile
|
||||
setDroppedFile(metadata)
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
|
||||
event.dataTransfer.clearData()
|
||||
},
|
||||
[application],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('dragenter', handleDragIn)
|
||||
window.addEventListener('dragleave', handleDragOut)
|
||||
window.addEventListener('dragover', handleDrag)
|
||||
window.addEventListener('drop', handleDrop)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('dragenter', handleDragIn)
|
||||
window.removeEventListener('dragleave', handleDragOut)
|
||||
window.removeEventListener('dragover', handleDrag)
|
||||
window.removeEventListener('drop', handleDrop)
|
||||
}
|
||||
}, [handleDragIn, handleDrop, handleDrag, handleDragOut])
|
||||
|
||||
if (!droppedFile) {
|
||||
return (
|
||||
<Text>
|
||||
To decrypt a backup file, drag and drop the file's respective <i>metadata.sn.json</i> file here.
|
||||
</Text>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PreferencesSegment>
|
||||
{!decryptedFileContent && <Text>Attempting to decrypt metadata file...</Text>}
|
||||
|
||||
{decryptedFileContent && (
|
||||
<>
|
||||
<Title>Backup Decryption</Title>
|
||||
|
||||
<Subtitle className="success">
|
||||
Successfully decrypted metadata file for <i>{decryptedFileContent.name}.</i>
|
||||
</Subtitle>
|
||||
|
||||
<HorizontalSeparator classes={'mt-3 mb-3'} />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<Subtitle>1. Choose related data file</Subtitle>
|
||||
<Text>{droppedFile.file.uuid}</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Button
|
||||
variant="normal"
|
||||
label="Choose"
|
||||
className={'px-1 text-xs min-w-40'}
|
||||
onClick={chooseRelatedBinaryFile}
|
||||
disabled={!!binaryFile}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<HorizontalSeparator classes={'mt-3 mb-3'} />
|
||||
|
||||
<div className="flex justify-between items-center">
|
||||
<Subtitle>2. Decrypt and save file to your computer</Subtitle>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
variant="normal"
|
||||
label={isSavingAsDecrypted ? undefined : 'Save'}
|
||||
className={'px-1 text-xs min-w-40'}
|
||||
onClick={downloadBinaryFileAsDecrypted}
|
||||
disabled={isSavingAsDecrypted || !binaryFile}
|
||||
>
|
||||
{isSavingAsDecrypted && (
|
||||
<div className="flex justify-center w-full">
|
||||
<div className="sk-spinner w-5 h-5 spinner-info"></div>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</PreferencesSegment>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import { PreferencesPane } from '@/Components/Preferences/PreferencesComponents'
|
||||
import { CloudLink } from './CloudBackups'
|
||||
import { DataBackups } from './DataBackups'
|
||||
import { EmailBackups } from './EmailBackups'
|
||||
import { FileBackups } from './FileBackups'
|
||||
|
||||
interface Props {
|
||||
appState: AppState
|
||||
@@ -15,6 +16,7 @@ export const Backups: FunctionComponent<Props> = ({ application, appState }) =>
|
||||
return (
|
||||
<PreferencesPane>
|
||||
<DataBackups application={application} appState={appState} />
|
||||
<FileBackups application={application} />
|
||||
<EmailBackups application={application} />
|
||||
<CloudLink application={application} />
|
||||
</PreferencesPane>
|
||||
|
||||
@@ -7,16 +7,17 @@ import { PreferencesGroup, PreferencesSegment, Text, Title } from '@/Components/
|
||||
|
||||
const formatCount = (count: number, itemType: string) => `${count} / ${count} ${itemType}`
|
||||
|
||||
const EncryptionStatusItem: FunctionComponent<{
|
||||
export const EncryptionStatusItem: FunctionComponent<{
|
||||
icon: ComponentChild
|
||||
status: string
|
||||
}> = ({ icon, status }) => (
|
||||
checkmark?: boolean
|
||||
}> = ({ icon, status, checkmark = true }) => (
|
||||
<div className="w-full rounded py-1.5 px-3 text-input my-1 min-h-8 flex flex-row items-center bg-contrast no-border focus-within:ring-info">
|
||||
{icon}
|
||||
<div className="min-w-3 min-h-1" />
|
||||
<div className="flex-grow color-text text-sm">{status}</div>
|
||||
<div className="min-w-3 min-h-1" />
|
||||
<Icon className="success min-w-4 min-h-4" type="check-bold" />
|
||||
{checkmark && <Icon className="success min-w-4 min-h-4" type="check-bold" />}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user