diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 0b87348d..80d35a74 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -69,6 +69,23 @@ jobs: with: files: ./coverage.txt token: ${{ secrets.CODECOV_UPLOAD_TOKEN }} + test-flutter-category: + # The settings editor and the backend have to read an extension list the + # same way, so both sides run the shared rule table. The Go side is covered + # by the test job above, this runs the ui side of it. + name: Test shared category rules + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - uses: subosito/flutter-action@v2 + with: + flutter-version: ${{ env.FLUTTER_VERSION }} + - name: Run the shared category rule table on the ui side + working-directory: ui/flutter + run: | + flutter pub get + flutter gen-l10n + flutter test test/api/downloader_config_test.dart test-webview-integration: name: Test webview integration (${{ matrix.name }}) strategy: diff --git a/cmd/server.go b/cmd/server.go index 667e01a8..7a0af23f 100644 --- a/cmd/server.go +++ b/cmd/server.go @@ -26,7 +26,7 @@ func Start(cfg *model.StartConfig) { if err != nil { panic(err) } - if downloadCfg.FirstLoad { + if rest.Downloader.IsFirstLoad() { // Set default download config if cfg.DownloadConfig != nil { cfg.DownloadConfig.Merge(downloadCfg) @@ -51,6 +51,12 @@ func Start(cfg *model.StartConfig) { rest.Downloader.PutConfig(downloadCfg) } } + + // The default download dir is resolved only after the core starts, so + // seed the built-in categories now that the dir is known. Init is + // idempotent and leaves an existing category list untouched. + downloadCfg.Init() + rest.Downloader.PutConfig(downloadCfg) } watchExit() diff --git a/pkg/base/model.go b/pkg/base/model.go index 98b4133f..4c0af40a 100644 --- a/pkg/base/model.go +++ b/pkg/base/model.go @@ -1,9 +1,11 @@ package base import ( + "encoding/json" "fmt" "net/http" "net/url" + "path/filepath" "sync" "time" @@ -117,6 +119,9 @@ type Options struct { Path string `json:"path"` // Use this path as the default download directory after creation succeeds AsDefaultPath bool `json:"asDefaultPath,omitempty"` + // SkipAutoCategorize keeps the base download directory of this task instead + // of routing it to the category directory of its resolved file + SkipAutoCategorize bool `json:"skipAutoCategorize,omitempty"` // Select file indexes to download SelectFiles []int `json:"selectFiles"` // Extra info for specific fetcher @@ -191,6 +196,8 @@ type DownloaderStoreConfig struct { Script *ScriptConfig `json:"script"` // Script is the script execution configuration AutoTorrent *AutoTorrentConfig `json:"autoTorrent"` // AutoTorrent is the auto torrent task creation configuration Archive *ArchiveConfig `json:"archive"` // Archive is the archive extraction configuration + Categories []*DownloadCategory `json:"categories"` // Categories is the download directory category configuration + AutoCategorize *bool `json:"autoCategorize,omitempty"` // AutoCategorize routes downloads to category directories by file extension; unset defaults to disabled API *APIServerConfig `json:"api"` // API is the optional REST server configuration AutoStartTasks bool `json:"autoStartTasks"` // AutoStartTasks continues all unfinished tasks when the backend starts AutoDeleteMissingFileTasks bool `json:"autoDeleteMissingFileTasks"` // AutoDeleteMissingFileTasks enables automatic deletion of tasks with missing files @@ -227,9 +234,85 @@ func (cfg *DownloaderStoreConfig) Init() *DownloaderStoreConfig { DeleteAfterExtract: false, } } + // Seed the built-in categories for deployments that never run the flutter + // startup flow, e.g. headless api servers. + if len(cfg.Categories) == 0 && cfg.DownloadDir != "" { + cfg.Categories = []*DownloadCategory{ + builtinSeedCategory("categoryMusic", "Music", cfg.DownloadDir), + builtinSeedCategory("categoryVideo", "Video", cfg.DownloadDir), + builtinSeedCategory("categoryDocument", "Document", cfg.DownloadDir), + builtinSeedCategory("categoryProgram", "Program", cfg.DownloadDir), + } + } return cfg } +// builtinCategoryExtensions mirrors the flutter side +// kDefaultCategoryExtensions, keep both maps in sync. +var builtinCategoryExtensions = map[string][]string{ + "categoryProgram": {"exe", "msi", "msix", "apk", "dmg", "deb", "rpm", "pkg", "appimage"}, + "categoryVideo": {"mp4", "mkv", "avi", "mov", "wmv", "flv", "webm", "m4v", "mpg", "mpeg", "3gp"}, + "categoryMusic": {"mp3", "flac", "wav", "aac", "ogg", "m4a", "wma", "ape"}, + "categoryDocument": {"pdf", "doc", "docx", "xls", "xlsx", "ppt", "pptx", "txt", "md", "csv", "epub", "rtf"}, +} + +func builtinSeedCategory(nameKey, name, downloadDir string) *DownloadCategory { + return &DownloadCategory{ + Name: "", + Path: filepath.Join(downloadDir, name), + NameKey: nameKey, + IsBuiltIn: true, + Extensions: append([]string(nil), builtinCategoryExtensions[nameKey]...), + } +} + +// MigrateLegacyExtraCategories moves the extra.downloadCategories list written +// by v2.0.0-beta builds into the top level categories field and clears the +// legacy key. The legacy list predates extension based routing, so built-in +// entries regain their default extension lists from their name key, while user +// created folders keep their empty list until edited in settings. It reports +// whether the config changed so the caller can persist the cleared key. +func (cfg *DownloaderStoreConfig) MigrateLegacyExtraCategories() bool { + if len(cfg.Categories) > 0 || cfg.Extra == nil { + return false + } + raw, ok := cfg.Extra["downloadCategories"] + if !ok { + return false + } + data, err := json.Marshal(raw) + if err != nil { + return false + } + var legacy []*DownloadCategory + if err := json.Unmarshal(data, &legacy); err != nil { + return false + } + categories := make([]*DownloadCategory, 0, len(legacy)) + for _, category := range legacy { + if category == nil { + continue + } + if extensions, exist := builtinCategoryExtensions[category.NameKey]; exist && len(category.Extensions) == 0 { + category.Extensions = append([]string(nil), extensions...) + } + categories = append(categories, category) + } + if len(categories) == 0 { + return false + } + cfg.Categories = categories + delete(cfg.Extra, "downloadCategories") + return true +} + +// AutoCategorizeEnabled reports whether downloads are routed to category +// directories by file extension. The switch is optional: an unset value keeps +// the feature off, so an older config that never saved the field stays off. +func (cfg *DownloaderStoreConfig) AutoCategorizeEnabled() bool { + return cfg.AutoCategorize != nil && *cfg.AutoCategorize +} + func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *DownloaderStoreConfig { if beforeCfg == nil { return cfg @@ -264,6 +347,12 @@ func (cfg *DownloaderStoreConfig) Merge(beforeCfg *DownloaderStoreConfig) *Downl if cfg.Archive == nil { cfg.Archive = beforeCfg.Archive } + if cfg.Categories == nil { + cfg.Categories = beforeCfg.Categories + } + if cfg.AutoCategorize == nil { + cfg.AutoCategorize = beforeCfg.AutoCategorize + } if cfg.API == nil { cfg.API = beforeCfg.API } @@ -312,6 +401,16 @@ type ArchiveConfig struct { DeleteAfterExtract bool `json:"deleteAfterExtract"` // DeleteAfterExtract deletes the archive after successful extraction } +// DownloadCategory is a download directory category that files can be routed to by extension +type DownloadCategory struct { + Name string `json:"name"` // Name is the category display name, empty for built-in categories that use NameKey + Path string `json:"path"` // Path is the download directory of the category + NameKey string `json:"nameKey,omitempty"` // NameKey is the i18n key of the built-in category name + IsBuiltIn bool `json:"isBuiltIn,omitempty"` // IsBuiltIn marks the category as built-in + IsDeleted bool `json:"isDeleted,omitempty"` // IsDeleted marks the built-in category as deleted + Extensions []string `json:"extensions,omitempty"` // Extensions is the file extension list routed to the category, without leading dots +} + type DownloaderProxyConfig struct { Enable bool `json:"enable"` // System is the flag that use system proxy diff --git a/pkg/base/model_test.go b/pkg/base/model_test.go index 5bd6864d..3b85e76c 100644 --- a/pkg/base/model_test.go +++ b/pkg/base/model_test.go @@ -1,8 +1,13 @@ package base import ( + "encoding/json" + "path/filepath" "reflect" + "strings" "testing" + + "github.com/GopeedLab/gopeed/pkg/util" ) func TestDownloaderStoreConfig_Init(t *testing.T) { @@ -192,6 +197,202 @@ func TestDownloaderStoreConfig_Init(t *testing.T) { } } +func TestDownloaderStoreConfig_InitSeedsBuiltInCategories(t *testing.T) { + t.Run("fresh config with a download dir gets the built-in categories", func(t *testing.T) { + dir := t.TempDir() + cfg := &DownloaderStoreConfig{DownloadDir: dir} + cfg.Init() + + wantKeys := []string{"categoryMusic", "categoryVideo", "categoryDocument", "categoryProgram"} + wantNames := []string{"Music", "Video", "Document", "Program"} + if len(cfg.Categories) != len(wantKeys) { + t.Fatalf("categories = %d, want %d", len(cfg.Categories), len(wantKeys)) + } + for i, category := range cfg.Categories { + if category.NameKey != wantKeys[i] { + t.Errorf("category[%d].NameKey = %q, want %q", i, category.NameKey, wantKeys[i]) + } + if !category.IsBuiltIn { + t.Errorf("category[%d] %q is not built-in", i, category.NameKey) + } + if category.Path != filepath.Join(dir, wantNames[i]) { + t.Errorf("category[%d].Path = %q, want %q", i, category.Path, filepath.Join(dir, wantNames[i])) + } + if len(category.Extensions) == 0 { + t.Errorf("category[%d] %q has no default extensions", i, category.NameKey) + } + } + }) + + // Init itself never reads the legacy key: Setup calls + // MigrateLegacyExtraCategories first, and only a config that skipped the + // migration ends up with the built-in seed. + t.Run("legacy extra downloadCategories is not read by Init alone", func(t *testing.T) { + cfg := &DownloaderStoreConfig{ + DownloadDir: t.TempDir(), + Extra: map[string]any{ + "downloadCategories": []any{map[string]any{"name": "Music", "path": "/old/Music"}}, + }, + } + cfg.Init() + if len(cfg.Categories) != 4 { + t.Fatalf("categories = %d, want the 4 built-ins seeded anyway", len(cfg.Categories)) + } + }) + + t.Run("existing categories and missing download dirs are untouched", func(t *testing.T) { + existing := []*DownloadCategory{{Name: "Mine", Path: "/downloads/Mine"}} + cfg := &DownloaderStoreConfig{DownloadDir: t.TempDir(), Categories: existing} + cfg.Init() + if len(cfg.Categories) != 1 || cfg.Categories[0].Name != "Mine" { + t.Fatalf("categories = %v, want the existing one", cfg.Categories) + } + + empty := &DownloaderStoreConfig{} + empty.Init() + if len(empty.Categories) != 0 { + t.Fatalf("categories = %d, want 0 without a download dir", len(empty.Categories)) + } + }) +} + +func TestDownloaderStoreConfig_AutoCategorizeEnabled(t *testing.T) { + // A config written before the switch existed has no value, and stays off. + if (&DownloaderStoreConfig{}).AutoCategorizeEnabled() { + t.Fatal("unset auto categorize = enabled, want disabled by default") + } + if !(&DownloaderStoreConfig{AutoCategorize: util.BoolPtr(true)}).AutoCategorizeEnabled() { + t.Fatal("explicit on = disabled, want the opt-in preserved") + } + if (&DownloaderStoreConfig{AutoCategorize: util.BoolPtr(false)}).AutoCategorizeEnabled() { + t.Fatal("explicit off = enabled, want the opt-out preserved") + } +} + +func TestDownloaderStoreConfig_MigrateLegacyExtraCategories(t *testing.T) { + t.Run("legacy built-ins move to the top level with their default extensions", func(t *testing.T) { + cfg := &DownloaderStoreConfig{ + DownloadDir: "/downloads", + Extra: map[string]any{ + "downloadCategories": []any{ + map[string]any{"name": "", "path": "/old/Music", "isBuiltIn": true, "nameKey": "categoryMusic"}, + map[string]any{"name": "Games", "path": "/old/Games"}, + }, + "themeMode": "dark", + }, + } + if !cfg.MigrateLegacyExtraCategories() { + t.Fatal("migration reported no change") + } + if len(cfg.Categories) != 2 { + t.Fatalf("categories = %d, want the 2 legacy entries", len(cfg.Categories)) + } + builtIn := cfg.Categories[0] + if builtIn.NameKey != "categoryMusic" || !builtIn.IsBuiltIn || builtIn.Path != "/old/Music" { + t.Fatalf("built-in category = %+v, want the legacy entry", builtIn) + } + if len(builtIn.Extensions) == 0 { + t.Fatal("migrated built-in must regain its default extensions") + } + custom := cfg.Categories[1] + if custom.Name != "Games" || custom.Path != "/old/Games" || len(custom.Extensions) != 0 { + t.Fatalf("custom category = %+v, want the legacy folder untouched", custom) + } + if _, ok := cfg.Extra["downloadCategories"]; ok { + t.Fatal("legacy key must be cleared") + } + if cfg.Extra["themeMode"] != "dark" { + t.Fatalf("unrelated extra entries must survive, got %v", cfg.Extra) + } + + // Setup runs Init after the migration, which must not seed over it. + cfg.Init() + if len(cfg.Categories) != 2 { + t.Fatalf("categories after Init = %d, want the migrated list kept", len(cfg.Categories)) + } + }) + + t.Run("existing top level categories are left alone", func(t *testing.T) { + cfg := &DownloaderStoreConfig{ + Extra: map[string]any{ + "downloadCategories": []any{map[string]any{"name": "Old", "path": "/old/Default"}}, + }, + Categories: []*DownloadCategory{{Name: "Mine", Path: "/downloads/Mine"}}, + } + if cfg.MigrateLegacyExtraCategories() { + t.Fatal("migration must not overwrite existing categories") + } + if len(cfg.Categories) != 1 || cfg.Categories[0].Name != "Mine" { + t.Fatalf("categories = %v, want the existing one", cfg.Categories) + } + if _, ok := cfg.Extra["downloadCategories"]; !ok { + t.Fatal("legacy key must stay for a manual move") + } + }) + + t.Run("missing, empty and malformed legacy lists are a no-op", func(t *testing.T) { + if (&DownloaderStoreConfig{}).MigrateLegacyExtraCategories() { + t.Fatal("empty config reported a change") + } + unrelated := &DownloaderStoreConfig{ + Extra: map[string]any{"themeMode": "dark"}, + } + if unrelated.MigrateLegacyExtraCategories() { + t.Fatal("config without the legacy key reported a change") + } + empty := &DownloaderStoreConfig{ + Extra: map[string]any{"downloadCategories": []any{}}, + } + if empty.MigrateLegacyExtraCategories() { + t.Fatal("empty legacy list reported a change") + } + malformed := &DownloaderStoreConfig{ + Extra: map[string]any{"downloadCategories": "not-a-list"}, + } + if malformed.MigrateLegacyExtraCategories() { + t.Fatal("malformed legacy list reported a change") + } + if _, ok := malformed.Extra["downloadCategories"]; !ok { + t.Fatal("malformed legacy key must stay untouched") + } + }) + + t.Run("a nil entry in the legacy list is skipped", func(t *testing.T) { + cfg := &DownloaderStoreConfig{ + DownloadDir: "/downloads", + Extra: map[string]any{ + "downloadCategories": []any{ + nil, + map[string]any{"name": "Games", "path": "/old/Games"}, + }, + }, + } + if !cfg.MigrateLegacyExtraCategories() { + t.Fatal("migration reported no change") + } + if len(cfg.Categories) != 1 || cfg.Categories[0].Name != "Games" { + t.Fatalf("categories = %+v, want the single valid legacy entry", cfg.Categories) + } + }) +} + +func TestDownloaderStoreConfig_AutoCategorizeJSON(t *testing.T) { + unset, err := json.Marshal(&DownloaderStoreConfig{}) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(unset), `"autoCategorize"`) { + t.Fatalf("unset autoCategorize must be omitted from the api json, got %s", unset) + } + off, err := json.Marshal(&DownloaderStoreConfig{AutoCategorize: util.BoolPtr(false)}) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(off), `"autoCategorize":false`) { + t.Fatalf("explicit autoCategorize=false must be emitted, got %s", off) + } +} + func TestDownloaderStoreConfig_Merge(t *testing.T) { type args struct { beforeCfg *DownloaderStoreConfig @@ -236,6 +437,24 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) { DownloadDir: "after", }, }, + { + "Merge Categories and AutoCategorize Override", + &DownloaderStoreConfig{}, + args{ + beforeCfg: &DownloaderStoreConfig{ + Categories: []*DownloadCategory{ + {Name: "Music", Path: "/downloads/Music"}, + }, + AutoCategorize: util.BoolPtr(true), + }, + }, + &DownloaderStoreConfig{ + Categories: []*DownloadCategory{ + {Name: "Music", Path: "/downloads/Music"}, + }, + AutoCategorize: util.BoolPtr(true), + }, + }, { "Merge MaxRunning No Override", &DownloaderStoreConfig{ @@ -449,6 +668,8 @@ func TestDownloaderStoreConfig_Merge(t *testing.T) { Webhook: tt.fields.Webhook, AutoTorrent: tt.fields.AutoTorrent, Archive: tt.fields.Archive, + Categories: tt.fields.Categories, + AutoCategorize: tt.fields.AutoCategorize, } if got := cfg.Merge(tt.args.beforeCfg); !reflect.DeepEqual(got, tt.want) { t.Errorf("Merge() = %v, want %v", got, tt.want) diff --git a/pkg/download/category.go b/pkg/download/category.go new file mode 100644 index 00000000..d4a6ff56 --- /dev/null +++ b/pkg/download/category.go @@ -0,0 +1,186 @@ +package download + +import ( + "errors" + "path" + "strings" + "unicode" + + "github.com/GopeedLab/gopeed/pkg/base" +) + +// categoryForResource returns the category that receives the resolved +// resource, or nil when auto categorize does not apply. Only a single file +// resource is routed: a folder or a multi file resource keeps its directory +// layout, so its files are never matched one by one, not even when only a +// single one of them is selected. +func categoryForResource(cfg *base.DownloaderStoreConfig, res *base.Resource) *base.DownloadCategory { + if !cfg.AutoCategorizeEnabled() || len(cfg.Categories) == 0 { + return nil + } + if res == nil || res.Name != "" || len(res.Files) != 1 { + return nil + } + return matchDownloadCategory(cfg, res.Files[0].Name) +} + +// decideDownloadDir returns the download directory of a task. The base +// directory is the one the task asked for, or the configured download +// directory when the task left it empty. A task that did not skip auto +// categorize is routed to the category directory of its resolved resource +// when one matches. Path placeholders are expanded and the white list check +// runs on the result, so a category directory the white list rejects falls +// back to the base directory of this task instead of failing it. +func (d *Downloader) decideDownloadDir(opts *base.Options, res *base.Resource) (string, error) { + if opts == nil { + opts = &base.Options{} + } + cfg, err := d.GetConfig() + if err != nil { + return "", err + } + base := dartTrimSpace(opts.Path) + if base == "" { + base = cfg.DownloadDir + } + // Refuse to fall through to the process working directory when neither the + // task nor the config names a directory. + if base == "" { + return "", errors.New("download directory is empty") + } + target := base + if !opts.SkipAutoCategorize { + if category := categoryForResource(cfg, res); category != nil { + target = dartTrimSpace(category.Path) + } + } + dir, err := d.initDownloadPath(target) + if err != nil && target != base { + d.Logger.Warn().Err(err).Msgf("category directory %q rejected by white list, falling back to %q", target, base) + dir, err = d.initDownloadPath(base) + } + if err != nil { + return "", err + } + return dir, nil +} + +// suggestedDownloadDir returns the category directory a task would be routed +// to, or an empty string when auto categorize does not apply. It only reports +// the decision of decideDownloadDir, it expands no directory and writes +// nothing. +func (d *Downloader) suggestedDownloadDir(res *base.Resource) string { + cfg, err := d.GetConfig() + if err != nil { + return "" + } + category := categoryForResource(cfg, res) + if category == nil { + return "" + } + dir, err := d.initDownloadPath(dartTrimSpace(category.Path)) + if err != nil { + return "" + } + return dir +} + +// asciiLower lowercases the ascii letters of s and leaves the rest alone. The +// ui lowercases the same way, because the unicode case tables of the two +// languages disagree for some code points and would let the two sides pick a +// different category for the same file. +func asciiLower(s string) string { + hasUpper := false + for i := 0; i < len(s); i++ { + if c := s[i]; c >= 'A' && c <= 'Z' { + hasUpper = true + break + } + } + if !hasUpper { + return s + } + b := []byte(s) + for i, c := range b { + if c >= 'A' && c <= 'Z' { + b[i] = c + ('a' - 'A') + } + } + return string(b) +} + +// matchDownloadCategory returns the first category whose extension list +// contains the extension of the given file name, or nil when nothing matches. +// An empty extension list never matches. +func matchDownloadCategory(cfg *base.DownloaderStoreConfig, name string) *base.DownloadCategory { + ext := fileExtension(name) + if ext == "" { + return nil + } + for _, category := range cfg.Categories { + if category == nil || category.IsDeleted || dartTrimSpace(category.Path) == "" { + continue + } + for _, e := range normalizeCategoryExtensions(category.Extensions) { + if e == ext { + return category + } + } + } + return nil +} + +// normalizeCategoryExtensions mirrors the dart side: entries are split on +// commas and whitespace, trimmed, lowercased, stripped of leading dots and +// deduplicated while keeping the input order, so a stored value like +// "exe, msi" behaves the same in the backend as it does in the ui. +func normalizeCategoryExtensions(raw []string) []string { + var result []string + seen := make(map[string]struct{}, len(raw)) + for _, item := range raw { + for _, part := range strings.FieldsFunc(item, func(r rune) bool { + return r == ',' || isECMAScriptSpace(r) + }) { + ext := asciiLower(strings.TrimLeft(dartTrimSpace(part), ".")) + if ext == "" { + continue + } + if _, ok := seen[ext]; ok { + continue + } + seen[ext] = struct{}{} + result = append(result, ext) + } + } + return result +} + +// fileExtension returns the lower case extension of a file name without the +// leading dot, e.g. "setup.exe" -> "exe". Windows style separators are +// treated like the ui does, so a dot inside a directory segment never counts +// as an extension. +func fileExtension(name string) string { + name = strings.ReplaceAll(name, `\`, "/") + return strings.TrimPrefix(asciiLower(path.Ext(name)), ".") +} + +// dartTrimSpace trims the whitespace the dart String.trim() removes, which is +// the unicode white space set plus the byte order mark. The ui trims the +// category paths and the extension lists with it, so the backend uses the same +// set instead of the narrower unicode.IsSpace. +func dartTrimSpace(s string) string { + return strings.TrimFunc(s, func(r rune) bool { + return unicode.IsSpace(r) || r == 0xFEFF + }) +} + +// isECMAScriptSpace reports whether r belongs to the whitespace class of the +// ECMAScript regular expressions. The ui splits its extension lists with that +// class, so the backend uses the same one instead of the wider unicode.IsSpace. +func isECMAScriptSpace(r rune) bool { + switch r { + case '\t', '\n', '\v', '\f', '\r', ' ', 0x00A0, 0x1680, 0x2028, 0x2029, 0x202F, 0x205F, 0x3000, 0xFEFF: + return true + } + return r >= 0x2000 && r <= 0x200A +} diff --git a/pkg/download/category_preparation_test.go b/pkg/download/category_preparation_test.go new file mode 100644 index 00000000..c65aa505 --- /dev/null +++ b/pkg/download/category_preparation_test.go @@ -0,0 +1,566 @@ +package download + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "sync/atomic" + "testing" + "time" + + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/util" +) + +type failNextTaskPutStorage struct { + Storage + fail atomic.Bool + failBucket string + beforeTaskPut func(*Task) +} + +func (s *failNextTaskPutStorage) Put(bucket string, key string, value any) error { + if bucket == bucketTask && s.beforeTaskPut != nil { + s.beforeTaskPut(value.(*Task)) + } + failBucket := s.failBucket + if failBucket == "" { + failBucket = bucketTask + } + if bucket == failBucket && (bucket != bucketTask || !value.(*Task).NeedsPreparation) && s.fail.CompareAndSwap(true, false) { + return errors.New("task snapshot write failed") + } + return s.Storage.Put(bucket, key, value) +} + +func TestDownloader_PreparationSaveFailureCanRetry(t *testing.T) { + for _, failBucket := range []string{bucketSave, bucketTask} { + for _, restart := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/restart=%t", failBucket, restart), func(t *testing.T) { + url := startCategoryFileServer(t, "setup.data", []byte("0123456789")) + downloadDir := t.TempDir() + storageDir := t.TempDir() + storage := &failNextTaskPutStorage{Storage: NewBoltStorage(storageDir), failBucket: failBucket} + downloader := NewDownloader(&DownloaderConfig{Storage: storage, StorageDir: storageDir}) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { downloader.Close() }) + categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"}) + if err := os.MkdirAll(categoryDir, 0755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(categoryDir, "setup.data"), []byte("existing"), 0644); err != nil { + t.Fatal(err) + } + failed := watchTaskError(t, downloader) + storage.fail.Store(true) + id, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{Path: downloadDir}) + if err != nil { + t.Fatal(err) + } + waitTaskError(t, failed) + task := downloader.GetTask(id) + if !task.NeedsPreparation || task.Meta.Opts.Path != downloadDir || task.Meta.Opts.Name != "" { + t.Fatal("failed preparation changed the original directory, name or flag") + } + if restart { + if err := downloader.Close(); err != nil { + t.Fatal(err) + } + downloader = NewDownloader(&DownloaderConfig{Storage: NewBoltStorage(storageDir), StorageDir: storageDir}) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + } + done := watchTaskDone(t, downloader) + if err := downloader.Continue(&TaskFilter{IDs: []string{id}}); err != nil { + t.Fatal(err) + } + waitTaskDone(t, done) + task = downloader.GetTask(id) + if task.NeedsPreparation || task.Meta.Opts.Path != categoryDir || task.Meta.Opts.Name != "setup (1).data" { + t.Fatalf("retry did not prepare the final directory and duplicate name: %+v", task.Meta.Opts) + } + data, err := os.ReadFile(filepath.Join(categoryDir, "setup (1).data")) + if err != nil || string(data) != "0123456789" { + t.Fatalf("downloaded content = %q, %v", data, err) + } + }) + } + } +} + +// watchTaskError subscribes to the error event, so a failing task can be +// awaited instead of polled. +func watchTaskError(t *testing.T, d *Downloader) <-chan struct{} { + t.Helper() + failed := make(chan struct{}, 1) + d.Listener(func(event *Event) { + if event.Key == EventKeyError { + select { + case failed <- struct{}{}: + default: + } + } + }) + return failed +} + +func waitTaskError(t *testing.T, failed <-chan struct{}) { + t.Helper() + select { + case <-failed: + case <-time.After(30 * time.Second): + t.Fatal("task did not fail") + } +} + +// waitTaskStatus waits until the task reaches one of the given statuses. +func waitTaskStatus(t *testing.T, d *Downloader, task *Task, statuses ...base.Status) base.Status { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + for time.Now().Before(deadline) { + status := d.taskStatus(task) + for _, want := range statuses { + if status == want { + return status + } + } + time.Sleep(20 * time.Millisecond) + } + t.Fatalf("task status = %q, want one of %v", d.taskStatus(task), statuses) + return "" +} + +// setMaxRunning limits the number of tasks that run at the same time. +func setMaxRunning(t *testing.T, d *Downloader, max int) { + t.Helper() + cfg, err := d.GetConfig() + if err != nil { + t.Fatal(err) + } + cfg.MaxRunning = max + if err := d.PutConfig(cfg); err != nil { + t.Fatal(err) + } +} + +// A task that waits in the queue is prepared when it starts, the first +// preparation must not be skipped by the wait status. +func TestDownloader_QueuedTaskIsPreparedWhenItStarts(t *testing.T) { + release := make(chan struct{}) + slow := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + <-release + w.Header().Set("Content-Disposition", `attachment; filename="slow.bin"`) + w.Write([]byte("slow")) + })) + defer slow.Close() + fast := startCategoryFileServer(t, "setup.data", []byte("0123456789")) + + downloadDir := t.TempDir() + downloader := newSetupCategoryDownloader(t) + categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"}) + setMaxRunning(t, downloader, 1) + + // The blocker takes the only running slot. + blockerID, err := downloader.CreateDirect(&base.Request{URL: slow.URL}, &base.Options{Path: downloadDir}) + if err != nil { + t.Fatal(err) + } + blocker := downloader.GetTask(blockerID) + if blocker == nil { + t.Fatal("blocker task not found") + } + waitTaskStatus(t, downloader, blocker, base.DownloadStatusRunning) + + queuedID, err := downloader.CreateDirect(&base.Request{URL: fast}, &base.Options{Path: downloadDir}) + if err != nil { + t.Fatal(err) + } + queued := downloader.GetTask(queuedID) + if queued == nil { + t.Fatal("task not found") + } + if queued.Status != base.DownloadStatusWait { + t.Fatalf("queued task status = %q, want wait", queued.Status) + } + + close(release) + waitTaskStatus(t, downloader, queued, base.DownloadStatusDone, base.DownloadStatusError) + + if queued.Meta.Opts.Path != categoryDir { + t.Fatalf("queued task path = %q, want the category dir %q", queued.Meta.Opts.Path, categoryDir) + } + if _, err := os.Stat(filepath.Join(categoryDir, "setup.data")); err != nil { + t.Fatalf("categorized file missing: %v", err) + } +} + +// A task that failed before it was prepared is prepared when it continues. +func TestDownloader_FailedTaskIsPreparedWhenItContinues(t *testing.T) { + url := startCategoryFileServer(t, "setup.data", []byte("0123456789")) + downloadDir := t.TempDir() + allowedDir := t.TempDir() + + downloader := newSetupCategoryDownloader(t) + categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"}) + // The white list rejects the base directory, so the first start fails. + downloader.cfg.WhiteDownloadDirs = []string{allowedDir} + + failed := watchTaskError(t, downloader) + taskID, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{Path: downloadDir}) + if err != nil { + t.Fatal(err) + } + task := downloader.GetTask(taskID) + if task == nil { + t.Fatal("task not found") + } + waitTaskError(t, failed) + waitTaskStatus(t, downloader, task, base.DownloadStatusError) + if !task.NeedsPreparation { + t.Fatal("a failed preparation must keep the needs preparation flag") + } + + // The white list accepts the base directory now, the retry prepares the + // task again. + downloader.cfg.WhiteDownloadDirs = []string{allowedDir, downloadDir, categoryDir} + done := watchTaskDone(t, downloader) + if err := downloader.Continue(&TaskFilter{IDs: []string{taskID}}); err != nil { + t.Fatal(err) + } + waitTaskDone(t, done) + + if task.Meta.Opts.Path != categoryDir { + t.Fatalf("retried task path = %q, want the category dir %q", task.Meta.Opts.Path, categoryDir) + } + if task.NeedsPreparation { + t.Fatal("a prepared task must clear the needs preparation flag") + } +} + +// A failed preparation save leaves the live task untouched, so the next +// attempt starts from the original base directory and name. +func TestDownloader_PreparationSaveFailureKeepsOriginalData(t *testing.T) { + url := startCategoryFileServer(t, "setup.data", []byte("0123456789")) + downloadDir := t.TempDir() + storage := &failNextTaskPutStorage{Storage: NewMemStorage()} + downloader := NewDownloader(&DownloaderConfig{Storage: storage}) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + downloader.Delete(nil, true) + downloader.Clear() + }) + categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"}) + setMaxRunning(t, downloader, 0) + + result, err := downloader.Resolve(&base.Request{URL: url}) + if err != nil { + t.Fatal(err) + } + taskID, err := downloader.CreateWithOptions(result.ID, &base.Options{ + Path: downloadDir, + Name: "renamed?.data", + }) + if err != nil { + t.Fatal(err) + } + task := downloader.GetTask(taskID) + if task == nil { + t.Fatal("task not found") + } + originalMeta := util.DeepClone(task.Meta) + originalRequest := task.Meta.Req + storage.beforeTaskPut = func(snapshot *Task) { + if !task.NeedsPreparation || !reflect.DeepEqual(task.Meta, originalMeta) { + t.Error("preparation was published before the snapshot was saved") + } + if snapshot.NeedsPreparation || snapshot.Meta.Opts.Path != categoryDir { + t.Error("the snapshot must contain the prepared directory and cleared flag") + } + } + + storage.fail.Store(true) + if err := downloader.prepareTask(task); err == nil { + t.Fatal("prepareTask() succeeded, want a storage error") + } + if !task.NeedsPreparation { + t.Fatal("a failed save must keep the needs preparation flag") + } + if !reflect.DeepEqual(task.Meta, originalMeta) { + t.Fatalf("task metadata changed after a failed save:\n got: %#v\nwant: %#v", task.Meta, originalMeta) + } + if !reflect.DeepEqual(task.fetcher.Meta(), originalMeta) { + t.Fatalf("fetcher metadata changed after a failed save:\n got: %#v\nwant: %#v", task.fetcher.Meta(), originalMeta) + } + + if err := downloader.prepareTask(task); err != nil { + t.Fatal(err) + } + if task.NeedsPreparation { + t.Fatal("a successful retry must clear the needs preparation flag") + } + if task.Meta.Opts.Path != categoryDir { + t.Fatalf("retried task path = %q, want %q", task.Meta.Opts.Path, categoryDir) + } + if task.Meta != task.fetcher.Meta() || task.Meta.Req != originalRequest { + t.Fatal("preparation must preserve the shared metadata and resolved request") + } + storage.beforeTaskPut = nil + task.Meta.Res.Size++ + var saved Task + if _, err := storage.Get(bucketTask, taskID, &saved); err != nil { + t.Fatal(err) + } + if saved.Meta.Res.Size != originalMeta.Res.Size { + t.Fatal("live metadata changes must not alter the saved task snapshot") + } +} + +// The white list is checked on every start, a task that was rejected once +// keeps failing instead of writing into the rejected directory. +func TestDownloader_WhiteListIsCheckedOnEveryStart(t *testing.T) { + url := startCategoryFileServer(t, "setup.data", []byte("0123456789")) + downloadDir := t.TempDir() + allowedDir := t.TempDir() + + downloader := newSetupCategoryDownloader(t) + putCategoryConfig(t, downloader, downloadDir, true, []string{"data"}) + downloader.cfg.WhiteDownloadDirs = []string{allowedDir} + + failed := watchTaskError(t, downloader) + taskID, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{Path: downloadDir}) + if err != nil { + t.Fatal(err) + } + task := downloader.GetTask(taskID) + if task == nil { + t.Fatal("task not found") + } + waitTaskError(t, failed) + waitTaskStatus(t, downloader, task, base.DownloadStatusError) + + // The task is continued, the white list still rejects the directory. + failed = watchTaskError(t, downloader) + if err := downloader.Continue(&TaskFilter{IDs: []string{taskID}}); err != nil { + t.Fatal(err) + } + waitTaskError(t, failed) + waitTaskStatus(t, downloader, task, base.DownloadStatusError) + + if _, err := os.Stat(filepath.Join(downloadDir, "setup.data")); err == nil { + t.Fatalf("the file was written into the rejected dir %q", downloadDir) + } +} + +// The white list is checked on every start, even for a task that was prepared +// before the white list changed. +func TestDownloader_WhiteListIsCheckedForAPreparedTask(t *testing.T) { + // The first request is truncated, so the download fails after the task was + // prepared and the retry can show that the directory is checked again. + var requests int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Disposition", `attachment; filename="setup.data"`) + w.Header().Set("Content-Length", "8") + w.WriteHeader(http.StatusOK) + if atomic.AddInt32(&requests, 1) == 1 { + w.Write([]byte("0123")) + return + } + w.Write([]byte("01234567")) + })) + defer server.Close() + + downloadDir := t.TempDir() + allowedDir := t.TempDir() + downloader := newSetupCategoryDownloader(t) + categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"}) + downloader.cfg.WhiteDownloadDirs = []string{allowedDir, downloadDir, categoryDir} + + failed := watchTaskError(t, downloader) + taskID, err := downloader.CreateDirect(&base.Request{URL: server.URL}, &base.Options{Path: downloadDir}) + if err != nil { + t.Fatal(err) + } + task := downloader.GetTask(taskID) + if task == nil { + t.Fatal("task not found") + } + waitTaskError(t, failed) + waitTaskStatus(t, downloader, task, base.DownloadStatusError) + if task.Meta.Opts.Path != categoryDir { + t.Fatalf("task path = %q, want the category dir %q", task.Meta.Opts.Path, categoryDir) + } + + // The white list no longer accepts the prepared directory, the task must + // not download into it. + downloader.cfg.WhiteDownloadDirs = []string{allowedDir} + requestsBeforeRetry := atomic.LoadInt32(&requests) + failed = watchTaskError(t, downloader) + if err := downloader.Continue(&TaskFilter{IDs: []string{taskID}}); err != nil { + t.Fatal(err) + } + waitTaskError(t, failed) + waitTaskStatus(t, downloader, task, base.DownloadStatusError) + + if atomic.LoadInt32(&requests) != requestsBeforeRetry { + t.Fatalf("the task downloaded into the rejected dir %q", categoryDir) + } +} + +// A prepared task keeps its directory, a later start never classifies it +// again. +func TestDownloader_PreparedTaskKeepsItsDirectory(t *testing.T) { + // The first request is truncated, so the download fails after the task was + // prepared and the retry can show that the directory is kept. + var requests int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Disposition", `attachment; filename="setup.data"`) + w.Header().Set("Content-Length", "8") + w.WriteHeader(http.StatusOK) + if atomic.AddInt32(&requests, 1) == 1 { + w.Write([]byte("0123")) + return + } + w.Write([]byte("01234567")) + })) + defer server.Close() + + downloadDir := t.TempDir() + downloader := newSetupCategoryDownloader(t) + categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"}) + + failed := watchTaskError(t, downloader) + taskID, err := downloader.CreateDirect(&base.Request{URL: server.URL}, &base.Options{Path: downloadDir}) + if err != nil { + t.Fatal(err) + } + task := downloader.GetTask(taskID) + if task == nil { + t.Fatal("task not found") + } + waitTaskError(t, failed) + waitTaskStatus(t, downloader, task, base.DownloadStatusError) + if task.Meta.Opts.Path != categoryDir { + t.Fatalf("task path = %q, want the category dir %q", task.Meta.Opts.Path, categoryDir) + } + if task.NeedsPreparation { + t.Fatal("a prepared task must clear the needs preparation flag") + } + + // The category no longer matches the file, the prepared task keeps the + // directory it was prepared with. + putCategoryConfig(t, downloader, downloadDir, true, []string{"iso"}) + done := watchTaskDone(t, downloader) + if err := downloader.Continue(&TaskFilter{IDs: []string{taskID}}); err != nil { + t.Fatal(err) + } + waitTaskDone(t, done) + + if task.Meta.Opts.Path != categoryDir { + t.Fatalf("task path = %q, want the prepared dir %q", task.Meta.Opts.Path, categoryDir) + } + if _, err := os.Stat(filepath.Join(categoryDir, "setup.data")); err != nil { + t.Fatalf("the file was not written into the prepared dir: %v", err) + } +} + +// The global default download directory is only saved when the user named the +// directory explicitly, an auto categorized task never becomes the default. +func TestDownloader_DefaultPathNeedsTheSkipFlag(t *testing.T) { + url := startCategoryFileServer(t, "setup.data", []byte("0123456789")) + downloadDir := t.TempDir() + chosenDir := t.TempDir() + downloader := newSetupCategoryDownloader(t) + putCategoryConfig(t, downloader, downloadDir, true, []string{"data"}) + done := watchTaskDone(t, downloader) + + // The skip flag is missing, the task keeps the auto categorize decision + // and the global default stays untouched. + if _, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{ + Path: chosenDir, + AsDefaultPath: true, + }); err != nil { + t.Fatal(err) + } + waitTaskDone(t, done) + cfg, err := downloader.GetConfig() + if err != nil { + t.Fatal(err) + } + if cfg.DownloadDir == chosenDir { + t.Fatalf("default download dir = %q, want it unchanged", cfg.DownloadDir) + } + + // The user named the directory, so it becomes the global default. + if _, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{ + Path: chosenDir, + AsDefaultPath: true, + SkipAutoCategorize: true, + }); err != nil { + t.Fatal(err) + } + waitTaskDone(t, done) + cfg, err = downloader.GetConfig() + if err != nil { + t.Fatal(err) + } + if cfg.DownloadDir != chosenDir { + t.Fatalf("default download dir = %q, want %q", cfg.DownloadDir, chosenDir) + } +} + +// Every task of a batch decides its own directory, the shared options of the +// batch do not leak into the single tasks. +func TestDownloader_BatchTasksDecideTheirOwnDirectory(t *testing.T) { + url := startCategoryFileServer(t, "setup.data", []byte("0123456789")) + downloadDir := t.TempDir() + chosenDir := t.TempDir() + downloader := newSetupCategoryDownloader(t) + categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"}) + done := watchTaskDone(t, downloader) + + taskIDs, err := downloader.CreateDirectBatch(&base.CreateTaskBatch{ + Reqs: []*base.CreateTaskBatchItem{ + { + Req: &base.Request{URL: url}, + Opts: &base.Options{Path: chosenDir, SkipAutoCategorize: true}, + }, + { + Req: &base.Request{URL: url}, + Opts: &base.Options{Path: downloadDir}, + }, + }, + Opts: &base.Options{Path: downloadDir}, + }) + if err != nil { + t.Fatal(err) + } + if len(taskIDs) != 2 { + t.Fatalf("task count = %d, want 2", len(taskIDs)) + } + waitTaskDone(t, done) + waitTaskDone(t, done) + + skipped := downloader.GetTask(taskIDs[0]) + if skipped == nil { + t.Fatal("task not found") + } + if skipped.Meta.Opts.Path != chosenDir { + t.Fatalf("skipped task path = %q, want %q", skipped.Meta.Opts.Path, chosenDir) + } + categorized := downloader.GetTask(taskIDs[1]) + if categorized == nil { + t.Fatal("task not found") + } + if categorized.Meta.Opts.Path != categoryDir { + t.Fatalf("categorized task path = %q, want %q", categorized.Meta.Opts.Path, categoryDir) + } +} diff --git a/pkg/download/category_test.go b/pkg/download/category_test.go new file mode 100644 index 00000000..8d762603 --- /dev/null +++ b/pkg/download/category_test.go @@ -0,0 +1,695 @@ +package download + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "slices" + "testing" + "time" + + "github.com/GopeedLab/gopeed/pkg/base" + "github.com/GopeedLab/gopeed/pkg/util" +) + +func newTestCategoryConfig() *base.DownloaderStoreConfig { + return &base.DownloaderStoreConfig{ + DownloadDir: "./downloads", + AutoCategorize: util.BoolPtr(true), + Categories: []*base.DownloadCategory{ + {Name: "Music", Path: filepath.Join("./downloads", "Music"), Extensions: []string{"mp3", "flac"}}, + {Name: "Video", Path: filepath.Join("./downloads", "Video"), Extensions: []string{"mp4", "mkv"}}, + {Name: "Program", Path: filepath.Join("./downloads", "Program"), Extensions: []string{"exe", "msi"}}, + }, + } +} + +// singleFileResource is the resource shape of a one file task: no folder name +// and a single file entry. +func singleFileResource(name string) *base.Resource { + return &base.Resource{Files: []*base.FileInfo{{Name: name, Size: 1}}} +} + +// folderResource is the resource shape of a multi file task: a folder name and +// one entry per file, even when only one of them is selected later. +func folderResource(name string, files ...string) *base.Resource { + res := &base.Resource{Name: name} + for _, file := range files { + res.Files = append(res.Files, &base.FileInfo{Name: file, Size: 1}) + } + return res +} + +func newUnitCategoryDownloader(cfg *base.DownloaderStoreConfig) *Downloader { + d := NewDownloader(nil) + d.cfg.DownloaderStoreConfig = cfg + return d +} + +func TestDecideDownloadDir(t *testing.T) { + programDir := filepath.Join("./downloads", "Program") + + tests := []struct { + name string + cfg func(cfg *base.DownloaderStoreConfig) + opts *base.Options + res *base.Resource + want string + }{ + { + name: "matching single file routes to the category", + opts: &base.Options{}, + res: singleFileResource("setup.exe"), + want: programDir, + }, + { + name: "an explicit base directory is routed too", + opts: &base.Options{Path: "./elsewhere"}, + res: singleFileResource("setup.exe"), + want: programDir, + }, + { + name: "skip flag keeps the configured download directory", + opts: &base.Options{Path: "./downloads", SkipAutoCategorize: true}, + res: singleFileResource("setup.exe"), + want: "./downloads", + }, + { + name: "skip flag keeps an explicit directory", + opts: &base.Options{Path: "./elsewhere", SkipAutoCategorize: true}, + res: singleFileResource("setup.exe"), + want: "./elsewhere", + }, + { + name: "switch off keeps the base directory", + cfg: func(cfg *base.DownloaderStoreConfig) { cfg.AutoCategorize = util.BoolPtr(false) }, + opts: &base.Options{}, + res: singleFileResource("setup.exe"), + want: "./downloads", + }, + { + name: "no categories keeps the base directory", + cfg: func(cfg *base.DownloaderStoreConfig) { cfg.Categories = nil }, + opts: &base.Options{}, + res: singleFileResource("setup.exe"), + want: "./downloads", + }, + { + name: "unknown extension keeps the base directory", + opts: &base.Options{}, + res: singleFileResource("archive.zip"), + want: "./downloads", + }, + { + name: "a name without an extension keeps the base directory", + opts: &base.Options{}, + res: singleFileResource("README"), + want: "./downloads", + }, + { + name: "a deleted category is skipped", + cfg: func(cfg *base.DownloaderStoreConfig) { + cfg.Categories = []*base.DownloadCategory{ + {Name: "Program", Path: programDir, Extensions: []string{"exe"}, IsDeleted: true}, + } + }, + opts: &base.Options{}, + res: singleFileResource("setup.exe"), + want: "./downloads", + }, + { + name: "a category without a path is skipped", + cfg: func(cfg *base.DownloaderStoreConfig) { + cfg.Categories = []*base.DownloadCategory{ + {Name: "Program", Path: " ", Extensions: []string{"exe"}}, + } + }, + opts: &base.Options{}, + res: singleFileResource("setup.exe"), + want: "./downloads", + }, + { + name: "a multi file resource keeps its layout", + opts: &base.Options{}, + res: folderResource("bundle", "setup.exe", "readme.txt"), + want: "./downloads", + }, + { + name: "a multi file resource with one match still keeps its layout", + opts: &base.Options{SelectFiles: []int{0}}, + res: folderResource("bundle", "setup.exe", "readme.txt"), + want: "./downloads", + }, + { + name: "a single file inside a folder resource is not routed", + opts: &base.Options{}, + res: folderResource("bundle", "setup.exe"), + want: "./downloads", + }, + { + name: "a missing resource keeps the base directory", + opts: &base.Options{}, + res: nil, + want: "./downloads", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := newTestCategoryConfig() + if tt.cfg != nil { + tt.cfg(cfg) + } + d := newUnitCategoryDownloader(cfg) + got, err := d.decideDownloadDir(tt.opts, tt.res) + if err != nil { + t.Fatalf("decideDownloadDir() error = %v", err) + } + if got != tt.want { + t.Fatalf("decideDownloadDir() = %q, want %q", got, tt.want) + } + }) + } +} + +func TestDecideDownloadDirWhiteList(t *testing.T) { + defaultDir := filepath.ToSlash(t.TempDir()) + categoryDir := defaultDir + "/Program" + newConfig := func() *base.DownloaderStoreConfig { + cfg := newTestCategoryConfig() + cfg.DownloadDir = defaultDir + cfg.Categories = []*base.DownloadCategory{ + {Name: "Program", Path: categoryDir, Extensions: []string{"exe"}}, + } + return cfg + } + + t.Run("a rejected category directory falls back to the base directory", func(t *testing.T) { + d := newUnitCategoryDownloader(newConfig()) + d.cfg.WhiteDownloadDirs = []string{defaultDir} + + got, err := d.decideDownloadDir(&base.Options{Path: defaultDir}, singleFileResource("setup.exe")) + if err != nil { + t.Fatalf("expected white list fallback, got %v", err) + } + if got != defaultDir { + t.Fatalf("path = %q, want the base directory %q", got, defaultDir) + } + }) + + t.Run("a rejected base directory is an error", func(t *testing.T) { + d := newUnitCategoryDownloader(newConfig()) + d.cfg.WhiteDownloadDirs = []string{defaultDir} + + _, err := d.decideDownloadDir(&base.Options{Path: defaultDir + "/Other", SkipAutoCategorize: true}, singleFileResource("setup.exe")) + if err == nil { + t.Fatal("expected a white list error for an explicit directory") + } + }) + + t.Run("equivalent category paths still fall back to the allowed spelling", func(t *testing.T) { + cfg := newConfig() + cfg.Categories[0].Path = defaultDir + "/." + d := newUnitCategoryDownloader(cfg) + d.cfg.WhiteDownloadDirs = []string{defaultDir} + got, err := d.decideDownloadDir(&base.Options{Path: defaultDir}, singleFileResource("setup.exe")) + if err != nil || got != defaultDir { + t.Fatalf("decideDownloadDir() = %q, %v; want %q, nil", got, err, defaultDir) + } + }) + + t.Run("an empty base directory is an error", func(t *testing.T) { + cfg := newConfig() + cfg.DownloadDir = "" + d := newUnitCategoryDownloader(cfg) + + _, err := d.decideDownloadDir(&base.Options{SkipAutoCategorize: true}, singleFileResource("setup.exe")) + if err == nil { + t.Fatal("expected an error for an empty download directory") + } + }) +} + +func TestSuggestedDownloadDir(t *testing.T) { + programDir := filepath.Join("./downloads", "Program") + + tests := []struct { + name string + cfg func(cfg *base.DownloaderStoreConfig) + res *base.Resource + want string + }{ + {name: "a matching single file is suggested", res: singleFileResource("setup.exe"), want: programDir}, + { + name: "a padded category path matches the creation decision", + cfg: func(cfg *base.DownloaderStoreConfig) { cfg.Categories[2].Path = " " + programDir + " " }, + res: singleFileResource("setup.exe"), + want: programDir, + }, + { + name: "the switch off suggests nothing", + cfg: func(cfg *base.DownloaderStoreConfig) { cfg.AutoCategorize = util.BoolPtr(false) }, + res: singleFileResource("setup.exe"), + want: "", + }, + {name: "a multi file resource suggests nothing", res: folderResource("bundle", "setup.exe"), want: ""}, + {name: "a folder resource suggests nothing", res: folderResource("bundle", "setup.exe", "a.txt"), want: ""}, + {name: "an unknown extension suggests nothing", res: singleFileResource("archive.zip"), want: ""}, + {name: "a missing resource suggests nothing", res: nil, want: ""}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := newTestCategoryConfig() + if tt.cfg != nil { + tt.cfg(cfg) + } + d := newUnitCategoryDownloader(cfg) + if got := d.suggestedDownloadDir(tt.res); got != tt.want { + t.Fatalf("suggestedDownloadDir() = %q, want %q", got, tt.want) + } + }) + } + + t.Run("a rejected category directory suggests nothing", func(t *testing.T) { + dir := filepath.ToSlash(t.TempDir()) + cfg := newTestCategoryConfig() + cfg.DownloadDir = dir + cfg.Categories = []*base.DownloadCategory{ + {Name: "Program", Path: dir + "/Program", Extensions: []string{"exe"}}, + } + d := newUnitCategoryDownloader(cfg) + d.cfg.WhiteDownloadDirs = []string{dir} + + if got := d.suggestedDownloadDir(singleFileResource("setup.exe")); got != "" { + t.Fatalf("suggestedDownloadDir() = %q, want an empty suggestion", got) + } + }) +} + +func TestNormalizeCategoryExtensions(t *testing.T) { + tests := []struct { + raw []string + want []string + }{ + {[]string{"exe, msi"}, []string{"exe", "msi"}}, + {[]string{" mp3 ", "..exe", "MUSIC"}, []string{"mp3", "exe", "music"}}, + {[]string{"zip", "ZIP", "zip"}, []string{"zip"}}, + {[]string{" ", "..", ""}, nil}, + {nil, nil}, + } + for _, tt := range tests { + got := normalizeCategoryExtensions(tt.raw) + if len(got) != len(tt.want) { + t.Fatalf("normalizeCategoryExtensions(%q) = %q, want %q", tt.raw, got, tt.want) + } + for i := range got { + if got[i] != tt.want[i] { + t.Fatalf("normalizeCategoryExtensions(%q) = %q, want %q", tt.raw, got, tt.want) + } + } + } +} + +// startCategoryFileServer serves a nameless url whose real file name only +// shows up in the Content-Disposition header. +func startCategoryFileServer(t *testing.T, fileName string, data []byte) string { + t.Helper() + mux := http.NewServeMux() + mux.HandleFunc("/download", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Disposition", `attachment; filename="`+fileName+`"`) + w.Header().Set("Content-Type", "application/octet-stream") + http.ServeContent(w, r, fileName, time.Time{}, bytes.NewReader(data)) + }) + server := httptest.NewServer(mux) + t.Cleanup(server.Close) + return server.URL + "/download" +} + +func newSetupCategoryDownloader(t *testing.T) *Downloader { + t.Helper() + downloader := NewDownloader(nil) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + downloader.Delete(nil, true) + downloader.Clear() + }) + return downloader +} + +func putCategoryConfig(t *testing.T, d *Downloader, downloadDir string, autoCategorize bool, extensions []string) string { + t.Helper() + categoryDir := filepath.Join(downloadDir, "Program") + cfg, err := d.GetConfig() + if err != nil { + t.Fatal(err) + } + cfg.DownloadDir = downloadDir + cfg.AutoCategorize = util.BoolPtr(autoCategorize) + cfg.Categories = []*base.DownloadCategory{ + {Name: "Program", Path: categoryDir, Extensions: extensions}, + } + if err := d.PutConfig(cfg); err != nil { + t.Fatal(err) + } + return categoryDir +} + +// watchTaskDone subscribes to the done event before the task is created, so a +// fast task can not finish before the test starts listening. +func watchTaskDone(t *testing.T, d *Downloader) <-chan struct{} { + t.Helper() + done := make(chan struct{}, 1) + d.Listener(func(event *Event) { + if event.Key == EventKeyDone { + select { + case done <- struct{}{}: + default: + } + } + }) + return done +} + +func waitTaskDone(t *testing.T, done <-chan struct{}) { + t.Helper() + select { + case <-done: + case <-time.After(30 * time.Second): + t.Fatal("task did not finish") + } +} + +func TestDownloader_CreateDirectAutoCategorize(t *testing.T) { + // The url carries no extension, the category is matched on the file name + // of the resolved resource. + url := startCategoryFileServer(t, "setup.data", []byte("0123456789")) + downloadDir := t.TempDir() + downloader := newSetupCategoryDownloader(t) + categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"}) + done := watchTaskDone(t, downloader) + + taskID, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{Path: downloadDir}) + if err != nil { + t.Fatal(err) + } + task := downloader.GetTask(taskID) + if task == nil { + t.Fatal("task not found") + } + waitTaskDone(t, done) + if task.Meta.Opts.Path != categoryDir { + t.Fatalf("task path = %q, want the category dir %q", task.Meta.Opts.Path, categoryDir) + } + if _, err := os.Stat(filepath.Join(categoryDir, "setup.data")); err != nil { + t.Fatalf("categorized file missing: %v", err) + } +} + +func TestDownloader_CreateDirectDoesNotMatchTheRename(t *testing.T) { + url := startCategoryFileServer(t, "setup.data", []byte("0123456789")) + downloadDir := t.TempDir() + downloader := newSetupCategoryDownloader(t) + putCategoryConfig(t, downloader, downloadDir, true, []string{"exe"}) + done := watchTaskDone(t, downloader) + + // opts.Name only renames the saved file, it never decides the category. + taskID, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{ + Path: downloadDir, + Name: "renamed.exe", + }) + if err != nil { + t.Fatal(err) + } + task := downloader.GetTask(taskID) + if task == nil { + t.Fatal("task not found") + } + waitTaskDone(t, done) + if task.Meta.Opts.Path != downloadDir { + t.Fatalf("task path = %q, want the base dir %q", task.Meta.Opts.Path, downloadDir) + } + if _, err := os.Stat(filepath.Join(downloadDir, "renamed.exe")); err != nil { + t.Fatalf("renamed file missing: %v", err) + } +} + +func TestDownloader_CreateDirectSkipAutoCategorize(t *testing.T) { + url := startCategoryFileServer(t, "setup.data", []byte("0123456789")) + downloadDir := t.TempDir() + chosenDir := t.TempDir() + downloader := newSetupCategoryDownloader(t) + putCategoryConfig(t, downloader, downloadDir, true, []string{"data"}) + done := watchTaskDone(t, downloader) + + taskID, err := downloader.CreateDirect(&base.Request{URL: url}, &base.Options{ + Path: chosenDir, + SkipAutoCategorize: true, + }) + if err != nil { + t.Fatal(err) + } + task := downloader.GetTask(taskID) + if task == nil { + t.Fatal("task not found") + } + waitTaskDone(t, done) + if task.Meta.Opts.Path != chosenDir { + t.Fatalf("task path = %q, want the chosen dir %q", task.Meta.Opts.Path, chosenDir) + } + if _, err := os.Stat(filepath.Join(chosenDir, "setup.data")); err != nil { + t.Fatalf("file missing in the chosen dir: %v", err) + } +} + +func TestDownloader_CreateWithOptionsAutoCategorize(t *testing.T) { + url := startCategoryFileServer(t, "setup.data", []byte("0123456789")) + downloadDir := t.TempDir() + downloader := newSetupCategoryDownloader(t) + categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"}) + done := watchTaskDone(t, downloader) + + rr, err := downloader.Resolve(&base.Request{URL: url}) + if err != nil { + t.Fatal(err) + } + if rr.SuggestedDownloadDir != categoryDir { + t.Fatalf("suggested dir = %q, want %q", rr.SuggestedDownloadDir, categoryDir) + } + taskID, err := downloader.CreateWithOptions(rr.ID, &base.Options{Path: downloadDir}) + if err != nil { + t.Fatal(err) + } + task := downloader.GetTask(taskID) + if task == nil { + t.Fatal("task not found") + } + waitTaskDone(t, done) + if task.Meta.Opts.Path != categoryDir { + t.Fatalf("task path = %q, want the category dir %q", task.Meta.Opts.Path, categoryDir) + } +} + +func TestDownloader_CreateWithOptionsKeepsTheDefaultDirectory(t *testing.T) { + url := startCategoryFileServer(t, "setup.data", []byte("0123456789")) + downloadDir := t.TempDir() + downloader := newSetupCategoryDownloader(t) + putCategoryConfig(t, downloader, downloadDir, true, []string{"data"}) + done := watchTaskDone(t, downloader) + + rr, err := downloader.Resolve(&base.Request{URL: url}) + if err != nil { + t.Fatal(err) + } + taskID, err := downloader.CreateWithOptions(rr.ID, &base.Options{ + Path: downloadDir, + AsDefaultPath: true, + }) + if err != nil { + t.Fatal(err) + } + waitTaskDone(t, done) + + // The task lands in the category directory, but that must not become the + // global default download directory. + task := downloader.GetTask(taskID) + if task == nil { + t.Fatal("task not found") + } + if task.Meta.Opts.Path == downloadDir { + t.Fatalf("task path = %q, want the category dir", task.Meta.Opts.Path) + } + cfg, err := downloader.GetConfig() + if err != nil { + t.Fatal(err) + } + if cfg.DownloadDir != downloadDir { + t.Fatalf("default download dir = %q, want the base dir %q", cfg.DownloadDir, downloadDir) + } +} + +func TestDownloader_ResolveSuggestsWithoutSideEffects(t *testing.T) { + url := startCategoryFileServer(t, "setup.data", []byte("0123456789")) + downloadDir := t.TempDir() + downloader := newSetupCategoryDownloader(t) + categoryDir := putCategoryConfig(t, downloader, downloadDir, true, []string{"data"}) + + rr, err := downloader.Resolve(&base.Request{URL: url}) + if err != nil { + t.Fatal(err) + } + if rr.SuggestedDownloadDir != categoryDir { + t.Fatalf("suggested dir = %q, want %q", rr.SuggestedDownloadDir, categoryDir) + } + if _, err := os.Stat(categoryDir); !os.IsNotExist(err) { + t.Fatalf("resolve created the suggested directory: %v", err) + } + cfg, err := downloader.GetConfig() + if err != nil { + t.Fatal(err) + } + if cfg.DownloadDir != downloadDir { + t.Fatalf("resolve changed the download dir to %q", cfg.DownloadDir) + } +} + +func TestDownloader_ResolveSuggestsNothingWhenTheSwitchIsOff(t *testing.T) { + url := startCategoryFileServer(t, "setup.data", []byte("0123456789")) + downloadDir := t.TempDir() + downloader := newSetupCategoryDownloader(t) + putCategoryConfig(t, downloader, downloadDir, false, []string{"data"}) + + rr, err := downloader.Resolve(&base.Request{URL: url}) + if err != nil { + t.Fatal(err) + } + if rr.SuggestedDownloadDir != "" { + t.Fatalf("suggested dir = %q, want an empty suggestion", rr.SuggestedDownloadDir) + } +} + +func TestDownloader_SetupMigratesLegacyExtraCategories(t *testing.T) { + storage := NewMemStorage() + if err := storage.Setup([]string{bucketConfig}); err != nil { + t.Fatal(err) + } + legacy := &base.DownloaderStoreConfig{ + DownloadDir: t.TempDir(), + Extra: map[string]any{ + "downloadCategories": []any{ + map[string]any{"name": "", "path": filepath.Join(t.TempDir(), "Music"), "isBuiltIn": true, "nameKey": "categoryMusic"}, + }, + }, + } + if err := storage.Put(bucketConfig, "config", legacy); err != nil { + t.Fatal(err) + } + + downloader := NewDownloader(&DownloaderConfig{Storage: storage, StorageDir: t.TempDir()}) + if err := downloader.Setup(); err != nil { + t.Fatal(err) + } + defer func() { + downloader.Delete(nil, true) + downloader.Clear() + }() + + cfg, err := downloader.GetConfig() + if err != nil { + t.Fatal(err) + } + if len(cfg.Categories) != 1 || cfg.Categories[0].NameKey != "categoryMusic" || len(cfg.Categories[0].Extensions) == 0 { + t.Fatalf("categories = %+v, want the migrated built-in with its default extensions", cfg.Categories) + } + if _, ok := cfg.Extra["downloadCategories"]; ok { + t.Fatal("in memory config still carries the legacy key") + } + + // The cleared key has to be persisted, not only held in memory. + var stored base.DownloaderStoreConfig + exist, err := storage.Get(bucketConfig, "config", &stored) + if err != nil { + t.Fatal(err) + } + if !exist { + t.Fatal("stored config missing") + } + if _, ok := stored.Extra["downloadCategories"]; ok { + t.Fatal("stored config still carries the legacy key") + } + if len(stored.Categories) != 1 || stored.Categories[0].NameKey != "categoryMusic" { + t.Fatalf("stored categories = %+v, want the migrated list", stored.Categories) + } +} + +// readOnlyConfigStorage refuses every config write, so the setup can not +// persist a migrated category list. +type readOnlyConfigStorage struct { + Storage +} + +func (s *readOnlyConfigStorage) Put(bucket string, key string, v any) error { + if bucket == bucketConfig { + return errors.New("config storage is read only") + } + return s.Storage.Put(bucket, key, v) +} + +func TestDownloader_SetupFailsWhenTheMigrationCanNotBePersisted(t *testing.T) { + storage := NewMemStorage() + if err := storage.Setup([]string{bucketConfig}); err != nil { + t.Fatal(err) + } + legacy := &base.DownloaderStoreConfig{ + DownloadDir: t.TempDir(), + Extra: map[string]any{ + "downloadCategories": []any{ + map[string]any{"name": "Music", "path": filepath.Join(t.TempDir(), "Music")}, + }, + }, + } + if err := storage.Put(bucketConfig, "config", legacy); err != nil { + t.Fatal(err) + } + + // The migrated list can not be persisted, so the setup reports the write + // error instead of continuing with a category list that is lost on restart. + downloader := NewDownloader(&DownloaderConfig{Storage: &readOnlyConfigStorage{Storage: storage}, StorageDir: t.TempDir()}) + if err := downloader.Setup(); err == nil { + t.Fatal("setup reported no error, want the failed config write") + } +} + +// categoryMatchingCases is the shared extension rule table of the backend and +// the ui. The ui runs the same file, see +// ui/flutter/test/api/downloader_config_test.dart, so a rule that only changes +// on one side breaks the other side's test. +type categoryMatchingCases struct { + NormalizeExtensions []struct { + Input []string `json:"input"` + Expected []string `json:"expected"` + } `json:"normalizeExtensions"` +} + +func TestSharedCategoryMatchingRules(t *testing.T) { + data, err := os.ReadFile(filepath.Join("testdata", "category_matching_cases.json")) + if err != nil { + t.Fatal(err) + } + var cases categoryMatchingCases + if err := json.Unmarshal(data, &cases); err != nil { + t.Fatal(err) + } + for _, c := range cases.NormalizeExtensions { + got := normalizeCategoryExtensions(c.Input) + if !slices.Equal(got, c.Expected) { + t.Errorf("normalizeCategoryExtensions(%q) = %q, want %q", c.Input, got, c.Expected) + } + } +} diff --git a/pkg/download/downloader.go b/pkg/download/downloader.go index 291f29c9..be7fe323 100644 --- a/pkg/download/downloader.go +++ b/pkg/download/downloader.go @@ -191,6 +191,15 @@ func (d *Downloader) Setup() error { FirstLoad: true, } } + // Move the legacy extra.downloadCategories list written by v2.0.0-beta + // builds into the top level categories field and persist the cleared key, + // before Init seeds the built-in categories. The config is written + // directly, PutConfig would validate a config that is not initialized yet. + if d.cfg.DownloaderStoreConfig.MigrateLegacyExtraCategories() { + if err := d.storage.Put(bucketConfig, "config", d.cfg.DownloaderStoreConfig); err != nil { + return err + } + } // Initialize the current backend configuration schema and defaults. if err := d.initProtocolConfigs(); err != nil { return err @@ -537,7 +546,8 @@ func (d *Downloader) Resolve(req *base.Request) (rr *ResolveResult, err error) { } if res != nil && len(res.Files) > 0 { rr = &ResolveResult{ - Res: res, + Res: res, + SuggestedDownloadDir: d.suggestedDownloadDir(res), } return } @@ -554,8 +564,9 @@ func (d *Downloader) Resolve(req *base.Request) (rr *ResolveResult, err error) { d.fetcherCache[rrId] = fetcher d.fetcherMapLock.Unlock() rr = &ResolveResult{ - ID: rrId, - Res: fetcher.Meta().Res, + ID: rrId, + Res: fetcher.Meta().Res, + SuggestedDownloadDir: d.suggestedDownloadDir(fetcher.Meta().Res), } return } @@ -654,26 +665,32 @@ func (d *Downloader) CreateWithOptions(rrId string, opts *base.Options) (taskId if opts == nil { opts = &base.Options{} } - opts, err = d.initOptions(opts.Clone(), fetcher.Meta().Req.URL) + // The options of the caller decide the global default download directory, + // the task options are prepared later, when the task starts. + defaultPath, err := d.prepareDefaultPath(opts) + if err != nil { + return "", err + } + taskOpts, err := d.initOptions(opts.Clone(), fetcher.Meta().Req.URL) if err != nil { return "", err } if res := fetcher.Meta().Res; res != nil { - for _, index := range opts.SelectFiles { + for _, index := range taskOpts.SelectFiles { if index < 0 || index >= len(res.Files) { return "", fmt.Errorf("invalid selected file index: %d", index) } } - opts.InitSelectFiles(len(res.Files)) + taskOpts.InitSelectFiles(len(res.Files)) } - fetcher.Meta().Opts = opts + fetcher.Meta().Opts = taskOpts if res := fetcher.Meta().Res; res != nil { res.CalcSize(fetcher.Meta().Opts.SelectFiles) } // Resolve has no creation options. Publish a submitted HTTP estimate now // while preserving any size already confirmed by the response. - if opts.Size > 0 { - if err := fetcher.Patch(nil, &base.Options{Size: opts.Size}); err != nil { + if taskOpts.Size > 0 { + if err := fetcher.Patch(nil, &base.Options{Size: taskOpts.Size}); err != nil { return "", err } } @@ -682,10 +699,6 @@ func (d *Downloader) CreateWithOptions(rrId string, opts *base.Options) (taskId delete(d.fetcherCache, rrId) d.fetcherMapLock.Unlock() }() - defaultPath, err := d.prepareDefaultPath(fetcher.Meta().Opts) - if err != nil { - return "", err - } taskId, err = d.doCreate(fetcher, nil) if err == nil { d.persistDefaultPathBestEffort(defaultPath) @@ -1370,6 +1383,15 @@ func (d *Downloader) GetConfig() (*base.DownloaderStoreConfig, error) { return util.DeepClone(d.cfg.DownloaderStoreConfig), nil } +// IsFirstLoad reports whether Setup created the config instead of loading it +// from storage. FirstLoad is not serialized, so the clone GetConfig returns +// always reports false. +func (d *Downloader) IsFirstLoad() bool { + d.configLock.RLock() + defer d.configLock.RUnlock() + return d.cfg.FirstLoad +} + func (d *Downloader) PutConfig(v *base.DownloaderStoreConfig) error { if v == nil { return errors.New("config is nil") @@ -1433,7 +1455,10 @@ func (d *Downloader) PutConfig(v *base.DownloaderStoreConfig) error { } func (d *Downloader) prepareDefaultPath(opts *base.Options) (string, error) { - if opts == nil || !opts.AsDefaultPath || strings.TrimSpace(opts.Path) == "" { + // Only a directory the user named explicitly becomes the global default + // download directory. A task that keeps the auto categorize decision owns + // its directory, the backend must not turn it into the global default. + if opts == nil || !opts.SkipAutoCategorize || !opts.AsDefaultPath || strings.TrimSpace(opts.Path) == "" { return "", nil } selectedPath := strings.TrimSpace(opts.Path) @@ -1780,6 +1805,9 @@ func (d *Downloader) doCreate(f fetcher.Fetcher, opts *base.Options) (taskId str task.Meta = f.Meta() task.Progress = &Progress{} _, task.Uploading = f.(fetcher.Uploader) + // A new task still needs its first preparation, it decides the download + // directory and the final name of the task. + task.NeedsPreparation = true initTask(task) if err = d.syncBlobTaskLease(task); err != nil { return "", err @@ -1823,6 +1851,10 @@ func httpSizeOptions(opts *base.Options, protocol string) *base.Options { return opts } +// initOptions makes sure the option set is usable: a nil option set becomes +// an empty one, the selection is never nil and the base download directory +// falls back to the configured one. The directory is decided and checked +// against the white list when the task starts. func (d *Downloader) initOptions(opts *base.Options, requestURL string) (*base.Options, error) { if opts == nil { opts = &base.Options{} @@ -1838,17 +1870,12 @@ func (d *Downloader) initOptions(opts *base.Options, requestURL string) (*base.O opts.SelectFiles = make([]int, 0) } if opts.Path == "" { - storeConfig, err := d.GetConfig() + cfg, err := d.GetConfig() if err != nil { return nil, err } - opts.Path = storeConfig.DownloadDir + opts.Path = cfg.DownloadDir } - path, err := d.initDownloadPath(opts.Path) - if err != nil { - return nil, err - } - opts.Path = path return opts, nil } @@ -1879,8 +1906,68 @@ func (d *Downloader) statusMut(task *Task, fn func() (bool, error)) (bool, error return fn() } +// prepareTask runs the first preparation of a task: it decides the download +// directory, resolves a duplicate name in it and persists both. The task keeps +// its NeedsPreparation flag when the preparation fails, so a retry prepares it +// again. The caller holds checkDuplicateLock when the fetcher manager renames +// duplicates, the lock has to stay held until the fetcher created the file. +func (d *Downloader) prepareTask(task *Task) error { + if task.Meta.Res == nil { + return errors.New("task resource is not resolved") + } + preparedMeta := *task.Meta + preparedOpts := *task.Meta.Opts + preparedMeta.Opts = &preparedOpts + preparedMeta.Res = util.DeepClone(task.Meta.Res) + dir, err := d.decideDownloadDir(preparedMeta.Opts, preparedMeta.Res) + if err != nil { + return err + } + preparedMeta.Opts.Path = dir + + if task.fetcherManager.AutoRename() { + preparedMeta.Opts.Name = util.SafeFilename(preparedMeta.Opts.Name) + // check if the download file is duplicated and rename it automatically. + if preparedMeta.Res.Name != "" { + preparedMeta.Res.Name = util.SafeFilename(preparedMeta.Res.Name) + fullDirPath := preparedMeta.FolderPath() + newName, err := util.CheckDuplicateAndRename(fullDirPath) + if err != nil { + return err + } + preparedMeta.Opts.Name = newName + } else { + preparedMeta.Res.Files[0].Name = util.SafeFilename(preparedMeta.Res.Files[0].Name) + fullFilePath := preparedMeta.SingleFilepath() + newName, err := util.CheckDuplicateAndRename(fullFilePath) + if err != nil { + return err + } + preparedMeta.Opts.Name = newName + } + } + + preparedMeta.Res.CalcSize(preparedMeta.Opts.SelectFiles) + + // Save a candidate task before publishing its metadata. The fetcher keeps + // its resolved protocol state, so a failed save cannot change the live + // directory, name or preparation flag. + task.statusLock.Lock() + preparedTask := *task + task.statusLock.Unlock() + preparedTask.Meta = &preparedMeta + preparedTask.NeedsPreparation = false + if err := d.saveTask(&preparedTask); err != nil { + return err + } + task.statusLock.Lock() + *task.Meta = preparedMeta + task.NeedsPreparation = false + task.statusLock.Unlock() + return nil +} + func (d *Downloader) doStart(task *Task) (err error) { - var isCreate bool var generation uint64 isReturn, err := d.statusMut(task, func() (isReturn bool, err error) { if task.Status == base.DownloadStatusRunning || task.Status == base.DownloadStatusDone { @@ -1893,7 +1980,6 @@ func (d *Downloader) doStart(task *Task) (err error) { d.Logger.Error().Stack().Err(err).Msgf("restore fetcher failed, task id: %s", task.ID) return } - isCreate = task.Status == base.DownloadStatusReady task.updateStatus(base.DownloadStatusRunning) task.runGeneration++ generation = task.runGeneration @@ -1933,7 +2019,8 @@ func (d *Downloader) doStart(task *Task) (err error) { if err := d.syncBlobTaskLease(task); err != nil { return err } - if task.Meta.Res == nil { + resolvedHere := task.Meta.Res == nil + if resolvedHere { err := task.fetcher.Resolve(task.Meta.Req) if err != nil { return err @@ -1941,32 +2028,25 @@ func (d *Downloader) doStart(task *Task) (err error) { task.Meta.Res = task.fetcher.Meta().Res } - if isCreate { + if task.NeedsPreparation { if task.fetcherManager.AutoRename() { + // The duplicate name is resolved from the files on disk, but + // the file only appears when the fetcher starts, so the lock + // has to cover the start as well. d.checkDuplicateLock.Lock() defer d.checkDuplicateLock.Unlock() - task.Meta.Opts.Name = util.SafeFilename(task.Meta.Opts.Name) - // check if the download file is duplicated and rename it automatically. - if task.Meta.Res.Name != "" { - task.Meta.Res.Name = util.SafeFilename(task.Meta.Res.Name) - fullDirPath := task.Meta.FolderPath() - newName, err := util.CheckDuplicateAndRename(fullDirPath) - if err != nil { - return err - } - task.Meta.Opts.Name = newName - } else { - task.Meta.Res.Files[0].Name = util.SafeFilename(task.Meta.Res.Files[0].Name) - fullFilePath := task.Meta.SingleFilepath() - newName, err := util.CheckDuplicateAndRename(fullFilePath) - if err != nil { - return err - } - task.Meta.Opts.Name = newName - } } - - task.Meta.Res.CalcSize(task.Meta.Opts.SelectFiles) + // The first preparation decides the download directory and the + // final name of the task. It runs for every task that has not + // been prepared yet, no matter whether the task waited in the + // queue, was paused or failed before. + if err := d.prepareTask(task); err != nil { + return err + } + } else if _, err := d.initDownloadPath(task.Meta.Opts.Path); err != nil { + // A prepared task keeps its directory, but the directory still has + // to pass the white list on every start. + return err } task.statusLock.Lock() diff --git a/pkg/download/downloader_test.go b/pkg/download/downloader_test.go index e41f2890..8b2e5073 100644 --- a/pkg/download/downloader_test.go +++ b/pkg/download/downloader_test.go @@ -841,6 +841,9 @@ func TestDownloader_CreateDirectUsesPathAsDefault(t *testing.T) { selectedRoot := t.TempDir() defaultPathOpts := newTestDownloadOptAt(selectedRoot) defaultPathOpts.AsDefaultPath = true + // The global default download directory is only saved for a directory the + // user named explicitly, so the task skips the auto categorize decision. + defaultPathOpts.SkipAutoCategorize = true createAndWait(defaultPathOpts) cfg, err = downloader.GetConfig() if err != nil { @@ -882,11 +885,26 @@ func TestDownloader_CreateNotInWhite(t *testing.T) { if err != nil { t.Fatal(err) } - _, err = downloader.CreateWithOptions(rr.ID, newTestDownloadOpt(t)) - if err == nil { - t.Fatal("Create should reject a directory outside the allowlist") + // The directory is decided when the task starts, so a directory outside + // the allowlist fails the task instead of the create call. + taskId, err := downloader.CreateWithOptions(rr.ID, newTestDownloadOpt(t)) + if err != nil { + t.Fatal(err) + } + task := downloader.GetTask(taskId) + if task == nil { + t.Fatal("task not found") + } + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + if task.Status == base.DownloadStatusError { + break + } + time.Sleep(20 * time.Millisecond) + } + if task.Status != base.DownloadStatusError { + t.Fatalf("task status = %q, want the task to be rejected by the allowlist", task.Status) } - } func TestDownloader_CreateDirectBatch(t *testing.T) { @@ -1142,6 +1160,33 @@ func TestDownloader_CreateRename(t *testing.T) { } } +func TestDownloader_IsFirstLoadSurvivesTheConfigClone(t *testing.T) { + d := NewDownloader(&DownloaderConfig{StorageDir: t.TempDir(), TempDir: t.TempDir()}) + if err := d.Setup(); err != nil { + t.Fatal(err) + } + defer d.Close() + + if !d.IsFirstLoad() { + t.Fatal("a downloader that created its config reports IsFirstLoad() = false") + } + // GetConfig clones through json and FirstLoad is not serialized, so the cmd + // layer cannot read the flag from the returned config. + cfg, err := d.GetConfig() + if err != nil { + t.Fatal(err) + } + if cfg.FirstLoad { + t.Fatal("the cloned config must not report the first load") + } + if err := d.PutConfig(cfg); err != nil { + t.Fatal(err) + } + if d.IsFirstLoad() { + t.Fatal("a downloader that stored its config reports IsFirstLoad() = true") + } +} + func TestDownloader_StoreAndRestore(t *testing.T) { listener := test.StartTestSlowFileServer(time.Millisecond * 2000) defer listener.Close() diff --git a/pkg/download/model.go b/pkg/download/model.go index 85fa4827..84ffc54a 100644 --- a/pkg/download/model.go +++ b/pkg/download/model.go @@ -21,6 +21,11 @@ import ( type ResolveResult struct { ID string `json:"id"` Res *base.Resource `json:"res"` + // SuggestedDownloadDir is the category directory the created task would be + // routed to. It is only set when auto categorize is enabled, the resource is + // a single file, a category matches and its directory passes the white list + // check. It is a hint for the ui, the create call decides the final path. + SuggestedDownloadDir string `json:"suggestedDownloadDir,omitempty"` } // Task represents a download unit. @@ -39,6 +44,14 @@ type Task struct { // finished. A done task never leaves the done state, so the value is // written once and kept. DoneAt *time.Time `json:"doneAt"` + // NeedsPreparation marks a task that has not run its first preparation + // yet. The preparation decides the download directory of the task and + // resolves the duplicate name in it. It runs once, after the resource is + // resolved, and the flag is cleared afterwards, so a queued, paused or + // retried task is still prepared while a prepared task is never + // classified or renamed again. A task stored before this flag existed + // does not have it and keeps its directory and name. + NeedsPreparation bool `json:"needsPreparation,omitempty"` producer production.Source producerReceived int64 diff --git a/pkg/download/testdata/category_matching_cases.json b/pkg/download/testdata/category_matching_cases.json new file mode 100644 index 00000000..7ac8fc13 --- /dev/null +++ b/pkg/download/testdata/category_matching_cases.json @@ -0,0 +1,59 @@ +{ + "normalizeExtensions": [ + { + "input": [ + "exe, msi", + " ZIP ", + ".tar.gz", + "", + "exe" + ], + "expected": [ + "exe", + "msi", + "zip", + "tar.gz" + ] + }, + { + "input": [], + "expected": [] + }, + { + "input": [ + " " + ], + "expected": [] + }, + { + "input": [ + "video/mp4" + ], + "expected": [ + "video/mp4" + ] + }, + { + "input": [ + ".exe", + "..msi" + ], + "expected": [ + "exe", + "msi" + ] + }, + { + "input": [ + "EXE", + "ſ", + "Ä" + ], + "expected": [ + "exe", + "ſ", + "Ä" + ] + } + ] +} diff --git a/pkg/mcpserver/server.go b/pkg/mcpserver/server.go index 367d29d2..e14d57c6 100644 --- a/pkg/mcpserver/server.go +++ b/pkg/mcpserver/server.go @@ -139,11 +139,12 @@ type checksumOption struct { // downloadOptions is the MCP-facing form of base.Options. Only HTTP currently // defines protocol-specific task options, so Extra has one concrete schema. type downloadOptions struct { - Name string `json:"name,omitempty" jsonschema:"Optional output file or directory name"` - Path string `json:"path,omitempty" jsonschema:"Optional download directory"` - AsDefaultPath bool `json:"asDefaultPath,omitempty" jsonschema:"Save path as the default download directory after task creation"` - SelectFiles []int `json:"selectFiles,omitempty" jsonschema:"Optional zero-based file indexes to download from a multi-file resource"` - Extra *httpTaskExtra `json:"extra,omitempty" jsonschema:"Optional HTTP task settings"` + Name string `json:"name,omitempty" jsonschema:"Optional output file or directory name"` + Path string `json:"path,omitempty" jsonschema:"Optional base download directory"` + AsDefaultPath bool `json:"asDefaultPath,omitempty" jsonschema:"Save path as the default download directory after task creation"` + SkipAutoCategorize bool `json:"skipAutoCategorize,omitempty" jsonschema:"Keep the base download directory instead of routing the task to its category directory"` + SelectFiles []int `json:"selectFiles,omitempty" jsonschema:"Optional zero-based file indexes to download from a multi-file resource"` + Extra *httpTaskExtra `json:"extra,omitempty" jsonschema:"Optional HTTP task settings"` } func requestExtraSchema() *jsonschema.Schema { @@ -213,11 +214,12 @@ func (o *downloadOptions) baseOptions() *base.Options { } } return &base.Options{ - Name: o.Name, - Path: o.Path, - AsDefaultPath: o.AsDefaultPath, - SelectFiles: o.SelectFiles, - Extra: extra, + Name: o.Name, + Path: o.Path, + AsDefaultPath: o.AsDefaultPath, + SkipAutoCategorize: o.SkipAutoCategorize, + SelectFiles: o.SelectFiles, + Extra: extra, } } @@ -228,6 +230,9 @@ type resolveTaskInput struct { type resolveTaskOutput struct { ID string `json:"id,omitempty"` Res *base.Resource `json:"res"` + // SuggestedDownloadDir is the category directory the created task would be + // routed to, empty when auto categorize does not apply. + SuggestedDownloadDir string `json:"suggestedDownloadDir,omitempty"` } type createTaskInput struct { @@ -331,7 +336,11 @@ func registerTools(server *mcp.Server, downloader *download.Downloader) { if err != nil { return nil, nil, err } - return nil, &resolveTaskOutput{ID: result.ID, Res: result.Res}, nil + return nil, &resolveTaskOutput{ + ID: result.ID, + Res: result.Res, + SuggestedDownloadDir: result.SuggestedDownloadDir, + }, nil }) mcp.AddTool(server, &mcp.Tool{ diff --git a/pkg/mcpserver/server_test.go b/pkg/mcpserver/server_test.go index 3da3bbce..097fe841 100644 --- a/pkg/mcpserver/server_test.go +++ b/pkg/mcpserver/server_test.go @@ -112,6 +112,21 @@ func TestStreamableHTTPTools(t *testing.T) { if !schemaIncludesType(optsExtra, "object") || optsExtra["additionalProperties"] != false { t.Fatalf("%s opts.extra schema = %#v, want concrete HTTP task settings", tool.Name, optsExtra) } + opts := nestedSchemaProperty(t, schema, "opts") + optsProperties, ok := opts["properties"].(map[string]any) + if !ok { + t.Fatalf("%s opts schema has no properties: %s", tool.Name, inputSchema) + } + if _, exists := optsProperties["skipAutoCategorize"]; !exists { + t.Fatalf("%s opts schema is missing skipAutoCategorize: %s", tool.Name, inputSchema) + } + } + resolveOutput, err := json.Marshal(resolveTaskTool.OutputSchema) + if err != nil { + t.Fatal(err) + } + if !bytes.Contains(resolveOutput, []byte(`"suggestedDownloadDir"`)) { + t.Fatalf("resolve_task output schema is missing suggestedDownloadDir: %s", resolveOutput) } outputSchema, err := json.Marshal(getTaskTool.OutputSchema) if err != nil { diff --git a/pkg/rest/server_test.go b/pkg/rest/server_test.go index 3699f991..b3e300cc 100644 --- a/pkg/rest/server_test.go +++ b/pkg/rest/server_test.go @@ -1195,6 +1195,14 @@ func doTest0(onStart func(cfg *model.StartConfig), handler func()) { onStart(cfg) } fileListener := doStart(cfg) + // The server process configures the default download directory on first + // load; a task without a base directory is rejected without it. + if current, err := Downloader.GetConfig(); err == nil { + current.DownloadDir = downloadDir + if err := Downloader.PutConfig(current); err != nil { + panic(err) + } + } defer func() { if err := fileListener.Close(); err != nil { panic(err) diff --git a/ui/flutter/lib/api/model/downloader_config.dart b/ui/flutter/lib/api/model/downloader_config.dart index f4813b42..19ef03e3 100644 --- a/ui/flutter/lib/api/model/downloader_config.dart +++ b/ui/flutter/lib/api/model/downloader_config.dart @@ -1,4 +1,5 @@ import 'package:json_annotation/json_annotation.dart'; +import 'package:path/path.dart' as path; part 'downloader_config.g.dart'; @@ -17,12 +18,16 @@ class DownloaderConfig { ApiServerConfig api = ApiServerConfig(); bool autoStartTasks; bool autoDeleteMissingFileTasks; + List categories; + bool autoCategorize; DownloaderConfig({ this.downloadDir = '', this.maxRunning = 0, this.autoStartTasks = false, this.autoDeleteMissingFileTasks = false, + this.categories = const [], + this.autoCategorize = false, }); factory DownloaderConfig.fromJson(Map json) => _$DownloaderConfigFromJson(json); @@ -192,7 +197,6 @@ class ExtraConfig { bool runAsMenubarApp; bool analyticsEnabled; String analyticsClientId; - List downloadCategories; ExtraConfig({ this.themeMode = '', @@ -211,7 +215,6 @@ class ExtraConfig { this.runAsMenubarApp = false, this.analyticsEnabled = true, this.analyticsClientId = '', - this.downloadCategories = const [], }) : windowState = windowState ?? WindowStateConfig(); factory ExtraConfig.fromJson(Map? json) => @@ -241,6 +244,9 @@ class DownloadCategory { bool isBuiltIn; String? nameKey; // i18n key for built-in categories (e.g., 'categoryMusic') bool isDeleted; // Mark built-in categories as deleted instead of removing them + // File extensions routed to this category, without leading dots, e.g. ['exe', 'msi']. + // An empty list means the category never matches automatically. + List extensions; DownloadCategory({ required this.name, @@ -248,13 +254,93 @@ class DownloadCategory { this.isBuiltIn = false, this.nameKey, this.isDeleted = false, + this.extensions = const [], }); factory DownloadCategory.fromJson(Map json) => _$DownloadCategoryFromJson(json); Map toJson() => _$DownloadCategoryToJson(this); + + /// The effective extension list. The result is always normalized: split on + /// commas/whitespace, trimmed, lowercased, without leading dots and without + /// duplicates; the original order is preserved. An empty list means the + /// category never matches automatically. + List effectiveExtensions() => normalizeCategoryExtensions(extensions); } +/// Lowercases the ascii letters of [value] and leaves the rest alone. The +/// backend lowercases the same way, because the unicode case tables of the two +/// languages disagree for some code points and would let the two sides pick a +/// different category for the same file. +String asciiLower(String value) { + final lowered = StringBuffer(); + for (final unit in value.codeUnits) { + lowered.writeCharCode(unit >= 0x41 && unit <= 0x5A ? unit + 0x20 : unit); + } + return lowered.toString(); +} + +/// Normalizes free form extension input into tokens: splits on commas and +/// whitespace, trims, lowercases, strips leading dots, drops empties and +/// deduplicates while keeping the original input order. +List normalizeCategoryExtensions(Iterable raw) { + final result = {}; + for (final item in raw) { + for (final part in item.split(RegExp(r'[,\s]+'))) { + var extension = asciiLower(part.trim()); + while (extension.startsWith('.')) { + extension = extension.substring(1); + } + if (extension.isNotEmpty) { + result.add(extension); + } + } + } + return result.toList(); +} + +/// Default extension lists of the built-in categories, keyed by the i18n +/// nameKey. Used to seed the built-in categories on first launch and to offer +/// the restore-defaults action in the category dialog. +const kDefaultCategoryExtensions = >{ + 'categoryProgram': ['exe', 'msi', 'msix', 'apk', 'dmg', 'deb', 'rpm', 'pkg', 'appimage'], + 'categoryVideo': ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'mpg', 'mpeg', '3gp'], + 'categoryMusic': ['mp3', 'flac', 'wav', 'aac', 'ogg', 'm4a', 'wma', 'ape'], + 'categoryDocument': ['pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'txt', 'md', 'csv', 'epub', 'rtf'], +}; + +/// Rebases built-in category paths that still live under [oldDir] onto +/// [newDir] after the default download directory changed. Categories the +/// user moved elsewhere, and user created categories, are left alone. +void rebaseBuiltinCategoryPaths(DownloaderConfig config, {required String oldDir, required String newDir}) { + if (oldDir.trim().isEmpty || newDir.trim().isEmpty) { + return; + } + // The backend seeds the categories with the separator of its platform, which + // the path package of the client does not always understand, so both sides + // are compared in the forward slash form. + final from = _forwardSlash(oldDir); + final to = _forwardSlash(newDir); + if (path.equals(from, to)) { + return; + } + for (final category in config.categories) { + if (!category.isBuiltIn || category.isDeleted || category.path.trim().isEmpty) { + continue; + } + final current = _forwardSlash(category.path); + if (path.equals(current, from)) { + category.path = to; + } else if (path.isWithin(from, current)) { + category.path = path.join(to, path.relative(current, from: from)); + } + } +} + +/// Rewrites the windows separator of [value] to the forward slash the path +/// package uses. +String _forwardSlash(String value) => value.replaceAll('\\', '/'); + @JsonSerializable() class WebhookConfig { bool enable; diff --git a/ui/flutter/lib/api/model/downloader_config.g.dart b/ui/flutter/lib/api/model/downloader_config.g.dart index d59e43d4..a5ede5f5 100644 --- a/ui/flutter/lib/api/model/downloader_config.g.dart +++ b/ui/flutter/lib/api/model/downloader_config.g.dart @@ -12,6 +12,12 @@ DownloaderConfig _$DownloaderConfigFromJson(Map json) => maxRunning: (json['maxRunning'] as num?)?.toInt() ?? 0, autoStartTasks: json['autoStartTasks'] as bool? ?? false, autoDeleteMissingFileTasks: json['autoDeleteMissingFileTasks'] as bool? ?? false, + categories: + (json['categories'] as List?) + ?.map((e) => DownloadCategory.fromJson(e as Map)) + .toList() ?? + const [], + autoCategorize: json['autoCategorize'] as bool? ?? false, ) ..protocolConfig = ProtocolConfig.fromJson(json['protocolConfig'] as Map?) ..extra = ExtraConfig.fromJson(json['extra'] as Map?) @@ -37,6 +43,8 @@ Map _$DownloaderConfigToJson(DownloaderConfig instance) => e.toJson()).toList(), + 'autoCategorize': instance.autoCategorize, }; ApiServerConfig _$ApiServerConfigFromJson(Map json) => ApiServerConfig( @@ -186,11 +194,6 @@ ExtraConfig _$ExtraConfigFromJson(Map json) => ExtraConfig( runAsMenubarApp: json['runAsMenubarApp'] as bool? ?? false, analyticsEnabled: json['analyticsEnabled'] as bool? ?? true, analyticsClientId: json['analyticsClientId'] as String? ?? '', - downloadCategories: - (json['downloadCategories'] as List?) - ?.map((e) => DownloadCategory.fromJson(e as Map)) - .toList() ?? - const [], ); Map _$ExtraConfigToJson(ExtraConfig instance) => { @@ -210,7 +213,6 @@ Map _$ExtraConfigToJson(ExtraConfig instance) => e.toJson()).toList(), }; WindowStateConfig _$WindowStateConfigFromJson(Map json) => WindowStateConfig( @@ -231,6 +233,7 @@ DownloadCategory _$DownloadCategoryFromJson(Map json) => Downlo isBuiltIn: json['isBuiltIn'] as bool? ?? false, nameKey: json['nameKey'] as String?, isDeleted: json['isDeleted'] as bool? ?? false, + extensions: (json['extensions'] as List?)?.map((e) => e as String).toList() ?? const [], ); Map _$DownloadCategoryToJson(DownloadCategory instance) => { @@ -239,6 +242,7 @@ Map _$DownloadCategoryToJson(DownloadCategory instance) => json) => WebhookConfig( diff --git a/ui/flutter/lib/api/model/options.dart b/ui/flutter/lib/api/model/options.dart index d208e7a6..7c1d5574 100644 --- a/ui/flutter/lib/api/model/options.dart +++ b/ui/flutter/lib/api/model/options.dart @@ -9,6 +9,10 @@ class Options { String name; String path; bool asDefaultPath; + + /// Keep the base download directory instead of routing the task to the + /// directory of the category its resolved file belongs to. + bool skipAutoCategorize; List selectFiles; Object? extra; @@ -17,6 +21,7 @@ class Options { this.name = '', this.path = '', this.asDefaultPath = false, + this.skipAutoCategorize = false, this.selectFiles = const [], this.extra, }); diff --git a/ui/flutter/lib/api/model/options.g.dart b/ui/flutter/lib/api/model/options.g.dart index dda01fee..2ff99978 100644 --- a/ui/flutter/lib/api/model/options.g.dart +++ b/ui/flutter/lib/api/model/options.g.dart @@ -11,6 +11,7 @@ Options _$OptionsFromJson(Map json) => Options( name: json['name'] as String? ?? '', path: json['path'] as String? ?? '', asDefaultPath: json['asDefaultPath'] as bool? ?? false, + skipAutoCategorize: json['skipAutoCategorize'] as bool? ?? false, selectFiles: (json['selectFiles'] as List?) ?.map((e) => (e as num).toInt()) @@ -24,6 +25,7 @@ Map _$OptionsToJson(Options instance) => { 'name': instance.name, 'path': instance.path, 'asDefaultPath': instance.asDefaultPath, + 'skipAutoCategorize': instance.skipAutoCategorize, 'selectFiles': instance.selectFiles, 'extra': ?instance.extra, }; diff --git a/ui/flutter/lib/api/model/resolve_result.dart b/ui/flutter/lib/api/model/resolve_result.dart index 6897314c..6fa5f817 100644 --- a/ui/flutter/lib/api/model/resolve_result.dart +++ b/ui/flutter/lib/api/model/resolve_result.dart @@ -9,7 +9,12 @@ class ResolveResult { String id; Resource res; - ResolveResult({this.id = "", required this.res}); + /// The category directory the created task would be routed to. Empty when + /// auto categorize does not apply. It is a hint for the ui, the create call + /// decides the final path. + String suggestedDownloadDir; + + ResolveResult({this.id = "", required this.res, this.suggestedDownloadDir = ""}); factory ResolveResult.fromJson(Map json) => _$ResolveResultFromJson(json); Map toJson() => _$ResolveResultToJson(this); diff --git a/ui/flutter/lib/api/model/resolve_result.g.dart b/ui/flutter/lib/api/model/resolve_result.g.dart index afee28bf..bd004892 100644 --- a/ui/flutter/lib/api/model/resolve_result.g.dart +++ b/ui/flutter/lib/api/model/resolve_result.g.dart @@ -6,10 +6,14 @@ part of 'resolve_result.dart'; // JsonSerializableGenerator // ************************************************************************** -ResolveResult _$ResolveResultFromJson(Map json) => - ResolveResult(id: json['id'] as String? ?? "", res: Resource.fromJson(json['res'] as Map)); +ResolveResult _$ResolveResultFromJson(Map json) => ResolveResult( + id: json['id'] as String? ?? "", + res: Resource.fromJson(json['res'] as Map), + suggestedDownloadDir: json['suggestedDownloadDir'] as String? ?? "", +); Map _$ResolveResultToJson(ResolveResult instance) => { 'id': instance.id, 'res': instance.res.toJson(), + 'suggestedDownloadDir': instance.suggestedDownloadDir, }; diff --git a/ui/flutter/lib/app/application/app_runtime_controller.dart b/ui/flutter/lib/app/application/app_runtime_controller.dart index 829da4ba..c463ecc0 100644 --- a/ui/flutter/lib/app/application/app_runtime_controller.dart +++ b/ui/flutter/lib/app/application/app_runtime_controller.dart @@ -1,11 +1,11 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import 'package:path/path.dart' as path; import 'package:path_provider/path_provider.dart'; import '../../api/api.dart' as api; import '../../api/model/downloader_config.dart'; +import 'downloader_config_init.dart'; import '../../core/capabilities/app_capabilities.dart'; import '../../core/common/api_server_state.dart'; import '../../core/common/start_config.dart'; @@ -242,60 +242,14 @@ class AppRuntimeController extends AsyncNotifier { return cfg; } - Future _loadDownloaderConfig() async { - DownloaderConfig config; - try { - config = await ref.read(gopeedServiceProvider).getConfig(); - } catch (error, stackTrace) { - logger.w('load downloader config failed', error, stackTrace); - config = DownloaderConfig(); - } - await _initDownloaderConfig(config); - return config; - } - - Future _initDownloaderConfig(DownloaderConfig config) async { - final extra = config.extra; - if (extra.themeMode.isEmpty) { - extra.themeMode = 'system'; - } - if (extra.themeColor.isEmpty) { - extra.themeColor = 'green'; - } - if (config.proxy.scheme.isEmpty) { - config.proxy.scheme = 'http'; - } - if (config.downloadDir.isEmpty) { - config.downloadDir = await _defaultDownloadDir(); - } - if (extra.downloadCategories.isEmpty) { - extra.downloadCategories = [ - DownloadCategory( - name: '', - path: path.join(config.downloadDir, 'Music'), - isBuiltIn: true, - nameKey: 'categoryMusic', - ), - DownloadCategory( - name: '', - path: path.join(config.downloadDir, 'Video'), - isBuiltIn: true, - nameKey: 'categoryVideo', - ), - DownloadCategory( - name: '', - path: path.join(config.downloadDir, 'Document'), - isBuiltIn: true, - nameKey: 'categoryDocument', - ), - DownloadCategory( - name: '', - path: path.join(config.downloadDir, 'Program'), - isBuiltIn: true, - nameKey: 'categoryProgram', - ), - ]; - } + Future _loadDownloaderConfig() { + return loadDownloaderConfig( + load: () => ref.read(gopeedServiceProvider).getConfig(), + save: (config) => ref.read(gopeedServiceProvider).putConfig(config), + defaultDownloadDir: _defaultDownloadDir, + onLoadError: (error, stackTrace) => logger.w('load downloader config failed', error, stackTrace), + onSaveError: (error, stackTrace) => logger.w('persist downloader config failed', error, stackTrace), + ); } Future _initAnalytics(DownloaderConfig config) async { @@ -322,11 +276,13 @@ class AppRuntimeController extends AsyncNotifier { } Future _defaultDownloadDir() async { + // An unknown directory stays empty: the config is sent to the backend, and + // a relative path would point at the backend working directory. if (kIsWeb) { - return './'; + return ''; } if (Util.isDesktop()) { - return (await getDownloadsDirectory())?.path ?? './'; + return (await getDownloadsDirectory())?.path ?? ''; } if (Util.isAndroid()) { return (await getExternalStorageDirectory())?.path ?? (await getApplicationDocumentsDirectory()).path; @@ -334,7 +290,7 @@ class AppRuntimeController extends AsyncNotifier { if (Util.isIOS()) { return (await getApplicationDocumentsDirectory()).path; } - return './'; + return ''; } String _runningAddress(StartConfig config, int runningPort) { diff --git a/ui/flutter/lib/app/application/downloader_config_init.dart b/ui/flutter/lib/app/application/downloader_config_init.dart new file mode 100644 index 00000000..dfe82c16 --- /dev/null +++ b/ui/flutter/lib/app/application/downloader_config_init.dart @@ -0,0 +1,92 @@ +import 'package:path/path.dart' as path; + +import '../../api/model/downloader_config.dart'; + +/// Reads the downloader config, applies first-launch defaults and persists +/// the result when the init changed it. Persisting is skipped when the +/// initial load failed: writing defaults over an unreadable store would +/// wipe the user's configuration. +Future loadDownloaderConfig({ + required Future Function() load, + required Future Function(DownloaderConfig config) save, + required Future Function() defaultDownloadDir, + void Function(Object error, StackTrace stackTrace)? onLoadError, + void Function(Object error, StackTrace stackTrace)? onSaveError, +}) async { + DownloaderConfig config; + var loaded = true; + try { + config = await load(); + } catch (error, stackTrace) { + onLoadError?.call(error, stackTrace); + config = DownloaderConfig(); + loaded = false; + } + final changed = await initDownloaderConfig(config, defaultDownloadDir: defaultDownloadDir); + if (loaded && changed) { + // Persist the seeded categories so the backend can route + // downloads to them right away instead of waiting for the settings page + // to save the config. + try { + await save(config); + } catch (error, stackTrace) { + onSaveError?.call(error, stackTrace); + } + } + return config; +} + +/// Applies defaults to a freshly loaded config: theme/tracker/proxy basics, +/// the default download directory, and seeding the built-in categories when +/// nothing exists yet. Returns whether the config changed and should be +/// persisted. +Future initDownloaderConfig( + DownloaderConfig config, { + required Future Function() defaultDownloadDir, +}) async { + var changed = false; + final extra = config.extra; + if (extra.themeMode.isEmpty) { + extra.themeMode = 'system'; + } + if (extra.themeColor.isEmpty) { + extra.themeColor = 'green'; + } + if (config.proxy.scheme.isEmpty) { + config.proxy.scheme = 'http'; + } + if (config.downloadDir.isEmpty) { + final downloadDir = await defaultDownloadDir(); + // An unknown directory stays empty, the backend resolves its own. + if (downloadDir.isNotEmpty) { + config.downloadDir = downloadDir; + changed = true; + } + } + if (config.categories.isEmpty && config.downloadDir.isNotEmpty) { + config.categories = [ + _builtinCategory('categoryMusic', path.join(config.downloadDir, 'Music')), + _builtinCategory('categoryVideo', path.join(config.downloadDir, 'Video')), + _builtinCategory('categoryDocument', path.join(config.downloadDir, 'Document')), + _builtinCategory('categoryProgram', path.join(config.downloadDir, 'Program')), + ]; + changed = true; + } + if (config.githubMirror.mirrors.isEmpty) { + config.githubMirror.mirrors = [ + GithubMirror(type: GithubMirrorType.jsdelivr, url: 'https://fastly.jsdelivr.net/gh', isBuiltIn: true), + GithubMirror(type: GithubMirrorType.ghProxy, url: 'https://fastgit.cc', isBuiltIn: true), + ]; + } + return changed; +} + +DownloadCategory _builtinCategory(String nameKey, String categoryPath) { + return DownloadCategory( + name: '', + path: categoryPath, + isBuiltIn: true, + nameKey: nameKey, + extensions: List.from(kDefaultCategoryExtensions[nameKey] ?? const []), + ); +} diff --git a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart index 7b8f1ce5..87fb0364 100644 --- a/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart +++ b/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' show Icons; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:path/path.dart' as path; import 'package:go_router/go_router.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import 'package:url_launcher/url_launcher.dart'; @@ -88,6 +89,7 @@ class _SettingsPageState extends ConsumerState { DownloaderConfig? _config; StartConfig? _startConfig; String? _loadedSignature; + String? _appliedDownloadDir; String? _loadedStartSignature; Timer? _textSaveTimer; bool _syncingControllers = false; @@ -470,13 +472,21 @@ class _SettingsPageState extends ConsumerState { SettingsItem( title: context.l10n.downloadCategories, child: DownloadCategoriesControl( - categories: config.extra.downloadCategories, + categories: config.categories, displayName: _categoryName, onAdd: () => unawaited(_editDownloadCategory()), onEdit: (category) => unawaited(_editDownloadCategory(category)), onDelete: (category) => unawaited(_deleteDownloadCategory(category)), ), ), + SettingsItem( + title: context.l10n.autoCategorizeByExtension, + subtitle: context.l10n.autoCategorizeByExtensionDescription, + child: shad.Switch( + value: config.autoCategorize, + onChanged: (value) => _mutateConfig((next) => next.autoCategorize = value), + ), + ), SettingsItem( title: context.l10n.maxRunning, subtitle: context.l10n.maxRunningDescription, @@ -996,6 +1006,7 @@ class _SettingsPageState extends ConsumerState { _config = DownloaderConfig.fromJson(config.toJson()); _syncingControllers = true; _downloadDirController.text = _config!.downloadDir; + _appliedDownloadDir = _config!.downloadDir; _maxRunningController.text = _config!.maxRunning.clamp(1, 256).toString(); _httpUserAgentController.text = _config!.protocolConfig.http.userAgent; _httpConnectionsController.text = _config!.protocolConfig.http.connections.clamp(1, 256).toString(); @@ -1087,7 +1098,13 @@ class _SettingsPageState extends ConsumerState { } void _applyTextControllers(DownloaderConfig config) { - config.downloadDir = _downloadDirController.text.trim(); + final downloadDir = _downloadDirController.text.trim(); + final previousDownloadDir = _appliedDownloadDir; + _appliedDownloadDir = downloadDir; + if (previousDownloadDir != null && !path.equals(previousDownloadDir, downloadDir)) { + rebaseBuiltinCategoryPaths(config, oldDir: previousDownloadDir, newDir: downloadDir); + } + config.downloadDir = downloadDir; config.maxRunning = _boundedInt(_maxRunningController, fallback: config.maxRunning, min: 1, max: 256); config.protocolConfig.http.userAgent = _httpUserAgentController.text.trim(); config.protocolConfig.http.connections = _boundedInt( @@ -1357,13 +1374,15 @@ class _SettingsPageState extends ConsumerState { category: category, initialName: category == null ? '' : _categoryName(category), initialPath: _downloadDirController.text.trim(), + categories: _config?.categories ?? const [], + displayName: _categoryName, ); if (draft == null || !mounted) return; _mutateConfig((config) { - final categories = List.from(config.extra.downloadCategories); + final categories = List.from(config.categories); if (category == null) { - categories.add(DownloadCategory(name: draft.name, path: draft.path)); + categories.add(DownloadCategory(name: draft.name, path: draft.path, extensions: draft.extensions)); } else { final index = categories.indexOf(category); if (index < 0) return; @@ -1371,10 +1390,11 @@ class _SettingsPageState extends ConsumerState { category ..name = draft.name ..path = draft.path + ..extensions = draft.extensions ..isDeleted = false; if (nameChanged) category.nameKey = null; } - config.extra.downloadCategories = categories; + config.categories = categories; }); } @@ -1386,7 +1406,7 @@ class _SettingsPageState extends ConsumerState { category.isDeleted = true; return; } - config.extra.downloadCategories = List.from(config.extra.downloadCategories)..remove(category); + config.categories = List.from(config.categories)..remove(category); }); } diff --git a/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart index dd0a40cb..ab8eb329 100644 --- a/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart +++ b/ui/flutter/lib/features/settings/presentation/widgets/download_categories_setting.dart @@ -11,12 +11,14 @@ import '../../../../shared/widgets/app_path_placeholder_button.dart'; import '../../../../shared/widgets/app_primary_button.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../l10n/l10n.dart'; +import 'extension_tag_editor.dart'; class DownloadCategoryDraft { - const DownloadCategoryDraft({required this.name, required this.path}); + const DownloadCategoryDraft({required this.name, required this.path, required this.extensions}); final String name; final String path; + final List extensions; } class DownloadCategoriesControl extends StatelessWidget { @@ -93,6 +95,7 @@ class _CategoryRow extends StatelessWidget { @override Widget build(BuildContext context) { final palette = AppPalette.of(context); + final extensions = category.effectiveExtensions(); return Row( key: ValueKey('download-category-${category.nameKey ?? category.name}-${category.path}'), children: [ @@ -111,6 +114,16 @@ class _CategoryRow extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle(color: palette.textSecondary, fontSize: 12), ), + if (extensions.isNotEmpty) ...[ + const SizedBox(height: 3), + Text( + extensions.join(', '), + key: const ValueKey('download-category-extensions'), + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle(color: palette.textSecondary, fontSize: 11), + ), + ], ], ), ), @@ -137,9 +150,29 @@ Future showDownloadCategoryDialog( DownloadCategory? category, required String initialName, required String initialPath, + required List categories, + required String Function(DownloadCategory) displayName, }) async { final nameController = TextEditingController(text: initialName); final pathController = TextEditingController(text: category?.path ?? initialPath); + final editorKey = GlobalKey(); + final nameKey = category?.nameKey; + final restoreDefaults = nameKey == null + ? const [] + : normalizeCategoryExtensions(kDefaultCategoryExtensions[nameKey] ?? const []); + // Extensions every other live category already owns, mapped to its + // display name; the edited category itself is exempt. + final takenExtensions = {}; + for (final other in categories) { + if (other.isDeleted || identical(other, category)) continue; + for (final extension in normalizeCategoryExtensions(other.effectiveExtensions())) { + takenExtensions.putIfAbsent(extension, () => displayName(other)); + } + } + + var extensionConflict = normalizeCategoryExtensions( + category?.effectiveExtensions() ?? const [], + ).any(takenExtensions.containsKey); String? validationMessage; final overlay = const shad.DialogOverlayHandler().show( @@ -150,14 +183,23 @@ Future showDownloadCategoryDialog( builder: (dialogContext, setDialogState) { final palette = AppPalette.of(dialogContext); + void reportExtensionConflict(bool value) { + if (extensionConflict == value) return; + setDialogState(() => extensionConflict = value); + } + void submit() { + if (extensionConflict) return; final name = nameController.text.trim(); final path = pathController.text.trim(); if (name.isEmpty || path.isEmpty) { setDialogState(() => validationMessage = dialogContext.l10n.categoryFieldsRequired); return; } - shad.closeOverlay(dialogContext, DownloadCategoryDraft(name: name, path: path)); + shad.closeOverlay( + dialogContext, + DownloadCategoryDraft(name: name, path: path, extensions: editorKey.currentState?.flush() ?? const []), + ); } return shad.AlertDialog( @@ -165,35 +207,49 @@ Future showDownloadCategoryDialog( content: SizedBox( key: const ValueKey('download-category-dialog'), width: (MediaQuery.sizeOf(dialogContext).width - 64).clamp(260.0, 420.0), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text(dialogContext.l10n.categoryName, style: TextStyle(color: palette.textSecondary, fontSize: 12)), - const SizedBox(height: 6), - AppTextField(key: const ValueKey('download-category-name'), controller: nameController), - const SizedBox(height: 14), - Text(dialogContext.l10n.categoryPath, style: TextStyle(color: palette.textSecondary, fontSize: 12)), - const SizedBox(height: 6), - AppPathPickerField.downloadDirectory( - fieldKey: const ValueKey('download-category-path'), - controller: pathController, - desktopWidth: AppDesignTokens.settingsFormControlWidth, - allowAndroidEditing: true, - ), - const SizedBox(height: AppDesignTokens.space4), - Align( - alignment: AlignmentDirectional.centerStart, - child: AppPathPlaceholderButton( - key: const ValueKey('download-category-placeholders'), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text(dialogContext.l10n.categoryName, style: TextStyle(color: palette.textSecondary, fontSize: 12)), + const SizedBox(height: 6), + AppTextField(key: const ValueKey('download-category-name'), controller: nameController), + const SizedBox(height: 14), + Text(dialogContext.l10n.categoryPath, style: TextStyle(color: palette.textSecondary, fontSize: 12)), + const SizedBox(height: 6), + AppPathPickerField.downloadDirectory( + fieldKey: const ValueKey('download-category-path'), controller: pathController, + desktopWidth: AppDesignTokens.settingsFormControlWidth, + allowAndroidEditing: true, ), - ), - if (validationMessage != null) ...[ - const SizedBox(height: 8), - Text(validationMessage!, style: TextStyle(color: palette.error, fontSize: 12)), + const SizedBox(height: AppDesignTokens.space4), + Align( + alignment: AlignmentDirectional.centerStart, + child: AppPathPlaceholderButton( + key: const ValueKey('download-category-placeholders'), + controller: pathController, + ), + ), + const SizedBox(height: 14), + ExtensionTagEditor( + key: editorKey, + inputKey: const ValueKey('download-category-extensions-input'), + initialTags: category?.effectiveExtensions() ?? const [], + labelText: dialogContext.l10n.categoryExtensions, + placeholderText: dialogContext.l10n.categoryExtensionsEmptyHint, + usagePlaceholderText: dialogContext.l10n.categoryExtensionsUsageHint, + restoreDefaults: restoreDefaults, + takenByCategory: takenExtensions, + onConflictChanged: reportExtensionConflict, + ), + if (validationMessage != null) ...[ + const SizedBox(height: 8), + Text(validationMessage!, style: TextStyle(color: palette.error, fontSize: 12)), + ], ], - ], + ), ), ), actions: [ @@ -201,7 +257,7 @@ Future showDownloadCategoryDialog( onPressed: () => shad.closeOverlay(dialogContext), child: Text(dialogContext.l10n.cancel), ), - AppPrimaryButton(onPressed: submit, child: Text(dialogContext.l10n.save)), + AppPrimaryButton(onPressed: extensionConflict ? null : submit, child: Text(dialogContext.l10n.save)), ], ); }, diff --git a/ui/flutter/lib/features/settings/presentation/widgets/extension_tag_editor.dart b/ui/flutter/lib/features/settings/presentation/widgets/extension_tag_editor.dart new file mode 100644 index 00000000..591ecd05 --- /dev/null +++ b/ui/flutter/lib/features/settings/presentation/widgets/extension_tag_editor.dart @@ -0,0 +1,274 @@ +import 'package:flutter/material.dart' show Icons; +import 'package:flutter/widgets.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import '../../../../api/model/downloader_config.dart'; +import '../../../../l10n/l10n.dart'; +import '../../../../shared/theme/app_design_tokens.dart'; +import '../../../../shared/theme/app_palette.dart'; +import '../../../../shared/widgets/app_text_field.dart'; + +/// Tag style editor for file extension lists. +/// +/// Typed text stays in the input until the submit action (Enter) commits it; +/// commas and whitespace split the committed text into several tags, so +/// `a b,c` becomes `a`, `b` and `c`. Every token goes through +/// [normalizeCategoryExtensions], so the tags are trimmed, lowercased, dot +/// free and deduplicated while keeping the input order. While typing, chips +/// whose tag matches the pending input are highlighted to surface +/// duplicates before they are committed. When the editor is empty and +/// [restoreDefaults] is provided, an outline restore action sits at the end +/// of the [labelText] row and refills the tags. [takenByCategory] lists +/// extensions already claimed by another category: those tokens are never +/// committed, their chips render with the error border, and the editor +/// reports the conflict through [onConflictChanged] so the host can block +/// submission while one is pending. +class ExtensionTagEditor extends StatefulWidget { + const ExtensionTagEditor({ + super.key, + this.inputKey, + required this.initialTags, + this.labelText, + this.placeholderText, + this.usagePlaceholderText, + this.restoreDefaults, + this.takenByCategory, + this.onConflictChanged, + }); + + /// Key attached to the underlying text field so tests can target it. + final Key? inputKey; + + final List initialTags; + + /// Field label rendered at the top of the editor with the restore action + /// aligned to its end. Null renders no label row unless the restore action + /// itself is visible. + final String? labelText; + + /// Shown inside the text field while there is neither a tag nor typed text. + final String? placeholderText; + + /// Shown inside the text field while there is no typed text but at least + /// one tag, carrying the usage rules once the empty-state text no longer + /// applies. + final String? usagePlaceholderText; + + /// Default extensions offered by the restore action while the editor is + /// empty. Null or empty hides the action (e.g. custom categories). + final List? restoreDefaults; + + /// Normalized extensions already owned by another category, mapped to + /// that category's display name. Tokens in this map are rejected with an + /// inline error naming the owner instead of being added to the tags. + final Map? takenByCategory; + + /// Reports whether the editor currently holds an extension claimed by + /// another category so the host can disable its submit action. + final ValueChanged? onConflictChanged; + + @override + State createState() => ExtensionTagEditorState(); +} + +class ExtensionTagEditorState extends State { + late final List _tags; + late final TextEditingController _controller; + late bool _lastConflictNotified; + + @override + void initState() { + super.initState(); + _tags = normalizeCategoryExtensions(widget.initialTags); + _controller = TextEditingController(); + // The host seeds its own initial state from the same data, so only + // subsequent changes need to be reported. + _lastConflictNotified = _firstTakenConflict() != null; + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + /// Commits any pending input text to the tags and returns the full list. + /// Extensions claimed by [takenByCategory] are left out. + List flush() { + _update(_commitPending); + final taken = widget.takenByCategory; + return List.unmodifiable(taken == null ? _tags : _tags.where((tag) => !taken.containsKey(tag))); + } + + void _update(VoidCallback fn) { + setState(fn); + final conflicted = _firstTakenConflict() != null; + if (conflicted != _lastConflictNotified) { + _lastConflictNotified = conflicted; + widget.onConflictChanged?.call(conflicted); + } + } + + void _commitPending() { + final rejected = normalizeCategoryExtensions([ + _controller.text, + ]).where((token) => widget.takenByCategory?.containsKey(token) ?? false).toList(growable: false); + _addTokens(_controller.text); + if (rejected.isEmpty) { + _controller.clear(); + } else { + // Keep the rejected tokens in the input so the error stays visible + // until the user edits them away. + _controller.text = rejected.join(' '); + _controller.selection = TextSelection.collapsed(offset: _controller.text.length); + } + } + + void _addTokens(String raw) { + for (final token in normalizeCategoryExtensions([raw])) { + if (_tags.contains(token)) continue; + if (widget.takenByCategory?.containsKey(token) ?? false) continue; + _tags.add(token); + } + } + + String? _firstTakenConflict() { + final taken = widget.takenByCategory; + if (taken == null || taken.isEmpty) return null; + for (final token in normalizeCategoryExtensions([_controller.text])) { + if (taken.containsKey(token)) return token; + } + for (final tag in _tags) { + if (taken.containsKey(tag)) return tag; + } + return null; + } + + void _handleChanged(String value) { + // Keep the placeholder and the duplicate highlight in sync while typing; + // nothing is committed until the submit action. + _update(() {}); + } + + void _handleSubmitted(String value) { + _update(_commitPending); + } + + void _removeTag(String tag) { + _update(() => _tags.remove(tag)); + } + + void _restoreDefaults() { + _update(() { + _tags + ..clear() + ..addAll(normalizeCategoryExtensions(widget.restoreDefaults ?? const [])); + }); + } + + @override + Widget build(BuildContext context) { + final palette = AppPalette.of(context); + final showPlaceholder = widget.placeholderText != null && _tags.isEmpty && _controller.text.isEmpty; + final showRestore = showPlaceholder && (widget.restoreDefaults?.isNotEmpty ?? false); + String? placeholder; + if (_controller.text.isEmpty) { + if (showPlaceholder) { + placeholder = widget.placeholderText; + } else if (_tags.isNotEmpty && widget.usagePlaceholderText != null) { + placeholder = widget.usagePlaceholderText; + } + } + final pendingDuplicates = normalizeCategoryExtensions([_controller.text]).toSet(); + final conflict = _firstTakenConflict(); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (widget.labelText != null || showRestore) ...[ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (widget.labelText != null) + Text(widget.labelText!, style: TextStyle(color: palette.textSecondary, fontSize: 12)), + if (showRestore) _buildRestoreButton(palette), + ], + ), + const SizedBox(height: 6), + ], + if (_tags.isNotEmpty) ...[ + Wrap( + spacing: AppDesignTokens.space4, + runSpacing: AppDesignTokens.space4, + children: [ + for (final tag in _tags) + _buildChip( + palette, + tag, + duplicate: pendingDuplicates.contains(tag) || (widget.takenByCategory?.containsKey(tag) ?? false), + ), + ], + ), + const SizedBox(height: 6), + ], + AppTextField( + key: widget.inputKey, + controller: _controller, + placeholder: placeholder != null + ? Text(placeholder, style: TextStyle(fontSize: 12, color: palette.textMuted)) + : null, + onChanged: _handleChanged, + onSubmitted: _handleSubmitted, + ), + if (conflict != null) ...[ + const SizedBox(height: 6), + Text( + context.l10n.categoryExtensionTaken(conflict, widget.takenByCategory![conflict]!), + style: TextStyle(color: palette.error, fontSize: 12), + ), + ], + ], + ); + } + + Widget _buildRestoreButton(AppPalette palette) { + return shad.OutlineButton( + key: const ValueKey('extension-restore-defaults'), + onPressed: _restoreDefaults, + size: shad.ButtonSize.small, + density: shad.ButtonDensity.compact, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.restart_alt, size: 14, color: palette.brand), + const SizedBox(width: 4), + Text(context.l10n.categoryExtensionsRestoreDefaults, style: TextStyle(fontSize: 12, color: palette.brand)), + ], + ), + ); + } + + Widget _buildChip(AppPalette palette, String tag, {required bool duplicate}) { + return Container( + key: ValueKey('extension-chip-$tag'), + padding: const EdgeInsets.only(left: 8, right: 1), + decoration: BoxDecoration( + border: Border.all(color: duplicate ? palette.error : palette.border), + borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Text(tag, style: TextStyle(fontSize: 11, color: palette.textPrimary)), + const SizedBox(width: 2), + GestureDetector( + key: ValueKey('extension-chip-remove-$tag'), + onTap: () => _removeTag(tag), + behavior: HitTestBehavior.opaque, + child: SizedBox(width: 32, height: 32, child: Icon(Icons.close, size: 13, color: palette.textSecondary)), + ), + ], + ), + ); + } +} diff --git a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart index f8b52588..eac26a55 100644 --- a/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart +++ b/ui/flutter/lib/features/tasks/presentation/pages/create_task_window_page.dart @@ -92,6 +92,27 @@ class _CreateTaskWindowPageState extends ConsumerState { bool _draggingTorrent = false; bool _asDefaultPath = false; String _configuredDownloadDirectory = ''; + + /// Index into `_downloadCategories` as filtered by `_loadDefaults` + /// (`!isDeleted`), not into the raw config list; null when the directory + /// falls outside every category. Cached, so it can lag the field while the + /// debounce is pending — `onShortcutSelected` reads exactly this value. + int? _activeCategoryIndex; + Timer? _categoryHighlightTimer; + + /// Whether the user picked a directory in this dialog. The backend routes a + /// task to its category directory only when this stays false, so the flag is + /// tracked on its own instead of comparing the field with the default dir. + bool _directoryManuallySet = false; + + /// Guards the controller listener while the page fills the field itself. + bool _programmaticDirectoryWrite = false; + + /// The text of the directory field the page knows about. The controller also + /// reports selection changes, e.g. when the caret moves, so the listener + /// compares the text to tell a user edit from a caret move. + String _lastDirectoryText = ''; + String _fileDataUri = ''; bool _programmaticUrlChange = false; String _lastUrlText = ''; @@ -100,6 +121,7 @@ class _CreateTaskWindowPageState extends ConsumerState { void initState() { super.initState(); _urlController.addListener(_handleUrlChanged); + _directoryController.addListener(_handleDirectoryChanged); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final task = widget.initialTask ?? ref.read(pendingCreateTaskProvider); @@ -127,9 +149,12 @@ class _CreateTaskWindowPageState extends ConsumerState { void dispose() { _urlController.removeListener(_handleUrlChanged); _urlController.dispose(); + _directoryController.removeListener(_handleDirectoryChanged); + _categoryHighlightTimer?.cancel(); + _categoryHighlightTimer = null; _renameController.dispose(); - _connectionsController.dispose(); _directoryController.dispose(); + _connectionsController.dispose(); _proxyServerController.dispose(); _proxyPortController.dispose(); _proxyUsernameController.dispose(); @@ -200,7 +225,7 @@ class _CreateTaskWindowPageState extends ConsumerState { void _applyInitialTask(CreateTask? task) { if (task == null || !mounted) return; final req = task.req; - final opts = task.opts; + final opts = task.opts ?? api_options.Options(); setState(() { _initialSize = 0; _checksumAlgorithm = 'md5'; @@ -241,30 +266,35 @@ class _CreateTaskWindowPageState extends ConsumerState { break; } } - if (opts != null) { - _initialSize = opts.size; - if (opts.path.isNotEmpty) { - _directoryController.text = opts.path; + // Filling the field is not a manual choice, the caller decides whether + // the task keeps its directory. + _directoryManuallySet = opts.skipAutoCategorize; + _asDefaultPath = opts.skipAutoCategorize && opts.asDefaultPath; + _programmaticDirectoryWrite = true; + // A task without a directory uses the configured one, the field must + // not keep the directory of the previous task. + _directoryController.text = opts.path.isNotEmpty ? opts.path : _configuredDownloadDirectory; + _programmaticDirectoryWrite = false; + _initialSize = opts.size; + if (opts.extra is Map) { + final extra = api_options.OptsExtraHttp.fromJson(Map.from(opts.extra! as Map)); + if (extra.connections > 0) { + _connectionsController.text = extra.connections.toString(); } - if (opts.extra is Map) { - final extra = api_options.OptsExtraHttp.fromJson(Map.from(opts.extra! as Map)); - if (extra.connections > 0) { - _connectionsController.text = extra.connections.toString(); - } - _autoTorrent = extra.autoTorrent; - _deleteTorrentAfterDownload = extra.deleteTorrentAfterDownload; - _autoExtract = extra.autoExtract; - _archivePasswordController.text = extra.archivePassword; - _deleteAfterExtract = extra.deleteAfterExtract; - if (extra.checksum != null) { - if (extra.checksum!.algorithm.isNotEmpty) { - _checksumAlgorithm = extra.checksum!.algorithm; - } - _checksumHashController.text = extra.checksum!.expected; + _autoTorrent = extra.autoTorrent; + _deleteTorrentAfterDownload = extra.deleteTorrentAfterDownload; + _autoExtract = extra.autoExtract; + _archivePasswordController.text = extra.archivePassword; + _deleteAfterExtract = extra.deleteAfterExtract; + if (extra.checksum != null) { + if (extra.checksum!.algorithm.isNotEmpty) { + _checksumAlgorithm = extra.checksum!.algorithm; } + _checksumHashController.text = extra.checksum!.expected; } } }); + _applyCategoryHighlightNow(); } void _applyInitialProxy(RequestProxy? proxy) { @@ -429,15 +459,21 @@ class _CreateTaskWindowPageState extends ConsumerState { setState(() => _asDefaultPath = false); } }, - onDirectoryPicked: (pickedDirectory) => setState( - () => _asDefaultPath = !_sameDirectory(pickedDirectory, _configuredDownloadDirectory), - ), + onDirectoryPicked: (pickedDirectory) { + setState(() { + _directoryManuallySet = true; + _asDefaultPath = !_sameDirectory(pickedDirectory, _configuredDownloadDirectory); + }); + _applyCategoryHighlightNow(); + }, ), const SizedBox(height: 8), _DirectoryOptions( key: const ValueKey('create-task-directory-options-row'), stacked: stacked, asDefaultPath: _asDefaultPath, + canSetAsDefaultPath: _directoryManuallySet, + activeIndex: _activeCategoryIndex, onAsDefaultPathChanged: (value) => setState(() => _asDefaultPath = value), shortcuts: [ for (final entry in _downloadCategories.indexed) @@ -448,16 +484,22 @@ class _CreateTaskWindowPageState extends ConsumerState { ), ], onShortcutSelected: (shortcut) { - _directoryController.text = shortcut.path; - if (_asDefaultPath) { - setState(() => _asDefaultPath = false); - } + // Tapping the active shortcut again clears the selection and + // restores the saved default directory. Either tap is an + // explicit directory choice, so the task skips auto classify. + final turnOn = _activeCategoryIndex != shortcut.index; + setState(() { + _directoryManuallySet = true; + _asDefaultPath = false; + }); + _directoryController.text = turnOn ? shortcut.path : _configuredDownloadDirectory; + _applyCategoryHighlightNow(); }, ), ], ), ), - const SizedBox(height: 14), + const SizedBox(height: 8), AppFormRow( direction: stacked ? Axis.vertical : Axis.horizontal, label: context.l10n.directDownload, @@ -808,7 +850,9 @@ class _CreateTaskWindowPageState extends ConsumerState { _configuredDownloadDirectory = config.downloadDir.trim(); if (config.downloadDir.isNotEmpty && (_directoryController.text.isEmpty || _directoryController.text == 'C:/Users/levi/Downloads')) { + _programmaticDirectoryWrite = true; _directoryController.text = config.downloadDir; + _programmaticDirectoryWrite = false; } if (_asDefaultPath && _sameDirectory(_directoryController.text, _configuredDownloadDirectory)) { _asDefaultPath = false; @@ -818,10 +862,9 @@ class _CreateTaskWindowPageState extends ConsumerState { _connectionsController.text = connections.toString(); } _directDownload = config.extra.defaultDirectDownload; - _downloadCategories = config.extra.downloadCategories - .where((category) => !category.isDeleted) - .toList(growable: false); + _downloadCategories = config.categories.where((category) => !category.isDeleted).toList(growable: false); }); + _applyCategoryHighlightNow(); } catch (_) { // Keep local defaults when the backend is not available yet. } @@ -1087,7 +1130,12 @@ class _CreateTaskWindowPageState extends ConsumerState { return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text); } - api_options.Options _buildOptions({String? name, List selectFiles = const [], bool omitChecksum = false}) { + api_options.Options _buildOptions({ + String? name, + List selectFiles = const [], + bool omitChecksum = false, + bool? skipAutoCategorize, + }) { final urls = _inputUrls(); final connections = int.tryParse(_connectionsController.text.trim()) ?? 0; final checksumExpected = _checksumHashController.text.trim(); @@ -1099,6 +1147,7 @@ class _CreateTaskWindowPageState extends ConsumerState { name: name ?? _renameController.text.trim(), path: _directoryController.text.trim(), asDefaultPath: _asDefaultPath, + skipAutoCategorize: skipAutoCategorize ?? _directoryManuallySet, selectFiles: selectFiles, extra: api_options.OptsExtraHttp( connections: connections, @@ -1119,6 +1168,60 @@ class _CreateTaskWindowPageState extends ConsumerState { return path.equals(path.normalize(normalizedLeft), path.normalize(normalizedRight)); } + /// Index of the category whose path matches [value], or null. Category paths + /// are resolved through `_renderPathPlaceholders` before comparing, while + /// [value] is taken verbatim, so a `%year%` pattern only matches once the + /// directory field already holds the rendered path. + int? _categoryIndexFor(String value) { + final target = value.trim(); + if (target.isEmpty) return null; + for (final entry in _downloadCategories.indexed) { + if (_sameDirectory(_renderPathPlaceholders(entry.$2.path), target)) return entry.$1; + } + return null; + } + + void _applyCategoryHighlight() { + final next = _categoryIndexFor(_directoryController.text); + if (next == _activeCategoryIndex) return; + setState(() => _activeCategoryIndex = next); + } + + /// Debounced variant for paths typed into the directory field, so the + /// highlight does not flicker while the path is still incomplete. + /// A text change the user typed is an explicit directory choice; one the + /// page writes itself (initial fill, incoming task) is not. The controller + /// also notifies on selection changes, e.g. when the caret moves, so only a + /// changed text counts as a manual choice. + void _handleDirectoryChanged() { + final text = _directoryController.text; + if (text == _lastDirectoryText) { + return; + } + _lastDirectoryText = text; + if (!_programmaticDirectoryWrite && !_directoryManuallySet) { + setState(() => _directoryManuallySet = true); + } + _scheduleCategoryHighlight(); + } + + void _scheduleCategoryHighlight() { + _categoryHighlightTimer?.cancel(); + _categoryHighlightTimer = Timer(_categoryHighlightDebounce, () { + if (!mounted) return; + _applyCategoryHighlight(); + }); + } + + /// Immediate variant for shortcut clicks and programmatic writes, which + /// cancels a pending debounce instead of waiting for it. + void _applyCategoryHighlightNow() { + _categoryHighlightTimer?.cancel(); + _categoryHighlightTimer = null; + if (!mounted) return; + _applyCategoryHighlight(); + } + Future _submitResolved(Request request, ResolveResult result, List selectedIndexes) async { final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList()) ..sort(); @@ -1136,6 +1239,11 @@ class _CreateTaskWindowPageState extends ConsumerState { } if (result.res.files.isNotEmpty) { + // The extension returned a file list, so the resource keeps a directory + // structure. The split tasks must not be classified into a category, the + // structure of the resolved resource is kept. The decision is based on + // the resolved resource, not on the number of selected files. + final keepStructure = _directoryManuallySet || result.res.name.isNotEmpty || result.res.files.length > 1; final reqs = selected.map((index) { final file = result.res.files[index]; final fileRequest = file.req ?? request; @@ -1145,6 +1253,7 @@ class _CreateTaskWindowPageState extends ConsumerState { size: _parseProtocol(fileRequest.url) == _TaskProtocol.http ? file.size : 0, name: file.name, path: path.join(_directoryController.text.trim(), result.res.name, file.path), + skipAutoCategorize: keepStructure, extra: _buildOptions(omitChecksum: isMultiFile).extra, ), ); @@ -1154,7 +1263,7 @@ class _CreateTaskWindowPageState extends ConsumerState { .createTaskBatch( CreateTaskBatch( reqs: reqs, - opts: _buildOptions(omitChecksum: isMultiFile), + opts: _buildOptions(omitChecksum: isMultiFile, skipAutoCategorize: keepStructure), ), ); return; @@ -1168,6 +1277,12 @@ class _CreateTaskWindowPageState extends ConsumerState { var selectedIndexes = List.of(initialSelection); var submitting = false; String? errorText; + // The directory the task will be saved to. A directory the user named + // wins over the category suggestion of the resolve response, the backend + // keeps it and does not classify the task. + final effectiveDownloadDir = _directoryManuallySet + ? _directoryController.text.trim() + : (result.suggestedDownloadDir.isNotEmpty ? result.suggestedDownloadDir : _directoryController.text.trim()); final dialog = const DialogOverlayHandler().show( context: context, @@ -1223,6 +1338,16 @@ class _CreateTaskWindowPageState extends ConsumerState { : dialogContext.l10n.resolvedFileCount(result.res.files.length), style: TextStyle(color: palette.textSecondary, fontSize: 12), ), + if (effectiveDownloadDir.isNotEmpty) ...[ + const SizedBox(height: 4), + Text( + dialogContext.l10n.resolvedCategoryDirectory(effectiveDownloadDir), + key: const ValueKey('resolve-category-directory'), + style: TextStyle(color: palette.textSecondary, fontSize: 12), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], const SizedBox(height: 12), SizedBox( height: treeHeight.clamp(260.0, 460.0), @@ -1512,9 +1637,10 @@ class _DirectDownloadToggle extends StatelessWidget { } class _AsDefaultPathToggle extends StatelessWidget { - const _AsDefaultPathToggle({required this.value, required this.onChanged}); + const _AsDefaultPathToggle({required this.value, required this.enabled, required this.onChanged}); final bool value; + final bool enabled; final ValueChanged onChanged; @override @@ -1523,7 +1649,7 @@ class _AsDefaultPathToggle extends StatelessWidget { return GestureDetector( key: const ValueKey('create-task-remember-download-directory'), behavior: HitTestBehavior.opaque, - onTap: () => onChanged(!value), + onTap: enabled ? () => onChanged(!value) : null, child: SizedBox( height: 24, child: Row( @@ -1532,7 +1658,7 @@ class _AsDefaultPathToggle extends StatelessWidget { Checkbox( key: const ValueKey('create-task-remember-download-directory-checkbox'), state: value ? CheckboxState.checked : CheckboxState.unchecked, - onChanged: (_) => onChanged(!value), + onChanged: enabled ? (_) => onChanged(!value) : null, ), const SizedBox(width: AppDesignTokens.checkboxLabelGap), Flexible( @@ -1564,6 +1690,8 @@ class _DirectoryOptions extends StatelessWidget { super.key, required this.stacked, required this.asDefaultPath, + required this.canSetAsDefaultPath, + required this.activeIndex, required this.onAsDefaultPathChanged, required this.shortcuts, required this.onShortcutSelected, @@ -1571,14 +1699,28 @@ class _DirectoryOptions extends StatelessWidget { final bool stacked; final bool asDefaultPath; + + /// The global default download directory is only saved for a directory the + /// user named explicitly, so the toggle stays disabled until then. + final bool canSetAsDefaultPath; + final int? activeIndex; final ValueChanged onAsDefaultPathChanged; final List<_DirectoryShortcut> shortcuts; final ValueChanged<_DirectoryShortcut> onShortcutSelected; @override Widget build(BuildContext context) { - final toggle = _AsDefaultPathToggle(value: asDefaultPath, onChanged: onAsDefaultPathChanged); - final shortcutList = _DirectoryShortcuts(shortcuts: shortcuts, alignEnd: !stacked, onSelected: onShortcutSelected); + final toggle = _AsDefaultPathToggle( + value: asDefaultPath, + enabled: canSetAsDefaultPath, + onChanged: onAsDefaultPathChanged, + ); + final shortcutList = _DirectoryShortcuts( + shortcuts: shortcuts, + activeIndex: activeIndex, + alignEnd: !stacked, + onSelected: onShortcutSelected, + ); if (stacked) { return Column( @@ -1613,9 +1755,15 @@ class _DirectoryOptions extends StatelessWidget { } class _DirectoryShortcuts extends StatelessWidget { - const _DirectoryShortcuts({required this.shortcuts, required this.alignEnd, required this.onSelected}); + const _DirectoryShortcuts({ + required this.shortcuts, + required this.activeIndex, + required this.alignEnd, + required this.onSelected, + }); final List<_DirectoryShortcut> shortcuts; + final int? activeIndex; final bool alignEnd; final ValueChanged<_DirectoryShortcut> onSelected; @@ -1631,6 +1779,7 @@ class _DirectoryShortcuts extends StatelessWidget { _DirectoryShortcutButton( key: ValueKey('create-task-category-${shortcut.index}'), shortcut: shortcut, + active: shortcut.index == activeIndex, onPressed: () => onSelected(shortcut), ), ], @@ -1651,6 +1800,7 @@ class _DirectoryShortcuts extends StatelessWidget { _DirectoryShortcutButton( key: ValueKey('create-task-category-${shortcut.index}'), shortcut: shortcut, + active: shortcut.index == activeIndex, onPressed: () => onSelected(shortcut), ), ], @@ -1662,9 +1812,10 @@ class _DirectoryShortcuts extends StatelessWidget { } class _DirectoryShortcutButton extends StatefulWidget { - const _DirectoryShortcutButton({super.key, required this.shortcut, required this.onPressed}); + const _DirectoryShortcutButton({super.key, required this.shortcut, required this.active, required this.onPressed}); final _DirectoryShortcut shortcut; + final bool active; final VoidCallback onPressed; @override @@ -1677,10 +1828,12 @@ class _DirectoryShortcutButtonState extends State<_DirectoryShortcutButton> { @override Widget build(BuildContext context) { final palette = AppPalette.of(context); + final active = widget.active; return AppTooltip( message: widget.shortcut.path, child: Semantics( button: true, + selected: active, label: widget.shortcut.label, child: MouseRegion( cursor: SystemMouseCursors.click, @@ -1696,21 +1849,25 @@ class _DirectoryShortcutButtonState extends State<_DirectoryShortcutButton> { constraints: const BoxConstraints(maxWidth: 168), padding: const EdgeInsets.symmetric(horizontal: 9), decoration: BoxDecoration( - color: _hovered ? palette.surfaceSoft : palette.cardBg, + color: active ? palette.brandSoft : (_hovered ? palette.surfaceSoft : palette.cardBg), borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), - border: Border.all(color: palette.border), + border: Border.all(color: active ? palette.brand.withValues(alpha: 0.45) : palette.border), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(Icons.folder_outlined, size: 14, color: palette.textMuted), + Icon(Icons.folder_outlined, size: 14, color: active ? palette.brand : palette.textMuted), const SizedBox(width: 5), Flexible( child: Text( widget.shortcut.label, maxLines: 1, overflow: TextOverflow.ellipsis, - style: TextStyle(color: palette.textSecondary, fontSize: 11.5, fontWeight: FontWeight.w600), + style: TextStyle( + color: active ? palette.textPrimary : palette.textSecondary, + fontSize: 11.5, + fontWeight: FontWeight.w600, + ), ), ), ], @@ -1842,6 +1999,8 @@ class _SegmentButton extends StatelessWidget { } } +const _categoryHighlightDebounce = Duration(milliseconds: 300); + const _advancedExpandDuration = Duration(milliseconds: 220); const _advancedScrollDelay = Duration(milliseconds: 90); const _advancedScrollDuration = Duration(milliseconds: 260); diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index f9845c82..ad6e458b 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -203,6 +203,13 @@ "categoryProgram": "برامج", "categoryName": "اسم الفئة", "categoryPath": "مسار الفئة", + "autoCategorizeByExtension": "تصنيف تلقائي حسب نوع الملف", + "autoCategorizeByExtensionDescription": "توجيه التنزيلات إلى مجلد الفئة تلقائيًا حسب امتداد الملف عند الحفظ في مجلد التنزيل الافتراضي.", + "categoryExtensions": "امتدادات الملفات", + "categoryExtensionsUsageHint": "افصل الامتدادات بفواصل أو مسافات ثم اضغط Enter.", + "categoryExtensionsEmptyHint": "بدون الامتدادات، لن تُحفظ الملفات في هذا التصنيف تلقائيًا.", + "categoryExtensionsRestoreDefaults": "استعادة الامتدادات الافتراضية", + "categoryExtensionTaken": "{extension} مستخدم بالفعل بواسطة {category}", "builtInCategory": "لا يمكن حذف الفئة المضمنة", "selectCategory": "اختر الفئة", "quickDirectories": "مجلدات سريعة", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "اختار ملف واحد على الأقل", "resolvedFiles": "الملفات المبتغاة", "resolvedFileCount": "{count}", + "resolvedCategoryDirectory": "سيتم الحفظ في {path}", "resourceHasNoFiles": "ولا توجد في هذا المورد قائمة ملف قابلة للاختيار", "noFiles": "لا توجد ملفات", "historyLinks": "روابط السجل", diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index 23ca1b05..897c496c 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -210,6 +210,13 @@ "categoryProgram": "Proqram", "categoryName": "Kateqoriyanın Adı", "categoryPath": "Kateqoriyanın Yolu", + "autoCategorizeByExtension": "Fayl tipinə görə avtomatik kateqoriyalaşdırma", + "autoCategorizeByExtensionDescription": "Standart yükləmə qovluğuna yadda saxlanarkən yükləmələr fayl uzantısına görə avtomatik olaraq kateqoriya qovluğuna yönləndirilir.", + "categoryExtensions": "Fayl uzantıları", + "categoryExtensionsUsageHint": "Uzantıları vergül və ya boşluqla ayırın, sonra Enter-a basın.", + "categoryExtensionsEmptyHint": "Uzantı yoxdursa, fayllar bu kateqoriyaya avtomatik olaraq saxlanmır.", + "categoryExtensionsRestoreDefaults": "Standart uzantıları bərpa et", + "categoryExtensionTaken": "{extension} artıq {category} tərəfindən istifadə olunur", "builtInCategory": "Daxili kateqoriya silinə bilməz", "selectCategory": "Kateqoriya Seç", "quickDirectories": "Sürətli qovluqlar", @@ -377,6 +384,7 @@ "selectAtLeastOneFile": "Ən azı bir fayl seçin", "resolvedFiles": "Tapılmış Fayllar", "resolvedFileCount": "{count} fayl tapıldı", + "resolvedCategoryDirectory": "{path} qovluğuna saxlanılacaq", "resourceHasNoFiles": "Bu resursda seçilə bilən fayl siyahısı yoxdur", "noFiles": "Fayl yoxdur", "historyLinks": "Tarixçə keçidləri", diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index aa633930..7f096d93 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -203,6 +203,13 @@ "categoryProgram": "Programa", "categoryName": "Nom de la categoria", "categoryPath": "Ruta de la categoria", + "autoCategorizeByExtension": "Categoritza automàticament segons el tipus de fitxer", + "autoCategorizeByExtensionDescription": "Les baixades es desen a la carpeta de categoria segons l'extensió del fitxer en utilitzar la carpeta de baixades predeterminada.", + "categoryExtensions": "Extensions de fitxer", + "categoryExtensionsUsageHint": "Separa les extensions amb comes o espais i prem Retorn.", + "categoryExtensionsEmptyHint": "Sense extensions, els fitxers no es desaran aquí automàticament.", + "categoryExtensionsRestoreDefaults": "Restaurar les extensions predeterminades", + "categoryExtensionTaken": "{extension} ja està utilitzat per {category}", "builtInCategory": "La categoria integrada no es pot suprimir", "selectCategory": "Seleccioneu una categoria", "quickDirectories": "Carpetes ràpides", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "Seleccioneu com a mínim un fitxer", "resolvedFiles": "Fitxers resolts", "resolvedFileCount": "{count}", + "resolvedCategoryDirectory": "Es desarà a {path}", "resourceHasNoFiles": "El recurs no té cap llista de fitxers per seleccionar", "noFiles": "Sense fitxers", "historyLinks": "Enllaços de l'historial", diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index 679d4856..2defe911 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -203,6 +203,13 @@ "categoryProgram": "Programm", "categoryName": "Kategoriename", "categoryPath": "Kategoriepfad", + "autoCategorizeByExtension": "Nach Dateityp automatisch kategorisieren", + "autoCategorizeByExtensionDescription": "Downloads werden beim Speichern im Standard-Download-Ordner anhand der Dateiendung automatisch einem Kategorieordner zugeordnet.", + "categoryExtensions": "Dateiendungen", + "categoryExtensionsUsageHint": "Trenne Endungen mit Kommas oder Leerzeichen und drücke Enter.", + "categoryExtensionsEmptyHint": "Ohne Endungen werden Dateien nicht automatisch dieser Kategorie zugeordnet.", + "categoryExtensionsRestoreDefaults": "Standard-Endungen wiederherstellen", + "categoryExtensionTaken": "{extension} wird bereits von {category} verwendet", "builtInCategory": "Integrierte Kategorie kann nicht gelöscht werden", "selectCategory": "Kategorie auswählen", "quickDirectories": "Schnellordner", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "Wählen Sie mindestens eine Datei aus", "resolvedFiles": "Aufgelöste Dateien", "resolvedFileCount": "{count}-Dateien behoben", + "resolvedCategoryDirectory": "Wird in {path} gespeichert", "resourceHasNoFiles": "Die Ressource verfügt über keine auswählbare Dateiliste", "noFiles": "Keine Dateien", "historyLinks": "Verlaufslinks", diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index 9b51a6ef..b1509a1d 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -217,6 +217,23 @@ "categoryProgram": "Program", "categoryName": "Category Name", "categoryPath": "Category Path", + "autoCategorizeByExtension": "Auto-categorize by file type", + "autoCategorizeByExtensionDescription": "Automatically route downloads into a category folder based on the file name when saving to the default download folder.", + "categoryExtensions": "Associated Extensions", + "categoryExtensionsUsageHint": "Separate suffixes with commas or spaces, then press Enter.", + "categoryExtensionsEmptyHint": "Without extensions, files won't be saved into this category automatically.", + "categoryExtensionsRestoreDefaults": "Restore default extensions", + "categoryExtensionTaken": "{extension} is already used by {category}", + "@categoryExtensionTaken": { + "placeholders": { + "extension": { + "type": "String" + }, + "category": { + "type": "String" + } + } + }, "builtInCategory": "Built-in category cannot be deleted", "selectCategory": "Select Category", "quickDirectories": "Quick folders", @@ -387,6 +404,14 @@ "selectAtLeastOneFile": "Select at least one file", "resolvedFiles": "Resolved Files", "resolvedFileCount": "{count, plural, =1{1 file resolved} other{{count} files resolved}}", + "resolvedCategoryDirectory": "Will be saved to {path}", + "@resolvedCategoryDirectory": { + "placeholders": { + "path": { + "type": "String" + } + } + }, "resourceHasNoFiles": "The resource has no selectable file list", "noFiles": "No files", "historyLinks": "History Links", diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index 1dce38f7..e7c51ca6 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -203,6 +203,13 @@ "categoryProgram": "Programa", "categoryName": "Nombre de categoría", "categoryPath": "Ruta de categoría", + "autoCategorizeByExtension": "Categorizar automáticamente por tipo de archivo", + "autoCategorizeByExtensionDescription": "Las descargas se guardan automáticamente en la carpeta de categoría según la extensión del archivo al usar la carpeta de descargas predeterminada.", + "categoryExtensions": "Extensiones de archivo", + "categoryExtensionsUsageHint": "Separa las extensiones con comas o espacios y pulsa Intro.", + "categoryExtensionsEmptyHint": "Sin extensiones, los archivos no se guardarán automáticamente en esta categoría.", + "categoryExtensionsRestoreDefaults": "Restaurar extensiones predeterminadas", + "categoryExtensionTaken": "{extension} ya está en uso en {category}", "builtInCategory": "La categoría incorporada no se puede eliminar", "selectCategory": "Seleccionar categoría", "quickDirectories": "Carpetas rápidas", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "Seleccione al menos un archivo", "resolvedFiles": "Archivos resueltos", "resolvedFileCount": "{count} resueltos", + "resolvedCategoryDirectory": "Se guardará en {path}", "resourceHasNoFiles": "El recurso no tiene lista de archivos seleccionable", "noFiles": "No hay archivos", "historyLinks": "Enlaces del historial", diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index 7f12ae8a..e4c14cb9 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -203,6 +203,13 @@ "categoryProgram": "برنامه", "categoryName": "نام دسته‌بندی", "categoryPath": "مسیر دسته‌بندی", + "autoCategorizeByExtension": "دسته‌بندی خودکار بر اساس نوع فایل", + "autoCategorizeByExtensionDescription": "هنگام ذخیره در پوشه پیش‌فرض دانلود، دانلودها بر اساس پسوند فایل به پوشه دسته مربوطه منتقل می‌شوند.", + "categoryExtensions": "پسوندهای فایل", + "categoryExtensionsUsageHint": "پسوندها را با ویرگول یا فاصله جدا کنید و Enter را بزنید.", + "categoryExtensionsEmptyHint": "بدون پسوندها، فایل‌ها به‌صورت خودکار در این دسته ذخیره نمی‌شوند.", + "categoryExtensionsRestoreDefaults": "بازیابی پسوندهای پیش‌فرض", + "categoryExtensionTaken": "{extension} از قبل توسط {category} استفاده شده است", "builtInCategory": "دسته‌بندی داخلی قابل حذف نیست", "selectCategory": "انتخاب دسته‌بندی", "quickDirectories": "پوشه‌های سریع", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "حداقل یک فایل را انتخاب کنید", "resolvedFiles": "فایل های حل شده", "resolvedFileCount": "{count} فایل شناسایی شد", + "resolvedCategoryDirectory": "در {path} ذخیره می‌شود", "resourceHasNoFiles": "منبع هیچ لیست فایل قابل انتخاب ندارد", "noFiles": "فایل ها", "historyLinks": "پیوندهای تاریخچه", diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index 5683c615..d190fa10 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -203,6 +203,13 @@ "categoryProgram": "Programme", "categoryName": "Nom de la catégorie", "categoryPath": "Chemin de la catégorie", + "autoCategorizeByExtension": "Catégoriser automatiquement selon le type de fichier", + "autoCategorizeByExtensionDescription": "Les téléchargements sont placés dans un dossier de catégorie selon l'extension du fichier lors de l'enregistrement dans le dossier de téléchargement par défaut.", + "categoryExtensions": "Extensions de fichier", + "categoryExtensionsUsageHint": "Séparez les extensions par virgules ou espaces, puis appuyez sur Entrée.", + "categoryExtensionsEmptyHint": "Sans extensions, les fichiers ne seront pas enregistrés ici automatiquement.", + "categoryExtensionsRestoreDefaults": "Restaurer les extensions par défaut", + "categoryExtensionTaken": "{extension} est déjà utilisé par {category}", "builtInCategory": "La catégorie intégrée ne peut pas être supprimée", "selectCategory": "Sélectionner une catégorie", "quickDirectories": "Dossiers rapides", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "Sélectionnez au moins un fichier", "resolvedFiles": "Fichiers résolus", "resolvedFileCount": "{count} résolus", + "resolvedCategoryDirectory": "Sera enregistré dans {path}", "resourceHasNoFiles": "La ressource n'a pas de liste de fichiers sélectionnable", "noFiles": "Aucun fichier", "historyLinks": "Liens de l'historique", diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index f5bc8d3a..36ebb394 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -203,6 +203,13 @@ "categoryProgram": "program", "categoryName": "Kategória neve", "categoryPath": "Kategória útvonala", + "autoCategorizeByExtension": "Automatikus kategorizálás fájltípus szerint", + "autoCategorizeByExtensionDescription": "Az alapértelmezett letöltési mappa használatakor a letöltések a fájl kiterjesztése alapján automatikusan a kategória mappájába kerülnek.", + "categoryExtensions": "Fájlkiterjesztések", + "categoryExtensionsUsageHint": "A kiterjesztéseket vesszővel vagy szóközzel válaszd el, majd nyomd le az Enter billentyűt.", + "categoryExtensionsEmptyHint": "Kiterjesztések nélkül a fájlok nem kerülnek automatikusan ebbe a kategóriába.", + "categoryExtensionsRestoreDefaults": "Alapértelmezett kiterjesztések visszaállítása", + "categoryExtensionTaken": "A(z) {extension} már használatban van itt: {category}", "builtInCategory": "A beépített kategória nem törölhető", "selectCategory": "Kategória kiválasztása", "quickDirectories": "Gyorsmappák", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "Válasszon ki legalább egy fájlt", "resolvedFiles": "Feloldott fájlok", "resolvedFileCount": "{count} fájlok megoldva", + "resolvedCategoryDirectory": "Ide lesz mentve: {path}", "resourceHasNoFiles": "Az erőforrásnak nincs kiválasztható fájllistája", "noFiles": "Nincsenek fájlok", "historyLinks": "Előzményhivatkozások", diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index 39607a44..e41f6377 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -203,6 +203,13 @@ "categoryProgram": "Program", "categoryName": "Nama Kategori", "categoryPath": "Path Kategori", + "autoCategorizeByExtension": "Kategorikan otomatis berdasarkan jenis file", + "autoCategorizeByExtensionDescription": "Unduhan dialihkan ke folder kategori berdasarkan ekstensi file saat disimpan ke folder unduhan bawaan.", + "categoryExtensions": "Ekstensi file", + "categoryExtensionsUsageHint": "Pisahkan ekstensi dengan koma atau spasi, lalu tekan Enter.", + "categoryExtensionsEmptyHint": "Tanpa ekstensi, file tidak akan otomatis disimpan ke kategori ini.", + "categoryExtensionsRestoreDefaults": "Pulihkan ekstensi default", + "categoryExtensionTaken": "{extension} sudah digunakan oleh {category}", "builtInCategory": "Kategori bawaan tidak dapat dihapus", "selectCategory": "Pilih Kategori", "quickDirectories": "Folder cepat", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "Pilih paling tidak satu berkas", "resolvedFiles": "Berkas Terpecahkan", "resolvedFileCount": "{count} terselesaikan", + "resolvedCategoryDirectory": "Akan disimpan ke {path}", "resourceHasNoFiles": "Sumber daya tidak memiliki daftar berkas yang dapat dipilih", "noFiles": "Tidak ada berkas", "historyLinks": "Tautan Riwayat", diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index 38a4f45f..3fa54ba0 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -203,6 +203,13 @@ "categoryProgram": "Programma", "categoryName": "Nome categoria", "categoryPath": "Percorso categoria", + "autoCategorizeByExtension": "Categorizza automaticamente per tipo di file", + "autoCategorizeByExtensionDescription": "Il download viene instradato automaticamente nella cartella della categoria in base all'estensione quando si salva nella cartella di download predefinita.", + "categoryExtensions": "Estensioni file", + "categoryExtensionsUsageHint": "Separa le estensioni con virgole o spazi e premi Invio.", + "categoryExtensionsEmptyHint": "Senza estensioni, i file non verranno salvati qui automaticamente.", + "categoryExtensionsRestoreDefaults": "Ripristina estensioni predefinite", + "categoryExtensionTaken": "{extension} è già in uso in {category}", "builtInCategory": "La categoria predefinita non può essere eliminata", "selectCategory": "Seleziona categoria", "quickDirectories": "Cartelle rapide", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "Selezionare almeno un file", "resolvedFiles": "File recuperati", "resolvedFileCount": "{count} risolti", + "resolvedCategoryDirectory": "Verrà salvato in {path}", "resourceHasNoFiles": "La risorsa non ha un elenco di file selezionabile", "noFiles": "Nessun file", "historyLinks": "Link della cronologia", diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index 2cd7d729..d146c205 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -203,6 +203,13 @@ "categoryProgram": "プログラム", "categoryName": "カテゴリ名", "categoryPath": "カテゴリパス", + "autoCategorizeByExtension": "ファイル種別で自動分類", + "autoCategorizeByExtensionDescription": "既定のダウンロードフォルダーに保存する際、拡張子に基づいて分類フォルダーへ自動的に振り分けます。", + "categoryExtensions": "ファイル拡張子", + "categoryExtensionsUsageHint": "カンマやスペースで拡張子を区切り、Enterで追加します。", + "categoryExtensionsEmptyHint": "拡張子が未設定の場合、ファイルは自動的にこの分類へ保存されません。", + "categoryExtensionsRestoreDefaults": "デフォルトの拡張子を復元", + "categoryExtensionTaken": "{extension} は {category} で既に使用されています", "builtInCategory": "内蔵カテゴリは削除できません", "selectCategory": "カテゴリを選択", "quickDirectories": "クイックフォルダー", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "少なくとも1つのファイルを選択します", "resolvedFiles": "解決されたファイル", "resolvedFileCount": "{count} ファイルが解決しました", + "resolvedCategoryDirectory": "{path} に保存されます", "resourceHasNoFiles": "リソースには、選択可能なファイルリストはありません", "noFiles": "ファイルがない場合", "historyLinks": "履歴リンク", diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index 48bf3ec7..d7323c31 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -203,6 +203,13 @@ "categoryProgram": "프로그램", "categoryName": "카테고리 이름", "categoryPath": "카테고리 경로", + "autoCategorizeByExtension": "파일 형식별 자동 분류", + "autoCategorizeByExtensionDescription": "기본 다운로드 폴더에 저장할 때 확장자에 따라 분류 폴더로 자동 이동합니다.", + "categoryExtensions": "파일 확장자", + "categoryExtensionsUsageHint": "쉼표나 공백으로 확장자를 구분하고 Enter를 눌러 추가하세요.", + "categoryExtensionsEmptyHint": "확장자가 없으면 파일이 이 분류로 자동 저장되지 않습니다.", + "categoryExtensionsRestoreDefaults": "기본 확장자 복원", + "categoryExtensionTaken": "{extension}은(는) {category}에서 이미 사용 중입니다", "builtInCategory": "기본 제공 카테고리는 삭제할 수 없습니다", "selectCategory": "카테고리 선택", "quickDirectories": "빠른 폴더", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "최소 1개의 파일을 선택하십시오", "resolvedFiles": "해결된 파일", "resolvedFileCount": "{count} 파일 해결", + "resolvedCategoryDirectory": "{path}에 저장됩니다", "resourceHasNoFiles": "리소스에는 선택 가능한 파일 목록이 없습니다", "noFiles": "파일 없음", "historyLinks": "기록 링크", diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index b39d206d..a47751ed 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -203,6 +203,13 @@ "categoryProgram": "Programy", "categoryName": "Nazwa kategorii", "categoryPath": "Ścieżka kategorii", + "autoCategorizeByExtension": "Automatyczna kategorizacja według typu pliku", + "autoCategorizeByExtensionDescription": "Pobierane pliki są automatycznie zapisywane w folderze kategorii na podstawie rozszerzenia, gdy używany jest domyślny folder pobierania.", + "categoryExtensions": "Rozszerzenia plików", + "categoryExtensionsUsageHint": "Rozdzielaj rozszerzenia przecinkami lub spacjami i naciskaj Enter.", + "categoryExtensionsEmptyHint": "Bez rozszerzeń pliki nie będą automatycznie zapisywane w tej kategorii.", + "categoryExtensionsRestoreDefaults": "Przywróć domyślne rozszerzenia", + "categoryExtensionTaken": "{extension} jest już używane przez {category}", "builtInCategory": "Wbudowanej kategorii nie można usunąć", "selectCategory": "Wybierz kategorię", "quickDirectories": "Szybkie foldery", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "Wybierz co najmniej jeden plik", "resolvedFiles": "Rozwiązywane pliki", "resolvedFileCount": "{count} plików rozwiązanych", + "resolvedCategoryDirectory": "Zostanie zapisane w {path}", "resourceHasNoFiles": "Zasób nie ma listy plików do wyboru", "noFiles": "Brak plików", "historyLinks": "Linki historii", diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index 55cec827..8a37123e 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -203,6 +203,13 @@ "categoryProgram": "Programa", "categoryName": "Nome da Categoria", "categoryPath": "Caminho da Categoria", + "autoCategorizeByExtension": "Categorizar automaticamente por tipo de arquivo", + "autoCategorizeByExtensionDescription": "Envia os downloads para a pasta da categoria com base na extensão ao salvar na pasta de download padrão.", + "categoryExtensions": "Extensões de arquivo", + "categoryExtensionsUsageHint": "Separe as extensões com vírgulas ou espaços e prima Enter.", + "categoryExtensionsEmptyHint": "Sem extensões, os ficheiros não serão guardados aqui automaticamente.", + "categoryExtensionsRestoreDefaults": "Restaurar extensões padrão", + "categoryExtensionTaken": "{extension} já está em uso em {category}", "builtInCategory": "Categoria integrada não pode ser excluída", "selectCategory": "Selecionar Categoria", "quickDirectories": "Pastas rápidas", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "Selecione pelo menos um arquivo", "resolvedFiles": "Ficheiros Resolvidos", "resolvedFileCount": "{count} ficheiros resolvidos", + "resolvedCategoryDirectory": "Será salvo em {path}", "resourceHasNoFiles": "O recurso não tem lista de arquivos selecionáveis", "noFiles": "Sem arquivos", "historyLinks": "Links do histórico", diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index e1d2da3d..bbfa3bb4 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -203,6 +203,13 @@ "categoryProgram": "Программа", "categoryName": "Название категории", "categoryPath": "Путь категории", + "autoCategorizeByExtension": "Автокатегоризация по типу файла", + "autoCategorizeByExtensionDescription": "Загрузки автоматически сохраняются в папку категории по расширению файла, если выбрана папка загрузки по умолчанию.", + "categoryExtensions": "Расширения файлов", + "categoryExtensionsUsageHint": "Разделяйте расширения запятыми или пробелами и нажимайте Enter.", + "categoryExtensionsEmptyHint": "Без расширений файлы не будут автоматически сохранены в эту категорию.", + "categoryExtensionsRestoreDefaults": "Восстановить расширения по умолчанию", + "categoryExtensionTaken": "{extension} уже используется в {category}", "builtInCategory": "Встроенную категорию нельзя удалить", "selectCategory": "Выбрать категорию", "quickDirectories": "Быстрые папки", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "Выберите хотя бы один файл", "resolvedFiles": "Решенные файлы", "resolvedFileCount": "{count} файлов, разрешенных", + "resolvedCategoryDirectory": "Будет сохранено в {path}", "resourceHasNoFiles": "Ресурс не имеет выбираемого списка файлов", "noFiles": "Нет файлов", "historyLinks": "Ссылки в истории", diff --git a/ui/flutter/lib/l10n/app_sk.arb b/ui/flutter/lib/l10n/app_sk.arb index 9a14f4d9..ad500c9b 100644 --- a/ui/flutter/lib/l10n/app_sk.arb +++ b/ui/flutter/lib/l10n/app_sk.arb @@ -217,6 +217,13 @@ "categoryProgram": "Program", "categoryName": "Názov kategórie", "categoryPath": "Cesta kategórie", + "autoCategorizeByExtension": "Automatická kategorizácia podľa typu súboru", + "autoCategorizeByExtensionDescription": "Sťahovania sa pri ukladaní do predvoleného priečinka sťahovania automaticky presúvajú do priečinka kategórie podľa prípony súboru.", + "categoryExtensions": "Prípony súborov", + "categoryExtensionsUsageHint": "Oddeľujte prípony čiarkami alebo medzerami a stlačte Enter.", + "categoryExtensionsEmptyHint": "Bez prípon sa súbory automaticky neuložia do tejto kategórie.", + "categoryExtensionsRestoreDefaults": "Obnoviť predvolené prípony", + "categoryExtensionTaken": "{extension} je už používané v {category}", "builtInCategory": "Vstavanú kategóriu nie je možné odstrániť", "selectCategory": "Vybrať kategóriu", "quickDirectories": "Rýchle priečinky", @@ -386,6 +393,7 @@ "selectAtLeastOneFile": "Vyberte aspoň jeden súbor", "resolvedFiles": "Vyriešené súbory", "resolvedFileCount": "{count, plural, =1{vyriešený 1 súbor} other{vyriešených {count} súborov}}", + "resolvedCategoryDirectory": "Uloží sa do {path}", "resourceHasNoFiles": "Zdroj nemá žiadny vyberateľný zoznam súborov", "noFiles": "Žiadne súbory", "historyLinks": "Odkazy v histórii", diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index cc6f3b2f..c96f7dbe 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -203,6 +203,13 @@ "categoryProgram": "நிரல்", "categoryName": "வகை பெயர்", "categoryPath": "வகை பாதை", + "autoCategorizeByExtension": "கோப்பு வகை வாரியாக தானாக வகைப்படுத்து", + "autoCategorizeByExtensionDescription": "இயல்புநிலை பதிவிறக்கக் கோப்புறையில் சேமிக்கும்போது, கோப்பு நீட்டிப்பின் அடிப்படையில் வகைக் கோப்புறைக்கு தானாக அனுப்பப்படும்.", + "categoryExtensions": "கோப்பு நீட்டிப்புகள்", + "categoryExtensionsUsageHint": "நீட்டிப்புகளை காற்புள்ளிகள் அல்லது இடைவெளிகளால் பிரித்து Enter அழுத்தவும்.", + "categoryExtensionsEmptyHint": "நீட்டிப்புகள் இல்லாததால், கோப்புகள் இந்த வகைப்பாட்டில் தானாகச் சேமிக்கப்படாது.", + "categoryExtensionsRestoreDefaults": "இயல்புநிலை நீட்டிப்புகளை மீட்டமை", + "categoryExtensionTaken": "{extension} ஏற்கனவே {category} இல் பயன்படுத்தப்படுகிறது", "builtInCategory": "உள்ளமைக்கப்பட்ட வகையை நீக்க முடியாது", "selectCategory": "வகையைத் தேர்ந்தெடுக்கவும்", "quickDirectories": "விரைவு கோப்புறைகள்", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "குறைந்தது ஒரு கோப்பையாவது தேர்ந்தெடுக்கவும்", "resolvedFiles": "தீர்க்கப்பட்ட கோப்புகள்", "resolvedFileCount": "{count} கோப்புகள் தீர்க்கப்பட்டன", + "resolvedCategoryDirectory": "{path} இல் சேமிக்கப்படும்", "resourceHasNoFiles": "வளத்தில் தேர்ந்தெடுக்கக்கூடிய கோப்பு பட்டியல் இல்லை", "noFiles": "(% 1 மொத்தம்) Items (Folders, Files), Size", "historyLinks": "வரலாற்று இணைப்புகள்", diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index cfb7120b..48efdda5 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -203,6 +203,13 @@ "categoryProgram": "Program", "categoryName": "Kategori Adı", "categoryPath": "Kategori Yolu", + "autoCategorizeByExtension": "Dosya tipine göre otomatik sınıflandır", + "autoCategorizeByExtensionDescription": "Varsayılan indirme klasörüne kaydedilirken dosya uzantısına göre ilgili klasöre otomatik yönlendirir.", + "categoryExtensions": "Dosya uzantıları", + "categoryExtensionsUsageHint": "Sonekleri virgül veya boşlukla ayırıp Enter'a basın.", + "categoryExtensionsEmptyHint": "Uzantı yoksa dosyalar bu kategoriye otomatik olarak kaydedilmez.", + "categoryExtensionsRestoreDefaults": "Varsayılan uzantıları geri yükle", + "categoryExtensionTaken": "{extension} zaten {category} içinde kullanılıyor", "builtInCategory": "Yerleşik kategori silinemez", "selectCategory": "Kategori Seç", "quickDirectories": "Hızlı klasörler", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "En az bir dosya seçin", "resolvedFiles": "Çözülen Dosyalar", "resolvedFileCount": "{count} dosyaları çözüldü", + "resolvedCategoryDirectory": "Şuraya kaydedilecek: {path}", "resourceHasNoFiles": "Kaynak, uygun olmayan dosya listesine sahiptir", "noFiles": "Hiçbir dosya yok", "historyLinks": "Geçmiş Bağlantıları", diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index 2143ba81..69478390 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -203,6 +203,13 @@ "categoryProgram": "Програма", "categoryName": "Назва категорії", "categoryPath": "Шлях категорії", + "autoCategorizeByExtension": "Автокатегоризація за типом файлу", + "autoCategorizeByExtensionDescription": "Завантаження автоматично зберігаються в папку категорії за розширенням файлу, якщо вибрано папку завантаження за замовчуванням.", + "categoryExtensions": "Розширення файлів", + "categoryExtensionsUsageHint": "Розділяйте розширення комами або пробілами та натискайте Enter.", + "categoryExtensionsEmptyHint": "Без розширень файли не зберігатимуться в цю категорію автоматично.", + "categoryExtensionsRestoreDefaults": "Відновити розширення за мовоччанням", + "categoryExtensionTaken": "{extension} уже використовується в {category}", "builtInCategory": "Вбудовану категорію неможливо видалити", "selectCategory": "Вибрати категорію", "quickDirectories": "Швидкі папки", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "Виберіть принаймні один файл", "resolvedFiles": "Знезалізовані файли", "resolvedFileCount": "{count} файли вирішені", + "resolvedCategoryDirectory": "Буде збережено в {path}", "resourceHasNoFiles": "Ресурс не має вибраного списку файлів", "noFiles": "Немає файлів", "historyLinks": "Посилання історії", diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index b242ebde..e21f91d8 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -203,6 +203,13 @@ "categoryProgram": "Chương trình", "categoryName": "Tên danh mục", "categoryPath": "Đường dẫn danh mục", + "autoCategorizeByExtension": "Tự động phân loại theo loại tệp", + "autoCategorizeByExtensionDescription": "Tải xuống sẽ được chuyển vào thư mục danh mục dựa trên phần mở rộng tệp khi lưu vào thư mục tải xuống mặc định.", + "categoryExtensions": "Phần mở rộng tệp", + "categoryExtensionsUsageHint": "Phân tách phần mở rộng bằng dấu phẩy hoặc khoảng trắng rồi nhấn Enter.", + "categoryExtensionsEmptyHint": "Không có phần mở rộng, tệp sẽ không tự động được lưu vào danh mục này.", + "categoryExtensionsRestoreDefaults": "Khôi phục phần mở rộng mặc định", + "categoryExtensionTaken": "{extension} đã được sử dụng trong {category}", "builtInCategory": "Không thể xóa danh mục tích hợp", "selectCategory": "Chọn danh mục", "quickDirectories": "Thư mục nhanh", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "Chọn ít nhất một tập tin", "resolvedFiles": "Các tập tin đã giải quyết", "resolvedFileCount": "{count} tập tin được giải quyết", + "resolvedCategoryDirectory": "Sẽ được lưu vào {path}", "resourceHasNoFiles": "Không thể chọn danh sách tập tin", "noFiles": "Không có tập tin", "historyLinks": "Liên kết lịch sử", diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index 920c4fc8..5e7a1960 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -203,6 +203,13 @@ "categoryProgram": "程序", "categoryName": "分类名称", "categoryPath": "分类路径", + "autoCategorizeByExtension": "按文件类型自动分类", + "autoCategorizeByExtensionDescription": "保存到默认下载目录时,根据文件后缀自动归入对应分类文件夹。", + "categoryExtensions": "文件后缀关联", + "categoryExtensionsUsageHint": "支持逗号/空格分隔后缀,按回车添加。", + "categoryExtensionsEmptyHint": "未设置后缀,文件不会自动归入此分类。", + "categoryExtensionsRestoreDefaults": "恢复默认后缀", + "categoryExtensionTaken": "{extension}已在{category}中配置", "builtInCategory": "内置分类不能删除", "selectCategory": "选择分类", "quickDirectories": "快捷目录", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "请至少选择一个文件", "resolvedFiles": "已解析文件", "resolvedFileCount": "已解析 {count} 个文件", + "resolvedCategoryDirectory": "将保存到 {path}", "resourceHasNoFiles": "该资源没有可选择的文件列表", "noFiles": "没有文件", "historyLinks": "历史链接", diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index f1ed9ec6..90c385a1 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -203,6 +203,13 @@ "categoryProgram": "程式", "categoryName": "分類名稱", "categoryPath": "分類路徑", + "autoCategorizeByExtension": "按檔案類型自動分類", + "autoCategorizeByExtensionDescription": "儲存至預設下載目錄時,根據檔案副檔名自動歸入對應分類資料夾。", + "categoryExtensions": "檔案副檔名", + "categoryExtensionsUsageHint": "支援逗號/空白分隔後綴,按回車新增。", + "categoryExtensionsEmptyHint": "未設定後綴,檔案不會自動歸入此分類。", + "categoryExtensionsRestoreDefaults": "恢復預設副檔名", + "categoryExtensionTaken": "{extension}已在{category}中設定", "builtInCategory": "內建分類無法刪除", "selectCategory": "選擇分類", "quickDirectories": "快捷目錄", @@ -363,6 +370,7 @@ "selectAtLeastOneFile": "請至少選擇一個檔案", "resolvedFiles": "已解析檔案", "resolvedFileCount": "已解析 {count} 個檔案", + "resolvedCategoryDirectory": "將儲存到 {path}", "resourceHasNoFiles": "該資源沒有可選擇的檔案列表", "noFiles": "沒有檔案", "historyLinks": "歷史連結", diff --git a/ui/flutter/lib/shared/widgets/app_path_picker_field.dart b/ui/flutter/lib/shared/widgets/app_path_picker_field.dart index e14ba37c..0f583142 100644 --- a/ui/flutter/lib/shared/widgets/app_path_picker_field.dart +++ b/ui/flutter/lib/shared/widgets/app_path_picker_field.dart @@ -134,9 +134,13 @@ class _AppPathPickerFieldState extends State { Future _pickPlatformDirectory() async { final path = await DownloadDirectoryPicker.pick(context, currentPath: widget.controller.text.trim()); - if (!mounted || path == null || path.isEmpty || path == widget.controller.text) return; - widget.controller.text = path; - _syncTooltipPath(path); + if (!mounted || path == null || path.isEmpty) return; + if (path != widget.controller.text) { + widget.controller.text = path; + _syncTooltipPath(path); + } + // Picking a directory is an explicit choice even when the picked directory + // is the one already shown, so the callback always runs. widget.onDirectoryPicked?.call(path); } diff --git a/ui/flutter/test/api/downloader_config_test.dart b/ui/flutter/test/api/downloader_config_test.dart index 5d5f3a49..eda82a5f 100644 --- a/ui/flutter/test/api/downloader_config_test.dart +++ b/ui/flutter/test/api/downloader_config_test.dart @@ -1,5 +1,9 @@ +import 'dart:convert'; +import 'dart:io'; + import 'package:flutter_test/flutter_test.dart'; import 'package:gopeed/api/model/downloader_config.dart'; +import 'package:path/path.dart' as path; void main() { test('auto-start tasks uses the backend-owned top-level config field', () { @@ -18,6 +22,17 @@ void main() { expect(DownloaderConfig.fromJson(json).autoStartTasks, isFalse); }); + test('legacy extra downloadCategories is ignored on parse', () { + final json = DownloaderConfig().toJson(); + (json['extra'] as Map)['downloadCategories'] = [ + {'name': 'Music', 'path': '/old/Music'}, + ]; + + final decoded = DownloaderConfig.fromJson(json); + expect(decoded.categories, isEmpty); + expect(decoded.toJson()['extra'], isNot(contains('downloadCategories'))); + }); + test('REST server settings round-trip through the Go downloader config', () { final config = DownloaderConfig() ..api = ApiServerConfig( @@ -78,4 +93,180 @@ void main() { expect(decoded.analyticsEnabled, isFalse); expect(decoded.analyticsClientId, 'client-id'); }); + + test('categories and the auto-categorize switch round-trip through the config', () { + final config = DownloaderConfig() + ..categories = [ + DownloadCategory(name: 'Program', path: '/dl/Program', extensions: const ['exe', 'msi']), + ] + ..autoCategorize = true; + + final decoded = DownloaderConfig.fromJson(config.toJson()); + expect(decoded.autoCategorize, isTrue); + expect(decoded.categories.single.name, 'Program'); + expect(decoded.categories.single.extensions, ['exe', 'msi']); + }); + + test('config parses when the category fields are absent or null', () { + final missing = DownloaderConfig().toJson() + ..remove('categories') + ..remove('autoCategorize'); + expect(DownloaderConfig.fromJson(missing).categories, isEmpty); + expect(DownloaderConfig.fromJson(missing).autoCategorize, isFalse); + + // The Go backend marshals a nil category slice and an unset switch as null. + final nil = DownloaderConfig().toJson() + ..['categories'] = null + ..['autoCategorize'] = null; + expect(DownloaderConfig.fromJson(nil).categories, isEmpty); + expect(DownloaderConfig.fromJson(nil).autoCategorize, isFalse); + }); + + test('auto categorize can be turned off and round-trips', () { + final config = DownloaderConfig(autoCategorize: false); + expect(DownloaderConfig.fromJson(config.toJson()).autoCategorize, isFalse); + }); + + test('auto categorize is off unless the config turns it on', () { + expect(DownloaderConfig().autoCategorize, isFalse); + expect(DownloaderConfig(autoCategorize: true).autoCategorize, isTrue); + }); + + test('effectiveExtensions returns the stored list and never falls back', () { + expect(DownloadCategory(name: 'Custom', path: '/x', extensions: const ['zip']).effectiveExtensions(), ['zip']); + final builtin = DownloadCategory(name: '', nameKey: 'categoryMusic', path: '/m'); + expect(builtin.effectiveExtensions(), isEmpty); + expect(DownloadCategory(name: 'Custom', path: '/x').effectiveExtensions(), isEmpty); + }); + + test('normalizeCategoryExtensions trims lowercases strips dots dedupes and splits on separators', () { + expect(normalizeCategoryExtensions([' .MP3 ', 'exe', '.Exe', 'FLAC', 'zip']), ['mp3', 'exe', 'flac', 'zip']); + expect(normalizeCategoryExtensions(['a b,c']), ['a', 'b', 'c']); + expect(normalizeCategoryExtensions(['', ' ', '..']), isEmpty); + }); + + test('effectiveExtensions normalizes stored lists in input order', () { + final stored = DownloadCategory(name: 'X', path: '/x', extensions: const [' .ZIP ', 'Exe', 'exe']); + expect(stored.effectiveExtensions(), ['zip', 'exe']); + }); + + group('rebaseBuiltinCategoryPaths', () { + DownloadCategory builtin(String categoryPath, {bool isDeleted = false}) => DownloadCategory( + name: '', + nameKey: 'categoryMusic', + path: categoryPath, + isBuiltIn: true, + isDeleted: isDeleted, + extensions: const ['mp3'], + ); + + test('rebases built-in categories that live under the old directory', () { + final config = DownloaderConfig() + ..categories = [ + builtin(path.join('/dl', 'Music')), + DownloadCategory(name: 'Mine', path: path.join('/dl', 'Mine')), + DownloadCategory( + name: 'Elsewhere', + path: path.join('/other', 'Music'), + isBuiltIn: true, + nameKey: 'categoryVideo', + ), + builtin(path.join('/dl', 'Old'), isDeleted: true), + ]; + + rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: '/data'); + + expect(config.categories[0].path, path.join('/data', 'Music')); + expect(config.categories[1].path, path.join('/dl', 'Mine')); + expect(config.categories[2].path, path.join('/other', 'Music')); + expect(config.categories[3].path, path.join('/dl', 'Old')); + }); + + test('rebases a built-in category that points at the old directory itself', () { + final config = DownloaderConfig()..categories = [builtin('/dl')]; + rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: '/data'); + expect(config.categories.single.path, '/data'); + }); + + test('does nothing when the directories are empty or unchanged', () { + final config = DownloaderConfig()..categories = [builtin(path.join('/dl', 'Music'))]; + rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: '/dl'); + rebaseBuiltinCategoryPaths(config, oldDir: '', newDir: '/data'); + rebaseBuiltinCategoryPaths(config, oldDir: '/dl', newDir: ''); + expect(config.categories.single.path, path.join('/dl', 'Music')); + }); + + test('rebases a windows path that the backend seeded with a backslash', () { + // The backend seeds the categories with the separator of its platform. + final config = DownloaderConfig() + ..categories = [ + builtin(r'C:\Users\me\Downloads\Music'), + DownloadCategory(name: 'Mine', path: r'C:\Users\me\Downloads\Mine'), + ]; + + rebaseBuiltinCategoryPaths(config, oldDir: r'C:\Users\me\Downloads', newDir: r'C:\Users\me\New'); + + // The joined separator depends on the platform the test runs on, so the + // assertion compares the forward slash form. + expect(config.categories[0].path.replaceAll(r'\', '/'), 'C:/Users/me/New/Music'); + expect(config.categories[1].path, r'C:\Users\me\Downloads\Mine'); + }); + }); + + test('go builtinCategoryExtensions stays in sync with kDefaultCategoryExtensions', () { + File? goSource; + for (final candidate in ['pkg/base/model.go', '../../pkg/base/model.go']) { + final file = File(candidate); + if (file.existsSync()) { + goSource = file; + break; + } + } + expect(goSource, isNotNull, reason: 'pkg/base/model.go not found from ${Directory.current}'); + + final block = RegExp( + r'builtinCategoryExtensions = map\[string\]\[\]string\{([\s\S]*?)\n\}', + ).firstMatch(goSource!.readAsStringSync()); + expect(block, isNotNull, reason: 'builtinCategoryExtensions map literal not found in pkg/base/model.go'); + + final entryPattern = RegExp(r'"(\w+)":\s*\{([^}]*)\}'); + final extensionPattern = RegExp(r'"([^"]+)"'); + final goDefaults = >{}; + for (final entry in entryPattern.allMatches(block!.group(1)!)) { + goDefaults[entry.group(1)!] = extensionPattern.allMatches(entry.group(2)!).map((m) => m.group(1)!).toSet(); + } + expect(goDefaults, isNotEmpty, reason: 'no built-in extension entries parsed from pkg/base/model.go'); + + final dartDefaults = kDefaultCategoryExtensions.map((key, value) => MapEntry(key, value.toSet())); + expect(goDefaults.keys.toSet(), dartDefaults.keys.toSet(), reason: 'built-in category nameKeys drifted'); + for (final key in goDefaults.keys) { + expect(goDefaults[key], dartDefaults[key], reason: 'extension list drifted for $key'); + } + }); + + test('runs the extension rule table shared with the backend', () { + // pkg/download/testdata/category_matching_cases.json is also read by the Go + // test, so a rule that only changes on one side breaks the other side. + final cases = jsonDecode(_readSharedRuleTable()) as Map; + for (final c in cases['normalizeExtensions'] as List) { + final input = (c['input'] as List).cast(); + final expected = (c['expected'] as List).cast(); + expect(normalizeCategoryExtensions(input), expected, reason: 'normalizeCategoryExtensions($input)'); + } + }); +} + +/// The rule table is shared with the backend, see +/// pkg/download/testdata/category_matching_cases.json. +String _readSharedRuleTable() { + for (final candidate in [ + 'pkg/download/testdata/category_matching_cases.json', + '../../pkg/download/testdata/category_matching_cases.json', + ]) { + final file = File(candidate); + if (file.existsSync()) { + return file.readAsStringSync(); + } + } + throw StateError('category_matching_cases.json not found from ${Directory.current}'); } diff --git a/ui/flutter/test/app/application/downloader_config_init_test.dart b/ui/flutter/test/app/application/downloader_config_init_test.dart new file mode 100644 index 00000000..704a0aed --- /dev/null +++ b/ui/flutter/test/app/application/downloader_config_init_test.dart @@ -0,0 +1,120 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:gopeed/api/model/downloader_config.dart'; +import 'package:gopeed/app/application/downloader_config_init.dart'; +import 'package:path/path.dart' as path; + +void main() { + DownloaderConfig configWith({List categories = const [], String downloadDir = ''}) { + final config = DownloaderConfig(downloadDir: downloadDir)..categories = List.of(categories); + return config; + } + + group('loadDownloaderConfig', () { + test('persists when the load succeeded and init changed the config', () async { + var saved = 0; + final config = await loadDownloaderConfig( + load: () async => configWith(), + save: (_) async => saved++, + defaultDownloadDir: () async => '/downloads', + ); + expect(saved, 1); + expect(config.categories, hasLength(4)); + }); + + test('does not persist when the initial load failed', () async { + var saved = 0; + final config = await loadDownloaderConfig( + load: () async => throw StateError('backend unavailable'), + save: (_) async => saved++, + defaultDownloadDir: () async => '/downloads', + ); + expect(saved, 0); + // The in-memory fallback still gets usable defaults. + expect(config.downloadDir, '/downloads'); + expect(config.categories, hasLength(4)); + }); + + test('skips persist when init changed nothing', () async { + var saved = 0; + await loadDownloaderConfig( + load: () async => configWith( + categories: [ + DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']), + ], + downloadDir: '/downloads', + ), + save: (_) async => saved++, + defaultDownloadDir: () async => '/other', + ); + expect(saved, 0); + }); + + test('persists the default download dir when it is missing', () async { + var saved = 0; + final config = await loadDownloaderConfig( + load: () async => configWith( + categories: [ + DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']), + ], + ), + save: (_) async => saved++, + defaultDownloadDir: () async => '/downloads', + ); + expect(saved, 1); + expect(config.downloadDir, '/downloads'); + }); + }); + + group('initDownloaderConfig', () { + test('seeds built-in categories with default extensions when empty', () async { + final config = configWith(); + final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => '/downloads'); + + expect(changed, isTrue); + expect(config.categories.map((c) => c.nameKey).toList(), [ + 'categoryMusic', + 'categoryVideo', + 'categoryDocument', + 'categoryProgram', + ]); + for (final category in config.categories) { + expect(category.isBuiltIn, isTrue); + expect(category.path, path.join('/downloads', category.nameKey!.replaceFirst('category', ''))); + expect(category.extensions, kDefaultCategoryExtensions[category.nameKey]); + } + }); + + test('keeps existing categories untouched', () async { + final existing = DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']); + final config = configWith(categories: [existing], downloadDir: '/downloads'); + final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => '/other'); + + expect(changed, isFalse); + expect(config.categories, hasLength(1)); + expect(config.categories.single, same(existing)); + }); + + test('reports a change when only the default download dir was filled', () async { + final config = configWith( + categories: [ + DownloadCategory(name: 'Mine', path: '/downloads/Mine', extensions: const ['zip']), + ], + ); + final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => '/downloads'); + + expect(changed, isTrue); + expect(config.downloadDir, '/downloads'); + }); + + test('leaves the config untouched when the default download dir is unknown', () async { + final config = configWith(); + final changed = await initDownloaderConfig(config, defaultDownloadDir: () async => ''); + + // A relative or unknown directory would make the backend write into its + // own working directory, so it is left for the backend to resolve. + expect(changed, isFalse); + expect(config.downloadDir, isEmpty); + expect(config.categories, isEmpty); + }); + }); +} diff --git a/ui/flutter/test/features/settings/presentation/widgets/extension_tag_editor_test.dart b/ui/flutter/test/features/settings/presentation/widgets/extension_tag_editor_test.dart new file mode 100644 index 00000000..e6ca26e4 --- /dev/null +++ b/ui/flutter/test/features/settings/presentation/widgets/extension_tag_editor_test.dart @@ -0,0 +1,217 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; + +import 'package:gopeed/features/settings/presentation/widgets/extension_tag_editor.dart'; +import 'package:gopeed/shared/theme/app_component_themes.dart'; +import 'package:gopeed/shared/theme/app_theme.dart'; + +Widget _wrap(Widget child) => shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes(child: child), +); + +List _chipTexts(WidgetTester tester) => tester + .widgetList(find.descendant(of: find.byType(Wrap), matching: find.byType(Text))) + .map((text) => text.data ?? '') + .toList(); + +Color _chipBorder(WidgetTester tester, String tag) { + final chip = tester.widget(find.byKey(ValueKey('extension-chip-$tag'))); + final border = (chip.decoration! as BoxDecoration).border! as Border; + return border.top.color; +} + +void main() { + testWidgets('separators stay in the input until Enter commits them as tags', (WidgetTester tester) async { + await tester.pumpWidget( + _wrap(ExtensionTagEditor(inputKey: const ValueKey('field'), initialTags: const ['.ZIP', ' Exe', 'exe'])), + ); + await tester.pumpAndSettle(); + expect(_chipTexts(tester), ['zip', 'exe']); + + await tester.enterText(find.byKey(const ValueKey('field')), 'mp3, flac'); + await tester.pump(); + expect(_chipTexts(tester), ['zip', 'exe']); + expect(find.text('mp3, flac'), findsOneWidget); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + expect(_chipTexts(tester), ['zip', 'exe', 'mp3', 'flac']); + expect(find.text('mp3, flac'), findsNothing); + + await tester.enterText(find.byKey(const ValueKey('field')), '.APE'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + expect(_chipTexts(tester), ['zip', 'exe', 'mp3', 'flac', 'ape']); + + await tester.tap(find.byKey(const ValueKey('extension-chip-remove-exe'))); + await tester.pump(); + expect(_chipTexts(tester), ['zip', 'mp3', 'flac', 'ape']); + expect(find.byKey(const ValueKey('extension-chip-remove-exe')), findsNothing); + + await tester.enterText(find.byKey(const ValueKey('field')), 'DO'); + final state = tester.state(find.byType(ExtensionTagEditor)); + expect(state.flush(), ['zip', 'mp3', 'flac', 'ape', 'do']); + await tester.pump(); + expect(_chipTexts(tester), ['zip', 'mp3', 'flac', 'ape', 'do']); + expect(tester.takeException(), isNull); + }); + + testWidgets('typing a duplicate highlights the existing chip without adding it', (WidgetTester tester) async { + await tester.pumpWidget( + _wrap(ExtensionTagEditor(inputKey: const ValueKey('field'), initialTags: const ['zip', 'exe'])), + ); + await tester.pumpAndSettle(); + final zipIdle = _chipBorder(tester, 'zip'); + + await tester.enterText(find.byKey(const ValueKey('field')), ' EXE '); + await tester.pump(); + expect(_chipBorder(tester, 'exe'), isNot(zipIdle)); + expect(_chipBorder(tester, 'zip'), zipIdle); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + expect(_chipTexts(tester), ['zip', 'exe']); + expect(_chipBorder(tester, 'exe'), zipIdle); + expect(tester.takeException(), isNull); + }); + + testWidgets('placeholder surfaces default extensions only while the editor is empty', (WidgetTester tester) async { + await tester.pumpWidget( + _wrap( + ExtensionTagEditor( + inputKey: const ValueKey('field'), + initialTags: const [], + placeholderText: 'empty hint', + usagePlaceholderText: 'usage hint', + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('empty hint'), findsOneWidget); + + await tester.enterText(find.byKey(const ValueKey('field')), 'a'); + await tester.pump(); + expect(find.text('empty hint'), findsNothing); + expect(find.text('usage hint'), findsNothing); + expect(_chipTexts(tester), isEmpty); + expect(find.byKey(const ValueKey('extension-restore-defaults')), findsNothing); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + expect(_chipTexts(tester), ['a']); + expect(find.text('empty hint'), findsNothing); + expect(find.text('usage hint'), findsOneWidget); + + await tester.enterText(find.byKey(const ValueKey('field')), 'b'); + await tester.pump(); + expect(find.text('usage hint'), findsNothing); + + await tester.enterText(find.byKey(const ValueKey('field')), ''); + await tester.pump(); + expect(find.text('usage hint'), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('extension-chip-remove-a'))); + await tester.pump(); + expect(find.text('empty hint'), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('empty state offers a restore-defaults action', (WidgetTester tester) async { + await tester.pumpWidget( + _wrap( + ExtensionTagEditor( + inputKey: const ValueKey('field'), + initialTags: const [], + placeholderText: 'empty state', + restoreDefaults: const ['Exe', '.MSI', 'exe'], + ), + ), + ); + await tester.pumpAndSettle(); + expect(find.text('empty state'), findsOneWidget); + expect(find.byKey(const ValueKey('extension-restore-defaults')), findsOneWidget); + + await tester.tap(find.byKey(const ValueKey('extension-restore-defaults'))); + await tester.pump(); + expect(_chipTexts(tester), ['exe', 'msi']); + expect(find.text('empty state'), findsNothing); + expect(find.byKey(const ValueKey('extension-restore-defaults')), findsNothing); + + await tester.tap(find.byKey(const ValueKey('extension-chip-remove-exe'))); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('extension-chip-remove-msi'))); + await tester.pump(); + expect(find.text('empty state'), findsOneWidget); + expect(find.byKey(const ValueKey('extension-restore-defaults')), findsOneWidget); + expect(tester.takeException(), isNull); + }); + + testWidgets('extensions owned by another category are blocked with an error', (WidgetTester tester) async { + var conflict = false; + await tester.pumpWidget( + _wrap( + ExtensionTagEditor( + inputKey: const ValueKey('field'), + initialTags: const ['zip'], + takenByCategory: const {'aaa': 'Music'}, + onConflictChanged: (value) => conflict = value, + ), + ), + ); + await tester.pumpAndSettle(); + expect(conflict, isFalse); + + await tester.enterText(find.byKey(const ValueKey('field')), 'mp3, aaa'); + await tester.pump(); + expect(conflict, isTrue); + expect(find.text('aaa is already used by Music'), findsOneWidget); + + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pump(); + expect(_chipTexts(tester), ['zip', 'mp3']); + expect(find.text('aaa'), findsOneWidget); + expect(find.text('aaa is already used by Music'), findsOneWidget); + expect(conflict, isTrue); + + final state = tester.state(find.byType(ExtensionTagEditor)); + expect(state.flush(), ['zip', 'mp3']); + expect(find.text('aaa'), findsOneWidget); + + await tester.enterText(find.byKey(const ValueKey('field')), 'zzz'); + await tester.pump(); + expect(conflict, isFalse); + expect(find.text('aaa is already used by Music'), findsNothing); + expect(tester.takeException(), isNull); + }); + + testWidgets('a tag claimed by another category renders red until removed', (WidgetTester tester) async { + var conflict = true; + await tester.pumpWidget( + _wrap( + ExtensionTagEditor( + inputKey: const ValueKey('field'), + initialTags: const ['zip', 'aaa'], + takenByCategory: const {'aaa': 'Music'}, + onConflictChanged: (value) => conflict = value, + ), + ), + ); + await tester.pumpAndSettle(); + final zipIdle = _chipBorder(tester, 'zip'); + expect(_chipBorder(tester, 'aaa'), isNot(zipIdle)); + expect(find.text('aaa is already used by Music'), findsOneWidget); + + final state = tester.state(find.byType(ExtensionTagEditor)); + expect(state.flush(), ['zip']); + + await tester.tap(find.byKey(const ValueKey('extension-chip-remove-aaa'))); + await tester.pump(); + expect(_chipTexts(tester), ['zip']); + expect(find.text('aaa is already used by Music'), findsNothing); + expect(conflict, isFalse); + expect(tester.takeException(), isNull); + }); +} diff --git a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart index dcf6e32d..ccf141ed 100644 --- a/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart +++ b/ui/flutter/test/features/tasks/presentation/create_task_window_page_test.dart @@ -22,10 +22,12 @@ import 'package:gopeed/core/capabilities/capability_rpc.dart'; import 'package:gopeed/core/capabilities/gopeed_capability.dart'; import 'package:gopeed/core/capabilities/storage_capability.dart'; import 'package:gopeed/features/tasks/presentation/pages/create_task_window_page.dart'; +import 'package:gopeed/features/tasks/presentation/widgets/resolve_file_tree.dart'; import 'package:gopeed/features/tasks/application/pending_create_task.dart'; import 'package:gopeed/shared/services/download_directory_picker.dart'; import 'package:gopeed/shared/theme/app_component_themes.dart'; import 'package:gopeed/shared/theme/app_design_tokens.dart'; +import 'package:gopeed/shared/theme/app_palette.dart'; import 'package:gopeed/shared/theme/app_theme.dart'; import 'package:gopeed/shared/widgets/app_loading_button.dart'; import 'package:gopeed/shared/widgets/app_tooltip.dart'; @@ -236,6 +238,78 @@ void main() { } }); + for (final omitOptions in [false, true]) { + testWidgets('an incoming task without a directory shows the configured directory (omit options: $omitOptions)', ( + tester, + ) async { + CreateTask? submitted; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind( + GopeedMethods.getConfig, + (_) => DownloaderConfig(downloadDir: '/downloads')..extra.defaultDirectDownload = true, + ) + ..bind(GopeedMethods.createTask, (task) { + submitted = task; + return 'created-id'; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final container = ProviderContainer( + overrides: [appCapabilitiesProvider.overrideWithValue(AppCapabilities(LocalCapabilityInvoker(registry)))], + ); + addTearDown(container.dispose); + final pending = container.read(pendingCreateTaskProvider.notifier); + final router = GoRouter( + initialLocation: '/create', + routes: [ + GoRoute(path: '/', builder: (_, _) => const SizedBox()), + GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()), + ], + ); + addTearDown(router.dispose); + await tester.pumpWidget( + UncontrolledProviderScope( + container: container, + child: shad.ShadcnApp.router( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + routerConfig: router, + builder: (_, child) => AppComponentThemes(child: child!), + ), + ), + ); + await tester.pumpAndSettle(); + + pending.set( + CreateTask( + req: Request(url: 'https://example.com/first.zip'), + opts: Options(path: 'D:/Other', skipAutoCategorize: true), + ), + ); + router.go('/create'); + await tester.pumpAndSettle(); + expect(_fieldText(tester, 'create-task-directory-input'), 'D:/Other'); + + // The second task names no directory, so the field may not keep the one the + // user chose for the first task. + pending.set( + CreateTask( + req: Request(url: 'https://example.com/second.zip'), + opts: omitOptions ? null : Options(), + ), + ); + router.go('/create'); + await tester.pumpAndSettle(); + expect(_fieldText(tester, 'create-task-directory-input'), '/downloads'); + + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + expect(submitted?.opts?.path, '/downloads'); + expect(submitted?.opts?.skipAutoCategorize, isFalse); + await _settleToastTimers(tester); + }); + } + testWidgets('mobile create task page stays within the system safe areas', (WidgetTester tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; tester.view.physicalSize = const Size(700, 760); @@ -293,7 +367,7 @@ void main() { CreateTask? submitted; final config = DownloaderConfig(downloadDir: 'C:/Downloads') ..extra.defaultDirectDownload = true - ..extra.downloadCategories = [ + ..categories = [ DownloadCategory(name: 'Archives', path: 'E:/Archives/%year%'), DownloadCategory(name: 'Archives', path: 'F:/Archives'), DownloadCategory(name: 'Long video category', path: 'G:/Videos'), @@ -479,7 +553,9 @@ void main() { await tester.pumpAndSettle(); await tester.tap(find.byKey(const ValueKey('android-app-directory-option'))); await tester.pumpAndSettle(); - expect(tester.widget(rememberDirectory).state, shad.CheckboxState.unchecked); + // Picking the directory already shown is still an explicit choice, so the + // default toggle is offered and pre-checked for a non-default directory. + expect(tester.widget(rememberDirectory).state, shad.CheckboxState.checked); await tester.tap(directoryPicker); await tester.pumpAndSettle(); @@ -685,6 +761,113 @@ void main() { expect(find.byKey(const ValueKey('create-history-close')), findsOneWidget); }); + testWidgets('the untouched default directory leaves the decision to the backend', (tester) async { + CreateTask? submitted; + await _pumpCreateTaskWithCategories( + tester, + downloadDir: 'C:/Downloads', + url: 'https://example.com/setup.exe', + categories: [ + DownloadCategory(name: 'Program', path: 'C:/Downloads/Program', extensions: const ['exe']), + ], + onCreate: (task) => submitted = task, + ); + + expect(_fieldText(tester, 'create-task-directory-input'), 'C:/Downloads'); + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(submitted?.opts?.path, 'C:/Downloads'); + expect(submitted?.opts?.skipAutoCategorize, isFalse); + await _settleToastTimers(tester); + }); + + testWidgets('a manual directory choice skips auto categorize', (tester) async { + CreateTask? submitted; + await _pumpCreateTaskWithCategories( + tester, + downloadDir: 'C:/Downloads', + url: 'https://example.com/setup.exe', + categories: [ + DownloadCategory(name: 'Program', path: 'C:/Downloads/Program', extensions: const ['exe']), + ], + onCreate: (task) => submitted = task, + ); + + await tester.enterText(find.byKey(const ValueKey('create-task-directory-input')), 'D:/Other'); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(submitted?.opts?.path, 'D:/Other'); + expect(submitted?.opts?.skipAutoCategorize, isTrue); + await _settleToastTimers(tester); + }); + + testWidgets('typing the default directory back still skips auto categorize', (tester) async { + CreateTask? submitted; + await _pumpCreateTaskWithCategories( + tester, + downloadDir: 'C:/Downloads', + url: 'https://example.com/setup.exe', + categories: [ + DownloadCategory(name: 'Program', path: 'C:/Downloads/Program', extensions: const ['exe']), + ], + onCreate: (task) => submitted = task, + ); + + final field = find.byKey(const ValueKey('create-task-directory-input')); + await tester.enterText(field, 'D:/Other'); + await tester.pumpAndSettle(); + await tester.enterText(field, 'C:/Downloads'); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pumpAndSettle(); + + // The value matches the default directory, but the user still named it. + expect(submitted?.opts?.path, 'C:/Downloads'); + expect(submitted?.opts?.skipAutoCategorize, isTrue); + await _settleToastTimers(tester); + }); + + testWidgets('picking the directory that is already shown still skips auto categorize', (tester) async { + CreateTask? submitted; + await _pumpCreateTaskWithCategories( + tester, + downloadDir: 'C:/Downloads', + url: 'https://example.com/setup.exe', + categories: [ + DownloadCategory(name: 'Program', path: 'C:/Downloads/Program', extensions: const ['exe']), + ], + onCreate: (task) => submitted = task, + ); + DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android; + DownloadDirectoryPicker.debugAndroidLocationsLoader = () async => { + 'application': 'C:/Downloads', + 'downloads': 'C:/Public', + }; + addTearDown(() => DownloadDirectoryPicker.debugAndroidLocationsLoader = null); + + await tester.tap(find.byKey(const ValueKey('create-task-directory-picker'))); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('android-app-directory-option'))); + await tester.pumpAndSettle(); + + // The picked directory equals the one already shown, but picking is an + // explicit choice, so the task skips the classification. + final checkbox = find.byKey(const ValueKey('create-task-remember-download-directory-checkbox')); + expect(tester.widget(checkbox).onChanged, isNotNull); + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(submitted?.opts?.path, 'C:/Downloads'); + expect(submitted?.opts?.skipAutoCategorize, isTrue); + await _settleToastTimers(tester); + }); + testWidgets('checksum state is reset for each incoming task and not inherited (T1)', (tester) async { debugDefaultTargetPlatformOverride = TargetPlatform.android; try { @@ -1050,6 +1233,495 @@ void main() { expect(find.text('Checksum Algorithm'), findsOneWidget); expect(find.text('Checksum Hash'), findsOneWidget); }); + + testWidgets('category shortcut highlights the current directory and toggles off when tapped again', (tester) async { + final palette = await _pumpCreateTaskWithCategories( + tester, + downloadDir: 'C:/Downloads', + categories: [ + DownloadCategory(name: 'Archives', path: 'E:/Archives'), + DownloadCategory(name: 'Videos', path: 'F:/Videos'), + ], + ); + + expect(_fieldText(tester, 'create-task-directory-input'), 'C:/Downloads'); + expect(_shortcutFill(tester, 0), palette.cardBg); + expect(_shortcutFill(tester, 1), palette.cardBg); + + await tester.tap(find.byKey(const ValueKey('create-task-category-0'))); + await tester.pumpAndSettle(); + expect(_fieldText(tester, 'create-task-directory-input'), 'E:/Archives'); + expect(_shortcutFill(tester, 0), palette.brandSoft); + expect(_shortcutFill(tester, 1), palette.cardBg); + + await tester.tap(find.byKey(const ValueKey('create-task-category-1'))); + await tester.pumpAndSettle(); + expect(_fieldText(tester, 'create-task-directory-input'), 'F:/Videos'); + expect(_shortcutFill(tester, 0), palette.cardBg); + expect(_shortcutFill(tester, 1), palette.brandSoft); + + await tester.tap(find.byKey(const ValueKey('create-task-category-1'))); + await tester.pumpAndSettle(); + expect(_fieldText(tester, 'create-task-directory-input'), 'C:/Downloads'); + expect(_shortcutFill(tester, 0), palette.cardBg); + expect(_shortcutFill(tester, 1), palette.cardBg); + expect(tester.takeException(), isNull); + }); + + testWidgets('directory edits highlight a matching category once the debounce settles', (tester) async { + final palette = await _pumpCreateTaskWithCategories( + tester, + downloadDir: 'C:/Downloads', + categories: [ + DownloadCategory(name: 'Archives', path: 'E:/Archives'), + DownloadCategory(name: 'Videos', path: 'F:/Videos'), + ], + ); + + await tester.enterText(find.byKey(const ValueKey('create-task-directory-input')), 'F:/Videos'); + await tester.pump(const Duration(milliseconds: 100)); + expect(_shortcutFill(tester, 1), palette.cardBg); + + await tester.pump(const Duration(milliseconds: 400)); + await tester.pumpAndSettle(); + expect(_shortcutFill(tester, 0), palette.cardBg); + expect(_shortcutFill(tester, 1), palette.brandSoft); + + await tester.enterText(find.byKey(const ValueKey('create-task-directory-input')), 'D:/Somewhere'); + await tester.pump(const Duration(milliseconds: 500)); + await tester.pumpAndSettle(); + expect(_shortcutFill(tester, 0), palette.cardBg); + expect(_shortcutFill(tester, 1), palette.cardBg); + expect(tester.takeException(), isNull); + }); + + testWidgets('clicking a shortcut selects its directory and skips auto categorize', (tester) async { + CreateTask? submitted; + final palette = await _pumpCreateTaskForSubmit( + tester, + categories: [ + DownloadCategory(name: 'Program', path: 'C:/Downloads/Program'), + DownloadCategory(name: 'Archives', path: 'E:/Archives'), + ], + onCreate: (task) => submitted = task, + ); + + await tester.tap(find.byKey(const ValueKey('create-task-category-0'))); + await tester.pumpAndSettle(); + expect(_fieldText(tester, 'create-task-directory-input'), 'C:/Downloads/Program'); + expect(_shortcutFill(tester, 0), palette.brandSoft); + expect(_shortcutFill(tester, 1), palette.cardBg); + + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pumpAndSettle(); + expect(submitted?.opts?.path, 'C:/Downloads/Program'); + expect(submitted?.opts?.skipAutoCategorize, isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets('tapping the selected shortcut restores the default directory and still skips', (tester) async { + CreateTask? submitted; + final palette = await _pumpCreateTaskForSubmit( + tester, + categories: [ + DownloadCategory(name: 'Program', path: 'C:/Downloads/Program'), + DownloadCategory(name: 'Archives', path: 'E:/Archives'), + ], + onCreate: (task) => submitted = task, + ); + + await tester.tap(find.byKey(const ValueKey('create-task-category-0'))); + await tester.pumpAndSettle(); + expect(_shortcutFill(tester, 0), palette.brandSoft); + + await tester.tap(find.byKey(const ValueKey('create-task-category-0'))); + await tester.pumpAndSettle(); + expect(_fieldText(tester, 'create-task-directory-input'), 'C:/Downloads'); + expect(_shortcutFill(tester, 0), palette.cardBg); + expect(_shortcutFill(tester, 1), palette.cardBg); + + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pumpAndSettle(); + expect(submitted?.opts?.path, 'C:/Downloads'); + expect(submitted?.opts?.skipAutoCategorize, isTrue); + expect(tester.takeException(), isNull); + }); + + testWidgets('the resolve dialog shows the suggested category directory', (tester) async { + tester.view.physicalSize = const Size(700, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind( + GopeedMethods.resolve, + (_) => ResolveResult( + id: 'resolved-id', + res: Resource(files: [FileInfo(name: 'setup.exe', size: 1024)]), + suggestedDownloadDir: '/downloads/Program', + ), + ) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes(child: const CreateTaskWindowPage()), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/setup.exe'); + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + final suggestion = find.byKey(const ValueKey('resolve-category-directory')); + expect(suggestion, findsOneWidget); + expect(tester.widget(suggestion).data, contains('/downloads/Program')); + expect(tester.takeException(), isNull); + }); + + testWidgets('the default download directory toggle needs a manual directory choice', (tester) async { + await _pumpCreateTaskWithCategories( + tester, + downloadDir: 'C:/Downloads', + url: 'https://example.com/setup.exe', + categories: [ + DownloadCategory(name: 'Program', path: 'C:/Downloads/Program', extensions: const ['exe']), + ], + onCreate: (task) {}, + ); + + final checkbox = find.byKey(const ValueKey('create-task-remember-download-directory-checkbox')); + // The global default directory is only saved for a directory the user + // named, so the toggle stays disabled until then. + expect(tester.widget(checkbox).onChanged, isNull); + + await tester.enterText(find.byKey(const ValueKey('create-task-directory-input')), 'D:/Other'); + await tester.pumpAndSettle(); + expect(tester.widget(checkbox).onChanged, isNotNull); + await _settleToastTimers(tester); + }); + + testWidgets('moving the caret in the directory field does not skip auto categorize', (tester) async { + CreateTask? submitted; + await _pumpCreateTaskWithCategories( + tester, + downloadDir: 'C:/Downloads', + url: 'https://example.com/setup.exe', + categories: [ + DownloadCategory(name: 'Program', path: 'C:/Downloads/Program', extensions: const ['exe']), + ], + onCreate: (task) => submitted = task, + ); + + // Only focus the field and move the caret, the text stays untouched. + final field = find.byKey(const ValueKey('create-task-directory-input')); + await tester.tap(field, warnIfMissed: false); + await tester.pumpAndSettle(); + await tester.tapAt(tester.getTopLeft(field) + const Offset(30, 10)); + await tester.pumpAndSettle(); + + expect(_fieldText(tester, 'create-task-directory-input'), 'C:/Downloads'); + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 50)); + + expect(submitted?.opts?.path, 'C:/Downloads'); + expect(submitted?.opts?.skipAutoCategorize, isFalse); + await _settleToastTimers(tester); + }); + + testWidgets('the resolve dialog shows the directory the user named, not the suggestion', (tester) async { + tester.view.physicalSize = const Size(700, 700); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind( + GopeedMethods.resolve, + (_) => ResolveResult( + id: 'resolved-id', + res: Resource(files: [FileInfo(name: 'setup.exe', size: 1024)]), + suggestedDownloadDir: '/downloads/Program', + ), + ) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes(child: const CreateTaskWindowPage()), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('create-task-directory-input')), '/work'); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/setup.exe'); + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + final shown = find.byKey(const ValueKey('resolve-category-directory')); + expect(shown, findsOneWidget); + expect(tester.widget(shown).data, contains('/work')); + expect(tester.widget(shown).data, isNot(contains('/downloads/Program'))); + expect(tester.takeException(), isNull); + }); + + testWidgets('a resolved file list is split into tasks that keep the directory structure', (tester) async { + tester.view.physicalSize = const Size(700, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + CreateTaskBatch? batch; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind( + GopeedMethods.resolve, + (_) => ResolveResult( + res: Resource( + name: 'bundle', + files: [ + FileInfo(name: 'first.zip', path: 'first.zip', size: 1), + FileInfo(name: 'second.zip', path: 'second.zip', size: 1), + ], + ), + ), + ) + ..bind(GopeedMethods.createTaskBatch, (request) { + batch = request; + return ['task-1', 'task-2']; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes(child: const CreateTaskWindowPage()), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/bundle'); + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + await tester.tap(find.byKey(const ValueKey('resolve-create-button'))); + await tester.pumpAndSettle(); + + expect(batch, isNotNull); + expect(batch!.reqs, hasLength(2)); + for (final item in batch!.reqs!) { + // The split tasks keep the structure of the resolved resource, so they + // are never classified into a category. + expect(item.opts?.skipAutoCategorize, isTrue); + } + expect(batch!.opts?.skipAutoCategorize, isTrue); + expect(tester.takeException(), isNull); + await _settleToastTimers(tester); + }); + + testWidgets('a single selected file of a multi file resource is still not classified', (tester) async { + tester.view.physicalSize = const Size(700, 900); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + CreateTaskBatch? batch; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => DownloaderConfig(downloadDir: '/downloads')) + ..bind( + GopeedMethods.resolve, + (_) => ResolveResult( + res: Resource( + files: [ + FileInfo(name: 'first.zip', path: 'first.zip', size: 1), + FileInfo(name: 'second.zip', path: 'second.zip', size: 1), + ], + ), + ), + ) + ..bind(GopeedMethods.createTaskBatch, (request) { + batch = request; + return ['task-1']; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes(child: const CreateTaskWindowPage()), + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/bundle'); + await tester.tap(find.byKey(const ValueKey('create-task-confirm-button'))); + await tester.pump(); + await tester.pump(const Duration(milliseconds: 300)); + + // Only the first file stays selected, the resource itself is a multi file + // resource and keeps its structure. + // Deselect the second file, the resource itself stays a multi file + // resource. + final treeCheckboxes = find.descendant(of: find.byType(ResolveFileTree), matching: find.byType(shad.Checkbox)); + await tester.tap(treeCheckboxes.last); + await tester.pump(); + await tester.tap(find.byKey(const ValueKey('resolve-create-button'))); + await tester.pumpAndSettle(); + + expect(batch, isNotNull); + expect(batch!.reqs, hasLength(1)); + for (final item in batch!.reqs!) { + expect(item.opts?.skipAutoCategorize, isTrue); + } + expect(batch!.opts?.skipAutoCategorize, isTrue); + expect(tester.takeException(), isNull); + await _settleToastTimers(tester); + }); +} + +/// Unmounts the tree and lets the shadcn toast close timers fire, so a test +/// ends without pending timers. +Future _settleToastTimers(WidgetTester tester) async { + await tester.pumpWidget(const SizedBox()); + await tester.pump(const Duration(seconds: 6)); +} + +/// Pumps the create dialog behind a router, so a finished submission can +/// navigate away, and captures the CreateTask that reaches the backend. +Future _pumpCreateTaskForSubmit( + WidgetTester tester, { + required void Function(CreateTask task) onCreate, + String downloadDir = 'C:/Downloads', + List categories = const [], +}) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.windows; + addTearDown(() => DownloadDirectoryPicker.debugPlatformOverride = null); + + final config = DownloaderConfig(downloadDir: downloadDir) + ..extra.defaultDirectDownload = true + ..categories = categories; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => config) + ..bind(GopeedMethods.createTask, (task) { + onCreate(task); + return 'created-task'; + }) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + final router = GoRouter( + initialLocation: '/create', + routes: [ + GoRoute(path: '/', builder: (_, _) => const SizedBox()), + GoRoute(path: '/create', builder: (_, _) => const CreateTaskWindowPage()), + ], + ); + addTearDown(router.dispose); + + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp.router( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + builder: (context, child) => AppComponentThemes(child: child ?? const SizedBox.shrink()), + routerConfig: router, + ), + ), + ); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), 'https://example.com/setup.exe'); + await tester.pumpAndSettle(); + return AppPalette.of(tester.element(find.byType(CreateTaskWindowPage))); +} + +Future _pumpCreateTaskWithCategories( + WidgetTester tester, { + required String downloadDir, + required List categories, + String? url, + String? rename, + void Function(CreateTask task)? onCreate, +}) async { + tester.view.physicalSize = const Size(1024, 720); + tester.view.devicePixelRatio = 1; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.windows; + addTearDown(() => DownloadDirectoryPicker.debugPlatformOverride = null); + + final config = DownloaderConfig(downloadDir: downloadDir) + ..extra.defaultDirectDownload = onCreate != null + ..categories = categories; + final registry = CapabilityRegistry(createAppCapabilityCodecs()) + ..bind(GopeedMethods.getConfig, (_) => config) + ..bind(StorageMethods.saveCreateHistory, (_) => const RpcUnit()); + if (onCreate != null) { + registry.bind(GopeedMethods.createTask, (task) { + onCreate(task); + return 'created-task'; + }); + } + final capabilities = AppCapabilities(LocalCapabilityInvoker(registry)); + + await tester.pumpWidget( + ProviderScope( + overrides: [appCapabilitiesProvider.overrideWithValue(capabilities)], + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: AppComponentThemes(child: const CreateTaskWindowPage()), + ), + ), + ); + await tester.pumpAndSettle(); + if (url != null) { + await tester.enterText(find.byKey(const ValueKey('create-task-url-input')), url); + await tester.pumpAndSettle(); + } + if (rename != null) { + await tester.enterText(find.byKey(const ValueKey('create-task-rename-input')), rename); + await tester.pumpAndSettle(); + } + return AppPalette.of(tester.element(find.byType(CreateTaskWindowPage))); +} + +// Asserts through the button's AnimatedContainer decoration, so a restructure of the button fails loudly. +Color? _shortcutFill(WidgetTester tester, int index) { + final container = tester.widget( + find.descendant(of: find.byKey(ValueKey('create-task-category-$index')), matching: find.byType(AnimatedContainer)), + ); + return (container.decoration! as BoxDecoration).color; } String _fieldText(WidgetTester tester, String key) { diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 123588d9..0ef262f5 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -1682,6 +1682,44 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('changing the download directory rebases built-in category paths', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 900)); + final settingsController = RebaseSettingsController(); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(() => settingsController), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Downloads')); + await tester.pumpAndSettle(); + + final directoryInput = find.byKey(const ValueKey('download-directory-input')); + expect(directoryInput, findsOneWidget); + await tester.enterText(directoryInput, '/dl/new'); + await tester.pump(const Duration(milliseconds: 700)); + await tester.pumpAndSettle(); + + final saved = settingsController.state.requireValue.config; + expect(saved.downloadDir, '/dl/new'); + expect( + {for (final category in saved.categories) category.name: category.path.replaceAll(r'\', '/')}, + { + 'Music': '/dl/new/Music', + 'Video': '/dl/new/Video', + 'Document': '/dl/new/Document', + 'Program': '/dl/new/Program', + 'Mine': '/dl/old/Mine', + }, + ); + expect(tester.takeException(), isNull); + }); + testWidgets('android download directory picker only switches to verified raw paths', (WidgetTester tester) async { await _setTestSize(tester, const Size(390, 760)); DownloadDirectoryPicker.debugPlatformOverride = TargetPlatform.android; @@ -2526,6 +2564,50 @@ void main() { expect(tester.takeException(), isNull); }); + testWidgets('extensions owned by another category block the add dialog', (WidgetTester tester) async { + await _setTestSize(tester, const Size(1024, 900)); + await tester.pumpWidget( + ProviderScope( + overrides: [ + appRuntimeControllerProvider.overrideWith(FakeRuntimeController.new), + appPlatformControllerProvider.overrideWith(FakePlatformController.new), + settingsControllerProvider.overrideWith(CategorySettingsController.new), + ], + child: const _SettingsTestApp(), + ), + ); + await tester.pumpAndSettle(); + await tester.tap(find.text('Downloads')); + await tester.pumpAndSettle(); + + await tester.tap(find.byKey(const ValueKey('add-download-category'))); + await tester.pumpAndSettle(); + await tester.enterText(find.byKey(const ValueKey('download-category-name')), '影集'); + await tester.enterText(find.byKey(const ValueKey('download-category-path')), '/tmp/shows'); + await tester.enterText(find.byKey(const ValueKey('download-category-extensions-input')), 'dat'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + + expect(find.text('dat is already used by 资料'), findsOneWidget); + AppPrimaryButton saveButton() => tester.widget( + find.ancestor(of: find.text('Save'), matching: find.byType(AppPrimaryButton)), + ); + expect(saveButton().onPressed, isNull); + + await tester.enterText(find.byKey(const ValueKey('download-category-extensions-input')), 'mkv'); + await tester.testTextInput.receiveAction(TextInputAction.done); + await tester.pumpAndSettle(); + expect(find.text('dat is already used by 资料'), findsNothing); + expect(saveButton().onPressed, isNotNull); + + await tester.tap(find.text('Save')); + await tester.pumpAndSettle(); + expect(find.text('影集'), findsOneWidget); + expect(find.text('/tmp/shows'), findsOneWidget); + expect(find.text('dat is already used by 资料'), findsNothing); + expect(tester.takeException(), isNull); + }); + testWidgets('settings content stretches responsively and stays centered', (WidgetTester tester) async { await _setTestSize(tester, const Size(1800, 900)); await tester.pumpWidget( @@ -6082,7 +6164,24 @@ class CategorySettingsController extends FakeSettingsController { @override Future build() async { final config = DownloaderConfig(); - config.extra.downloadCategories = [DownloadCategory(name: '资料', path: '/tmp/data')]; + config.categories = [ + DownloadCategory(name: '资料', path: '/tmp/data', extensions: ['dat']), + ]; + return SettingsState(config: config); + } +} + +class RebaseSettingsController extends FakeSettingsController { + @override + Future build() async { + final config = DownloaderConfig()..downloadDir = '/dl/old'; + config.categories = [ + DownloadCategory(name: 'Music', path: '/dl/old/Music', isBuiltIn: true, nameKey: 'categoryMusic'), + DownloadCategory(name: 'Video', path: '/dl/old/Video', isBuiltIn: true, nameKey: 'categoryVideo'), + DownloadCategory(name: 'Document', path: '/dl/old/Document', isBuiltIn: true, nameKey: 'categoryDocument'), + DownloadCategory(name: 'Program', path: '/dl/old/Program', isBuiltIn: true, nameKey: 'categoryProgram'), + DownloadCategory(name: 'Mine', path: '/dl/old/Mine'), + ]; return SettingsState(config: config); } }