From 4b6db84afc633940015835e39c014abef37764aa Mon Sep 17 00:00:00 2001 From: Masahiko AMANO Date: Fri, 3 Jul 2026 08:36:12 +0300 Subject: [PATCH] feat: per-pool automatic file sorting MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pools now store a sort setting (sort_key + sort_order). "manual" keeps the user-arranged order in file_pool.position and allows drag-to-reorder; any other key (content_datetime, created, original_name) sorts the pool's files automatically server-side, in which case reordering is rejected. Manual order is always ascending by position — direction does not apply. Backend: add the columns (sort_key defaults to 'manual'), generalise the pool-files keyset cursor to page by the active sort, persist the setting on create/update, and guard reorder against non-manual pools. Frontend: a sort dropdown (+ direction toggle) on the pool page that PATCHes the pool and reloads; drag-to-reorder is disabled unless the pool is manual. Closes the "automatic sorting in pools" requirement (REQUIREMENTS.md §4.6.5). Co-Authored-By: Claude Opus 4.8 --- backend/internal/db/postgres/pool_repo.go | 120 ++++++++++++++++---- backend/internal/domain/pool.go | 31 +++++ backend/internal/handler/pool_handler.go | 14 +++ backend/internal/integration/server_test.go | 113 ++++++++++++++++++ backend/internal/port/repository.go | 4 + backend/internal/service/pool_service.go | 60 ++++++++-- backend/migrations/003_data_tables.sql | 11 +- frontend/src/routes/pools/[id]/+page.svelte | 109 +++++++++++++++++- openapi.yaml | 16 +++ 9 files changed, 445 insertions(+), 33 deletions(-) diff --git a/backend/internal/db/postgres/pool_repo.go b/backend/internal/db/postgres/pool_repo.go index 2dcc810..e722817 100644 --- a/backend/internal/db/postgres/pool_repo.go +++ b/backend/internal/db/postgres/pool_repo.go @@ -30,6 +30,8 @@ type poolRow struct { CreatorID int16 `db:"creator_id"` CreatorName string `db:"creator_name"` IsPublic bool `db:"is_public"` + SortKey string `db:"sort_key"` + SortOrder string `db:"sort_order"` FileCount int `db:"file_count"` } @@ -68,6 +70,8 @@ func toPool(r poolRow) domain.Pool { CreatorID: r.CreatorID, CreatorName: r.CreatorName, IsPublic: r.IsPublic, + SortKey: r.SortKey, + SortOrder: r.SortOrder, FileCount: r.FileCount, CreatedAt: domain.UUIDCreatedAt(r.ID), } @@ -103,9 +107,15 @@ func toPoolFile(r poolFileRow) domain.PoolFile { // Cursor // --------------------------------------------------------------------------- +// poolFileCursor is the keyset paging cursor for pool files. Which fields are +// populated depends on the pool's sort key: Pos for manual (position), Val for +// content_datetime (RFC3339Nano) / original_name (the coalesced name); the +// "created" sort orders by file id alone, so only FileID matters. FileID is +// always the final tiebreak. type poolFileCursor struct { - Position int `json:"p"` - FileID string `json:"id"` + Pos int `json:"p,omitempty"` + Val string `json:"v,omitempty"` + FileID string `json:"id"` } func encodePoolCursor(c poolFileCursor) string { @@ -135,6 +145,7 @@ const poolCountSubquery = `(SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool const poolSelectFrom = ` SELECT p.id, p.name, p.notes, p.metadata, p.creator_id, u.name AS creator_name, p.is_public, + p.sort_key, p.sort_order, COALESCE(fc.cnt, 0) AS file_count FROM data.pools p JOIN core.users u ON u.id = p.creator_id @@ -147,6 +158,29 @@ func poolSortColumn(s string) string { return "p.id" // "created" } +// poolFileSort maps a pool's stored sort settings to the SQL column expression, +// the ORDER BY direction, and the keyset comparison operator used for paging. +// The column is chosen so it is never NULL (original_name is coalesced), which +// keeps the keyset comparison total. +func poolFileSort(sortKey, sortOrder string) (col, dir, cmp string) { + dir, cmp = "ASC", ">" + if strings.EqualFold(sortOrder, domain.SortOrderDesc) { + dir, cmp = "DESC", "<" + } + switch sortKey { + case domain.PoolSortContentDatetime: + col = "f.content_datetime" + case domain.PoolSortOriginalName: + col = "COALESCE(f.original_name, '')" + case domain.PoolSortCreated: + col = "f.id" + default: // manual — the user-arranged sequence; direction does not apply + col = "fp.position" + dir, cmp = "ASC", ">" + } + return col, dir, cmp +} + // --------------------------------------------------------------------------- // PoolRepo // --------------------------------------------------------------------------- @@ -207,6 +241,7 @@ func (r *PoolRepo) List(ctx context.Context, params port.OffsetParams) (*domain. query := fmt.Sprintf(` SELECT p.id, p.name, p.notes, p.metadata, p.creator_id, u.name AS creator_name, p.is_public, + p.sort_key, p.sort_order, COALESCE(fc.cnt, 0) AS file_count, COUNT(*) OVER() AS total FROM data.pools p @@ -289,6 +324,7 @@ WITH ins AS ( ) SELECT ins.id, ins.name, ins.notes, ins.metadata, ins.creator_id, u.name AS creator_name, ins.is_public, + ins.sort_key, ins.sort_order, 0 AS file_count FROM ins JOIN core.users u ON u.id = ins.creator_id` @@ -322,15 +358,18 @@ func (r *PoolRepo) Update(ctx context.Context, id uuid.UUID, p *domain.Pool) (*d const query = ` WITH upd AS ( UPDATE data.pools SET - name = $2, - notes = $3, - metadata = COALESCE($4, metadata), - is_public = $5 + name = $2, + notes = $3, + metadata = COALESCE($4, metadata), + is_public = $5, + sort_key = $6, + sort_order = $7 WHERE id = $1 RETURNING * ) SELECT upd.id, upd.name, upd.notes, upd.metadata, upd.creator_id, u.name AS creator_name, upd.is_public, + upd.sort_key, upd.sort_order, COALESCE(fc.cnt, 0) AS file_count FROM upd JOIN core.users u ON u.id = upd.creator_id @@ -343,7 +382,7 @@ LEFT JOIN (SELECT pool_id, COUNT(*) AS cnt FROM data.file_pool WHERE pool_id = $ } q := connOrTx(ctx, r.pool) - rows, err := q.Query(ctx, query, id, p.Name, p.Notes, meta, p.IsPublic) + rows, err := q.Query(ctx, query, id, p.Name, p.Notes, meta, p.IsPublic, p.SortKey, p.SortOrder) if err != nil { return nil, fmt.Errorf("PoolRepo.Update: %w", err) } @@ -412,8 +451,16 @@ func (r *PoolRepo) ListFiles(ctx context.Context, poolID uuid.UUID, params port. } } - // Cursor condition. - var orderBy string + // Resolve the pool's sort setting (defaulting to manual position order) into + // the ORDER BY column, direction, and keyset comparison operator. + sortKey := params.SortKey + if !domain.ValidPoolSortKey(sortKey) { + sortKey = domain.PoolSortManual + } + col, dir, cmp := poolFileSort(sortKey, params.SortOrder) + + // Keyset cursor condition. For "created" the file id is both the sort key and + // the tiebreak, so a single comparison suffices; the others compare (col, id). if params.Cursor != "" { cur, err := decodePoolCursor(params.Cursor) if err != nil { @@ -423,13 +470,36 @@ func (r *PoolRepo) ListFiles(ctx context.Context, poolID uuid.UUID, params port. if err != nil { return nil, domain.ErrValidation } - conds = append(conds, fmt.Sprintf( - "(fp.position > $%d OR (fp.position = $%d AND fp.file_id > $%d))", - n, n, n+1)) - args = append(args, cur.Position, fileID) - n += 2 + if sortKey == domain.PoolSortCreated { + conds = append(conds, fmt.Sprintf("f.id %s $%d", cmp, n)) + args = append(args, fileID) + n++ + } else { + conds = append(conds, fmt.Sprintf( + "(%s %s $%d OR (%s = $%d AND f.id %s $%d))", col, cmp, n, col, n, cmp, n+1)) + switch sortKey { + case domain.PoolSortContentDatetime: + t, err := time.Parse(time.RFC3339Nano, cur.Val) + if err != nil { + return nil, domain.ErrValidation + } + args = append(args, t) + case domain.PoolSortOriginalName: + args = append(args, cur.Val) + default: // manual + args = append(args, cur.Pos) + } + args = append(args, fileID) + n += 2 + } + } + + var orderBy string + if sortKey == domain.PoolSortCreated { + orderBy = fmt.Sprintf("f.id %s", dir) + } else { + orderBy = fmt.Sprintf("%s %s, f.id %s", col, dir, dir) } - orderBy = "fp.position ASC, fp.file_id ASC" where := "WHERE " + strings.Join(conds, " AND ") args = append(args, limit+1) @@ -468,11 +538,21 @@ LIMIT $%d`, fileSelectForPool, where, orderBy, n) if hasMore && len(collected) > 0 { last := collected[len(collected)-1] - cur := encodePoolCursor(poolFileCursor{ - Position: last.Position, - FileID: last.ID.String(), - }) - page.NextCursor = &cur + cursor := poolFileCursor{FileID: last.ID.String()} + switch sortKey { + case domain.PoolSortContentDatetime: + cursor.Val = last.ContentDatetime.UTC().Format(time.RFC3339Nano) + case domain.PoolSortOriginalName: + if last.OriginalName != nil { + cursor.Val = *last.OriginalName + } + case domain.PoolSortCreated: + // file id alone orders; nothing else to carry + default: // manual + cursor.Pos = last.Position + } + enc := encodePoolCursor(cursor) + page.NextCursor = &enc } // Batch-load tags. diff --git a/backend/internal/domain/pool.go b/backend/internal/domain/pool.go index bceb50f..cc1ed3e 100644 --- a/backend/internal/domain/pool.go +++ b/backend/internal/domain/pool.go @@ -7,6 +7,32 @@ import ( "github.com/google/uuid" ) +// Pool file sort keys. PoolSortManual keeps the user-defined order in +// file_pool.position; the others sort the pool's files by that file field. +const ( + PoolSortManual = "manual" + PoolSortContentDatetime = "content_datetime" + PoolSortCreated = "created" + PoolSortOriginalName = "original_name" + + SortOrderAsc = "asc" + SortOrderDesc = "desc" +) + +// ValidPoolSortKey reports whether s is an accepted pool sort key. +func ValidPoolSortKey(s string) bool { + switch s { + case PoolSortManual, PoolSortContentDatetime, PoolSortCreated, PoolSortOriginalName: + return true + } + return false +} + +// ValidSortOrder reports whether s is an accepted sort direction. +func ValidSortOrder(s string) bool { + return s == SortOrderAsc || s == SortOrderDesc +} + // Pool is an ordered collection of files. type Pool struct { ID uuid.UUID @@ -16,6 +42,11 @@ type Pool struct { CreatorID int16 CreatorName string // denormalized IsPublic bool + // SortKey / SortOrder control how the pool's files are ordered. When SortKey + // is PoolSortManual, files follow the manual position order and can be + // reordered; otherwise they are sorted automatically and reordering is a no-op. + SortKey string + SortOrder string FileCount int CreatedAt time.Time // extracted from UUID v7 via UUIDCreatedAt } diff --git a/backend/internal/handler/pool_handler.go b/backend/internal/handler/pool_handler.go index 7ddd457..574df2d 100644 --- a/backend/internal/handler/pool_handler.go +++ b/backend/internal/handler/pool_handler.go @@ -34,6 +34,8 @@ type poolJSON struct { CreatorID int16 `json:"creator_id"` CreatorName string `json:"creator_name"` IsPublic bool `json:"is_public"` + SortKey string `json:"sort_key"` + SortOrder string `json:"sort_order"` FileCount int `json:"file_count"` CreatedAt string `json:"created_at"` } @@ -51,6 +53,8 @@ func toPoolJSON(p domain.Pool) poolJSON { CreatorID: p.CreatorID, CreatorName: p.CreatorName, IsPublic: p.IsPublic, + SortKey: p.SortKey, + SortOrder: p.SortOrder, FileCount: p.FileCount, CreatedAt: p.CreatedAt.UTC().Format(time.RFC3339), } @@ -214,6 +218,16 @@ func (h *PoolHandler) Update(c *gin.Context) { params.IsPublic = &b } } + if v, ok := raw["sort_key"]; ok { + if s, ok := v.(string); ok { + params.SortKey = &s + } + } + if v, ok := raw["sort_order"]; ok { + if s, ok := v.(string); ok { + params.SortOrder = &s + } + } updated, err := h.poolSvc.Update(c.Request.Context(), id, params) if err != nil { diff --git a/backend/internal/integration/server_test.go b/backend/internal/integration/server_test.go index e1a2e7f..58a52e3 100644 --- a/backend/internal/integration/server_test.go +++ b/backend/internal/integration/server_test.go @@ -26,6 +26,7 @@ import ( "net/url" "os" "path/filepath" + "sort" "strings" "testing" "time" @@ -1745,3 +1746,115 @@ func TestDuplicateDetection(t *testing.T) { resp.decode(t, &list) assert.Equal(t, 0, list.Total, "dismissal must survive a rescan") } + +// TestPoolAutomaticSort exercises per-pool file ordering: the default manual +// order, switching to automatic sorts (by name and by creation), that an +// auto-sorted pool rejects manual reordering, and that keyset paging honours the +// active sort. +func TestPoolAutomaticSort(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + + h := setupSuite(t) + admin := h.login("admin", "admin") + + // Upload three files with distinct names in a non-alphabetical order. + idB := h.uploadJPEG(admin, "b.jpg")["id"].(string) + idA := h.uploadJPEG(admin, "a.jpg")["id"].(string) + idC := h.uploadJPEG(admin, "c.jpg")["id"].(string) + + // "created" order is UUID (byte) order — lexicographic on the canonical string. + createdAsc := []string{idA, idB, idC} + sort.Strings(createdAsc) + createdDesc := []string{createdAsc[2], createdAsc[1], createdAsc[0]} + + // New pool defaults to manual ordering. + resp := h.doJSON("POST", "/pools", map[string]any{"name": "sortpool"}, admin) + require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String()) + var pool map[string]any + resp.decode(t, &pool) + poolID := pool["id"].(string) + require.Equal(t, "manual", pool["sort_key"]) + + // Add in upload order (b, a, c) → that becomes the manual position order. + resp = h.doJSON("POST", "/pools/"+poolID+"/files", + map[string]any{"file_ids": []string{idB, idA, idC}}, admin) + require.Equal(t, http.StatusCreated, resp.StatusCode, resp.String()) + + listIDs := func() []string { + resp := h.doJSON("GET", "/pools/"+poolID+"/files", nil, admin) + require.Equal(t, http.StatusOK, resp.StatusCode, resp.String()) + var page struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + } + resp.decode(t, &page) + ids := make([]string, len(page.Items)) + for i, it := range page.Items { + ids[i] = it.ID + } + return ids + } + + setSort := func(key, order string) { + body := map[string]any{"sort_key": key} + if order != "" { + body["sort_order"] = order + } + resp := h.doJSON("PATCH", "/pools/"+poolID, body, admin) + require.Equal(t, http.StatusOK, resp.StatusCode, resp.String()) + } + + // Manual = insertion order. + require.Equal(t, []string{idB, idA, idC}, listIDs()) + + // By name ascending → a, b, c. + setSort("original_name", "asc") + require.Equal(t, []string{idA, idB, idC}, listIDs()) + + // By creation descending → reverse UUID order. + setSort("created", "desc") + require.Equal(t, createdDesc, listIDs()) + + // Reordering an auto-sorted pool is rejected. + resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder", + map[string]any{"file_ids": []string{idC, idB, idA}}, admin) + require.Equal(t, http.StatusBadRequest, resp.StatusCode, resp.String()) + + // Back to manual → reordering works again and sticks. + setSort("manual", "") + resp = h.doJSON("PUT", "/pools/"+poolID+"/files/reorder", + map[string]any{"file_ids": []string{idC, idB, idA}}, admin) + require.Equal(t, http.StatusNoContent, resp.StatusCode, resp.String()) + require.Equal(t, []string{idC, idB, idA}, listIDs()) + + // Keyset paging (limit 1) under an automatic sort returns the full sequence. + setSort("original_name", "asc") + var paged []string + cursor := "" + for i := 0; i < 10; i++ { + url := "/pools/" + poolID + "/files?limit=1" + if cursor != "" { + url += "&cursor=" + cursor + } + resp := h.doJSON("GET", url, nil, admin) + require.Equal(t, http.StatusOK, resp.StatusCode, resp.String()) + var page struct { + Items []struct { + ID string `json:"id"` + } `json:"items"` + NextCursor *string `json:"next_cursor"` + } + resp.decode(t, &page) + for _, it := range page.Items { + paged = append(paged, it.ID) + } + if page.NextCursor == nil { + break + } + cursor = *page.NextCursor + } + require.Equal(t, []string{idA, idB, idC}, paged) +} diff --git a/backend/internal/port/repository.go b/backend/internal/port/repository.go index 524b739..989073c 100644 --- a/backend/internal/port/repository.go +++ b/backend/internal/port/repository.go @@ -36,6 +36,10 @@ type PoolFileListParams struct { Cursor string Limit int Filter string // filter DSL expression + // SortKey / SortOrder come from the pool itself (not the request) and select + // how files are ordered: manual position order or an automatic file-field sort. + SortKey string + SortOrder string } // FileRepo is the persistence interface for file records. diff --git a/backend/internal/service/pool_service.go b/backend/internal/service/pool_service.go index 66555e3..4bd865c 100644 --- a/backend/internal/service/pool_service.go +++ b/backend/internal/service/pool_service.go @@ -13,12 +13,15 @@ import ( const poolObjectType = "pool" const poolObjectTypeID int16 = 4 // fourth row in 007_seed_data.sql object_types -// PoolParams holds the fields for creating or patching a pool. +// PoolParams holds the fields for creating or patching a pool. SortKey and +// SortOrder are pointers so a patch can leave them unchanged (nil) vs set them. type PoolParams struct { - Name string - Notes *string - Metadata json.RawMessage - IsPublic *bool + Name string + Notes *string + Metadata json.RawMessage + IsPublic *bool + SortKey *string + SortOrder *string } // PoolService handles pool CRUD and pool–file management with ACL + audit. @@ -164,6 +167,18 @@ func (s *PoolService) Update(ctx context.Context, id uuid.UUID, p PoolParams) (* if p.IsPublic != nil { patch.IsPublic = *p.IsPublic } + if p.SortKey != nil { + if !domain.ValidPoolSortKey(*p.SortKey) { + return nil, domain.ErrValidation + } + patch.SortKey = *p.SortKey + } + if p.SortOrder != nil { + if !domain.ValidSortOrder(*p.SortOrder) { + return nil, domain.ErrValidation + } + patch.SortOrder = *p.SortOrder + } updated, err := s.pools.Update(ctx, id, &patch) if err != nil { @@ -205,12 +220,24 @@ func (s *PoolService) Delete(ctx context.Context, id uuid.UUID) error { // Pool–file operations // --------------------------------------------------------------------------- -// ListFiles returns cursor-paginated files within a pool ordered by position, -// enforcing view ACL on the pool. +// ListFiles returns cursor-paginated files within a pool, enforcing view ACL. +// The ordering is the pool's own stored sort setting (manual position order or +// an automatic file-field sort), not a request parameter. func (s *PoolService) ListFiles(ctx context.Context, poolID uuid.UUID, params port.PoolFileListParams) (*domain.PoolFilePage, error) { - if err := s.authorizeView(ctx, poolID); err != nil { + userID, isAdmin, _ := domain.UserFromContext(ctx) + pool, err := s.pools.GetByID(ctx, poolID) + if err != nil { return nil, err } + ok, err := s.acl.CanView(ctx, userID, isAdmin, pool.CreatorID, pool.IsPublic, poolObjectTypeID, poolID) + if err != nil { + return nil, err + } + if !ok { + return nil, domain.ErrForbidden + } + params.SortKey = pool.SortKey + params.SortOrder = pool.SortOrder return s.pools.ListFiles(ctx, poolID, params) } @@ -242,11 +269,24 @@ func (s *PoolService) RemoveFiles(ctx context.Context, poolID uuid.UUID, fileIDs } // Reorder sets the ordered sequence of file IDs within a pool, enforcing edit -// ACL on the pool. +// ACL on the pool. Manual ordering only applies when the pool's sort key is +// "manual"; reordering an auto-sorted pool is rejected as a validation error. func (s *PoolService) Reorder(ctx context.Context, poolID uuid.UUID, fileIDs []uuid.UUID) error { - if err := s.authorizeEdit(ctx, poolID); err != nil { + userID, isAdmin, _ := domain.UserFromContext(ctx) + pool, err := s.pools.GetByID(ctx, poolID) + if err != nil { return err } + ok, err := s.acl.CanEdit(ctx, userID, isAdmin, pool.CreatorID, poolObjectTypeID, poolID) + if err != nil { + return err + } + if !ok { + return domain.ErrForbidden + } + if pool.SortKey != domain.PoolSortManual { + return domain.ErrValidation + } if err := s.pools.Reorder(ctx, poolID, fileIDs); err != nil { return err } diff --git a/backend/migrations/003_data_tables.sql b/backend/migrations/003_data_tables.sql index 288d509..7f07897 100644 --- a/backend/migrations/003_data_tables.sql +++ b/backend/migrations/003_data_tables.sql @@ -76,8 +76,17 @@ CREATE TABLE data.pools ( creator_id smallint NOT NULL REFERENCES core.users(id) ON UPDATE CASCADE ON DELETE RESTRICT, is_public boolean NOT NULL DEFAULT false, + -- File ordering within the pool. 'manual' keeps the user-defined order in + -- data.file_pool.position (drag-to-reorder); any other key sorts the pool's + -- files automatically by that file field, in which case reordering is disabled. + sort_key varchar(32) NOT NULL DEFAULT 'manual', + sort_order varchar(4) NOT NULL DEFAULT 'asc', - CONSTRAINT uni__pools__name UNIQUE (name) + CONSTRAINT uni__pools__name UNIQUE (name), + CONSTRAINT chk__pools__sort_key + CHECK (sort_key IN ('manual', 'content_datetime', 'created', 'original_name')), + CONSTRAINT chk__pools__sort_order + CHECK (sort_order IN ('asc', 'desc')) ); -- `position` uses integer with gaps (e.g. 1000, 2000, 3000) to allow diff --git a/frontend/src/routes/pools/[id]/+page.svelte b/frontend/src/routes/pools/[id]/+page.svelte index b6f4d4d..0a2c1af 100644 --- a/frontend/src/routes/pools/[id]/+page.svelte +++ b/frontend/src/routes/pools/[id]/+page.svelte @@ -61,8 +61,15 @@ let addSelected = $state(new Set()); let addSearchPrev = $state(''); - // ---- Drag-to-reorder (disabled when filter active) ---- - let canReorder = $derived(!filterParam); + // ---- Sorting ---- + // The pool stores its own file sort. "manual" keeps the drag order; any other + // key sorts automatically (server-side) and disables reordering. + let sortKey = $derived(pool?.sort_key ?? 'manual'); + let sortOrder = $derived(pool?.sort_order ?? 'asc'); + let sortChanging = $state(false); + + // ---- Drag-to-reorder (only in manual order, and not while filtering) ---- + let canReorder = $derived(!filterParam && sortKey === 'manual'); let dragSrcIdx = $state(null); let dragOverIdx = $state(null); let reorderPending = $state(false); @@ -131,6 +138,27 @@ } } + // ---- Change the pool's file sort ---- + // Persist the new sort on the pool, then reload the files from the top so the + // new server-side order takes effect. A no-op if the setting hasn't changed. + async function changeSort(key: string, order: string) { + if (!pool || sortChanging) return; + if (key === sortKey && order === sortOrder) return; + sortChanging = true; + try { + pool = await api.patch(`/pools/${poolId}`, { sort_key: key, sort_order: order }); + files = []; + nextCursor = null; + hasMore = true; + filesError = ''; + await loadMore(); + } catch (e) { + filesError = e instanceof ApiError ? e.message : 'Failed to change sort'; + } finally { + sortChanging = false; + } + } + // ---- Save pool ---- async function save() { if (!name.trim() || saving) return; @@ -591,6 +619,32 @@ {#if pool?.file_count != null}({pool.file_count}){/if}
+
+ + {#if sortKey !== 'manual'} + + {/if} +
{#if canReorder && files.length > 1}