feat: add filepicker package
This commit is contained in:
5
packages/filepicker/example/.eslintrc
Normal file
5
packages/filepicker/example/.eslintrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"rules": {
|
||||
"no-console": ["off"]
|
||||
}
|
||||
}
|
||||
21
packages/filepicker/example/index.html
Normal file
21
packages/filepicker/example/index.html
Normal 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>
|
||||
42
packages/filepicker/example/package.json
Normal file
42
packages/filepicker/example/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
56
packages/filepicker/example/src/classic_file_api.ts
Normal file
56
packages/filepicker/example/src/classic_file_api.ts
Normal 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
|
||||
}
|
||||
}
|
||||
68
packages/filepicker/example/src/file_system_api.ts
Normal file
68
packages/filepicker/example/src/file_system_api.ts
Normal 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!')
|
||||
}
|
||||
}
|
||||
}
|
||||
89
packages/filepicker/example/src/index.ts
Normal file
89
packages/filepicker/example/src/index.ts
Normal 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()
|
||||
138
packages/filepicker/example/src/web_device_interface.js
Normal file
138
packages/filepicker/example/src/web_device_interface.js
Normal 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)
|
||||
}
|
||||
}
|
||||
11
packages/filepicker/example/tsconfig.json
Normal file
11
packages/filepicker/example/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "dist",
|
||||
"target": "es2015",
|
||||
"module": "es2015",
|
||||
"moduleResolution": "node",
|
||||
"baseUrl": ".",
|
||||
},
|
||||
"exclude": ["dist", "node_modules"]
|
||||
}
|
||||
54
packages/filepicker/example/webpack.config.js
Normal file
54
packages/filepicker/example/webpack.config.js
Normal 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,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
};
|
||||
4100
packages/filepicker/example/yarn.lock
Normal file
4100
packages/filepicker/example/yarn.lock
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user