diff --git a/KMReader.xcodeproj/project.pbxproj b/KMReader.xcodeproj/project.pbxproj index c11af01a..229b5d3d 100644 --- a/KMReader.xcodeproj/project.pbxproj +++ b/KMReader.xcodeproj/project.pbxproj @@ -442,7 +442,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 405; + CURRENT_PROJECT_VERSION = 406; DEVELOPMENT_TEAM = M777UHWZA4; ENABLE_APP_SANDBOX = YES; ENABLE_HARDENED_RUNTIME = YES; @@ -500,7 +500,7 @@ "CODE_SIGN_ENTITLEMENTS[sdk=macosx*]" = "KMReader/KMReader-macOS.entitlements"; CODE_SIGN_IDENTITY = "Apple Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 405; + CURRENT_PROJECT_VERSION = 406; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=appletvos*]" = M777UHWZA4; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4; @@ -560,7 +560,7 @@ CODE_SIGN_ENTITLEMENTS = KMReaderWidgets/KMReaderWidgets.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 405; + CURRENT_PROJECT_VERSION = 406; DEVELOPMENT_TEAM = M777UHWZA4; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = KMReaderWidgets/Info.plist; @@ -594,7 +594,7 @@ CODE_SIGN_IDENTITY = "Apple Distribution"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; CODE_SIGN_STYLE = Manual; - CURRENT_PROJECT_VERSION = 405; + CURRENT_PROJECT_VERSION = 406; DEVELOPMENT_TEAM = ""; "DEVELOPMENT_TEAM[sdk=iphoneos*]" = M777UHWZA4; GENERATE_INFOPLIST_FILE = YES; diff --git a/KMReader/Core/Network/APIClient.swift b/KMReader/Core/Network/APIClient.swift index 07e70ab2..740709f2 100644 --- a/KMReader/Core/Network/APIClient.swift +++ b/KMReader/Core/Network/APIClient.swift @@ -792,6 +792,409 @@ class APIClient { } } + private struct DownloadFileResult { + let response: HTTPURLResponse + let temporaryURL: URL + let expectedBytes: Int64? + let receivedBytes: Int64 + } + + private func executeDownloadRequest( + _ request: URLRequest, + destinationURL: URL, + session: URLSession? = nil, + isTemporary: Bool = false, + requestCategory: RequestCategory = .download, + retryCount: Int = 0, + maxRetryCount: Int? = nil, + onProgress: ProgressHandler? = nil + ) async throws -> HTTPURLResponse { + let method = request.httpMethod ?? "GET" + let urlString = request.url?.absoluteString ?? "" + let prefix = isTemporary ? "[TEMP] " : "" + logger.info("📡 \(prefix)\(method) \(urlString)") + + let startTime = Date() + let sessionToUse = session ?? currentSession() + let effectiveMaxRetryCount = maxRetryCount ?? AppConfig.apiRetryCount + let temporaryURL = makeTemporaryDownloadURL(for: destinationURL) + + actor DownloadState { + let onProgress: ProgressHandler? + let urlString: String + var response: HTTPURLResponse? + var expectedBytes: Int64? + var receivedBytes: Int64 = 0 + var downloadedFileURL: URL? + var downloadedFileError: Error? + var didReceiveFile = false + var didComplete = false + var completionError: Error? + var continuation: CheckedContinuation? + var lastUpdate = Date.distantPast + let updateInterval: TimeInterval = 0.1 + + init(onProgress: ProgressHandler?, urlString: String) { + self.onProgress = onProgress + self.urlString = urlString + } + + func setContinuation(_ continuation: CheckedContinuation) { + self.continuation = continuation + } + + func handleProgress(received: Int64, expected: Int64) { + receivedBytes = received + if expected > 0 { + expectedBytes = expected + } + + let now = Date() + guard now.timeIntervalSince(lastUpdate) >= updateInterval else { return } + lastUpdate = now + onProgress?(receivedBytes, expectedBytes) + } + + func handleDownloadedFile(_ fileURL: URL?, error: Error?) { + didReceiveFile = true + downloadedFileURL = fileURL + downloadedFileError = error + finishIfReady() + } + + func handleComplete(_ error: Error?, response: HTTPURLResponse?) { + didComplete = true + completionError = error + if let response { + self.response = response + let expectedLength = response.expectedContentLength + if expectedLength > 0 { + expectedBytes = expectedLength + } + } + finishIfReady() + } + + private func finishIfReady() { + guard let continuation else { return } + guard didComplete else { return } + + if let completionError { + self.continuation = nil + continuation.resume(throwing: completionError) + return + } + + guard didReceiveFile else { return } + + if let downloadedFileError { + self.continuation = nil + continuation.resume(throwing: downloadedFileError) + return + } + + guard let response else { + self.continuation = nil + continuation.resume(throwing: APIError.invalidResponse(url: urlString)) + return + } + + guard let downloadedFileURL else { + self.continuation = nil + continuation.resume( + throwing: AppErrorType.missingRequiredData(message: "Missing downloaded file.") + ) + return + } + + onProgress?(receivedBytes, expectedBytes) + self.continuation = nil + continuation.resume( + returning: DownloadFileResult( + response: response, + temporaryURL: downloadedFileURL, + expectedBytes: expectedBytes, + receivedBytes: receivedBytes + ) + ) + } + } + + final class DownloadDelegate: NSObject, URLSessionDownloadDelegate { + let state: DownloadState + let urlString: String + let temporaryURL: URL + + init(state: DownloadState, urlString: String, temporaryURL: URL) { + self.state = state + self.urlString = urlString + self.temporaryURL = temporaryURL + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didFinishDownloadingTo location: URL + ) { + let moveError: Error? + do { + let directory = temporaryURL.deletingLastPathComponent() + if !FileManager.default.fileExists(atPath: directory.path) { + try FileManager.default.createDirectory( + at: directory, + withIntermediateDirectories: true + ) + } + if FileManager.default.fileExists(atPath: temporaryURL.path) { + try FileManager.default.removeItem(at: temporaryURL) + } + try FileManager.default.moveItem(at: location, to: temporaryURL) + moveError = nil + } catch { + moveError = error + } + + Task { + await state.handleDownloadedFile(moveError == nil ? temporaryURL : nil, error: moveError) + } + } + + func urlSession( + _ session: URLSession, + downloadTask: URLSessionDownloadTask, + didWriteData bytesWritten: Int64, + totalBytesWritten: Int64, + totalBytesExpectedToWrite: Int64 + ) { + Task { + await state.handleProgress( + received: totalBytesWritten, + expected: totalBytesExpectedToWrite + ) + } + } + + func urlSession( + _ session: URLSession, + task: URLSessionTask, + didCompleteWithError error: Error? + ) { + defer { + session.finishTasksAndInvalidate() + } + + let response = task.response as? HTTPURLResponse + Task { await state.handleComplete(error, response: response) } + } + } + + do { + logger.debug("Streaming file download started: url=\(urlString)") + let state = DownloadState(onProgress: onProgress, urlString: urlString) + let delegate = DownloadDelegate( + state: state, + urlString: urlString, + temporaryURL: temporaryURL + ) + let delegateQueue = OperationQueue() + delegateQueue.maxConcurrentOperationCount = 1 + let downloadSession = URLSession( + configuration: sessionToUse.configuration, + delegate: delegate, + delegateQueue: delegateQueue + ) + + let result = try await withCheckedThrowingContinuation { continuation in + Task { + await state.setContinuation(continuation) + let task = downloadSession.downloadTask(with: request) + task.resume() + } + } + + let duration = Date().timeIntervalSince(startTime) + let httpResponse = result.response + let statusEmoji = (200...299).contains(httpResponse.statusCode) ? "✅" : "❌" + let durationMs = String(format: "%.2f", duration * 1000) + + if let sessionToken = httpResponse.value(forHTTPHeaderField: "X-Auth-Token") { + if AppConfig.current.sessionToken != sessionToken { + AppConfig.current.sessionToken = sessionToken + } + } + + logger.info( + "\(statusEmoji) \(prefix)\(httpResponse.statusCode) \(method) \(urlString) (\(durationMs)ms)" + ) + + guard (200...299).contains(httpResponse.statusCode) else { + let responseBody = try? String(contentsOf: result.temporaryURL, encoding: .utf8) + try? FileManager.default.removeItem(at: result.temporaryURL) + + if httpResponse.statusCode == 401 && retryCount == 0 && !isTemporary + && AppConfig.current.authMethod != .apiKey + { + let token = AppConfig.current.authToken + if !token.isEmpty { + logger.info("🔒 Unauthorized, attempting re-login to refresh session...") + do { + let loginRequest = try buildLoginRequest( + path: "/api/v2/users/me", + method: "GET", + queryItems: [URLQueryItem(name: "remember-me", value: "true")], + headers: ["X-Auth-Token": ""], + authToken: token, + useSessionToken: false + ) + + _ = try await reLoginActor.getReLoginTask { [weak self] in + guard let self = self else { + throw APIError.networkError( + AppErrorType.missingRequiredData(message: "APIClient deallocated"), + url: urlString + ) + } + return try await self.executeRequest( + loginRequest, + session: sessionToUse, + isTemporary: false, + requestCategory: .auth, + retryCount: 1, + maxRetryCount: maxRetryCount + ) + } + + logger.info("✅ Re-login successful, retrying original file download...") + onProgress?(0, result.expectedBytes) + return try await executeDownloadRequest( + request, + destinationURL: destinationURL, + session: sessionToUse, + isTemporary: false, + requestCategory: requestCategory, + retryCount: 1, + maxRetryCount: maxRetryCount, + onProgress: onProgress + ) + } catch { + logger.error("❌ Re-login failed: \(error.localizedDescription)") + } + } + } + + let errorMessage = responseBody ?? "Unknown error" + let requestBody = request.httpBody.flatMap { String(data: $0, encoding: .utf8) } + + switch httpResponse.statusCode { + case 400: + throw APIError.badRequest( + message: errorMessage, url: urlString, response: responseBody, request: requestBody) + case 401: + throw APIError.unauthorized(url: urlString) + case 403: + throw APIError.forbidden( + message: errorMessage, url: urlString, response: responseBody, request: requestBody) + case 404: + throw APIError.notFound( + message: errorMessage, url: urlString, response: responseBody, request: requestBody) + case 429: + throw APIError.tooManyRequests( + message: errorMessage, url: urlString, response: responseBody, request: requestBody) + case 500...599: + if retryCount < effectiveMaxRetryCount { + logger.warning( + "⚠️ Server error, retrying (\(retryCount + 1)/\(effectiveMaxRetryCount)): \(httpResponse.statusCode)" + ) + try? await Task.sleep(nanoseconds: 1_000_000_000) + return try await executeDownloadRequest( + request, + destinationURL: destinationURL, + session: session, + isTemporary: isTemporary, + requestCategory: requestCategory, + retryCount: retryCount + 1, + maxRetryCount: maxRetryCount, + onProgress: onProgress + ) + } + throw APIError.serverError( + code: httpResponse.statusCode, message: errorMessage, url: urlString, + response: responseBody, request: requestBody) + default: + throw APIError.httpError( + code: httpResponse.statusCode, message: errorMessage, url: urlString, + response: responseBody, request: requestBody) + } + } + + let destinationDir = destinationURL.deletingLastPathComponent() + if !FileManager.default.fileExists(atPath: destinationDir.path) { + try FileManager.default.createDirectory( + at: destinationDir, + withIntermediateDirectories: true + ) + } + if FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.removeItem(at: destinationURL) + } + try FileManager.default.moveItem(at: result.temporaryURL, to: destinationURL) + + let dataSize = ByteCountFormatter.string( + fromByteCount: result.receivedBytes, + countStyle: .binary + ) + logger.info("\(httpResponse.statusCode) \(method) \(urlString) [\(dataSize)]") + + await offlineFailureTracker.recordSuccess() + return httpResponse + } catch let error as APIError { + try? FileManager.default.removeItem(at: temporaryURL) + throw error + } catch let appError as AppErrorType { + try? FileManager.default.removeItem(at: temporaryURL) + logger.error("❌ Network error for \(urlString): \(appError.description)") + let shouldHandleOffline = !isTemporary && requestCategory != .download + if shouldHandleOffline { + await handleNetworkError(appError, requestCategory: requestCategory) + } + throw APIError.networkError(appError, url: urlString) + } catch let nsError as NSError where nsError.domain == NSURLErrorDomain { + try? FileManager.default.removeItem(at: temporaryURL) + let appError = AppErrorType.from(nsError) + logger.error("❌ Network error for \(urlString): \(appError.description)") + let shouldHandleOffline = !isTemporary && requestCategory != .download + if shouldHandleOffline { + await handleNetworkError(nsError, requestCategory: requestCategory) + } + throw APIError.networkError(appError, url: urlString) + } catch { + try? FileManager.default.removeItem(at: temporaryURL) + if retryCount < effectiveMaxRetryCount && !(error is CancellationError) { + logger.warning( + "⚠️ File download failed, retrying (\(retryCount + 1)/\(effectiveMaxRetryCount)): \(error.localizedDescription)" + ) + try? await Task.sleep(nanoseconds: 1_000_000_000) + return try await executeDownloadRequest( + request, + destinationURL: destinationURL, + session: session, + isTemporary: isTemporary, + requestCategory: requestCategory, + retryCount: retryCount + 1, + maxRetryCount: maxRetryCount, + onProgress: onProgress + ) + } + + logger.error("❌ Network error for \(urlString): \(error.localizedDescription)") + let shouldHandleOffline = !isTemporary && requestCategory != .download + if shouldHandleOffline { + await handleNetworkError(error, requestCategory: requestCategory) + } + throw APIError.networkError(error, url: urlString) + } + } + private func handleNetworkError( _ error: Error, requestCategory: RequestCategory @@ -935,19 +1338,25 @@ class APIClient { return logAndExtractDataResponse(data: data, response: httpResponse, request: urlRequest) } - func requestDataWithProgress( + func requestFileWithProgress( path: String, progressKey: String, + destinationURL: URL, method: String = "GET", - headers: [String: String]? = nil, - ) async throws -> (data: Data, contentType: String?, suggestedFilename: String?) { + headers: [String: String]? = nil + ) async throws -> (contentType: String?, suggestedFilename: String?) { try throwIfOffline() let urlRequest = try buildRequest( - path: path, method: method, headers: headers, category: .download) + path: path, + method: method, + headers: headers, + category: .download + ) let urlString = urlRequest.url?.absoluteString ?? "" - let (data, httpResponse) = try await executeRequest( + let httpResponse = try await executeDownloadRequest( urlRequest, + destinationURL: destinationURL, requestCategory: .download, onProgress: { received, expected in Task { @MainActor in @@ -965,7 +1374,10 @@ class APIClient { } ) - return logAndExtractDataResponse(data: data, response: httpResponse, request: urlRequest) + return ( + httpResponse.value(forHTTPHeaderField: "Content-Type"), + filenameFromContentDisposition(httpResponse.value(forHTTPHeaderField: "Content-Disposition")) + ) } func requestData( @@ -1025,6 +1437,12 @@ class APIClient { return nil } + + private func makeTemporaryDownloadURL(for destinationURL: URL) -> URL { + let directory = destinationURL.deletingLastPathComponent() + let fileName = destinationURL.lastPathComponent + return directory.appendingPathComponent(".\(fileName).\(UUID().uuidString).download") + } } private enum APIClientDateParser { diff --git a/KMReader/Features/Book/Services/BookService.swift b/KMReader/Features/Book/Services/BookService.swift index f0f22743..cf606efe 100644 --- a/KMReader/Features/Book/Services/BookService.swift +++ b/KMReader/Features/Book/Services/BookService.swift @@ -6,7 +6,6 @@ import Foundation struct BookFileDownloadResult { - let data: Data let contentType: String? let suggestedFilename: String? } @@ -207,13 +206,13 @@ class BookService { logger.debug("✅ [Progress/Epub] Request completed: book=\(bookId)") } - func downloadBookFile(bookId: String) async throws -> BookFileDownloadResult { - let result = try await apiClient.requestDataWithProgress( + func downloadBookFile(bookId: String, to destinationURL: URL) async throws -> BookFileDownloadResult { + let result = try await apiClient.requestFileWithProgress( path: "/api/v1/books/\(bookId)/file", progressKey: bookId, + destinationURL: destinationURL ) return BookFileDownloadResult( - data: result.data, contentType: result.contentType, suggestedFilename: result.suggestedFilename ) diff --git a/KMReader/Features/Offline/Services/DownloadProgressTracker.swift b/KMReader/Features/Offline/Services/DownloadProgressTracker.swift index a34c17b2..08a631c0 100644 --- a/KMReader/Features/Offline/Services/DownloadProgressTracker.swift +++ b/KMReader/Features/Offline/Services/DownloadProgressTracker.swift @@ -27,6 +27,10 @@ class DownloadProgressTracker { /// Token to force UI refreshes when queue-related state changes var queueUpdateToken: UUID = UUID() + @ObservationIgnored private var lastProgressUpdate: [String: Date] = [:] + @ObservationIgnored private let progressUpdateInterval: TimeInterval = 1.0 + @ObservationIgnored private let progressUpdateDelta = 0.001 + /// Whether a download is currently active var isDownloading: Bool { currentBookName != nil @@ -58,11 +62,30 @@ class DownloadProgressTracker { private init() {} func updateProgress(bookId: String, value: Double) { - progress[bookId] = value + let clampedValue = min(max(value, 0), 1) + let previousValue = progress[bookId] + let isTerminalValue = clampedValue == 0 || clampedValue == 1 + let hasMeaningfulDelta = previousValue.map { abs($0 - clampedValue) >= progressUpdateDelta } ?? true + + if !isTerminalValue, !hasMeaningfulDelta { + return + } + + let now = Date() + if !isTerminalValue, + let lastUpdate = lastProgressUpdate[bookId], + now.timeIntervalSince(lastUpdate) < progressUpdateInterval + { + return + } + + lastProgressUpdate[bookId] = now + progress[bookId] = clampedValue } func clearProgress(bookId: String) { progress.removeValue(forKey: bookId) + lastProgressUpdate.removeValue(forKey: bookId) } func startDownload(bookName: String) { diff --git a/KMReader/Features/Offline/Services/OfflineManager.swift b/KMReader/Features/Offline/Services/OfflineManager.swift index b321b310..462ad4bb 100644 --- a/KMReader/Features/Offline/Services/OfflineManager.swift +++ b/KMReader/Features/Offline/Services/OfflineManager.swift @@ -1567,7 +1567,7 @@ actor OfflineManager { await LiveActivityManager.shared.updateActivity( seriesTitle: seriesTitle, bookInfo: bookInfo, - progress: 1.0, + progress: candidate == nil ? 1.0 : 0.0, pendingCount: pendingBooks.count, failedCount: failedCount ) @@ -1820,6 +1820,21 @@ actor OfflineManager { logger.debug( "🔒 Claimed background finalize for book \(bookId), completed=\(completedTasks)/\(totalTasks)" ) + let pendingBooks = + (try? await DatabaseOperator.database().fetchPendingBooks( + instanceId: info.instanceId + )) ?? [] + let failedCount = + (try? await DatabaseOperator.database().fetchFailedBooksCount( + instanceId: info.instanceId + )) ?? 0 + await LiveActivityManager.shared.updateActivity( + seriesTitle: info.seriesTitle, + bookInfo: String(localized: "Processing offline files..."), + progress: 0.0, + pendingCount: pendingBooks.count, + failedCount: failedCount + ) await finalizeBackgroundBookDownload(bookId: bookId, info: info) } @@ -2018,16 +2033,14 @@ actor OfflineManager { DownloadProgressTracker.shared.updateProgress(bookId: bookId, value: 0.0) } - let result = try await BookService.shared.downloadBookFile(bookId: bookId) + let archiveFile = bookDir.appendingPathComponent(format.fileName) + _ = try await BookService.shared.downloadBookFile(bookId: bookId, to: archiveFile) + Self.excludeFromBackupIfNeeded(at: archiveFile) await MainActor.run { DownloadProgressTracker.shared.updateProgress(bookId: bookId, value: 0.5) } - let archiveFile = bookDir.appendingPathComponent(format.fileName) - try result.data.write(to: archiveFile, options: [.atomic]) - Self.excludeFromBackupIfNeeded(at: archiveFile) - try Task.checkCancellation() try extractImageArchive(archiveFile: archiveFile, format: format, pages: pages, bookDir: bookDir) try? FileManager.default.removeItem(at: archiveFile) @@ -2049,16 +2062,14 @@ actor OfflineManager { DownloadProgressTracker.shared.updateProgress(bookId: bookId, value: 0.0) } - let result = try await BookService.shared.downloadBookFile(bookId: bookId) + let epubFile = bookDir.appendingPathComponent(Self.epubFileName) + _ = try await BookService.shared.downloadBookFile(bookId: bookId, to: epubFile) + Self.excludeFromBackupIfNeeded(at: epubFile) await MainActor.run { DownloadProgressTracker.shared.updateProgress(bookId: bookId, value: 0.5) } - let epubFile = bookDir.appendingPathComponent(Self.epubFileName) - try result.data.write(to: epubFile, options: [.atomic]) - Self.excludeFromBackupIfNeeded(at: epubFile) - try Task.checkCancellation() try extractEpubDivinaImages(epubFile: epubFile, pages: pages, bookDir: bookDir) try? FileManager.default.removeItem(at: epubFile) @@ -2133,8 +2144,7 @@ actor OfflineManager { private func downloadPdfFile(bookId: String, to bookDir: URL) async throws { let fileURL = bookDir.appendingPathComponent(Self.pdfFileName) - let result = try await BookService.shared.downloadBookFile(bookId: bookId) - try result.data.write(to: fileURL, options: [.atomic]) + _ = try await BookService.shared.downloadBookFile(bookId: bookId, to: fileURL) Self.excludeFromBackupIfNeeded(at: fileURL) await MainActor.run { DownloadProgressTracker.shared.updateProgress(bookId: bookId, value: 1.0) @@ -2153,16 +2163,14 @@ actor OfflineManager { DownloadProgressTracker.shared.updateProgress(bookId: bookId, value: 0.0) } - let result = try await BookService.shared.downloadBookFile(bookId: bookId) + let epubFile = bookDir.appendingPathComponent(Self.epubFileName) + _ = try await BookService.shared.downloadBookFile(bookId: bookId, to: epubFile) + Self.excludeFromBackupIfNeeded(at: epubFile) await MainActor.run { DownloadProgressTracker.shared.updateProgress(bookId: bookId, value: 0.5) } - let epubFile = bookDir.appendingPathComponent(Self.epubFileName) - try result.data.write(to: epubFile, options: [.atomic]) - Self.excludeFromBackupIfNeeded(at: epubFile) - try Task.checkCancellation() try extractEpubToWebPub(epubFile: epubFile, bookId: bookId, manifest: manifest, bookDir: bookDir) diff --git a/KMReader/Features/Offline/Views/OfflineTasksView.swift b/KMReader/Features/Offline/Views/OfflineTasksView.swift index a89441a7..229a595b 100644 --- a/KMReader/Features/Offline/Views/OfflineTasksView.swift +++ b/KMReader/Features/Offline/Views/OfflineTasksView.swift @@ -244,10 +244,15 @@ struct OfflineTaskRow: View { switch book.downloadStatus { case .pending: if let progress = progress { - ProgressView(value: progress) { - Text("Downloading \(Int(progress * 100))%") - .font(.caption) - .foregroundColor(.secondary) + let isProcessing = progress >= 1 + ProgressView(value: isProcessing ? nil : progress) { + Text( + isProcessing + ? String(localized: "Processing offline files...") + : "Downloading \(Int(progress * 100))%" + ) + .font(.caption) + .foregroundColor(.secondary) } } else { Text("Pending in queue...") diff --git a/KMReader/Localizable.xcstrings b/KMReader/Localizable.xcstrings index 934f4997..f644aed5 100644 --- a/KMReader/Localizable.xcstrings +++ b/KMReader/Localizable.xcstrings @@ -50207,6 +50207,70 @@ } } }, + "Processing offline files..." : { + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Offline-Dateien werden verarbeitet..." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Processing offline files..." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Procesando archivos sin conexión..." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Traitement des fichiers hors ligne..." + } + }, + "it" : { + "stringUnit" : { + "state" : "translated", + "value" : "Elaborazione dei file offline..." + } + }, + "ja" : { + "stringUnit" : { + "state" : "translated", + "value" : "オフラインファイルを処理中..." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "오프라인 파일 처리 중..." + } + }, + "ru" : { + "stringUnit" : { + "state" : "translated", + "value" : "Обработка офлайн-файлов..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在处理离线文件..." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在處理離線檔案..." + } + } + } + }, "Publication" : { "localizations" : { "de" : {