mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-06 16:40:32 +00:00
feat: show extraction progress (#1217)
This commit is contained in:
1 parent
dbe37befa3
commit
b0e133b554
27 files changed
+2591
-40
No files matched your search
@@ -114,45 +114,58 @@ func TestFetcher_Resolve(t *testing.T) {
|
||||
},
|
||||
}, t)
|
||||
|
||||
// Test URL without file path - should use domain/host as filename
|
||||
listener := test.StartTestRootServer()
|
||||
defer listener.Close()
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "http://github.com",
|
||||
URL: "http://" + listener.Addr().String() + "/",
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if fetcher.Meta().Res.Files[0].Name != "github.com" {
|
||||
t.Errorf("Resolve() got = %v, want %v", fetcher.Meta().Res, "github.com")
|
||||
// When no filename is provided, it should use the hostname (without port) as the name
|
||||
expectedName := "127.0.0.1"
|
||||
if fetcher.Meta().Res.Files[0].Name != expectedName {
|
||||
t.Errorf("Resolve() got name = %v, want %v", fetcher.Meta().Res.Files[0].Name, expectedName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_ResolveWithHostHeader(t *testing.T) {
|
||||
listener := test.StartTestHostHeaderServer()
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "https://bing.com",
|
||||
URL: "http://" + listener.Addr().String() + "/",
|
||||
Extra: &http.ReqExtra{
|
||||
Header: map[string]string{
|
||||
"Host": "test",
|
||||
},
|
||||
},
|
||||
})
|
||||
// The server should return 400 for invalid Host header
|
||||
if err == nil || !strings.Contains(err.Error(), "400") {
|
||||
t.Errorf("Resolve() got = %v, want %v", err, "400")
|
||||
t.Errorf("Resolve() got = %v, want error containing 400", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetcher_ResolveWithInvalidHeader(t *testing.T) {
|
||||
listener := test.StartTestRootServer()
|
||||
defer listener.Close()
|
||||
|
||||
fetcher := buildFetcher()
|
||||
err := fetcher.Resolve(&base.Request{
|
||||
URL: "https://bing.com",
|
||||
URL: "http://" + listener.Addr().String() + "/",
|
||||
Extra: &http.ReqExtra{
|
||||
Header: map[string]string{
|
||||
"Referer": "\rtest",
|
||||
},
|
||||
},
|
||||
})
|
||||
// Invalid header with \r should be sanitized by Go's http client, allowing the request to succeed
|
||||
if err != nil {
|
||||
t.Errorf("Resolve() got = %v, want nil", err)
|
||||
t.Errorf("Resolve() got = %v, want nil (invalid headers should be sanitized)", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -173,6 +173,39 @@ func StartTestCustomServer() net.Listener {
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestHostHeaderServer starts a server that validates the Host header
|
||||
// Returns 400 Bad Request if the Host header value equals "test"
|
||||
func StartTestHostHeaderServer() net.Listener {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
|
||||
// If the Host header is "test", return 400 (simulating server that validates Host)
|
||||
if request.Host == "test" {
|
||||
writer.WriteHeader(400)
|
||||
writer.Write([]byte("Bad Request: Invalid Host header"))
|
||||
return
|
||||
}
|
||||
writer.WriteHeader(200)
|
||||
writer.Write([]byte("OK"))
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
// StartTestRootServer starts a simple server at the root path
|
||||
// Used to test URL resolution when no filename is provided
|
||||
func StartTestRootServer() net.Listener {
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("/", func(writer http.ResponseWriter, request *http.Request) {
|
||||
writer.Header().Set("Content-Type", "text/html")
|
||||
writer.WriteHeader(200)
|
||||
writer.Write([]byte("<html><body>Test Page</body></html>"))
|
||||
})
|
||||
return mux
|
||||
})
|
||||
}
|
||||
|
||||
func StartTestRetryServer() net.Listener {
|
||||
counter := 0
|
||||
return startTestServer(func(sl *shutdownListener) http.Handler {
|
||||
|
||||
@@ -45,6 +45,20 @@ var (
|
||||
|
||||
type Listener func(event *Event)
|
||||
|
||||
// ExtractStatus represents the current status of archive extraction
|
||||
type ExtractStatus string
|
||||
|
||||
const (
|
||||
// ExtractStatusNone indicates extraction has not started
|
||||
ExtractStatusNone ExtractStatus = ""
|
||||
// ExtractStatusExtracting indicates extraction is in progress
|
||||
ExtractStatusExtracting ExtractStatus = "extracting"
|
||||
// ExtractStatusDone indicates extraction completed successfully
|
||||
ExtractStatusDone ExtractStatus = "done"
|
||||
// ExtractStatusError indicates extraction failed
|
||||
ExtractStatusError ExtractStatus = "error"
|
||||
)
|
||||
|
||||
type Progress struct {
|
||||
// Total download time(ns)
|
||||
Used int64 `json:"used"`
|
||||
@@ -56,6 +70,10 @@ type Progress struct {
|
||||
UploadSpeed int64 `json:"uploadSpeed"`
|
||||
// Uploaded size(bytes)
|
||||
Uploaded int64 `json:"uploaded"`
|
||||
// ExtractStatus indicates the current status of archive extraction
|
||||
ExtractStatus ExtractStatus `json:"extractStatus"`
|
||||
// ExtractProgress is the percentage of extraction completed (0-100)
|
||||
ExtractProgress int `json:"extractProgress"`
|
||||
}
|
||||
|
||||
type Downloader struct {
|
||||
@@ -881,13 +899,29 @@ func (d *Downloader) watch(task *Task) {
|
||||
// Auto-extract archive files
|
||||
if e.AutoExtract && isArchiveFile(downloadFilePath) {
|
||||
go func() {
|
||||
// Set extraction status to extracting
|
||||
task.Progress.ExtractStatus = ExtractStatusExtracting
|
||||
task.Progress.ExtractProgress = 0
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
|
||||
// Extract to the same directory as the downloaded file
|
||||
destDir := task.Meta.Opts.Path
|
||||
extractErr := extractArchive(downloadFilePath, destDir, e.ArchivePassword)
|
||||
extractErr := extractArchive(downloadFilePath, destDir, e.ArchivePassword, func(extractedFiles int, totalFiles int, progress int) {
|
||||
task.Progress.ExtractProgress = progress
|
||||
d.emit(EventKeyProgress, task)
|
||||
})
|
||||
if extractErr != nil {
|
||||
d.Logger.Error().Err(extractErr).Msgf("auto extract archive failed, task id: %s", task.ID)
|
||||
task.Progress.ExtractStatus = ExtractStatusError
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
} else {
|
||||
d.Logger.Info().Msgf("auto extract archive completed, task id: %s", task.ID)
|
||||
task.Progress.ExtractStatus = ExtractStatusDone
|
||||
task.Progress.ExtractProgress = 100
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
// Delete archive after successful extraction if enabled
|
||||
if e.DeleteAfterExtract {
|
||||
deleteErr := os.Remove(downloadFilePath)
|
||||
|
||||
+1070
-3
File diff suppressed because it is too large.
Load diff
+119
-27
@@ -3,10 +3,12 @@ package download
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"math"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/mholt/archives"
|
||||
)
|
||||
@@ -35,6 +37,9 @@ var supportedArchiveExtensions = []string{
|
||||
".lz",
|
||||
}
|
||||
|
||||
// ExtractProgressCallback is called to report extraction progress
|
||||
type ExtractProgressCallback func(extractedFiles int, totalFiles int, progress int)
|
||||
|
||||
// isArchiveFile checks if a file is a supported archive format
|
||||
func isArchiveFile(filename string) bool {
|
||||
lowerName := strings.ToLower(filename)
|
||||
@@ -43,35 +48,35 @@ func isArchiveFile(filename string) bool {
|
||||
})
|
||||
}
|
||||
|
||||
// extractArchive extracts an archive file to a destination directory
|
||||
func extractArchive(archivePath string, destDir string, password string) error {
|
||||
// Open the archive file
|
||||
// archiveInfo holds information about an opened archive
|
||||
type archiveInfo struct {
|
||||
file *os.File
|
||||
stat os.FileInfo
|
||||
format archives.Format
|
||||
input io.Reader
|
||||
}
|
||||
|
||||
// openArchive opens an archive file and identifies its format
|
||||
func openArchive(archivePath string, password string) (*archiveInfo, error) {
|
||||
file, err := os.Open(archivePath)
|
||||
if err != nil {
|
||||
return err
|
||||
return nil, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// Get file info for format identification
|
||||
stat, err := file.Stat()
|
||||
if err != nil {
|
||||
return err
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Identify the archive format
|
||||
format, input, err := archives.Identify(context.Background(), archivePath, file)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Create destination directory if it doesn't exist
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return err
|
||||
file.Close()
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Handle password-protected archives
|
||||
if password != "" {
|
||||
// Try to set password for formats that support it
|
||||
if rar, ok := format.(archives.Rar); ok {
|
||||
rar.Password = password
|
||||
format = rar
|
||||
@@ -80,17 +85,57 @@ func extractArchive(archivePath string, destDir string, password string) error {
|
||||
sz.Password = password
|
||||
format = sz
|
||||
}
|
||||
// Note: Zip format in archives doesn't have a Password field
|
||||
// Password-protected zips are not fully supported
|
||||
}
|
||||
|
||||
return &archiveInfo{
|
||||
file: file,
|
||||
stat: stat,
|
||||
format: format,
|
||||
input: input,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// createExtractionHandler creates a handler function for extracting files with progress tracking
|
||||
func createExtractionHandler(destDir string, totalFiles int, progressCallback ExtractProgressCallback) func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||
var extractedFiles atomic.Int32
|
||||
return func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||
err := extractFile(ctx, fileInfo, destDir)
|
||||
if err == nil && !fileInfo.IsDir() {
|
||||
extracted := int(extractedFiles.Add(1))
|
||||
if progressCallback != nil && totalFiles > 0 {
|
||||
progress := int(math.Min(float64((extracted*100)/totalFiles), 100))
|
||||
progressCallback(extracted, totalFiles, progress)
|
||||
}
|
||||
}
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// extractArchive extracts an archive file to a destination directory
|
||||
func extractArchive(archivePath string, destDir string, password string, progressCallback ExtractProgressCallback) error {
|
||||
// Open the archive file
|
||||
info, err := openArchive(archivePath, password)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer info.file.Close()
|
||||
|
||||
// Create destination directory if it doesn't exist
|
||||
if err := os.MkdirAll(destDir, 0755); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Handle extraction based on format type
|
||||
switch f := format.(type) {
|
||||
switch f := info.format.(type) {
|
||||
case archives.Extractor:
|
||||
// For archive formats (zip, rar, 7z, tar, etc.)
|
||||
return f.Extract(context.Background(), input, func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||
return extractFile(ctx, fileInfo, destDir)
|
||||
})
|
||||
// First, count total files for progress tracking
|
||||
totalFiles, err := countArchiveFiles(archivePath, password)
|
||||
if err != nil {
|
||||
// If counting fails, proceed without progress reporting
|
||||
totalFiles = 0
|
||||
}
|
||||
return f.Extract(context.Background(), info.input, createExtractionHandler(destDir, totalFiles, progressCallback))
|
||||
case archives.Decompressor:
|
||||
// For single-file compression formats (gz, bz2, xz, etc.)
|
||||
// Decompress to a file without the compression extension
|
||||
@@ -106,7 +151,7 @@ func extractArchive(archivePath string, destDir string, password string) error {
|
||||
}
|
||||
destPath := filepath.Join(destDir, baseName)
|
||||
|
||||
reader, err := f.OpenReader(input)
|
||||
reader, err := f.OpenReader(info.input)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -118,24 +163,71 @@ func extractArchive(archivePath string, destDir string, password string) error {
|
||||
}
|
||||
defer destFile.Close()
|
||||
|
||||
// Report progress at start and end for decompression
|
||||
if progressCallback != nil {
|
||||
progressCallback(0, 1, 0)
|
||||
}
|
||||
_, err = io.Copy(destFile, reader)
|
||||
if err == nil && progressCallback != nil {
|
||||
progressCallback(1, 1, 100)
|
||||
}
|
||||
return err
|
||||
case archives.Archiver:
|
||||
// This format is an archiver, try to extract using the extractor interface
|
||||
if ext, ok := format.(archives.Extractor); ok {
|
||||
if ext, ok := info.format.(archives.Extractor); ok {
|
||||
// Reset file position
|
||||
if seeker, ok := input.(io.Seeker); ok {
|
||||
if seeker, ok := info.input.(io.Seeker); ok {
|
||||
seeker.Seek(0, io.SeekStart)
|
||||
}
|
||||
return ext.Extract(context.Background(), io.NewSectionReader(file, 0, stat.Size()), func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||
return extractFile(ctx, fileInfo, destDir)
|
||||
})
|
||||
// Count total files for progress tracking
|
||||
totalFiles, err := countArchiveFiles(archivePath, password)
|
||||
if err != nil {
|
||||
totalFiles = 0
|
||||
}
|
||||
return ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createExtractionHandler(destDir, totalFiles, progressCallback))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// createCountingHandler creates a handler function for counting files in an archive
|
||||
func createCountingHandler(count *int) func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||
return func(ctx context.Context, fileInfo archives.FileInfo) error {
|
||||
if !fileInfo.IsDir() {
|
||||
*count++
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// countArchiveFiles counts the number of files in an archive for progress calculation
|
||||
func countArchiveFiles(archivePath string, password string) (int, error) {
|
||||
info, err := openArchive(archivePath, password)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer info.file.Close()
|
||||
|
||||
count := 0
|
||||
switch f := info.format.(type) {
|
||||
case archives.Extractor:
|
||||
err = f.Extract(context.Background(), info.input, createCountingHandler(&count))
|
||||
case archives.Archiver:
|
||||
if ext, ok := info.format.(archives.Extractor); ok {
|
||||
if seeker, ok := info.input.(io.Seeker); ok {
|
||||
seeker.Seek(0, io.SeekStart)
|
||||
}
|
||||
err = ext.Extract(context.Background(), io.NewSectionReader(info.file, 0, info.stat.Size()), createCountingHandler(&count))
|
||||
}
|
||||
case archives.Decompressor:
|
||||
// Single file compression, count as 1
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
return count, err
|
||||
}
|
||||
|
||||
// extractFile handles extracting a single file from an archive
|
||||
func extractFile(ctx context.Context, fileInfo archives.FileInfo, destDir string) error {
|
||||
// Skip directories, they will be created when extracting files
|
||||
|
||||
+1168
-2
File diff suppressed because it is too large.
Load diff
@@ -8,6 +8,18 @@ enum Status { ready, running, pause, wait, error, done }
|
||||
|
||||
enum Protocol { http, bt }
|
||||
|
||||
// ExtractStatus enum matching Go backend
|
||||
enum ExtractStatus {
|
||||
@JsonValue('')
|
||||
none,
|
||||
@JsonValue('extracting')
|
||||
extracting,
|
||||
@JsonValue('done')
|
||||
done,
|
||||
@JsonValue('error')
|
||||
error
|
||||
}
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
class Task {
|
||||
String id;
|
||||
@@ -43,6 +55,8 @@ class Progress {
|
||||
int downloaded;
|
||||
int uploadSpeed;
|
||||
int uploaded;
|
||||
ExtractStatus extractStatus;
|
||||
int extractProgress;
|
||||
|
||||
Progress({
|
||||
required this.used,
|
||||
@@ -50,6 +64,8 @@ class Progress {
|
||||
required this.downloaded,
|
||||
required this.uploadSpeed,
|
||||
required this.uploaded,
|
||||
this.extractStatus = ExtractStatus.none,
|
||||
this.extractProgress = 0,
|
||||
});
|
||||
|
||||
factory Progress.fromJson(Map<String, dynamic> json) =>
|
||||
|
||||
@@ -53,12 +53,23 @@ const _$ProtocolEnumMap = {
|
||||
Protocol.bt: 'bt',
|
||||
};
|
||||
|
||||
const _$ExtractStatusEnumMap = {
|
||||
ExtractStatus.none: '',
|
||||
ExtractStatus.extracting: 'extracting',
|
||||
ExtractStatus.done: 'done',
|
||||
ExtractStatus.error: 'error',
|
||||
};
|
||||
|
||||
Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(
|
||||
used: (json['used'] as num).toInt(),
|
||||
speed: (json['speed'] as num).toInt(),
|
||||
downloaded: (json['downloaded'] as num).toInt(),
|
||||
uploadSpeed: (json['uploadSpeed'] as num).toInt(),
|
||||
uploaded: (json['uploaded'] as num).toInt(),
|
||||
extractStatus: $enumDecodeNullable(
|
||||
_$ExtractStatusEnumMap, json['extractStatus']) ??
|
||||
ExtractStatus.none,
|
||||
extractProgress: (json['extractProgress'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
|
||||
Map<String, dynamic> _$ProgressToJson(Progress instance) => <String, dynamic>{
|
||||
@@ -67,4 +78,6 @@ Map<String, dynamic> _$ProgressToJson(Progress instance) => <String, dynamic>{
|
||||
'downloaded': instance.downloaded,
|
||||
'uploadSpeed': instance.uploadSpeed,
|
||||
'uploaded': instance.uploaded,
|
||||
'extractStatus': _$ExtractStatusEnumMap[instance.extractStatus]!,
|
||||
'extractProgress': instance.extractProgress,
|
||||
};
|
||||
@@ -187,6 +187,19 @@ class BuildTaskListView extends GetView {
|
||||
return totalSize <= 0 ? 0 : task.progress.downloaded / totalSize;
|
||||
}
|
||||
|
||||
String getExtractionStatusText() {
|
||||
switch (task.progress.extractStatus) {
|
||||
case ExtractStatus.extracting:
|
||||
return '${'extracting'.tr} ${task.progress.extractProgress}%';
|
||||
case ExtractStatus.done:
|
||||
return 'extractDone'.tr;
|
||||
case ExtractStatus.error:
|
||||
return 'extractError'.tr;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
String getProgressText() {
|
||||
if (isDone()) {
|
||||
return Util.fmtByte(task.meta.res!.size);
|
||||
@@ -317,6 +330,56 @@ class BuildTaskListView extends GetView {
|
||||
: LinearProgressIndicator(
|
||||
value: getProgress(),
|
||||
),
|
||||
// Extraction status row
|
||||
if (task.progress.extractStatus != ExtractStatus.none)
|
||||
Builder(builder: (context) {
|
||||
final isExtracting =
|
||||
task.progress.extractStatus == ExtractStatus.extracting;
|
||||
final isExtractDone =
|
||||
task.progress.extractStatus == ExtractStatus.done;
|
||||
final statusColor = isExtracting
|
||||
? Get.theme.colorScheme.primary
|
||||
: (isExtractDone ? Colors.green : Colors.red);
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
isExtracting
|
||||
? Icons.unarchive
|
||||
: (isExtractDone
|
||||
? Icons.check_circle
|
||||
: Icons.error),
|
||||
size: 16,
|
||||
color: statusColor,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
getExtractionStatusText(),
|
||||
style: Get.textTheme.bodySmall?.copyWith(
|
||||
color: statusColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
).padding(left: 18),
|
||||
),
|
||||
],
|
||||
).padding(top: 4, bottom: isExtracting ? 0 : 8),
|
||||
// Extraction progress bar
|
||||
if (isExtracting)
|
||||
LinearProgressIndicator(
|
||||
value: task.progress.extractProgress / 100.0,
|
||||
backgroundColor:
|
||||
Get.theme.colorScheme.surfaceContainerHighest,
|
||||
valueColor: AlwaysStoppedAnimation<Color>(
|
||||
Get.theme.colorScheme.secondary),
|
||||
),
|
||||
],
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
)).padding(horizontal: 14, top: 8),
|
||||
|
||||
@@ -121,6 +121,9 @@ const deDE = {
|
||||
'archivePassword': 'Archiv-Passwort',
|
||||
'archivePasswordHint': 'Leer lassen, wenn nicht passwortgeschützt',
|
||||
'deleteAfterExtract': 'Archiv nach Entpacken löschen',
|
||||
'extracting': 'Wird entpackt',
|
||||
'extractDone': 'Entpacken abgeschlossen',
|
||||
'extractError': 'Entpacken fehlgeschlagen',
|
||||
'name': 'Name',
|
||||
'size': 'Größe',
|
||||
'unknown': 'Unbekannt',
|
||||
|
||||
@@ -129,6 +129,9 @@ const enUS = {
|
||||
'archivePassword': 'Archive Password',
|
||||
'archivePasswordHint': 'Leave empty if not password protected',
|
||||
'deleteAfterExtract': 'Delete Archive After Extraction',
|
||||
'extracting': 'Extracting',
|
||||
'extractDone': 'Extraction completed',
|
||||
'extractError': 'Extraction failed',
|
||||
'name': 'Name',
|
||||
'size': 'Size',
|
||||
'unknown': 'Unknown',
|
||||
|
||||
@@ -113,6 +113,9 @@ const esES = {
|
||||
'archivePassword': 'Contraseña del archivo',
|
||||
'archivePasswordHint': 'Dejar vacío si no tiene contraseña',
|
||||
'deleteAfterExtract': 'Eliminar archivo después de extraer',
|
||||
'extracting': 'Extrayendo',
|
||||
'extractDone': 'Extracción completada',
|
||||
'extractError': 'Error en la extracción',
|
||||
'insertPlaceholder': 'Insertar marcador de posición',
|
||||
'placeholderYear': 'Año actual',
|
||||
'placeholderMonth': 'Mes actual (01-12)',
|
||||
|
||||
@@ -68,6 +68,9 @@ const faIR = {
|
||||
'archivePassword': 'رمز عبور بایگانی',
|
||||
'archivePasswordHint': 'اگر رمز ندارد خالی بگذارید',
|
||||
'deleteAfterExtract': 'حذف آرشیو پس از استخراج',
|
||||
'extracting': 'در حال استخراج',
|
||||
'extractDone': 'استخراج کامل شد',
|
||||
'extractError': 'استخراج ناموفق بود',
|
||||
'insertPlaceholder': 'درج نگهدارنده',
|
||||
'placeholderYear': 'سال جاری',
|
||||
'placeholderMonth': 'ماه جاری (01-12)',
|
||||
|
||||
@@ -105,6 +105,9 @@ const frFR = {
|
||||
'archivePassword': 'Mot de passe de l\'archive',
|
||||
'archivePasswordHint': 'Laisser vide si non protégé par mot de passe',
|
||||
'deleteAfterExtract': "Supprimer l'archive après extraction",
|
||||
'extracting': 'Extraction en cours',
|
||||
'extractDone': 'Extraction terminée',
|
||||
'extractError': 'Échec de l\'extraction',
|
||||
'insertPlaceholder': 'Insérer un espace réservé',
|
||||
'placeholderYear': 'Année en cours',
|
||||
'placeholderMonth': 'Mois en cours (01-12)',
|
||||
|
||||
@@ -121,6 +121,9 @@ const huHU = {
|
||||
'archivePassword': 'Archívum jelszó',
|
||||
'archivePasswordHint': 'Hagyja üresen, ha nincs jelszóval védve',
|
||||
'deleteAfterExtract': 'Archívum törlése kicsomagolás után',
|
||||
'extracting': 'Kicsomagolás',
|
||||
'extractDone': 'Kicsomagolás befejezve',
|
||||
'extractError': 'Kicsomagolás sikertelen',
|
||||
'name': 'Név',
|
||||
'size': 'Méret',
|
||||
'unknown': 'Ismeretlen',
|
||||
|
||||
@@ -115,6 +115,9 @@ const idID = {
|
||||
'archivePassword': 'Kata Sandi Arsip',
|
||||
'archivePasswordHint': 'Kosongkan jika tidak dilindungi kata sandi',
|
||||
'deleteAfterExtract': 'Hapus arsip setelah ekstraksi',
|
||||
'extracting': 'Mengekstrak',
|
||||
'extractDone': 'Ekstraksi selesai',
|
||||
'extractError': 'Ekstraksi gagal',
|
||||
'insertPlaceholder': 'Sisipkan placeholder',
|
||||
'placeholderYear': 'Tahun saat ini',
|
||||
'placeholderMonth': 'Bulan saat ini (01-12)',
|
||||
|
||||
@@ -103,6 +103,9 @@ const itIT = {
|
||||
'archivePassword': 'Password archivio',
|
||||
'archivePasswordHint': 'Lasciare vuoto se non protetto da password',
|
||||
'deleteAfterExtract': "Elimina l'archivio dopo l'estrazione",
|
||||
'extracting': 'Estrazione in corso',
|
||||
'extractDone': 'Estrazione completata',
|
||||
'extractError': 'Estrazione fallita',
|
||||
'insertPlaceholder': 'Inserisci segnaposto',
|
||||
'placeholderYear': 'Anno corrente',
|
||||
'placeholderMonth': 'Mese corrente (01-12)',
|
||||
|
||||
@@ -69,6 +69,9 @@ const jaJP = {
|
||||
'archivePassword': 'アーカイブパスワード',
|
||||
'archivePasswordHint': 'パスワードなしの場合は空のままにしてください',
|
||||
'deleteAfterExtract': '展開後にアーカイブを削除',
|
||||
'extracting': '展開中',
|
||||
'extractDone': '展開完了',
|
||||
'extractError': '展開失敗',
|
||||
'insertPlaceholder': 'プレースホルダーを挿入',
|
||||
'placeholderYear': '現在の年',
|
||||
'placeholderMonth': '現在の月 (01-12)',
|
||||
|
||||
@@ -101,6 +101,9 @@ const plPL = {
|
||||
'archivePassword': 'Hasło archiwum',
|
||||
'archivePasswordHint': 'Pozostaw puste, jeśli nie ma hasła',
|
||||
'deleteAfterExtract': 'Usuń archiwum po rozpakowaniu',
|
||||
'extracting': 'Rozpakowywanie',
|
||||
'extractDone': 'Rozpakowywanie zakończone',
|
||||
'extractError': 'Rozpakowywanie nie powiodło się',
|
||||
'insertPlaceholder': 'Wstaw placeholder',
|
||||
'placeholderYear': 'Bieżący rok',
|
||||
'placeholderMonth': 'Bieżący miesiąc (01-12)',
|
||||
|
||||
@@ -118,6 +118,9 @@ const ptBR = {
|
||||
'archivePassword': 'Senha do arquivo',
|
||||
'archivePasswordHint': 'Deixar vazio se não estiver protegido por senha',
|
||||
'deleteAfterExtract': 'Excluir arquivo após extração',
|
||||
'extracting': 'Extraindo',
|
||||
'extractDone': 'Extração concluída',
|
||||
'extractError': 'Falha na extração',
|
||||
'name': 'Nome',
|
||||
'size': 'Tamanho',
|
||||
'unknown': 'Desconhecido',
|
||||
|
||||
@@ -118,6 +118,9 @@ const ruRU = {
|
||||
'archivePassword': 'Пароль архива',
|
||||
'archivePasswordHint': 'Оставить пустым, если архив не защищён паролем',
|
||||
'deleteAfterExtract': 'Удалить архив после распаковки',
|
||||
'extracting': 'Распаковка',
|
||||
'extractDone': 'Распаковка завершена',
|
||||
'extractError': 'Ошибка распаковки',
|
||||
'name': 'Название',
|
||||
'size': 'Размер',
|
||||
'unknown': 'Неизвестно',
|
||||
|
||||
@@ -103,6 +103,9 @@ const taTA = {
|
||||
'archivePassword': 'காப்பக கடவுச்சொல்',
|
||||
'archivePasswordHint': 'கடவுச்சொல் இல்லையென்றால் காலியாக விடவும்',
|
||||
'deleteAfterExtract': 'பிரித்தெடுத்த பிறகு காப்பகத்தை நீக்கு',
|
||||
'extracting': 'பிரித்தெடுக்கிறது',
|
||||
'extractDone': 'பிரித்தெடுத்தல் முடிந்தது',
|
||||
'extractError': 'பிரித்தெடுத்தல் தோல்வியடைந்தது',
|
||||
'insertPlaceholder': 'இடங்காட்டி செருக',
|
||||
'placeholderYear': 'தற்போதைய ஆண்டு',
|
||||
'placeholderMonth': 'தற்போதைய மாதம் (01-12)',
|
||||
|
||||
@@ -119,6 +119,9 @@ const trTR = {
|
||||
'archivePassword': 'Arşiv şifresi',
|
||||
'archivePasswordHint': 'Şifre yoksa boş bırakın',
|
||||
'deleteAfterExtract': 'Çıkarmadan sonra arşivi sil',
|
||||
'extracting': 'Çıkarılıyor',
|
||||
'extractDone': 'Çıkarma tamamlandı',
|
||||
'extractError': 'Çıkarma başarısız',
|
||||
'name': 'Ad',
|
||||
'size': 'Boyut',
|
||||
'unknown': 'Bilinmeyen',
|
||||
|
||||
@@ -118,6 +118,9 @@ const ukUA = {
|
||||
'archivePassword': 'Пароль архіву',
|
||||
'archivePasswordHint': 'Залишити порожнім, якщо архів не захищений паролем',
|
||||
'deleteAfterExtract': 'Видалити архів після розпакування',
|
||||
'extracting': 'Розпакування',
|
||||
'extractDone': 'Розпакування завершено',
|
||||
'extractError': 'Помилка розпакування',
|
||||
'name': 'Назва',
|
||||
'size': 'Розмір',
|
||||
'unknown': 'Невідомо',
|
||||
|
||||
@@ -96,6 +96,9 @@ const viVN = {
|
||||
'archivePassword': 'Mật khẩu tập tin nén',
|
||||
'archivePasswordHint': 'Để trống nếu không có mật khẩu',
|
||||
'deleteAfterExtract': 'Xóa tập tin nén sau khi giải nén',
|
||||
'extracting': 'Đang giải nén',
|
||||
'extractDone': 'Giải nén hoàn tất',
|
||||
'extractError': 'Giải nén thất bại',
|
||||
'insertPlaceholder': 'Chèn placeholder',
|
||||
'placeholderYear': 'Năm hiện tại',
|
||||
'placeholderMonth': 'Tháng hiện tại (01-12)',
|
||||
|
||||
@@ -127,6 +127,9 @@ const zhCN = {
|
||||
'archivePassword': '压缩包密码',
|
||||
'archivePasswordHint': '如无密码请留空',
|
||||
'deleteAfterExtract': '解压后删除压缩包',
|
||||
'extracting': '正在解压',
|
||||
'extractDone': '解压完成',
|
||||
'extractError': '解压失败',
|
||||
'name': '名称',
|
||||
'size': '大小',
|
||||
'unknown': '未知',
|
||||
|
||||
@@ -115,6 +115,9 @@ const zhTW = {
|
||||
'archivePassword': '壓縮包密碼',
|
||||
'archivePasswordHint': '如無密碼請留空',
|
||||
'deleteAfterExtract': '解壓後刪除壓縮檔',
|
||||
'extracting': '正在解壓',
|
||||
'extractDone': '解壓完成',
|
||||
'extractError': '解壓失敗',
|
||||
'name': '名稱',
|
||||
'size': '大小',
|
||||
'unknown': '未知',
|
||||
|
||||
Reference in new issue
Block a user