Files
Andreas Reinhold / reiniandClaude Opus 5.5 30a485f5d9 Mark the file the folder download is on
During "Download all to a folder" the recipient saw only the file name
under the progress bar. The file's row in the list now shows the loading
indicator while it is on its way, and the list scrolls that row into
view, so a run through hundreds of files can be followed in the list.
The row is scrolled rather than the indicator, which is still hidden
when the run moves on to the file.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 23:28:23 +02:00

252 lines
8.6 KiB
JavaScript

/**
* `shareDownloader`: the download page's file list. It remembers, in this browser, which files the
* recipient has downloaded, and where the browser can write to a folder (the File System Access
* API: Chrome and Edge on a computer) it downloads every file into the folder the recipient picks,
* one at a time, in its folder path.
*
* One request per file keeps every request short enough for a proxy in front of the server. A
* request that breaks off is retried after 1, 2, 4, 8 and 16 seconds from the byte the file on
* disk has reached (an HTTP Range request); a file already there at its full size is skipped, so
* running it again after a stop picks up where it stopped. A file that still fails is listed for
* the Retry button; a share that is gone (404) stops the run.
*/
const RETRY_DELAYS = [1000, 2000, 4000, 8000, 16000]
const UNITS = ['B', 'KB', 'MB', 'GB', 'TB']
function formatSize(bytes) {
let unit = 0
while (bytes >= 1024 && unit < UNITS.length - 1) {
bytes /= 1024
unit++
}
return `${bytes.toFixed(unit === 0 ? 0 : 1)} ${UNITS[unit]}`
}
function readStored(key) {
try {
return JSON.parse(window.localStorage.getItem(key)) ?? []
} catch {
return []
}
}
/** The file's handle under the picked folder, creating its subfolders; never a path out of it. */
async function fileHandle(directory, path) {
const segments = path.split('/').filter((segment) => segment !== '' && segment !== '.' && segment !== '..')
const name = segments.pop()
for (const segment of segments) {
directory = await directory.getDirectoryHandle(segment, { create: true })
}
return directory.getFileHandle(name, { create: true })
}
document.addEventListener('alpine:init', () => {
window.Alpine.data('shareDownloader', ({ token, files, messages }) => {
const storageKey = `sealshare.downloaded.${token}`
const total = files.reduce((sum, file) => sum + file.size, 0)
// Kept out of Alpine's reactive data: a proxied native handle throws on its own methods.
let directory = null
let controller = null
return {
supported: window.isSecureContext && 'showDirectoryPicker' in window,
/** The ids of the files this browser has downloaded. */
done: readStored(storageKey),
running: false,
/** Bytes of the run's files on disk, for the progress bar. */
received: 0,
/** How many of the run's files are in the folder. */
saved: 0,
/** The file on its way: its path for the status line, its id for its row in the list. */
current: null,
currentId: null,
/** The files that failed, kept for the Retry button. */
failed: [],
/** Why the run ended early, if it did. */
error: null,
finished: false,
get progress() {
return total === 0 ? 100 : (this.received / total) * 100
},
get status() {
return messages.progress
.replace(':saved', this.saved)
.replace(':count', files.length)
.replace(':received', formatSize(this.received))
.replace(':total', formatSize(total))
},
isDone(id) {
return this.done.includes(id)
},
markDone(id) {
if (this.done.includes(id)) {
return
}
this.done.push(id)
try {
window.localStorage.setItem(storageKey, JSON.stringify(this.done))
} catch {
// Private mode or storage blocked: the marks last as long as the page.
}
},
async downloadAll() {
try {
directory = await window.showDirectoryPicker({ id: 'sealshare', mode: 'readwrite' })
} catch {
return
}
this.run(files)
},
retry() {
this.run(this.failed)
},
cancel() {
controller?.abort()
},
async run(list) {
controller = new AbortController()
this.running = true
this.finished = false
this.error = null
this.failed = []
this.saved = files.length - list.length
this.received = total - list.reduce((sum, file) => sum + file.size, 0)
this.downloaded = true
for (const file of list) {
const before = this.received
this.current = file.path
this.currentId = file.id
try {
await this.save(file, controller.signal)
this.saved++
this.markDone(file.id)
} catch (error) {
if (controller.signal.aborted) {
break
}
if (error.status === 404) {
this.error = messages.gone
break
}
this.failed.push(file)
this.received = before + file.size
}
}
this.running = false
this.current = null
this.currentId = null
this.finished = ! controller.signal.aborted
},
async save(file, signal) {
const handle = await fileHandle(directory, file.path)
const before = this.received
// ponytail: a file is matched by name and size only, so a different file of the same name and size already in the picked folder is kept; compare a checksum if that ever bites.
let written = (await handle.getFile()).size
if (written > file.size) {
written = 0
}
for (let attempt = 0; written < file.size; attempt++) {
try {
written = await this.fetchInto(handle, file, written, signal, (bytes) => (this.received = before + bytes))
} catch (error) {
if (signal.aborted || error.status === 404 || attempt >= RETRY_DELAYS.length) {
throw error
}
await new Promise((resolve) => setTimeout(resolve, RETRY_DELAYS[attempt]))
written = (await handle.getFile()).size
}
}
this.received = before + file.size
},
/** Write the file from byte `offset` on; the bytes that arrived are kept even when the connection breaks. */
async fetchInto(handle, file, offset, signal, onProgress) {
const response = await fetch(file.url, {
headers: offset > 0 ? { Range: `bytes=${offset}-` } : {},
signal,
})
if (! response.ok) {
throw Object.assign(new Error(`HTTP ${response.status}`), { status: response.status })
}
if (response.status !== 206) {
offset = 0
}
const writable = await handle.createWritable({ keepExistingData: offset > 0 })
const reader = response.body.getReader()
try {
await writable.truncate(offset)
await writable.seek(offset)
for (;;) {
const { done, value } = await reader.read()
if (done) {
break
}
await writable.write(value)
offset += value.length
onProgress(offset)
}
} finally {
// Closing commits what was written, so a retry continues from there.
await writable.close().catch(() => {})
}
if (offset < file.size) {
throw new Error('The download ended early')
}
return offset
},
warnBeforeLeaving(event) {
if (this.running) {
event.preventDefault()
event.returnValue = ''
}
},
}
})
})