@e280/quay 0.0.0-2 → 0.0.0-20

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.
Files changed (168) hide show
  1. package/README.md +98 -0
  2. package/package.json +17 -11
  3. package/s/cellar/cask.ts +1 -13
  4. package/s/cellar/cellar.test.ts +25 -42
  5. package/s/cellar/cellar.ts +16 -20
  6. package/s/cellar/index.ts +0 -4
  7. package/s/cellar/mammoth.ts +50 -0
  8. package/s/cellar/mammoth.worker.bundle.ts +30 -0
  9. package/s/demo/main.bundle.ts +2 -3
  10. package/s/dom/components/breadcrumb/component.ts +6 -5
  11. package/s/dom/components/browser/component.ts +11 -7
  12. package/s/dom/components/browser/style.css.ts +31 -31
  13. package/s/dom/components/dropzone/component.ts +6 -4
  14. package/s/dom/components/dropzone/style.css.ts +12 -13
  15. package/s/dom/components/filter/component.ts +13 -8
  16. package/s/dom/components/outliner/component.ts +8 -5
  17. package/s/dom/components/searchbar/component.ts +13 -7
  18. package/s/dom/components/sort/component.ts +13 -9
  19. package/s/dom/theme.css.ts +66 -2
  20. package/s/dom/views/tree/component.ts +10 -8
  21. package/s/dom/views/tree/style.css.ts +10 -10
  22. package/s/genius/parts/hy-tree.ts +10 -9
  23. package/s/index.html.ts +68 -73
  24. package/s/index.ts +6 -1
  25. package/s/logic/aspects/codex/codex.ts +3 -3
  26. package/s/logic/aspects/codex/example.ts +1 -4
  27. package/s/logic/aspects/codex/parts/codex-item.ts +4 -4
  28. package/s/logic/aspects/codex/parts/hierarchy.ts +1 -0
  29. package/s/logic/aspects/codex/parts/types.ts +1 -1
  30. package/s/logic/aspects/dropzone.ts +20 -16
  31. package/s/logic/aspects/tree-trail.ts +5 -4
  32. package/s/logic/brain.ts +5 -3
  33. package/s/logic/group.ts +13 -10
  34. package/s/logic/presets/media/group.ts +5 -4
  35. package/s/logic/presets/media/library.test.ts +82 -0
  36. package/s/logic/presets/media/library.ts +249 -0
  37. package/s/logic/presets/media/schema.ts +10 -1
  38. package/s/logic/presets/opfs/schema.ts +1 -1
  39. package/s/logic/presets/plain/group.ts +2 -2
  40. package/s/logic/presets/plain/schema.ts +1 -1
  41. package/s/logic/types.ts +1 -1
  42. package/s/tests.test.ts +2 -0
  43. package/x/cellar/cask.d.ts +2 -4
  44. package/x/cellar/cask.js +3 -12
  45. package/x/cellar/cask.js.map +1 -1
  46. package/x/cellar/cellar.d.ts +9 -7
  47. package/x/cellar/cellar.js +17 -22
  48. package/x/cellar/cellar.js.map +1 -1
  49. package/x/cellar/cellar.test.d.ts +2 -3
  50. package/x/cellar/cellar.test.js +24 -39
  51. package/x/cellar/cellar.test.js.map +1 -1
  52. package/x/cellar/index.d.ts +0 -3
  53. package/x/cellar/index.js +0 -3
  54. package/x/cellar/index.js.map +1 -1
  55. package/x/cellar/mammoth.d.ts +23 -0
  56. package/x/cellar/mammoth.js +34 -0
  57. package/x/cellar/mammoth.js.map +1 -0
  58. package/x/cellar/mammoth.worker.bundle.d.ts +1 -0
  59. package/x/cellar/mammoth.worker.bundle.js +20 -0
  60. package/x/cellar/mammoth.worker.bundle.js.map +1 -0
  61. package/x/cellar/mammoth.worker.bundle.min.js +8 -0
  62. package/x/cellar/mammoth.worker.bundle.min.js.map +7 -0
  63. package/x/demo/main.bundle.js +2 -2
  64. package/x/demo/main.bundle.js.map +1 -1
  65. package/x/demo/main.bundle.min.js +229 -197
  66. package/x/demo/main.bundle.min.js.LEGAL.txt +33 -0
  67. package/x/demo/main.bundle.min.js.map +7 -0
  68. package/x/dom/components/breadcrumb/component.d.ts +1 -1
  69. package/x/dom/components/breadcrumb/component.js +6 -5
  70. package/x/dom/components/breadcrumb/component.js.map +1 -1
  71. package/x/dom/components/browser/component.d.ts +1 -1
  72. package/x/dom/components/browser/component.js +10 -7
  73. package/x/dom/components/browser/component.js.map +1 -1
  74. package/x/dom/components/browser/style.css.d.ts +1 -1
  75. package/x/dom/components/browser/style.css.js +31 -30
  76. package/x/dom/components/browser/style.css.js.map +1 -1
  77. package/x/dom/components/dropzone/component.d.ts +1 -1
  78. package/x/dom/components/dropzone/component.js +6 -4
  79. package/x/dom/components/dropzone/component.js.map +1 -1
  80. package/x/dom/components/dropzone/style.css.d.ts +1 -1
  81. package/x/dom/components/dropzone/style.css.js +12 -12
  82. package/x/dom/components/dropzone/style.css.js.map +1 -1
  83. package/x/dom/components/filter/component.d.ts +1 -1
  84. package/x/dom/components/filter/component.js +10 -8
  85. package/x/dom/components/filter/component.js.map +1 -1
  86. package/x/dom/components/outliner/component.d.ts +1 -1
  87. package/x/dom/components/outliner/component.js +7 -5
  88. package/x/dom/components/outliner/component.js.map +1 -1
  89. package/x/dom/components/searchbar/component.d.ts +1 -1
  90. package/x/dom/components/searchbar/component.js +12 -6
  91. package/x/dom/components/searchbar/component.js.map +1 -1
  92. package/x/dom/components/sort/component.d.ts +1 -1
  93. package/x/dom/components/sort/component.js +11 -9
  94. package/x/dom/components/sort/component.js.map +1 -1
  95. package/x/dom/components.d.ts +7 -7
  96. package/x/dom/theme.css.d.ts +1 -1
  97. package/x/dom/theme.css.js +65 -1
  98. package/x/dom/theme.css.js.map +1 -1
  99. package/x/dom/views/tree/component.d.ts +1 -6
  100. package/x/dom/views/tree/component.js +10 -8
  101. package/x/dom/views/tree/component.js.map +1 -1
  102. package/x/dom/views/tree/style.css.d.ts +1 -1
  103. package/x/dom/views/tree/style.css.js +10 -10
  104. package/x/dom/views/tree/style.css.js.map +1 -1
  105. package/x/genius/parts/hy-tree.js +9 -8
  106. package/x/genius/parts/hy-tree.js.map +1 -1
  107. package/x/index.d.ts +6 -1
  108. package/x/index.html +70 -167
  109. package/x/index.html.d.ts +1 -2
  110. package/x/index.html.js +67 -73
  111. package/x/index.html.js.map +1 -1
  112. package/x/index.js +6 -1
  113. package/x/index.js.map +1 -1
  114. package/x/logic/aspects/codex/codex.js +3 -3
  115. package/x/logic/aspects/codex/codex.js.map +1 -1
  116. package/x/logic/aspects/codex/example.d.ts +0 -1
  117. package/x/logic/aspects/codex/example.js +1 -3
  118. package/x/logic/aspects/codex/example.js.map +1 -1
  119. package/x/logic/aspects/codex/parts/codex-item.d.ts +1 -1
  120. package/x/logic/aspects/codex/parts/codex-item.js +4 -4
  121. package/x/logic/aspects/codex/parts/codex-item.js.map +1 -1
  122. package/x/logic/aspects/codex/parts/hierarchy.js +1 -0
  123. package/x/logic/aspects/codex/parts/hierarchy.js.map +1 -1
  124. package/x/logic/aspects/codex/parts/types.d.ts +1 -1
  125. package/x/logic/aspects/dropzone.d.ts +1 -1
  126. package/x/logic/aspects/dropzone.js +19 -15
  127. package/x/logic/aspects/dropzone.js.map +1 -1
  128. package/x/logic/aspects/tree-trail.d.ts +1 -1
  129. package/x/logic/aspects/tree-trail.js +4 -4
  130. package/x/logic/aspects/tree-trail.js.map +1 -1
  131. package/x/logic/brain.d.ts +4 -2
  132. package/x/logic/brain.js.map +1 -1
  133. package/x/logic/group.d.ts +7 -7
  134. package/x/logic/group.js +10 -8
  135. package/x/logic/group.js.map +1 -1
  136. package/x/logic/presets/media/group.d.ts +1 -1
  137. package/x/logic/presets/media/group.js +3 -3
  138. package/x/logic/presets/media/group.js.map +1 -1
  139. package/x/logic/presets/media/library.d.ts +59 -0
  140. package/x/logic/presets/media/library.js +201 -0
  141. package/x/logic/presets/media/library.js.map +1 -0
  142. package/x/logic/presets/media/library.test.d.ts +9 -0
  143. package/x/logic/presets/media/library.test.js +62 -0
  144. package/x/logic/presets/media/library.test.js.map +1 -0
  145. package/x/logic/presets/media/schema.d.ts +10 -1
  146. package/x/logic/presets/opfs/schema.d.ts +1 -1
  147. package/x/logic/presets/plain/group.js +2 -2
  148. package/x/logic/presets/plain/group.js.map +1 -1
  149. package/x/logic/presets/plain/schema.d.ts +1 -1
  150. package/x/logic/types.d.ts +1 -1
  151. package/x/tests.test.js +2 -0
  152. package/x/tests.test.js.map +1 -1
  153. package/s/cellar/forklift.ts +0 -9
  154. package/s/cellar/memory-forklift.ts +0 -29
  155. package/s/cellar/opfs-forklift.ts +0 -49
  156. package/x/cellar/forklift.d.ts +0 -7
  157. package/x/cellar/forklift.js +0 -2
  158. package/x/cellar/forklift.js.map +0 -1
  159. package/x/cellar/memory-forklift.d.ts +0 -9
  160. package/x/cellar/memory-forklift.js +0 -21
  161. package/x/cellar/memory-forklift.js.map +0 -1
  162. package/x/cellar/opfs-forklift.d.ts +0 -11
  163. package/x/cellar/opfs-forklift.js +0 -44
  164. package/x/cellar/opfs-forklift.js.map +0 -1
  165. package/x/genius/folio.md +0 -29
  166. package/x/genius/quay.md +0 -13
  167. package/x/genius/turbo.md +0 -21
  168. package/x/importmap.json +0 -47
@@ -1,7 +1,4 @@
1
1
 
2
- import "@benev/slate/x/node.js"
3
- import {repeat_string} from "@benev/turtle"
4
-
5
2
  import {Codex} from "./codex.js"
6
3
  import {AsSchema, Taxon} from "./parts/types.js"
7
4
 
@@ -48,7 +45,7 @@ src
48
45
  // we can crawl any folder!
49
46
  // 👇
50
47
  for (const [item, ancestors] of folder.crawl()) {
51
- const indent = repeat_string(ancestors.length, " ")
48
+ const indent = " ".repeat(ancestors.length)
52
49
  const specimen = item.specimen
53
50
  const label = item.kind === "folder"
54
51
  ? specimen.label + "/"
@@ -1,5 +1,5 @@
1
1
 
2
- import {signal} from "@benev/slate"
2
+ import {signal} from "@e280/strata"
3
3
  import {Codex} from "../codex.js"
4
4
  import {Id, Kind, Schema} from "./types.js"
5
5
 
@@ -52,14 +52,14 @@ export class CodexItem<Sc extends Schema = any, K extends Kind<Sc> = Kind<Sc>> {
52
52
  /** add items as children */
53
53
  attach(...items: CodexItem<Sc>[]) {
54
54
  this.codex.hierarchy.attach(this.id, ...items.map(i => i.id))
55
- this.signal.publish()
55
+ this.signal(this)
56
56
  return this
57
57
  }
58
58
 
59
59
  /** detach this item from the hierarchy completely */
60
60
  detach() {
61
61
  this.codex.hierarchy.detach(this.id)
62
- this.signal.publish()
62
+ this.signal(this)
63
63
  }
64
64
 
65
65
  /** create a new item that will be a child */
@@ -72,7 +72,7 @@ export class CodexItem<Sc extends Schema = any, K extends Kind<Sc> = Kind<Sc>> {
72
72
  /** create a new item that will be a child */
73
73
  destroy() {
74
74
  this.codex.hierarchy.destroy(this.id)
75
- this.signal.publish()
75
+ this.signal(this)
76
76
  }
77
77
 
78
78
  /** iterate over this item and all its descendants */
@@ -55,6 +55,7 @@ export class Hierarchy {
55
55
 
56
56
  /** destroy all relations associated with this id, and all its descendants */
57
57
  destroy(id: Id) {
58
+ this.detach(id)
58
59
  const tree = [...this.crawl(id)]
59
60
  for (const [id] of tree) {
60
61
  this.#children.delete(id)
@@ -1,5 +1,5 @@
1
1
 
2
- import {Content} from "@benev/slate"
2
+ import {Content} from "@e280/sly"
3
3
 
4
4
  export type Id = string
5
5
 
@@ -1,11 +1,12 @@
1
- import {ShockDragDrop, signal} from "@benev/slate"
1
+ import {loot} from "@e280/sly/loot"
2
+ import {signal} from "@e280/strata"
2
3
 
3
4
  import {Group} from "../group.js"
4
5
  import {CodexItem} from "./codex/parts/codex-item.js"
5
6
 
6
7
  export class Dropzone {
7
- #drag_drop = new ShockDragDrop<CodexItem, CodexItem>({
8
- handle_drop: (_e, grabbed, hovering) => this.group.move(grabbed, hovering)
8
+ #drag_drop = new loot.DragAndDrops<CodexItem, CodexItem>({
9
+ acceptDrop: (_e, grabbed, hovering) => this.group.move(grabbed, hovering)
9
10
  })
10
11
 
11
12
  constructor(private group: Group) {}
@@ -14,37 +15,40 @@ export class Dropzone {
14
15
  #hovering = signal<CodexItem | undefined>(undefined)
15
16
 
16
17
  get grabbed() {
17
- return this.#drag_drop.grabbed
18
+ return this.#drag_drop.dragging
18
19
  }
19
20
 
20
21
  get hovering() {
21
- return this.#drag_drop.hovering ?? this.#hovering.value
22
+ return this.#drag_drop.hovering ?? this.#hovering()
22
23
  }
23
24
 
24
- dragenter = (e: DragEvent, target?: CodexItem) => {
25
+ dragenter = (e: DragEvent, target: CodexItem) => {
25
26
  e.preventDefault()
26
- this.#drag_drop.dropzone.dragenter()(e)
27
- this.#hovering.value = target
27
+ this.#drag_drop.dropzone(() => target).dragenter(e)
28
+ this.#hovering(target)
28
29
  }
29
30
 
30
31
  dragleave = (e: DragEvent) => {
31
- this.#drag_drop.dropzone.dragleave()(e)
32
- this.#hovering.value = undefined
32
+ const hovering = this.hovering
33
+ if (hovering)
34
+ this.#drag_drop.dropzone(() => hovering).dragleave(e)
35
+ this.#hovering(undefined)
33
36
  }
34
37
 
35
38
  dragstart = (e: DragEvent, n: CodexItem) => {
36
39
  e.stopPropagation()
37
- this.#drag_drop.dragzone.dragstart(n)(e)
40
+ this.#drag_drop.dragzone(() => n).dragstart(e)
38
41
  }
39
42
 
40
43
  dragover = (e: DragEvent, n: CodexItem) => {
41
44
  e.preventDefault()
42
- this.#drag_drop.dropzone.dragover(n)(e)
45
+ this.#drag_drop.dropzone(() => n).dragover(e)
43
46
  }
44
47
 
45
48
  dragend = (e: DragEvent) => {
46
- this.#drag_drop.dragzone.dragend()(e)
47
- this.#hovering.value = undefined
49
+ this.#drag_drop.$draggy(undefined)
50
+ this.#drag_drop.$droppy(undefined)
51
+ this.#hovering(undefined)
48
52
  }
49
53
 
50
54
  drop = (e: DragEvent, target: CodexItem) => {
@@ -60,9 +64,9 @@ export class Dropzone {
60
64
  const parent = this.grabbed?.parent?.id === this.hovering?.id
61
65
 
62
66
  if(!same && !child && !parent)
63
- this.#drag_drop.dropzone.drop(target)(e)
67
+ this.#drag_drop.dropzone(() => target).drop(e)
64
68
 
65
- this.#hovering.value = undefined
69
+ this.#hovering(undefined)
66
70
  }
67
71
 
68
72
  change = (e: DragEvent, targetFolder: CodexItem) => {
@@ -1,4 +1,5 @@
1
- import {signal} from "@benev/slate"
1
+
2
+ import {signal} from "@e280/strata"
2
3
  import {Schema} from "./codex/parts/types.js"
3
4
  import {CodexItem} from "./codex/parts/codex-item.js"
4
5
 
@@ -6,7 +7,7 @@ export class TreeTrail<Sc extends Schema> {
6
7
  readonly signal = signal<CodexItem<Sc>[]>([])
7
8
 
8
9
  constructor(root: CodexItem<Sc>) {
9
- this.signal.value = [root]
10
+ this.signal([root])
10
11
  }
11
12
 
12
13
  setTrail(e: Event, item: CodexItem<Sc>) {
@@ -23,11 +24,11 @@ export class TreeTrail<Sc extends Schema> {
23
24
  cur = cur.parent
24
25
  }
25
26
 
26
- this.signal.value = trail
27
+ this.signal(trail)
27
28
  }
28
29
 
29
30
  get currentFolder() {
30
- return this.signal.value.at(-1)?.children ?? []
31
+ return this.signal().at(-1)?.children ?? []
31
32
  }
32
33
  }
33
34
 
package/s/logic/brain.ts CHANGED
@@ -2,6 +2,8 @@
2
2
  import {MapG} from "@e280/stz"
3
3
  import {Group} from "./group.js"
4
4
 
5
+ type RegisteredGroup<G> = G extends Group<infer _Schema> ? G : never
6
+
5
7
  /**
6
8
  * Quay's global state
7
9
  * - manages groups
@@ -10,13 +12,13 @@ import {Group} from "./group.js"
10
12
  export class Brain {
11
13
  #groups = new MapG<string, Group>()
12
14
 
13
- setGroup<G extends Group>(name: string, group: G) {
15
+ setGroup<G>(name: string, group: RegisteredGroup<G>) {
14
16
  this.#groups.set(name, group)
15
17
  return group
16
18
  }
17
19
 
18
- getGroup<G extends Group>(name: string) {
19
- return this.#groups.require(name) as G
20
+ getGroup<G = Group>(name: string) {
21
+ return this.#groups.require(name) as RegisteredGroup<G>
20
22
  }
21
23
  }
22
24
 
package/s/logic/group.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import {sub} from "@e280/stz"
2
- import {signal} from "@benev/slate"
2
+ import {signal} from "@e280/strata"
3
3
 
4
4
  import {Permission} from "./permissions.js"
5
5
  import {Dropzone} from "./aspects/dropzone.js"
@@ -18,7 +18,7 @@ export class Group<Sc extends Schema = any> {
18
18
  on = {
19
19
  newFolder: sub<[{parent: CodexItem<Sc>}]>(),
20
20
  move: sub<[{item: CodexItem<Sc>, target: CodexItem<Sc>}]>(),
21
- delete: sub<[{item: CodexItem<Sc>}]>(),
21
+ delete: sub<[{items: CodexItem<Sc>[]}]>(),
22
22
  rename: sub<[{item: CodexItem<Sc>, newName: string}]>(),
23
23
  upload: sub<[{files: File[], target: CodexItem<Sc>}]>(),
24
24
  search: sub<[{terms: string[]}]>(),
@@ -26,13 +26,13 @@ export class Group<Sc extends Schema = any> {
26
26
  }
27
27
 
28
28
  constructor(public config: GroupConfig<Sc>) {
29
- this.selectedFilter.value = config.defaultFilter
30
- this.selectedSort.value = config.defaultSort
29
+ this.selectedFilter(config.defaultFilter)
30
+ this.selectedSort(config.defaultSort)
31
31
  this.trail = new TreeTrail(config.root)
32
32
  }
33
33
 
34
34
  get permissions() {
35
- return this.config.permissions as (item: CodexItem) => Permission
35
+ return this.config.permissions as (item: CodexItem<Sc>) => Permission
36
36
  }
37
37
 
38
38
  getFilterFn(filterKey: string): SearchFn<Sc> {
@@ -49,13 +49,13 @@ export class Group<Sc extends Schema = any> {
49
49
  }
50
50
 
51
51
  sort(items: CodexItem<Sc>[]): CodexItem<Sc>[] {
52
- const sortFn = this.getSortFn(this.selectedSort.value)
52
+ const sortFn = this.getSortFn(this.selectedSort())
53
53
  return [...items].sort(sortFn)
54
54
  }
55
55
 
56
56
  matches(item: CodexItem<Sc>) {
57
- const filterFn = this.getFilterFn(this.selectedFilter.value)
58
- const searchFn = this.getSearchFn(this.searchText.value)
57
+ const filterFn = this.getFilterFn(this.selectedFilter())
58
+ const searchFn = this.getSearchFn(this.searchText())
59
59
  return filterFn(item) && searchFn(item)
60
60
  }
61
61
 
@@ -73,9 +73,11 @@ export class Group<Sc extends Schema = any> {
73
73
  if(!this.permissions(item).delete)
74
74
  throw new Error("delete permission not granted")
75
75
 
76
+ const items = [...item.crawl()].map(([item]) => item)
77
+
76
78
  item.destroy()
77
79
 
78
- this.on.delete.pub({item})
80
+ return this.on.delete.pub({items})
79
81
  }
80
82
 
81
83
  rename(item: CodexItem<Sc>, newName: string) {
@@ -89,7 +91,8 @@ export class Group<Sc extends Schema = any> {
89
91
  if(!this.permissions(folder).upload)
90
92
  throw new Error("upload permission not granted")
91
93
 
92
- this.on.upload.pub({files, target: folder})
94
+ return this.on.upload.pub({files, target: folder})
95
+ .then(() => this.on.refresh.pub({}))
93
96
  }
94
97
 
95
98
  addFolder(parent: CodexItem<Sc>) {
@@ -1,15 +1,16 @@
1
1
 
2
+ import {html} from "lit"
2
3
  import {MapG} from "@e280/stz"
3
- import {Content, html} from "@benev/slate"
4
+ import {Content} from "@e280/sly"
4
5
 
5
- import Quay from "../../../index.js"
6
6
  import {Group} from "../../group.js"
7
+ import {Permissions} from "../../permissions.js"
7
8
  import {Codex} from "../../aspects/codex/codex.js"
8
9
  import {MediaFormat, MediaSchema} from "./schema.js"
9
10
  import {GroupConfig, SearchFn, SortFn} from "../../types.js"
10
11
  import {CodexItem} from "../../aspects/codex/parts/codex-item.js"
11
12
 
12
- export class MediaGroup extends Group<MediaSchema> implements Group {
13
+ export class MediaGroup extends Group<MediaSchema> {
13
14
  static config = (): GroupConfig<MediaSchema> => {
14
15
  const formatIcons = new MapG<MediaFormat, Content>()
15
16
  .set("audio", html`<sl-icon name=music-note-beamed></sl-icon>`)
@@ -83,7 +84,7 @@ export class MediaGroup extends Group<MediaSchema> implements Group {
83
84
  // // TODO icons
84
85
  // : html`<sl-icon name=${item.taxon.icon}></sl-icon>`
85
86
  },
86
- permissions: item => Quay.permissions.all,
87
+ permissions: item => Permissions.all,
87
88
  }
88
89
  }
89
90
 
@@ -0,0 +1,82 @@
1
+
2
+ import {Txt} from "@e280/stz"
3
+ import {expect, Science, test} from "@e280/science"
4
+
5
+ import {MediaLibrary} from "./library.js"
6
+ import {Permissions} from "../../permissions.js"
7
+
8
+ delete (globalThis as any).localStorage
9
+
10
+ export default Science.suite({
11
+ "uploads files into cellar and index": test(async() => {
12
+ const group = new MediaLibrary()
13
+ await group.upload([file("hello.txt", "hello")], group.config.root)
14
+
15
+ const record = await recordByLabel(group, "hello.txt")
16
+ const item = group.findByHash(record.hash)!
17
+
18
+ expect(item.specimen.label).is("hello.txt")
19
+ expect(await group.cellar.has(record.hash)).is(true)
20
+ }),
21
+
22
+ "upload respects upload permission": test(async() => {
23
+ const group = new MediaLibrary()
24
+ const file = new File([Txt.toBytes("hello")], "hello.txt", {type: "text/plain"})
25
+ group.config.permissions = () => Permissions.readOnly
26
+
27
+ expect(() => group.upload([file], group.config.root)).throws()
28
+ }),
29
+
30
+ "lists media records": test(async() => {
31
+ const store = new MediaLibrary()
32
+ await store.upload([file("image.png", "image", "image/png")], store.config.root)
33
+
34
+ expect(await recordByLabel(store, "image.png")).ok()
35
+ }),
36
+
37
+ "includes existing media in a scope": test(async() => {
38
+ const library = new MediaLibrary()
39
+ await library.upload([file("include.txt", "include")], library.config.root)
40
+ const record = await recordByLabel(library, "include.txt")
41
+ const project = await library.scope("include-project")
42
+
43
+ await project.include(record.hash)
44
+
45
+ expect(project.findByHash(record.hash)).ok()
46
+ expect(await recordByLabel(project, "include.txt")).ok()
47
+ expect(await project.cellar.has(record.hash)).is(true)
48
+ await expect(() => project.include("missing")).throwsAsync()
49
+ }),
50
+
51
+ "root deletion removes media records and resources": test(async() => {
52
+ const store = new MediaLibrary()
53
+ await store.upload([file("delete.png", "delete-image", "image/png")], store.config.root)
54
+
55
+ const record = await recordByLabel(store, "delete.png")
56
+ const item = store.findByHash(record.hash)!
57
+
58
+ await store.delete(item)
59
+
60
+ expect(store.findByHash(record.hash)).is(undefined)
61
+ expect(await store.cellar.has(record.hash)).is(false)
62
+ }),
63
+
64
+ })
65
+
66
+ function file(name: string, text: string, type = "text/plain") {
67
+ return new File([Txt.toBytes(text)], name, {type})
68
+ }
69
+
70
+ async function records(store: MediaLibrary) {
71
+ const records = []
72
+ for await (const record of store.records())
73
+ records.push(record)
74
+ return records
75
+ }
76
+
77
+ async function recordByLabel(store: MediaLibrary, label: string) {
78
+ const record = (await records(store)).find(r => r.label === label)
79
+ if (!record)
80
+ throw new Error(`expected record "${label}"`)
81
+ return record
82
+ }
@@ -0,0 +1,249 @@
1
+
2
+ import {collect, sub} from "@e280/stz"
3
+ import {Kv, StorageMagazine} from "@e280/kv"
4
+
5
+ import {MediaGroup} from "./group.js"
6
+ import {Cellar} from "../../../cellar/cellar.js"
7
+ import {MediaFormat, MediaProgress, MediaSchema} from "./schema.js"
8
+ import {CodexItem} from "../../aspects/codex/parts/codex-item.js"
9
+
10
+ export type MediaRecord = {
11
+ hash: string
12
+ label: string
13
+ format: MediaFormat
14
+ mime: string
15
+ size: number
16
+ createdAt: number
17
+ updatedAt: number
18
+ }
19
+
20
+ export class MediaLibrary extends MediaGroup {
21
+ static async open(scope = "default") {
22
+ const group = new this()
23
+ group.cellar = await Cellar.opfs("media")
24
+ group.#index = mediaIndex(scope)
25
+ group.#lineage = [group.#index]
26
+ await group.#load()
27
+ return group
28
+ }
29
+
30
+ #index
31
+ #lineage
32
+ #root = this
33
+ #objectUrls = new Map<string, string>()
34
+
35
+ progress = sub<[MediaProgress]>()
36
+ cellar = new Cellar()
37
+
38
+ constructor() {
39
+ super()
40
+ this.#index = mediaIndex("default")
41
+ this.#lineage = [this.#index]
42
+ this.on.upload.sub(({files, target}) => {
43
+ return this.#upload(files, target)
44
+ })
45
+ this.on.delete.sub(({items}) => {
46
+ const hashes = this.#hashes(items)
47
+ return this.#delete(hashes)
48
+ })
49
+ }
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
+
65
+ async *records() {
66
+ for await (const [, record] of this.#index.entries())
67
+ yield record
68
+ }
69
+
70
+ async include(hash: string) {
71
+ const record = await this.#root.#index.need(hash)
72
+ await this.#index.commit(this.#lineage.map(index => index.op.set(hash, record)))
73
+ if (record.format === "image" && !this.#objectUrls.has(hash))
74
+ await this.#loadPreview(record)
75
+ this.#attachRecord(record)
76
+ this.on.refresh.pub({})
77
+ }
78
+
79
+ async #load() {
80
+ for await (const record of this.records()) {
81
+ if (record.format === "image" && !this.#objectUrls.has(record.hash))
82
+ await this.#loadPreview(record)
83
+ this.#attachRecord(record, this.config.root)
84
+ }
85
+ }
86
+
87
+ async #upload(files: File[], parent: CodexItem<MediaSchema>) {
88
+ await Promise.all(files.map(file => this.#storeFile(file, parent)))
89
+ }
90
+
91
+ async #storeFile(file: File, parent: CodexItem<MediaSchema>) {
92
+ const item = this.#attachPending(file, parent)
93
+
94
+ try {
95
+ const hash = await this.cellar.write(trackProgress(file.stream(), loaded =>
96
+ this.progress.pub({item, loaded, total: file.size})
97
+ ))
98
+ const record = await this.#saveRecord(file, hash)
99
+ item.destroy()
100
+ this.#attachRecord(record, parent)
101
+ return record
102
+ }
103
+ catch (error) {
104
+ item.destroy()
105
+ this.on.refresh.pub({})
106
+ throw error
107
+ }
108
+ }
109
+
110
+ #attachPending(file: File, parent: CodexItem<MediaSchema>) {
111
+ const item = this.config.codex.create("file", {
112
+ label: file.name,
113
+ format: mediaFormat(file.type),
114
+ mime: file.type,
115
+ size: file.size,
116
+ previewUrl: null,
117
+ })
118
+ parent.attach(item)
119
+ this.on.refresh.pub({})
120
+ return item
121
+ }
122
+
123
+ async #saveRecord(file: File, hash: string) {
124
+ const existing = await this.#index.get(hash) ?? await this.#root.#index.get(hash)
125
+ const now = Date.now()
126
+ const record: MediaRecord = {
127
+ hash,
128
+ label: existing?.label ?? file.name,
129
+ format: mediaFormat(file.type),
130
+ mime: file.type,
131
+ size: file.size,
132
+ createdAt: existing?.createdAt ?? now,
133
+ updatedAt: now,
134
+ }
135
+ await this.#index.commit(this.#lineage.map(index => index.op.set(hash, record)))
136
+ if (record.format === "image")
137
+ this.#setPreview(hash, URL.createObjectURL(file))
138
+ return record
139
+ }
140
+
141
+ #hashes(items: CodexItem<MediaSchema>[]) {
142
+ return items
143
+ .map(item => item.isKind("file") ? item.specimen.hash : undefined)
144
+ .filter((hash): hash is string => !!hash)
145
+ }
146
+
147
+ async #delete(hashes: string[]) {
148
+ const index = this.#index.crush()
149
+ const keys = (await collect(index.entries()))
150
+ .filter(([, record]) => hashes.includes(record.hash))
151
+ .map(([key]) => key)
152
+ await index.commit(keys.map(key => index.op.delete(key)))
153
+
154
+ for (const hash of hashes) {
155
+ this.#revokePreview(hash)
156
+ if (this.#isRoot)
157
+ await this.cellar.delete(hash)
158
+ }
159
+ }
160
+
161
+ findByHash(hash: string) {
162
+ for (const [item] of this.config.root.crawl()) {
163
+ if (item.isKind("file") && item.specimen.hash === hash)
164
+ return item
165
+ }
166
+ }
167
+
168
+ dispose() {
169
+ for (const url of this.#objectUrls.values())
170
+ URL.revokeObjectURL(url)
171
+ this.#objectUrls.clear()
172
+ }
173
+
174
+ #attachRecord(record: MediaRecord, parent = this.config.root) {
175
+ const existing = this.findByHash(record.hash)
176
+ if (existing)
177
+ return existing
178
+
179
+ const item = this.config.codex.create("file", {
180
+ hash: record.hash,
181
+ label: record.label,
182
+ format: record.format,
183
+ mime: record.mime,
184
+ size: record.size,
185
+ previewUrl: this.#objectUrls.get(record.hash) ?? null,
186
+ })
187
+ parent.attach(item)
188
+ return item
189
+ }
190
+
191
+ async #loadPreview(record: MediaRecord) {
192
+ const cask = await this.cellar.load(record.hash)
193
+ const blob = new Blob([cask.file], {type: record.mime || "image/*"})
194
+ return this.#setPreview(record.hash, URL.createObjectURL(blob))
195
+ }
196
+
197
+ #setPreview(hash: string, url: string) {
198
+ this.#revokePreview(hash)
199
+ this.#objectUrls.set(hash, url)
200
+ return url
201
+ }
202
+
203
+ #revokePreview(hash: string) {
204
+ const url = this.#objectUrls.get(hash)
205
+ if (url) {
206
+ URL.revokeObjectURL(url)
207
+ this.#objectUrls.delete(hash)
208
+ }
209
+ }
210
+ }
211
+
212
+ const mediaFormats = new Set(["video", "image", "audio"])
213
+ const memoryIndexes = new Map<string, Kv<MediaRecord>>()
214
+
215
+ function mediaFormat(mime: string): MediaFormat {
216
+ const [type] = mime.split("/")
217
+ return mediaFormats.has(type) ? type as MediaFormat : "other"
218
+ }
219
+
220
+ function mediaIndex(scope: string) {
221
+ const storage = globalThis.localStorage
222
+ if (storage)
223
+ return new Kv<MediaRecord>(new StorageMagazine(storage))
224
+ .scope("quay")
225
+ .scope("media")
226
+ .scope<MediaRecord>(scope)
227
+
228
+ const existing = memoryIndexes.get(scope)
229
+ if (existing)
230
+ return existing
231
+
232
+ const index = new Kv<MediaRecord>()
233
+ memoryIndexes.set(scope, index)
234
+ return index
235
+ }
236
+
237
+ function trackProgress(
238
+ readable: ReadableStream<Uint8Array>,
239
+ onProgress: (loaded: number) => void,
240
+ ) {
241
+ let loaded = 0
242
+ return readable.pipeThrough(new TransformStream({
243
+ transform(chunk, controller) {
244
+ loaded += chunk.byteLength
245
+ onProgress(loaded)
246
+ controller.enqueue(chunk)
247
+ },
248
+ }))
249
+ }
@@ -1,6 +1,7 @@
1
1
 
2
- import {Content} from "@benev/slate"
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
 
@@ -16,7 +17,15 @@ export type MediaSchema = AsSchema<{
16
17
  label: string
17
18
  format: MediaFormat
18
19
  previewUrl: string | null
20
+ hash?: string
21
+ mime?: string
22
+ size?: number
19
23
  }
20
24
  }
21
25
  }>
22
26
 
27
+ export type MediaProgress = {
28
+ item: CodexItem<MediaSchema>
29
+ loaded: number
30
+ total: number
31
+ }
@@ -1,5 +1,5 @@
1
1
 
2
- import {Content} from "@benev/slate"
2
+ import {Content} from "@e280/sly"
3
3
  import {AsSchema} from "../../aspects/codex/parts/types.js"
4
4
 
5
5
  export type OpfsSchema = AsSchema<{
@@ -1,7 +1,7 @@
1
1
 
2
- import Quay from "../../../index.js"
3
2
  import {Group} from "../../group.js"
4
3
  import {PlainSchema} from "./schema.js"
4
+ import {Permissions} from "../../permissions.js"
5
5
  import {GroupConfig, SortFn} from "../../types.js"
6
6
  import {Codex} from "../../aspects/codex/codex.js"
7
7
 
@@ -33,7 +33,7 @@ export class PlainGroup extends Group<PlainSchema> {
33
33
  renderLabel: item => item.id,
34
34
  renderIcon: item => item.taxon.icon,
35
35
  renderPreview: () => null,
36
- permissions: item => Quay.permissions.all,
36
+ permissions: item => Permissions.all,
37
37
  }
38
38
  }
39
39