cursedbelt-core 1.0.0

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 (307) hide show
  1. package/LICENSE +15 -0
  2. package/dist/client/wsWire.d.ts +49 -0
  3. package/dist/client/wsWire.js +57 -0
  4. package/dist/config/appConfig.d.ts +361 -0
  5. package/dist/config/appConfig.js +308 -0
  6. package/dist/core/activity/index.d.ts +8 -0
  7. package/dist/core/activity/index.js +7 -0
  8. package/dist/core/activity/model.d.ts +115 -0
  9. package/dist/core/activity/model.js +63 -0
  10. package/dist/core/analytics/index.d.ts +18 -0
  11. package/dist/core/analytics/index.js +18 -0
  12. package/dist/core/analytics/range.d.ts +186 -0
  13. package/dist/core/analytics/range.js +271 -0
  14. package/dist/core/analytics/schedule.d.ts +301 -0
  15. package/dist/core/analytics/schedule.js +372 -0
  16. package/dist/core/category/index.d.ts +3 -0
  17. package/dist/core/category/index.js +6 -0
  18. package/dist/core/category/memoryStore.d.ts +21 -0
  19. package/dist/core/category/memoryStore.js +97 -0
  20. package/dist/core/category/tree.d.ts +26 -0
  21. package/dist/core/category/tree.js +113 -0
  22. package/dist/core/category/types.d.ts +48 -0
  23. package/dist/core/category/types.js +8 -0
  24. package/dist/core/color.d.ts +14 -0
  25. package/dist/core/color.js +32 -0
  26. package/dist/core/colorScheme/colorSchemeStore.d.ts +70 -0
  27. package/dist/core/colorScheme/colorSchemeStore.js +123 -0
  28. package/dist/core/colorScheme/index.d.ts +7 -0
  29. package/dist/core/colorScheme/index.js +7 -0
  30. package/dist/core/domainOwners.d.ts +19 -0
  31. package/dist/core/domainOwners.js +15 -0
  32. package/dist/core/download.d.ts +14 -0
  33. package/dist/core/download.js +37 -0
  34. package/dist/core/events/ccEventBus.d.ts +44 -0
  35. package/dist/core/events/ccEventBus.js +70 -0
  36. package/dist/core/file-tree/fileTreeModel.d.ts +276 -0
  37. package/dist/core/file-tree/fileTreeModel.js +312 -0
  38. package/dist/core/folder-tree/actions.d.ts +84 -0
  39. package/dist/core/folder-tree/actions.js +119 -0
  40. package/dist/core/folder-tree/index.d.ts +3 -0
  41. package/dist/core/folder-tree/index.js +20 -0
  42. package/dist/core/folder-tree/path.d.ts +76 -0
  43. package/dist/core/folder-tree/path.js +146 -0
  44. package/dist/core/folder-tree/tree.d.ts +86 -0
  45. package/dist/core/folder-tree/tree.js +184 -0
  46. package/dist/core/layout/index.d.ts +1 -0
  47. package/dist/core/layout/index.js +7 -0
  48. package/dist/core/master-lock/index.d.ts +14 -0
  49. package/dist/core/master-lock/index.js +14 -0
  50. package/dist/core/master-lock/kdf.d.ts +63 -0
  51. package/dist/core/master-lock/kdf.js +117 -0
  52. package/dist/core/master-lock/policy.d.ts +32 -0
  53. package/dist/core/master-lock/policy.js +52 -0
  54. package/dist/core/master-lock/presence.d.ts +52 -0
  55. package/dist/core/master-lock/presence.js +61 -0
  56. package/dist/core/master-lock/wire.d.ts +139 -0
  57. package/dist/core/master-lock/wire.js +81 -0
  58. package/dist/core/media/bgRemoval.d.ts +27 -0
  59. package/dist/core/media/bgRemoval.js +101 -0
  60. package/dist/core/media/ffmpeg.d.ts +57 -0
  61. package/dist/core/media/ffmpeg.js +126 -0
  62. package/dist/core/media/format.d.ts +20 -0
  63. package/dist/core/media/format.js +54 -0
  64. package/dist/core/media/index.d.ts +12 -0
  65. package/dist/core/media/index.js +18 -0
  66. package/dist/core/media/mediaItem.d.ts +15 -0
  67. package/dist/core/media/mediaItem.js +34 -0
  68. package/dist/core/media/renderPlan.d.ts +63 -0
  69. package/dist/core/media/renderPlan.js +232 -0
  70. package/dist/core/media/segment.d.ts +79 -0
  71. package/dist/core/media/segment.js +247 -0
  72. package/dist/core/media/size.d.ts +1 -0
  73. package/dist/core/media/size.js +3 -0
  74. package/dist/core/media/trim.d.ts +21 -0
  75. package/dist/core/media/trim.js +40 -0
  76. package/dist/core/media/types.d.ts +148 -0
  77. package/dist/core/media/types.js +11 -0
  78. package/dist/core/media/uploadLimits.d.ts +7 -0
  79. package/dist/core/media/uploadLimits.js +16 -0
  80. package/dist/core/media/validate.d.ts +9 -0
  81. package/dist/core/media/validate.js +121 -0
  82. package/dist/core/media/videoCuts.d.ts +152 -0
  83. package/dist/core/media/videoCuts.js +284 -0
  84. package/dist/core/navigation/breadcrumbCompat.d.ts +24 -0
  85. package/dist/core/navigation/breadcrumbCompat.js +21 -0
  86. package/dist/core/navigation/coverageTest.d.ts +24 -0
  87. package/dist/core/navigation/coverageTest.js +48 -0
  88. package/dist/core/navigation/index.d.ts +6 -0
  89. package/dist/core/navigation/index.js +10 -0
  90. package/dist/core/navigation/migrateNavPreferences.d.ts +55 -0
  91. package/dist/core/navigation/migrateNavPreferences.js +63 -0
  92. package/dist/core/navigation/navigationModel.d.ts +165 -0
  93. package/dist/core/navigation/navigationModel.js +14 -0
  94. package/dist/core/navigation/navigationSelectors.d.ts +63 -0
  95. package/dist/core/navigation/navigationSelectors.js +167 -0
  96. package/dist/core/navigation/searchCatalogue.d.ts +23 -0
  97. package/dist/core/navigation/searchCatalogue.js +76 -0
  98. package/dist/core/net/reconnectSchedule.d.ts +46 -0
  99. package/dist/core/net/reconnectSchedule.js +38 -0
  100. package/dist/core/palette/ShortcutRegistry.d.ts +33 -0
  101. package/dist/core/palette/ShortcutRegistry.js +52 -0
  102. package/dist/core/palette/fuzzyRank.d.ts +18 -0
  103. package/dist/core/palette/fuzzyRank.js +73 -0
  104. package/dist/core/palette/hotkeys.d.ts +28 -0
  105. package/dist/core/palette/hotkeys.js +118 -0
  106. package/dist/core/palette/index.d.ts +3 -0
  107. package/dist/core/palette/index.js +8 -0
  108. package/dist/core/schema/projection.d.ts +84 -0
  109. package/dist/core/schema/projection.js +184 -0
  110. package/dist/core/session/claims.d.ts +145 -0
  111. package/dist/core/session/claims.js +157 -0
  112. package/dist/core/sharing/index.d.ts +5 -0
  113. package/dist/core/sharing/index.js +4 -0
  114. package/dist/core/sharing/model.d.ts +195 -0
  115. package/dist/core/sharing/model.js +150 -0
  116. package/dist/core/site/siteCopy.d.ts +92 -0
  117. package/dist/core/site/siteCopy.js +91 -0
  118. package/dist/core/slots.d.ts +22 -0
  119. package/dist/core/slots.js +31 -0
  120. package/dist/core/styles/constructableSheet.d.ts +51 -0
  121. package/dist/core/styles/constructableSheet.js +80 -0
  122. package/dist/core/tokens/registry.d.ts +119 -0
  123. package/dist/core/tokens/registry.js +447 -0
  124. package/dist/core/uiScale/index.d.ts +8 -0
  125. package/dist/core/uiScale/index.js +8 -0
  126. package/dist/core/uiScale/scale.d.ts +36 -0
  127. package/dist/core/uiScale/scale.js +47 -0
  128. package/dist/core/uiScale/uiScaleStore.d.ts +47 -0
  129. package/dist/core/uiScale/uiScaleStore.js +63 -0
  130. package/dist/core/wire/__fixtures__/rowsMarshalWorker.d.ts +1 -0
  131. package/dist/core/wire/__fixtures__/rowsMarshalWorker.js +23 -0
  132. package/dist/core/wire/index.d.ts +17 -0
  133. package/dist/core/wire/index.js +17 -0
  134. package/dist/core/wire/ingress.d.ts +44 -0
  135. package/dist/core/wire/ingress.js +134 -0
  136. package/dist/core/wire/marshalWorker.d.ts +70 -0
  137. package/dist/core/wire/marshalWorker.js +273 -0
  138. package/dist/core/wire/marshalling.d.ts +122 -0
  139. package/dist/core/wire/marshalling.js +126 -0
  140. package/dist/core/wire/wireHub.d.ts +80 -0
  141. package/dist/core/wire/wireHub.js +116 -0
  142. package/dist/ctgr/base64url.d.ts +8 -0
  143. package/dist/ctgr/base64url.js +39 -0
  144. package/dist/ctgr/chunkAccumulator.d.ts +214 -0
  145. package/dist/ctgr/chunkAccumulator.js +307 -0
  146. package/dist/ctgr/chunkStore.d.ts +24 -0
  147. package/dist/ctgr/chunkStore.js +125 -0
  148. package/dist/ctgr/codec.d.ts +44 -0
  149. package/dist/ctgr/codec.js +153 -0
  150. package/dist/ctgr/compress.d.ts +2 -0
  151. package/dist/ctgr/compress.js +20 -0
  152. package/dist/ctgr/crc32.d.ts +2 -0
  153. package/dist/ctgr/crc32.js +23 -0
  154. package/dist/ctgr/digest.d.ts +1 -0
  155. package/dist/ctgr/digest.js +15 -0
  156. package/dist/ctgr/index.d.ts +9 -0
  157. package/dist/ctgr/index.js +17 -0
  158. package/dist/ctgr/types.d.ts +64 -0
  159. package/dist/ctgr/types.js +21 -0
  160. package/dist/ctgr/v0compat.d.ts +27 -0
  161. package/dist/ctgr/v0compat.js +128 -0
  162. package/dist/index.d.ts +15 -0
  163. package/dist/index.js +17 -0
  164. package/dist/shared/errors.d.ts +101 -0
  165. package/dist/shared/errors.js +99 -0
  166. package/dist/testing/bytes.d.ts +32 -0
  167. package/dist/testing/bytes.js +33 -0
  168. package/dist/testing/domInspect.d.ts +59 -0
  169. package/dist/testing/domInspect.js +115 -0
  170. package/dist/testing/fakeFetch.d.ts +23 -0
  171. package/dist/testing/fakeFetch.js +21 -0
  172. package/dist/testing/index.d.ts +3 -0
  173. package/dist/testing/index.js +11 -0
  174. package/package.json +373 -0
  175. package/src/client/wsWire.ts +83 -0
  176. package/src/config/appConfig.spec.ts +158 -0
  177. package/src/config/appConfig.ts +372 -0
  178. package/src/core/activity/index.ts +21 -0
  179. package/src/core/activity/model.ts +160 -0
  180. package/src/core/analytics/index.ts +18 -0
  181. package/src/core/analytics/range.spec.ts +280 -0
  182. package/src/core/analytics/range.ts +383 -0
  183. package/src/core/analytics/schedule.spec.ts +311 -0
  184. package/src/core/analytics/schedule.ts +624 -0
  185. package/src/core/category/category.spec.ts +144 -0
  186. package/src/core/category/index.ts +19 -0
  187. package/src/core/category/memoryStore.ts +104 -0
  188. package/src/core/category/tree.ts +118 -0
  189. package/src/core/category/types.ts +59 -0
  190. package/src/core/color.spec.ts +35 -0
  191. package/src/core/color.ts +35 -0
  192. package/src/core/colorScheme/colorScheme.spec.ts +127 -0
  193. package/src/core/colorScheme/colorSchemeStore.ts +184 -0
  194. package/src/core/colorScheme/index.ts +23 -0
  195. package/src/core/domainOwners.ts +32 -0
  196. package/src/core/download.spec.ts +38 -0
  197. package/src/core/download.ts +35 -0
  198. package/src/core/events/ccEventBus.spec.ts +90 -0
  199. package/src/core/events/ccEventBus.ts +120 -0
  200. package/src/core/file-tree/fileTreeModel.spec.ts +462 -0
  201. package/src/core/file-tree/fileTreeModel.ts +511 -0
  202. package/src/core/folder-tree/actions.spec.ts +103 -0
  203. package/src/core/folder-tree/actions.ts +153 -0
  204. package/src/core/folder-tree/index.ts +46 -0
  205. package/src/core/folder-tree/path.spec.ts +113 -0
  206. package/src/core/folder-tree/path.ts +148 -0
  207. package/src/core/folder-tree/tree.spec.ts +157 -0
  208. package/src/core/folder-tree/tree.ts +244 -0
  209. package/src/core/layout/index.ts +7 -0
  210. package/src/core/master-lock/index.ts +50 -0
  211. package/src/core/master-lock/kdf.spec.ts +95 -0
  212. package/src/core/master-lock/kdf.ts +145 -0
  213. package/src/core/master-lock/policy.ts +55 -0
  214. package/src/core/master-lock/presence.spec.ts +73 -0
  215. package/src/core/master-lock/presence.ts +71 -0
  216. package/src/core/master-lock/wire.ts +155 -0
  217. package/src/core/media/bgRemoval.spec.ts +120 -0
  218. package/src/core/media/bgRemoval.ts +123 -0
  219. package/src/core/media/ffmpeg.spec.ts +135 -0
  220. package/src/core/media/ffmpeg.ts +162 -0
  221. package/src/core/media/format.spec.ts +41 -0
  222. package/src/core/media/format.ts +58 -0
  223. package/src/core/media/index.ts +105 -0
  224. package/src/core/media/mediaItem.spec.ts +46 -0
  225. package/src/core/media/mediaItem.ts +40 -0
  226. package/src/core/media/renderPlan.spec.ts +140 -0
  227. package/src/core/media/renderPlan.ts +318 -0
  228. package/src/core/media/segment.spec.ts +159 -0
  229. package/src/core/media/segment.ts +352 -0
  230. package/src/core/media/size.spec.ts +19 -0
  231. package/src/core/media/size.ts +3 -0
  232. package/src/core/media/trim.spec.ts +75 -0
  233. package/src/core/media/trim.ts +60 -0
  234. package/src/core/media/types.ts +147 -0
  235. package/src/core/media/uploadLimits.ts +18 -0
  236. package/src/core/media/validate.spec.ts +106 -0
  237. package/src/core/media/validate.ts +131 -0
  238. package/src/core/media/videoCuts.spec.ts +327 -0
  239. package/src/core/media/videoCuts.ts +355 -0
  240. package/src/core/navigation/breadcrumbCompat.ts +44 -0
  241. package/src/core/navigation/coverageTest.ts +54 -0
  242. package/src/core/navigation/index.ts +50 -0
  243. package/src/core/navigation/migrateNavPreferences.ts +119 -0
  244. package/src/core/navigation/navigation.spec.ts +388 -0
  245. package/src/core/navigation/navigationModel.ts +185 -0
  246. package/src/core/navigation/navigationSelectors.ts +206 -0
  247. package/src/core/navigation/searchCatalogue.ts +89 -0
  248. package/src/core/net/reconnectSchedule.spec.ts +49 -0
  249. package/src/core/net/reconnectSchedule.ts +78 -0
  250. package/src/core/palette/ShortcutRegistry.ts +69 -0
  251. package/src/core/palette/fuzzyRank.ts +78 -0
  252. package/src/core/palette/hotkeys.ts +132 -0
  253. package/src/core/palette/index.ts +22 -0
  254. package/src/core/palette/palette.spec.ts +160 -0
  255. package/src/core/schema/projection.spec.ts +122 -0
  256. package/src/core/schema/projection.ts +225 -0
  257. package/src/core/session/claims.ts +181 -0
  258. package/src/core/sharing/index.ts +33 -0
  259. package/src/core/sharing/model.spec.ts +104 -0
  260. package/src/core/sharing/model.ts +296 -0
  261. package/src/core/site/siteCopy.ts +115 -0
  262. package/src/core/slots.ts +38 -0
  263. package/src/core/styles/constructableSheet.ts +86 -0
  264. package/src/core/tokens/registry.spec.ts +160 -0
  265. package/src/core/tokens/registry.ts +536 -0
  266. package/src/core/uiScale/index.ts +32 -0
  267. package/src/core/uiScale/scale.ts +63 -0
  268. package/src/core/uiScale/uiScale.spec.ts +77 -0
  269. package/src/core/uiScale/uiScaleStore.ts +100 -0
  270. package/src/core/wire/__fixtures__/rowsMarshalWorker.ts +24 -0
  271. package/src/core/wire/index.ts +56 -0
  272. package/src/core/wire/ingress.spec.ts +276 -0
  273. package/src/core/wire/ingress.ts +182 -0
  274. package/src/core/wire/marshalWorker.spec.ts +196 -0
  275. package/src/core/wire/marshalWorker.ts +361 -0
  276. package/src/core/wire/marshalling.spec.ts +139 -0
  277. package/src/core/wire/marshalling.ts +238 -0
  278. package/src/core/wire/wireHub.spec.ts +137 -0
  279. package/src/core/wire/wireHub.ts +213 -0
  280. package/src/ctgr/base64url.ts +44 -0
  281. package/src/ctgr/chunkAccumulator.ts +403 -0
  282. package/src/ctgr/chunkStore.spec.ts +101 -0
  283. package/src/ctgr/chunkStore.ts +168 -0
  284. package/src/ctgr/codec.spec.ts +208 -0
  285. package/src/ctgr/codec.ts +202 -0
  286. package/src/ctgr/compress.ts +21 -0
  287. package/src/ctgr/crc32.ts +23 -0
  288. package/src/ctgr/digest.ts +14 -0
  289. package/src/ctgr/index.ts +39 -0
  290. package/src/ctgr/types.ts +80 -0
  291. package/src/ctgr/v0compat.spec.ts +73 -0
  292. package/src/ctgr/v0compat.ts +156 -0
  293. package/src/declaredDepsAreImported.spec.ts +87 -0
  294. package/src/index.ts +25 -0
  295. package/src/leafSubpathsImportNothing.spec.ts +287 -0
  296. package/src/lockfileIsTracked.spec.ts +50 -0
  297. package/src/namedSubpathsResolve.spec.ts +122 -0
  298. package/src/noPathDeps.spec.ts +24 -0
  299. package/src/publishShape.spec.ts +89 -0
  300. package/src/shared/errors.ts +153 -0
  301. package/src/skippedTestsAreAnswered.spec.ts +221 -0
  302. package/src/testTmpRoot.spec.ts +102 -0
  303. package/src/testing/bytes.ts +38 -0
  304. package/src/testing/domInspect.spec.ts +79 -0
  305. package/src/testing/domInspect.ts +127 -0
  306. package/src/testing/fakeFetch.ts +26 -0
  307. package/src/testing/index.ts +11 -0
@@ -0,0 +1,312 @@
1
+ /**
2
+ * The arithmetic behind `react/file-tree` β€” what is in the tree, what is on screen, and
3
+ * which drops are legal. No DOM, so all of it is unit-tested; the pointer handling, the
4
+ * markers and the keyboard live in the component and are not.
5
+ *
6
+ * ── Where this came from, and why it is here rather than in an app ──────────────────────
7
+ * Owner, 2026-09-08: *"the spreadsheet app has a much better file tree sidebar than
8
+ * collections has. Add the file tree viewer from it into cb so apps like collections can
9
+ * use it."* `cursed-embeddable-spreadsheet`'s `FileTree` is the tree he means β€” it has been
10
+ * carrying that app's whole navigation for weeks. What made it good is mostly in this file:
11
+ * a drop that cannot happen is never OFFERED, so there is no marker, no highlight and no
12
+ * error toast after the fact β€” the cursor simply says no while the row is still in the air.
13
+ *
14
+ * ── πŸ”΄ Why this is not `core/folder-tree` ──────────────────────────────────────────────
15
+ * `folder-tree` derives a hierarchy from delimited PATH STRINGS that items carry, and its
16
+ * nodes are the result of that derivation. This models a tree that already exists, whatever
17
+ * produced it: a node has an id, a kind and children, and an app may hand over folders
18
+ * derived from paths (collections), rows from a table, or a mix of both under one root. The
19
+ * two are complementary β€” collections feeds `buildFolderTree`'s output straight into this β€”
20
+ * and collapsing them would force every consumer of one to adopt the other's storage shape.
21
+ */
22
+ const childrenOf = (node) => node.kind === 'folder' ? (node.children ?? []) : [];
23
+ /** Every node in the tree, depth-first β€” the order the rows would be in fully expanded. */
24
+ export function walkFileTree(nodes) {
25
+ const out = [];
26
+ const visit = (list) => {
27
+ for (const node of list) {
28
+ out.push(node);
29
+ visit(childrenOf(node));
30
+ }
31
+ };
32
+ visit(nodes);
33
+ return out;
34
+ }
35
+ export function findFileTreeNode(nodes, id) {
36
+ for (const node of walkFileTree(nodes))
37
+ if (node.id === id)
38
+ return node;
39
+ return null;
40
+ }
41
+ /**
42
+ * The rows actually on screen, given which folders are shut.
43
+ *
44
+ * πŸ”΄ `collapsed` is the exception list, not `expanded`. A tree derived fresh from the server
45
+ * on every refresh gets new nodes constantly, and an expanded-set model shows each of them
46
+ * shut β€” so a newly created folder would appear closed and empty at the moment its contents
47
+ * are the thing you are looking for. The exception list defaults everything open, which is
48
+ * also what reads best for the small trees these sidebars hold.
49
+ */
50
+ export function flattenFileTree(nodes, collapsed) {
51
+ const shut = new Set(collapsed);
52
+ const out = [];
53
+ const visit = (list, depth, parentId) => {
54
+ list.forEach((node, index) => {
55
+ const kids = childrenOf(node);
56
+ /*
57
+ * πŸ”΄ EVERY folder is expandable unless it says otherwise (2026-09-11).
58
+ *
59
+ * Owner: *"Make them use an arrow icon next to a dir even when the dir is empty."*
60
+ * This was `kids.length > 0` and it was wrong in both directions. For a LAZY tree it
61
+ * is a trap door β€” a consumer that loads a row's contents only while it is open gives
62
+ * a collapsed row no children, the row loses its twisty, and it can never be opened
63
+ * again (measured in `apps/collections`, whose sidebar fetches one folder's files at a
64
+ * time). For an EAGER tree it draws an empty folder as a leaf, so the one mark that
65
+ * distinguishes a place from a file disappears exactly while the folder is new and
66
+ * being filled.
67
+ *
68
+ * "A row that claims to open onto nothing is its own small lie" was the old reasoning
69
+ * and it is answered by what VS Code does: the chevron says *folder*, not *occupied*,
70
+ * and opening an empty one onto nothing is the honest answer, not a broken promise.
71
+ * `node.expandable === false` is still honoured for a row that can never hold anything.
72
+ */
73
+ const expandable = node.kind === 'folder' && (node.expandable ?? true);
74
+ const expanded = expandable && !shut.has(node.id);
75
+ out.push({ node, depth, parentId, index, expandable, expanded });
76
+ if (expanded)
77
+ visit(kids, depth + 1, node.id);
78
+ });
79
+ };
80
+ visit(nodes, 0, null);
81
+ return out;
82
+ }
83
+ /** `id` and everything under it β€” the set a drag may not land inside. */
84
+ export function descendantIdsOf(nodes, id) {
85
+ const node = findFileTreeNode(nodes, id);
86
+ const out = new Set();
87
+ if (!node)
88
+ return out;
89
+ const visit = (n) => {
90
+ out.add(n.id);
91
+ for (const child of childrenOf(n))
92
+ visit(child);
93
+ };
94
+ visit(node);
95
+ return out;
96
+ }
97
+ /**
98
+ * The id of the folder a node sits in β€” `null` at the top level, and `null` for a node this
99
+ * tree does not contain.
100
+ *
101
+ * πŸ”΄ The two nulls are deliberately one answer. Every caller is asking "where would a move
102
+ * put this", and for a row the tree no longer holds the honest answer is the top level
103
+ * rather than a throw: a tree re-derived from the server between the press and the release
104
+ * can genuinely lose the row under the pointer.
105
+ */
106
+ export function parentIdOf(nodes, id) {
107
+ const visit = (list, parent) => {
108
+ for (const node of list) {
109
+ if (node.id === id)
110
+ return { at: parent };
111
+ const found = visit(childrenOf(node), node.id);
112
+ if (found)
113
+ return found;
114
+ }
115
+ return null;
116
+ };
117
+ return visit(nodes, null)?.at ?? null;
118
+ }
119
+ /** The share of a folder row's height that means "into" rather than "between". */
120
+ export const INTO_ZONE = 0.5;
121
+ /**
122
+ * Where the pointer currently is, as a decision.
123
+ *
124
+ * The rule the whole gesture is built around: **a drop that cannot happen is never
125
+ * offered.** Every refusal below returns a reason rather than a null target, so the cursor
126
+ * and the chip can say no while the row is still in the air.
127
+ */
128
+ export function resolveDrop({ nodes, row, offsetRatio, dragId, allowReorder = false, lockedReason = 'That folder is locked', canDrop, }) {
129
+ // Blank space under the last row: out of every folder. The one gesture that needs no
130
+ // target, and the only way to say "top level" when the top level has no row of its own.
131
+ if (!row)
132
+ return canDrop?.(dragId, { kind: 'root' })
133
+ ? { target: null, refused: true, reason: canDrop(dragId, { kind: 'root' }) }
134
+ : { target: { kind: 'root' }, refused: false, reason: null };
135
+ const forbidden = dragId ? descendantIdsOf(nodes, dragId) : new Set();
136
+ const target = row.node;
137
+ /** The consumer's last word on an otherwise-legal target. */
138
+ const vetted = (offer) => {
139
+ if (!offer.target || !canDrop)
140
+ return offer;
141
+ const reason = canDrop(dragId, offer.target);
142
+ return reason ? { target: null, refused: true, reason } : offer;
143
+ };
144
+ const intoFolder = () => {
145
+ if (target.kind !== 'folder')
146
+ return { target: null, refused: false, reason: null };
147
+ if (forbidden.has(target.id))
148
+ return {
149
+ target: null,
150
+ refused: true,
151
+ reason: target.id === dragId
152
+ ? 'A folder cannot go inside itself'
153
+ : 'A folder cannot go inside one of its own folders',
154
+ };
155
+ if (target.acceptsDrop === false)
156
+ return { target: null, refused: true, reason: lockedReason };
157
+ return vetted({ target: { kind: 'into', parentId: target.id }, refused: false, reason: null });
158
+ };
159
+ // A FILE row means "the folder that row is in" β€” what every file explorer does, and what
160
+ // keeps a long list of files from being a wall of rows nothing can be dropped on.
161
+ if (target.kind === 'file' && !allowReorder) {
162
+ if (row.parentId === null)
163
+ return vetted({ target: { kind: 'root' }, refused: false, reason: null });
164
+ const parent = findFileTreeNode(nodes, row.parentId);
165
+ if (parent && parent.acceptsDrop === false)
166
+ return { target: null, refused: true, reason: lockedReason };
167
+ if (parent && forbidden.has(parent.id))
168
+ return { target: null, refused: true, reason: 'A folder cannot go inside itself' };
169
+ return vetted({ target: { kind: 'into', parentId: row.parentId }, refused: false, reason: null });
170
+ }
171
+ if (!allowReorder)
172
+ return intoFolder();
173
+ // With an order to change, the middle half of a folder row is "into" and the edges are
174
+ // "between". A file row is all edges β€” there is no inside of a file.
175
+ const edge = target.kind === 'folder' ? (1 - INTO_ZONE) / 2 : 0.5;
176
+ if (target.kind === 'folder' && offsetRatio > edge && offsetRatio < 1 - edge)
177
+ return intoFolder();
178
+ const side = offsetRatio < 0.5 ? 'before' : 'after';
179
+ if (forbidden.has(target.id))
180
+ return { target: null, refused: true, reason: 'A folder cannot go inside itself' };
181
+ return vetted({
182
+ target: { kind: side, siblingId: target.id, parentId: row.parentId },
183
+ refused: false,
184
+ reason: null,
185
+ });
186
+ }
187
+ /**
188
+ * Where βŒ₯β†’ / βŒ₯← would put the focused row β€” the keyboard's half of the drag.
189
+ *
190
+ * πŸ”΄ It exists because a gesture with no keyboard route is a gesture half the people using
191
+ * it cannot perform, and because TOUCH has no drag here at all: a sidebar row is a small
192
+ * target on a surface whose one constant gesture is the flick, so the pointer drag gives
193
+ * that up and this is what is left. `cursedesk`'s tree has carried it since it was written;
194
+ * this one shipped with the drag alone.
195
+ *
196
+ * The two directions are the only two a keyboard can mean unambiguously. **β†’ demotes into
197
+ * the nearest folder ABOVE it among its own siblings** β€” the one place a demotion can go
198
+ * without asking which of several folders was meant β€” and **← promotes out to the folder its
199
+ * own folder is in**, which is `root` at depth 1.
200
+ *
201
+ * πŸ”΄ Every refusal the DRAG honours is honoured here, from the same fields: `acceptsDrop`,
202
+ * the consumer's `canDrop`, and the descendant rule. A keyboard route that can reach a
203
+ * destination the pointer refuses is a second, weaker opinion about what is legal β€” and it
204
+ * is the one nobody looks at, because the pointer is what gets tested by hand.
205
+ *
206
+ * There is deliberately no βŒ₯↑/βŒ₯↓ here. Those would move a row among its siblings, which is
207
+ * only meaningful when the consumer stores an order β€” and when it does, the sibling before
208
+ * and after are already reachable as `before`/`after` drops. Offering them unconditionally
209
+ * would write a position nothing reads and then draw the row back where it started.
210
+ */
211
+ export function resolveKeyboardMove({ nodes, rows, row, direction, lockedReason = 'That folder is locked', canDrop, }) {
212
+ const vetted = (target) => {
213
+ const reason = canDrop?.(row.node.id, target);
214
+ return reason
215
+ ? { target: null, refused: true, reason }
216
+ : { target, refused: false, reason: null };
217
+ };
218
+ if (direction === 'out') {
219
+ if (row.parentId === null)
220
+ return { target: null, refused: true, reason: 'It is already at the top level' };
221
+ const grandparentId = parentIdOf(nodes, row.parentId);
222
+ if (grandparentId === null)
223
+ return vetted({ kind: 'root' });
224
+ const grandparent = findFileTreeNode(nodes, grandparentId);
225
+ if (grandparent?.acceptsDrop === false)
226
+ return { target: null, refused: true, reason: lockedReason };
227
+ return vetted({ kind: 'into', parentId: grandparentId });
228
+ }
229
+ // A row's siblings are all on screen whenever the row itself is β€” a shut folder hides its
230
+ // CHILDREN, never its peers β€” so the visible rows are the whole level.
231
+ const level = rows.filter((entry) => entry.parentId === row.parentId);
232
+ const above = level
233
+ .filter((entry) => entry.index < row.index && entry.node.kind === 'folder')
234
+ .at(-1);
235
+ if (!above)
236
+ return { target: null, refused: true, reason: 'There is no folder above it to move into' };
237
+ if (above.node.acceptsDrop === false)
238
+ return { target: null, refused: true, reason: lockedReason };
239
+ // Belt and braces: a folder cannot go inside itself, and `above` is a sibling rather than
240
+ // a descendant β€” but the rule is the tree's, not this function's, and stating it once here
241
+ // is cheaper than discovering the day someone changes what `above` means.
242
+ if (descendantIdsOf(nodes, row.node.id).has(above.node.id))
243
+ return { target: null, refused: true, reason: 'A folder cannot go inside itself' };
244
+ return vetted({ kind: 'into', parentId: above.node.id });
245
+ }
246
+ /**
247
+ * Where a drop would put the thing being carried, in words β€” for the chip under the cursor
248
+ * and for the screen reader that cannot see it.
249
+ *
250
+ * πŸ”΄ A drag that says only WHAT is held answers half the question. Owner, of the tree this
251
+ * one was ported from: *"dragging an item from one place to another does not have any
252
+ * indication that it is working until you drop it somewhere new and see it move."* The chip
253
+ * following the cursor answers "it is working"; this answers "and it is going there", which
254
+ * is what stops a wrong drop before the pointer is lifted rather than after.
255
+ *
256
+ * Empty for no target β€” a refusal has its own words and this must never carry a second "no".
257
+ */
258
+ export function describeDrop(nodes, drop) {
259
+ if (!drop)
260
+ return '';
261
+ if (drop.kind === 'root')
262
+ return 'To the top level';
263
+ if (drop.kind === 'into') {
264
+ const folder = findFileTreeNode(nodes, drop.parentId);
265
+ return folder ? `Into ${folder.name}` : 'Into this folder';
266
+ }
267
+ const sibling = findFileTreeNode(nodes, drop.siblingId);
268
+ const where = drop.kind === 'before' ? 'Above' : 'Below';
269
+ return sibling ? `${where} ${sibling.name}` : `${where} this row`;
270
+ }
271
+ /**
272
+ * The next row whose name starts with `query`, searching from `fromIndex` and wrapping.
273
+ *
274
+ * Typeahead is what makes a long tree usable without a search box β€” `bud` jumps to
275
+ * `budget-2026` β€” and wrapping is what stops it dying at the bottom of the list.
276
+ */
277
+ export function matchTypeahead(rows, query, fromIndex) {
278
+ if (!query)
279
+ return -1;
280
+ const needle = query.toLowerCase();
281
+ for (let step = 1; step <= rows.length; step += 1) {
282
+ const index = (fromIndex + step + rows.length) % rows.length;
283
+ const row = rows[index];
284
+ if (row?.node.name.toLowerCase().startsWith(needle))
285
+ return index;
286
+ }
287
+ return -1;
288
+ }
289
+ /**
290
+ * The tree narrowed to what matches, with the folders on the way to a match kept.
291
+ *
292
+ * A folder that matches keeps ALL of its contents β€” searching for a folder means you want
293
+ * to see what is in it, not a folder that looks empty because its children spell something
294
+ * else.
295
+ */
296
+ export function filterFileTree(nodes, query) {
297
+ const needle = query.trim().toLowerCase();
298
+ if (!needle)
299
+ return nodes;
300
+ const keep = (node) => {
301
+ const hit = node.name.toLowerCase().includes(needle);
302
+ if (hit)
303
+ return node;
304
+ const kids = childrenOf(node)
305
+ .map(keep)
306
+ .filter((n) => n !== null);
307
+ if (kids.length === 0)
308
+ return null;
309
+ return { ...node, children: kids };
310
+ };
311
+ return nodes.map(keep).filter((n) => n !== null);
312
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * The FOLDER OPERATIONS every app with folders offers β€” one model, so "move it into
3
+ * that one" means the same thing in music as it does in collections.
4
+ *
5
+ * ── The owner's ask, verbatim (draft, 2026-08-25) ───────────────────────────────────────
6
+ * *"in cb make or ensure we have a standardized folder list view. I want it applied to
7
+ * music app and collections app and other apps with folders like that which should get the
8
+ * same features if any. The features should include a 3 dot menu for choosing to move all
9
+ * contents to a different folder or move a folder into another folder. For instance I could
10
+ * move rock songs from a folder 'extra-rock-songs' to a 'rock' folder with it. Also allow
11
+ * deleting a folder which deletes all the folder contents or allows deleting the folder and
12
+ * having the contents not grouped in a folder anymore. Allow renaming folders."*
13
+ *
14
+ * ── πŸ”΄ Four verbs, and three of them already existed ────────────────────────────────────
15
+ * `planFolderRename`, `planFolderMove` and `planFolderDelete` in `tree.ts` are exactly
16
+ * rename, "move a folder into another folder", and "delete the folder and leave the
17
+ * contents ungrouped" β€” built for the vault and notes, and reachable from no menu anywhere.
18
+ * The gap the owner is describing is not model, it is SURFACE: every app grew its own
19
+ * subset of folder tools, so the operations that were never wired are the ones nobody has.
20
+ * This module adds the ONE missing verb and the shared descriptions; the menu that renders
21
+ * them is `react/folder-tree/FolderActionsMenu`.
22
+ *
23
+ * ── πŸ”΄ Why "delete the contents too" is not a plan ──────────────────────────────────────
24
+ * The other three verbs are pure path rewrites β€” a {@link FolderRemap} list the consumer
25
+ * applies, with nothing destroyed. Deleting the CONTENTS destroys items, and this module
26
+ * has never seen an item; it only ever sees the paths they carry. So `purge` is a mode the
27
+ * menu offers and the CONSUMER implements against its own store, which is also the only
28
+ * place that can count what is about to be lost or refuse the operation. Modelling it here
29
+ * would mean inventing an item, and an item this model cannot see is one it cannot protect.
30
+ */
31
+ import type { FolderNode, FolderRemap } from "./tree";
32
+ /**
33
+ * Move everything at or under `source` INTO `destination`, and stop `source` existing.
34
+ *
35
+ * The owner's own example: *"I could move rock songs from a folder 'extra-rock-songs' to a
36
+ * 'rock' folder with it."* Items directly in `extra-rock-songs` land directly in `rock`;
37
+ * `extra-rock-songs/live` becomes `rock/live`.
38
+ *
39
+ * πŸ”΄ This is NOT `planFolderMove`, and the difference is the whole reason it exists.
40
+ * `planFolderMove(source, newParent)` makes `source` a CHILD β€” `rock/extra-rock-songs` β€”
41
+ * which keeps the folder the owner is trying to get rid of. Both are real operations a
42
+ * person wants ("put this inside that" vs "empty this into that"), they read almost
43
+ * identically in a menu, and getting them confused is silent: the files are all present and
44
+ * the folder you meant to dissolve is still there one level down.
45
+ *
46
+ * @param destination an empty array means the ROOT β€” "take these out of any folder".
47
+ */
48
+ export declare function planFolderMergeInto(serializedPaths: Iterable<string>, source: readonly string[], destination: readonly string[]): FolderRemap[];
49
+ /** One place a folder may be moved or merged to. `path` empty = the root. */
50
+ export interface FolderTarget {
51
+ path: string[];
52
+ serialized: string;
53
+ /** `Home β€Ί Phones`, for a menu label. The root's label is the caller's word for it. */
54
+ label: string;
55
+ key: string;
56
+ }
57
+ /**
58
+ * Every folder `source` may legally be moved into, deepest-first within each branch.
59
+ *
60
+ * πŸ”΄ A folder can never be moved into itself or into one of its own descendants β€” that
61
+ * orphans the subtree, and `planFolderMove` throws on it. Filtering the DESTINATIONS rather
62
+ * than catching the throw is what makes the menu honest: the offer a person is shown is the
63
+ * set of offers that work, so there is no way to pick one and be told no.
64
+ *
65
+ * The current parent is filtered out too. "Move it to where it already is" is not a
66
+ * no-op a person meant to ask for; it is a menu entry that does nothing, which reads as a
67
+ * broken control.
68
+ */
69
+ export declare function folderMoveTargets(nodes: readonly FolderNode[], source: readonly string[], options?: {
70
+ rootLabel?: string;
71
+ separator?: string;
72
+ }): FolderTarget[];
73
+ /**
74
+ * What "delete this folder" is allowed to mean.
75
+ *
76
+ * Two modes rather than one, because the owner asked for both in one sentence and they are
77
+ * opposites: one is a filing change and the other destroys files. They are never the same
78
+ * button β€” see {@link FolderActionsMenuProps} for how the menu keeps them apart.
79
+ */
80
+ export type FolderDeleteMode =
81
+ /** Splice the folder out; every item keeps existing, one path segment shorter. */
82
+ "ungroup"
83
+ /** Delete the folder AND everything in it. The consumer's store does this, not us. */
84
+ | "purge";
@@ -0,0 +1,119 @@
1
+ /**
2
+ * The FOLDER OPERATIONS every app with folders offers β€” one model, so "move it into
3
+ * that one" means the same thing in music as it does in collections.
4
+ *
5
+ * ── The owner's ask, verbatim (draft, 2026-08-25) ───────────────────────────────────────
6
+ * *"in cb make or ensure we have a standardized folder list view. I want it applied to
7
+ * music app and collections app and other apps with folders like that which should get the
8
+ * same features if any. The features should include a 3 dot menu for choosing to move all
9
+ * contents to a different folder or move a folder into another folder. For instance I could
10
+ * move rock songs from a folder 'extra-rock-songs' to a 'rock' folder with it. Also allow
11
+ * deleting a folder which deletes all the folder contents or allows deleting the folder and
12
+ * having the contents not grouped in a folder anymore. Allow renaming folders."*
13
+ *
14
+ * ── πŸ”΄ Four verbs, and three of them already existed ────────────────────────────────────
15
+ * `planFolderRename`, `planFolderMove` and `planFolderDelete` in `tree.ts` are exactly
16
+ * rename, "move a folder into another folder", and "delete the folder and leave the
17
+ * contents ungrouped" β€” built for the vault and notes, and reachable from no menu anywhere.
18
+ * The gap the owner is describing is not model, it is SURFACE: every app grew its own
19
+ * subset of folder tools, so the operations that were never wired are the ones nobody has.
20
+ * This module adds the ONE missing verb and the shared descriptions; the menu that renders
21
+ * them is `react/folder-tree/FolderActionsMenu`.
22
+ *
23
+ * ── πŸ”΄ Why "delete the contents too" is not a plan ──────────────────────────────────────
24
+ * The other three verbs are pure path rewrites β€” a {@link FolderRemap} list the consumer
25
+ * applies, with nothing destroyed. Deleting the CONTENTS destroys items, and this module
26
+ * has never seen an item; it only ever sees the paths they carry. So `purge` is a mode the
27
+ * menu offers and the CONSUMER implements against its own store, which is also the only
28
+ * place that can count what is about to be lost or refuse the operation. Modelling it here
29
+ * would mean inventing an item, and an item this model cannot see is one it cannot protect.
30
+ */
31
+ import { folderPathKey, isFolderPathUnder, parseFolderPath, serializeFolderPath, } from "./path";
32
+ /**
33
+ * Move everything at or under `source` INTO `destination`, and stop `source` existing.
34
+ *
35
+ * The owner's own example: *"I could move rock songs from a folder 'extra-rock-songs' to a
36
+ * 'rock' folder with it."* Items directly in `extra-rock-songs` land directly in `rock`;
37
+ * `extra-rock-songs/live` becomes `rock/live`.
38
+ *
39
+ * πŸ”΄ This is NOT `planFolderMove`, and the difference is the whole reason it exists.
40
+ * `planFolderMove(source, newParent)` makes `source` a CHILD β€” `rock/extra-rock-songs` β€”
41
+ * which keeps the folder the owner is trying to get rid of. Both are real operations a
42
+ * person wants ("put this inside that" vs "empty this into that"), they read almost
43
+ * identically in a menu, and getting them confused is silent: the files are all present and
44
+ * the folder you meant to dissolve is still there one level down.
45
+ *
46
+ * @param destination an empty array means the ROOT β€” "take these out of any folder".
47
+ */
48
+ export function planFolderMergeInto(serializedPaths, source, destination) {
49
+ if (source.length === 0)
50
+ throw new Error("cannot move the root's contents");
51
+ if (isFolderPathUnder(destination, source)) {
52
+ throw new Error("cannot move a folder's contents into itself or one of its descendants");
53
+ }
54
+ const out = [];
55
+ const seen = new Set();
56
+ for (const raw of serializedPaths) {
57
+ const path = parseFolderPath(raw);
58
+ if (path.length === 0 || !isFolderPathUnder(path, source))
59
+ continue;
60
+ const key = folderPathKey(path);
61
+ if (seen.has(key))
62
+ continue;
63
+ seen.add(key);
64
+ // Drop the whole SOURCE prefix and re-root what is left under `destination`.
65
+ const moved = [...destination, ...path.slice(source.length)];
66
+ const from = serializeFolderPath(path);
67
+ const to = serializeFolderPath(moved);
68
+ if (from !== to)
69
+ out.push({ from, to });
70
+ }
71
+ return out;
72
+ }
73
+ /**
74
+ * Every folder `source` may legally be moved into, deepest-first within each branch.
75
+ *
76
+ * πŸ”΄ A folder can never be moved into itself or into one of its own descendants β€” that
77
+ * orphans the subtree, and `planFolderMove` throws on it. Filtering the DESTINATIONS rather
78
+ * than catching the throw is what makes the menu honest: the offer a person is shown is the
79
+ * set of offers that work, so there is no way to pick one and be told no.
80
+ *
81
+ * The current parent is filtered out too. "Move it to where it already is" is not a
82
+ * no-op a person meant to ask for; it is a menu entry that does nothing, which reads as a
83
+ * broken control.
84
+ */
85
+ export function folderMoveTargets(nodes, source, options = {}) {
86
+ const separator = options.separator ?? " β€Ί ";
87
+ const currentParent = folderPathKey(source.slice(0, -1));
88
+ const targets = [];
89
+ // The root, unless the folder is already at the top level.
90
+ if (source.length > 1) {
91
+ targets.push({
92
+ path: [],
93
+ serialized: "",
94
+ label: options.rootLabel ?? "Top level",
95
+ key: folderPathKey([]),
96
+ });
97
+ }
98
+ const walk = (list) => {
99
+ for (const node of list) {
100
+ const key = folderPathKey(node.path);
101
+ const isSelfOrDescendant = isFolderPathUnder(node.path, source);
102
+ if (!isSelfOrDescendant && key !== currentParent) {
103
+ targets.push({
104
+ path: [...node.path],
105
+ serialized: node.serialized,
106
+ label: node.path.join(separator),
107
+ key,
108
+ });
109
+ }
110
+ // πŸ”΄ Descend even into a branch that was itself refused: only the SUBTREE of
111
+ // `source` is unreachable. The current parent is skipped as a target and its
112
+ // children are perfectly good ones.
113
+ if (!isSelfOrDescendant)
114
+ walk(node.children);
115
+ }
116
+ };
117
+ walk(nodes);
118
+ return targets;
119
+ }
@@ -0,0 +1,3 @@
1
+ export { FOLDER_PATH_DELIMITER, FOLDER_PATH_ESCAPE, folderDescendantPrefix, folderPathKey, formatFolderPath, isFolderPathUnder, isValidFolderName, normalizeFolderName, parseFolderPath, serializeFolderPath, } from "./path";
2
+ export { buildFolderIndex, buildFolderTree, flattenFolderTree, type FolderIndex, type FolderNode, type FolderRemap, planFolderDelete, planFolderMove, planFolderRename, } from "./tree";
3
+ export { type FolderDeleteMode, type FolderTarget, folderMoveTargets, planFolderMergeInto, } from "./actions";
@@ -0,0 +1,20 @@
1
+ // cursedbelt-core/folder-tree β€” the PURE nested-folder model: path encoding, the derived
2
+ // tree, per-folder counts, and the structural-edit plans (rename/move/delete). No
3
+ // React, no DOM, node-builtin-free β€” so a SERVER can hold the same folder semantics
4
+ // its UI does.
5
+ //
6
+ // import { buildFolderTree, planFolderRename, serializeFolderPath } from 'cursedbelt-core/folder-tree';
7
+ //
8
+ // The three UI pieces (rail, picker, breadcrumb) live at `cursedbelt/react/folder-tree`,
9
+ // which re-exports everything here β€” so a browser-only consumer keeps one import.
10
+ //
11
+ // ── Why the model is its own export ─────────────────────────────────────────────
12
+ // The vault (the first consumer) is zero-knowledge: folders are derived entirely in
13
+ // the browser from encrypted meta, so a React subpath was the whole world. `apps/notes`
14
+ // is the second, and its folders are SERVER state β€” filed items are searched, filtered
15
+ // and bulk-moved by SQL. Importing the React barrel into a Bun server to reach
16
+ // `isFolderPathUnder` would drag `Button`/`Input` and the whole component graph into a
17
+ // process that renders nothing. Same law as `cursedbelt-core/sharing` vs `cursedbelt/react/sharing`.
18
+ export { FOLDER_PATH_DELIMITER, FOLDER_PATH_ESCAPE, folderDescendantPrefix, folderPathKey, formatFolderPath, isFolderPathUnder, isValidFolderName, normalizeFolderName, parseFolderPath, serializeFolderPath, } from "./path";
19
+ export { buildFolderIndex, buildFolderTree, flattenFolderTree, planFolderDelete, planFolderMove, planFolderRename, } from "./tree";
20
+ export { folderMoveTargets, planFolderMergeInto, } from "./actions";
@@ -0,0 +1,76 @@
1
+ /**
2
+ * Folder PATH encoding β€” the storage contract every consumer of the folder tree
3
+ * shares. A folder path is an ordered array of segment names (`["Home","Phones",
4
+ * "Noah"]`); on disk it is a single delimiter-joined STRING so it can ride in one
5
+ * field (an entry's `meta.folder`, a note's group tag) without a separate registry.
6
+ *
7
+ * ── Why a delimited string and not id references ─────────────────────────────
8
+ * The first consumer (the vault) is a zero-knowledge, op-log-synced store: a folder
9
+ * lives inside an entry's ENCRYPTED meta, and there is no server-side place to keep
10
+ * an id→name map that every device would have to converge on. Deriving the whole
11
+ * tree from the set of path strings on the items keeps folders a pure function of
12
+ * the items β€” nothing extra to sync, nothing to version-skew. The cost is that a
13
+ * folder exists only while it (or a descendant) holds an item; that is the same
14
+ * "derived, not declared" model the vault already used for flat folders, extended
15
+ * to nesting.
16
+ *
17
+ * ── The delimiter, and why it is ESCAPED rather than forbidden ───────────────
18
+ * `/` joins segments. A `/` is common enough inside a real folder name ("TV/Film",
19
+ * "and/or") that forbidding it would be a footgun, and β€” critically β€” a MIGRATION
20
+ * hazard: a legacy flat folder literally named "TV/Movies" must stay ONE folder,
21
+ * not silently split into two. So the delimiter is escaped, not banned: a literal
22
+ * `/` in a name becomes `\/` and a literal `\` becomes `\\`. Serialize→parse is a
23
+ * lossless round trip for any name that is non-empty and free of control chars.
24
+ */
25
+ /** The character that joins path segments in the stored string. */
26
+ export declare const FOLDER_PATH_DELIMITER = "/";
27
+ /** The escape character. A literal delimiter or escape inside a name is prefixed. */
28
+ export declare const FOLDER_PATH_ESCAPE = "\\";
29
+ /** Trim a raw name for use as a segment. Internal whitespace is preserved; only the
30
+ * ends are trimmed, so " My Bank " and "My Bank" are the same folder. */
31
+ export declare function normalizeFolderName(name: string): string;
32
+ /** A folder name is valid iff it is non-empty after trimming and carries no control
33
+ * characters (which would corrupt the stored string and never render). The
34
+ * delimiter and escape are ALLOWED β€” they are escaped on serialize. */
35
+ export declare function isValidFolderName(name: string): boolean;
36
+ /**
37
+ * Serialize a path (array of segment names) into the stored string. Empty and
38
+ * whitespace-only segments are dropped, and each surviving segment is trimmed, so
39
+ * the output is canonical: `serialize(parse(s))` normalizes any spelling of `s`.
40
+ */
41
+ export declare function serializeFolderPath(segments: readonly string[]): string;
42
+ /**
43
+ * Parse a stored string back into its segment array. Splits on UNESCAPED delimiters
44
+ * only, then unescapes each segment. Empty segments (a leading/trailing/double
45
+ * delimiter) are dropped rather than becoming blank folders.
46
+ */
47
+ export declare function parseFolderPath(serialized: string): string[];
48
+ /**
49
+ * A case-insensitive identity key for a path β€” the thing two paths compare equal by.
50
+ * Folder names dedupe case-insensitively (like the flat rail always did), so "Work"
51
+ * and "work" are one folder, but the key is still per-SEGMENT so "Home/Phones" and
52
+ * "Homework/hones" never collide (which a naive lowercased-string prefix would).
53
+ */
54
+ export declare function folderPathKey(segments: readonly string[]): string;
55
+ /** True when `child` is `parent` or sits anywhere beneath it (segment-aligned, so
56
+ * `Home` contains `Home/Phones` but NOT `Homework`). An empty `parent` (the root)
57
+ * contains everything. */
58
+ export declare function isFolderPathUnder(child: readonly string[], parent: readonly string[]): boolean;
59
+ /**
60
+ * The stored-string prefix EVERY descendant of `segments` starts with, and nothing
61
+ * else does β€” `serialize(segments) + "/"`. It exists so a store that keeps the
62
+ * serialized path in one column (a SQL `LIKE 'Home/%'`, a `startsWith` on a key/value
63
+ * store) can select a whole subtree without parsing every row.
64
+ *
65
+ * It is exact BECAUSE the delimiter is escaped: a folder literally named `TV/Film`
66
+ * serializes as `TV\/Film`, whose fifth character is the escape, so it can never be
67
+ * mistaken for `TV` + a child. `Homework` does not start with `Home/` either. The one
68
+ * thing the prefix does NOT cover is the folder itself β€” a caller wants
69
+ * `path = serialized OR path LIKE prefix || '%'`, which is what `isFolderPathUnder`
70
+ * says in memory. The root (an empty path) has no prefix that means "everything", so
71
+ * it returns `""` and the caller should skip the filter entirely.
72
+ */
73
+ export declare function folderDescendantPrefix(segments: readonly string[]): string;
74
+ /** Render a path as a human breadcrumb: `Home β€Ί Phones β€Ί Noah`. The separator is a
75
+ * display choice; the default is the one the picker and rail use. */
76
+ export declare function formatFolderPath(segments: readonly string[], separator?: string): string;