@ossy/resources 3.10.0 → 3.11.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 (86) hide show
  1. package/package.json +4 -4
  2. package/src/CreateDirectory.jsx +47 -53
  3. package/src/CreateDocument.jsx +1 -0
  4. package/src/GenericResourceForm.jsx +49 -31
  5. package/src/ResourceDialogMove.jsx +99 -50
  6. package/src/ResourceGrid.jsx +36 -84
  7. package/src/ResourceList.jsx +10 -1
  8. package/src/ResourcePanel.jsx +96 -17
  9. package/src/ResourceShareDialog.jsx +50 -12
  10. package/src/Upload.jsx +25 -8
  11. package/src/batch-delete.flow.js +81 -0
  12. package/src/batch-download.flow.js +69 -0
  13. package/src/cancel-upload.action.js +4 -0
  14. package/src/cancel-upload.flow.js +40 -0
  15. package/src/close-share.action.js +4 -0
  16. package/src/confirm-upload.action.js +4 -0
  17. package/src/copy-share-link.action.js +4 -0
  18. package/src/create-directory.action.js +5 -0
  19. package/src/create-directory.flow.js +30 -0
  20. package/src/create-directory.form.js +5 -0
  21. package/src/create-directory.schema.js +9 -0
  22. package/src/create-document.flow.js +36 -0
  23. package/src/create-document.form.js +5 -0
  24. package/src/create-document.schema.js +11 -0
  25. package/src/create.action.js +5 -1
  26. package/src/delete-directory.flow.js +39 -0
  27. package/src/delete-file.flow.js +32 -0
  28. package/src/done-selecting.action.js +5 -0
  29. package/src/download-file.flow.js +31 -0
  30. package/src/download-resource.action.js +4 -0
  31. package/src/download-selected.action.js +4 -0
  32. package/src/en.translations.json +18 -0
  33. package/src/finish-upload.action.js +4 -0
  34. package/src/index.js +42 -0
  35. package/src/list.task.js +17 -6
  36. package/src/list.task.spec.js +81 -0
  37. package/src/make-private-resource.flow.js +72 -0
  38. package/src/make-private.action.js +4 -0
  39. package/src/make-public.action.js +4 -0
  40. package/src/move-file.flow.js +78 -0
  41. package/src/move.form.js +5 -0
  42. package/src/move.schema.js +9 -0
  43. package/src/open-delete-directory.action.js +7 -0
  44. package/src/open-move.action.js +4 -0
  45. package/src/open-remove-selected.action.js +5 -0
  46. package/src/open-rename.action.js +4 -0
  47. package/src/open-share.action.js +4 -0
  48. package/src/open-sort.action.js +5 -0
  49. package/src/open-view.action.js +5 -0
  50. package/src/rename-file.flow.js +43 -0
  51. package/src/rename.form.js +5 -0
  52. package/src/rename.schema.js +9 -0
  53. package/src/resource-list-query.js +182 -0
  54. package/src/resource-list-query.spec.js +75 -0
  55. package/src/resource-selection.js +25 -0
  56. package/src/resource-selection.spec.js +40 -0
  57. package/src/resource.helpers.js +8 -1
  58. package/src/resources-list-options.js +169 -0
  59. package/src/resources-list-options.spec.js +141 -0
  60. package/src/resources.action-media.js +6 -0
  61. package/src/resources.queries.js +91 -6
  62. package/src/search-payload.js +10 -0
  63. package/src/search-payload.spec.js +20 -0
  64. package/src/search.task.js +2 -1
  65. package/src/share-resource.flow.js +59 -0
  66. package/src/sort-ascending.action.js +5 -0
  67. package/src/sort-by-created.action.js +5 -0
  68. package/src/sort-by-last-updated.action.js +5 -0
  69. package/src/sort-by-name.action.js +5 -0
  70. package/src/sort-by-size.action.js +5 -0
  71. package/src/sort-by-type.action.js +5 -0
  72. package/src/sort-descending.action.js +5 -0
  73. package/src/sort-storage-created.flow.js +87 -0
  74. package/src/sort-storage-last-updated.flow.js +119 -0
  75. package/src/sort-storage-name-asc.flow.js +34 -0
  76. package/src/sort-storage-size-desc.flow.js +34 -0
  77. package/src/sort-storage-size.flow.js +87 -0
  78. package/src/sort-storage-type.flow.js +83 -0
  79. package/src/sort-storage.flow.js +86 -0
  80. package/src/sv.translations.json +18 -0
  81. package/src/toggle-selection-mode.action.js +4 -0
  82. package/src/upload-file.flow.js +44 -0
  83. package/src/useResourceList.js +94 -0
  84. package/src/view-grid.action.js +5 -0
  85. package/src/view-list.action.js +5 -0
  86. package/src/view-storage.flow.js +49 -0
@@ -0,0 +1,43 @@
1
+ import uploadFileFlow from './upload-file.flow.js'
2
+ import { metadata as OpenRename } from './open-rename.action.js'
3
+ import { metadata as UpdateResourceName } from './update-name.action.js'
4
+ import { metadata as RenameForm } from './rename.form.js'
5
+
6
+ export const metadata = {
7
+ id: '@ossy/resources/flows/rename-file',
8
+ feature: 'resources',
9
+ requires: ['server', 'database'],
10
+ timeout: 150_000,
11
+ }
12
+
13
+ /**
14
+ * After uploading a file, open rename in the resource panel, change the name,
15
+ * confirm, and see the new name in storage.
16
+ */
17
+ export default {
18
+ title: 'Rename uploaded file',
19
+ description:
20
+ 'Signed-in user uploads a file, renames it from the resource panel, and sees the new name in storage',
21
+ steps: [
22
+ ...uploadFileFlow.steps,
23
+ { click: { selector: '[data-resource-name="e2e-upload.txt"]' } },
24
+ { result: { action: OpenRename, timeout: 15000 } },
25
+ { action: OpenRename },
26
+ { result: { action: UpdateResourceName, timeout: 10000 } },
27
+ { form: RenameForm, fields: { name: 'e2e-renamed.txt' } },
28
+ { action: UpdateResourceName },
29
+ {
30
+ result: {
31
+ selector: '[data-resource-name="e2e-renamed.txt"]',
32
+ timeout: 20000,
33
+ },
34
+ },
35
+ {
36
+ result: {
37
+ selector: '[data-resource-name="e2e-upload.txt"]',
38
+ hidden: true,
39
+ timeout: 10000,
40
+ },
41
+ },
42
+ ],
43
+ }
@@ -0,0 +1,5 @@
1
+ export const metadata = {
2
+ id: '@ossy/resources/form/rename',
3
+ schemaId: '@ossy/resources/schema/rename',
4
+ label: '@ossy/resources/form/rename.label',
5
+ }
@@ -0,0 +1,9 @@
1
+ /** Rename resource form — UI fields and flow faker schema. */
2
+ export default {
3
+ id: '@ossy/resources/schema/rename',
4
+ name: 'Rename resource',
5
+ icon: 'pen',
6
+ fields: [
7
+ { name: 'name', type: 'text', required: true },
8
+ ],
9
+ }
@@ -0,0 +1,182 @@
1
+ import { useCallback, useMemo } from 'react'
2
+ import { useSearchParams } from '@ossy/router-react'
3
+ import { RESOURCE_LIST_SORT_FIELDS } from './resources-list-options.js'
4
+
5
+ export const RESOURCE_LIST_QUERY_PAGE = 'page'
6
+ export const RESOURCE_LIST_QUERY_SORT = 'sort'
7
+ export const RESOURCE_LIST_QUERY_ORDER = 'order'
8
+
9
+ export const DEFAULT_RESOURCE_LIST_SORT_BY = 'name'
10
+ export const DEFAULT_RESOURCE_LIST_SORT_ORDER = 'asc'
11
+
12
+ function normalizeSortBy (value, fallback = DEFAULT_RESOURCE_LIST_SORT_BY) {
13
+ if (typeof value !== 'string') return fallback
14
+ return RESOURCE_LIST_SORT_FIELDS.includes(value) ? value : fallback
15
+ }
16
+
17
+ function normalizeSortOrder (value, fallback = DEFAULT_RESOURCE_LIST_SORT_ORDER) {
18
+ if (typeof value !== 'string') return fallback
19
+ const order = value.trim().toLowerCase()
20
+ return order === 'desc' ? 'desc' : 'asc'
21
+ }
22
+
23
+ function normalizePageIndex (value) {
24
+ const n = Number(value)
25
+ if (!Number.isFinite(n)) return 0
26
+ return Math.max(0, Math.trunc(n))
27
+ }
28
+
29
+ /**
30
+ * Parse storage list controls from a query object (`page` is 1-based).
31
+ *
32
+ * @param {Record<string, unknown>} [search]
33
+ * @param {{ defaultSortBy?: string, defaultSortOrder?: 'asc' | 'desc' }} [options]
34
+ */
35
+ export function parseResourceListQuery (search = {}, {
36
+ defaultSortBy = DEFAULT_RESOURCE_LIST_SORT_BY,
37
+ defaultSortOrder = DEFAULT_RESOURCE_LIST_SORT_ORDER,
38
+ } = {}) {
39
+ const pageRaw = Number(search?.[RESOURCE_LIST_QUERY_PAGE])
40
+ const pageIndex = Number.isFinite(pageRaw) && pageRaw >= 1
41
+ ? Math.trunc(pageRaw) - 1
42
+ : 0
43
+
44
+ return {
45
+ pageIndex,
46
+ sortBy: normalizeSortBy(search?.[RESOURCE_LIST_QUERY_SORT], defaultSortBy),
47
+ sortOrder: normalizeSortOrder(search?.[RESOURCE_LIST_QUERY_ORDER], defaultSortOrder),
48
+ }
49
+ }
50
+
51
+ /**
52
+ * Serialize list controls for the URL. Defaults become `null` so they can be
53
+ * omitted / deleted (`page=1`, `sort=name`, `order=asc`).
54
+ *
55
+ * @param {{
56
+ * pageIndex?: number,
57
+ * sortBy?: string,
58
+ * sortOrder?: 'asc' | 'desc',
59
+ * }} [state]
60
+ * @param {{ defaultSortBy?: string, defaultSortOrder?: 'asc' | 'desc' }} [options]
61
+ * @returns {{ page: string | null, sort: string | null, order: string | null }}
62
+ */
63
+ export function serializeResourceListQuery ({
64
+ pageIndex = 0,
65
+ sortBy = DEFAULT_RESOURCE_LIST_SORT_BY,
66
+ sortOrder = DEFAULT_RESOURCE_LIST_SORT_ORDER,
67
+ } = {}, {
68
+ defaultSortBy = DEFAULT_RESOURCE_LIST_SORT_BY,
69
+ defaultSortOrder = DEFAULT_RESOURCE_LIST_SORT_ORDER,
70
+ } = {}) {
71
+ const safePageIndex = normalizePageIndex(pageIndex)
72
+ const field = normalizeSortBy(sortBy, defaultSortBy)
73
+ const order = normalizeSortOrder(sortOrder, defaultSortOrder)
74
+
75
+ return {
76
+ [RESOURCE_LIST_QUERY_PAGE]: safePageIndex > 0 ? String(safePageIndex + 1) : null,
77
+ [RESOURCE_LIST_QUERY_SORT]: field === defaultSortBy ? null : field,
78
+ [RESOURCE_LIST_QUERY_ORDER]: order === defaultSortOrder ? null : order,
79
+ }
80
+ }
81
+
82
+ /**
83
+ * Query string fragment for list controls, without a leading `?`.
84
+ */
85
+ export function resourceListQuerySearchString (state, options) {
86
+ return Object.entries(serializeResourceListQuery(state, options))
87
+ .filter(([, value]) => value != null)
88
+ .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
89
+ .join('&')
90
+ }
91
+
92
+ /**
93
+ * Storage list page/sort as URL search params — same pattern as `r` for the
94
+ * open resource. Defaults stay out of the URL.
95
+ *
96
+ * @param {{
97
+ * defaultSortBy?: string,
98
+ * defaultSortOrder?: 'asc' | 'desc',
99
+ * }} [options]
100
+ */
101
+ export function useResourceListQuery ({
102
+ defaultSortBy = DEFAULT_RESOURCE_LIST_SORT_BY,
103
+ defaultSortOrder = DEFAULT_RESOURCE_LIST_SORT_ORDER,
104
+ } = {}) {
105
+ const searchParams = useSearchParams()
106
+ const { pageIndex, sortBy, sortOrder } = parseResourceListQuery(searchParams, {
107
+ defaultSortBy,
108
+ defaultSortOrder,
109
+ })
110
+
111
+ const patch = useCallback((updates) => {
112
+ if (typeof searchParams.patch === 'function') {
113
+ searchParams.patch(updates)
114
+ return
115
+ }
116
+ for (const [name, value] of Object.entries(updates || {})) {
117
+ if (value == null || value === '') searchParams.delete(name)
118
+ else searchParams.set(name, value)
119
+ }
120
+ }, [searchParams.patch, searchParams.delete, searchParams.set])
121
+
122
+ const setPage = useCallback((nextIndex) => {
123
+ const nextPageIndex = normalizePageIndex(nextIndex)
124
+ patch({
125
+ [RESOURCE_LIST_QUERY_PAGE]: nextPageIndex > 0 ? String(nextPageIndex + 1) : null,
126
+ })
127
+ }, [patch])
128
+
129
+ const nextPage = useCallback(() => {
130
+ setPage(pageIndex + 1)
131
+ }, [pageIndex, setPage])
132
+
133
+ const previousPage = useCallback(() => {
134
+ setPage(Math.max(0, pageIndex - 1))
135
+ }, [pageIndex, setPage])
136
+
137
+ const resetPage = useCallback(() => {
138
+ setPage(0)
139
+ }, [setPage])
140
+
141
+ const setSort = useCallback((nextSortBy, nextSortOrder) => {
142
+ patch(serializeResourceListQuery({
143
+ pageIndex: 0,
144
+ sortBy: nextSortBy ?? sortBy,
145
+ sortOrder: nextSortOrder ?? sortOrder,
146
+ }, { defaultSortBy, defaultSortOrder }))
147
+ }, [patch, sortBy, sortOrder, defaultSortBy, defaultSortOrder])
148
+
149
+ const setSortBy = useCallback((nextSortBy) => {
150
+ if (nextSortBy == null) return
151
+ setSort(nextSortBy, sortOrder)
152
+ }, [setSort, sortOrder])
153
+
154
+ const setSortOrder = useCallback((nextSortOrder) => {
155
+ if (nextSortOrder == null) return
156
+ setSort(sortBy, nextSortOrder)
157
+ }, [setSort, sortBy])
158
+
159
+ return useMemo(() => ({
160
+ pageIndex,
161
+ sortBy,
162
+ sortOrder,
163
+ setPage,
164
+ nextPage,
165
+ previousPage,
166
+ resetPage,
167
+ setSort,
168
+ setSortBy,
169
+ setSortOrder,
170
+ }), [
171
+ pageIndex,
172
+ sortBy,
173
+ sortOrder,
174
+ setPage,
175
+ nextPage,
176
+ previousPage,
177
+ resetPage,
178
+ setSort,
179
+ setSortBy,
180
+ setSortOrder,
181
+ ])
182
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, expect, it } from '@jest/globals'
2
+ import {
3
+ DEFAULT_RESOURCE_LIST_SORT_BY,
4
+ DEFAULT_RESOURCE_LIST_SORT_ORDER,
5
+ parseResourceListQuery,
6
+ resourceListQuerySearchString,
7
+ serializeResourceListQuery,
8
+ } from './resource-list-query.js'
9
+
10
+ describe('resource-list-query', () => {
11
+ describe('parseResourceListQuery', () => {
12
+ it('defaults to page 0, name, asc', () => {
13
+ expect(parseResourceListQuery({})).toEqual({
14
+ pageIndex: 0,
15
+ sortBy: DEFAULT_RESOURCE_LIST_SORT_BY,
16
+ sortOrder: DEFAULT_RESOURCE_LIST_SORT_ORDER,
17
+ })
18
+ })
19
+
20
+ it('reads 1-based page from the query', () => {
21
+ expect(parseResourceListQuery({ page: '2' }).pageIndex).toBe(1)
22
+ expect(parseResourceListQuery({ page: '1' }).pageIndex).toBe(0)
23
+ expect(parseResourceListQuery({ page: '0' }).pageIndex).toBe(0)
24
+ expect(parseResourceListQuery({ page: 'nope' }).pageIndex).toBe(0)
25
+ })
26
+
27
+ it('accepts known sort fields and orders', () => {
28
+ expect(parseResourceListQuery({ sort: 'created', order: 'desc' })).toEqual({
29
+ pageIndex: 0,
30
+ sortBy: 'created',
31
+ sortOrder: 'desc',
32
+ })
33
+ })
34
+
35
+ it('ignores unknown sort fields', () => {
36
+ expect(parseResourceListQuery({ sort: 'mystery' }).sortBy).toBe('name')
37
+ })
38
+ })
39
+
40
+ describe('serializeResourceListQuery', () => {
41
+ it('omits defaults', () => {
42
+ expect(serializeResourceListQuery({
43
+ pageIndex: 0,
44
+ sortBy: 'name',
45
+ sortOrder: 'asc',
46
+ })).toEqual({
47
+ page: null,
48
+ sort: null,
49
+ order: null,
50
+ })
51
+ })
52
+
53
+ it('writes 1-based page and non-default sort', () => {
54
+ expect(serializeResourceListQuery({
55
+ pageIndex: 2,
56
+ sortBy: 'lastUpdated',
57
+ sortOrder: 'desc',
58
+ })).toEqual({
59
+ page: '3',
60
+ sort: 'lastUpdated',
61
+ order: 'desc',
62
+ })
63
+ })
64
+ })
65
+
66
+ describe('resourceListQuerySearchString', () => {
67
+ it('builds a query fragment without defaults or a leading ?', () => {
68
+ expect(resourceListQuerySearchString({
69
+ pageIndex: 0,
70
+ sortBy: 'size',
71
+ sortOrder: 'asc',
72
+ })).toBe('sort=size')
73
+ })
74
+ })
75
+ })
@@ -89,3 +89,28 @@ export function applyResourceSelectionGesture ({
89
89
  nextAnchorId: targetId,
90
90
  }
91
91
  }
92
+
93
+ /**
94
+ * Keep selected resource records so batch download/remove still work after
95
+ * paging away. Drops ids that are no longer selected; prefers `knownResources`
96
+ * (usually the current page) and falls back to the previous map.
97
+ *
98
+ * @param {Iterable<string> | Set<string>} selectedIds
99
+ * @param {Map<string, object> | null | undefined} previousById
100
+ * @param {object[]} [knownResources]
101
+ * @returns {Map<string, object>}
102
+ */
103
+ export function syncSelectedResources (selectedIds, previousById, knownResources = []) {
104
+ const ids = selectedIds instanceof Set ? selectedIds : new Set(selectedIds || [])
105
+ const knownById = new Map()
106
+ for (const resource of knownResources) {
107
+ if (resource?.id) knownById.set(resource.id, resource)
108
+ }
109
+ const prev = previousById instanceof Map ? previousById : new Map()
110
+ const next = new Map()
111
+ for (const id of ids) {
112
+ const resource = knownById.get(id) ?? prev.get(id)
113
+ if (resource) next.set(id, resource)
114
+ }
115
+ return next
116
+ }
@@ -5,6 +5,7 @@ import {
5
5
  isMultiSelectGesture,
6
6
  isRangeSelectModifier,
7
7
  isToggleSelectModifier,
8
+ syncSelectedResources,
8
9
  } from './resource-selection.js'
9
10
 
10
11
  describe('resource-selection', () => {
@@ -74,4 +75,43 @@ describe('resource-selection', () => {
74
75
  expect(result.nextAnchorId).toBe('a')
75
76
  })
76
77
  })
78
+
79
+ describe('syncSelectedResources', () => {
80
+ it('keeps previously selected resources that are not on the current page', () => {
81
+ const previous = new Map([
82
+ ['a', { id: 'a', name: 'one' }],
83
+ ['b', { id: 'b', name: 'two' }],
84
+ ])
85
+ const next = syncSelectedResources(
86
+ new Set(['a', 'c']),
87
+ previous,
88
+ [{ id: 'c', name: 'three' }],
89
+ )
90
+
91
+ expect([...next.keys()]).toEqual(['a', 'c'])
92
+ expect(next.get('a')).toEqual({ id: 'a', name: 'one' })
93
+ expect(next.get('c')).toEqual({ id: 'c', name: 'three' })
94
+ })
95
+
96
+ it('prefers the current page record when both exist', () => {
97
+ const previous = new Map([['a', { id: 'a', name: 'stale' }]])
98
+ const next = syncSelectedResources(
99
+ ['a'],
100
+ previous,
101
+ [{ id: 'a', name: 'fresh' }],
102
+ )
103
+
104
+ expect(next.get('a')).toEqual({ id: 'a', name: 'fresh' })
105
+ })
106
+
107
+ it('drops resources that are no longer selected', () => {
108
+ const previous = new Map([
109
+ ['a', { id: 'a' }],
110
+ ['b', { id: 'b' }],
111
+ ])
112
+ const next = syncSelectedResources(new Set(['b']), previous, [])
113
+
114
+ expect([...next.keys()]).toEqual(['b'])
115
+ })
116
+ })
77
117
  })
@@ -34,10 +34,17 @@ export {
34
34
  isMultiSelectGesture,
35
35
  isRangeSelectModifier,
36
36
  isToggleSelectModifier,
37
+ syncSelectedResources,
37
38
  } from './resource-selection.js'
38
39
 
39
40
  function invalidateLocation(sdk, location) {
40
- sdk.invalidate(cacheKey(ListResources, { location: normalizeLocation(location) }))
41
+ const normalized = normalizeLocation(location)
42
+ if (typeof sdk.invalidateLocation === 'function') {
43
+ sdk.invalidateLocation(normalized)
44
+ return
45
+ }
46
+ // Fallback for older sdk-react builds: exact unpaginated key only.
47
+ sdk.invalidate(cacheKey(ListResources, { location: normalized }))
41
48
  }
42
49
 
43
50
  export async function createDocument(sdk, document) {
@@ -0,0 +1,169 @@
1
+ export const RESOURCE_LIST_SORT_FIELDS = Object.freeze([
2
+ 'name',
3
+ 'created',
4
+ 'lastUpdated',
5
+ 'size',
6
+ 'type',
7
+ ])
8
+
9
+ export const DEFAULT_RESOURCE_LIST_LIMIT = 50
10
+ export const MAX_RESOURCE_LIST_LIMIT = 200
11
+
12
+ const LIST_OPTION_KEYS = new Set(['limit', 'offset', 'sortBy', 'sortOrder', 'sort'])
13
+
14
+ export function isDirectoryResourceType(type) {
15
+ return type === '@ossy/platform/schema/directory' || type === 'directory'
16
+ }
17
+
18
+ export function resourceSizeOf(resource) {
19
+ const length = resource?.content?.ContentLength
20
+ if (typeof length === 'number' && Number.isFinite(length)) return length
21
+ if (typeof resource?.size === 'number' && Number.isFinite(resource.size)) return resource.size
22
+ return 0
23
+ }
24
+
25
+ function parsePositiveInt(value, { fallback = null, min = 0, max = Number.MAX_SAFE_INTEGER } = {}) {
26
+ if (value === undefined || value === null || value === '') return fallback
27
+ const n = Number(value)
28
+ if (!Number.isFinite(n)) return fallback
29
+ return Math.min(max, Math.max(min, Math.trunc(n)))
30
+ }
31
+
32
+ function normalizeSortField(value) {
33
+ if (typeof value !== 'string') return null
34
+ const field = value.trim()
35
+ return RESOURCE_LIST_SORT_FIELDS.includes(field) ? field : null
36
+ }
37
+
38
+ function normalizeSortOrder(value) {
39
+ if (typeof value !== 'string') return 'asc'
40
+ const order = value.trim().toLowerCase()
41
+ return order === 'desc' ? 'desc' : 'asc'
42
+ }
43
+
44
+ /**
45
+ * Pull pagination/sort controls out of a resources query object so they are not
46
+ * treated as Mongo field filters (e.g. `state.limit`).
47
+ */
48
+ export function extractResourceListOptions(query = {}) {
49
+ const source = query && typeof query === 'object' ? query : {}
50
+ const rest = { ...source }
51
+
52
+ let sortBy = normalizeSortField(rest.sortBy)
53
+ let sortOrder = normalizeSortOrder(rest.sortOrder)
54
+
55
+ if (!sortBy && typeof rest.sort === 'string' && rest.sort.trim()) {
56
+ const [field, order] = rest.sort.trim().split(':')
57
+ sortBy = normalizeSortField(field)
58
+ if (order) sortOrder = normalizeSortOrder(order)
59
+ }
60
+
61
+ const limit = parsePositiveInt(rest.limit, {
62
+ fallback: null,
63
+ min: 1,
64
+ max: MAX_RESOURCE_LIST_LIMIT,
65
+ })
66
+ const offset = parsePositiveInt(rest.offset, {
67
+ fallback: 0,
68
+ min: 0,
69
+ }) ?? 0
70
+
71
+ for (const key of LIST_OPTION_KEYS) delete rest[key]
72
+
73
+ const paginated = limit != null
74
+ return {
75
+ filter: rest,
76
+ sortBy: sortBy ?? (paginated ? 'name' : null),
77
+ sortOrder,
78
+ limit,
79
+ offset,
80
+ paginated,
81
+ }
82
+ }
83
+
84
+ export function mergeResourceListParams(payload = {}, reqQuery = {}) {
85
+ const merged = { ...(payload?.query ?? {}) }
86
+ for (const key of LIST_OPTION_KEYS) {
87
+ if (payload?.[key] !== undefined) merged[key] = payload[key]
88
+ else if (reqQuery?.[key] !== undefined) merged[key] = reqQuery[key]
89
+ }
90
+ if (payload?.location !== undefined) merged.location = payload.location
91
+ else if (merged.location === undefined && reqQuery?.location !== undefined) {
92
+ merged.location = reqQuery.location
93
+ }
94
+ return merged
95
+ }
96
+
97
+ function compareStrings(a, b) {
98
+ return String(a ?? '').localeCompare(String(b ?? ''), undefined, {
99
+ sensitivity: 'base',
100
+ numeric: true,
101
+ })
102
+ }
103
+
104
+ function compareValues(a, b, sortBy) {
105
+ switch (sortBy) {
106
+ case 'created':
107
+ case 'lastUpdated':
108
+ return (Number(a?.[sortBy]) || 0) - (Number(b?.[sortBy]) || 0)
109
+ case 'size':
110
+ return resourceSizeOf(a) - resourceSizeOf(b)
111
+ case 'type':
112
+ return compareStrings(a?.type, b?.type)
113
+ case 'name':
114
+ default:
115
+ return compareStrings(a?.name, b?.name)
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Stable folder-first sort used by storage UI and list API defaults.
121
+ */
122
+ export function sortResources(resources = [], { sortBy = 'name', sortOrder = 'asc' } = {}) {
123
+ const field = normalizeSortField(sortBy) ?? 'name'
124
+ const order = normalizeSortOrder(sortOrder)
125
+ const direction = order === 'desc' ? -1 : 1
126
+
127
+ return [...resources].sort((a, b) => {
128
+ const aDir = isDirectoryResourceType(a?.type)
129
+ const bDir = isDirectoryResourceType(b?.type)
130
+ if (aDir && !bDir) return -1
131
+ if (!aDir && bDir) return 1
132
+ const primary = compareValues(a, b, field) * direction
133
+ if (primary !== 0) return primary
134
+ return compareStrings(a?.name, b?.name)
135
+ })
136
+ }
137
+
138
+ export function paginateResources(resources = [], { limit = DEFAULT_RESOURCE_LIST_LIMIT, offset = 0 } = {}) {
139
+ const safeLimit = parsePositiveInt(limit, {
140
+ fallback: DEFAULT_RESOURCE_LIST_LIMIT,
141
+ min: 1,
142
+ max: MAX_RESOURCE_LIST_LIMIT,
143
+ })
144
+ const safeOffset = parsePositiveInt(offset, { fallback: 0, min: 0 }) ?? 0
145
+ const total = resources.length
146
+ const items = resources.slice(safeOffset, safeOffset + safeLimit)
147
+ return {
148
+ items,
149
+ total,
150
+ limit: safeLimit,
151
+ offset: safeOffset,
152
+ }
153
+ }
154
+
155
+ export function mongoSortForResourceList({ sortBy = 'name', sortOrder = 'asc' } = {}) {
156
+ const field = normalizeSortField(sortBy) ?? 'name'
157
+ const order = normalizeSortOrder(sortOrder) === 'desc' ? -1 : 1
158
+ const fieldPath = field === 'size'
159
+ ? 'state.content.ContentLength'
160
+ : `state.${field}`
161
+
162
+ // Directories first, then the requested field. Secondary name keeps ties stable.
163
+ const sort = {
164
+ _isDirectory: 1,
165
+ [fieldPath]: order,
166
+ }
167
+ if (field !== 'name') sort['state.name'] = 1
168
+ return sort
169
+ }