feat: add filepicker package

This commit is contained in:
Karol Sójko
2022-07-05 19:28:22 +02:00
parent 577da2ca84
commit d4188a3fa2
45 changed files with 5848 additions and 25 deletions

View File

@@ -0,0 +1,5 @@
{
"rules": {
"no-console": ["off"]
}
}

View File

@@ -0,0 +1,21 @@
<!DOCTYPE html>
<html>
<head>
<style>
body {
background-color: gray;
}
</style>
</head>
<body>
<label>Classic File Picker</label>
<button id="filePicker">Classic Picker</button>
<label>FileSystem API Picker</label>
<button id="fileSystemUploadButton">FileSystem Upload File</button>
<button id="downloadButton">Download File</button>
</body>
</html>

View File

@@ -0,0 +1,42 @@
{
"name": "files-demo",
"main": "dist/src/index.js",
"types": "dist/src/index.d.ts",
"files": [
"dist/src"
],
"publishConfig": {
"access": "public"
},
"license": "AGPL-3.0-or-later",
"scripts": {
"clean": "rm -fr dist",
"prestart": "yarn clean",
"start": "webpack-dev-server --config webpack.config.js",
"watch": "webpack -w --config webpack.config.js",
"prebuild": "yarn clean",
"build": "tsc -p tsconfig.json",
"lint": "eslint . --ext .ts"
},
"devDependencies": {
"@babel/core": "^7.15.8",
"@babel/preset-env": "^7.15.8",
"@babel/preset-typescript": "^7.15.0",
"@standardnotes/config": "^2.2.0",
"@types/wicg-native-file-system": "^2020.6.0",
"babel-eslint": "^10.1.0",
"babel-loader": "^8.2.3",
"html-webpack-plugin": "^5.5.0",
"ts-loader": "^9.2.6",
"typescript": "^4.0.5",
"typescript-eslint": "0.0.1-alpha.0",
"webpack": "^5.59.1",
"webpack-cli": "^4.9.1",
"webpack-dev-server": "^4.3.1"
},
"dependencies": {
"@standardnotes/sncrypto-web": "^1.7.0",
"@standardnotes/snjs": "^2.61.3",
"regenerator-runtime": "^0.13.9"
}
}

View File

@@ -0,0 +1,56 @@
import { SNApplication, ContentType, FileItem, ClientDisplayableError } from '../../../snjs'
import { ClassicFileReader, ClassicFileSaver } from '../../../filepicker'
export class ClassicFileApi {
constructor(private application: SNApplication) {
this.configureFilePicker()
}
configureFilePicker(): void {
const input = document.getElementById('filePicker') as HTMLInputElement
input.onclick = () => {
void this.openFilePicker()
}
console.log('Classic file picker ready.')
}
async openFilePicker(): Promise<void> {
const files = await ClassicFileReader.selectFiles()
for (const file of files) {
const operation = await this.application.files.beginNewFileUpload()
if (operation instanceof ClientDisplayableError) {
continue
}
const fileResult = await ClassicFileReader.readFile(file, 2_000_000, async (chunk, index, isLast) => {
await this.application.files.pushBytesForUpload(operation, chunk, index, isLast)
})
const snFile = await this.application.files.finishUpload(operation, fileResult)
if (snFile instanceof ClientDisplayableError) {
return
}
const bytes = await this.downloadFileBytes(snFile.remoteIdentifier)
new ClassicFileSaver().saveFile(`${snFile.name}.${snFile.ext}`, bytes)
}
}
downloadFileBytes = async (remoteIdentifier: string): Promise<Uint8Array> => {
console.log('Downloading file', remoteIdentifier)
const file = this.application['itemManager']
.getItems(ContentType.File)
.find((file: FileItem) => file.remoteIdentifier === remoteIdentifier)
let receivedBytes = new Uint8Array()
await this.application.files.downloadFile(file, async (decryptedBytes: Uint8Array) => {
console.log(`Downloaded ${decryptedBytes.length} bytes`)
receivedBytes = new Uint8Array([...receivedBytes, ...decryptedBytes])
})
console.log('Successfully downloaded and decrypted file!')
return receivedBytes
}
}

View File

@@ -0,0 +1,68 @@
import { StreamingFileReader, StreamingFileSaver } from '../../../filepicker'
import { SNApplication, FileItem, ClientDisplayableError } from '../../../snjs'
export class FileSystemApi {
private uploadedFiles: FileItem[] = []
constructor(private application: SNApplication) {
this.configureFilePicker()
this.configureDownloadButton()
}
get downloadButton(): HTMLButtonElement {
return document.getElementById('downloadButton') as HTMLButtonElement
}
configureDownloadButton(): void {
this.downloadButton.onclick = this.downloadFiles
this.downloadButton.style.display = 'none'
}
configureFilePicker(): void {
const button = document.getElementById('fileSystemUploadButton') as HTMLButtonElement
button.onclick = this.uploadFiles
console.log('File picker ready.')
}
uploadFiles = async (): Promise<void> => {
const snFiles = []
const selectedFiles = await StreamingFileReader.selectFiles()
for (const file of selectedFiles) {
const operation = await this.application.files.beginNewFileUpload()
if (operation instanceof ClientDisplayableError) {
continue
}
const fileResult = await StreamingFileReader.readFile(file, 2_000_000, async (chunk, index, isLast) => {
await this.application.files.pushBytesForUpload(operation, chunk, index, isLast)
})
const snFile = await this.application.files.finishUpload(operation, fileResult)
snFiles.push(snFile)
}
this.downloadButton.style.display = ''
this.uploadedFiles = snFiles
}
downloadFiles = async (): Promise<void> => {
for (const snFile of this.uploadedFiles) {
console.log('Downloading file', snFile.remoteIdentifier)
const saver = new StreamingFileSaver(snFile.name)
await saver.selectFileToSaveTo()
saver.loggingEnabled = true
await this.application.files.downloadFile(snFile, async (decryptedBytes: Uint8Array) => {
console.log(`Pushing ${decryptedBytes.length} decrypted bytes to disk`)
await saver.pushBytes(decryptedBytes)
})
console.log('Closing file saver reader')
await saver.finish()
console.log('Successfully downloaded and decrypted file!')
}
}
}

View File

@@ -0,0 +1,89 @@
import { SNApplication, Environment, Platform, SNLog } from '../../../snjs'
import WebDeviceInterface from './web_device_interface'
import { SNWebCrypto } from '../../../sncrypto-web'
import { ClassicFileApi } from './classic_file_api'
import { FileSystemApi } from './file_system_api'
SNLog.onLog = console.log
SNLog.onError = console.error
console.log('Clearing localStorage...')
localStorage.clear()
/**
* Important:
* If reusing e2e docker servers, you must edit docker/auth.env ACCESS_TOKEN_AGE
* and REFRESH_TOKEN_AGE and increase their ttl.
*/
const host = 'http://localhost:3123'
const mocksHost = 'http://localhost:3124'
const application = new SNApplication({
environment: Environment.Web,
platform: Platform.MacWeb,
deviceInterface: new WebDeviceInterface(),
crypto: new SNWebCrypto(),
alertService: {
confirm: async () => true,
alert: async () => {
alert()
},
blockingDialog: () => () => {
confirm()
},
},
identifier: `${Math.random()}`,
defaultHost: host,
appVersion: '1.0.0',
})
console.log('Created application', application)
export async function publishMockedEvent(eventType: string, eventPayload: unknown): Promise<void> {
await fetch(`${mocksHost}/events`, {
method: 'POST',
headers: {
Accept: 'application/json',
'Content-Type': 'application/json',
},
body: JSON.stringify({
eventType,
eventPayload,
}),
})
}
const run = async () => {
console.log('Preparing for launch...')
await application.prepareForLaunch({
receiveChallenge: () => {
console.warn('Ignoring challenge')
},
})
await application.launch()
console.log('Application launched...')
const email = String(Math.random())
const password = String(Math.random())
console.log('Registering account...')
await application.register(email, password)
console.log(`Registered account ${email}/${password}. Be sure to edit docker/auth.env to increase session TTL.`)
console.log('Creating mock subscription...')
await publishMockedEvent('SUBSCRIPTION_PURCHASED', {
userEmail: email,
subscriptionId: 1,
subscriptionName: 'PLUS_PLAN',
subscriptionExpiresAt: (new Date().getTime() + 3_600_000) * 1_000,
timestamp: Date.now(),
offline: false,
})
console.log('Successfully created mock subscription...')
new ClassicFileApi(application)
new FileSystemApi(application)
}
void run()

View File

@@ -0,0 +1,138 @@
/* eslint-disable no-undef */
const KEYCHAIN_STORAGE_KEY = 'keychain'
export default class WebDeviceInterface {
async getRawStorageValue(key) {
return localStorage.getItem(key)
}
async getJsonParsedRawStorageValue(key) {
const value = await this.getRawStorageValue(key)
if (isNullOrUndefined(value)) {
return undefined
}
try {
return JSON.parse(value)
} catch (e) {
return value
}
}
async getAllRawStorageKeyValues() {
const results = []
for (const key of Object.keys(localStorage)) {
results.push({
key: key,
value: localStorage[key],
})
}
return results
}
async setRawStorageValue(key, value) {
localStorage.setItem(key, value)
}
async removeRawStorageValue(key) {
localStorage.removeItem(key)
}
async removeAllRawStorageValues() {
localStorage.clear()
}
async openDatabase(_identifier) {
return {}
}
_getDatabaseKeyPrefix(identifier) {
if (identifier) {
return `${identifier}-item-`
} else {
return 'item-'
}
}
_keyForPayloadId(id, identifier) {
return `${this._getDatabaseKeyPrefix(identifier)}${id}`
}
async getAllRawDatabasePayloads(identifier) {
const models = []
for (const key in localStorage) {
if (key.startsWith(this._getDatabaseKeyPrefix(identifier))) {
models.push(JSON.parse(localStorage[key]))
}
}
return models
}
async saveRawDatabasePayload(payload, identifier) {
localStorage.setItem(this._keyForPayloadId(payload.uuid, identifier), JSON.stringify(payload))
}
async saveRawDatabasePayloads(payloads, identifier) {
for (const payload of payloads) {
await this.saveRawDatabasePayload(payload, identifier)
}
}
async removeRawDatabasePayloadWithId(id, identifier) {
localStorage.removeItem(this._keyForPayloadId(id, identifier))
}
async removeAllRawDatabasePayloads(identifier) {
for (const key in localStorage) {
if (key.startsWith(this._getDatabaseKeyPrefix(identifier))) {
delete localStorage[key]
}
}
}
/** @keychain */
async getNamespacedKeychainValue(identifier) {
const keychain = await this.getRawKeychainValue(identifier)
if (!keychain) {
return
}
return keychain[identifier]
}
async setNamespacedKeychainValue(value, identifier) {
let keychain = await this.getRawKeychainValue()
if (!keychain) {
keychain = {}
}
localStorage.setItem(
KEYCHAIN_STORAGE_KEY,
JSON.stringify({
...keychain,
[identifier]: value,
}),
)
}
async clearNamespacedKeychainValue(identifier) {
const keychain = await this.getRawKeychainValue()
if (!keychain) {
return
}
delete keychain[identifier]
localStorage.setItem(KEYCHAIN_STORAGE_KEY, JSON.stringify(keychain))
}
/** Allows unit tests to set legacy keychain structure as it was <= 003 */
// eslint-disable-next-line camelcase
async setLegacyRawKeychainValue(value) {
localStorage.setItem(KEYCHAIN_STORAGE_KEY, JSON.stringify(value))
}
async getRawKeychainValue() {
const keychain = localStorage.getItem(KEYCHAIN_STORAGE_KEY)
return JSON.parse(keychain)
}
async clearRawKeychainValue() {
localStorage.removeItem(KEYCHAIN_STORAGE_KEY)
}
}

View File

@@ -0,0 +1,11 @@
{
"compilerOptions": {
"rootDir": "src",
"outDir": "dist",
"target": "es2015",
"module": "es2015",
"moduleResolution": "node",
"baseUrl": ".",
},
"exclude": ["dist", "node_modules"]
}

View File

@@ -0,0 +1,54 @@
const path = require('path');
const webpack = require('webpack');
const HtmlWebpackPlugin = require('html-webpack-plugin');
module.exports = (env) => {
return {
entry: './src/index.ts',
output: {
filename: './dist/index.js',
},
mode: 'development',
optimization: {
minimize: false,
},
plugins: [
new HtmlWebpackPlugin({
template: './index.html',
inject: true,
templateParameters: {
env: process.env,
},
}),
],
devServer: {
hot: 'only',
static: './public',
port: 3030,
},
resolve: {
extensions: ['.ts', '.tsx', '.js'],
fallback: {
crypto: false,
path: false,
},
},
module: {
rules: [
{
test: /\.(js|tsx?)$/,
exclude: /(node_modules)/,
use: [
'babel-loader',
{
loader: 'ts-loader',
options: {
transpileOnly: true,
},
},
],
},
],
},
};
};

File diff suppressed because it is too large Load Diff