From 3be1c67fb35f19c0f986620346281525242c9dc3 Mon Sep 17 00:00:00 2001 From: Stefan Zerkalica Date: Wed, 13 Nov 2024 01:06:36 +0300 Subject: [PATCH] $mol_file webdav --- file/demo/demo.view.tree | 34 ++++++++++ file/demo/demo.view.ts | 26 ++++++++ file/demo/index.html | 17 +++++ file/file.node.ts | 47 +++++++------- file/file.ts | 27 +++++--- file/file.web.ts | 129 +++++++++++++++++++++++++++++++++++--- file/progress/progress.ts | 93 +++++++++++++++++++++++++++ 7 files changed, 331 insertions(+), 42 deletions(-) create mode 100644 file/demo/demo.view.tree create mode 100644 file/demo/demo.view.ts create mode 100644 file/demo/index.html create mode 100644 file/progress/progress.ts diff --git a/file/demo/demo.view.tree b/file/demo/demo.view.tree new file mode 100644 index 0000000000..92521cc3aa --- /dev/null +++ b/file/demo/demo.view.tree @@ -0,0 +1,34 @@ +$mol_file_demo $mol_example + title \Webdav demo + actual_url? \ + sub / + <= Left $mol_page + title \Webdav controls + body / + <= Form $mol_form + body / + <= Webdav_url_field $mol_form_field + name \WebDav url + Content <= Webdav_url_control $mol_string + hint \http:// + value? <=> webdav_url? \ + submit? <=> refresh? null + buttons / + <= Update $mol_button_major + title \Update + click? <=> refresh? null + <= Right $mol_page + title \Files + body / <= Files $mol_list + minimal_width 320 + rows <= file_list /$mol_view + <= File*0 $mol_card + title <= file_name* \ + status <= file_type* \file + tags / + \$mol_button + \form + \file upload + \webdav + aspects / + \Widget/Form diff --git a/file/demo/demo.view.ts b/file/demo/demo.view.ts new file mode 100644 index 0000000000..07a1589190 --- /dev/null +++ b/file/demo/demo.view.ts @@ -0,0 +1,26 @@ +namespace $.$$ { + export class $mol_file_demo extends $.$mol_file_demo { + @ $mol_mem + root() { + if (! this.actual_url().match(/^https?:\/\/.+/)) return null + return this.$.$mol_file.absolute(this.actual_url()) + } + + @ $mol_mem + override file_list() { + return this.root()?.sub().map(file => this.File(file.path())) ?? [] + } + + @ $mol_mem + override refresh(e?: Event) { + this.actual_url(this.webdav_url()) + } + + file_name(path: string) { + return this.$.$mol_file.absolute(path).relate() + } + file_type(path: string) { + return this.$.$mol_file.absolute(path).type() + } + } +} diff --git a/file/demo/index.html b/file/demo/index.html new file mode 100644 index 0000000000..b2b9e76818 --- /dev/null +++ b/file/demo/index.html @@ -0,0 +1,17 @@ + + + + + + + + + + + + + + + + + diff --git a/file/file.node.ts b/file/file.node.ts index 8933cf4c99..b949935e84 100644 --- a/file/file.node.ts +++ b/file/file.node.ts @@ -38,6 +38,8 @@ namespace $ { append = $node.fs.constants.O_APPEND, } + export type $mol_file_mode = keyof typeof file_modes + function mode_mask(modes: readonly $mol_file_mode[]) { return modes.reduce( ( res, mode )=> res | file_modes[ mode ], 0 ) } @@ -175,36 +177,30 @@ namespace $ { } @ $mol_mem_key - override readable({ modes }: { modes: readonly $mol_file_mode[] }) { - return this.handle(modes).readableWebStream({ type: 'bytes' }) as ReadableStream - } - - @ $mol_mem_key - override writable({ modes, start }: { modes: readonly $mol_file_mode[], start?: number }) { - const { Writable } = $node['node:stream'] as typeof import('stream') - const stream = this.handle(modes).createWriteStream({ - start, - // encoding?: BufferEncoding | null | undefined; - // autoClose?: boolean | undefined; - // emitClose?: boolean | undefined; - // start?: number | undefined; - // highWaterMark?: number | undefined; - // flush?: boolean | undefined; + override readable(opts: { start?: number, end?: number }) { + const { Readable } = $node['node:stream'] as typeof import('stream') + const stream = $node.fs.createReadStream(this.path(), { + flags: 'r', + autoClose: true, + start: opts?.start, + end: opts?.end, + encoding: 'binary', }) - const web = Writable.toWeb(stream) - - return Object.assign(web, { - destructor: () => web.close() - }) + return Readable.toWeb(stream) as ReadableStream } - protected handle(modes: readonly $mol_file_mode[]) { - const mode = mode_mask(modes) - - const handle = $mol_wire_sync($node.fs.promises).open(this.path(), mode) + @ $mol_mem + override writable(opts?: { start?: number }) { + const { Writable } = $node['node:stream'] as typeof import('stream') + const stream = $node.fs.createWriteStream(this.path(), { + flags: 'w+', + autoClose: true, + start: opts?.start, + encoding: 'binary', + }) - return $mol_wire_sync(handle) + return Writable.toWeb(stream) as WritableStream } open( ... modes: readonly $mol_file_mode[] ) { @@ -216,5 +212,6 @@ namespace $ { } + $.$mol_file = $mol_file_node } diff --git a/file/file.ts b/file/file.ts index ff0a4cff43..1a15eb5935 100644 --- a/file/file.ts +++ b/file/file.ts @@ -10,8 +10,6 @@ namespace $ { ctime: Date } - export type $mol_file_mode = 'create' | 'exists_truncate' | 'exists_fail' | 'read_only' | 'write_only' | 'read_write' | 'append' - // export class $mol_file_not_found extends Error {} export class $mol_file extends $mol_object { @@ -46,8 +44,7 @@ namespace $ { // Отслеживать проверку наличия родительской папки не стоит до корня диска // Лучше ограничить mam-ом - const root = this.$.$mol_file.watch_root ?? this - if ( path !== root ) { + if ( path !== this.$.$mol_file.watch_root && path !== parent.path() ) { /* Если родитель удалился, надо ресетнуть все дочерние на любой глубине Родитель может удалиться, потом создасться, а дочерняя папка только удалиться. @@ -167,7 +164,7 @@ namespace $ { static watch_off(side_effect: () => Result, affected_dir: string) { // ждем, пока выполнится предыдущий watch_off - const unlock = this.lock.grab() + const unlock = () => {} // this.lock.grab() this.watching_off(affected_dir) try { @@ -205,9 +202,19 @@ namespace $ { protected kids() { return [] as readonly this[] } + static headers() { return {} as Record } + headers() { return (this.constructor as typeof $mol_file).headers() } + + @ $mol_mem_key + readable(opts: { start?: number, end?: number }) { + return new ReadableStream + } + + @ $mol_mem_key + writable(opts: { start?: number }) { + return new WritableStream + } - readable(opts: { modes: readonly $mol_file_mode[] }) { return new ReadableStream() } - writable(opts: { modes: readonly $mol_file_mode[] }) { return new WritableStream() } // open( ... modes: readonly $mol_file_mode[] ) { return 0 } @ $mol_mem @@ -359,8 +366,10 @@ namespace $ { throw new Error('implement') } - relate( base?: $mol_file ): string { - throw new Error('implement') + relate( base = ( this.constructor as typeof $mol_file ).relative( '.' )): string { + const base_path = base.path() + const path = this.path() + return path.startsWith(base_path) ? path.slice(base_path.length) : path } find( diff --git a/file/file.web.ts b/file/file.web.ts index 798909b5b0..f70d4942af 100644 --- a/file/file.web.ts +++ b/file/file.web.ts @@ -10,6 +10,15 @@ namespace $ { ? new URL( '.' , ($mol_dom_context.document.currentScript as any)['src'] ).toString() : '' + protected override read() { + const response = this.$.$mol_fetch.response(this.path(), { + headers: this.headers() + }) + if (response.native.status === 404) return new Uint8Array + + return new Uint8Array(response.buffer()) + } + @ $mol_mem override buffer( next? : Uint8Array ) { if (next !== undefined) throw new Error(`Saving content not supported: ${this.path}`) @@ -21,34 +30,138 @@ namespace $ { return new Uint8Array(response.buffer()) } + protected fetch(init: RequestInit) { + return this.$.$mol_fetch.success(this.path(), { + ...init, + headers: { + ...this.headers(), + ...init.headers, + } + }) + } + + protected override write( body : Uint8Array ) { + this.fetch({ method: 'PUT', body }) + } + + @ $mol_mem_key + override readable( opts: { start?: number; end?: number } ) { + return this.fetch({ + headers: ! opts.start ? {} : { + 'Range': `bytes=${opts.start}-${opts.end ?? ''}` + } + }).stream() || $mol_fail(new Error('Not found')) + } + override resolve( path : string ) { let res = this.path() + '/' + path while( true ) { let prev = res + // foo/../ -> / res = res.replace( /\/[^\/.]+\/\.\.\// , '/' ) if( prev === res ) break } - + + // http://localhost/.. -> http://localhost + res = res.replace(/\/\.\.\/?$/, '') + + if (res === this.path()) return this + return ( this.constructor as typeof $mol_file ).absolute( res ) as this } protected override ensure() { - throw new Error('$mol_file_web.ensure() not implemented') - } + this.fetch({ method: 'MKCOL' }) + } protected override drop() { - throw new Error('$mol_file_web.drop() not implemented') + this.fetch({ method: 'DELETE' }) + } + + protected override copy(to: string) { + this.fetch({ + method: 'COPY', + headers: { Destination: to } + }) } protected override kids() : readonly this[] { - throw new Error('$mol_file_web.sub() not implemented') + const response = this.fetch({ method: 'PROPFIND' }) + const xml = response.xml() + const info = webdav_list(xml) + + const kids = info.map(({ path, stat }) => { + const file = this.resolve(path) + file.stat(stat, 'virt') + return file + }) + + return kids } - - override relate( base = ( this.constructor as typeof $mol_file ).relative( '.' )): string { - throw new Error('$mol_file_web.relate() not implemented') + + protected override info() { + return this.stat_make(0) + // return this.kids()?.at(0)?.stat() ?? null + } + + } + + function webdav_list(xml: Document) { + const result = [] + + for (const multistatus of xml.childNodes) { + if (multistatus.nodeName !== 'D:multistatus') continue + + for (const response of multistatus.childNodes) { + let path + + if (response.nodeName === 'D:href') path = response.textContent ?? '' + + if (! path ) continue + if ( response.nodeName !== 'D:propstat') continue + + const stat = webdav_stat(response) + + result.push({ path, stat }) + } + } + + return result + } + + function webdav_stat(prop_stat: ChildNode) { + const now = new Date() + const stat: $mol_file_stat = { + type: 'file', + size: 0, + atime: now, + mtime: now, + ctime: now, + } + + for (const prop of prop_stat.childNodes) { + if (prop.nodeName !== 'D:prop') continue + + for (const value of prop.childNodes) { + const name = value.nodeName + const text = value.textContent ?? '' + + if (name === 'D:getcontenttype') { + stat.type = text.endsWith('directory') ? 'dir' : 'file' + } + + if (name === 'D:getcontentlength') { + stat.size = Number(value.textContent || '0') + if (Number.isNaN(stat.size)) stat.size = 0 + } + + if (name === 'D:getlastmodified') stat.mtime = stat.atime = new Date(text) + if (name === 'D:creationdate') stat.ctime = new Date(text) + } } + return stat } $.$mol_file = $mol_file_web diff --git a/file/progress/progress.ts b/file/progress/progress.ts new file mode 100644 index 0000000000..5843c59842 --- /dev/null +++ b/file/progress/progress.ts @@ -0,0 +1,93 @@ +namespace $ { + export class $mol_file_progress extends $mol_object { + @ $mol_mem + processed(next?: number): number { + return ( $mol_wire_probe(( ) => this.processed()) ?? 0) + (next ?? 0) + } + + protected attach() { return { + destructor() {} + } } + + @ $mol_mem + done(next?: boolean) { + if (next === undefined) this.attach() + return next ?? false + } + } + + export class $mol_file_progress_write extends $mol_file_progress { + constructor( + protected readable: ReadableStream, + readonly native: WritableStream, + ) { + super() + } + + @ $mol_mem + protected override attach() { + const writer = this.native.getWriter() + const write = writer.write.bind(writer) + + writer.write = async (chunk: Uint8Array) => { + try { + await write(chunk) + } catch (error) { + this.done(error as boolean) + $mol_fail_hidden(error) + } + this.processed(chunk.length) + } + + this.readable.pipeTo(this.native) + this.readable_progress = new $mol_file_progress_read(this.readable) + + return super.attach() + } + + readable_progress = null as null | $mol_file_progress_read + + @ $mol_mem + done(next?: boolean) { + if (next === undefined) { + this.attach() + const progress = this.readable_progress! + + return progress.done() && progress.processed() === this.processed() + } + + return next + } + + } + + export class $mol_file_progress_read extends $mol_file_progress { + constructor( + readonly native: ReadableStream + ) { + super() + } + + @ $mol_mem + protected override attach() { + const reader = this.native.getReader() + const read = reader.read.bind(reader) + + reader.read = async () => { + try { + const result = await read() + this.processed(result.value?.length ?? 0) + if (result.done) this.done(true) + return result + } catch (error) { + this.done(error as boolean) + $mol_fail_hidden(error) + } + } + + return super.attach() + } + + + } +}