fix: torrent redundant directory (#649)

This commit is contained in:
Levi authored and GitHub committed 2024-08-14 21:54:59 +08:00
1 parent d9f04ae928
commit ece5b91dc2
13 files changed
+300 -92

No files matched your search

+4
View File
@@ -110,6 +110,10 @@ type FetcherManager interface {
Filters() []*SchemeFilter Filters() []*SchemeFilter
// Build returns a new fetcher. // Build returns a new fetcher.
Build() Fetcher Build() Fetcher
// ParseName name displayed when the task is not yet resolved, parsed from the request URL
ParseName(u string) string
// AutoRename returns whether the fetcher need renaming the download file when has the same name file.
AutoRename() bool
// DefaultConfig returns the default configuration of the protocol. // DefaultConfig returns the default configuration of the protocol.
DefaultConfig() any DefaultConfig() any
+30 -5
View File
@@ -11,6 +11,7 @@ import (
"github.com/GopeedLab/gopeed/pkg/util" "github.com/GopeedLab/gopeed/pkg/util"
"github.com/anacrolix/torrent" "github.com/anacrolix/torrent"
"github.com/anacrolix/torrent/metainfo" "github.com/anacrolix/torrent/metainfo"
"net/url"
"path/filepath" "path/filepath"
"strings" "strings"
"sync" "sync"
@@ -108,7 +109,7 @@ func (f *Fetcher) Resolve(req *base.Request) error {
func (f *Fetcher) Create(opts *base.Options) (err error) { func (f *Fetcher) Create(opts *base.Options) (err error) {
f.meta.Opts = opts f.meta.Opts = opts
if f.meta.Res != nil { if f.meta.Res != nil {
torrentDirMap[f.meta.Res.Hash] = f.meta.FolderPath() torrentDirMap[f.meta.Res.Hash] = opts.Path
} }
return nil return nil
} }
@@ -120,7 +121,7 @@ func (f *Fetcher) Start() (err error) {
} }
} }
if ft, ok := ftMap[f.meta.Res.Hash]; ok { if ft, ok := ftMap[f.meta.Res.Hash]; ok {
ft.setTorrentDir(f.meta.FolderPath()) ft.setTorrentDir(f.meta.Opts.Path)
} }
files := f.torrent.Files() files := f.torrent.Files()
// If the user does not specify the file to download, all files will be downloaded by default // If the user does not specify the file to download, all files will be downloaded by default
@@ -245,16 +246,18 @@ func (f *Fetcher) isDone() bool {
func (f *Fetcher) updateRes() { func (f *Fetcher) updateRes() {
res := &base.Resource{ res := &base.Resource{
Name: f.torrent.Name(),
Range: true, Range: true,
Files: make([]*base.FileInfo, len(f.torrent.Files())), Files: make([]*base.FileInfo, len(f.torrent.Files())),
Hash: f.torrent.InfoHash().String(), Hash: f.torrent.InfoHash().String(),
} }
f.torrent.PeerConns() // Directory torrent
if f.torrent.Info().Length == 0 {
res.Name = f.torrent.Name()
}
for i, file := range f.torrent.Files() { for i, file := range f.torrent.Files() {
res.Files[i] = &base.FileInfo{ res.Files[i] = &base.FileInfo{
Name: filepath.Base(file.DisplayPath()), Name: filepath.Base(file.DisplayPath()),
Path: util.Dir(file.Path()), Path: util.Dir(file.DisplayPath()),
Size: file.Length(), Size: file.Length(),
} }
} }
@@ -458,6 +461,28 @@ func (fm *FetcherManager) Build() fetcher.Fetcher {
return &Fetcher{} return &Fetcher{}
} }
func (fm *FetcherManager) ParseName(u string) string {
var name string
url, err := url.Parse(u)
if err != nil {
return ""
}
params := url.Query()
if params.Get("dn") != "" {
return params.Get("dn")
}
if params.Get("xt") != "" {
xt := strings.Split(params.Get("xt"), ":")
return xt[len(xt)-1]
}
return name
}
func (fm *FetcherManager) AutoRename() bool {
return false
}
func (fm *FetcherManager) DefaultConfig() any { func (fm *FetcherManager) DefaultConfig() any {
return &config{ return &config{
ListenPort: 0, ListenPort: 0,
+113 -24
View File
@@ -33,7 +33,6 @@ func TestFetcher_Resolve_DataUri_Torrent(t *testing.T) {
} }
want := &base.Resource{ want := &base.Resource{
Name: "ubuntu-22.04-live-server-amd64.iso",
Size: 1466714112, Size: 1466714112,
Range: true, Range: true,
Files: []*base.FileInfo{ Files: []*base.FileInfo{
@@ -69,33 +68,123 @@ func TestFetcher_ResolveWithProxy(t *testing.T) {
} }
func doResolve(t *testing.T, fetcher fetcher.Fetcher) { func doResolve(t *testing.T, fetcher fetcher.Fetcher) {
err := fetcher.Resolve(&base.Request{ t.Run("Resolve Single File", func(t *testing.T) {
URL: "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent", err := fetcher.Resolve(&base.Request{
Extra: bt.ReqExtra{ URL: "./testdata/ubuntu-22.04-live-server-amd64.iso.torrent",
Trackers: []string{ Extra: bt.ReqExtra{
"udp://tracker.birkenwald.de:6969/announce", Trackers: []string{
"udp://tracker.bitsearch.to:1337/announce", "udp://tracker.birkenwald.de:6969/announce",
"udp://tracker.bitsearch.to:1337/announce",
},
}, },
}, })
}) if err != nil {
if err != nil { panic(err)
panic(err) }
}
want := &base.Resource{ want := &base.Resource{
Name: "ubuntu-22.04-live-server-amd64.iso", Size: 1466714112,
Size: 1466714112, Range: true,
Range: true, Files: []*base.FileInfo{
Files: []*base.FileInfo{ {
{ Name: "ubuntu-22.04-live-server-amd64.iso",
Name: "ubuntu-22.04-live-server-amd64.iso", Size: 1466714112,
Size: 1466714112, },
}, },
}, Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
Hash: "8a55cfbd5ca5d11507364765936c4f9e55b253ed", }
if !reflect.DeepEqual(want, fetcher.Meta().Res) {
t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want)
}
})
t.Run("Resolve Multi Files", func(t *testing.T) {
err := fetcher.Resolve(&base.Request{
URL: "./testdata/test.torrent",
Extra: bt.ReqExtra{
Trackers: []string{
"udp://tracker.birkenwald.de:6969/announce",
"udp://tracker.bitsearch.to:1337/announce",
},
},
})
if err != nil {
panic(err)
}
want := &base.Resource{
Name: "test",
Size: 107484864,
Range: true,
Files: []*base.FileInfo{
{
Name: "c.txt",
Path: "path",
Size: 98501754,
},
{
Name: "b.txt",
Size: 8904996,
},
{
Name: "a.txt",
Size: 78114,
},
},
Hash: "ccbc92b0cd8deec16a2ef4be242a8c9243b1cedb",
}
if !reflect.DeepEqual(want, fetcher.Meta().Res) {
t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want)
}
})
}
func TestFetcherManager_ParseName(t *testing.T) {
type args struct {
u string
} }
if !reflect.DeepEqual(want, fetcher.Meta().Res) { tests := []struct {
t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, want) name string
args args
want string
}{
{
name: "broken url",
args: args{
u: "magnet://!@#%github.com",
},
want: "",
},
{
name: "dn",
args: args{
u: "magnet:?xt=urn:btih:8a55cfbd5ca5d11507364765936c4f9e55b253ed&dn=ubuntu-22.04-live-server-amd64.iso",
},
want: "ubuntu-22.04-live-server-amd64.iso",
},
{
name: "no dn",
args: args{
u: "magnet:?xt=urn:btih:8a55cfbd5ca5d11507364765936c4f9e55b253ed",
},
want: "8a55cfbd5ca5d11507364765936c4f9e55b253ed",
},
{
name: "non standard magnet",
args: args{
u: "magnet:?xxt=abcd",
},
want: "",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fm := &FetcherManager{}
if got := fm.ParseName(tt.args.u); got != tt.want {
t.Errorf("ParseName() = %v, want %v", got, tt.want)
}
})
} }
} }
Binary file not shown.
+22 -2
View File
@@ -99,7 +99,7 @@ func (f *Fetcher) Resolve(req *base.Request) error {
} }
if base.HttpCodePartialContent == httpResp.StatusCode || (base.HttpCodeOK == httpResp.StatusCode && httpResp.Header.Get(base.HttpHeaderAcceptRanges) == base.HttpHeaderBytes && strings.HasPrefix(httpResp.Header.Get(base.HttpHeaderContentRange), base.HttpHeaderBytes)) { if base.HttpCodePartialContent == httpResp.StatusCode || (base.HttpCodeOK == httpResp.StatusCode && httpResp.Header.Get(base.HttpHeaderAcceptRanges) == base.HttpHeaderBytes && strings.HasPrefix(httpResp.Header.Get(base.HttpHeaderContentRange), base.HttpHeaderBytes)) {
// 1.返回206响应码表示支持断点下载 2.不返回206但是Accept-Ranges首部并且等于bytes也表示支持断点下载 // response 206 status code, support breakpoint continuation
res.Range = true res.Range = true
// 解析资源大小: bytes 0-1000/1001 => 1001 // 解析资源大小: bytes 0-1000/1001 => 1001
contentTotal := path.Base(httpResp.Header.Get(base.HttpHeaderContentRange)) contentTotal := path.Base(httpResp.Header.Get(base.HttpHeaderContentRange))
@@ -111,7 +111,8 @@ func (f *Fetcher) Resolve(req *base.Request) error {
res.Size = parse res.Size = parse
} }
} else if base.HttpCodeOK == httpResp.StatusCode { } else if base.HttpCodeOK == httpResp.StatusCode {
// 返回200响应码,不支持断点下载,通过Content-Length头获取文件大小,获取不到的话可能是chunked编码 // response 200 status code, not support breakpoint continuation, get file size by Content-Length header
// if not found, maybe chunked encoding
contentLength := httpResp.Header.Get(base.HttpHeaderContentLength) contentLength := httpResp.Header.Get(base.HttpHeaderContentLength)
if contentLength != "" { if contentLength != "" {
parse, err := strconv.ParseInt(contentLength, 10, 64) parse, err := strconv.ParseInt(contentLength, 10, 64)
@@ -481,6 +482,25 @@ func (fm *FetcherManager) Build() fetcher.Fetcher {
return &Fetcher{} return &Fetcher{}
} }
func (fm *FetcherManager) ParseName(u string) string {
var name string
url, err := url.Parse(u)
if err != nil {
return ""
}
// Get filePath by URL
name = path.Base(url.Path)
// If file name is empty, use host name
if name == "" || name == "/" || name == "." {
name = url.Hostname()
}
return name
}
func (fm *FetcherManager) AutoRename() bool {
return true
}
func (fm *FetcherManager) DefaultConfig() any { func (fm *FetcherManager) DefaultConfig() any {
return &config{ return &config{
UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36", UserAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36",
+51 -3
View File
@@ -192,6 +192,54 @@ func TestFetcher_ConfigUseServerCtime(t *testing.T) {
} }
} }
func TestFetcherManager_ParseName(t *testing.T) {
type args struct {
u string
}
tests := []struct {
name string
args args
want string
}{
{
name: "broken url",
args: args{
u: "https://!@#%github.com",
},
want: "",
},
{
name: "file path",
args: args{
u: "https://github.com/index.html",
},
want: "index.html",
},
{
name: "file path with query and hash",
args: args{
u: "https://github.com/a/b/index.html/#list?name=1",
},
want: "index.html",
},
{
name: "no file path",
args: args{
u: "https://github.com",
},
want: "github.com",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
fm := &FetcherManager{}
if got := fm.ParseName(tt.args.u); got != tt.want {
t.Errorf("ParseName() = %v, want %v", got, tt.want)
}
})
}
}
func downloadReady(listener net.Listener, connections int, t *testing.T) fetcher.Fetcher { func downloadReady(listener net.Listener, connections int, t *testing.T) fetcher.Fetcher {
return doDownloadReady(buildFetcher(), listener, connections, t) return doDownloadReady(buildFetcher(), listener, connections, t)
} }
@@ -359,11 +407,11 @@ func downloadWithProxy(httpListener net.Listener, proxyListener net.Listener, t
} }
func buildFetcher() *Fetcher { func buildFetcher() *Fetcher {
fb := new(FetcherManager) fm := new(FetcherManager)
fetcher := fb.Build() fetcher := fm.Build()
newController := controller.NewController() newController := controller.NewController()
newController.GetConfig = func(v any) { newController.GetConfig = func(v any) {
json.Unmarshal([]byte(test.ToJson(fb.DefaultConfig())), v) json.Unmarshal([]byte(test.ToJson(fm.DefaultConfig())), v)
} }
fetcher.Setup(newController) fetcher.Setup(newController)
return fetcher.(*Fetcher) return fetcher.(*Fetcher)
+21 -19
View File
@@ -930,26 +930,28 @@ func (d *Downloader) doStart(task *Task) (err error) {
} }
if isCreate { if isCreate {
d.checkDuplicateLock.Lock() if task.fetcherManager.AutoRename() {
defer d.checkDuplicateLock.Unlock() d.checkDuplicateLock.Lock()
task.Meta.Opts.Name = util.ReplaceInvalidFilename(task.Meta.Opts.Name) defer d.checkDuplicateLock.Unlock()
// check if the download file is duplicated and rename it automatically. task.Meta.Opts.Name = util.ReplaceInvalidFilename(task.Meta.Opts.Name)
if task.Meta.Res.Name != "" { // check if the download file is duplicated and rename it automatically.
task.Meta.Res.Name = util.ReplaceInvalidFilename(task.Meta.Res.Name) if task.Meta.Res.Name != "" {
fullDirPath := task.Meta.FolderPath() task.Meta.Res.Name = util.ReplaceInvalidFilename(task.Meta.Res.Name)
newName, err := util.CheckDuplicateAndRename(fullDirPath) fullDirPath := task.Meta.FolderPath()
if err != nil { newName, err := util.CheckDuplicateAndRename(fullDirPath)
return err if err != nil {
return err
}
task.Meta.Opts.Name = newName
} else {
task.Meta.Res.Files[0].Name = util.ReplaceInvalidFilename(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.Opts.Name = newName
} else {
task.Meta.Res.Files[0].Name = util.ReplaceInvalidFilename(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) task.Meta.Res.CalcSize(task.Meta.Opts.SelectFiles)
+42
View File
@@ -1,6 +1,7 @@
package download package download
import ( import (
"encoding/json"
"github.com/GopeedLab/gopeed/internal/controller" "github.com/GopeedLab/gopeed/internal/controller"
"github.com/GopeedLab/gopeed/internal/fetcher" "github.com/GopeedLab/gopeed/internal/fetcher"
"github.com/GopeedLab/gopeed/internal/protocol/bt" "github.com/GopeedLab/gopeed/internal/protocol/bt"
@@ -49,6 +50,47 @@ func NewTask() *Task {
} }
} }
// Name returns the display name of the task.
func (t *Task) Name() string {
// Custom name first
if t.Meta.Opts.Name != "" {
return t.Meta.Opts.Name
}
// Task is not resolved, parse the name from the URL
if t.Meta.Res == nil {
fallbackName := "unknown"
if t.fetcherManager == nil {
return fallbackName
}
parseName := t.fetcherManager.ParseName(t.Meta.Req.URL)
if parseName == "" {
return fallbackName
}
return parseName
}
// Task is a folder
if t.Meta.Res.Name != "" {
return t.Meta.Res.Name
}
// Get the name of the first file
return t.Meta.Res.Files[0].Name
}
func (t *Task) MarshalJSON() ([]byte, error) {
type rawTaskType Task
jsonTask := struct {
rawTaskType
Name string `json:"name"`
}{
rawTaskType(*t),
t.Name(),
}
return json.Marshal(jsonTask)
}
func (t *Task) updateStatus(status base.Status) { func (t *Task) updateStatus(status base.Status) {
t.UpdatedAt = time.Now() t.UpdatedAt = time.Now()
t.Status = status t.Status = status
@@ -9,7 +9,7 @@ part of 'downloader_config.dart';
DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) => DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
DownloaderConfig( DownloaderConfig(
downloadDir: json['downloadDir'] as String? ?? '', downloadDir: json['downloadDir'] as String? ?? '',
maxRunning: json['maxRunning'] as int? ?? 0, maxRunning: (json['maxRunning'] as num?)?.toInt() ?? 0,
) )
..protocolConfig = ProtocolConfig.fromJson( ..protocolConfig = ProtocolConfig.fromJson(
json['protocolConfig'] as Map<String, dynamic>?) json['protocolConfig'] as Map<String, dynamic>?)
@@ -38,7 +38,7 @@ Map<String, dynamic> _$ProtocolConfigToJson(ProtocolConfig instance) =>
HttpConfig _$HttpConfigFromJson(Map<String, dynamic> json) => HttpConfig( HttpConfig _$HttpConfigFromJson(Map<String, dynamic> json) => HttpConfig(
userAgent: json['userAgent'] as String? ?? '', userAgent: json['userAgent'] as String? ?? '',
connections: json['connections'] as int? ?? 0, connections: (json['connections'] as num?)?.toInt() ?? 0,
useServerCtime: json['useServerCtime'] as bool? ?? false, useServerCtime: json['useServerCtime'] as bool? ?? false,
); );
@@ -50,14 +50,14 @@ Map<String, dynamic> _$HttpConfigToJson(HttpConfig instance) =>
}; };
BtConfig _$BtConfigFromJson(Map<String, dynamic> json) => BtConfig( BtConfig _$BtConfigFromJson(Map<String, dynamic> json) => BtConfig(
listenPort: json['listenPort'] as int? ?? 0, listenPort: (json['listenPort'] as num?)?.toInt() ?? 0,
trackers: (json['trackers'] as List<dynamic>?) trackers: (json['trackers'] as List<dynamic>?)
?.map((e) => e as String) ?.map((e) => e as String)
.toList() ?? .toList() ??
const [], const [],
seedKeep: json['seedKeep'] as bool? ?? false, seedKeep: json['seedKeep'] as bool? ?? false,
seedRatio: (json['seedRatio'] as num?)?.toDouble() ?? 0, seedRatio: (json['seedRatio'] as num?)?.toDouble() ?? 0,
seedTime: json['seedTime'] as int? ?? 0, seedTime: (json['seedTime'] as num?)?.toInt() ?? 0,
); );
Map<String, dynamic> _$BtConfigToJson(BtConfig instance) => <String, dynamic>{ Map<String, dynamic> _$BtConfigToJson(BtConfig instance) => <String, dynamic>{
+2
View File
@@ -11,6 +11,7 @@ enum Protocol { http, bt }
@JsonSerializable(explicitToJson: true) @JsonSerializable(explicitToJson: true)
class Task { class Task {
String id; String id;
String name;
Protocol? protocol; Protocol? protocol;
Meta meta; Meta meta;
Status status; Status status;
@@ -21,6 +22,7 @@ class Task {
Task({ Task({
required this.id, required this.id,
required this.name,
required this.meta, required this.meta,
required this.status, required this.status,
required this.uploading, required this.uploading,
+7 -5
View File
@@ -8,6 +8,7 @@ part of 'task.dart';
Task _$TaskFromJson(Map<String, dynamic> json) => Task( Task _$TaskFromJson(Map<String, dynamic> json) => Task(
id: json['id'] as String, id: json['id'] as String,
name: json['name'] as String,
meta: Meta.fromJson(json['meta'] as Map<String, dynamic>), meta: Meta.fromJson(json['meta'] as Map<String, dynamic>),
status: $enumDecode(_$StatusEnumMap, json['status']), status: $enumDecode(_$StatusEnumMap, json['status']),
uploading: json['uploading'] as bool, uploading: json['uploading'] as bool,
@@ -19,6 +20,7 @@ Task _$TaskFromJson(Map<String, dynamic> json) => Task(
Map<String, dynamic> _$TaskToJson(Task instance) { Map<String, dynamic> _$TaskToJson(Task instance) {
final val = <String, dynamic>{ final val = <String, dynamic>{
'id': instance.id, 'id': instance.id,
'name': instance.name,
}; };
void writeNotNull(String key, dynamic value) { void writeNotNull(String key, dynamic value) {
@@ -52,11 +54,11 @@ const _$ProtocolEnumMap = {
}; };
Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress( Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(
used: json['used'] as int, used: (json['used'] as num).toInt(),
speed: json['speed'] as int, speed: (json['speed'] as num).toInt(),
downloaded: json['downloaded'] as int, downloaded: (json['downloaded'] as num).toInt(),
uploadSpeed: json['uploadSpeed'] as int, uploadSpeed: (json['uploadSpeed'] as num).toInt(),
uploaded: json['uploaded'] as int, uploaded: (json['uploaded'] as num).toInt(),
); );
Map<String, dynamic> _$ProgressToJson(Progress instance) => <String, dynamic>{ Map<String, dynamic> _$ProgressToJson(Progress instance) => <String, dynamic>{
@@ -82,8 +82,7 @@ class TaskView extends GetView<TaskController> {
), ),
ListTile( ListTile(
title: Text('taskName'.tr), title: Text('taskName'.tr),
subtitle: subtitle: buildTooltipSubtitle(selectTask.value?.name)),
buildTooltipSubtitle(selectTask.value?.showName)),
ListTile( ListTile(
title: Text('taskUrl'.tr), title: Text('taskUrl'.tr),
subtitle: subtitle:
@@ -121,37 +120,12 @@ class TaskView extends GetView<TaskController> {
} }
extension TaskEnhance on Task { extension TaskEnhance on Task {
String get showName {
if (meta.opts.name.isNotEmpty) {
return meta.opts.name;
}
if (meta.res == null) {
final u = Uri.parse(meta.req.url);
if (u.scheme.startsWith("http")) {
return u.path.isNotEmpty
? u.path.substring(u.path.lastIndexOf("/") + 1)
: u.host;
} else {
final params = u.queryParameters;
if (params.containsKey("dn")) {
return params["dn"]!;
} else {
return params["xt"]!.split(":").last;
}
}
}
if (meta.res!.name.isNotEmpty) {
return meta.res!.name;
}
return meta.res!.files[0].name;
}
bool get isFolder { bool get isFolder {
return meta.res?.name.isNotEmpty ?? false; return meta.res?.name.isNotEmpty ?? false;
} }
String get explorerUrl { String get explorerUrl {
return path.join(Util.safeDir(meta.opts.path), Util.safeDir(showName)); return path.join(Util.safeDir(meta.opts.path), Util.safeDir(name));
} }
Future<void> explorer() async { Future<void> explorer() async {
@@ -183,10 +183,10 @@ class BuildTaskListView extends GetView {
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
ListTile( ListTile(
title: Text(task.showName), title: Text(task.name),
leading: isFolderTask() leading: isFolderTask()
? const Icon(FaIcons.folder) ? const Icon(FaIcons.folder)
: Icon(FaIcons.allIcons[findIcon(task.showName)])), : Icon(FaIcons.allIcons[findIcon(task.name)])),
Row( Row(
children: [ children: [
Expanded( Expanded(