mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-07 17:10:29 +00:00
feat(download): auto-categorize downloads into folders by file extension (#1572)
This commit is contained in:
1 parent
d125d4ab75
commit
9aae13453a
55 files changed
+4403
-217
No files matched your search
@@ -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
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
+85
-29
@@ -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);
|
||||
|
||||
@@ -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": "روابط السجل",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "پیوندهای تاریخچه",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "履歴リンク",
|
||||
|
||||
@@ -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": "기록 링크",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ссылки в истории",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "வரலாற்று இணைப்புகள்",
|
||||
|
||||
@@ -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ı",
|
||||
|
||||
@@ -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": "Посилання історії",
|
||||
|
||||
@@ -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ử",
|
||||
|
||||
@@ -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": "历史链接",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
+217
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user