From 3d94932da6d0828fc02c11e602eec88fdb7a7b38 Mon Sep 17 00:00:00 2001 From: Dannii Willis Date: Mon, 7 Oct 2024 14:38:21 +1000 Subject: [PATCH] Use a controller to work between the dialog and storage, the dialog is now much more polished --- src/dialog/browser/browser.ts | 128 ++++++++++++--- src/dialog/browser/common.ts | 95 +---------- src/dialog/browser/download.ts | 11 +- src/dialog/browser/interface.ts | 10 +- src/dialog/browser/storage.ts | 51 +++--- src/dialog/browser/ui/AlertDialog.svelte | 14 +- src/dialog/browser/ui/DirTree.svelte | 9 +- src/dialog/browser/ui/FileDialog.svelte | 185 +++++++++++++++------- src/dialog/browser/ui/FileList.svelte | 60 ------- src/dialog/browser/ui/FileListItem.svelte | 38 ++++- 10 files changed, 323 insertions(+), 278 deletions(-) delete mode 100644 src/dialog/browser/ui/FileList.svelte diff --git a/src/dialog/browser/browser.ts b/src/dialog/browser/browser.ts index b0f4e41..121ab86 100644 --- a/src/dialog/browser/browser.ts +++ b/src/dialog/browser/browser.ts @@ -9,17 +9,18 @@ https://github.com/curiousdannii/asyncglk */ +import {saveAs as filesave_saveAs} from 'file-saver' import path from 'path-browserify-esm' import type {DialogDirectories, DialogOptions} from '../common/interface.js' -import {DownloadProvider} from './download.js' -import type {BrowserDialog, DownloadOptions, ProgressCallback, Provider} from './interface.js' +import {DownloadProvider, read_uploaded_file} from './download.js' +import type {BrowserDialog, DirEntry, DownloadOptions, FilesMetadata, ProgressCallback, Provider} from './interface.js' import {WebStorageProvider} from './storage.js' -import DialogUI from './ui/FileDialog.svelte' +import FileDialog from './ui/FileDialog.svelte' export class ProviderBasedBrowserDialog implements BrowserDialog { 'async' = true as const - private dialog: DialogUI | undefined + private controller!: DialogController private dirs: DialogDirectories = { storyfile: '', system_cwd: '/', @@ -43,16 +44,11 @@ export class ProviderBasedBrowserDialog implements BrowserDialog { if (next) { provider.next = next } + // Set up the Svelte UI + if (provider.browseable) { + this.controller = new DialogController(provider) + } } - - // Set up the Svelte UI - const dir_browser = await this.providers[0].browse() - this.dialog = new DialogUI({ - target: document.body, - props: { - dir_browser, - }, - }) } async download(url: string, progress_callback?: ProgressCallback): Promise { @@ -65,16 +61,8 @@ export class ProviderBasedBrowserDialog implements BrowserDialog { return this.dirs } - async prompt(extension: string, save: boolean): Promise { - const action = save ? 'Save' : 'Restore' - const filter = extension_to_filter(extension) - return this.dialog!.prompt({ - dir_browser: await this.providers[0].browse(), - filter, - save, - submit_label: action, - title: `${action} a ${filter?.title}`, - }) + prompt(extension: string, save: boolean): Promise { + return this.controller.prompt(extension, save) } set_storyfile_dir(path: string): Partial { @@ -109,7 +97,99 @@ export class ProviderBasedBrowserDialog implements BrowserDialog { const parsed_path = path.parse(file_path) this.dirs.storyfile = parsed_path.dir this.dirs.working = '/usr/' + parsed_path.name.toLowerCase().trim() - this.dialog!.update_direntry(this.dirs.working) + this.controller.update_working(this.dirs.working) + } +} + +/** Controller for FileDialog */ +export class DialogController { + private dialog: FileDialog + private metadata: FilesMetadata = {} + private provider: Provider + + constructor(provider: Provider) { + this.dialog = new FileDialog({ + target: document.body, + props: { + controller: this, + }, + }) + this.provider = provider + } + + async prompt(extension: string, save: boolean): Promise { + const action = save ? 'Save' : 'Restore' + const filter = extension_to_filter(extension) + this.metadata = await this.provider.metadata() + return this.dialog.prompt({ + filter, + metadata: this.metadata, + save, + submit_label: action, + title: `${action} a ${filter?.title}`, + }) + } + + async delete(file: DirEntry) { + if (file.dir) { + const dir_path = file.full_path + '/' + for (const path of Object.keys(this.metadata)) { + if (path.startsWith(dir_path)) { + delete this.metadata[path] + if (!path.endsWith('.fakefile')) { + await this.provider.delete(path) + } + } + } + } + else { + await this.provider.delete(file.full_path) + delete this.metadata[file.full_path] + } + await this.update() + } + + async download(file: DirEntry) { + const data = (await this.provider.read(file.full_path))! + filesave_saveAs(new Blob([data]), file.name) + } + + new_folder(path: string) { + this.metadata[path + '/.fakefile'] = {atime: 0, mtime: 0} + this.dialog.$set({ + cur_dir: path, + metadata: this.metadata, + }) + } + + async rename(file: DirEntry, new_file_name: string) { + const parsed_path = path.parse(file.full_path) + const new_path = parsed_path.dir + '/' + new_file_name + await this.provider.rename(file.dir, file.full_path, new_path) + await this.update(true) + } + + async update(refresh: boolean = false) { + if (refresh) { + this.metadata = await this.provider.metadata() + } + this.dialog.$set({metadata: this.metadata}) + } + + update_working(path: string) { + this.dialog.$set({cur_dir: path}) + } + + async upload(files: Record) { + const now = Date.now() + for (const [path, data] of Object.entries(files)) { + await this.provider.write(path, await read_uploaded_file(data)) + this.metadata[path] = { + atime: now, + mtime: now, + } + } + await this.update() } } diff --git a/src/dialog/browser/common.ts b/src/dialog/browser/common.ts index bf31f82..77b07f9 100644 --- a/src/dialog/browser/common.ts +++ b/src/dialog/browser/common.ts @@ -9,16 +9,11 @@ https://github.com/curiousdannii/asyncglk */ -import path from 'path-browserify-esm' -import {saveAs as filesave_saveAs} from 'file-saver' - -import type {DirEntry, FileData, FilesMetadata, Provider} from './interface.js' +import type {FilesMetadata, Provider} from './interface.js' export class NullProvider implements Provider { + browseable = false next: Provider = this - async browse(): Promise { - throw new Error('A NullProvider should not be browsed') - } async delete(_path: string) { return null } @@ -31,90 +26,10 @@ export class NullProvider implements Provider { async read(_path: string) { return null } + rename(): Promise { + throw new Error('Invalid method') + } async write(_path: string, _data: Uint8Array) { return null } -} - -interface NestableDirEntry extends DirEntry { - children?: NestableDirEntry[] - dir: boolean - name: string - meta?: FileData -} - -/** A caching directory browser that receives the list of files once and remembers for as long as the dialog is open */ -export class DirBrowser { - files!: NestableDirEntry - provider: Provider - - constructor(files: FilesMetadata, provider: Provider) { - this.provider = provider - this.update(files) - } - - async add_files(files: Record) { - for (const [path, data] of Object.entries(files)) { - await this.provider.write(path, data) - } - this.update(await this.provider.metadata()) - } - - browse(dir_path: string): DirEntry[] { - if (!dir_path.startsWith('/usr')) { - throw new Error('Can only browse /usr') - } - return this.cd(dir_path).children! - } - - private cd(path: string): NestableDirEntry { - const dirs = path.substring(1).split('/') - dirs.shift() - let dir_entry = this.files - for (const subdir of dirs) { - let new_subdir = dir_entry.children!.find(child => child.name === subdir)! - if (!new_subdir) { - new_subdir = { - children: [], - dir: true, - full_path: dir_entry.full_path + '/' + subdir, - name: subdir, - } - dir_entry.children!.push(new_subdir) - } - dir_entry = new_subdir - } - return dir_entry - } - - async delete(file: DirEntry) { - await this.provider.delete(file.full_path) - this.update(await this.provider.metadata()) - } - - async download(file: DirEntry) { - const data = (await this.provider.read(file.full_path))! - filesave_saveAs(new Blob([data]), file.name) - } - - private update(metadata: FilesMetadata) { - this.files = { - children: [], - dir: true, - full_path: '/usr', - name: 'usr', - } - for (const [file_path, meta] of Object.entries(metadata)) { - if (file_path.startsWith('/usr/')) { - const parsed_path = path.parse(file_path) - const dir_entry = this.cd(parsed_path.dir) - dir_entry.children!.push({ - dir: false, - full_path: file_path, - name: parsed_path.base, - meta, - }) - } - } - } } \ No newline at end of file diff --git a/src/dialog/browser/download.ts b/src/dialog/browser/download.ts index 7663d5f..f032b77 100644 --- a/src/dialog/browser/download.ts +++ b/src/dialog/browser/download.ts @@ -11,11 +11,12 @@ https://github.com/curiousdannii/asyncglk // The download provider stores its own files just in a map (maybe to be cached in the future), but if files are written next to them, then they need to be done so in another provider -import {DirBrowser, NullProvider} from './common.js' +import {NullProvider} from './common.js' import type {DownloadOptions, FilesMetadata, ProgressCallback, Provider} from './interface.js' import {utf8decoder} from '../../common/misc.js' export class DownloadProvider implements Provider { + browseable = false next = new NullProvider() private options: DownloadOptions private store: Map = new Map() @@ -38,10 +39,6 @@ export class DownloadProvider implements Provider { return path } - async browse(): Promise { - return this.next.browse() - } - async delete(path: string): Promise { if (this.store.has(path)) { this.store.delete(path) @@ -74,6 +71,10 @@ export class DownloadProvider implements Provider { // TODO: try downloading a sibling file } + rename(): Promise { + throw new Error('Invalid method') + } + async write(path: string, data: Uint8Array): Promise { return this.next.write(path, data) } diff --git a/src/dialog/browser/interface.ts b/src/dialog/browser/interface.ts index da68333..43a2e18 100644 --- a/src/dialog/browser/interface.ts +++ b/src/dialog/browser/interface.ts @@ -11,8 +11,6 @@ https://github.com/curiousdannii/asyncglk import type {AsyncDialog} from '../common/interface.js' -import {DirBrowser} from './common.js' - export type ProgressCallback = (bytes: number) => void export interface BrowserDialog extends AsyncDialog { @@ -31,10 +29,10 @@ export interface DownloadOptions { /** A provider handles part of the filesystem, and can cascade down to another provider for files it doesn't handle */ export interface Provider { + /** Whether we can browse this provider */ + browseable: boolean /** A link to the next provider */ next: Provider - /** Get a `DirBrowser` instance for browsing */ - browse(): Promise /** Delete a file */ delete(path: string): Promise /** Check if a file exists */ @@ -43,6 +41,8 @@ export interface Provider { metadata(): Promise /** Read a file */ read(path: string): Promise + /** Rename a file or folder */ + rename(dir: boolean, path: string, new_name: string): Promise /** Write a file */ write(path: string, data: Uint8Array): Promise } @@ -50,8 +50,8 @@ export interface Provider { export interface DirEntry { dir: boolean full_path: string - name: string meta?: FileData + name: string } export type FilesMetadata = Record diff --git a/src/dialog/browser/storage.ts b/src/dialog/browser/storage.ts index 5ac4159..65fef50 100644 --- a/src/dialog/browser/storage.ts +++ b/src/dialog/browser/storage.ts @@ -11,7 +11,7 @@ https://github.com/curiousdannii/asyncglk import {decode as base32768_decode, encode as base32768_encode} from 'base32768' -import {DirBrowser, NullProvider} from './common.js' +import {NullProvider} from './common.js' import type {FilesMetadata, Provider} from './interface.js' //type WebStorageFileMetadata = Pick @@ -22,11 +22,12 @@ const STORAGE_VERSION_KEY = 'dialog_storage_version' const enum MetadataUpdateOperation { DELETE = 1, READ = 2, - WRITE = 3, + RENAME = 3, + WRITE = 4, } export class WebStorageProvider implements Provider { - private browseable: boolean + browseable: boolean next = new NullProvider() private prefix: string private store: Storage @@ -41,20 +42,10 @@ export class WebStorageProvider implements Provider { } } - async browse(): Promise { - if (this.browseable) { - const metadata = this.metadata() - return new DirBrowser(metadata, this) - } - else { - return this.next.browse() - } - } - async delete(path: string): Promise { if (path.startsWith(this.prefix)) { this.store.removeItem(path) - this.update_metadata(path, MetadataUpdateOperation.DELETE) + await this.update_metadata(path, MetadataUpdateOperation.DELETE) } else { return this.next.delete(path) @@ -70,7 +61,7 @@ export class WebStorageProvider implements Provider { } } - metadata() { + async metadata(): Promise { return JSON.parse(this.store.getItem(METADATA_KEY) || '{}') } @@ -78,7 +69,7 @@ export class WebStorageProvider implements Provider { if (path.startsWith(this.prefix)) { const res = this.store.getItem(path) if (res !== null) { - this.update_metadata(path, MetadataUpdateOperation.READ) + await this.update_metadata(path, MetadataUpdateOperation.READ) return base32768_decode(res) } return null @@ -88,11 +79,29 @@ export class WebStorageProvider implements Provider { } } + async rename(dir: boolean, old_path: string, new_path: string): Promise { + if (dir) { + const metadata = await this.metadata() + for (const path of Object.keys(metadata)) { + if (path.startsWith(old_path)) { + const new_file_path = path.replace(old_path, new_path) + await this.rename(false, path, new_file_path) + } + } + } + else { + const data = await this.read(old_path) + await this.write(new_path, data!) + await this.update_metadata(new_path, MetadataUpdateOperation.RENAME, old_path) + await this.delete(old_path) + } + } + async write(path: string, data: Uint8Array): Promise { if (path.startsWith(this.prefix)) { // TODO: detect out of space this.store.setItem(path, base32768_encode(data)) - this.update_metadata(path, MetadataUpdateOperation.WRITE) + await this.update_metadata(path, MetadataUpdateOperation.WRITE) return null } else { @@ -100,9 +109,9 @@ export class WebStorageProvider implements Provider { } } - private update_metadata(path: string, op: MetadataUpdateOperation) { + private async update_metadata(path: string, op: MetadataUpdateOperation, old_path?: string) { const now = Date.now() - const metadata: FilesMetadata = this.metadata() + const metadata: FilesMetadata = await this.metadata() switch (op) { case MetadataUpdateOperation.DELETE: delete metadata[path] @@ -110,6 +119,10 @@ export class WebStorageProvider implements Provider { case MetadataUpdateOperation.READ: metadata[path].atime = now break + case MetadataUpdateOperation.RENAME: + metadata[path] = metadata[old_path!] + delete metadata[old_path!] + break case MetadataUpdateOperation.WRITE: if (!metadata[path]) { metadata[path] = { diff --git a/src/dialog/browser/ui/AlertDialog.svelte b/src/dialog/browser/ui/AlertDialog.svelte index 3654e07..8020433 100644 --- a/src/dialog/browser/ui/AlertDialog.svelte +++ b/src/dialog/browser/ui/AlertDialog.svelte @@ -9,16 +9,16 @@ import BaseDialog from './BaseDialog.svelte' let base_dialog: BaseDialog - let message: string - let mode: AlertMode + export let initial: string | undefined + export let message: string + export let mode: AlertMode + export let title: string let val_input: HTMLTextAreaElement - export function open(_mode: AlertMode, title: string, _message: string): Promise { - message = _message - mode = _mode + export function open(): Promise { const promise = base_dialog.open(title) if (val_input) { - val_input.value = '' + val_input.value = initial || '' val_input.focus() } return promise @@ -31,7 +31,7 @@ function on_create_input(node: HTMLTextAreaElement) { val_input = node val_input.focus() - val_input.value = '' + val_input.value = initial || '' } function on_input_keydown(ev: KeyboardEvent) { diff --git a/src/dialog/browser/ui/DirTree.svelte b/src/dialog/browser/ui/DirTree.svelte index cfbe631..e31322f 100644 --- a/src/dialog/browser/ui/DirTree.svelte +++ b/src/dialog/browser/ui/DirTree.svelte @@ -1,13 +1,12 @@ diff --git a/src/dialog/browser/ui/FileDialog.svelte b/src/dialog/browser/ui/FileDialog.svelte index 9287e8c..8110d55 100644 --- a/src/dialog/browser/ui/FileDialog.svelte +++ b/src/dialog/browser/ui/FileDialog.svelte @@ -1,11 +1,10 @@ @@ -187,6 +248,13 @@ text-align: right; } + #filelist { + border: 2px solid var(--asyncglk-ui-border); + flex: 1; + overflow-y: scroll; + padding: 6px; + } + .filename { display: flex; } @@ -216,21 +284,21 @@ - + + +
+ {#each cur_direntry as file} + + {/each}
- {#key cur_filter} - - {/key} {#if saving}
@@ -257,5 +325,4 @@
- \ No newline at end of file diff --git a/src/dialog/browser/ui/FileList.svelte b/src/dialog/browser/ui/FileList.svelte deleted file mode 100644 index 7656d4d..0000000 --- a/src/dialog/browser/ui/FileList.svelte +++ /dev/null @@ -1,60 +0,0 @@ - - - - -
- {#each files as file, i} - {#if filter_file(file)} - - {/if} - {/each} -
\ No newline at end of file diff --git a/src/dialog/browser/ui/FileListItem.svelte b/src/dialog/browser/ui/FileListItem.svelte index 33cfc64..668d2c5 100644 --- a/src/dialog/browser/ui/FileListItem.svelte +++ b/src/dialog/browser/ui/FileListItem.svelte @@ -1,4 +1,6 @@