Skip to content

Commit

Permalink
Can now save/restore using a new Svelte dialog
Browse files Browse the repository at this point in the history
  • Loading branch information
curiousdannii committed Jul 17, 2024
1 parent 9b5ea57 commit 334df85
Show file tree
Hide file tree
Showing 27 changed files with 420 additions and 96 deletions.
22 changes: 17 additions & 5 deletions src/dialog/browser/browser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,15 @@ https://github.com/curiousdannii/asyncglk
*/

import {AsyncDialog, DialogDirectories, DialogOptions} from '../common/interface.js'
import {DownloadOptions, DownloadProvider, ProgressCallback} from './download.js'
import {Provider} from './interface.js'
import type {AsyncDialog, DialogDirectories, DialogOptions} from '../common/interface.js'
import {type DownloadOptions, DownloadProvider, type ProgressCallback} from './download.js'
import type {Provider} from './interface.js'
import {WebStorageProvider} from './storage.js'
import DialogUI from './ui/Dialog.svelte'

Check failure on line 16 in src/dialog/browser/browser.ts

View workflow job for this annotation

GitHub Actions / test (Node v18)

Cannot find module './ui/Dialog.svelte' or its corresponding type declarations.

Check failure on line 16 in src/dialog/browser/browser.ts

View workflow job for this annotation

GitHub Actions / test (Node v20)

Cannot find module './ui/Dialog.svelte' or its corresponding type declarations.

Check failure on line 16 in src/dialog/browser/browser.ts

View workflow job for this annotation

GitHub Actions / test (Node v22)

Cannot find module './ui/Dialog.svelte' or its corresponding type declarations.

export class BrowserDialog implements AsyncDialog {
'async' = true as const
private dialog: DialogUI | undefined
private dirs: DialogDirectories = {
storyfile: '',
system_cwd: '/usr',
Expand All @@ -26,12 +28,15 @@ export class BrowserDialog implements AsyncDialog {
private providers: Provider[] = []

async init(options: DialogOptions & DownloadOptions): Promise<void> {
this.dialog = new DialogUI({
target: document.body,
})
this.downloader = new DownloadProvider(options)
// TODO: ensure that localStorage is wrapped in a try/catch in case it's disabled
this.providers = [
this.downloader,
new WebStorageProvider('/tmp', sessionStorage),
new WebStorageProvider('/', localStorage),
new WebStorageProvider('/', localStorage, true),
]

for (const [i, provider] of this.providers.entries()) {
Expand All @@ -51,7 +56,14 @@ export class BrowserDialog implements AsyncDialog {
}

async prompt(extension: string, save: boolean): Promise<string | null> {
return prompt('Filename')
const dir_browser = await this.providers[0].browse()
return this.dialog!.prompt({
dir: '/usr',
dir_browser,
save,
submit_label: save ? 'Save' : 'Restore',
title: 'Filename',
})
}

set_storyfile_dir(path: string): Partial<DialogDirectories> {
Expand Down
100 changes: 100 additions & 0 deletions src/dialog/browser/common.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
Common implementations
======================
Copyright (c) 2024 Dannii Willis
MIT licenced
https://github.com/curiousdannii/asyncglk
*/

import path from 'path-browserify-esm'

Check failure on line 12 in src/dialog/browser/common.ts

View workflow job for this annotation

GitHub Actions / test (Node v18)

Cannot find module 'path-browserify-esm' or its corresponding type declarations.

Check failure on line 12 in src/dialog/browser/common.ts

View workflow job for this annotation

GitHub Actions / test (Node v20)

Cannot find module 'path-browserify-esm' or its corresponding type declarations.

Check failure on line 12 in src/dialog/browser/common.ts

View workflow job for this annotation

GitHub Actions / test (Node v22)

Cannot find module 'path-browserify-esm' or its corresponding type declarations.

import type {Provider, DirBrowser, FileData, DirEntry} from './interface.js'

export class NullProvider implements Provider {
next: Provider = this
async browse(): Promise<DirBrowser> {
return new CachingDirBrowser({}, this)
}
async delete(_path: string) {
return null
}
async exists(_path: string) {
return null
}
async read(_path: string) {
return null
}
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 CachingDirBrowser implements DirBrowser {
files: NestableDirEntry = {
children: [],
dir: true,
full_path: '/usr',
name: 'usr',
}
provider: Provider

constructor(files: Record<string, FileData>, provider: Provider) {
this.provider = provider
for (const [file_path, meta] of Object.entries(files)) {
if (file_path.startsWith('/usr/')) {
const parsed_path = path.parse(file_path)
const dirs = parsed_path.dir.substring(1).split('/')
dirs.shift()
// Find the directory for this file, creating it if necessary
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
}
dir_entry.children!.push({
dir: false,
full_path: file_path,
name: parsed_path.base,
meta,
})
}
}
}

async browse(dir_path: string): Promise<DirEntry[]> {
if (!dir_path.startsWith('/usr')) {
throw new Error('Can only browse /usr')
}
const parsed_path = path.parse(dir_path)
const dirs = parsed_path.dir.substring(1).split('/')
dirs.shift()
let dir_entry = this.files
for (const subdir of dirs) {
dir_entry = dir_entry.children!.find(child => child.name === subdir)!
if (!dir_entry) {
throw new Error('Invalid directory state')
}
}
return dir_entry.children!
}
}
11 changes: 6 additions & 5 deletions src/dialog/browser/download.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,8 @@ 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 {DirEntry, NullProvider, Provider} from './interface.js'
import {NullProvider} from './common.js'
import {type DirBrowser, type Provider} from './interface.js'
import {utf8decoder} from '../../common/misc.js'

export interface DownloadOptions {
Expand Down Expand Up @@ -41,6 +42,10 @@ export class DownloadProvider implements Provider {
return path
}

async browse(): Promise<DirBrowser> {
return this.next.browse()
}

async delete(path: string): Promise<void | null> {
if (this.store.has(path)) {
this.store.delete(path)
Expand All @@ -59,10 +64,6 @@ export class DownloadProvider implements Provider {
}
}

async list(path: string): Promise<DirEntry[] | null> {
throw new Error('Should not be browsing the download provider')
}

async read(path: string): Promise<Uint8Array | null> {
if (this.store.has(path)) {
return this.store.get(path)!
Expand Down
39 changes: 12 additions & 27 deletions src/dialog/browser/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,53 +10,38 @@ https://github.com/curiousdannii/asyncglk
*/

/** A provider handles part of the filesystem, and can cascade down to another provider for files it doesn't handle */
// Inspired by Koa
export interface Provider {
/** A link to the next provider */
next: Provider
/** Get a `DirBrowser` instance for browsing */
browse(): Promise<DirBrowser>
/** Delete a file */
delete(path: string): Promise<void | null>
/** Check if a file exists */
exists(path: string): Promise<boolean | null>
/** Directory listing */
list(path: string): Promise<DirEntry[] | null>
/** Read a file */
read(path: string): Promise<Uint8Array | null>
/** Write a file */
write(path: string, data: Uint8Array): Promise<void | null>
}

/** Browse a directory; may cache all the files or request each time you change directory */
export interface DirBrowser {
browse(path: string): Promise<DirEntry[]>
}

export interface DirEntry {
dir: boolean
full_path: string
name: string
meta?: FileData
}

export interface FileData {
accessed: number
created: number
atime: number
etag?: string
data: Uint8Array
'last-modified'?: string
modified: number
path_prefix: string
mtime: number
path_prefix?: string
story_id?: string
}

export class NullProvider implements Provider {
next: Provider = this
async delete(_path: string) {
return null
}
async exists(_path: string) {
return null
}
async list(_path: string) {
return null
}
async read(_path: string) {
return null
}
async write(_path: string, _data: Uint8Array) {
return null
}
}
44 changes: 28 additions & 16 deletions src/dialog/browser/storage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@ https://github.com/curiousdannii/asyncglk

import {decode as base32768_decode, encode as base32768_encode} from 'base32768'

import {DirEntry, FileData, NullProvider, Provider} from './interface.js'
import {CachingDirBrowser, NullProvider} from './common.js'
import type {DirBrowser, FileData, Provider} from './interface.js'

type WebStorageFileMetadata = Pick<FileData, 'accessed' | 'created' | 'modified'>
type WebStorageFileMetadata = Pick<FileData, 'atime' | 'mtime'>

const METADATA_KEY = 'dialog_metadata'

Expand All @@ -24,17 +25,29 @@ const enum MetadataUpdateOperation {
}

export class WebStorageProvider implements Provider {
private browseable: boolean
next = new NullProvider()
prefix: string
store: Storage
private prefix: string
private store: Storage

constructor(prefix: string, store: Storage) {
constructor(prefix: string, store: Storage, browseable?: boolean) {
this.browseable = browseable ?? false
this.prefix = prefix
this.store = store

// TODO: upgrade storage
}

async browse(): Promise<DirBrowser> {
if (this.browseable) {
const metadata = this.get_metadata()
return new CachingDirBrowser(metadata, this)
}
else {
return this.next.browse()
}
}

async delete(path: string): Promise<void | null> {
if (path.startsWith(this.prefix)) {
this.store.removeItem(path)
Expand All @@ -54,10 +67,6 @@ export class WebStorageProvider implements Provider {
}
}

async list(path: string): Promise<DirEntry[] | null> {
throw new Error('Not implemented yet')
}

async read(path: string): Promise<Uint8Array | null> {
if (path.startsWith(this.prefix)) {
const res = this.store.getItem(path)
Expand All @@ -84,25 +93,28 @@ export class WebStorageProvider implements Provider {
}
}

update_metadata(path: string, op: MetadataUpdateOperation) {
private get_metadata(): Record<string, WebStorageFileMetadata> {
return JSON.parse(this.store.getItem(METADATA_KEY) || '{}')
}

private update_metadata(path: string, op: MetadataUpdateOperation) {
const now = Date.now()
const metadata: Record<string, WebStorageFileMetadata> = JSON.parse(this.store.getItem(METADATA_KEY) || '{}')
const metadata = this.get_metadata()
switch (op) {
case MetadataUpdateOperation.DELETE:
delete metadata[path]
break
case MetadataUpdateOperation.READ:
metadata[path].accessed = now
metadata[path].atime = now
break
case MetadataUpdateOperation.WRITE:
if (!metadata[path]) {
metadata[path] = {
accessed: now,
created: now,
modified: now,
atime: now,
mtime: now,
}
}
metadata[path].modified = now
metadata[path].mtime = now
}
this.store.setItem(METADATA_KEY, JSON.stringify(metadata))
}
Expand Down
Loading

0 comments on commit 334df85

Please sign in to comment.