feat(download): auto-categorize downloads into folders by file extension (#1572)

This commit is contained in:
WuFeng authored and GitHub committed 2026-10-23 20:38:25 +08:00
1 parent d125d4ab75
commit 9aae13453a
55 files changed
+4403 -217

No files matched your search

+17
View File
@@ -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:
+7 -1
View File
@@ -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()
+99
View File
@@ -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
+221
View File
@@ -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)
+186
View File
@@ -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
}
+566
View File
@@ -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)
}
}
+695
View File
@@ -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)
}
}
}
+126 -46
View File
@@ -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()
+49 -4
View File
@@ -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()
+13
View File
@@ -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
+59
View File
@@ -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",
"ſ",
"Ä"
]
}
]
}
+20 -11
View File
@@ -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{
+15
View File
@@ -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 {
+8
View File
@@ -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)
@@ -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<DownloadCategory> 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<String, dynamic> json) => _$DownloaderConfigFromJson(json);
@@ -192,7 +197,6 @@ class ExtraConfig {
bool runAsMenubarApp;
bool analyticsEnabled;
String analyticsClientId;
List<DownloadCategory> 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<String, dynamic>? 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<String> 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<String, dynamic> json) => _$DownloadCategoryFromJson(json);
Map<String, dynamic> 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<String> 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<String> normalizeCategoryExtensions(Iterable<String> raw) {
final result = <String>{};
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 = <String, List<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'],
};
/// 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;
@@ -12,6 +12,12 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> 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<dynamic>?)
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
autoCategorize: json['autoCategorize'] as bool? ?? false,
)
..protocolConfig = ProtocolConfig.fromJson(json['protocolConfig'] as Map<String, dynamic>?)
..extra = ExtraConfig.fromJson(json['extra'] as Map<String, dynamic>?)
@@ -37,6 +43,8 @@ Map<String, dynamic> _$DownloaderConfigToJson(DownloaderConfig instance) => <Str
'api': instance.api.toJson(),
'autoStartTasks': instance.autoStartTasks,
'autoDeleteMissingFileTasks': instance.autoDeleteMissingFileTasks,
'categories': instance.categories.map((e) => e.toJson()).toList(),
'autoCategorize': instance.autoCategorize,
};
ApiServerConfig _$ApiServerConfigFromJson(Map<String, dynamic> json) => ApiServerConfig(
@@ -186,11 +194,6 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
runAsMenubarApp: json['runAsMenubarApp'] as bool? ?? false,
analyticsEnabled: json['analyticsEnabled'] as bool? ?? true,
analyticsClientId: json['analyticsClientId'] as String? ?? '',
downloadCategories:
(json['downloadCategories'] as List<dynamic>?)
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
.toList() ??
const [],
);
Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynamic>{
@@ -210,7 +213,6 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) => <String, dynam
'runAsMenubarApp': instance.runAsMenubarApp,
'analyticsEnabled': instance.analyticsEnabled,
'analyticsClientId': instance.analyticsClientId,
'downloadCategories': instance.downloadCategories.map((e) => e.toJson()).toList(),
};
WindowStateConfig _$WindowStateConfigFromJson(Map<String, dynamic> json) => WindowStateConfig(
@@ -231,6 +233,7 @@ DownloadCategory _$DownloadCategoryFromJson(Map<String, dynamic> json) => Downlo
isBuiltIn: json['isBuiltIn'] as bool? ?? false,
nameKey: json['nameKey'] as String?,
isDeleted: json['isDeleted'] as bool? ?? false,
extensions: (json['extensions'] as List<dynamic>?)?.map((e) => e as String).toList() ?? const [],
);
Map<String, dynamic> _$DownloadCategoryToJson(DownloadCategory instance) => <String, dynamic>{
@@ -239,6 +242,7 @@ Map<String, dynamic> _$DownloadCategoryToJson(DownloadCategory instance) => <Str
'isBuiltIn': instance.isBuiltIn,
'nameKey': ?instance.nameKey,
'isDeleted': instance.isDeleted,
'extensions': instance.extensions,
};
WebhookConfig _$WebhookConfigFromJson(Map<String, dynamic> json) => WebhookConfig(
+5
View File
@@ -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<int> selectFiles;
Object? extra;
@@ -17,6 +21,7 @@ class Options {
this.name = '',
this.path = '',
this.asDefaultPath = false,
this.skipAutoCategorize = false,
this.selectFiles = const [],
this.extra,
});
+2
View File
@@ -11,6 +11,7 @@ Options _$OptionsFromJson(Map<String, dynamic> 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<dynamic>?)
?.map((e) => (e as num).toInt())
@@ -24,6 +25,7 @@ Map<String, dynamic> _$OptionsToJson(Options instance) => <String, dynamic>{
'name': instance.name,
'path': instance.path,
'asDefaultPath': instance.asDefaultPath,
'skipAutoCategorize': instance.skipAutoCategorize,
'selectFiles': instance.selectFiles,
'extra': ?instance.extra,
};
+6 -1
View File
@@ -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<String, dynamic> json) => _$ResolveResultFromJson(json);
Map<String, dynamic> toJson() => _$ResolveResultToJson(this);
@@ -6,10 +6,14 @@ part of 'resolve_result.dart';
// JsonSerializableGenerator
// **************************************************************************
ResolveResult _$ResolveResultFromJson(Map<String, dynamic> json) =>
ResolveResult(id: json['id'] as String? ?? "", res: Resource.fromJson(json['res'] as Map<String, dynamic>));
ResolveResult _$ResolveResultFromJson(Map<String, dynamic> json) => ResolveResult(
id: json['id'] as String? ?? "",
res: Resource.fromJson(json['res'] as Map<String, dynamic>),
suggestedDownloadDir: json['suggestedDownloadDir'] as String? ?? "",
);
Map<String, dynamic> _$ResolveResultToJson(ResolveResult instance) => <String, dynamic>{
'id': instance.id,
'res': instance.res.toJson(),
'suggestedDownloadDir': instance.suggestedDownloadDir,
};
@@ -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<AppRuntimeState> {
return cfg;
}
Future<DownloaderConfig> _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<void> _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<DownloaderConfig> _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<void> _initAnalytics(DownloaderConfig config) async {
@@ -322,11 +276,13 @@ class AppRuntimeController extends AsyncNotifier<AppRuntimeState> {
}
Future<String> _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<AppRuntimeState> {
if (Util.isIOS()) {
return (await getApplicationDocumentsDirectory()).path;
}
return './';
return '';
}
String _runningAddress(StartConfig config, int runningPort) {
@@ -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<DownloaderConfig> loadDownloaderConfig({
required Future<DownloaderConfig> Function() load,
required Future<void> Function(DownloaderConfig config) save,
required Future<String> 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<bool> initDownloaderConfig(
DownloaderConfig config, {
required Future<String> 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<String>.from(kDefaultCategoryExtensions[nameKey] ?? const []),
);
}
@@ -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<SettingsPage> {
DownloaderConfig? _config;
StartConfig? _startConfig;
String? _loadedSignature;
String? _appliedDownloadDir;
String? _loadedStartSignature;
Timer? _textSaveTimer;
bool _syncingControllers = false;
@@ -470,13 +472,21 @@ class _SettingsPageState extends ConsumerState<SettingsPage> {
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<SettingsPage> {
_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<SettingsPage> {
}
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<SettingsPage> {
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<DownloadCategory>.from(config.extra.downloadCategories);
final categories = List<DownloadCategory>.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<SettingsPage> {
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<SettingsPage> {
category.isDeleted = true;
return;
}
config.extra.downloadCategories = List<DownloadCategory>.from(config.extra.downloadCategories)..remove(category);
config.categories = List<DownloadCategory>.from(config.categories)..remove(category);
});
}
@@ -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<String> 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<DownloadCategoryDraft?> showDownloadCategoryDialog(
DownloadCategory? category,
required String initialName,
required String initialPath,
required List<DownloadCategory> categories,
required String Function(DownloadCategory) displayName,
}) async {
final nameController = TextEditingController(text: initialName);
final pathController = TextEditingController(text: category?.path ?? initialPath);
final editorKey = GlobalKey<ExtensionTagEditorState>();
final nameKey = category?.nameKey;
final restoreDefaults = nameKey == null
? const <String>[]
: normalizeCategoryExtensions(kDefaultCategoryExtensions[nameKey] ?? const []);
// Extensions every other live category already owns, mapped to its
// display name; the edited category itself is exempt.
final takenExtensions = <String, String>{};
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<DownloadCategoryDraft?>(
@@ -150,14 +183,23 @@ Future<DownloadCategoryDraft?> 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<DownloadCategoryDraft?> 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<DownloadCategoryDraft?> 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)),
],
);
},
@@ -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<String> 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<String>? 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<String, String>? takenByCategory;
/// Reports whether the editor currently holds an extension claimed by
/// another category so the host can disable its submit action.
final ValueChanged<bool>? onConflictChanged;
@override
State<ExtensionTagEditor> createState() => ExtensionTagEditorState();
}
class ExtensionTagEditorState extends State<ExtensionTagEditor> {
late final List<String> _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<String> flush() {
_update(_commitPending);
final taken = widget.takenByCategory;
return List<String>.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)),
),
],
),
);
}
}
@@ -92,6 +92,27 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
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<CreateTaskWindowPage> {
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<CreateTaskWindowPage> {
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<CreateTaskWindowPage> {
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<CreateTaskWindowPage> {
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<String, dynamic>.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<String, dynamic>.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<CreateTaskWindowPage> {
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<CreateTaskWindowPage> {
),
],
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<CreateTaskWindowPage> {
_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<CreateTaskWindowPage> {
_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<CreateTaskWindowPage> {
return text.length == 40 && RegExp(r'^[0-9a-fA-F]+$').hasMatch(text);
}
api_options.Options _buildOptions({String? name, List<int> selectFiles = const [], bool omitChecksum = false}) {
api_options.Options _buildOptions({
String? name,
List<int> 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<CreateTaskWindowPage> {
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<CreateTaskWindowPage> {
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<void> _submitResolved(Request request, ResolveResult result, List<int> selectedIndexes) async {
final selected = (selectedIndexes.isEmpty ? result.res.files.asMap().keys.toList() : selectedIndexes.toList())
..sort();
@@ -1136,6 +1239,11 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
}
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<CreateTaskWindowPage> {
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<CreateTaskWindowPage> {
.createTaskBatch(
CreateTaskBatch(
reqs: reqs,
opts: _buildOptions(omitChecksum: isMultiFile),
opts: _buildOptions(omitChecksum: isMultiFile, skipAutoCategorize: keepStructure),
),
);
return;
@@ -1168,6 +1277,12 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
var selectedIndexes = List<int>.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<bool>(
context: context,
@@ -1223,6 +1338,16 @@ class _CreateTaskWindowPageState extends ConsumerState<CreateTaskWindowPage> {
: 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<bool> 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<bool> 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);
+8
View File
@@ -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": "روابط السجل",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+25
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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": "پیوندهای تاریخچه",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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": "履歴リンク",
+8
View File
@@ -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": "기록 링크",
+8
View File
@@ -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",
+8
View File
@@ -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",
+8
View File
@@ -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": "Ссылки в истории",
+8
View File
@@ -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",
+8
View File
@@ -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": "வரலாற்று இணைப்புகள்",
+8
View File
@@ -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ı",
+8
View File
@@ -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": "Посилання історії",
+8
View File
@@ -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ử",
+8
View File
@@ -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": "历史链接",
+8
View File
@@ -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": "歷史連結",
@@ -134,9 +134,13 @@ class _AppPathPickerFieldState extends State<AppPathPickerField> {
Future<void> _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);
}
@@ -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<String, dynamic>)['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 = <String, Set<String>>{};
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<String, dynamic>;
for (final c in cases['normalizeExtensions'] as List) {
final input = (c['input'] as List).cast<String>();
final expected = (c['expected'] as List).cast<String>();
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}');
}
@@ -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<DownloadCategory> 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);
});
});
}
@@ -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<String> _chipTexts(WidgetTester tester) => tester
.widgetList<Text>(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<Container>(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<ExtensionTagEditorState>(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<ExtensionTagEditorState>(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<ExtensionTagEditorState>(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);
});
}
@@ -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<shad.Checkbox>(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<shad.Checkbox>(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<shad.Checkbox>(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<Text>(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<shad.Checkbox>(checkbox).onChanged, isNull);
await tester.enterText(find.byKey(const ValueKey('create-task-directory-input')), 'D:/Other');
await tester.pumpAndSettle();
expect(tester.widget<shad.Checkbox>(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<Text>(shown).data, contains('/work'));
expect(tester.widget<Text>(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<void> _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<AppPalette> _pumpCreateTaskForSubmit(
WidgetTester tester, {
required void Function(CreateTask task) onCreate,
String downloadDir = 'C:/Downloads',
List<DownloadCategory> 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<AppPalette> _pumpCreateTaskWithCategories(
WidgetTester tester, {
required String downloadDir,
required List<DownloadCategory> 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<AnimatedContainer>(
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) {
+100 -1
View File
@@ -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<AppPrimaryButton>(
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<SettingsState> 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<SettingsState> 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);
}
}