Manual server file uploads implemented
This commit is contained in:
parent
c8308664d3
commit
70b5285e16
10 changed files with 329 additions and 26 deletions
|
|
@ -15,18 +15,62 @@ export class API {
|
|||
const res = await fetch(path, { method, headers, body })
|
||||
// An HTTP communication level error occured.
|
||||
if (!res.ok || res.status != 200)
|
||||
throw new Error('HTTP error', { cause: { type: 'http', error: res, }})
|
||||
throw new Error('HTTP error', { cause: { type: 'http', error: res, } })
|
||||
|
||||
// Application level response are handled here.
|
||||
const json = await res.json()
|
||||
if (!json.OK)
|
||||
throw new Error(json.Error, { cause: { type: 'application', application: json, }})
|
||||
throw new Error(json.Error, { cause: { type: 'application', application: json, } })
|
||||
|
||||
return json
|
||||
|
||||
} catch (err) {
|
||||
// Catch any other errors from fetch.
|
||||
throw new Error(err.message, { cause: { type: 'http', error: err, }})
|
||||
throw new Error(err.message, { cause: { type: 'http', error: err, } })
|
||||
}
|
||||
}
|
||||
|
||||
// Sends a block of binary data to server.
|
||||
static async upload(uuid, data, sentBytes, file) {
|
||||
try {
|
||||
const path = `/sync/file/upload/${uuid}`
|
||||
const headers = {
|
||||
'X-File-Sent-Bytes': sentBytes,
|
||||
'X-File-Total-Bytes': file.size,
|
||||
}
|
||||
|
||||
// Metadata is needed for the database.
|
||||
// No need to send it every block.
|
||||
if (sentBytes === 0) {
|
||||
headers['X-File-Name'] = file.name
|
||||
headers['X-File-Size'] = file.size
|
||||
headers['X-File-Type'] = file.type
|
||||
headers['X-File-Modified'] = file.lastModified
|
||||
}
|
||||
|
||||
// Authentication is done with a bearer token.
|
||||
// Here provided to the backend if set.
|
||||
const token = localStorage.getItem('token')
|
||||
if (token) {
|
||||
headers.Authorization = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const res = await fetch(path, { method: 'POST', headers, body: data })
|
||||
// An HTTP communication level error occured.
|
||||
if (!res.ok || res.status != 200)
|
||||
throw new Error('HTTP error', { cause: { type: 'http', error: res, } })
|
||||
|
||||
// Application level response are handled here.
|
||||
const json = await res.json()
|
||||
if (!json.OK)
|
||||
throw new Error(json.Error, { cause: { type: 'application', application: json, } })
|
||||
|
||||
return json
|
||||
|
||||
} catch (err) {
|
||||
// Catch any other errors from fetch.
|
||||
console.error(err)
|
||||
throw new Error(err, { cause: { type: 'http', error: err } })
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { CustomHTMLElement } from "./lib/custom_html_element.mjs";
|
||||
|
||||
export class N2File extends CustomHTMLElement {
|
||||
static {
|
||||
static {// {{{
|
||||
this.tmpl = document.createElement('template')
|
||||
this.tmpl.innerHTML = `
|
||||
<style>
|
||||
|
|
@ -30,24 +30,46 @@ export class N2File extends CustomHTMLElement {
|
|||
<img data-el="image" src="/images/${_VERSION}/file_icons/generic.svg">
|
||||
<div data-el="filename"></div>
|
||||
`
|
||||
}
|
||||
constructor() {
|
||||
}// }}}
|
||||
constructor() {// {{{
|
||||
super(true)
|
||||
|
||||
this.addEventListener('click', event => {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
|
||||
// Download to disk.
|
||||
if (event.shiftKey) {
|
||||
this.downloadToDisk()
|
||||
return
|
||||
}
|
||||
|
||||
// Open in browser.
|
||||
window.open(
|
||||
URL.createObjectURL(this.file),
|
||||
(event.ctrlKey || event.shiftKey) ? '_blank' : '_self',
|
||||
event.ctrlKey ? '_blank' : '_self',
|
||||
)
|
||||
})
|
||||
|
||||
this.render()
|
||||
}
|
||||
}// }}}
|
||||
async downloadToDisk() {// {{{
|
||||
try {
|
||||
const handle = await window.showSaveFilePicker({
|
||||
suggestedName: this.file.name,
|
||||
})
|
||||
|
||||
async render() {
|
||||
const writable = await handle.createWritable()
|
||||
const blobStream = this.file.stream()
|
||||
await blobStream.pipeTo(writable)
|
||||
} catch (err) {
|
||||
if (err.name == 'AbortError')
|
||||
return
|
||||
console.error(err)
|
||||
alert(err.message)
|
||||
}
|
||||
}// }}}
|
||||
async render() {// {{{
|
||||
const src = this.getAttribute('src')
|
||||
|
||||
// N2's db:// URLs are fetched from IndexedDB.
|
||||
|
|
@ -76,6 +98,6 @@ export class N2File extends CustomHTMLElement {
|
|||
}
|
||||
} else
|
||||
this.elImage.src = src
|
||||
}
|
||||
}// }}}
|
||||
}
|
||||
customElements.define('n2-file', N2File)
|
||||
|
|
|
|||
|
|
@ -15,12 +15,13 @@ export class NodeStore {
|
|||
this.sendQueue = null
|
||||
this.nodesHistory = null
|
||||
this.files = null
|
||||
this.filesMetadata = null
|
||||
|
||||
this.initializeSpecialNodes()
|
||||
}//}}}
|
||||
initializeDB() {//{{{
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = indexedDB.open('notes', 8)
|
||||
const req = indexedDB.open('notes', 10)
|
||||
|
||||
// Schema upgrades for IndexedDB.
|
||||
// These can start from different points depending on updates to Notes2 since a device was online.
|
||||
|
|
@ -71,6 +72,14 @@ export class NodeStore {
|
|||
case 8:
|
||||
files = db.createObjectStore('files', { keyPath: 'UUID' })
|
||||
break
|
||||
|
||||
case 9:
|
||||
db.createObjectStore('files_metadata', { keyPath: 'UUID' })
|
||||
break
|
||||
|
||||
case 10:
|
||||
trx.objectStore('files_metadata').createIndex('byUploaded', 'uploaded', { unique: false })
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -80,6 +89,7 @@ export class NodeStore {
|
|||
this.sendQueue = new SimpleNodeStore(this.db, 'send_queue')
|
||||
this.nodesHistory = new NodeHistoryStore(this.db, 'nodes_history')
|
||||
this.files = new SimpleNodeStore(this.db, 'files')
|
||||
this.filesMetadata = new SimpleNodeStore(this.db, 'files_metadata')
|
||||
resolve()
|
||||
}
|
||||
|
||||
|
|
@ -332,6 +342,56 @@ export class NodeStore {
|
|||
}
|
||||
})
|
||||
}//}}}
|
||||
|
||||
async storeFile(file) {// {{{
|
||||
const metadata = new FileMetadata(file, {})
|
||||
|
||||
await this.files.add({ data: { UUID: metadata.UUID, file } })
|
||||
await this.filesMetadata.add({ data: metadata })
|
||||
|
||||
return metadata
|
||||
}// }}}
|
||||
nextFileToSync() {// {{{
|
||||
return new Promise((resolve, reject) => {
|
||||
const req = this.db
|
||||
.transaction('files_metadata', 'readonly')
|
||||
.objectStore('files_metadata')
|
||||
.index('byUploaded')
|
||||
.get('only_on_device')
|
||||
|
||||
req.onerror = (e) => reject(e)
|
||||
req.onsuccess = (e) => {
|
||||
const data = e.target.result
|
||||
|
||||
// No more files to sync.
|
||||
if (data === undefined) {
|
||||
resolve({ file: null, metadata: null })
|
||||
return
|
||||
}
|
||||
|
||||
// Retrieve file as well.
|
||||
const filereq = this.db.
|
||||
transaction('files', 'readonly')
|
||||
.objectStore('files')
|
||||
.get(data.UUID)
|
||||
|
||||
filereq.onsuccess = (e) => {
|
||||
const f = e.target.result
|
||||
|
||||
// File should always be in IndexedDB when metadata is.
|
||||
if (f === undefined) {
|
||||
reject(new Error(`File with UUID '${data.UUID}' is missing.`))
|
||||
return
|
||||
}
|
||||
|
||||
resolve({
|
||||
file: f.file,
|
||||
metadata: new FileMetadata(f.file, data),
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}// }}}
|
||||
}
|
||||
|
||||
class SimpleNodeStore {
|
||||
|
|
@ -351,7 +411,6 @@ class SimpleNodeStore {
|
|||
// Node to be moved is first stored in the new queue.
|
||||
const req = store.put(node.data)
|
||||
req.onsuccess = () => {
|
||||
console.log('here')
|
||||
resolve()
|
||||
}
|
||||
req.onerror = (event) => {
|
||||
|
|
@ -539,4 +598,29 @@ export function uuidv7() {// {{{
|
|||
return `${str.slice(0, 8)}-${str.slice(8, 12)}-${str.slice(12, 16)}-${str.slice(16, 20)}-${str.slice(20)}`
|
||||
}// }}}
|
||||
|
||||
class FileMetadata {
|
||||
constructor(file, data) {// {{{
|
||||
this.UUID = data.UUID || uuidv7()
|
||||
this.uploaded = data.uploaded || 'only_on_device'
|
||||
|
||||
this.name = file.name
|
||||
this.size = file.size
|
||||
this.type = file.type || 'application/octet-stream'
|
||||
this.modified = file.lastModified
|
||||
}// }}}
|
||||
setUploaded() {// {{{
|
||||
this.uploaded = 'on_server'
|
||||
}// }}}
|
||||
get data() {// {{{
|
||||
return {
|
||||
UUID: this.UUID,
|
||||
uploaded: this.uploaded,
|
||||
name: this.name,
|
||||
size: this.size,
|
||||
type: this.type,
|
||||
modified: this.modified,
|
||||
}
|
||||
}// }}}
|
||||
}
|
||||
|
||||
// vim: foldmethod=marker
|
||||
|
|
|
|||
|
|
@ -267,11 +267,11 @@ export class N2PageNodeUI extends CustomHTMLElement {
|
|||
const file = item.getAsFile()
|
||||
if (!file)
|
||||
throw new Error("Couldn't convert image to file object.")
|
||||
const uuid = uuidv7()
|
||||
await globalThis.nodeStore.files.add({ data: { UUID: uuid, file: file } })
|
||||
|
||||
const fileMetadata = await globalThis.nodeStore.storeFile(file)
|
||||
|
||||
const [start, end] = [this.elNodeContent.selectionStart, this.elNodeContent.selectionEnd]
|
||||
this.elNodeContent.setRangeText(``, start, end, 'select');
|
||||
this.elNodeContent.setRangeText(``, start, end, 'select');
|
||||
|
||||
// Editing the textarea programatically doesn't generate the events it usually gets when edited interactively.
|
||||
this.node.setContent(this.elNodeContent.value)
|
||||
|
|
|
|||
|
|
@ -164,6 +164,36 @@ export class Sync {
|
|||
}
|
||||
}
|
||||
}//}}}
|
||||
async filesToServer() {
|
||||
try {
|
||||
const BLOCKSIZE = 128*1024
|
||||
let sentBytes = 0
|
||||
|
||||
const { file, metadata } = await nodeStore.nextFileToSync()
|
||||
if (file === null)
|
||||
return
|
||||
|
||||
const stream = file.stream()
|
||||
const reader = stream.getReader({ mode: 'byob' }) // Bring Your Own Buffer
|
||||
let buffer = new Uint8Array(BLOCKSIZE)
|
||||
|
||||
while (true) {
|
||||
const { value, done } = await reader.read(buffer)
|
||||
|
||||
await API.upload(metadata.UUID, value, sentBytes, file)
|
||||
metadata.setUploaded()
|
||||
nodeStore.filesMetadata.add(metadata)
|
||||
|
||||
if (done)
|
||||
break
|
||||
sentBytes += value.length
|
||||
buffer = new Uint8Array(value.buffer)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert(e.message)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export class N2SyncProgress extends CustomHTMLElement {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue