mirror of
https://github.com/gtsteffaniak/filebrowser.git
synced 2026-11-04 16:50:08 -05:00
Compare commits
2
Commits
main
...
v1.4.0-stable
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4072e46e44 | ||
|
|
38d141eb97 |
No files matched your search
@@ -89,7 +89,7 @@ func processDirectoryMetadata(response *iteminfo.ExtendedFileInfo, idx *indexing
|
||||
wg.Wait()
|
||||
}
|
||||
|
||||
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping)
|
||||
// finalizeResponse handles final response adjustments (OnlyOffice ID, scope stripping).
|
||||
func finalizeResponse(response *iteminfo.ExtendedFileInfo, info *iteminfo.FileInfo, realPath string, user *users.User, userScope string) {
|
||||
// Add OnlyOffice ID if applicable
|
||||
if settings.Config.Integrations.OnlyOffice.Secret != "" && info.Type != "directory" && iteminfo.IsOnlyOffice(info.Name) {
|
||||
@@ -279,6 +279,12 @@ func fileInfoFasterImpl(opts utils.FileOptions, access *access.Storage, user *us
|
||||
}
|
||||
response.IsShared = share.IsShared(response.Path, idx.Path, user.ID)
|
||||
}
|
||||
if info.Type == "directory" && opts.ShowPinnedItems {
|
||||
if source, ok := users.ResolveSourceKey(response.Source); ok {
|
||||
response.PinnedItems = user.PinnedNamesForDirectory(source.Path, response.Path)
|
||||
}
|
||||
}
|
||||
|
||||
defer finalizeResponse(response, info, response.RealPath, user, userScope)
|
||||
if opts.SkipExtendedAttrs {
|
||||
if info.Type == "directory" && opts.HideFileExt != "" {
|
||||
|
||||
@@ -57,6 +57,7 @@ type FileOptions struct {
|
||||
ExtractEmbeddedSubtitles bool // whether to extract embedded subtitles from media files
|
||||
AlbumArt bool // whether to get album art from media files
|
||||
ShowHidden bool // whether to show hidden files (true = show, false = hide)
|
||||
ShowPinnedItems bool // whether to show pinned items
|
||||
HideFileExt string // Hide files based on extensions (eg: '.lrc' or '.srt')
|
||||
FollowSymlinks bool // whether to follow symlinks
|
||||
Only string // whether to only get files or folders
|
||||
@@ -108,4 +109,4 @@ func HideFileByExt(filename, hideExt string) bool {
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
@@ -138,17 +138,8 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
}
|
||||
user.SidebarLinks = adjustedLinks
|
||||
}
|
||||
if slices.Contains(fields, "PinnedItems") {
|
||||
adjustedPinnedItems, err := user.GetBackendPinnedItems()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.PinnedItems = adjustedPinnedItems
|
||||
}
|
||||
// Use reflection to access struct fields
|
||||
userFields := reflect.ValueOf(user).Elem() // Get struct value
|
||||
existingUserFields := reflect.ValueOf(existingUser).Elem()
|
||||
needsUserSave := false
|
||||
for _, field := range fields {
|
||||
// Get the corresponding field using reflection
|
||||
fieldValue := userFields.FieldByName(field)
|
||||
@@ -171,27 +162,12 @@ func (st usersBackend) Update(user *users.User, actorIsAdmin bool, fields ...str
|
||||
}
|
||||
// If otpEnabled is true, continue with normal field update
|
||||
}
|
||||
existingFieldValue := existingUserFields.FieldByName(field)
|
||||
if existingFieldValue.IsValid() && existingFieldValue.CanSet() {
|
||||
existingFieldValue.Set(reflect.ValueOf(val))
|
||||
}
|
||||
if field == "PinnedItems" {
|
||||
existingUser.PinnedItems = user.PinnedItems
|
||||
needsUserSave = true
|
||||
continue
|
||||
}
|
||||
// Update the database
|
||||
if err := st.db.UpdateField(existingUser, field, val); err != nil {
|
||||
return fmt.Errorf("failed to update user field: %s, error: %v", field, err)
|
||||
}
|
||||
}
|
||||
|
||||
if needsUserSave {
|
||||
if err := st.db.Save(existingUser); err != nil {
|
||||
return fmt.Errorf("failed to save user with pinned items: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -119,64 +119,6 @@ func (u *User) GetBackendSidebarLinks() ([]SidebarLink, error) {
|
||||
return newLinks, nil
|
||||
}
|
||||
|
||||
// GetBackendPinnedItems converts pinned item source keys from frontend source names to backend source paths.
|
||||
func (u *User) GetBackendPinnedItems() (PinnedItems, error) {
|
||||
if len(u.PinnedItems) == 0 {
|
||||
return PinnedItems{}, nil
|
||||
}
|
||||
if sourceConfig == nil {
|
||||
return nil, fmt.Errorf("source config not initialized")
|
||||
}
|
||||
|
||||
newPinnedItems := PinnedItems{}
|
||||
for sourceKey, directories := range u.PinnedItems {
|
||||
source, ok := sourceConfig.GetSourceByPath(sourceKey)
|
||||
if !ok {
|
||||
source, ok = sourceConfig.GetSourceByName(sourceKey)
|
||||
}
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if newPinnedItems[source.Path] == nil {
|
||||
newPinnedItems[source.Path] = map[string][]string{}
|
||||
}
|
||||
|
||||
for directoryPath, pinnedPaths := range directories {
|
||||
copiedPaths := append([]string(nil), pinnedPaths...)
|
||||
newPinnedItems[source.Path][directoryPath] = copiedPaths
|
||||
}
|
||||
}
|
||||
|
||||
return newPinnedItems, nil
|
||||
}
|
||||
|
||||
// GetFrontendPinnedItems converts pinned item source keys from backend source paths to frontend source names.
|
||||
func (u *User) GetFrontendPinnedItems() PinnedItems {
|
||||
if len(u.PinnedItems) == 0 || sourceConfig == nil {
|
||||
return PinnedItems{}
|
||||
}
|
||||
|
||||
newPinnedItems := PinnedItems{}
|
||||
for sourceKey, directories := range u.PinnedItems {
|
||||
source, ok := sourceConfig.GetSourceByPath(sourceKey)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
if newPinnedItems[source.Name] == nil {
|
||||
newPinnedItems[source.Name] = map[string][]string{}
|
||||
}
|
||||
|
||||
for directoryPath, pinnedPaths := range directories {
|
||||
copiedPaths := append([]string(nil), pinnedPaths...)
|
||||
newPinnedItems[source.Name][directoryPath] = copiedPaths
|
||||
}
|
||||
}
|
||||
|
||||
return newPinnedItems
|
||||
}
|
||||
|
||||
// normalizeScope ensures scope starts with / and doesn't end with / (except for root)
|
||||
func normalizeScope(scope string) string {
|
||||
if !strings.HasPrefix(scope, "/") {
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package users
|
||||
|
||||
import "slices"
|
||||
|
||||
// Add records a pinned item name under sourcePath and indexDirPath.
|
||||
func (p PinnedItems) Add(sourcePath, indexDirPath, name string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
if p[sourcePath] == nil {
|
||||
p[sourcePath] = make(map[string][]string)
|
||||
}
|
||||
items := p[sourcePath][indexDirPath]
|
||||
if slices.Contains(items, name) {
|
||||
return
|
||||
}
|
||||
p[sourcePath][indexDirPath] = append(items, name)
|
||||
}
|
||||
|
||||
// Remove deletes a pinned item name and prunes empty map entries.
|
||||
func (p PinnedItems) Remove(sourcePath, indexDirPath, name string) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
byDir, ok := p[sourcePath]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
items := byDir[indexDirPath]
|
||||
idx := slices.Index(items, name)
|
||||
if idx < 0 {
|
||||
return
|
||||
}
|
||||
items = append(items[:idx], items[idx+1:]...)
|
||||
if len(items) == 0 {
|
||||
delete(byDir, indexDirPath)
|
||||
} else {
|
||||
byDir[indexDirPath] = items
|
||||
}
|
||||
if len(byDir) == 0 {
|
||||
delete(p, sourcePath)
|
||||
}
|
||||
}
|
||||
|
||||
// PinnedNamesForDirectory returns pinned item names for a source/index directory path.
|
||||
func (u *User) PinnedNamesForDirectory(sourcePath, indexDirPath string) []string {
|
||||
if u == nil || len(u.PinnedItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
byDir, ok := u.PinnedItems[sourcePath]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
names := byDir[indexDirPath]
|
||||
if len(names) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]string, len(names))
|
||||
copy(out, names)
|
||||
return out
|
||||
}
|
||||
|
||||
// EnsurePinnedItems returns a non-nil map for mutation.
|
||||
func (u *User) EnsurePinnedItems() PinnedItems {
|
||||
if u.PinnedItems == nil {
|
||||
u.PinnedItems = make(PinnedItems)
|
||||
}
|
||||
return u.PinnedItems
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
package users
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestPinnedItemsAddRemove(t *testing.T) {
|
||||
p := make(PinnedItems)
|
||||
const source = "/data/files"
|
||||
const dir = "/photos/"
|
||||
const name = "vacation.jpg"
|
||||
|
||||
p.Add(source, dir, name)
|
||||
if len(p[source][dir]) != 1 || p[source][dir][0] != name {
|
||||
t.Fatalf("add failed: %#v", p)
|
||||
}
|
||||
|
||||
p.Add(source, dir, name)
|
||||
if len(p[source][dir]) != 1 {
|
||||
t.Fatalf("duplicate add should be ignored: %#v", p)
|
||||
}
|
||||
|
||||
p.Remove(source, dir, name)
|
||||
if len(p) != 0 {
|
||||
t.Fatalf("remove should prune empty maps: %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedNamesForDirectory(t *testing.T) {
|
||||
u := &User{
|
||||
NonAdminEditable: NonAdminEditable{
|
||||
PinnedItems: PinnedItems{
|
||||
"/data/files": {
|
||||
"/photos/": {"vacation.jpg", "notes.txt"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
names := u.PinnedNamesForDirectory("/data/files", "/photos/")
|
||||
if len(names) != 2 || names[0] != "vacation.jpg" || names[1] != "notes.txt" {
|
||||
t.Fatalf("unexpected names: %#v", names)
|
||||
}
|
||||
if got := u.PinnedNamesForDirectory("/missing", "/photos/"); got != nil {
|
||||
t.Fatalf("expected nil for missing source, got %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedItemsRemoveMissing(t *testing.T) {
|
||||
p := make(PinnedItems)
|
||||
p.Remove("/data/files", "/photos/", "vacation.jpg")
|
||||
if len(p) != 0 {
|
||||
t.Fatalf("remove on empty map should be no-op: %#v", p)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedItemsMultipleItems(t *testing.T) {
|
||||
p := make(PinnedItems)
|
||||
const source = "/data/files"
|
||||
const dir = "/photos/"
|
||||
|
||||
p.Add(source, dir, "a.jpg")
|
||||
p.Add(source, dir, "b.jpg")
|
||||
p.Add(source, dir, "c.jpg")
|
||||
|
||||
if len(p[source][dir]) != 3 {
|
||||
t.Fatalf("expected 3 items, got %d", len(p[source][dir]))
|
||||
}
|
||||
|
||||
p.Remove(source, dir, "b.jpg")
|
||||
items := p[source][dir]
|
||||
if len(items) != 2 || items[0] != "a.jpg" || items[1] != "c.jpg" {
|
||||
t.Fatalf("unexpected items after remove: %#v", items)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPinnedItemsMultipleDirectories(t *testing.T) {
|
||||
p := make(PinnedItems)
|
||||
const source = "/data/files"
|
||||
const photosDir = "/photos/"
|
||||
const docsDir = "/docs/"
|
||||
|
||||
p.Add(source, photosDir, "a.jpg")
|
||||
p.Add(source, docsDir, "notes.txt")
|
||||
|
||||
if len(p[source][photosDir]) != 1 || p[source][photosDir][0] != "a.jpg" {
|
||||
t.Fatalf("unexpected photos dir: %#v", p[source][photosDir])
|
||||
}
|
||||
if len(p[source][docsDir]) != 1 || p[source][docsDir][0] != "notes.txt" {
|
||||
t.Fatalf("unexpected docs dir: %#v", p[source][docsDir])
|
||||
}
|
||||
|
||||
p.Remove(source, photosDir, "a.jpg")
|
||||
if _, ok := p[source][photosDir]; ok {
|
||||
t.Fatalf("photos dir should be pruned: %#v", p)
|
||||
}
|
||||
if len(p[source][docsDir]) != 1 {
|
||||
t.Fatalf("docs dir should remain: %#v", p[source][docsDir])
|
||||
}
|
||||
}
|
||||
@@ -96,26 +96,27 @@ type Preview struct {
|
||||
Models bool `json:"models"` // show live thumbnails for 3D models files
|
||||
}
|
||||
|
||||
// source filesystem path -> index directory path -> pinned item names (unique within the directory)
|
||||
type PinnedItems map[string]map[string][]string
|
||||
|
||||
// User describes a persisted account. Scopes and source sidebar links store filesystem paths in Name/SourceName;
|
||||
// JSON responses use display names only (see http prepForFrontend).
|
||||
type User struct {
|
||||
NonAdminEditable
|
||||
DisableSettings bool `json:"disableSettings"`
|
||||
ID uint `storm:"id,increment" json:"id"`
|
||||
Username string `storm:"unique" json:"username"`
|
||||
Scopes []SourceScope `json:"scopes"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
LockPassword bool `json:"lockPassword"`
|
||||
Permissions Permissions `json:"permissions"`
|
||||
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"` // deprecated: use Tokens instead
|
||||
Tokens map[string]AuthToken `json:"tokens,omitempty"`
|
||||
TOTPSecret string `json:"totpSecret,omitempty"`
|
||||
TOTPNonce string `json:"totpNonce,omitempty"`
|
||||
LoginMethod LoginMethod `json:"loginMethod"`
|
||||
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
|
||||
Version int `json:"version"`
|
||||
DisableSettings bool `json:"disableSettings"`
|
||||
ID uint `storm:"id,increment" json:"id"`
|
||||
Username string `storm:"unique" json:"username"`
|
||||
Scopes []SourceScope `json:"scopes"`
|
||||
Scope string `json:"scope,omitempty"`
|
||||
LockPassword bool `json:"lockPassword"`
|
||||
Permissions Permissions `json:"permissions"`
|
||||
ApiKeys map[string]AuthToken `json:"apiKeys,omitempty"` // deprecated: use Tokens instead
|
||||
Tokens map[string]AuthToken `json:"tokens,omitempty"`
|
||||
TOTPSecret string `json:"totpSecret,omitempty"`
|
||||
TOTPNonce string `json:"totpNonce,omitempty"`
|
||||
LoginMethod LoginMethod `json:"loginMethod"`
|
||||
OtpEnabled bool `json:"otpEnabled"` // true if TOTP is enabled, false otherwise
|
||||
Version int `json:"version"`
|
||||
// legacy for migration purposes... og filebrowser has perm attribute
|
||||
Perm Permissions `json:"perm,omitzero"` // deprecated: use Permissions instead
|
||||
}
|
||||
@@ -127,44 +128,44 @@ type SourceScope struct {
|
||||
|
||||
// json tags must match variable name with smaller case first letter
|
||||
type NonAdminEditable struct {
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
Preview Preview `json:"preview"`
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode bool `json:"darkMode"` // should dark mode be enabled
|
||||
Password string `json:"password,omitempty"`
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
Sorting Sorting `json:"sorting"`
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
HideFileExt string `json:"hideFileExt"` // space separated list of file extensions to hide in UI and API
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
|
||||
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
PinnedItems PinnedItems `json:"pinnedItems,omitempty"` // pinned items organized by source and directory path
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
|
||||
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
|
||||
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
|
||||
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
|
||||
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
|
||||
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
|
||||
ShowFirstLogin bool `json:"showFirstLogin"`
|
||||
PasskeyCredentials []WebAuthnCredential `json:"passkeyCredentials,omitempty"`
|
||||
EditorQuickSave bool `json:"editorQuickSave"` // show quick save button in editor
|
||||
HideSidebarFileActions bool `json:"hideSidebarFileActions"` // hide the file actions in the sidebar
|
||||
DisableQuickToggles bool `json:"disableQuickToggles"` // disable the quick toggles in the sidebar
|
||||
DisableSearchOptions bool `json:"disableSearchOptions"` // disable the search options in the search bar
|
||||
DeleteWithoutConfirming bool `json:"deleteWithoutConfirming"` // delete files without confirmation
|
||||
Preview Preview `json:"preview"`
|
||||
StickySidebar bool `json:"stickySidebar"` // keep sidebar open when navigating
|
||||
DarkMode bool `json:"darkMode"` // should dark mode be enabled
|
||||
Password string `json:"password,omitempty"`
|
||||
Locale string `json:"locale"` // language to use: eg. de, en, or fr
|
||||
ViewMode string `json:"viewMode"` // view mode to use: eg. normal, list, grid, or compact
|
||||
SingleClick bool `json:"singleClick"` // open directory on single click, also enables middle click to open in new tab
|
||||
Sorting Sorting `json:"sorting"`
|
||||
ShowHidden bool `json:"showHidden"` // show hidden files in the UI. On windows this includes files starting with a dot and windows hidden files
|
||||
HideFileExt string `json:"hideFileExt"` // space separated list of file extensions to hide in UI and API
|
||||
DateFormat bool `json:"dateFormat"` // when false, the date is relative, when true, the date is an exact timestamp
|
||||
GallerySize int `json:"gallerySize"` // 0-9 - the size of the gallery thumbnails
|
||||
ThemeColor string `json:"themeColor"` // theme color to use: eg. #ff0000, or var(--red), var(--purple), etc
|
||||
QuickDownload bool `json:"quickDownload"` // show icon to download in one click
|
||||
DisableUpdateNotifications bool `json:"disableUpdateNotifications"` // disable update notifications
|
||||
FileLoading FileLoading `json:"fileLoading"` // upload and download settings
|
||||
DisableOfficePreviewExt string `json:"disableOfficePreviewExt"` // deprecated
|
||||
DisableOnlyOfficeExt string `json:"disableOnlyOfficeExt"` // deprecated
|
||||
DisablePreviewExt string `json:"disablePreviewExt"` // space separated list of file extensions to disable preview for
|
||||
DisableViewingExt string `json:"disableViewingExt"` // space separated list of file extensions to disable viewing for
|
||||
CustomTheme string `json:"customTheme"` // Name of theme to use chosen from custom themes config.
|
||||
ShowSelectMultiple bool `json:"showSelectMultiple"` // show select multiple files on desktop
|
||||
ShowCopyPath bool `json:"showCopyPath"` // show copy path action in the context menu
|
||||
ShowToolsInSidebar bool `json:"showToolsInSidebar"` // when false, sidebar hides links with category "tool" (default: true)
|
||||
DebugOffice bool `json:"debugOffice"` // debug onlyoffice editor
|
||||
OtpEnabled bool `json:"otpEnabled"` // allow non-admin users to disable their own OTP
|
||||
SidebarLinks []SidebarLink `json:"sidebarLinks"` // customizable sidebar links
|
||||
HideFilesInTree bool `json:"hideFilesInTree"` // hide files in the sidebar tree navigation, when true, will show only directories.
|
||||
DeleteAfterArchive bool `json:"deleteAfterArchive"` // delete source files after successful creation/extraction of archives
|
||||
PreferEditorForMarkdown bool `json:"preferEditorForMarkdown"` // prefer editor first for markdown files instead of the Markdown Viewer
|
||||
ShowFirstLogin bool `json:"showFirstLogin"`
|
||||
PasskeyCredentials []WebAuthnCredential `json:"passkeyCredentials,omitempty"`
|
||||
PinnedItems PinnedItems `json:"pinnedItems,omitempty"`
|
||||
}
|
||||
|
||||
type FileLoading struct {
|
||||
|
||||
@@ -91,6 +91,7 @@ func StartHttp(ctx context.Context, storage *bolt.BoltStore, shutdownComplete ch
|
||||
api.HandleFunc("GET /users", withUser(userGetHandler))
|
||||
api.HandleFunc("POST /users", withSelfOrAdmin(usersPostHandler))
|
||||
api.HandleFunc("PUT /users", withUser(userPutHandler))
|
||||
api.HandleFunc("PATCH /users/pinnedItems", withUser(userPatchPinnedItemsHandler))
|
||||
api.HandleFunc("DELETE /users", withSelfOrAdmin(userDeleteHandler))
|
||||
publicApi.HandleFunc("GET /users", withUser(userGetHandler))
|
||||
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"github.com/gtsteffaniak/filebrowser/backend/common/utils"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/database/users"
|
||||
"github.com/gtsteffaniak/filebrowser/backend/indexing"
|
||||
)
|
||||
|
||||
// pinnedItemPatchRequest is the JSON body for PATCH /api/users/pinnedItems.
|
||||
type pinnedItemPatchRequest struct {
|
||||
Source string `json:"source" validate:"required"`
|
||||
Path string `json:"path" validate:"required"` // scope-relative parent directory
|
||||
Name string `json:"name" validate:"required"` // item basename within path
|
||||
}
|
||||
|
||||
// scopeRelativeDirToIndexPath maps a scope-relative directory to source and index paths.
|
||||
func scopeRelativeDirToIndexPath(sourceName, userScope, directoryPath string) (sourcePath, indexDirPath string, err error) {
|
||||
userScope = strings.TrimRight(userScope, "/")
|
||||
source, ok := users.ResolveSourceKey(sourceName)
|
||||
if !ok {
|
||||
return "", "", fmt.Errorf("source not found: %s", sourceName)
|
||||
}
|
||||
idx := indexing.GetIndex(source.Name)
|
||||
if idx == nil {
|
||||
return "", "", fmt.Errorf("index not found for source %s", source.Name)
|
||||
}
|
||||
|
||||
cleanDir, err := utils.SanitizeUserPath(directoryPath)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("invalid path: %w", err)
|
||||
}
|
||||
|
||||
fullDirPath := utils.JoinPathAsUnix(userScope, cleanDir)
|
||||
return source.Path, idx.MakeIndexPath(fullDirPath, true), nil
|
||||
}
|
||||
|
||||
// pinnedItemAction returns the patch action query param, defaulting to "add".
|
||||
func pinnedItemAction(r *http.Request) string {
|
||||
action := strings.ToLower(strings.TrimSpace(r.URL.Query().Get("action")))
|
||||
if action == "" {
|
||||
return "add"
|
||||
}
|
||||
return action
|
||||
}
|
||||
|
||||
// userPatchPinnedItemsHandler adds or removes a single pinned item for the authenticated user.
|
||||
// @Summary Add or remove a pinned item
|
||||
// @Description Patches one pinned item at a time. Defaults to add; pass ?action=remove to unpin.
|
||||
// @Tags Users
|
||||
// @Accept json
|
||||
// @Produce json
|
||||
// @Param action query string false "add (default) or remove"
|
||||
// @Param body body pinnedItemPatchRequest true "Pinned item"
|
||||
// @Success 204 "No Content"
|
||||
// @Failure 400 {object} map[string]string "Bad Request"
|
||||
// @Failure 403 {object} map[string]string "Forbidden"
|
||||
// @Failure 500 {object} map[string]string "Internal Server Error"
|
||||
// @Router /api/users/pinnedItems [patch]
|
||||
func userPatchPinnedItemsHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (int, error) {
|
||||
var body pinnedItemPatchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
return http.StatusBadRequest, fmt.Errorf("failed to decode body: %w", err)
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
action := pinnedItemAction(r)
|
||||
if action != "add" && action != "remove" {
|
||||
return http.StatusBadRequest, fmt.Errorf("action must be add or remove")
|
||||
}
|
||||
|
||||
if body.Source == "" || body.Path == "" || body.Name == "" {
|
||||
return http.StatusBadRequest, fmt.Errorf("source, path, and name are required")
|
||||
}
|
||||
cleanPath, err := utils.SanitizeUserPath(body.Path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
body.Path = cleanPath
|
||||
|
||||
cleanName, err := utils.SanitizeUserPath(body.Name)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
body.Name = cleanName
|
||||
|
||||
source, ok := users.ResolveSourceKey(body.Source)
|
||||
if !ok {
|
||||
return http.StatusBadRequest, fmt.Errorf("source not found: %s", body.Source)
|
||||
}
|
||||
if !d.user.HasSourceByPath(source.Path) {
|
||||
return http.StatusForbidden, fmt.Errorf("access denied to source %s", body.Source)
|
||||
}
|
||||
|
||||
userScope, err := d.user.GetScopeForSourcePath(source.Path)
|
||||
if err != nil {
|
||||
return http.StatusForbidden, err
|
||||
}
|
||||
|
||||
sourcePath, indexDirPath, err := scopeRelativeDirToIndexPath(body.Source, userScope, body.Path)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
}
|
||||
|
||||
u, err := store.Users.Get(d.user.ID)
|
||||
if err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
pinned := u.EnsurePinnedItems()
|
||||
switch action {
|
||||
case "add":
|
||||
pinned.Add(sourcePath, indexDirPath, body.Name)
|
||||
case "remove":
|
||||
pinned.Remove(sourcePath, indexDirPath, body.Name)
|
||||
}
|
||||
|
||||
if err := store.Users.Update(u, d.user.Permissions.Admin, "PinnedItems"); err != nil {
|
||||
return http.StatusInternalServerError, err
|
||||
}
|
||||
|
||||
return http.StatusNoContent, nil
|
||||
}
|
||||
@@ -103,6 +103,7 @@ func resourceGetHandler(w http.ResponseWriter, r *http.Request, d *requestContex
|
||||
HideFileExt: d.user.HideFileExt,
|
||||
SkipExtendedAttrs: skipExtendedAttrs,
|
||||
ShowSharedAttr: true,
|
||||
ShowPinnedItems: true,
|
||||
}, store.Access, d.user, store.Share)
|
||||
if err != nil {
|
||||
return errToStatus(err), err
|
||||
|
||||
+1
-17
@@ -99,8 +99,8 @@ func prepForFrontend(u *users.User) {
|
||||
u.TOTPSecret = ""
|
||||
u.TOTPNonce = ""
|
||||
u.Scopes = u.GetFrontendScopes()
|
||||
u.PinnedItems = u.GetFrontendPinnedItems()
|
||||
u.SidebarLinks = GetFrontendSidebarLinks(u.SidebarLinks, u.ShowToolsInSidebar)
|
||||
u.PinnedItems = nil
|
||||
u.Locale = normalizeLocale(u.Locale)
|
||||
for i := range u.PasskeyCredentials {
|
||||
u.PasskeyCredentials[i].PublicKey = ""
|
||||
@@ -356,10 +356,6 @@ func userPutOnlyNonAdminEditableFields(which []string) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
func userPutOnlyPinnedItems(which []string) bool {
|
||||
return len(which) == 1 && strings.EqualFold(strings.TrimSpace(which[0]), "PinnedItems")
|
||||
}
|
||||
|
||||
// verifyActorPasswordForUserPut requires URL-encoded X-Password when the authenticated actor uses
|
||||
// password login. Callers should invoke this only when the update requires re-authentication.
|
||||
func verifyActorPasswordForUserActions(r *http.Request, d *requestContext) (int, error) {
|
||||
@@ -458,18 +454,6 @@ func userPutHandler(w http.ResponseWriter, r *http.Request, d *requestContext) (
|
||||
}
|
||||
}
|
||||
|
||||
if userPutOnlyPinnedItems(req.Which) {
|
||||
adjustedPinnedItems, err2 := req.User.GetBackendPinnedItems()
|
||||
if err2 != nil {
|
||||
return http.StatusBadRequest, err2
|
||||
}
|
||||
oldUser.PinnedItems = adjustedPinnedItems
|
||||
if err2 := store.Users.Save(oldUser, false, false); err2 != nil {
|
||||
return http.StatusBadRequest, err2
|
||||
}
|
||||
return http.StatusNoContent, nil
|
||||
}
|
||||
|
||||
err = store.Users.Update(&req.User, d.user.Permissions.Admin, req.Which...)
|
||||
if err != nil {
|
||||
return http.StatusBadRequest, err
|
||||
|
||||
@@ -67,4 +67,5 @@ type ExtendedFileInfo struct {
|
||||
Source string `json:"source,omitempty"` // associated index source for the file
|
||||
Hash string `json:"hash,omitempty"` // hash for the file -- used for sharing
|
||||
RealPath string `json:"-"`
|
||||
PinnedItems []string `json:"pinnedItems,omitempty"` // pinned item names in this directory listing
|
||||
}
|
||||
@@ -3600,6 +3600,70 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/users/pinnedItems": {
|
||||
"patch": {
|
||||
"description": "Patches one pinned item at a time. Defaults to add; pass ?action=remove to unpin.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Add or remove a pinned item",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "add (default) or remove",
|
||||
"name": "action",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"description": "Pinned item",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.pinnedItemPatchRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"description": "Returns the health status of the API.",
|
||||
@@ -5034,6 +5098,27 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.pinnedItemPatchRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"path",
|
||||
"source"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "item basename within path",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "scope-relative parent directory",
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.unarchiveRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5150,6 +5235,13 @@ const docTemplate = `{
|
||||
"description": "path scoped to the associated index",
|
||||
"type": "string"
|
||||
},
|
||||
"pinnedItems": {
|
||||
"description": "pinned item names in this directory listing",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"size": {
|
||||
"description": "length in bytes for regular files",
|
||||
"type": "integer"
|
||||
@@ -7590,12 +7682,7 @@ const docTemplate = `{
|
||||
"$ref": "#/definitions/users.Permissions"
|
||||
},
|
||||
"pinnedItems": {
|
||||
"description": "pinned items organized by source and directory path",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/users.PinnedItems"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/users.PinnedItems"
|
||||
},
|
||||
"preferEditorForMarkdown": {
|
||||
"description": "prefer editor first for markdown files instead of the Markdown Viewer",
|
||||
|
||||
@@ -3589,6 +3589,70 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/users/pinnedItems": {
|
||||
"patch": {
|
||||
"description": "Patches one pinned item at a time. Defaults to add; pass ?action=remove to unpin.",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"tags": [
|
||||
"Users"
|
||||
],
|
||||
"summary": "Add or remove a pinned item",
|
||||
"parameters": [
|
||||
{
|
||||
"type": "string",
|
||||
"description": "add (default) or remove",
|
||||
"name": "action",
|
||||
"in": "query"
|
||||
},
|
||||
{
|
||||
"description": "Pinned item",
|
||||
"name": "body",
|
||||
"in": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"$ref": "#/definitions/http.pinnedItemPatchRequest"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": "No Content"
|
||||
},
|
||||
"400": {
|
||||
"description": "Bad Request",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"403": {
|
||||
"description": "Forbidden",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"500": {
|
||||
"description": "Internal Server Error",
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"description": "Returns the health status of the API.",
|
||||
@@ -5023,6 +5087,27 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.pinnedItemPatchRequest": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"name",
|
||||
"path",
|
||||
"source"
|
||||
],
|
||||
"properties": {
|
||||
"name": {
|
||||
"description": "item basename within path",
|
||||
"type": "string"
|
||||
},
|
||||
"path": {
|
||||
"description": "scope-relative parent directory",
|
||||
"type": "string"
|
||||
},
|
||||
"source": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"http.unarchiveRequest": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -5139,6 +5224,13 @@
|
||||
"description": "path scoped to the associated index",
|
||||
"type": "string"
|
||||
},
|
||||
"pinnedItems": {
|
||||
"description": "pinned item names in this directory listing",
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"size": {
|
||||
"description": "length in bytes for regular files",
|
||||
"type": "integer"
|
||||
@@ -7579,12 +7671,7 @@
|
||||
"$ref": "#/definitions/users.Permissions"
|
||||
},
|
||||
"pinnedItems": {
|
||||
"description": "pinned items organized by source and directory path",
|
||||
"allOf": [
|
||||
{
|
||||
"$ref": "#/definitions/users.PinnedItems"
|
||||
}
|
||||
]
|
||||
"$ref": "#/definitions/users.PinnedItems"
|
||||
},
|
||||
"preferEditorForMarkdown": {
|
||||
"description": "prefer editor first for markdown files instead of the Markdown Viewer",
|
||||
|
||||
@@ -324,6 +324,21 @@ definitions:
|
||||
- $ref: '#/definitions/http.fileWatchMetadata'
|
||||
description: File metadata for non-text files
|
||||
type: object
|
||||
http.pinnedItemPatchRequest:
|
||||
properties:
|
||||
name:
|
||||
description: item basename within path
|
||||
type: string
|
||||
path:
|
||||
description: scope-relative parent directory
|
||||
type: string
|
||||
source:
|
||||
type: string
|
||||
required:
|
||||
- name
|
||||
- path
|
||||
- source
|
||||
type: object
|
||||
http.unarchiveRequest:
|
||||
properties:
|
||||
deleteAfter:
|
||||
@@ -410,6 +425,11 @@ definitions:
|
||||
path:
|
||||
description: path scoped to the associated index
|
||||
type: string
|
||||
pinnedItems:
|
||||
description: pinned item names in this directory listing
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
size:
|
||||
description: length in bytes for regular files
|
||||
type: integer
|
||||
@@ -2236,9 +2256,7 @@ definitions:
|
||||
permissions:
|
||||
$ref: '#/definitions/users.Permissions'
|
||||
pinnedItems:
|
||||
allOf:
|
||||
- $ref: '#/definitions/users.PinnedItems'
|
||||
description: pinned items organized by source and directory path
|
||||
$ref: '#/definitions/users.PinnedItems'
|
||||
preferEditorForMarkdown:
|
||||
description: prefer editor first for markdown files instead of the Markdown
|
||||
Viewer
|
||||
@@ -4858,6 +4876,49 @@ paths:
|
||||
summary: Update a user's details
|
||||
tags:
|
||||
- Users
|
||||
/api/users/pinnedItems:
|
||||
patch:
|
||||
consumes:
|
||||
- application/json
|
||||
description: Patches one pinned item at a time. Defaults to add; pass ?action=remove
|
||||
to unpin.
|
||||
parameters:
|
||||
- description: add (default) or remove
|
||||
in: query
|
||||
name: action
|
||||
type: string
|
||||
- description: Pinned item
|
||||
in: body
|
||||
name: body
|
||||
required: true
|
||||
schema:
|
||||
$ref: '#/definitions/http.pinnedItemPatchRequest'
|
||||
produces:
|
||||
- application/json
|
||||
responses:
|
||||
"204":
|
||||
description: No Content
|
||||
"400":
|
||||
description: Bad Request
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"403":
|
||||
description: Forbidden
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
"500":
|
||||
description: Internal Server Error
|
||||
schema:
|
||||
additionalProperties:
|
||||
type: string
|
||||
type: object
|
||||
summary: Add or remove a pinned item
|
||||
tags:
|
||||
- Users
|
||||
/health:
|
||||
get:
|
||||
consumes:
|
||||
|
||||
+25
-21
@@ -14,29 +14,33 @@ export async function login(username, password, recaptcha, otp) {
|
||||
password = ''
|
||||
}
|
||||
|
||||
const params = { username, recaptcha };
|
||||
const apiPath = getApiPath('auth/login', params);
|
||||
const res = await fetch(apiPath, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Password': encodeURIComponent(password),
|
||||
'X-Secret': otp,
|
||||
}
|
||||
});
|
||||
|
||||
const bodyText = await res.text();
|
||||
let body;
|
||||
|
||||
try {
|
||||
body = JSON.parse(bodyText);
|
||||
} catch {
|
||||
body = { message: bodyText };
|
||||
}
|
||||
const params = { username, recaptcha };
|
||||
const apiPath = getApiPath('auth/login', params);
|
||||
const res = await fetch(apiPath, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: {
|
||||
'X-Password': encodeURIComponent(password),
|
||||
'X-Secret': otp,
|
||||
}
|
||||
});
|
||||
|
||||
if (res.status !== 200) {
|
||||
const msg = body.message || 'Forbidden';
|
||||
throw new Error(msg);
|
||||
const bodyText = await res.text();
|
||||
let body;
|
||||
|
||||
try {
|
||||
body = JSON.parse(bodyText);
|
||||
} catch {
|
||||
body = { message: bodyText };
|
||||
}
|
||||
|
||||
if (res.status !== 200) {
|
||||
const msg = body.message || 'Forbidden';
|
||||
throw new Error(msg);
|
||||
}
|
||||
} catch (err) {
|
||||
throw err instanceof Error ? err : new Error(String(err));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -166,6 +166,16 @@ export async function update(user, which = ['all'], options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
// PATCH /api/users/pinnedItems (add by default; ?action=remove to unpin)
|
||||
export async function patchPinnedItem({ source, path, name, action = 'add' }) {
|
||||
const params = { action }
|
||||
const apiPath = getApiPath('users/pinnedItems', params)
|
||||
await fetchURL(apiPath, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ source, path, name }),
|
||||
})
|
||||
}
|
||||
|
||||
// DELETE /api/users (remove user)
|
||||
// Password-login: tries without X-Password first; on 401 requiring X-Password, opens the prompt and retries.
|
||||
// options.skipActorPasswordConfirm / pre-set X-Password skip that flow.
|
||||
|
||||
@@ -52,6 +52,7 @@ export async function fetchJSON(url, opts) {
|
||||
|
||||
export function adjustedData(data) {
|
||||
if (data.type === "directory") {
|
||||
const pinnedNames = new Set(data.pinnedItems || []);
|
||||
// Combine folders and files into items
|
||||
data.items = [...(data.folders || []), ...(data.files || [])];
|
||||
data.items = data.items.map((item) => {
|
||||
@@ -59,6 +60,7 @@ export function adjustedData(data) {
|
||||
if (item.isShared === undefined) {
|
||||
item.isShared = false;
|
||||
}
|
||||
item.pinned = pinnedNames.has(item.name);
|
||||
if (data.path === "/") {
|
||||
if (item.type === "directory") {
|
||||
item.path = `/${item.name}/`
|
||||
@@ -74,6 +76,7 @@ export function adjustedData(data) {
|
||||
}
|
||||
return item;
|
||||
});
|
||||
delete data.pinnedItems;
|
||||
}
|
||||
if (data.files) {
|
||||
data.files = []
|
||||
|
||||
@@ -54,10 +54,10 @@ describe('adjustedData', () => {
|
||||
folders: [],
|
||||
files: [],
|
||||
items: [
|
||||
{ isShared: false, name: "folder1", path: "/root/folder1/", source: undefined, type: "directory" },
|
||||
{ isShared: false, name: "folder2", path: "/root/folder2/", source: undefined, type: "directory" },
|
||||
{ isShared: false, name: "file1.txt", path: "/root/file1.txt", source: undefined, type: "file" },
|
||||
{ isShared: false, name: "file2.txt", path: "/root/file2.txt", source: undefined, type: "file" },
|
||||
{ isShared: false, name: "folder1", path: "/root/folder1/", source: undefined, type: "directory", pinned: false },
|
||||
{ isShared: false, name: "folder2", path: "/root/folder2/", source: undefined, type: "directory", pinned: false },
|
||||
{ isShared: false, name: "file1.txt", path: "/root/file1.txt", source: undefined, type: "file", pinned: false },
|
||||
{ isShared: false, name: "file2.txt", path: "/root/file2.txt", source: undefined, type: "file", pinned: false },
|
||||
],
|
||||
path: "/root/",
|
||||
};
|
||||
@@ -105,5 +105,28 @@ describe('adjustedData', () => {
|
||||
expect(adjustedData(input)).toEqual(expected);
|
||||
});
|
||||
|
||||
it('marks items as pinned from directory pinnedItems names', () => {
|
||||
const input = {
|
||||
type: "directory",
|
||||
path: "/",
|
||||
pinnedItems: ["alpha.txt"],
|
||||
folders: [{ name: "docs", type: "directory" }],
|
||||
files: [
|
||||
{ name: "alpha.txt", type: "file" },
|
||||
{ name: "beta.txt", type: "file" },
|
||||
],
|
||||
source: "Docs",
|
||||
};
|
||||
|
||||
const result = adjustedData(input);
|
||||
|
||||
expect(result.pinnedItems).toBeUndefined();
|
||||
expect(result.items).toEqual([
|
||||
{ isShared: false, name: "docs", path: "/docs/", source: "Docs", type: "directory", pinned: false },
|
||||
{ isShared: false, name: "alpha.txt", path: "/alpha.txt", source: "Docs", type: "file", pinned: true },
|
||||
{ isShared: false, name: "beta.txt", path: "/beta.txt", source: "Docs", type: "file", pinned: false },
|
||||
]);
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
@@ -195,7 +195,7 @@
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { resourcesApi } from "@/api";
|
||||
import { resourcesApi, usersApi } from "@/api";
|
||||
import Action from "@/components/Action.vue";
|
||||
import { notify } from "@/notify";
|
||||
import { getters, mutations, state } from "@/store";
|
||||
@@ -343,10 +343,7 @@ export default {
|
||||
return this.isPinnedSelection ? this.$t("buttons.unpinItem") : this.$t("buttons.pinItem");
|
||||
},
|
||||
isPinnedSelection() {
|
||||
if (!this.firstSelected) {
|
||||
return false;
|
||||
}
|
||||
return getters.isItemPinned(this.firstSelected);
|
||||
return !!this.firstSelected?.pinned;
|
||||
},
|
||||
showCopy() {
|
||||
if (this.showLimitedOptions) return false;
|
||||
@@ -724,9 +721,31 @@ export default {
|
||||
},
|
||||
});
|
||||
},
|
||||
togglePin() {
|
||||
async togglePin() {
|
||||
const item = this.firstSelected;
|
||||
if (!item?.name || getters.isShare() || !getters.isLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
mutations.closeHovers();
|
||||
mutations.togglePinnedItem(this.firstSelected);
|
||||
const source = item.source || state.req?.source || state.sources.current;
|
||||
if (!source) {
|
||||
return;
|
||||
}
|
||||
const directoryPath = state.req?.type === "directory"
|
||||
? state.req.path
|
||||
: url.getParentDir(item.path);
|
||||
const action = item.pinned ? "remove" : "add";
|
||||
try {
|
||||
await usersApi.patchPinnedItem({
|
||||
source,
|
||||
path: directoryPath,
|
||||
name: item.name,
|
||||
action,
|
||||
});
|
||||
mutations.setReload(true);
|
||||
} catch (e) {
|
||||
notify.showError(e);
|
||||
}
|
||||
},
|
||||
async copyPathToClipboard() {
|
||||
const item = this.firstSelected;
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
:forceFilesApi="!!browseSource"
|
||||
:showLimitedOptions="true"
|
||||
:inlinePin="true"
|
||||
:pinned="item.pinned"
|
||||
@click.prevent="(event) => handleItemClick(item, index, event)"
|
||||
@dblclick.prevent="(event) => handleItemDblClick(item, index, event)"
|
||||
/>
|
||||
@@ -292,20 +293,6 @@ export default {
|
||||
// Fetch files for the share
|
||||
this.withLoading(() => resourcesApi.fetchFilesPublic("/", newHash).then(this.fillOptions));
|
||||
},
|
||||
// Sort items to make pinned items appear on top always (it was working before, but just for the first load)
|
||||
// after navigate the pinned was lost, this will also keep our sorting preference in all prompts that use FileList
|
||||
sortItemsWithPinned(items, source, path) {
|
||||
if (!items?.length) return items;
|
||||
const parentEntry = items.find(i => i.name === "..");
|
||||
const rest = items.filter(i => i.name !== "..");
|
||||
const dirs = rest.filter(i => i.type === 'directory');
|
||||
const files = rest.filter(i => i.type !== 'directory');
|
||||
const pinnedPaths = getters.pinnedPathsFor(source, path);
|
||||
const sorting = getters.sorting();
|
||||
const sortedDirs = sortedItems(dirs, sorting.by, sorting.asc, pinnedPaths);
|
||||
const sortedFiles = sortedItems(files, sorting.by, sorting.asc, pinnedPaths);
|
||||
return parentEntry ? [parentEntry, ...sortedDirs, ...sortedFiles] : [...sortedDirs, ...sortedFiles];
|
||||
},
|
||||
fillOptions(req) {
|
||||
// Sets the current path and resets the current items.
|
||||
// Use this.path (the path we're browsing) instead of req.path (which may be relative)
|
||||
@@ -321,34 +308,38 @@ export default {
|
||||
isValid: !this.requireFileSelection, // Folder is only valid if file is not required
|
||||
});
|
||||
|
||||
// If the path isn't the root path,
|
||||
// show a button to navigate to the previous
|
||||
// directory (unless we are only displaying files).
|
||||
const entries = [];
|
||||
if (req.items && Array.isArray(req.items)) {
|
||||
for (const item of req.items) {
|
||||
if (!this.showFolders && item.type === "directory") continue;
|
||||
if (!this.showFiles && item.type !== "directory") continue;
|
||||
if (item.type !== "directory" && !this.isFileTypeAllowed(item.type)) continue;
|
||||
entries.push({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
source: item.source || req.source,
|
||||
type: item.type,
|
||||
pinned: !!item.pinned,
|
||||
originalItem: item,
|
||||
});
|
||||
}
|
||||
const sorting = getters.sorting();
|
||||
const dirs = entries.filter((item) => item.type === "directory");
|
||||
const files = entries.filter((item) => item.type !== "directory");
|
||||
this.items = [
|
||||
...sortedItems(dirs, sorting.by, sorting.asc),
|
||||
...sortedItems(files, sorting.by, sorting.asc),
|
||||
];
|
||||
}
|
||||
|
||||
if (this.path !== "/" && this.showFolders) {
|
||||
this.items.push({
|
||||
this.items.unshift({
|
||||
name: "..",
|
||||
path: `${url.removeLastDir(this.path)}/`,
|
||||
source: this.source,
|
||||
type: "directory",
|
||||
});
|
||||
}
|
||||
|
||||
// If this folder has no items or items is not an array, finish here.
|
||||
if (!req.items || !Array.isArray(req.items)) return;
|
||||
for (const item of req.items) {
|
||||
if (!this.showFolders && item.type === "directory") continue;
|
||||
if (!this.showFiles && item.type !== "directory") continue;
|
||||
// Filter by file type if specified (only for files, not directories)
|
||||
if (item.type !== "directory" && !this.isFileTypeAllowed(item.type)) continue;
|
||||
this.items.push({
|
||||
name: item.name,
|
||||
path: item.path,
|
||||
source: item.source || req.source,
|
||||
type: item.type, // Store type for file selection
|
||||
originalItem: item, // Store original item for Icon component
|
||||
});
|
||||
}
|
||||
this.items = this.sortItemsWithPinned(this.items, this.source, this.path);
|
||||
},
|
||||
next: function (event) {
|
||||
// Retrieves the URL of the directory the user
|
||||
|
||||
@@ -657,7 +657,7 @@ export default {
|
||||
|
||||
// Compare node to each candidate (usually only one, but safe to loop)
|
||||
return candidates.some(selected => {
|
||||
if (!selected?.path) return false;
|
||||
if (!selected.path) return false;
|
||||
if (this.isShare) {
|
||||
return selected.path === node.path;
|
||||
} else {
|
||||
|
||||
@@ -373,7 +373,8 @@ export default {
|
||||
|
||||
// Preload the next frame (cancellable fetch warms HTTP cache)
|
||||
const nextIndex = (index + 1) % sequence.length;
|
||||
const frameUrl = sequence[nextIndex];
|
||||
const frameUrl = sequence.at(nextIndex);
|
||||
if (!frameUrl) return;
|
||||
const preloadController = new AbortController();
|
||||
this.preloadAbortControllers.push(preloadController);
|
||||
fetchPreviewImage(frameUrl, preloadController.signal)
|
||||
|
||||
@@ -217,6 +217,10 @@ export default {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
pinned: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
},
|
||||
computed: {
|
||||
displayName() {
|
||||
@@ -321,10 +325,7 @@ export default {
|
||||
return this.clickable;
|
||||
},
|
||||
isPinned() {
|
||||
return getters.isItemPinned({
|
||||
path: this.path,
|
||||
source: this.source,
|
||||
});
|
||||
return this.pinned;
|
||||
},
|
||||
// Computed properties for display values - Vue caches these automatically!
|
||||
humanSize() {
|
||||
|
||||
@@ -111,6 +111,7 @@ import Unarchive from "./Unarchive.vue";
|
||||
import OfficeDebug from "./OfficeDebug.vue";
|
||||
import ThreeJSControls from "./ThreeJSControls.vue";
|
||||
import { state, getters, mutations } from "@/store";
|
||||
import { getObjectProperty, omitObjectProperty, setObjectProperty } from "@/utils/object.js";
|
||||
|
||||
export default {
|
||||
name: "Prompts",
|
||||
@@ -396,7 +397,7 @@ export default {
|
||||
cleanupDragState(id) {
|
||||
delete this.dragOffsets[id];
|
||||
delete this.dragStarts[id];
|
||||
delete this.touchIds[id];
|
||||
this.touchIds = omitObjectProperty(this.touchIds, id);
|
||||
this.draggingIds.delete(id);
|
||||
delete this.sizes[id];
|
||||
},
|
||||
@@ -499,7 +500,10 @@ export default {
|
||||
this.makeTopPrompt(id);
|
||||
if (type === "mouse" && e.button !== 0) return;
|
||||
if (type === "touch") {
|
||||
this.touchIds[id] = e.changedTouches?.[0]?.identifier;
|
||||
const touchId = e.changedTouches?.[0]?.identifier;
|
||||
if (touchId !== undefined) {
|
||||
this.touchIds = setObjectProperty(this.touchIds, id, touchId);
|
||||
}
|
||||
}
|
||||
|
||||
const pos = this.getPointerPos(e, type);
|
||||
@@ -526,7 +530,7 @@ export default {
|
||||
let pos;
|
||||
if (type === "touch") {
|
||||
if (!e.touches) return;
|
||||
const t = Array.from(e.touches).find((touch) => touch.identifier === this.touchIds[id]);
|
||||
const t = Array.from(e.touches).find((touch) => touch.identifier === getObjectProperty(this.touchIds, id));
|
||||
if (!t) return;
|
||||
e.preventDefault();
|
||||
pos = { x: t.clientX, y: t.clientY };
|
||||
@@ -546,7 +550,7 @@ export default {
|
||||
},
|
||||
onPointerEnd(_e, id, type, moveHandler, endHandler) {
|
||||
if (type === "touch") {
|
||||
delete this.touchIds[id];
|
||||
this.touchIds = omitObjectProperty(this.touchIds, id);
|
||||
window.removeEventListener("touchmove", moveHandler);
|
||||
window.removeEventListener("touchend", endHandler);
|
||||
window.removeEventListener("touchcancel", endHandler);
|
||||
|
||||
@@ -79,6 +79,7 @@
|
||||
|
||||
<script>
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import { getObjectProperty } from "@/utils/object.js";
|
||||
|
||||
function defaultCompare(av, bv) {
|
||||
if (av === bv) {
|
||||
@@ -94,13 +95,17 @@ function cellValueLookup(row, k) {
|
||||
if (!row || k === null) {
|
||||
return undefined;
|
||||
}
|
||||
if (typeof row[k] !== "undefined") {
|
||||
return row[k];
|
||||
const direct = getObjectProperty(row, k);
|
||||
if (direct !== undefined) {
|
||||
return direct;
|
||||
}
|
||||
if (typeof k === "string" && k.indexOf(".") !== -1) {
|
||||
let cur = row;
|
||||
for (const segment of k.split(".")) {
|
||||
cur = cur === null ? cur : cur[segment];
|
||||
if (cur === null) {
|
||||
return cur;
|
||||
}
|
||||
cur = getObjectProperty(cur, segment);
|
||||
}
|
||||
return cur;
|
||||
}
|
||||
|
||||
@@ -5,7 +5,8 @@ import { globalVars, previewViews, tools } from '@/utils/constants';
|
||||
import { getFileExtension } from '@/utils/files.js';
|
||||
import { getTypeInfo } from '@/utils/mimetype';
|
||||
import { fromNow } from '@/utils/moment';
|
||||
import { buildItemUrl, removeLeadingSlash, removePrefix, getParentDir } from '@/utils/url.js';
|
||||
import { getNestedProperty, getObjectProperty } from '@/utils/object.js';
|
||||
import { buildItemUrl, removeLeadingSlash, removePrefix } from '@/utils/url.js';
|
||||
|
||||
export const getters = {
|
||||
displayPreferenceFor: (source, path) => {
|
||||
@@ -13,7 +14,7 @@ export const getters = {
|
||||
if (!sourceKey || !path) {
|
||||
return null;
|
||||
}
|
||||
return state.displayPreferences?.[sourceKey]?.[path] || null;
|
||||
return getNestedProperty(state.displayPreferences, sourceKey, path) || null;
|
||||
},
|
||||
eventTheme: () => {
|
||||
if (getters.isShare()) {
|
||||
@@ -69,23 +70,6 @@ export const getters = {
|
||||
|
||||
return getters.displayPreferenceFor(source, path);
|
||||
},
|
||||
pinnedPathsFor: (source, path) => {
|
||||
if (getters.isShare() || !getters.isLoggedIn()) {
|
||||
return [];
|
||||
}
|
||||
const sourceKey = source || state.sources.current || state.req?.source || "";
|
||||
const directoryPath = path && path !== "/" ? url.removeTrailingSlash(path) : "/";
|
||||
const pinnedPaths = state.user?.pinnedItems?.[sourceKey]?.[directoryPath];
|
||||
return Array.isArray(pinnedPaths) ? pinnedPaths : [];
|
||||
},
|
||||
isItemPinned: (item) => {
|
||||
if (!item?.path || getters.isShare() || !getters.isLoggedIn()) {
|
||||
return false;
|
||||
}
|
||||
const directoryPath = getParentDir(item.path);
|
||||
const source = item.source || state.req?.source || state.sources.current;
|
||||
return getters.pinnedPathsFor(source, directoryPath).includes(item.path);
|
||||
},
|
||||
viewMode: () => {
|
||||
if (!state.user || state.user?.username === "") {
|
||||
return "normal";
|
||||
@@ -243,7 +227,7 @@ export const getters = {
|
||||
if (!state.req.items) return { pinned, dirs, files };
|
||||
|
||||
for (const item of state.req.items) {
|
||||
if (getters.isItemPinned(item)) {
|
||||
if (item.pinned) {
|
||||
pinned.push(item);
|
||||
continue;
|
||||
}
|
||||
@@ -432,12 +416,13 @@ export const getters = {
|
||||
|
||||
const files = []
|
||||
|
||||
for (const index in state.upload.uploads) {
|
||||
const upload = state.upload.uploads[index]
|
||||
for (const index of Object.keys(state.upload.uploads)) {
|
||||
const upload = getObjectProperty(state.upload.uploads, index)
|
||||
if (!upload) continue
|
||||
const id = upload.id
|
||||
const type = upload.type
|
||||
const name = upload.file.name
|
||||
const size = state.upload.sizes[id] || 0 // Default to 0 if size is undefined
|
||||
const size = getObjectProperty(state.upload.sizes, id) ?? 0 // Default to 0 if size is undefined
|
||||
const isDir = upload.file.type === 'directory'
|
||||
const progress = isDir
|
||||
? 100
|
||||
|
||||
@@ -407,9 +407,6 @@ export const mutations = {
|
||||
await i18n.setLocale(value.locale);
|
||||
}
|
||||
state.user = value;
|
||||
if (!state.user.pinnedItems || typeof state.user.pinnedItems !== "object") {
|
||||
state.user.pinnedItems = {};
|
||||
}
|
||||
state.user.sorting = {};
|
||||
state.user.sorting.by = "name";
|
||||
state.user.sorting.asc = true;
|
||||
@@ -607,7 +604,6 @@ export const mutations = {
|
||||
"fileLoading",
|
||||
"deleteAfterArchive",
|
||||
"preferEditorForMarkdown",
|
||||
"pinnedItems",
|
||||
].includes(key)
|
||||
);
|
||||
value.id = state.user.id;
|
||||
@@ -630,22 +626,16 @@ export const mutations = {
|
||||
const sorting = getters.sorting();
|
||||
const sortby = sorting.by;
|
||||
const asc = sorting.asc;
|
||||
const pinnedPaths = getters.pinnedPathsFor(value.source, value.path || state.route.path || "/");
|
||||
// Separate directories and files
|
||||
const dirs = value.items.filter((item) => item.type === 'directory');
|
||||
const files = value.items.filter((item) => item.type !== 'directory');
|
||||
|
||||
// Sort them separately
|
||||
const sortedDirs = sortedItems(dirs, sortby, asc, pinnedPaths);
|
||||
const sortedFiles = sortedItems(files, sortby, asc, pinnedPaths);
|
||||
|
||||
// Combine them and assign indices
|
||||
const sortedDirs = sortedItems(dirs, sortby, asc);
|
||||
const sortedFiles = sortedItems(files, sortby, asc);
|
||||
value.items = [...sortedDirs, ...sortedFiles];
|
||||
value.items.map((item, index) => {
|
||||
value.items.forEach((item, index) => {
|
||||
item.index = index;
|
||||
return item;
|
||||
})
|
||||
|
||||
});
|
||||
state.req = value;
|
||||
emitStateChanged();
|
||||
},
|
||||
@@ -837,62 +827,6 @@ export const mutations = {
|
||||
}
|
||||
emitStateChanged();
|
||||
},
|
||||
togglePinnedItem: (item) => {
|
||||
if (!item?.path || getters.isShare() || !getters.isLoggedIn()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const directoryPath = url.getParentDir(item.path);
|
||||
const sourceKey = item.source || state.req?.source || state.sources.current;
|
||||
if (!sourceKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
const nextPinnedItems = {
|
||||
...(state.user?.pinnedItems || {}),
|
||||
};
|
||||
const sourcePinnedItems = {
|
||||
...(nextPinnedItems[sourceKey] || {}),
|
||||
};
|
||||
const pinnedPaths = Array.isArray(sourcePinnedItems[directoryPath]) ? [...sourcePinnedItems[directoryPath]] : [];
|
||||
const existingIndex = pinnedPaths.indexOf(item.path);
|
||||
|
||||
if (existingIndex >= 0) {
|
||||
pinnedPaths.splice(existingIndex, 1);
|
||||
} else {
|
||||
pinnedPaths.push(item.path);
|
||||
}
|
||||
|
||||
if (pinnedPaths.length > 0) {
|
||||
sourcePinnedItems[directoryPath] = pinnedPaths;
|
||||
nextPinnedItems[sourceKey] = sourcePinnedItems;
|
||||
} else {
|
||||
delete sourcePinnedItems[directoryPath];
|
||||
if (Object.keys(sourcePinnedItems).length > 0) {
|
||||
nextPinnedItems[sourceKey] = sourcePinnedItems;
|
||||
} else {
|
||||
delete nextPinnedItems[sourceKey];
|
||||
}
|
||||
}
|
||||
|
||||
mutations.updateCurrentUser({
|
||||
pinnedItems: nextPinnedItems,
|
||||
});
|
||||
|
||||
const currentDirectoryPath = state.req?.type === "directory" ? state.req.path : url.getParentDir(state.req?.path);
|
||||
const currentSourceKey = state.req?.source || state.sources.current;
|
||||
|
||||
if (
|
||||
getters.currentView() === "listingView" &&
|
||||
currentDirectoryPath === directoryPath &&
|
||||
currentSourceKey === sourceKey
|
||||
) {
|
||||
mutations.updateListingItems();
|
||||
return;
|
||||
}
|
||||
|
||||
emitStateChanged();
|
||||
},
|
||||
setNavigationEnabled: (enabled) => {
|
||||
if (state.navigation.enabled === enabled) {
|
||||
return;
|
||||
@@ -924,8 +858,7 @@ export const mutations = {
|
||||
|
||||
// Sort listing according to sorting preferences
|
||||
const sorting = getters.sorting();
|
||||
const pinnedPaths = getters.pinnedPathsFor(currentItem?.source || state.req?.source, directoryPath);
|
||||
listing = sortedItems(listing, sorting.by, sorting.asc, pinnedPaths);
|
||||
listing = sortedItems(listing, sorting.by, sorting.asc);
|
||||
|
||||
// Find current item index in the listing
|
||||
for (let i = 0; i < listing.length; i++) {
|
||||
@@ -941,8 +874,7 @@ export const mutations = {
|
||||
}
|
||||
|
||||
// Find previous item (skip directories)
|
||||
for (let j = state.navigation.currentIndex - 1; j >= 0; j--) {
|
||||
const item = listing[j];
|
||||
for (const item of listing.slice(0, state.navigation.currentIndex).reverse()) {
|
||||
if (item.type === 'directory') continue;
|
||||
|
||||
item.path = url.joinPath(directoryPath, item.name);
|
||||
@@ -956,8 +888,7 @@ export const mutations = {
|
||||
}
|
||||
|
||||
// Find next item (skip directories)
|
||||
for (let j = state.navigation.currentIndex + 1; j < listing.length; j++) {
|
||||
const item = listing[j];
|
||||
for (const item of listing.slice(state.navigation.currentIndex + 1)) {
|
||||
if (item.type === 'directory') continue;
|
||||
|
||||
item.path = url.joinPath(directoryPath, item.name);
|
||||
|
||||
@@ -49,7 +49,6 @@ export const state = reactive({
|
||||
info: {},
|
||||
},
|
||||
user: {
|
||||
pinnedItems: {},
|
||||
preview: {
|
||||
audio: true,
|
||||
video: true,
|
||||
|
||||
@@ -41,7 +41,6 @@ export interface ShareInfoObject {
|
||||
}
|
||||
|
||||
export interface UserObject {
|
||||
pinnedItems?: Record<string, Record<string, string[]>>;
|
||||
preview: {
|
||||
video: boolean;
|
||||
audio: boolean;
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
export default function (name) {
|
||||
const re = new RegExp(
|
||||
`(?:(?:^|.*;\\s*)${name}\\s*\\=\\s*([^;]*).*$)|^.*$`
|
||||
);
|
||||
return document.cookie.replace(re, "$1");
|
||||
export default function getCookieDefault(name) {
|
||||
return getCookie(name);
|
||||
}
|
||||
|
||||
export function getCookie(name) {
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
/**
|
||||
* Safe property access helpers for plain objects (prototype-pollution / injection-sink safe).
|
||||
*/
|
||||
|
||||
function normalizeKey(key) {
|
||||
if (typeof key === 'string' || typeof key === 'number') {
|
||||
return String(key);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** @returns {unknown} */
|
||||
export function getObjectProperty(obj, key) {
|
||||
const prop = normalizeKey(key);
|
||||
if (obj === null || obj === undefined || prop === null || !Object.hasOwn(obj, prop)) {
|
||||
return undefined;
|
||||
}
|
||||
return obj[prop];
|
||||
}
|
||||
|
||||
/** @returns {Record<string, unknown>} */
|
||||
export function setObjectProperty(obj, key, value) {
|
||||
const prop = normalizeKey(key);
|
||||
if (prop === null) {
|
||||
return { ...(obj ?? {}) };
|
||||
}
|
||||
const result = { ...(obj ?? {}) };
|
||||
result[prop] = value;
|
||||
return result;
|
||||
}
|
||||
|
||||
/** @returns {Record<string, unknown>} */
|
||||
export function omitObjectProperty(obj, key) {
|
||||
const prop = normalizeKey(key);
|
||||
if (obj === null || obj === undefined || prop === null || !Object.hasOwn(obj, prop)) {
|
||||
return { ...(obj ?? {}) };
|
||||
}
|
||||
return Object.fromEntries(
|
||||
Object.entries(obj).filter(([entryKey]) => entryKey !== prop)
|
||||
);
|
||||
}
|
||||
|
||||
/** @returns {unknown} */
|
||||
export function getNestedProperty(obj, outerKey, innerKey) {
|
||||
const outer = getObjectProperty(obj, outerKey);
|
||||
return getObjectProperty(outer, innerKey);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown> | null | undefined} obj
|
||||
* @param {string | number} outerKey
|
||||
* @param {string | number} innerKey
|
||||
* @param {unknown} innerValue
|
||||
* @param {{ removeInner?: boolean; removeOuterIfEmpty?: boolean }} [options]
|
||||
* @returns {Record<string, unknown>}
|
||||
*/
|
||||
export function updateNestedProperty(obj, outerKey, innerKey, innerValue, options = {}) {
|
||||
const { removeInner = false, removeOuterIfEmpty = false } = options;
|
||||
const outerObj = getObjectProperty(obj, outerKey) ?? {};
|
||||
let newOuter;
|
||||
if (removeInner) {
|
||||
newOuter = omitObjectProperty(outerObj, innerKey);
|
||||
} else {
|
||||
newOuter = setObjectProperty(outerObj, innerKey, innerValue);
|
||||
}
|
||||
|
||||
if (removeOuterIfEmpty && Object.keys(newOuter).length === 0) {
|
||||
return omitObjectProperty(obj, outerKey);
|
||||
}
|
||||
return setObjectProperty(obj, outerKey, newOuter);
|
||||
}
|
||||
@@ -1,15 +1,7 @@
|
||||
import { removeTrailingSlash } from "@/utils/url.js";
|
||||
|
||||
export function sortedItems(items = [], sortby = "name", asc = true, pinnedPaths = []) {
|
||||
// Normalise pinned paths
|
||||
const normalisedPinnedPaths = (Array.isArray(pinnedPaths) ? pinnedPaths : []).map(p => removeTrailingSlash(p));
|
||||
const pinnedSet = new Set(normalisedPinnedPaths);
|
||||
|
||||
export function sortedItems(items = [], sortby = "name", asc = true) {
|
||||
return items.sort((a, b) => {
|
||||
const aPathNorm = a.path ? removeTrailingSlash(a.path) : '';
|
||||
const bPathNorm = b.path ? removeTrailingSlash(b.path) : '';
|
||||
const aPinned = pinnedSet.has(aPathNorm);
|
||||
const bPinned = pinnedSet.has(bPathNorm);
|
||||
const aPinned = !!a.pinned;
|
||||
const bPinned = !!b.pinned;
|
||||
|
||||
if (aPinned !== bPinned) {
|
||||
return aPinned ? -1 : 1;
|
||||
|
||||
@@ -141,18 +141,18 @@ describe('testSort', () => {
|
||||
it('keeps pinned items at the top while preserving sort order among them', () => {
|
||||
const input = [
|
||||
{ name: "beta.txt", path: "/beta.txt" },
|
||||
{ name: "delta.txt", path: "/delta.txt" },
|
||||
{ name: "alpha.txt", path: "/alpha.txt" },
|
||||
{ name: "delta.txt", path: "/delta.txt", pinned: true },
|
||||
{ name: "alpha.txt", path: "/alpha.txt", pinned: true },
|
||||
{ name: "gamma.txt", path: "/gamma.txt" },
|
||||
];
|
||||
const expected = [
|
||||
{ name: "alpha.txt", path: "/alpha.txt" },
|
||||
{ name: "delta.txt", path: "/delta.txt" },
|
||||
{ name: "alpha.txt", path: "/alpha.txt", pinned: true },
|
||||
{ name: "delta.txt", path: "/delta.txt", pinned: true },
|
||||
{ name: "beta.txt", path: "/beta.txt" },
|
||||
{ name: "gamma.txt", path: "/gamma.txt" },
|
||||
];
|
||||
|
||||
expect(sortedItems(input, "name", true, ["/delta.txt", "/alpha.txt"])).toEqual(expected);
|
||||
expect(sortedItems(input, "name", true)).toEqual(expected);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -132,13 +132,16 @@ export function convertToVTT (ext, text) {
|
||||
if (!dialogue) return '' // Skip empty captions
|
||||
const startFormatted = formatMillisecondsToVTT(startTime)
|
||||
|
||||
// Use next start time as the end time if available
|
||||
const nextSyncMatch = text.match(
|
||||
new RegExp(`<SYNC Start=${parseInt(startTime, 10) + 1}>`)
|
||||
)
|
||||
const endTime = nextSyncMatch
|
||||
? nextSyncMatch[1]
|
||||
: parseInt(startTime, 10) + 3000 // Default to +3s
|
||||
// Use next SYNC start time as the end time if available
|
||||
const currentTag = `<SYNC Start=${startTime}>`
|
||||
const tagPos = text.indexOf(currentTag)
|
||||
let endTime = parseInt(startTime, 10) + 3000 // Default to +3s
|
||||
if (tagPos >= 0) {
|
||||
const nextMatch = text.slice(tagPos + currentTag.length).match(/<SYNC Start=(\d+)>/)
|
||||
if (nextMatch) {
|
||||
endTime = parseInt(nextMatch[1], 10)
|
||||
}
|
||||
}
|
||||
|
||||
const endFormatted = formatMillisecondsToVTT(endTime)
|
||||
return `${startFormatted} --> ${endFormatted}\n${dialogue}`
|
||||
|
||||
@@ -28,6 +28,7 @@ import { url } from "@/utils";
|
||||
import router from "@/router";
|
||||
import { extractSourceFromPath } from "@/utils/url";
|
||||
import LoadingSpinner from "@/components/LoadingSpinner.vue";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
|
||||
function directoryListingHasMediaChildren(req) {
|
||||
return (
|
||||
|
||||
@@ -18,6 +18,7 @@ import { defineComponent, watch } from "vue";
|
||||
import ePub, { type Book, type Rendition } from "epubjs";
|
||||
import { state, mutations, getters } from "@/store"; // Assuming your store setup
|
||||
import { resourcesApi } from "@/api";
|
||||
import router from "@/router";
|
||||
import { removeLastDir } from "@/utils/url"; // Assuming your utils setup
|
||||
|
||||
/** Hash format: `#epubcfi=<encodeURIComponent(epub-cfi)>` — distinct from listing `#filename` hashes. */
|
||||
@@ -194,9 +195,10 @@ export default defineComponent({
|
||||
},
|
||||
// Close the viewer and navigate away
|
||||
close() {
|
||||
const current = window.location.pathname;
|
||||
const newPath = removeLastDir(current);
|
||||
window.location.href = `${newPath}#${state.req.name}`;
|
||||
const filename = state.req.name;
|
||||
mutations.replaceRequest({});
|
||||
const uri = `${removeLastDir(state.route.path)}/`;
|
||||
router.push({ path: uri, hash: `#${filename}` });
|
||||
},
|
||||
// Error handler
|
||||
onLoadComponentError(error: unknown) {
|
||||
|
||||
@@ -84,6 +84,7 @@
|
||||
v-bind:metadata="item.metadata"
|
||||
v-bind:hasDuration="hasDuration"
|
||||
v-bind:isShared="item.isShared"
|
||||
v-bind:pinned="item.pinned"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -113,6 +114,7 @@
|
||||
v-bind:hasPreview="item.hasPreview"
|
||||
v-bind:hasDuration="hasDuration"
|
||||
v-bind:isShared="item.isShared"
|
||||
v-bind:pinned="item.pinned"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -143,6 +145,7 @@
|
||||
v-bind:metadata="item.metadata"
|
||||
v-bind:hasDuration="hasDuration"
|
||||
v-bind:isShared="item.isShared"
|
||||
v-bind:pinned="item.pinned"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -238,18 +241,21 @@ export default {
|
||||
});
|
||||
|
||||
// Decide category by checking which section is above
|
||||
let letter = topItem.getAttribute("data-name")?.[0]?.toUpperCase() || "A";
|
||||
let letter = "A";
|
||||
let category = "folders"; // Default category
|
||||
|
||||
if (!topItem && this.numPinned > 0) {
|
||||
if (topItem) {
|
||||
letter = topItem.getAttribute("data-name")?.[0]?.toUpperCase() || "A";
|
||||
} else if (this.numPinned > 0) {
|
||||
const pinnedHeader = this.$el.querySelector(".pinned-items h2");
|
||||
if (pinnedHeader && pinnedHeader.getBoundingClientRect().top >= 0) {
|
||||
category = "pinned";
|
||||
const firstPinned = this.pinnedItems[0];
|
||||
letter = firstPinned?.name?.[0]?.toUpperCase();
|
||||
}
|
||||
}
|
||||
else if (topItem?.closest('.pinned-items')) {
|
||||
}
|
||||
|
||||
if (topItem?.closest('.pinned-items')) {
|
||||
category = "pinned";
|
||||
const firstPinned = this.pinnedItems[0];
|
||||
letter = firstPinned?.name?.[0]?.toUpperCase();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
<script>
|
||||
import { DocumentEditor } from "@onlyoffice/document-editor-vue";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
import router from "@/router";
|
||||
import { state, mutations } from "@/store";
|
||||
import { removeLastDir } from "@/utils/url";
|
||||
import { officeApi } from "@/api";
|
||||
@@ -79,17 +80,17 @@ export default {
|
||||
if (window.DocsAPI) delete window.DocsAPI;
|
||||
const iframes = document.querySelectorAll('iframe');
|
||||
iframes.forEach(iframe => {
|
||||
if (iframe.src?.includes('onlyoffice')) {
|
||||
if (iframe.src.includes('onlyoffice')) {
|
||||
iframe.remove();
|
||||
}
|
||||
});
|
||||
},
|
||||
methods: {
|
||||
close() {
|
||||
const current = window.location.pathname;
|
||||
const newpath = removeLastDir(current);
|
||||
mutations.replaceRequest({});
|
||||
const uri = `${removeLastDir(state.route.path)}/`;
|
||||
const filename = this.path.split('/').pop() || "";
|
||||
window.location.href = `${newpath}#${filename}`;
|
||||
router.push({ path: uri, hash: `#${filename}` });
|
||||
},
|
||||
|
||||
showDebugPrompt() {
|
||||
|
||||
@@ -714,7 +714,7 @@ export default {
|
||||
try {
|
||||
navigator.mediaSession.setActionHandler(action, handler);
|
||||
} catch (e) {
|
||||
console.warn(`The media session action "${action}" is not supported`, e);
|
||||
console.warn(`The media session action "${String(action)}" is not supported`, e);
|
||||
}
|
||||
}
|
||||
this.updateMediaSessionPlaybackState();
|
||||
|
||||
@@ -50,8 +50,8 @@ export default {
|
||||
const splitted = name.split(where);
|
||||
name = "";
|
||||
|
||||
for (let i = 0; i < splitted.length; i++) {
|
||||
name += `${splitted[i].charAt(0).toUpperCase() + splitted[i].slice(1)} `;
|
||||
for (const part of splitted) {
|
||||
name += `${part.charAt(0).toUpperCase() + part.slice(1)} `;
|
||||
}
|
||||
|
||||
return name.slice(0, -1);
|
||||
|
||||
@@ -119,6 +119,7 @@ import { eventBus } from "@/store/eventBus";
|
||||
import ListingItem from "@/components/files/ListingItem.vue";
|
||||
import PathPickerButton from "@/components/files/PathPickerButton.vue";
|
||||
import { getTypeInfo } from "@/utils/mimetype";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
|
||||
export default {
|
||||
name: "DuplicateFinder",
|
||||
|
||||
@@ -98,6 +98,7 @@ import { getHumanReadableFilesize } from "@/utils/filesizes";
|
||||
import { formatTimestamp, fromNow } from "@/utils/moment";
|
||||
import { notify } from "@/notify";
|
||||
import PathPickerButton from "@/components/files/PathPickerButton.vue";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
|
||||
export default {
|
||||
name: "FileWatcher",
|
||||
|
||||
@@ -72,6 +72,7 @@
|
||||
<script>
|
||||
import { materialSymbols, getIconClass } from "@/utils/material-symbols";
|
||||
import { copyToClipboard } from "@/utils/clipboard";
|
||||
import { globalVars } from "@/utils/constants";
|
||||
|
||||
export default {
|
||||
name: "MaterialIconPicker",
|
||||
|
||||
Reference in new issue
Block a user