diff --git a/.gitignore b/.gitignore index 8a80e27f8..274b62ec6 100644 --- a/.gitignore +++ b/.gitignore @@ -115,4 +115,10 @@ vendor/ venv/ Podfile.lock config_settings.yaml -default_config/ \ No newline at end of file +default_config/ + +# Translations ignored files +.venv/ +I18N/ +*.lproj/ +!en.lproj/ diff --git a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift index e1c3b12b5..f17939bd4 100644 --- a/Authorization/AuthorizationTests/AuthorizationMock.generated.swift +++ b/Authorization/AuthorizationTests/AuthorizationMock.generated.swift @@ -2405,6 +2405,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func removeAppSupportDirectoryUnusedContent() { + addInvocation(.m_removeAppSupportDirectoryUnusedContent) + let perform = methodPerformValue(.m_removeAppSupportDirectoryUnusedContent) as? () -> Void + perform?() + } + fileprivate enum MethodType { case m_publisher @@ -2421,6 +2427,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_fileUrl__for_blockId(Parameter) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_removeAppSupportDirectoryUnusedContent case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2477,6 +2484,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) + + case (.m_removeAppSupportDirectoryUnusedContent, .m_removeAppSupportDirectoryUnusedContent): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -2498,6 +2507,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_fileUrl__for_blockId(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_removeAppSupportDirectoryUnusedContent: return 0 case .p_currentDownloadTask_get: return 0 } } @@ -2517,6 +2527,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_fileUrl__for_blockId: return ".fileUrl(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -2673,6 +2684,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} + public static func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -2722,6 +2734,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) } + public static func removeAppSupportDirectoryUnusedContent(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeAppSupportDirectoryUnusedContent, performs: perform) + } } public func given(_ method: Given) { diff --git a/Core/Core/Data/CoreStorage.swift b/Core/Core/Data/CoreStorage.swift index b3a8faca0..5acf16b66 100644 --- a/Core/Core/Data/CoreStorage.swift +++ b/Core/Core/Data/CoreStorage.swift @@ -17,6 +17,7 @@ public protocol CoreStorage { var lastReviewDate: Date? {get set} var user: DataLayer.User? {get set} var userSettings: UserSettings? {get set} + var resetAppSupportDirectoryUserData: Bool? {get set} func clear() } @@ -31,6 +32,7 @@ public class CoreStorageMock: CoreStorage { public var lastReviewDate: Date? public var user: DataLayer.User? public var userSettings: UserSettings? + public var resetAppSupportDirectoryUserData: Bool? public func clear() {} public init() {} diff --git a/Core/Core/Network/DownloadManager.swift b/Core/Core/Network/DownloadManager.swift index 1ef8bcb3a..1db912da9 100644 --- a/Core/Core/Network/DownloadManager.swift +++ b/Core/Core/Network/DownloadManager.swift @@ -124,6 +124,8 @@ public protocol DownloadManagerProtocol { func resumeDownloading() throws func fileUrl(for blockId: String) -> URL? func isLargeVideosSize(blocks: [CourseBlock]) -> Bool + + func removeAppSupportDirectoryUnusedContent() } public enum DownloadManagerEvent { @@ -470,6 +472,60 @@ public class DownloadManager: DownloadManagerProtocol { debugLog("SaveFile Error", error.localizedDescription) } } + + public func removeAppSupportDirectoryUnusedContent() { + deleteMD5HashedFolders() + } + + private func getApplicationSupportDirectory() -> URL? { + let fileManager = FileManager.default + do { + let appSupportDirectory = try fileManager.url( + for: .applicationSupportDirectory, + in: .userDomainMask, + appropriateFor: nil, + create: true + ) + return appSupportDirectory + } catch { + debugPrint("Error getting Application Support Directory: \(error)") + return nil + } + } + + private func isMD5Hash(_ folderName: String) -> Bool { + let md5Regex = "^[a-fA-F0-9]{32}$" + let predicate = NSPredicate(format: "SELF MATCHES %@", md5Regex) + return predicate.evaluate(with: folderName) + } + + private func deleteMD5HashedFolders() { + guard let appSupportDirectory = getApplicationSupportDirectory() else { + return + } + + let fileManager = FileManager.default + do { + let folderContents = try fileManager.contentsOfDirectory( + at: appSupportDirectory, + includingPropertiesForKeys: nil, + options: [] + ) + for folderURL in folderContents { + let folderName = folderURL.lastPathComponent + if isMD5Hash(folderName) { + do { + try fileManager.removeItem(at: folderURL) + debugPrint("Deleted folder: \(folderName)") + } catch { + debugPrint("Error deleting folder \(folderName): \(error)") + } + } + } + } catch { + debugPrint("Error reading contents of Application Support directory: \(error)") + } + } } @available(iOSApplicationExtension, unavailable) @@ -638,6 +694,9 @@ public class DownloadManagerMock: DownloadManagerProtocol { false } + public func removeAppSupportDirectoryUnusedContent() { + + } } #endif // swiftlint:enable file_length diff --git a/Core/Core/View/Base/ProgressBar.swift b/Core/Core/View/Base/ProgressBar.swift index 7bc6e5195..b3be390e7 100644 --- a/Core/Core/View/Base/ProgressBar.swift +++ b/Core/Core/View/Base/ProgressBar.swift @@ -40,17 +40,18 @@ public struct ProgressBar: View { Circle() .stroke(lineWidth: lineWidth) .foregroundColor(Theme.Colors.accentColor.opacity(0.3)) - .frame(width: size, height: size) Circle() .trim(from: 0.0, to: 0.7) .stroke(gradient, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) - .frame(width: size, height: size) - .rotationEffect(Angle.degrees(isAnimating ? 360 : 0), anchor: .center) - .animation(animation, value: isAnimating) } + .frame(width: size, height: size) + .rotationEffect(Angle.degrees(isAnimating ? 360 : 0), anchor: .center) + .animation(animation, value: isAnimating) .onAppear { - isAnimating = true + DispatchQueue.main.async { + isAnimating = true + } } } } diff --git a/Core/Core/View/Base/VideoDownloadQualityView.swift b/Core/Core/View/Base/VideoDownloadQualityView.swift index 06e3f7c5a..26cce95c4 100644 --- a/Core/Core/View/Base/VideoDownloadQualityView.swift +++ b/Core/Core/View/Base/VideoDownloadQualityView.swift @@ -34,13 +34,15 @@ public struct VideoDownloadQualityView: View { private var viewModel: VideoDownloadQualityViewModel private var analytics: CoreAnalytics private var router: BaseRouter + private var isModal: Bool @Environment (\.isHorizontal) private var isHorizontal public init( downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?, analytics: CoreAnalytics, - router: BaseRouter + router: BaseRouter, + isModal: Bool = false ) { self._viewModel = StateObject( wrappedValue: .init( @@ -50,41 +52,46 @@ public struct VideoDownloadQualityView: View { ) self.analytics = analytics self.router = router + self.isModal = isModal } public var body: some View { GeometryReader { proxy in ZStack(alignment: .top) { - VStack { - ThemeAssets.headerBackground.swiftUIImage - .resizable() - .edgesIgnoringSafeArea(.top) + if !isModal { + VStack { + ThemeAssets.headerBackground.swiftUIImage + .resizable() + .edgesIgnoringSafeArea(.top) + } + .frame(maxWidth: .infinity, maxHeight: 200) + .accessibilityIdentifier("auth_bg_image") } - .frame(maxWidth: .infinity, maxHeight: 200) - .accessibilityIdentifier("auth_bg_image") // MARK: - Page name VStack(alignment: .center) { - ZStack { - HStack { - Text(CoreLocalization.Settings.videoDownloadQualityTitle) - .titleSettings(color: Theme.Colors.loginNavigationText) - .accessibilityIdentifier("manage_account_text") + if !isModal { + ZStack { + HStack { + Text(CoreLocalization.Settings.videoDownloadQualityTitle) + .titleSettings(color: Theme.Colors.loginNavigationText) + .accessibilityIdentifier("manage_account_text") + } + VStack { + BackNavigationButton( + color: Theme.Colors.loginNavigationText, + action: { + router.back() + } + ) + .backViewStyle() + .padding(.leading, isHorizontal ? 48 : 0) + .accessibilityIdentifier("back_button") + + }.frame(minWidth: 0, + maxWidth: .infinity, + alignment: .topLeading) } - VStack { - BackNavigationButton( - color: Theme.Colors.loginNavigationText, - action: { - router.back() - } - ) - .backViewStyle() - .padding(.leading, isHorizontal ? 48 : 0) - .accessibilityIdentifier("back_button") - - }.frame(minWidth: 0, - maxWidth: .infinity, - alignment: .topLeading) } // MARK: - Page Body ScrollView { @@ -129,8 +136,8 @@ public struct VideoDownloadQualityView: View { } } } - .navigationBarHidden(true) - .navigationBarBackButtonHidden(true) + .navigationBarHidden(!isModal) + .navigationBarBackButtonHidden(!isModal) .navigationTitle(CoreLocalization.Settings.videoDownloadQualityTitle) .ignoresSafeArea(.all, edges: .horizontal) .background( @@ -210,3 +217,17 @@ public extension DownloadQuality { } } } + +#if DEBUG +struct VideoDownloadQualityView_Previews: PreviewProvider { + static var previews: some View { + VideoDownloadQualityView( + downloadQuality: .auto, + didSelect: nil, + analytics: CoreAnalyticsMock(), + router: BaseRouterMock(), + isModal: true + ) + } +} +#endif diff --git a/Course/Course/Presentation/Downloads/DownloadsView.swift b/Course/Course/Presentation/Downloads/DownloadsView.swift index 791791204..93149bd4f 100644 --- a/Course/Course/Presentation/Downloads/DownloadsView.swift +++ b/Course/Course/Presentation/Downloads/DownloadsView.swift @@ -126,6 +126,7 @@ public struct DownloadsView: View { } } label: { DownloadProgressView() + .id("cirle loading indicator " + task.id) .accessibilityElement(children: .ignore) .accessibilityLabel(CourseLocalization.Accessibility.cancelDownload) .accessibilityIdentifier("cancel_download_button") diff --git a/Course/Course/Presentation/Outline/CourseOutlineView.swift b/Course/Course/Presentation/Outline/CourseOutlineView.swift index c6e0454aa..bb51fa74a 100644 --- a/Course/Course/Presentation/Outline/CourseOutlineView.swift +++ b/Course/Course/Presentation/Outline/CourseOutlineView.swift @@ -179,7 +179,8 @@ public struct CourseOutlineView: View { downloadQuality: $0.downloadQuality, didSelect: viewModel.update(downloadQuality:), analytics: viewModel.coreAnalytics, - router: viewModel.router + router: viewModel.router, + isModal: true ) } } diff --git a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift index 09fcf8001..79007c35e 100644 --- a/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift +++ b/Course/Course/Presentation/Subviews/VideoDownloadQualityBarView/VideoDownloadQualityContainerView.swift @@ -17,17 +17,20 @@ struct VideoDownloadQualityContainerView: View { private var didSelect: ((DownloadQuality) -> Void)? private let analytics: CoreAnalytics private let router: CourseRouter + private var isModal: Bool init( downloadQuality: DownloadQuality, didSelect: ((DownloadQuality) -> Void)?, analytics: CoreAnalytics, - router: CourseRouter + router: CourseRouter, + isModal: Bool = false ) { self.downloadQuality = downloadQuality self.didSelect = didSelect self.analytics = analytics self.router = router + self.isModal = isModal } var body: some View { @@ -36,7 +39,8 @@ struct VideoDownloadQualityContainerView: View { downloadQuality: downloadQuality, didSelect: didSelect, analytics: analytics, - router: router + router: router, + isModal: isModal ) .navigationBarTitleDisplayMode(.inline) .toolbar { diff --git a/Course/CourseTests/CourseMock.generated.swift b/Course/CourseTests/CourseMock.generated.swift index 1b68b62f7..855432bc8 100644 --- a/Course/CourseTests/CourseMock.generated.swift +++ b/Course/CourseTests/CourseMock.generated.swift @@ -2831,6 +2831,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func removeAppSupportDirectoryUnusedContent() { + addInvocation(.m_removeAppSupportDirectoryUnusedContent) + let perform = methodPerformValue(.m_removeAppSupportDirectoryUnusedContent) as? () -> Void + perform?() + } + fileprivate enum MethodType { case m_publisher @@ -2847,6 +2853,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_fileUrl__for_blockId(Parameter) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_removeAppSupportDirectoryUnusedContent case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2903,6 +2910,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) + + case (.m_removeAppSupportDirectoryUnusedContent, .m_removeAppSupportDirectoryUnusedContent): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -2924,6 +2933,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_fileUrl__for_blockId(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_removeAppSupportDirectoryUnusedContent: return 0 case .p_currentDownloadTask_get: return 0 } } @@ -2943,6 +2953,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_fileUrl__for_blockId: return ".fileUrl(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -3099,6 +3110,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} + public static func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -3148,6 +3160,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) } + public static func removeAppSupportDirectoryUnusedContent(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeAppSupportDirectoryUnusedContent, performs: perform) + } } public func given(_ method: Given) { diff --git a/Dashboard/DashboardTests/DashboardMock.generated.swift b/Dashboard/DashboardTests/DashboardMock.generated.swift index 27620fef4..5dd6af2cc 100644 --- a/Dashboard/DashboardTests/DashboardMock.generated.swift +++ b/Dashboard/DashboardTests/DashboardMock.generated.swift @@ -2144,6 +2144,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func removeAppSupportDirectoryUnusedContent() { + addInvocation(.m_removeAppSupportDirectoryUnusedContent) + let perform = methodPerformValue(.m_removeAppSupportDirectoryUnusedContent) as? () -> Void + perform?() + } + fileprivate enum MethodType { case m_publisher @@ -2160,6 +2166,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_fileUrl__for_blockId(Parameter) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_removeAppSupportDirectoryUnusedContent case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2216,6 +2223,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) + + case (.m_removeAppSupportDirectoryUnusedContent, .m_removeAppSupportDirectoryUnusedContent): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -2237,6 +2246,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_fileUrl__for_blockId(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_removeAppSupportDirectoryUnusedContent: return 0 case .p_currentDownloadTask_get: return 0 } } @@ -2256,6 +2266,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_fileUrl__for_blockId: return ".fileUrl(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -2412,6 +2423,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} + public static func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -2461,6 +2473,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) } + public static func removeAppSupportDirectoryUnusedContent(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeAppSupportDirectoryUnusedContent, performs: perform) + } } public func given(_ method: Given) { diff --git a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift index 28f8eba5a..7b7d2c10a 100644 --- a/Discovery/DiscoveryTests/DiscoveryMock.generated.swift +++ b/Discovery/DiscoveryTests/DiscoveryMock.generated.swift @@ -2338,6 +2338,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func removeAppSupportDirectoryUnusedContent() { + addInvocation(.m_removeAppSupportDirectoryUnusedContent) + let perform = methodPerformValue(.m_removeAppSupportDirectoryUnusedContent) as? () -> Void + perform?() + } + fileprivate enum MethodType { case m_publisher @@ -2354,6 +2360,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_fileUrl__for_blockId(Parameter) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_removeAppSupportDirectoryUnusedContent case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -2410,6 +2417,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) + + case (.m_removeAppSupportDirectoryUnusedContent, .m_removeAppSupportDirectoryUnusedContent): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -2431,6 +2440,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_fileUrl__for_blockId(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_removeAppSupportDirectoryUnusedContent: return 0 case .p_currentDownloadTask_get: return 0 } } @@ -2450,6 +2460,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_fileUrl__for_blockId: return ".fileUrl(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -2606,6 +2617,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} + public static func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -2655,6 +2667,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) } + public static func removeAppSupportDirectoryUnusedContent(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeAppSupportDirectoryUnusedContent, performs: perform) + } } public func given(_ method: Given) { diff --git a/Discussion/DiscussionTests/DiscussionMock.generated.swift b/Discussion/DiscussionTests/DiscussionMock.generated.swift index d8324dd64..85aa084a5 100644 --- a/Discussion/DiscussionTests/DiscussionMock.generated.swift +++ b/Discussion/DiscussionTests/DiscussionMock.generated.swift @@ -3275,6 +3275,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func removeAppSupportDirectoryUnusedContent() { + addInvocation(.m_removeAppSupportDirectoryUnusedContent) + let perform = methodPerformValue(.m_removeAppSupportDirectoryUnusedContent) as? () -> Void + perform?() + } + fileprivate enum MethodType { case m_publisher @@ -3291,6 +3297,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_fileUrl__for_blockId(Parameter) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_removeAppSupportDirectoryUnusedContent case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -3347,6 +3354,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) + + case (.m_removeAppSupportDirectoryUnusedContent, .m_removeAppSupportDirectoryUnusedContent): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -3368,6 +3377,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_fileUrl__for_blockId(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_removeAppSupportDirectoryUnusedContent: return 0 case .p_currentDownloadTask_get: return 0 } } @@ -3387,6 +3397,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_fileUrl__for_blockId: return ".fileUrl(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -3543,6 +3554,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} + public static func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -3592,6 +3604,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) } + public static func removeAppSupportDirectoryUnusedContent(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeAppSupportDirectoryUnusedContent, performs: perform) + } } public func given(_ method: Given) { diff --git a/Makefile b/Makefile new file mode 100644 index 000000000..5f97f7c59 --- /dev/null +++ b/Makefile @@ -0,0 +1,12 @@ +clean_translations_temp_directory: + rm -rf I18N/ + +translation_requirements: + pip3 install -r i18n_scripts/requirements.txt + +pull_translations: clean_translations_temp_directory + atlas pull $(ATLAS_OPTIONS) translations/openedx-app-ios/I18N:I18N + python3 i18n_scripts/translation.py --split --replace-underscore + +extract_translations: clean_translations_temp_directory + python3 i18n_scripts/translation.py --combine diff --git a/OpenEdX/Data/AppStorage.swift b/OpenEdX/Data/AppStorage.swift index 183d3a57a..2dce861eb 100644 --- a/OpenEdX/Data/AppStorage.swift +++ b/OpenEdX/Data/AppStorage.swift @@ -223,6 +223,19 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto } } } + + public var resetAppSupportDirectoryUserData: Bool? { + get { + return userDefaults.bool(forKey: KEY_RESET_APP_SUPPORT_DIRECTORY_USER_DATA) + } + set(newValue) { + if let newValue { + userDefaults.set(newValue, forKey: KEY_RESET_APP_SUPPORT_DIRECTORY_USER_DATA) + } else { + userDefaults.removeObject(forKey: KEY_RESET_APP_SUPPORT_DIRECTORY_USER_DATA) + } + } + } public var lastCalendarName: String? { get { @@ -317,4 +330,5 @@ public class AppStorage: CoreStorage, ProfileStorage, WhatsNewStorage, CourseSto private let KEY_LAST_CALENDAR_UPDATE_DATE = "lastCalendarUpdateDate" private let KEY_HIDE_INACTIVE_COURSES = "hideInactiveCourses" private let KEY_FIRST_CALENDAR_UPDATE = "firstCalendarUpdate" + private let KEY_RESET_APP_SUPPORT_DIRECTORY_USER_DATA = "resetAppSupportDirectoryUserData" } diff --git a/OpenEdX/RouteController.swift b/OpenEdX/RouteController.swift index ac53c3d36..6df0b22c0 100644 --- a/OpenEdX/RouteController.swift +++ b/OpenEdX/RouteController.swift @@ -44,6 +44,7 @@ class RouteController: UIViewController { } } + resetAppSupportDirectoryUserData() coreAnalytics.trackEvent(.launch, biValue: .launch) } @@ -99,4 +100,23 @@ class RouteController: UIViewController { } present(navigation, animated: false) } + + /** + This code will delete any old application’s downloaded user data, such as video files, + from the Application Support directory to optimize storage. This activity will be performed + only once during the upgrade from the old Open edX application to the new one or during + fresh installation. We can consider removing this code once we are confident that most or + all users have transitioned to the new application. + */ + private func resetAppSupportDirectoryUserData() { + guard var upgradationValue = Container.shared.resolve(CoreStorage.self), + let downloadManager = Container.shared.resolve(DownloadManagerProtocol.self), + upgradationValue.resetAppSupportDirectoryUserData == false + else { return } + + Task { + downloadManager.removeAppSupportDirectoryUnusedContent() + upgradationValue.resetAppSupportDirectoryUserData = true + } + } } diff --git a/Profile/ProfileTests/ProfileMock.generated.swift b/Profile/ProfileTests/ProfileMock.generated.swift index fd295cc1a..d9516df36 100644 --- a/Profile/ProfileTests/ProfileMock.generated.swift +++ b/Profile/ProfileTests/ProfileMock.generated.swift @@ -1612,6 +1612,12 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { return __value } + open func removeAppSupportDirectoryUnusedContent() { + addInvocation(.m_removeAppSupportDirectoryUnusedContent) + let perform = methodPerformValue(.m_removeAppSupportDirectoryUnusedContent) as? () -> Void + perform?() + } + fileprivate enum MethodType { case m_publisher @@ -1628,6 +1634,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case m_fileUrl__for_blockId(Parameter) case m_resumeDownloading case m_isLargeVideosSize__blocks_blocks(Parameter<[CourseBlock]>) + case m_removeAppSupportDirectoryUnusedContent case p_currentDownloadTask_get static func compareParameters(lhs: MethodType, rhs: MethodType, matcher: Matcher) -> Matcher.ComparisonResult { @@ -1684,6 +1691,8 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { var results: [Matcher.ParameterComparisonResult] = [] results.append(Matcher.ParameterComparisonResult(Parameter.compare(lhs: lhsBlocks, rhs: rhsBlocks, with: matcher), lhsBlocks, rhsBlocks, "blocks")) return Matcher.ComparisonResult(results) + + case (.m_removeAppSupportDirectoryUnusedContent, .m_removeAppSupportDirectoryUnusedContent): return .match case (.p_currentDownloadTask_get,.p_currentDownloadTask_get): return Matcher.ComparisonResult.match default: return .none } @@ -1705,6 +1714,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case let .m_fileUrl__for_blockId(p0): return p0.intValue case .m_resumeDownloading: return 0 case let .m_isLargeVideosSize__blocks_blocks(p0): return p0.intValue + case .m_removeAppSupportDirectoryUnusedContent: return 0 case .p_currentDownloadTask_get: return 0 } } @@ -1724,6 +1734,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { case .m_fileUrl__for_blockId: return ".fileUrl(for:)" case .m_resumeDownloading: return ".resumeDownloading()" case .m_isLargeVideosSize__blocks_blocks: return ".isLargeVideosSize(blocks:)" + case .m_removeAppSupportDirectoryUnusedContent: return ".removeAppSupportDirectoryUnusedContent()" case .p_currentDownloadTask_get: return "[get] .currentDownloadTask" } } @@ -1880,6 +1891,7 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func fileUrl(for blockId: Parameter) -> Verify { return Verify(method: .m_fileUrl__for_blockId(`blockId`))} public static func resumeDownloading() -> Verify { return Verify(method: .m_resumeDownloading)} public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>) -> Verify { return Verify(method: .m_isLargeVideosSize__blocks_blocks(`blocks`))} + public static func removeAppSupportDirectoryUnusedContent() -> Verify { return Verify(method: .m_removeAppSupportDirectoryUnusedContent)} public static var currentDownloadTask: Verify { return Verify(method: .p_currentDownloadTask_get) } } @@ -1929,6 +1941,9 @@ open class DownloadManagerProtocolMock: DownloadManagerProtocol, Mock { public static func isLargeVideosSize(blocks: Parameter<[CourseBlock]>, perform: @escaping ([CourseBlock]) -> Void) -> Perform { return Perform(method: .m_isLargeVideosSize__blocks_blocks(`blocks`), performs: perform) } + public static func removeAppSupportDirectoryUnusedContent(perform: @escaping () -> Void) -> Perform { + return Perform(method: .m_removeAppSupportDirectoryUnusedContent, performs: perform) + } } public func given(_ method: Given) { diff --git a/README.md b/README.md index 828a971a4..bb4b9b578 100644 --- a/README.md +++ b/README.md @@ -19,6 +19,47 @@ Modern vision of the mobile application for the Open edX platform from Raccoon G 6. Click the **Run** button. +## Translations +### Getting translations for the app +Translations aren't included in the source code of this repository as of [OEP-58](https://docs.openedx.org/en/latest/developers/concepts/oep58.html). Therefore, they need to be pulled before testing or publishing to App Store. + +Before retrieving the translations for the app, we need to install the requirements listed in the requirements.txt file located in the i18n_scripts directory. This can be done easily by running the following make command: +```bash +make translation_requirements +``` + +Then, to get the latest translations for all languages use the following command: +```bash +make pull_translations +``` +This command runs [`atlas pull`](https://github.com/openedx/openedx-atlas) to download the latest translations files from the [openedx/openedx-translations](https://github.com/openedx/openedx-translations) repository. These files contain the latest translations for all languages. In the [openedx/openedx-translations](https://github.com/openedx/openedx-translations) repository each language's translations are saved as a single file e.g. `I18N/I18N/uk.lproj/Localization.strings` ([example](https://github.com/openedx/openedx-translations/blob/6448167e9695a921f003ff6bd8f40f006a2d6743/translations/openedx-app-ios/I18N/I18N/uk.lproj/Localizable.strings)). After these are pulled, each language's translation file is split into the App's modules e.g. `Discovery/Discovery/uk.lproj/Localization.strings`. + + After this command is run the application can load the translations by changing the device (or the emulator) language in the settings. + +### Using custom translations + +By default, the command `make pull_translations` runs [`atlas pull`](https://github.com/openedx/openedx-atlas) with no arguments which pulls transaltions from the [openedx-translations repository](https://github.com/openedx/openedx-translations). + +You can use custom translations on your fork of the openedx-translations repository by setting the following configuration parameters: + +- `--revision` (default: `"main"`): Branch or git tag to pull translations from. +- `--repository` (default: `"openedx/openedx-translations"`): GitHub repository slug. There's a feature request to [support GitLab and other providers](https://github.com/openedx/openedx-atlas/issues/20). + +Arguments can be passed via the `ATLAS_OPTIONS` environment variable as shown below: +``` bash +make ATLAS_OPTIONS='--repository=/ --revision=' pull_translations +``` +Additional arguments can be passed to `atlas pull`. Refer to the [atlas documentations ](https://github.com/openedx/openedx-atlas) for more information. + +### How to translate the app + +Translations are managed in the [open-edx/openedx-translations](https://app.transifex.com/open-edx/openedx-translations/dashboard/) Transifex project. + +To translate the app join the [Transifex project](https://app.transifex.com/open-edx/openedx-translations/dashboard/) and add your translations `openedx-app-ios` resource: https://app.transifex.com/open-edx/openedx-translations/openedx-app-ios/ (the link will start working after the [pull request #442](https://github.com/openedx/openedx-app-ios/pull/422) is merged) + +Once the resource is both 100% translated and reviewed the [Transifex integration](https://github.com/apps/transifex-integration) will automatically push it to the [openedx-translations](https://github.com/openedx/openedx-translations) repository and developers can use the translations in their app. + + ## API This project targets on the latest Open edX release and rely on the relevant mobile APIs. diff --git a/i18n_scripts/requirements.txt b/i18n_scripts/requirements.txt new file mode 100644 index 000000000..384c433ad --- /dev/null +++ b/i18n_scripts/requirements.txt @@ -0,0 +1,3 @@ +# Translation processing dependencies +openedx-atlas==0.6.1 +localizable==0.1.3 \ No newline at end of file diff --git a/i18n_scripts/translation.py b/i18n_scripts/translation.py new file mode 100644 index 000000000..5a56ca48e --- /dev/null +++ b/i18n_scripts/translation.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +""" +This script performs two jobs: + 1- Combine the English translations from all modules in the repository to the I18N directory. After the English + translation is combined, it will be pushed to the openedx-translations repository as described in OEP-58. +2- Split the pulled translation files from the openedx-translations repository into the iOS app modules. + +More detailed specifications are described in the docs/0002-atlas-translations-management.rst design doc. +""" + +import argparse +import os +import re +import sys +from collections import defaultdict +import localizable + + +def parse_arguments(): + """ + This function is the argument parser for this script. + The script takes only one of the two arguments --split or --combine. + Additionally, the --replace-underscore argument can only be used with --split. + """ + parser = argparse.ArgumentParser(description='Split or Combine translations.') + group = parser.add_mutually_exclusive_group(required=True) + group.add_argument('--split', action='store_true', + help='Split translations into separate files for each module and language.') + group.add_argument('--combine', action='store_true', + help='Combine the English translations from all modules into a single file.') + parser.add_argument('--replace-underscore', action='store_true', + help='Replace underscores with "-r" in language directories (only with --split).') + return parser.parse_args() + + +def get_translation_file_path(modules_dir, module_name, lang_dir, create_dirs=False): + """ + Retrieves the path of the translation file for a specified module and language directory. + + Parameters: + modules_dir (str): The path to the base directory containing all the modules. + module_name (str): The name of the module for which the translation path is being retrieved. + lang_dir (str): The name of the language directory within the module's directory. + create_dirs (bool): If True, creates the parent directories if they do not exist. Defaults to False. + + Returns: + str: The path to the module's translation file (Localizable.strings). + """ + try: + lang_dir_path = os.path.join(modules_dir, module_name, module_name, lang_dir, 'Localizable.strings') + if create_dirs: + os.makedirs(os.path.dirname(lang_dir_path), exist_ok=True) + return lang_dir_path + except Exception as e: + print(f"Error creating directory path: {e}", file=sys.stderr) + raise + + +def get_modules_to_translate(modules_dir): + """ + Retrieve the names of modules that have translation files for a specified language. + + Parameters: + modules_dir (str): The path to the directory containing all the modules. + + Returns: + list of str: A list of module names that have translation files for the specified language. + """ + try: + modules_list = [ + directory for directory in os.listdir(modules_dir) + if ( + os.path.isdir(os.path.join(modules_dir, directory)) + and os.path.isfile(get_translation_file_path(modules_dir, directory, 'en.lproj')) + and directory != 'I18N' + ) + ] + return modules_list + except FileNotFoundError as e: + print(f"Directory not found: {e}", file=sys.stderr) + raise + except PermissionError as e: + print(f"Permission denied: {e}", file=sys.stderr) + raise + + +def get_translations(modules_dir): + """ + Retrieve the translations from all modules in the modules_dir. + + Parameters: + modules_dir (str): The directory containing the modules. + + Returns: + dict: A dict containing a list of dictionaries containing the 'key', 'value', and 'comment' for each + translation line. The key of the outer dict is the name of the module where the translations are going + to be saved. + """ + translations = [] + try: + modules = get_modules_to_translate(modules_dir) + for module in modules: + translation_file = get_translation_file_path(modules_dir, module, lang_dir='en.lproj') + module_translation = localizable.parse_strings(filename=translation_file) + + translations += [ + { + 'key': f"{module}.{translation_entry['key']}", + 'value': translation_entry['value'], + 'comment': translation_entry['comment'] + } for translation_entry in module_translation + ] + except Exception as e: + print(f"Error retrieving translations: {e}", file=sys.stderr) + raise + + return {'I18N': translations} + + +def combine_translation_files(modules_dir=None): + """ + Combine translation files from different modules into a single file. + """ + try: + if not modules_dir: + modules_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + translation = get_translations(modules_dir) + write_translations_to_modules(modules_dir, 'en.lproj', translation) + except Exception as e: + print(f"Error combining translation files: {e}", file=sys.stderr) + raise + + +def get_languages_dirs(modules_dir): + """ + Retrieve directories containing language files for translation. + + Args: + modules_dir (str): The directory containing all the modules. + + Returns: + list: A list of directories containing language files for translation. Each directory represents + a specific language and ends with the '.lproj' extension. + """ + try: + lang_parent_dir = os.path.join(modules_dir, 'I18N', 'I18N') + languages_dirs = [ + directory for directory in os.listdir(lang_parent_dir) + if directory.endswith('.lproj') and directory != "en.lproj" + ] + return languages_dirs + except FileNotFoundError as e: + print(f"Directory not found: {e}", file=sys.stderr) + raise + except PermissionError as e: + print(f"Permission denied: {e}", file=sys.stderr) + raise + + +def get_translations_from_file(modules_dir, lang_dir): + """ + Get translations from the translation file in the 'I18N' directory and distribute them into the appropriate + modules' directories. + + Args: + modules_dir (str): The directory containing all the modules. + lang_dir (str): The directory containing the translation file being split. + + Returns: + dict: A dictionary containing translations split by module. The keys are module names, + and the values are lists of dictionaries, each containing the 'key', 'value', and 'comment' + for each translation entry within the module. + """ + translations = defaultdict(list) + try: + translations_file_path = get_translation_file_path(modules_dir, 'I18N', lang_dir) + lang_list = localizable.parse_strings(filename=translations_file_path) + for translation_entry in lang_list: + module_name, key_remainder = translation_entry['key'].split('.', maxsplit=1) + split_entry = { + 'key': key_remainder, + 'value': translation_entry['value'], + 'comment': translation_entry['comment'] + } + translations[module_name].append(split_entry) + except Exception as e: + print(f"Error extracting translations from file: {e}", file=sys.stderr) + raise + return translations + + +def write_translations_to_modules(modules_dir, lang_dir, modules_translations): + """ + Write translations to language files for each module. + + Args: + modules_dir (str): The directory containing all the modules. + lang_dir (str): The directory of the translation file being written. + modules_translations (dict): A dictionary containing translations for each module. + + Returns: + None + """ + for module, translation_list in modules_translations.items(): + try: + translation_file_path = get_translation_file_path(modules_dir, module, lang_dir, create_dirs=True) + with open(translation_file_path, 'w') as f: + for translation_entry in translation_list: + write_line_and_comment(f, translation_entry) + except Exception as e: + print(f"Error writing translations to file.\n Module: {module}\n Error: {e}", file=sys.stderr) + raise + + +def _escape(s): + """ + Reverse the replacements performed by _unescape() in the localizable library + """ + s = s.replace('\n', r'\n').replace('\r', r'\r').replace('"', r'\"') + return s + + +def write_line_and_comment(f, entry): + """ + Write a translation line with an optional comment to a file. + + Args: + file (file object): The file object to write to. + entry (dict): A dictionary containing the translation entry with 'key', 'value', and optional 'comment'. + + Returns: + None + """ + comment = entry.get('comment') # Retrieve the comment, if present + if comment: + f.write(f"/* {comment} */\n") + f.write(f'"{entry["key"]}" = "{_escape(entry["value"])}";\n') + + +def split_translation_files(modules_dir=None): + """ + Split translation files into separate files for each module and language. + + Args: + modules_dir (str, optional): The directory containing all the modules. If not provided, + it defaults to the parent directory of the directory containing this script. + + Returns: + None + """ + try: + if not modules_dir: + modules_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + languages_dirs = get_languages_dirs(modules_dir) + for lang_dir in languages_dirs: + translations = get_translations_from_file(modules_dir, lang_dir) + write_translations_to_modules(modules_dir, lang_dir, translations) + except Exception as e: + print(f"Error splitting translation files: {e}", file=sys.stderr) + raise + + +def replace_underscores(modules_dir=None): + try: + if not modules_dir: + modules_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + languages_dirs = get_languages_dirs(modules_dir) + + for lang_dir in languages_dirs: + try: + pattern = r'_(\w\w.lproj$)' + if re.search(pattern, lang_dir): + replacement = r'-\1' + new_name = re.sub(pattern, replacement, lang_dir) + lang_old_path = os.path.dirname(get_translation_file_path(modules_dir, 'I18N', lang_dir)) + lang_new_path = os.path.dirname(get_translation_file_path(modules_dir, 'I18N', new_name)) + + os.rename(lang_old_path, lang_new_path) + print(f"Renamed {lang_old_path} to {lang_new_path}") + + except FileNotFoundError as e: + print(f"Error: The file or directory {lang_old_path} does not exist: {e}", file=sys.stderr) + raise + except PermissionError as e: + print(f"Error: Permission denied while renaming {lang_old_path}: {e}", file=sys.stderr) + raise + except Exception as e: + print(f"Error: An unexpected error occurred while renaming {lang_old_path} to {lang_new_path}: {e}", + file=sys.stderr) + raise + + except Exception as e: + print(f"Error: An unexpected error occurred in rename_translations_files: {e}", file=sys.stderr) + raise + + +def main(): + args = parse_arguments() + if args.split: + if args.replace_underscore: + replace_underscores() + split_translation_files() + elif args.combine: + combine_translation_files() + + +if __name__ == "__main__": + main()