@e280/quay 0.0.0-17 → 0.0.0-19
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/package.json +6 -4
- package/s/cellar/cask.ts +0 -18
- package/s/cellar/cellar.test.ts +19 -38
- package/s/cellar/cellar.ts +16 -20
- package/s/cellar/index.ts +0 -4
- package/s/cellar/mammoth.ts +50 -0
- package/s/cellar/mammoth.worker.bundle.ts +30 -0
- package/s/genius/parts/hy-tree.ts +10 -9
- package/s/logic/presets/media/library.test.ts +2 -3
- package/s/logic/presets/media/library.ts +84 -14
- package/s/logic/presets/media/schema.ts +6 -0
- package/x/cellar/cask.d.ts +0 -2
- package/x/cellar/cask.js +0 -13
- package/x/cellar/cask.js.map +1 -1
- package/x/cellar/cellar.d.ts +9 -7
- package/x/cellar/cellar.js +17 -22
- package/x/cellar/cellar.js.map +1 -1
- package/x/cellar/cellar.test.d.ts +2 -3
- package/x/cellar/cellar.test.js +19 -35
- package/x/cellar/cellar.test.js.map +1 -1
- package/x/cellar/index.d.ts +0 -3
- package/x/cellar/index.js +0 -3
- package/x/cellar/index.js.map +1 -1
- package/x/cellar/mammoth.d.ts +23 -0
- package/x/cellar/mammoth.js +34 -0
- package/x/cellar/mammoth.js.map +1 -0
- package/x/cellar/mammoth.worker.bundle.d.ts +1 -0
- package/x/cellar/mammoth.worker.bundle.js +20 -0
- package/x/cellar/mammoth.worker.bundle.js.map +1 -0
- package/x/cellar/mammoth.worker.bundle.min.js +8 -0
- package/x/cellar/mammoth.worker.bundle.min.js.map +7 -0
- package/x/demo/main.bundle.min.js +43 -43
- package/x/demo/main.bundle.min.js.map +4 -4
- package/x/genius/parts/hy-tree.js +9 -8
- package/x/genius/parts/hy-tree.js.map +1 -1
- package/x/index.html +2 -2
- package/x/logic/presets/media/library.d.ts +4 -2
- package/x/logic/presets/media/library.js +71 -12
- package/x/logic/presets/media/library.js.map +1 -1
- package/x/logic/presets/media/library.test.d.ts +1 -1
- package/x/logic/presets/media/library.test.js +2 -2
- package/x/logic/presets/media/library.test.js.map +1 -1
- package/x/logic/presets/media/schema.d.ts +6 -0
- package/s/cellar/forklift.ts +0 -9
- package/s/cellar/memory-forklift.ts +0 -30
- package/s/cellar/opfs-forklift.ts +0 -47
- package/x/cellar/forklift.d.ts +0 -7
- package/x/cellar/forklift.js +0 -2
- package/x/cellar/forklift.js.map +0 -1
- package/x/cellar/memory-forklift.d.ts +0 -9
- package/x/cellar/memory-forklift.js +0 -22
- package/x/cellar/memory-forklift.js.map +0 -1
- package/x/cellar/opfs-forklift.d.ts +0 -11
- package/x/cellar/opfs-forklift.js +0 -42
- package/x/cellar/opfs-forklift.js.map +0 -1
package/README.md
CHANGED
|
@@ -124,7 +124,7 @@ In this way, you can setup sophisticated rules about what actions are permitted,
|
|
|
124
124
|
```ts
|
|
125
125
|
import {MediaLibrary, brain, register, components} from "@e280/quay"
|
|
126
126
|
|
|
127
|
-
const media = await MediaLibrary.open("my-
|
|
127
|
+
const media = await MediaLibrary.open("my-app")
|
|
128
128
|
brain.setGroup("media", media)
|
|
129
129
|
|
|
130
130
|
register(components)
|
|
@@ -137,13 +137,17 @@ register(components)
|
|
|
137
137
|
</div>
|
|
138
138
|
```
|
|
139
139
|
|
|
140
|
-
|
|
140
|
+
Open a library once, then derive nested scopes as needed.
|
|
141
141
|
|
|
142
142
|
```ts
|
|
143
|
-
const
|
|
144
|
-
const
|
|
143
|
+
const project = await media.scope("project-a")
|
|
144
|
+
const favorites = await project.scope("favorites")
|
|
145
145
|
```
|
|
146
146
|
|
|
147
|
+
Adding media to a scope also adds it to every parent scope. Removing media from
|
|
148
|
+
a scope removes it from that scope and its child scopes. Removing it from the
|
|
149
|
+
root removes it everywhere, including its stored file.
|
|
150
|
+
|
|
147
151
|
<br/>
|
|
148
152
|
|
|
149
153
|
## Styling
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@e280/quay",
|
|
3
|
-
"version": "0.0.0-
|
|
3
|
+
"version": "0.0.0-19",
|
|
4
4
|
"description": "File-browser and outliner UI for the web",
|
|
5
5
|
"author": "Przemysław Gałęzki",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,18 +11,20 @@
|
|
|
11
11
|
"s"
|
|
12
12
|
],
|
|
13
13
|
"dependencies": {
|
|
14
|
-
"@
|
|
15
|
-
"@e280/kv": "^0.
|
|
14
|
+
"@e280/comrade": "^0.1.0",
|
|
15
|
+
"@e280/kv": "^0.2.0-3",
|
|
16
|
+
"@e280/mammoth": "^0.1.0-19",
|
|
16
17
|
"@e280/scute": "^0.4.0-next.4",
|
|
17
18
|
"@e280/sly": "^0.4.0-next.5",
|
|
18
19
|
"@e280/strata": "^0.4.0-next.8",
|
|
19
|
-
"@e280/stz": "^0.
|
|
20
|
+
"@e280/stz": "^0.2.8",
|
|
20
21
|
"lit": "^3.3.1"
|
|
21
22
|
},
|
|
22
23
|
"devDependencies": {
|
|
23
24
|
"@e280/hottie": "^0.1.1",
|
|
24
25
|
"@e280/science": "^0.0.5",
|
|
25
26
|
"@shoelace-style/shoelace": "^2.20.1",
|
|
27
|
+
"@types/wicg-file-system-access": "^2023.10.7",
|
|
26
28
|
"npm-run-all": "^4.1.5",
|
|
27
29
|
"typescript": "^5.8.3"
|
|
28
30
|
},
|
package/s/cellar/cask.ts
CHANGED
|
@@ -1,24 +1,6 @@
|
|
|
1
1
|
|
|
2
|
-
import {Hex} from "@e280/stz"
|
|
3
|
-
import {blake3} from "@awasm/noble"
|
|
4
|
-
|
|
5
2
|
/** File with a hash label */
|
|
6
3
|
export class Cask {
|
|
7
|
-
static async hash(file: Blob) {
|
|
8
|
-
const hasher = blake3.create()
|
|
9
|
-
|
|
10
|
-
for await (const chunk of file.stream())
|
|
11
|
-
hasher.update(chunk)
|
|
12
|
-
|
|
13
|
-
const digest = hasher.digest()
|
|
14
|
-
return Hex.fromBytes(digest)
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
static async make(file: Blob) {
|
|
18
|
-
const hash = await this.hash(file)
|
|
19
|
-
return new this(hash, file)
|
|
20
|
-
}
|
|
21
|
-
|
|
22
4
|
constructor(public hash: string, public file: Blob) {}
|
|
23
5
|
}
|
|
24
6
|
|
package/s/cellar/cellar.test.ts
CHANGED
|
@@ -2,59 +2,42 @@
|
|
|
2
2
|
import {Txt} from "@e280/stz"
|
|
3
3
|
import {expect, Science, test} from "@e280/science"
|
|
4
4
|
|
|
5
|
-
import {Cask} from "./cask.js"
|
|
6
5
|
import {Cellar} from "./cellar.js"
|
|
7
|
-
import {MemoryForklift} from "./memory-forklift.js"
|
|
8
6
|
|
|
9
7
|
const blob = (text: string) => new Blob([Txt.toBytes(text)])
|
|
10
8
|
|
|
11
9
|
export default Science.suite({
|
|
12
|
-
"
|
|
10
|
+
"write + load roundtrip": test(async() => {
|
|
13
11
|
const cellar = new Cellar()
|
|
14
12
|
const text = "hello world"
|
|
15
13
|
const file = blob(text)
|
|
16
|
-
await cellar.
|
|
14
|
+
const digest = await cellar.write(file.stream())
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
expect(await cellar.has(hash)).is(true)
|
|
16
|
+
expect(await cellar.has(digest)).is(true)
|
|
20
17
|
|
|
21
|
-
const loaded = await cellar.load(
|
|
22
|
-
expect(loaded.hash).is(
|
|
18
|
+
const loaded = await cellar.load(digest)
|
|
19
|
+
expect(loaded.hash).is(digest)
|
|
23
20
|
expect(await loaded.file.text()).is(text)
|
|
24
21
|
}),
|
|
25
22
|
|
|
26
|
-
"
|
|
23
|
+
"write is idempotent": test(async() => {
|
|
27
24
|
const cellar = new Cellar()
|
|
28
25
|
const file = blob("foo bar")
|
|
29
|
-
await cellar.
|
|
30
|
-
await cellar.
|
|
26
|
+
const digest = await cellar.write(file.stream())
|
|
27
|
+
expect(await cellar.write(file.stream())).is(digest)
|
|
31
28
|
|
|
32
|
-
|
|
33
|
-
expect(await cellar.has(hash)).is(true)
|
|
34
|
-
}),
|
|
35
|
-
|
|
36
|
-
"throws on corruption": test(async() => {
|
|
37
|
-
const forklift = new MemoryForklift()
|
|
38
|
-
const cellar = new Cellar(forklift)
|
|
39
|
-
const good = blob("valid data")
|
|
40
|
-
const bad = blob("corrupt data")
|
|
41
|
-
|
|
42
|
-
const hash = await Cask.hash(good)
|
|
43
|
-
await forklift.save(hash, bad)
|
|
44
|
-
|
|
45
|
-
await expect(async() => cellar.load(hash)).throwsAsync()
|
|
29
|
+
expect(await cellar.has(digest)).is(true)
|
|
46
30
|
}),
|
|
47
31
|
|
|
48
32
|
"delete removes file": test(async() => {
|
|
49
33
|
const cellar = new Cellar()
|
|
50
34
|
const file = blob("temporary data")
|
|
51
|
-
await cellar.
|
|
35
|
+
const digest = await cellar.write(file.stream())
|
|
52
36
|
|
|
53
|
-
|
|
54
|
-
expect(await cellar.has(hash)).is(true)
|
|
37
|
+
expect(await cellar.has(digest)).is(true)
|
|
55
38
|
|
|
56
|
-
await cellar.delete(
|
|
57
|
-
expect(await cellar.has(
|
|
39
|
+
await cellar.delete(digest)
|
|
40
|
+
expect(await cellar.has(digest)).is(false)
|
|
58
41
|
}),
|
|
59
42
|
|
|
60
43
|
"load missing throws": test(async() => {
|
|
@@ -69,24 +52,22 @@ export default Science.suite({
|
|
|
69
52
|
const texts = ["a", "b", "c"]
|
|
70
53
|
|
|
71
54
|
for (const t of texts)
|
|
72
|
-
await cellar.
|
|
55
|
+
await cellar.write(blob(t).stream())
|
|
73
56
|
|
|
74
57
|
const hashes = []
|
|
75
58
|
for await (const hash of cellar.list())
|
|
76
59
|
hashes.push(hash)
|
|
77
60
|
|
|
78
61
|
expect(hashes.length).is(3)
|
|
79
|
-
for (const
|
|
80
|
-
|
|
81
|
-
expect(hashes.includes(h)).is(true)
|
|
82
|
-
}
|
|
62
|
+
for (const text of texts)
|
|
63
|
+
expect(hashes.includes(await cellar.write(blob(text).stream()))).is(true)
|
|
83
64
|
}),
|
|
84
65
|
|
|
85
66
|
"clear removes all entries": test(async() => {
|
|
86
67
|
const cellar = new Cellar()
|
|
87
|
-
await cellar.
|
|
88
|
-
await cellar.
|
|
89
|
-
await cellar.
|
|
68
|
+
await cellar.write(blob("one").stream())
|
|
69
|
+
await cellar.write(blob("two").stream())
|
|
70
|
+
await cellar.write(blob("three").stream())
|
|
90
71
|
|
|
91
72
|
let count = 0
|
|
92
73
|
for await (const _ of cellar.list()) count++
|
package/s/cellar/cellar.ts
CHANGED
|
@@ -1,49 +1,45 @@
|
|
|
1
1
|
|
|
2
|
+
import {Mammoth} from "@e280/mammoth"
|
|
3
|
+
|
|
2
4
|
import {Cask} from "./cask.js"
|
|
3
|
-
import {
|
|
4
|
-
import {OpfsForklift} from "./opfs-forklift.js"
|
|
5
|
-
import {MemoryForklift} from "./memory-forklift.js"
|
|
5
|
+
import {MammothOpfs} from "./mammoth.js"
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
8
|
* Cellar is an immutable content-addressable file store
|
|
9
9
|
* - files are identified by their hashes (duplicates are impossible)
|
|
10
10
|
* - files cannot be modified (delete it and save another)
|
|
11
11
|
* - no filenames or metadata (store those somewhere else)
|
|
12
|
-
* -
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
* - new Cellar uses Mammoth's in-memory backend by default
|
|
13
|
+
* - Cellar.opfs creates MammothOpfs for persistent browser storage
|
|
14
|
+
* - pass in a Mammoth to use a different backend
|
|
15
|
+
* - most likely you want to use Cellar.opfs for storing files in the browser
|
|
15
16
|
*/
|
|
16
17
|
export class Cellar {
|
|
17
18
|
static async opfs(dirname = "cellar") {
|
|
18
|
-
return new this(await
|
|
19
|
+
return new this(await MammothOpfs.setup(dirname))
|
|
19
20
|
}
|
|
20
21
|
|
|
21
|
-
constructor(private
|
|
22
|
+
constructor(private mammoth: Mammoth | MammothOpfs = new Mammoth()) {}
|
|
22
23
|
|
|
23
24
|
async *list(): AsyncIterable<string> {
|
|
24
|
-
|
|
25
|
+
for await (const [hash] of this.mammoth.entries())
|
|
26
|
+
yield hash
|
|
25
27
|
}
|
|
26
28
|
|
|
27
29
|
async has(hash: string): Promise<boolean> {
|
|
28
|
-
return this.
|
|
30
|
+
return this.mammoth.has(hash)
|
|
29
31
|
}
|
|
30
32
|
|
|
31
|
-
async
|
|
32
|
-
|
|
33
|
-
if (!await this.forklift.has(cask.hash))
|
|
34
|
-
await this.forklift.save(cask.hash, cask.file)
|
|
35
|
-
return cask
|
|
33
|
+
async write(readable: ReadableStream<Uint8Array>) {
|
|
34
|
+
return this.mammoth.write(readable)
|
|
36
35
|
}
|
|
37
36
|
|
|
38
37
|
async load(hash: string): Promise<Cask> {
|
|
39
|
-
|
|
40
|
-
const cask = await Cask.make(bytes)
|
|
41
|
-
if (cask.hash !== hash) throw new Error(`corruption error, hash mismatch (requested ${hash}) (got ${cask.hash})`)
|
|
42
|
-
return cask
|
|
38
|
+
return new Cask(hash, await this.mammoth.read(hash))
|
|
43
39
|
}
|
|
44
40
|
|
|
45
41
|
async delete(hash: string) {
|
|
46
|
-
await this.
|
|
42
|
+
await this.mammoth.delete(hash)
|
|
47
43
|
}
|
|
48
44
|
|
|
49
45
|
async clear() {
|
package/s/cellar/index.ts
CHANGED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
|
|
2
|
+
import {Comrade, Thread, tune, AsSchematic} from "@e280/comrade"
|
|
3
|
+
|
|
4
|
+
export type MammothSchematic = AsSchematic<{
|
|
5
|
+
work: {
|
|
6
|
+
setup(dirname: string): Promise<void>
|
|
7
|
+
write(readable: ReadableStream<Uint8Array>): Promise<string>
|
|
8
|
+
has(hash: string): Promise<boolean>
|
|
9
|
+
read(hash: string): Promise<Blob>
|
|
10
|
+
delete(hash: string): Promise<void>
|
|
11
|
+
hashes(): Promise<string[]>
|
|
12
|
+
}
|
|
13
|
+
host: {}
|
|
14
|
+
}>
|
|
15
|
+
|
|
16
|
+
/** Worker proxy for Mammoth using an OPFS bucket and IndexedDB manifest. */
|
|
17
|
+
export class MammothOpfs {
|
|
18
|
+
static async setup(dirname: string) {
|
|
19
|
+
const thread = await Comrade.thread<MammothSchematic>({
|
|
20
|
+
workerUrl: "/node_modules/@e280/quay/x/cellar/mammoth.worker.bundle.min.js",
|
|
21
|
+
setupHost: () => ({}),
|
|
22
|
+
})
|
|
23
|
+
const mammoth = new this(thread)
|
|
24
|
+
await mammoth.thread.work.setup(dirname)
|
|
25
|
+
return mammoth
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
constructor(private thread: Thread<MammothSchematic>) {}
|
|
29
|
+
|
|
30
|
+
async write(readable: ReadableStream<Uint8Array>) {
|
|
31
|
+
return this.thread.work.write[tune]({transfer: [readable]})(readable)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async has(hash: string) {
|
|
35
|
+
return this.thread.work.has(hash)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async read(hash: string) {
|
|
39
|
+
return this.thread.work.read(hash)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async delete(hash: string) {
|
|
43
|
+
return this.thread.work.delete(hash)
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
async *entries(): AsyncIterable<[string, unknown]> {
|
|
47
|
+
for (const hash of await this.thread.work.hashes())
|
|
48
|
+
yield [hash, undefined]
|
|
49
|
+
}
|
|
50
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
|
|
2
|
+
import {collect} from "@e280/stz"
|
|
3
|
+
import {Comrade} from "@e280/comrade"
|
|
4
|
+
import {Mammoth, OpfsBucket} from "@e280/mammoth"
|
|
5
|
+
import {idbOpen, IdbMagazine, Kv} from "@e280/kv"
|
|
6
|
+
|
|
7
|
+
import type {MammothSchematic} from "./mammoth.js"
|
|
8
|
+
|
|
9
|
+
let mammoth: Mammoth
|
|
10
|
+
|
|
11
|
+
await Comrade.worker<MammothSchematic>(() => ({
|
|
12
|
+
async setup(dirname) {
|
|
13
|
+
const root = await navigator.storage.getDirectory()
|
|
14
|
+
const directory = await root.getDirectoryHandle(dirname, {create: true})
|
|
15
|
+
mammoth = new Mammoth(
|
|
16
|
+
new OpfsBucket(directory),
|
|
17
|
+
new Kv(new IdbMagazine(await idbOpen(`quay.mammoth:${dirname}`))),
|
|
18
|
+
)
|
|
19
|
+
},
|
|
20
|
+
|
|
21
|
+
write: readable => mammoth.write(readable),
|
|
22
|
+
has: hash => mammoth.has(hash),
|
|
23
|
+
read: hash => mammoth.read(hash),
|
|
24
|
+
delete: hash => mammoth.delete(hash),
|
|
25
|
+
|
|
26
|
+
async hashes() {
|
|
27
|
+
return (await collect(mammoth.entries())).map(([hash]) => hash)
|
|
28
|
+
},
|
|
29
|
+
}))
|
|
30
|
+
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
|
|
2
|
-
import {
|
|
2
|
+
import {collect} from "@e280/stz"
|
|
3
|
+
import {Kv} from "@e280/kv"
|
|
3
4
|
|
|
4
5
|
export type HyId = string
|
|
5
6
|
export type HyItem = {parent: HyId | null, children: HyId[]}
|
|
@@ -27,7 +28,7 @@ export class HyTree {
|
|
|
27
28
|
}
|
|
28
29
|
|
|
29
30
|
async gets(...ids: HyId[]): Promise<(HyItem | undefined)[]> {
|
|
30
|
-
const records = await this.records.
|
|
31
|
+
const records = await this.records.getMany(ids)
|
|
31
32
|
return records.map(r => {
|
|
32
33
|
if (!r) return undefined
|
|
33
34
|
const [parent, children] = r
|
|
@@ -36,13 +37,13 @@ export class HyTree {
|
|
|
36
37
|
}
|
|
37
38
|
|
|
38
39
|
async require(id: HyId): Promise<HyItem> {
|
|
39
|
-
const record = await this.records.
|
|
40
|
+
const record = await this.records.need(id)
|
|
40
41
|
const [parent, children] = record
|
|
41
42
|
return {parent, children}
|
|
42
43
|
}
|
|
43
44
|
|
|
44
45
|
async requires(...ids: HyId[]): Promise<HyItem[]> {
|
|
45
|
-
const records = await this.records.
|
|
46
|
+
const records = await this.records.needMany(ids)
|
|
46
47
|
return records.map(([parent, children]) => ({parent, children}))
|
|
47
48
|
}
|
|
48
49
|
|
|
@@ -73,10 +74,10 @@ export class HyTree {
|
|
|
73
74
|
}
|
|
74
75
|
|
|
75
76
|
// save all new children
|
|
76
|
-
await this.records.
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
77
|
+
await this.records.commit([
|
|
78
|
+
...newcomers.map(([id, record]) => this.records.op.set(id, record)),
|
|
79
|
+
this.records.op.set(parent, [parentItem.parent, [...siblings]]),
|
|
80
|
+
])
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
/** detach this id from its parent in the hierarchy */
|
|
@@ -102,7 +103,7 @@ export class HyTree {
|
|
|
102
103
|
await this.detach(id)
|
|
103
104
|
const tree = await collect(this.crawl(id))
|
|
104
105
|
const ids = tree.map(({id}) => id)
|
|
105
|
-
await this.records.
|
|
106
|
+
await this.records.commit(ids.map(id => this.records.op.delete(id)))
|
|
106
107
|
}
|
|
107
108
|
|
|
108
109
|
/** delete absolutely everything */
|
|
@@ -34,7 +34,7 @@ export default Science.suite({
|
|
|
34
34
|
expect(await recordByLabel(store, "image.png")).ok()
|
|
35
35
|
}),
|
|
36
36
|
|
|
37
|
-
"
|
|
37
|
+
"root deletion removes media records and resources": test(async() => {
|
|
38
38
|
const store = new MediaLibrary()
|
|
39
39
|
await store.upload([file("delete.png", "delete-image", "image/png")], store.config.root)
|
|
40
40
|
|
|
@@ -43,8 +43,8 @@ export default Science.suite({
|
|
|
43
43
|
|
|
44
44
|
await store.delete(item)
|
|
45
45
|
|
|
46
|
-
expect(await store.cellar.has(record.hash)).is(false)
|
|
47
46
|
expect(store.findByHash(record.hash)).is(undefined)
|
|
47
|
+
expect(await store.cellar.has(record.hash)).is(false)
|
|
48
48
|
}),
|
|
49
49
|
|
|
50
50
|
})
|
|
@@ -66,4 +66,3 @@ async function recordByLabel(store: MediaLibrary, label: string) {
|
|
|
66
66
|
throw new Error(`expected record "${label}"`)
|
|
67
67
|
return record
|
|
68
68
|
}
|
|
69
|
-
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
|
|
2
|
-
import {
|
|
2
|
+
import {collect, sub} from "@e280/stz"
|
|
3
|
+
import {Kv, StorageMagazine} from "@e280/kv"
|
|
3
4
|
|
|
4
5
|
import {MediaGroup} from "./group.js"
|
|
5
6
|
import {Cellar} from "../../../cellar/cellar.js"
|
|
6
|
-
import {MediaFormat, MediaSchema} from "./schema.js"
|
|
7
|
+
import {MediaFormat, MediaProgress, MediaSchema} from "./schema.js"
|
|
7
8
|
import {CodexItem} from "../../aspects/codex/parts/codex-item.js"
|
|
8
9
|
|
|
9
10
|
export type MediaRecord = {
|
|
@@ -21,18 +22,23 @@ export class MediaLibrary extends MediaGroup {
|
|
|
21
22
|
const group = new this()
|
|
22
23
|
group.cellar = await Cellar.opfs("media")
|
|
23
24
|
group.#index = mediaIndex(scope)
|
|
25
|
+
group.#lineage = [group.#index]
|
|
24
26
|
await group.#load()
|
|
25
27
|
return group
|
|
26
28
|
}
|
|
27
29
|
|
|
30
|
+
#index
|
|
31
|
+
#lineage
|
|
32
|
+
#root = this
|
|
28
33
|
#objectUrls = new Map<string, string>()
|
|
29
|
-
#index: Kv<MediaRecord>
|
|
30
34
|
|
|
35
|
+
progress = sub<[MediaProgress]>()
|
|
31
36
|
cellar = new Cellar()
|
|
32
37
|
|
|
33
38
|
constructor() {
|
|
34
39
|
super()
|
|
35
40
|
this.#index = mediaIndex("default")
|
|
41
|
+
this.#lineage = [this.#index]
|
|
36
42
|
this.on.upload.sub(({files, target}) => {
|
|
37
43
|
return this.#upload(files, target)
|
|
38
44
|
})
|
|
@@ -42,6 +48,20 @@ export class MediaLibrary extends MediaGroup {
|
|
|
42
48
|
})
|
|
43
49
|
}
|
|
44
50
|
|
|
51
|
+
get #isRoot() {
|
|
52
|
+
return this === this.#root
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
async scope(name: string) {
|
|
56
|
+
const child = new MediaLibrary()
|
|
57
|
+
child.cellar = this.cellar
|
|
58
|
+
child.#index = this.#index.scope<MediaRecord>(name)
|
|
59
|
+
child.#lineage = [...this.#lineage, child.#index]
|
|
60
|
+
child.#root = this.#root
|
|
61
|
+
await child.#load()
|
|
62
|
+
return child
|
|
63
|
+
}
|
|
64
|
+
|
|
45
65
|
async *records() {
|
|
46
66
|
for await (const [, record] of this.#index.entries())
|
|
47
67
|
yield record
|
|
@@ -60,11 +80,42 @@ export class MediaLibrary extends MediaGroup {
|
|
|
60
80
|
}
|
|
61
81
|
|
|
62
82
|
async #storeFile(file: File, parent: CodexItem<MediaSchema>) {
|
|
63
|
-
const
|
|
64
|
-
|
|
83
|
+
const item = this.#attachPending(file, parent)
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
const hash = await this.cellar.write(trackProgress(file.stream(), loaded =>
|
|
87
|
+
this.progress.pub({item, loaded, total: file.size})
|
|
88
|
+
))
|
|
89
|
+
const record = await this.#saveRecord(file, hash)
|
|
90
|
+
item.destroy()
|
|
91
|
+
this.#attachRecord(record, parent)
|
|
92
|
+
return record
|
|
93
|
+
}
|
|
94
|
+
catch (error) {
|
|
95
|
+
item.destroy()
|
|
96
|
+
this.on.refresh.pub({})
|
|
97
|
+
throw error
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
#attachPending(file: File, parent: CodexItem<MediaSchema>) {
|
|
102
|
+
const item = this.config.codex.create("file", {
|
|
103
|
+
label: file.name,
|
|
104
|
+
format: mediaFormat(file.type),
|
|
105
|
+
mime: file.type,
|
|
106
|
+
size: file.size,
|
|
107
|
+
previewUrl: null,
|
|
108
|
+
})
|
|
109
|
+
parent.attach(item)
|
|
110
|
+
this.on.refresh.pub({})
|
|
111
|
+
return item
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
async #saveRecord(file: File, hash: string) {
|
|
115
|
+
const existing = await this.#index.get(hash) ?? await this.#root.#index.get(hash)
|
|
65
116
|
const now = Date.now()
|
|
66
117
|
const record: MediaRecord = {
|
|
67
|
-
hash
|
|
118
|
+
hash,
|
|
68
119
|
label: existing?.label ?? file.name,
|
|
69
120
|
format: mediaFormat(file.type),
|
|
70
121
|
mime: file.type,
|
|
@@ -72,10 +123,9 @@ export class MediaLibrary extends MediaGroup {
|
|
|
72
123
|
createdAt: existing?.createdAt ?? now,
|
|
73
124
|
updatedAt: now,
|
|
74
125
|
}
|
|
75
|
-
await this.#index.set(
|
|
126
|
+
await this.#index.commit(this.#lineage.map(index => index.op.set(hash, record)))
|
|
76
127
|
if (record.format === "image")
|
|
77
|
-
this.#setPreview(
|
|
78
|
-
this.#attachRecord(record, parent)
|
|
128
|
+
this.#setPreview(hash, URL.createObjectURL(file))
|
|
79
129
|
return record
|
|
80
130
|
}
|
|
81
131
|
|
|
@@ -86,10 +136,16 @@ export class MediaLibrary extends MediaGroup {
|
|
|
86
136
|
}
|
|
87
137
|
|
|
88
138
|
async #delete(hashes: string[]) {
|
|
139
|
+
const index = this.#index.crush()
|
|
140
|
+
const keys = (await collect(index.entries()))
|
|
141
|
+
.filter(([, record]) => hashes.includes(record.hash))
|
|
142
|
+
.map(([key]) => key)
|
|
143
|
+
await index.commit(keys.map(key => index.op.delete(key)))
|
|
144
|
+
|
|
89
145
|
for (const hash of hashes) {
|
|
90
|
-
await this.#index.del(hash)
|
|
91
|
-
await this.cellar.delete(hash)
|
|
92
146
|
this.#revokePreview(hash)
|
|
147
|
+
if (this.#isRoot)
|
|
148
|
+
await this.cellar.delete(hash)
|
|
93
149
|
}
|
|
94
150
|
}
|
|
95
151
|
|
|
@@ -155,9 +211,10 @@ function mediaFormat(mime: string): MediaFormat {
|
|
|
155
211
|
function mediaIndex(scope: string) {
|
|
156
212
|
const storage = globalThis.localStorage
|
|
157
213
|
if (storage)
|
|
158
|
-
return new Kv<MediaRecord>(new
|
|
159
|
-
.scope("quay
|
|
160
|
-
.scope(
|
|
214
|
+
return new Kv<MediaRecord>(new StorageMagazine(storage))
|
|
215
|
+
.scope("quay")
|
|
216
|
+
.scope("media")
|
|
217
|
+
.scope<MediaRecord>(scope)
|
|
161
218
|
|
|
162
219
|
const existing = memoryIndexes.get(scope)
|
|
163
220
|
if (existing)
|
|
@@ -168,3 +225,16 @@ function mediaIndex(scope: string) {
|
|
|
168
225
|
return index
|
|
169
226
|
}
|
|
170
227
|
|
|
228
|
+
function trackProgress(
|
|
229
|
+
readable: ReadableStream<Uint8Array>,
|
|
230
|
+
onProgress: (loaded: number) => void,
|
|
231
|
+
) {
|
|
232
|
+
let loaded = 0
|
|
233
|
+
return readable.pipeThrough(new TransformStream({
|
|
234
|
+
transform(chunk, controller) {
|
|
235
|
+
loaded += chunk.byteLength
|
|
236
|
+
onProgress(loaded)
|
|
237
|
+
controller.enqueue(chunk)
|
|
238
|
+
},
|
|
239
|
+
}))
|
|
240
|
+
}
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
|
|
2
2
|
import {Content} from "@e280/sly"
|
|
3
3
|
import {AsSchema} from "../../aspects/codex/parts/types.js"
|
|
4
|
+
import {CodexItem} from "../../aspects/codex/parts/codex-item.js"
|
|
4
5
|
|
|
5
6
|
export type MediaFormat = "video" | "image" | "audio" | "other"
|
|
6
7
|
|
|
@@ -23,3 +24,8 @@ export type MediaSchema = AsSchema<{
|
|
|
23
24
|
}
|
|
24
25
|
}>
|
|
25
26
|
|
|
27
|
+
export type MediaProgress = {
|
|
28
|
+
item: CodexItem<MediaSchema>
|
|
29
|
+
loaded: number
|
|
30
|
+
total: number
|
|
31
|
+
}
|
package/x/cellar/cask.d.ts
CHANGED
package/x/cellar/cask.js
CHANGED
|
@@ -1,20 +1,7 @@
|
|
|
1
|
-
import { Hex } from "@e280/stz";
|
|
2
|
-
import { blake3 } from "@awasm/noble";
|
|
3
1
|
/** File with a hash label */
|
|
4
2
|
export class Cask {
|
|
5
3
|
hash;
|
|
6
4
|
file;
|
|
7
|
-
static async hash(file) {
|
|
8
|
-
const hasher = blake3.create();
|
|
9
|
-
for await (const chunk of file.stream())
|
|
10
|
-
hasher.update(chunk);
|
|
11
|
-
const digest = hasher.digest();
|
|
12
|
-
return Hex.fromBytes(digest);
|
|
13
|
-
}
|
|
14
|
-
static async make(file) {
|
|
15
|
-
const hash = await this.hash(file);
|
|
16
|
-
return new this(hash, file);
|
|
17
|
-
}
|
|
18
5
|
constructor(hash, file) {
|
|
19
6
|
this.hash = hash;
|
|
20
7
|
this.file = file;
|
package/x/cellar/cask.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cask.js","sourceRoot":"","sources":["../../s/cellar/cask.ts"],"names":[],"mappings":"AACA,
|
|
1
|
+
{"version":3,"file":"cask.js","sourceRoot":"","sources":["../../s/cellar/cask.ts"],"names":[],"mappings":"AACA,6BAA6B;AAC7B,MAAM,OAAO,IAAI;IACG;IAAqB;IAAxC,YAAmB,IAAY,EAAS,IAAU;QAA/B,SAAI,GAAJ,IAAI,CAAQ;QAAS,SAAI,GAAJ,IAAI,CAAM;IAAG,CAAC;CACtD"}
|