Add storage usage stats settings view
This commit implements a new Storage settings view that displays storage usage statistics for NostrDB, snapshot database, and Kingfisher image cache. Key features: - Interactive pie chart visualization (iOS 17+) with tap-to-select functionality - Pull-to-refresh gesture to recalculate storage - Categorized list showing each storage type with size and percentage - Total storage sum displayed at bottom - Conditional compilation for iOS 16/17+ compatibility - All calculations run on background thread to avoid blocking main thread - NostrDB storage breakdown Changelog-Added: Storage usage statistics view in Settings Changelog-Changed: Moved clear cache button to storage settings Closes: https://github.com/damus-io/damus/issues/3649 Signed-off-by: Daniel D’Aquino <daniel@daquino.me>
This commit is contained in:
@@ -41,7 +41,6 @@
|
||||
3A92C0FF2DE16E9800CEEBAC /* FaviconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A92C0FD2DE16E9800CEEBAC /* FaviconCache.swift */; };
|
||||
3A92C1002DE16E9800CEEBAC /* FaviconCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A92C0FD2DE16E9800CEEBAC /* FaviconCache.swift */; };
|
||||
3A92C1022DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A92C1012DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift */; };
|
||||
EBCC3486DE53D8DB2532B98E /* LoadableNostrEventViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AE7EE3216D7983A50BE2D9 /* LoadableNostrEventViewModelTests.swift */; };
|
||||
3A96E3FE2D6BCE3800AE1630 /* RepostedTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3A96E3FD2D6BCE3800AE1630 /* RepostedTests.swift */; };
|
||||
3AA247FF297E3D900090C62D /* RepostsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA247FE297E3D900090C62D /* RepostsView.swift */; };
|
||||
3AA24802297E3DC20090C62D /* RepostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3AA24801297E3DC20090C62D /* RepostView.swift */; };
|
||||
@@ -1713,6 +1712,14 @@
|
||||
D77135D62E7B78D700E7639F /* DataExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = D77135D22E7B766300E7639F /* DataExtensions.swift */; };
|
||||
D773BC5F2C6D538500349F0A /* CommentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D773BC5E2C6D538500349F0A /* CommentItem.swift */; };
|
||||
D773BC602C6D538500349F0A /* CommentItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = D773BC5E2C6D538500349F0A /* CommentItem.swift */; };
|
||||
D774A5C82F4F93F5006A4D64 /* StorageStatsViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5C72F4F93F5006A4D64 /* StorageStatsViewHelper.swift */; };
|
||||
D774A5C92F4F93F5006A4D64 /* StorageStatsViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5C72F4F93F5006A4D64 /* StorageStatsViewHelper.swift */; };
|
||||
D774A5CA2F4F93F5006A4D64 /* StorageStatsViewHelper.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5C72F4F93F5006A4D64 /* StorageStatsViewHelper.swift */; };
|
||||
D774A5CC2F4F9679006A4D64 /* StorageStatsManagerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5CB2F4F9679006A4D64 /* StorageStatsManagerTests.swift */; };
|
||||
D774A5CE2F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5CD2F4FBDEA006A4D64 /* NdbDatabase+UI.swift */; };
|
||||
D774A5CF2F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5CD2F4FBDEA006A4D64 /* NdbDatabase+UI.swift */; };
|
||||
D774A5D02F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5CD2F4FBDEA006A4D64 /* NdbDatabase+UI.swift */; };
|
||||
D774A5D12F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */ = {isa = PBXBuildFile; fileRef = D774A5CD2F4FBDEA006A4D64 /* NdbDatabase+UI.swift */; };
|
||||
D776BE402F232B17002DA1C9 /* EntityPreloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */; };
|
||||
D776BE412F232B17002DA1C9 /* EntityPreloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */; };
|
||||
D776BE422F232B17002DA1C9 /* EntityPreloader.swift in Sources */ = {isa = PBXBuildFile; fileRef = D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */; };
|
||||
@@ -1731,6 +1738,12 @@
|
||||
D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */; };
|
||||
D7870BC12AC4750B0080BA88 /* MentionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC02AC4750B0080BA88 /* MentionView.swift */; };
|
||||
D7870BC32AC47EBC0080BA88 /* EventLoaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */; };
|
||||
D78778222F49476700DA73E4 /* StorageStatsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78778212F49476700DA73E4 /* StorageStatsManager.swift */; };
|
||||
D78778232F49476700DA73E4 /* StorageStatsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78778212F49476700DA73E4 /* StorageStatsManager.swift */; };
|
||||
D78778242F49476700DA73E4 /* StorageStatsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78778212F49476700DA73E4 /* StorageStatsManager.swift */; };
|
||||
D78778262F49478200DA73E4 /* StorageSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78778252F49478200DA73E4 /* StorageSettingsView.swift */; };
|
||||
D78778272F49478200DA73E4 /* StorageSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78778252F49478200DA73E4 /* StorageSettingsView.swift */; };
|
||||
D78778282F49478200DA73E4 /* StorageSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78778252F49478200DA73E4 /* StorageSettingsView.swift */; };
|
||||
D789D1202AFEFBF20083A7AB /* secp256k1 in Frameworks */ = {isa = PBXBuildFile; productRef = D789D11F2AFEFBF20083A7AB /* secp256k1 */; };
|
||||
D78BA6652DD7DFB9000AE62C /* InterestSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78BA6642DD7DFB9000AE62C /* InterestSelectionView.swift */; };
|
||||
D78BA6662DD7DFB9000AE62C /* InterestSelectionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D78BA6642DD7DFB9000AE62C /* InterestSelectionView.swift */; };
|
||||
@@ -1780,6 +1793,9 @@
|
||||
D7ADD3DE2B53854300F104C4 /* DamusPurpleURL.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */; };
|
||||
D7ADD3E02B538D4200F104C4 /* DamusPurpleURLSheetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */; };
|
||||
D7ADD3E22B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */; };
|
||||
D7B62B9A2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */; };
|
||||
D7B62B9B2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */; };
|
||||
D7B62B9C2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */; };
|
||||
D7B76C902C825042003A16CB /* PushNotificationClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7D2A3802BF815D000E4B42B /* PushNotificationClient.swift */; };
|
||||
D7B76C912C82507F003A16CB /* NIP98AuthenticatedRequest.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */; };
|
||||
D7BEE6F92D37B37400CF659F /* DraftTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D7BEE6F82D37B37400CF659F /* DraftTests.swift */; };
|
||||
@@ -1956,6 +1972,7 @@
|
||||
E0EE9DD42B8E5FEA00F3002D /* ImageProcessing.swift in Sources */ = {isa = PBXBuildFile; fileRef = E0EE9DD32B8E5FEA00F3002D /* ImageProcessing.swift */; };
|
||||
E4FA1C032A24BB7F00482697 /* SearchSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E4FA1C022A24BB7F00482697 /* SearchSettingsView.swift */; };
|
||||
E990020F2955F837003BBC5A /* EditMetadataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E990020E2955F837003BBC5A /* EditMetadataView.swift */; };
|
||||
EBCC3486DE53D8DB2532B98E /* LoadableNostrEventViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0AE7EE3216D7983A50BE2D9 /* LoadableNostrEventViewModelTests.swift */; };
|
||||
F71694EA2A662232001F4053 /* OnboardingSuggestionsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694E92A662232001F4053 /* OnboardingSuggestionsView.swift */; };
|
||||
F71694EC2A662292001F4053 /* SuggestedUsersViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694EB2A662292001F4053 /* SuggestedUsersViewModel.swift */; };
|
||||
F71694F22A67314D001F4053 /* SuggestedUserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71694F12A67314D001F4053 /* SuggestedUserView.swift */; };
|
||||
@@ -2094,7 +2111,6 @@
|
||||
3A929C22297F2CF80090925E /* it-IT */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "it-IT"; path = "it-IT.lproj/Localizable.stringsdict"; sourceTree = "<group>"; };
|
||||
3A92C0FD2DE16E9800CEEBAC /* FaviconCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FaviconCache.swift; sourceTree = "<group>"; };
|
||||
3A92C1012DE17ACA00CEEBAC /* NIP05DomainTimelineHeaderViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP05DomainTimelineHeaderViewTests.swift; sourceTree = "<group>"; };
|
||||
C0AE7EE3216D7983A50BE2D9 /* LoadableNostrEventViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableNostrEventViewModelTests.swift; sourceTree = "<group>"; };
|
||||
3A93342929884CA600D6A8F3 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pl-PL"; path = "pl-PL.lproj/InfoPlist.strings"; sourceTree = "<group>"; };
|
||||
3A93342A29884CA600D6A8F3 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "pl-PL"; path = "pl-PL.lproj/Localizable.strings"; sourceTree = "<group>"; };
|
||||
3A93342B29884CA600D6A8F3 /* pl-PL */ = {isa = PBXFileReference; lastKnownFileType = text.plist.stringsdict; name = "pl-PL"; path = "pl-PL.lproj/Localizable.stringsdict"; sourceTree = "<group>"; };
|
||||
@@ -2763,6 +2779,7 @@
|
||||
BA693073295D649800ADDB87 /* UserSettingsStore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserSettingsStore.swift; sourceTree = "<group>"; };
|
||||
BAB68BEC29543FA3007BA466 /* SelectWalletView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectWalletView.swift; sourceTree = "<group>"; };
|
||||
BAY65YZMB3VK8HOZYGCNV2YJ /* KingfisherImageProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = KingfisherImageProvider.swift; sourceTree = "<group>"; };
|
||||
C0AE7EE3216D7983A50BE2D9 /* LoadableNostrEventViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoadableNostrEventViewModelTests.swift; sourceTree = "<group>"; };
|
||||
D2277EE92A089BD5006C3807 /* Router.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Router.swift; sourceTree = "<group>"; };
|
||||
D5C1AFBE2E5DF7E60092F72F /* ContactCardManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManager.swift; sourceTree = "<group>"; };
|
||||
D5C1AFC32E5DFF700092F72F /* ContactCardManagerMock.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactCardManagerMock.swift; sourceTree = "<group>"; };
|
||||
@@ -2843,6 +2860,9 @@
|
||||
D76BE18B2E0CF3D5004AD0C6 /* Interests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Interests.swift; sourceTree = "<group>"; };
|
||||
D77135D22E7B766300E7639F /* DataExtensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DataExtensions.swift; sourceTree = "<group>"; };
|
||||
D773BC5E2C6D538500349F0A /* CommentItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommentItem.swift; sourceTree = "<group>"; };
|
||||
D774A5C72F4F93F5006A4D64 /* StorageStatsViewHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageStatsViewHelper.swift; sourceTree = "<group>"; };
|
||||
D774A5CB2F4F9679006A4D64 /* StorageStatsManagerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageStatsManagerTests.swift; sourceTree = "<group>"; };
|
||||
D774A5CD2F4FBDEA006A4D64 /* NdbDatabase+UI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NdbDatabase+UI.swift"; sourceTree = "<group>"; };
|
||||
D776BE3F2F232B17002DA1C9 /* EntityPreloader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPreloader.swift; sourceTree = "<group>"; };
|
||||
D776BE432F233012002DA1C9 /* EntityPreloaderTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EntityPreloaderTests.swift; sourceTree = "<group>"; };
|
||||
D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RelayHintsTests.swift; sourceTree = "<group>"; };
|
||||
@@ -2854,6 +2874,8 @@
|
||||
D78525242A7B2EA4002FA637 /* NoteContentViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NoteContentViewTests.swift; sourceTree = "<group>"; };
|
||||
D7870BC02AC4750B0080BA88 /* MentionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MentionView.swift; sourceTree = "<group>"; };
|
||||
D7870BC22AC47EBC0080BA88 /* EventLoaderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventLoaderView.swift; sourceTree = "<group>"; };
|
||||
D78778212F49476700DA73E4 /* StorageStatsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageStatsManager.swift; sourceTree = "<group>"; };
|
||||
D78778252F49478200DA73E4 /* StorageSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StorageSettingsView.swift; sourceTree = "<group>"; };
|
||||
D78BA6642DD7DFB9000AE62C /* InterestSelectionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterestSelectionView.swift; sourceTree = "<group>"; };
|
||||
D78CD5972B8990300014D539 /* DamusAppNotificationView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusAppNotificationView.swift; sourceTree = "<group>"; };
|
||||
D78DB85A2C20FE4F00F0AB12 /* VectorMath.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VectorMath.swift; sourceTree = "<group>"; };
|
||||
@@ -2874,6 +2896,7 @@
|
||||
D7ADD3DD2B53854300F104C4 /* DamusPurpleURL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURL.swift; sourceTree = "<group>"; };
|
||||
D7ADD3DF2B538D4200F104C4 /* DamusPurpleURLSheetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleURLSheetView.swift; sourceTree = "<group>"; };
|
||||
D7ADD3E12B538E3500F104C4 /* DamusPurpleVerifyNpubView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DamusPurpleVerifyNpubView.swift; sourceTree = "<group>"; };
|
||||
D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NostrDBDetailView.swift; sourceTree = "<group>"; };
|
||||
D7BEE6F82D37B37400CF659F /* DraftTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DraftTests.swift; sourceTree = "<group>"; };
|
||||
D7C6787D2B2D34CC00BCEAFB /* NIP98AuthenticatedRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NIP98AuthenticatedRequest.swift; sourceTree = "<group>"; };
|
||||
D7CB5D3D2B116DAD00AD4105 /* NotificationsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NotificationsManager.swift; sourceTree = "<group>"; };
|
||||
@@ -3406,6 +3429,7 @@
|
||||
4C78EFD92A707C4D007E8197 /* secp256k1.h */,
|
||||
D798D2272B085CDA00234419 /* NdbNote+.swift */,
|
||||
4CF480582B633F3800F2B2C0 /* NdbBlock.swift */,
|
||||
D774A5CD2F4FBDEA006A4D64 /* NdbDatabase+UI.swift */,
|
||||
);
|
||||
path = nostrdb;
|
||||
sourceTree = "<group>";
|
||||
@@ -3894,6 +3918,7 @@
|
||||
4CE6DEF627F7A08200C66700 /* damusTests */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D774A5CB2F4F9679006A4D64 /* StorageStatsManagerTests.swift */,
|
||||
D77A96BE2F3131BE00CC3246 /* RelayHintsTests.swift */,
|
||||
D776BE432F233012002DA1C9 /* EntityPreloaderTests.swift */,
|
||||
D77DA2CD2F1C2596000B7093 /* SubscriptionManagerNegentropyTests.swift */,
|
||||
@@ -4516,6 +4541,8 @@
|
||||
5C78A7912E3036DA00CF177D /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D7B62B992F4CE59B001EE26F /* NostrDBDetailView.swift */,
|
||||
D78778252F49478200DA73E4 /* StorageSettingsView.swift */,
|
||||
4C15C7142A55DE7A00D0A0DB /* ReactionsSettingsView.swift */,
|
||||
4C1A9A1C29DDCF9B00516EAC /* NotificationSettingsView.swift */,
|
||||
4C1A9A1E29DDD24B00516EAC /* AppearanceSettingsView.swift */,
|
||||
@@ -5001,6 +5028,8 @@
|
||||
5C78A7BD2E306D6000CF177D /* Storage */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
D774A5C72F4F93F5006A4D64 /* StorageStatsViewHelper.swift */,
|
||||
D78778212F49476700DA73E4 /* StorageStatsManager.swift */,
|
||||
D7CCDB882F034FBB00218972 /* DatabaseSnapshotManager.swift */,
|
||||
D7EDED322B12ACAE0018B19C /* DamusUserDefaults.swift */,
|
||||
D7315A292ACDF3B70036E30A /* DamusCacheManager.swift */,
|
||||
@@ -5892,6 +5921,7 @@
|
||||
4CC6193A29DC777C006A86D1 /* RelayBootstrap.swift in Sources */,
|
||||
4C285C8A2838B985008A31F1 /* ProfilePictureSelector.swift in Sources */,
|
||||
4CFD502F2A2DA45800A229DB /* MediaView.swift in Sources */,
|
||||
D7B62B9B2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */,
|
||||
D7373BA62B688EA300F7783D /* DamusPurpleTranslationSetupView.swift in Sources */,
|
||||
D776BE402F232B17002DA1C9 /* EntityPreloader.swift in Sources */,
|
||||
4C9F18E429ABDE6D008C55EC /* MaybeAnonPfpView.swift in Sources */,
|
||||
@@ -5974,6 +6004,7 @@
|
||||
4C363A9A28283854006E126D /* Reply.swift in Sources */,
|
||||
BA693074295D649800ADDB87 /* UserSettingsStore.swift in Sources */,
|
||||
D7ADD3E02B538D4200F104C4 /* DamusPurpleURLSheetView.swift in Sources */,
|
||||
D774A5D02F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */,
|
||||
4CFF8F6729CC9E3A008DB934 /* FullScreenCarouselView.swift in Sources */,
|
||||
4CA927632A290EB10098A105 /* EventTop.swift in Sources */,
|
||||
4C90BD18283A9EE5008EE7EF /* LoginView.swift in Sources */,
|
||||
@@ -6094,6 +6125,7 @@
|
||||
D73B74E12D8365BA0067BDBC /* ExtraFonts.swift in Sources */,
|
||||
4C7D09622A098D0E00943473 /* WalletConnect.swift in Sources */,
|
||||
4C64987E286D082C00EAE2B3 /* DirectMessagesModel.swift in Sources */,
|
||||
D774A5CA2F4F93F5006A4D64 /* StorageStatsViewHelper.swift in Sources */,
|
||||
4C12535E2A76CA870004F4B8 /* SwitchedTimelineNotify.swift in Sources */,
|
||||
D74F430A2B23F0BE00425B75 /* DamusPurple.swift in Sources */,
|
||||
5C4FA7ED2DC29AE900CE658C /* FollowPackEvent.swift in Sources */,
|
||||
@@ -6119,6 +6151,7 @@
|
||||
4C011B5F2BD0A56A002F2F9B /* ChatroomThreadView.swift in Sources */,
|
||||
4C9F18E229AA9B6C008C55EC /* CustomizeZapView.swift in Sources */,
|
||||
4C2859602A12A2BE004746F7 /* SupporterBadge.swift in Sources */,
|
||||
D78778232F49476700DA73E4 /* StorageStatsManager.swift in Sources */,
|
||||
D73E5F7F2C6AA066007EB227 /* DamusAliases.swift in Sources */,
|
||||
4C1A9A2A29DDF54400516EAC /* DamusVideoPlayerView.swift in Sources */,
|
||||
D73BDB152D71216500D69970 /* UserRelayListManager.swift in Sources */,
|
||||
@@ -6185,6 +6218,7 @@
|
||||
4C12536A2A76D3850004F4B8 /* RelaysChangedNotify.swift in Sources */,
|
||||
4C30AC8029A6A53F00E2BD5A /* ProfilePicturesView.swift in Sources */,
|
||||
D7373BAA2B68A65A00F7783D /* PurpleAccountUpdateNotify.swift in Sources */,
|
||||
D78778272F49478200DA73E4 /* StorageSettingsView.swift in Sources */,
|
||||
5C6E1DAD2A193EC2008FC15A /* GradientButtonStyle.swift in Sources */,
|
||||
3CCD1E6A2A874C4E0099A953 /* Nip98HTTPAuth.swift in Sources */,
|
||||
5C4FA7FF2DC5119300CE658C /* FollowPackPreview.swift in Sources */,
|
||||
@@ -6400,6 +6434,7 @@
|
||||
D7DB1FEE2D5AC51B00CF06DA /* NIP44v2EncryptionTests.swift in Sources */,
|
||||
4C4F14A72A2A61A30045A0B9 /* NostrScriptTests.swift in Sources */,
|
||||
D78525252A7B2EA4002FA637 /* NoteContentViewTests.swift in Sources */,
|
||||
D774A5CC2F4F9679006A4D64 /* StorageStatsManagerTests.swift in Sources */,
|
||||
4C3EA67B28FF7B3900C48A62 /* InvoiceTests.swift in Sources */,
|
||||
4C363A9E2828A822006E126D /* ReplyTests.swift in Sources */,
|
||||
3A96E3FE2D6BCE3800AE1630 /* RepostedTests.swift in Sources */,
|
||||
@@ -6556,6 +6591,7 @@
|
||||
82D6FAFA2CD99F7900C925F4 /* UnmuteThreadNotify.swift in Sources */,
|
||||
82D6FAFB2CD99F7900C925F4 /* ReconnectRelaysNotify.swift in Sources */,
|
||||
3ACF94432DA9FCAB00971A4E /* NIP05DomainTimelineView.swift in Sources */,
|
||||
D78778242F49476700DA73E4 /* StorageStatsManager.swift in Sources */,
|
||||
82D6FAFC2CD99F7900C925F4 /* PurpleAccountUpdateNotify.swift in Sources */,
|
||||
82D6FAFD2CD99F7900C925F4 /* IdType.swift in Sources */,
|
||||
82D6FAFE2CD99F7900C925F4 /* Pubkey.swift in Sources */,
|
||||
@@ -6713,6 +6749,7 @@
|
||||
82D6FB822CD99F7900C925F4 /* Reply.swift in Sources */,
|
||||
82D6FB832CD99F7900C925F4 /* SearchModel.swift in Sources */,
|
||||
82D6FB842CD99F7900C925F4 /* NostrFilter+Hashable.swift in Sources */,
|
||||
D7B62B9A2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */,
|
||||
82D6FB852CD99F7900C925F4 /* Contacts.swift in Sources */,
|
||||
82D6FB862CD99F7900C925F4 /* CreateAccountModel.swift in Sources */,
|
||||
82D6FB872CD99F7900C925F4 /* HomeModel.swift in Sources */,
|
||||
@@ -6747,6 +6784,7 @@
|
||||
82D6FB9E2CD99F7900C925F4 /* ContentFilters.swift in Sources */,
|
||||
3A515C512DF4E100002D3B34 /* TrustedNetworkRepliesTip.swift in Sources */,
|
||||
82D6FB9F2CD99F7900C925F4 /* DamusCacheManager.swift in Sources */,
|
||||
D774A5C92F4F93F5006A4D64 /* StorageStatsViewHelper.swift in Sources */,
|
||||
82D6FBA02CD99F7900C925F4 /* NotificationsManager.swift in Sources */,
|
||||
D755B28E2D3E7D8800BBEEFA /* NIP37Draft.swift in Sources */,
|
||||
82D6FBA12CD99F7900C925F4 /* Contacts+.swift in Sources */,
|
||||
@@ -6787,6 +6825,7 @@
|
||||
82D6FBC12CD99F7900C925F4 /* RelayURL.swift in Sources */,
|
||||
D76BE18C2E0CF3DA004AD0C6 /* Interests.swift in Sources */,
|
||||
82D6FBC22CD99F7900C925F4 /* NostrEvent+.swift in Sources */,
|
||||
D78778262F49478200DA73E4 /* StorageSettingsView.swift in Sources */,
|
||||
82D6FBC32CD99F7900C925F4 /* NIP98AuthenticatedRequest.swift in Sources */,
|
||||
82D6FBC42CD99F7900C925F4 /* NostrAuth.swift in Sources */,
|
||||
82D6FBC52CD99F7900C925F4 /* MakeZapRequest.swift in Sources */,
|
||||
@@ -6825,6 +6864,7 @@
|
||||
82D6FBEA2CD99F7900C925F4 /* FullScreenCarouselView.swift in Sources */,
|
||||
D7F360272CEBBDC0009D34DA /* DamusVideoControlsView.swift in Sources */,
|
||||
82D6FBEB2CD99F7900C925F4 /* ProfilePicImageView.swift in Sources */,
|
||||
D774A5CF2F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */,
|
||||
82D6FBEC2CD99F7900C925F4 /* ImageContainerView.swift in Sources */,
|
||||
5C8F97492EB4620A009399B1 /* Glow.swift in Sources */,
|
||||
82D6FBED2CD99F7900C925F4 /* MediaView.swift in Sources */,
|
||||
@@ -7020,6 +7060,7 @@
|
||||
4C3624792D5EA20200DD066E /* bolt11.c in Sources */,
|
||||
4C3624782D5EA1FE00DD066E /* error.c in Sources */,
|
||||
D776BE422F232B17002DA1C9 /* EntityPreloader.swift in Sources */,
|
||||
D774A5D12F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */,
|
||||
4C3624772D5EA1FA00DD066E /* nostr_bech32.c in Sources */,
|
||||
4C3624762D5EA1F600DD066E /* content_parser.c in Sources */,
|
||||
4C3624752D5EA1E000DD066E /* block.c in Sources */,
|
||||
@@ -7166,6 +7207,7 @@
|
||||
D73E5E912C6A97F4007EB227 /* CustomizeZapModel.swift in Sources */,
|
||||
D73E5E922C6A97F4007EB227 /* EventGroup.swift in Sources */,
|
||||
D73E5E932C6A97F4007EB227 /* ZapGroup.swift in Sources */,
|
||||
D774A5C82F4F93F5006A4D64 /* StorageStatsViewHelper.swift in Sources */,
|
||||
D73E5E942C6A97F4007EB227 /* NotificationStatusModel.swift in Sources */,
|
||||
3A515C542DF5371D002D3B34 /* TrustedNetworkButtonTipViewStyle.swift in Sources */,
|
||||
D73E5E952C6A97F4007EB227 /* ThreadModel.swift in Sources */,
|
||||
@@ -7222,6 +7264,7 @@
|
||||
D77DA2C42F19CA48000B7093 /* AsyncStreamUtilities.swift in Sources */,
|
||||
D73E5EB42C6A97F4007EB227 /* NoteContent.swift in Sources */,
|
||||
D73E5EB52C6A97F4007EB227 /* LongformEvent.swift in Sources */,
|
||||
D78778222F49476700DA73E4 /* StorageStatsManager.swift in Sources */,
|
||||
D73E5EB62C6A97F4007EB227 /* PushNotificationClient.swift in Sources */,
|
||||
D706C5B92D602A110027C627 /* QueueableNotify.swift in Sources */,
|
||||
D71AD8FD2CEC176A002E2C3C /* AppAccessibilityIdentifiers.swift in Sources */,
|
||||
@@ -7298,6 +7341,7 @@
|
||||
D73E5EFA2C6A97F4007EB227 /* NotificationItemView.swift in Sources */,
|
||||
5C8F97272EB460CA009399B1 /* LiveStreamBanner.swift in Sources */,
|
||||
D73E5EFB2C6A97F4007EB227 /* ProfilePicturesView.swift in Sources */,
|
||||
D78778282F49478200DA73E4 /* StorageSettingsView.swift in Sources */,
|
||||
D73E5EFC2C6A97F4007EB227 /* DamusAppNotificationView.swift in Sources */,
|
||||
D73E5EFD2C6A97F4007EB227 /* InnerTimelineView.swift in Sources */,
|
||||
D73E5EFE2C6A97F4007EB227 /* (null) in Sources */,
|
||||
@@ -7437,6 +7481,7 @@
|
||||
D73E5F6C2C6A97F5007EB227 /* RepostsView.swift in Sources */,
|
||||
D734B1462CCC19B1000B5C97 /* DamusFullScreenCover.swift in Sources */,
|
||||
D73BDB142D71216500D69970 /* UserRelayListManager.swift in Sources */,
|
||||
D7B62B9C2F4CE59B001EE26F /* NostrDBDetailView.swift in Sources */,
|
||||
D73E5F6D2C6A97F5007EB227 /* Launch.storyboard in Sources */,
|
||||
D73E5F6F2C6A97F5007EB227 /* RelayFilterView.swift in Sources */,
|
||||
5CFDE6E72EF4F782004E8661 /* TenorModels.swift in Sources */,
|
||||
@@ -7687,6 +7732,7 @@
|
||||
D798D2232B0859B700234419 /* KeychainStorage.swift in Sources */,
|
||||
D74AAFC32B153395006CF0F4 /* HeadlessDamusState.swift in Sources */,
|
||||
D78F08122D7F78F900FC6C75 /* Response.swift in Sources */,
|
||||
D774A5CE2F4FBDEA006A4D64 /* NdbDatabase+UI.swift in Sources */,
|
||||
D7CCFC102B05880F00323D86 /* Id.swift in Sources */,
|
||||
D7CB5D532B1174E900AD4105 /* DeepLPlan.swift in Sources */,
|
||||
D7EDED282B1180940018B19C /* ImageUploadModel.swift in Sources */,
|
||||
|
||||
154
damus/Core/Storage/StorageStatsManager.swift
Normal file
154
damus/Core/Storage/StorageStatsManager.swift
Normal file
@@ -0,0 +1,154 @@
|
||||
//
|
||||
// StorageStatsManager.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2026-02-20.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import Kingfisher
|
||||
|
||||
/// Storage statistics for various Damus data stores
|
||||
struct StorageStats: Hashable {
|
||||
/// Detailed breakdown of NostrDB storage by kind, indices, and other
|
||||
let nostrdbDetails: NdbStats?
|
||||
|
||||
/// Size of the main NostrDB database file in bytes (total)
|
||||
let nostrdbSize: UInt64
|
||||
|
||||
/// Size of the snapshot NostrDB database file in bytes
|
||||
let snapshotSize: UInt64
|
||||
|
||||
/// Size of the Kingfisher image cache in bytes
|
||||
let imageCacheSize: UInt64
|
||||
|
||||
/// Total storage used across all data stores
|
||||
var totalSize: UInt64 {
|
||||
return nostrdbSize + snapshotSize + imageCacheSize
|
||||
}
|
||||
|
||||
/// Calculate the percentage of total storage used by a specific size
|
||||
/// - Parameter size: The size to calculate percentage for
|
||||
/// - Returns: Percentage value between 0.0 and 100.0
|
||||
func percentage(for size: UInt64) -> Double {
|
||||
guard totalSize > 0 else { return 0.0 }
|
||||
return Double(size) / Double(totalSize) * 100.0
|
||||
}
|
||||
}
|
||||
|
||||
/// Manager for calculating storage statistics across Damus data stores
|
||||
struct StorageStatsManager {
|
||||
static let shared = StorageStatsManager()
|
||||
|
||||
private init() {}
|
||||
|
||||
/// Calculate storage statistics for all Damus data stores
|
||||
///
|
||||
/// This method runs all file operations on a background thread to avoid blocking
|
||||
/// the main thread. It calculates:
|
||||
/// - NostrDB database file size
|
||||
/// - Detailed NostrDB breakdown (if ndb instance provided)
|
||||
/// - Snapshot database file size
|
||||
/// - Kingfisher image cache size
|
||||
///
|
||||
/// - Parameter ndb: Optional Ndb instance to get detailed storage breakdown
|
||||
/// - Returns: StorageStats containing all calculated sizes
|
||||
/// - Throws: Error if critical file operations fail
|
||||
func calculateStorageStats(ndb: Ndb? = nil) async throws -> StorageStats {
|
||||
// Run all file operations on background thread
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
DispatchQueue.global(qos: .userInitiated).async {
|
||||
do {
|
||||
let nostrdbSize = self.getNostrDBSize()
|
||||
let snapshotSize = self.getSnapshotDBSize()
|
||||
|
||||
// Get detailed NostrDB stats if ndb instance provided
|
||||
let nostrdbDetails: NdbStats? = ndb?.getStats(physicalSize: nostrdbSize)
|
||||
|
||||
// Kingfisher cache size requires async callback
|
||||
KingfisherManager.shared.cache.calculateDiskStorageSize { result in
|
||||
let imageCacheSize: UInt64
|
||||
switch result {
|
||||
case .success(let size):
|
||||
imageCacheSize = UInt64(size)
|
||||
case .failure(let error):
|
||||
Log.error("Failed to calculate Kingfisher cache size: %@", for: .storage, error.localizedDescription)
|
||||
imageCacheSize = 0
|
||||
}
|
||||
|
||||
let stats = StorageStats(
|
||||
nostrdbDetails: nostrdbDetails,
|
||||
nostrdbSize: nostrdbSize,
|
||||
snapshotSize: snapshotSize,
|
||||
imageCacheSize: imageCacheSize
|
||||
)
|
||||
|
||||
continuation.resume(returning: stats)
|
||||
}
|
||||
} catch {
|
||||
continuation.resume(throwing: error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get the size of the main NostrDB database file
|
||||
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
|
||||
private func getNostrDBSize() -> UInt64 {
|
||||
guard let dbPath = Ndb.db_path else {
|
||||
Log.error("Failed to get NostrDB path", for: .storage)
|
||||
return 0
|
||||
}
|
||||
|
||||
let dataFilePath = "\(dbPath)/\(Ndb.main_db_file_name)"
|
||||
return getFileSize(at: dataFilePath, description: "NostrDB")
|
||||
}
|
||||
|
||||
/// Get the size of the snapshot NostrDB database file
|
||||
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
|
||||
private func getSnapshotDBSize() -> UInt64 {
|
||||
guard let snapshotPath = Ndb.snapshot_db_path else {
|
||||
Log.error("Failed to get snapshot DB path", for: .storage)
|
||||
return 0
|
||||
}
|
||||
|
||||
let dataFilePath = "\(snapshotPath)/\(Ndb.main_db_file_name)"
|
||||
return getFileSize(at: dataFilePath, description: "Snapshot DB")
|
||||
}
|
||||
|
||||
/// Get the size of a file at the specified path
|
||||
/// - Parameters:
|
||||
/// - path: Full path to the file
|
||||
/// - description: Human-readable description for logging
|
||||
/// - Returns: Size in bytes, or 0 if file doesn't exist or error occurs
|
||||
private func getFileSize(at path: String, description: String) -> UInt64 {
|
||||
guard FileManager.default.fileExists(atPath: path) else {
|
||||
Log.info("%@ file does not exist at path: %@", for: .storage, description, path)
|
||||
return 0
|
||||
}
|
||||
|
||||
do {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: path)
|
||||
guard let fileSize = attributes[.size] as? UInt64 else {
|
||||
Log.error("Failed to get size attribute for %@", for: .storage, description)
|
||||
return 0
|
||||
}
|
||||
return fileSize
|
||||
} catch {
|
||||
Log.error("Failed to get file size for %@: %@", for: .storage, description, error.localizedDescription)
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Format bytes into a human-readable string
|
||||
/// - Parameter bytes: Number of bytes
|
||||
/// - Returns: Formatted string (e.g., "45.3 MB", "1.2 GB")
|
||||
static func formatBytes(_ bytes: UInt64) -> String {
|
||||
let formatter = ByteCountFormatter()
|
||||
formatter.allowedUnits = [.useAll]
|
||||
formatter.countStyle = .file
|
||||
formatter.includesUnit = true
|
||||
formatter.isAdaptive = true
|
||||
return formatter.string(fromByteCount: Int64(bytes))
|
||||
}
|
||||
}
|
||||
169
damus/Core/Storage/StorageStatsViewHelper.swift
Normal file
169
damus/Core/Storage/StorageStatsViewHelper.swift
Normal file
@@ -0,0 +1,169 @@
|
||||
//
|
||||
// StorageStatsViewHelper.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D'Aquino on 2026-02-25.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
/// Shared helper functions for storage statistics views
|
||||
/// Consolidates common logic between StorageSettingsView and NostrDBDetailView
|
||||
enum StorageStatsViewHelper {
|
||||
|
||||
// MARK: - Category Ranges
|
||||
|
||||
/// Computes cumulative ranges for angle selection in pie charts (iOS 17+)
|
||||
/// - Parameter categories: Array of storage categories
|
||||
/// - Returns: Array of tuples containing category ID and cumulative range
|
||||
static func computeCategoryRanges(for categories: [StorageCategory]) -> [(category: String, range: Range<Double>)] {
|
||||
var total: UInt64 = 0
|
||||
return categories.map { category in
|
||||
let newTotal = total + category.size
|
||||
let result = (category: category.id, range: Double(total)..<Double(newTotal))
|
||||
total = newTotal
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Storage Stats Loading
|
||||
|
||||
/// Load storage statistics asynchronously
|
||||
/// - Parameter ndb: The NostrDB instance
|
||||
/// - Returns: Calculated storage statistics
|
||||
/// - Throws: Error if storage calculation fails
|
||||
@concurrent
|
||||
static func loadStorageStatsAsync(ndb: Ndb) async throws -> StorageStats {
|
||||
return try await StorageStatsManager.shared.calculateStorageStats(ndb: ndb)
|
||||
}
|
||||
|
||||
// MARK: - Export Preparation
|
||||
|
||||
/// Prepare export text for storage statistics on background thread
|
||||
/// - Parameters:
|
||||
/// - stats: The storage statistics to export
|
||||
/// - formatter: Closure that formats the stats into text
|
||||
/// - Returns: Formatted text ready for export
|
||||
@concurrent
|
||||
static func prepareExportText(
|
||||
stats: StorageStats,
|
||||
formatter: @escaping @concurrent (StorageStats) async -> String
|
||||
) async -> String {
|
||||
return await formatter(stats)
|
||||
}
|
||||
|
||||
// MARK: - Text Formatting
|
||||
|
||||
/// Format storage statistics as exportable text
|
||||
/// - Parameter stats: The storage statistics to format
|
||||
/// - Returns: Formatted text representation of storage stats
|
||||
@concurrent
|
||||
static func formatStorageStatsAsText(_ stats: StorageStats) async -> String {
|
||||
// Build categories list
|
||||
let categories = [
|
||||
StorageCategory(
|
||||
id: "nostrdb",
|
||||
title: NSLocalizedString("NostrDB", comment: "Label for main NostrDB database"),
|
||||
icon: "internaldrive.fill",
|
||||
color: .blue,
|
||||
size: stats.nostrdbSize
|
||||
),
|
||||
StorageCategory(
|
||||
id: "snapshot",
|
||||
title: NSLocalizedString("Snapshot Database", comment: "Label for snapshot database"),
|
||||
icon: "doc.on.doc.fill",
|
||||
color: .purple,
|
||||
size: stats.snapshotSize
|
||||
),
|
||||
StorageCategory(
|
||||
id: "cache",
|
||||
title: NSLocalizedString("Image Cache", comment: "Label for Kingfisher image cache"),
|
||||
icon: "photo.fill",
|
||||
color: .orange,
|
||||
size: stats.imageCacheSize
|
||||
)
|
||||
]
|
||||
|
||||
var text = "Damus Storage Statistics\n"
|
||||
text += "Generated: \(Date().formatted(date: .abbreviated, time: .shortened))\n"
|
||||
text += String(repeating: "=", count: 50) + "\n\n"
|
||||
|
||||
// Top-level Categories
|
||||
text += "Storage Breakdown:\n"
|
||||
text += String(repeating: "-", count: 50) + "\n"
|
||||
|
||||
for category in categories {
|
||||
let percentage = stats.percentage(for: category.size)
|
||||
let titlePadded = category.title.padding(toLength: 25, withPad: " ", startingAt: 0)
|
||||
let sizePadded = StorageStatsManager.formatBytes(category.size).padding(toLength: 10, withPad: " ", startingAt: 0)
|
||||
text += "\(titlePadded) \(sizePadded) (\(String(format: "%.1f", percentage))%)\n"
|
||||
}
|
||||
|
||||
text += String(repeating: "-", count: 50) + "\n"
|
||||
let totalTitlePadded = "Total Storage".padding(toLength: 25, withPad: " ", startingAt: 0)
|
||||
let totalSizePadded = StorageStatsManager.formatBytes(stats.totalSize).padding(toLength: 10, withPad: " ", startingAt: 0)
|
||||
text += "\(totalTitlePadded) \(totalSizePadded)\n\n"
|
||||
|
||||
// Add NostrDB detailed breakdown if available
|
||||
if let details = stats.nostrdbDetails {
|
||||
text += await formatNostrDBDetails(details: details)
|
||||
}
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
/// Format NostrDB statistics as exportable text
|
||||
/// - Parameter stats: The storage statistics containing NostrDB details
|
||||
/// - Returns: Formatted text representation of NostrDB stats breakdown
|
||||
@concurrent
|
||||
static func formatNostrDBStatsAsText(_ stats: StorageStats) async -> String {
|
||||
guard let details = stats.nostrdbDetails else {
|
||||
return "NostrDB details not available"
|
||||
}
|
||||
|
||||
var text = "Damus NostrDB Detailed Statistics\n"
|
||||
text += "Generated: \(Date().formatted(date: .abbreviated, time: .shortened))\n"
|
||||
text += String(repeating: "=", count: 50) + "\n\n"
|
||||
|
||||
text += await formatNostrDBDetails(details: details)
|
||||
|
||||
return text
|
||||
}
|
||||
|
||||
// MARK: - Private Helpers
|
||||
|
||||
/// Format NostrDB details section
|
||||
/// - Parameter details: The NostrDB statistics details
|
||||
/// - Returns: Formatted text representation of NostrDB details
|
||||
@concurrent
|
||||
private static func formatNostrDBDetails(details: NdbStats) async -> String {
|
||||
var text = String(repeating: "=", count: 50) + "\n\n"
|
||||
text += "NostrDB Detailed Breakdown:\n"
|
||||
text += String(repeating: "-", count: 50) + "\n"
|
||||
|
||||
// Per-database breakdown (sorted by size, already done in getStats)
|
||||
if !details.databaseStats.isEmpty {
|
||||
text += "\nDatabases:\n"
|
||||
|
||||
for dbStat in details.databaseStats {
|
||||
let percentage = details.totalSize > 0 ? Double(dbStat.totalSize) / Double(details.totalSize) * 100.0 : 0.0
|
||||
let dbNamePadded = dbStat.database.displayName.padding(toLength: 30, withPad: " ", startingAt: 0)
|
||||
let sizePadded = StorageStatsManager.formatBytes(dbStat.totalSize).padding(toLength: 12, withPad: " ", startingAt: 0)
|
||||
text += "\(dbNamePadded) \(sizePadded) (\(String(format: "%.1f", percentage))%)\n"
|
||||
|
||||
// Only show keys/values breakdown if both exist
|
||||
if dbStat.keySize > 0 && dbStat.valueSize > 0 {
|
||||
text += " Keys: \(StorageStatsManager.formatBytes(dbStat.keySize)), Values: \(StorageStatsManager.formatBytes(dbStat.valueSize))\n"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
text += "\n" + String(repeating: "-", count: 50) + "\n"
|
||||
let nostrdbTitlePadded = "NostrDB Total".padding(toLength: 30, withPad: " ", startingAt: 0)
|
||||
let nostrdbSizePadded = StorageStatsManager.formatBytes(details.totalSize).padding(toLength: 12, withPad: " ", startingAt: 0)
|
||||
text += "\(nostrdbTitlePadded) \(nostrdbSizePadded)\n"
|
||||
|
||||
return text
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,6 @@
|
||||
|
||||
import SwiftUI
|
||||
|
||||
fileprivate let CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS: Double = 60
|
||||
fileprivate let MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS: Double = 1
|
||||
|
||||
/// A simple type to keep track of the cache clearing state
|
||||
fileprivate enum CacheClearingState {
|
||||
case not_cleared
|
||||
case clearing
|
||||
case cleared
|
||||
}
|
||||
|
||||
struct ResizedEventPreview: View {
|
||||
let damus_state: DamusState
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
@@ -59,8 +49,6 @@ struct AppearanceSettingsView: View {
|
||||
let damus_state: DamusState
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
@Environment(\.dismiss) var dismiss
|
||||
@State fileprivate var cache_clearing_state: CacheClearingState = .not_cleared
|
||||
@State var showing_cache_clear_alert: Bool = false
|
||||
|
||||
@State var showing_enable_animation_alert: Bool = false
|
||||
@State var enable_animation_toggle_is_user_initiated: Bool = true
|
||||
@@ -142,8 +130,6 @@ struct AppearanceSettingsView: View {
|
||||
.tag(uploader.model.tag)
|
||||
}
|
||||
}
|
||||
|
||||
self.ClearCacheButton
|
||||
}
|
||||
|
||||
// MARK: - GIFs
|
||||
@@ -192,30 +178,6 @@ struct AppearanceSettingsView: View {
|
||||
}
|
||||
}
|
||||
|
||||
func clear_cache_button_action() {
|
||||
cache_clearing_state = .clearing
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
group.enter()
|
||||
DamusCacheManager.shared.clear_cache(damus_state: self.damus_state, completion: {
|
||||
group.leave()
|
||||
})
|
||||
|
||||
// Make clear cache button take at least a second or so to avoid issues with labor perception bias (https://growth.design/case-studies/labor-perception-bias)
|
||||
group.enter()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS) {
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
cache_clearing_state = .cleared
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS) {
|
||||
cache_clearing_state = .not_cleared
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var EnableAnimationsToggle: some View {
|
||||
Toggle(NSLocalizedString("Animations", comment: "Toggle to enable or disable image animation"), isOn: $settings.enable_animation)
|
||||
.toggleStyle(.switch)
|
||||
@@ -231,7 +193,9 @@ struct AppearanceSettingsView: View {
|
||||
Alert(title: Text("Confirmation", comment: "Confirmation dialog title"),
|
||||
message: Text("Changing this setting will cause the cache to be cleared. This will free space, but images may take longer to load again. Are you sure you want to proceed?", comment: "Message explaining consequences of changing the 'enable animation' setting"),
|
||||
primaryButton: .default(Text("OK", comment: "Button label indicating user wants to proceed.")) {
|
||||
self.clear_cache_button_action()
|
||||
Task.detached(priority: .utility, operation: {
|
||||
await DamusCacheManager.shared.clear_cache(damus_state: self.damus_state, completion: {})
|
||||
})
|
||||
},
|
||||
secondaryButton: .cancel() {
|
||||
// Toggle back if user cancels action
|
||||
@@ -241,33 +205,6 @@ struct AppearanceSettingsView: View {
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
var ClearCacheButton: some View {
|
||||
Button(action: { self.showing_cache_clear_alert = true }, label: {
|
||||
HStack(spacing: 6) {
|
||||
switch cache_clearing_state {
|
||||
case .not_cleared:
|
||||
Text("Clear Cache", comment: "Button to clear image cache.")
|
||||
case .clearing:
|
||||
ProgressView()
|
||||
Text("Clearing Cache", comment: "Loading message indicating that the cache is being cleared.")
|
||||
case .cleared:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Cache has been cleared", comment: "Message indicating that the cache was successfully cleared.")
|
||||
}
|
||||
}
|
||||
})
|
||||
.disabled(self.cache_clearing_state != .not_cleared)
|
||||
.alert(isPresented: $showing_cache_clear_alert) {
|
||||
Alert(title: Text("Confirmation", comment: "Confirmation dialog title"),
|
||||
message: Text("Are you sure you want to clear the cache? This will free space, but images may take longer to load again.", comment: "Message explaining what it means to clear the cache, asking if user wants to proceed."),
|
||||
primaryButton: .default(Text("OK", comment: "Button label indicating user wants to proceed.")) {
|
||||
self.clear_cache_button_action()
|
||||
},
|
||||
secondaryButton: .cancel())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ struct ConfigView: View {
|
||||
private let translationTitle = NSLocalizedString("Translation", comment: "Section header for text and appearance settings")
|
||||
private let reactionsTitle = NSLocalizedString("Reactions", comment: "Section header for reactions settings")
|
||||
private let developerTitle = NSLocalizedString("Developer", comment: "Section header for developer settings")
|
||||
private let storageTitle = NSLocalizedString("Storage", comment: "Section header for storage usage statistics")
|
||||
private let firstAidTitle = NSLocalizedString("First Aid", comment: "Section header for first aid tools and settings")
|
||||
private let signOutTitle = NSLocalizedString("Sign out", comment: "Sidebar menu label to sign out of the account.")
|
||||
private let deleteAccountTitle = NSLocalizedString("Delete Account", comment: "Button to delete the user's account.")
|
||||
@@ -104,6 +105,12 @@ struct ConfigView: View {
|
||||
IconLabel(developerTitle,img_name:"magic-stick2.fill",color:DamusColors.adaptableBlack)
|
||||
}
|
||||
}
|
||||
// Storage
|
||||
if showSettingsButton(title: storageTitle){
|
||||
NavigationLink(value: Route.StorageSettings(settings: settings)){
|
||||
IconLabel(storageTitle, img_name: "disk", color: .gray)
|
||||
}
|
||||
}
|
||||
//First Aid
|
||||
if showSettingsButton(title: firstAidTitle){
|
||||
NavigationLink(value: Route.FirstAidSettings(settings: settings)){
|
||||
|
||||
245
damus/Features/Settings/Views/NostrDBDetailView.swift
Normal file
245
damus/Features/Settings/Views/NostrDBDetailView.swift
Normal file
@@ -0,0 +1,245 @@
|
||||
//
|
||||
// NostrDBDetailView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D'Aquino on 2026-02-23.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
/// Detail view displaying NostrDB storage breakdown by kind, indices, and other categories
|
||||
struct NostrDBDetailView: View {
|
||||
let damus_state: DamusState
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
let initialStats: StorageStats
|
||||
|
||||
@State private var stats: StorageStats
|
||||
@State private var isLoading: Bool = false
|
||||
@State private var error: String?
|
||||
@State private var selectedAngle: Double?
|
||||
@State private var showShareSheet: Bool = false
|
||||
@State private var exportText: String?
|
||||
@State private var isPreparingExport: Bool = false
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
init(damus_state: DamusState, settings: UserSettingsStore, stats: StorageStats) {
|
||||
self.damus_state = damus_state
|
||||
self.settings = settings
|
||||
self.initialStats = stats
|
||||
self._stats = State(initialValue: stats)
|
||||
}
|
||||
|
||||
/// Storage categories with cumulative ranges for angle selection (iOS 17+)
|
||||
private var categoryRanges: [(category: String, range: Range<Double>)] {
|
||||
guard stats.nostrdbDetails != nil else { return [] }
|
||||
return StorageStatsViewHelper.computeCategoryRanges(for: detailedCategories)
|
||||
}
|
||||
|
||||
/// Selected storage category based on pie chart interaction (iOS 17+)
|
||||
private var selectedCategory: StorageCategory? {
|
||||
guard let selectedAngle = selectedAngle else { return nil }
|
||||
|
||||
if let selectedIndex = categoryRanges.firstIndex(where: { $0.range.contains(selectedAngle) }) {
|
||||
return detailedCategories[selectedIndex]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Detailed categories showing per-database breakdown
|
||||
private var detailedCategories: [StorageCategory] {
|
||||
guard let details = stats.nostrdbDetails else { return [] }
|
||||
|
||||
var result: [StorageCategory] = []
|
||||
|
||||
// Per-database categories (sorted by size descending in getStats)
|
||||
for dbStat in details.databaseStats {
|
||||
result.append(StorageCategory(
|
||||
id: dbStat.database.id,
|
||||
title: dbStat.database.displayName,
|
||||
icon: dbStat.database.icon,
|
||||
color: dbStat.database.color,
|
||||
size: dbStat.totalSize
|
||||
))
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
// Chart Section (iOS 17+ only)
|
||||
if stats.nostrdbDetails != nil {
|
||||
if #available(iOS 17.0, *) {
|
||||
Section {
|
||||
StoragePieChart(
|
||||
categories: detailedCategories,
|
||||
selectedAngle: $selectedAngle,
|
||||
selectedCategory: selectedCategory,
|
||||
totalSize: stats.nostrdbDetails?.totalSize ?? stats.nostrdbSize
|
||||
)
|
||||
.frame(height: 300)
|
||||
.padding(.vertical)
|
||||
}
|
||||
}
|
||||
|
||||
// Detailed Categories List
|
||||
Section {
|
||||
ForEach(detailedCategories) { category in
|
||||
if #available(iOS 17.0, *) {
|
||||
StorageCategoryRow(
|
||||
category: category,
|
||||
percentage: percentageOfNostrDB(for: category.size),
|
||||
isSelected: selectedCategory?.id == category.id
|
||||
)
|
||||
} else {
|
||||
StorageCategoryRow(
|
||||
category: category,
|
||||
percentage: percentageOfNostrDB(for: category.size),
|
||||
isSelected: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NostrDB Total
|
||||
Section {
|
||||
HStack {
|
||||
Text("NostrDB Total", comment: "Label for total NostrDB storage")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text(StorageStatsManager.formatBytes(stats.nostrdbSize))
|
||||
.foregroundColor(.secondary)
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if isLoading {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error state
|
||||
if let error = error {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 50)
|
||||
.navigationTitle(NSLocalizedString("NostrDB Details", comment: "Navigation title for NostrDB detail view"))
|
||||
.toolbar {
|
||||
if stats.nostrdbDetails != nil {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { Task { await prepareExport() } }) {
|
||||
if isPreparingExport {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
.disabled(isPreparingExport)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let exportText = exportText {
|
||||
TextShareSheet(activityItems: [exportText])
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await loadStorageStatsAsync()
|
||||
}
|
||||
.onReceive(handle_notify(.switched_timeline)) { _ in
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare export text on background thread before showing share sheet
|
||||
@concurrent
|
||||
private func prepareExport() async {
|
||||
// Atomically check/export all needed @State on MainActor
|
||||
let (shouldProceed, statsSnapshot): (Bool, StorageStats?) = await MainActor.run {
|
||||
let hasDetails = stats.nostrdbDetails != nil
|
||||
let notAlreadyPreparing = !isPreparingExport
|
||||
if hasDetails && notAlreadyPreparing {
|
||||
isPreparingExport = true
|
||||
return (true, stats)
|
||||
} else {
|
||||
return (false, nil)
|
||||
}
|
||||
}
|
||||
guard shouldProceed, let statsSnapshot else { return }
|
||||
|
||||
// Format text off-main
|
||||
let text = await StorageStatsViewHelper.formatNostrDBStatsAsText(statsSnapshot)
|
||||
|
||||
// Update UI on main thread
|
||||
await MainActor.run {
|
||||
self.exportText = text
|
||||
self.isPreparingExport = false
|
||||
self.showShareSheet = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Calculate percentage of NostrDB size
|
||||
private func percentageOfNostrDB(for size: UInt64) -> Double {
|
||||
guard stats.nostrdbSize > 0 else { return 0.0 }
|
||||
return Double(size) / Double(stats.nostrdbSize) * 100.0
|
||||
}
|
||||
|
||||
/// Load storage statistics asynchronously (for refreshable)
|
||||
private func loadStorageStatsAsync() async {
|
||||
await MainActor.run {
|
||||
isLoading = true
|
||||
error = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let calculatedStats = try await StorageStatsViewHelper.loadStorageStatsAsync(ndb: damus_state.ndb)
|
||||
await MainActor.run {
|
||||
self.stats = calculatedStats
|
||||
self.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.error = String(format: NSLocalizedString("Failed to calculate storage: %@", comment: "Error message when storage calculation fails"), error.localizedDescription)
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview("NostrDB Detail") {
|
||||
NavigationStack {
|
||||
NostrDBDetailView(
|
||||
damus_state: test_damus_state,
|
||||
settings: test_damus_state.settings,
|
||||
stats: StorageStats(
|
||||
nostrdbDetails: NdbStats(
|
||||
databaseStats: [
|
||||
NdbDatabaseStats(database: .other, keySize: 0, valueSize: 2000000000),
|
||||
NdbDatabaseStats(database: .note, keySize: 50000, valueSize: 200000),
|
||||
NdbDatabaseStats(database: .noteBlocks, keySize: 100000, valueSize: 50000),
|
||||
NdbDatabaseStats(database: .profile, keySize: 25000, valueSize: 100000),
|
||||
NdbDatabaseStats(database: .noteId, keySize: 75000, valueSize: 75000)
|
||||
]
|
||||
),
|
||||
nostrdbSize: 2500000000,
|
||||
snapshotSize: 100000,
|
||||
imageCacheSize: 5000000
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
457
damus/Features/Settings/Views/StorageSettingsView.swift
Normal file
457
damus/Features/Settings/Views/StorageSettingsView.swift
Normal file
@@ -0,0 +1,457 @@
|
||||
//
|
||||
// StorageSettingsView.swift
|
||||
// damus
|
||||
//
|
||||
// Created by Daniel D’Aquino on 2026-02-20.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
import Charts
|
||||
|
||||
fileprivate let CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS: Double = 60
|
||||
fileprivate let MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS: Double = 1
|
||||
|
||||
/// A simple type to keep track of the cache clearing state
|
||||
fileprivate enum CacheClearingState {
|
||||
case not_cleared
|
||||
case clearing
|
||||
case cleared
|
||||
}
|
||||
|
||||
/// Storage category for display in list and chart
|
||||
struct StorageCategory: Identifiable {
|
||||
let id: String
|
||||
let title: String
|
||||
let icon: String
|
||||
let color: Color
|
||||
let size: UInt64
|
||||
|
||||
var range: Range<Double> {
|
||||
return 0..<Double(size)
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings view displaying storage usage statistics for Damus data stores
|
||||
struct StorageSettingsView: View {
|
||||
let damus_state: DamusState
|
||||
@ObservedObject var settings: UserSettingsStore
|
||||
@Environment(\.dismiss) var dismiss
|
||||
|
||||
@State private var stats: StorageStats?
|
||||
@State private var isLoading: Bool = false
|
||||
@State private var error: String?
|
||||
@State private var selectedAngle: Double?
|
||||
@State private var showShareSheet: Bool = false
|
||||
@State private var exportText: String?
|
||||
@State private var isPreparingExport: Bool = false
|
||||
@State fileprivate var cache_clearing_state: CacheClearingState = .not_cleared
|
||||
@State var showing_cache_clear_alert: Bool = false
|
||||
|
||||
/// Storage categories with cumulative ranges for angle selection (iOS 17+)
|
||||
private var categoryRanges: [(category: String, range: Range<Double>)] {
|
||||
guard let stats = stats else { return [] }
|
||||
return StorageStatsViewHelper.computeCategoryRanges(for: categories)
|
||||
}
|
||||
|
||||
/// Selected storage category based on pie chart interaction (iOS 17+)
|
||||
private var selectedCategory: StorageCategory? {
|
||||
guard let selectedAngle = selectedAngle else { return nil }
|
||||
|
||||
if let selectedIndex = categoryRanges.firstIndex(where: { $0.range.contains(selectedAngle) }) {
|
||||
return categories[selectedIndex]
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
/// All storage categories for display (top-level view)
|
||||
private var categories: [StorageCategory] {
|
||||
guard let stats = stats else { return [] }
|
||||
|
||||
return [
|
||||
StorageCategory(
|
||||
id: "nostrdb",
|
||||
title: NSLocalizedString("NostrDB", comment: "Label for main NostrDB database"),
|
||||
icon: "internaldrive.fill",
|
||||
color: .blue,
|
||||
size: stats.nostrdbSize
|
||||
),
|
||||
StorageCategory(
|
||||
id: "snapshot",
|
||||
title: NSLocalizedString("Snapshot Database", comment: "Label for snapshot database"),
|
||||
icon: "doc.on.doc.fill",
|
||||
color: .purple,
|
||||
size: stats.snapshotSize
|
||||
),
|
||||
StorageCategory(
|
||||
id: "cache",
|
||||
title: NSLocalizedString("Image Cache", comment: "Label for Kingfisher image cache"),
|
||||
icon: "photo.fill",
|
||||
color: .orange,
|
||||
size: stats.imageCacheSize
|
||||
)
|
||||
]
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
// Chart Section (iOS 17+ only)
|
||||
if let stats = stats {
|
||||
if #available(iOS 17.0, *) {
|
||||
Section {
|
||||
StoragePieChart(
|
||||
categories: categories,
|
||||
selectedAngle: $selectedAngle,
|
||||
selectedCategory: selectedCategory,
|
||||
totalSize: stats.totalSize
|
||||
)
|
||||
.frame(height: 300)
|
||||
.padding(.vertical)
|
||||
}
|
||||
}
|
||||
|
||||
// Categories List
|
||||
Section {
|
||||
ForEach(categories) { category in
|
||||
if category.id == "nostrdb", stats.nostrdbDetails != nil {
|
||||
// NostrDB is drillable when we have detailed stats
|
||||
NavigationLink(value: Route.NostrDBStorageDetail(stats: stats)) {
|
||||
if #available(iOS 17.0, *) {
|
||||
StorageCategoryRow(
|
||||
category: category,
|
||||
percentage: stats.percentage(for: category.size),
|
||||
isSelected: selectedCategory?.id == category.id
|
||||
)
|
||||
} else {
|
||||
StorageCategoryRow(
|
||||
category: category,
|
||||
percentage: stats.percentage(for: category.size),
|
||||
isSelected: false
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Other categories are not drillable
|
||||
if #available(iOS 17.0, *) {
|
||||
StorageCategoryRow(
|
||||
category: category,
|
||||
percentage: stats.percentage(for: category.size),
|
||||
isSelected: selectedCategory?.id == category.id
|
||||
)
|
||||
} else {
|
||||
StorageCategoryRow(
|
||||
category: category,
|
||||
percentage: stats.percentage(for: category.size),
|
||||
isSelected: false
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Total at bottom
|
||||
Section {
|
||||
HStack {
|
||||
Text("Total Storage", comment: "Label for total storage used")
|
||||
.font(.headline)
|
||||
Spacer()
|
||||
Text(StorageStatsManager.formatBytes(stats.totalSize))
|
||||
.foregroundColor(.secondary)
|
||||
.font(.headline)
|
||||
}
|
||||
}
|
||||
|
||||
// Clear Cache Section
|
||||
Section {
|
||||
self.ClearCacheButton
|
||||
}
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if isLoading {
|
||||
Section {
|
||||
HStack {
|
||||
Spacer()
|
||||
ProgressView()
|
||||
Spacer()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Error state
|
||||
if let error = error {
|
||||
Section {
|
||||
Text(error)
|
||||
.foregroundColor(.red)
|
||||
.font(.caption)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.bottom, 50)
|
||||
.navigationTitle(NSLocalizedString("Storage", comment: "Navigation title for storage settings"))
|
||||
.toolbar {
|
||||
if stats != nil {
|
||||
ToolbarItem(placement: .navigationBarTrailing) {
|
||||
Button(action: { Task { await prepareExport() } }) {
|
||||
if isPreparingExport {
|
||||
ProgressView()
|
||||
} else {
|
||||
Image(systemName: "square.and.arrow.up")
|
||||
}
|
||||
}
|
||||
.disabled(isPreparingExport)
|
||||
}
|
||||
}
|
||||
}
|
||||
.sheet(isPresented: $showShareSheet) {
|
||||
if let exportText = exportText {
|
||||
TextShareSheet(activityItems: [exportText])
|
||||
}
|
||||
}
|
||||
.refreshable {
|
||||
await loadStorageStatsAsync()
|
||||
}
|
||||
.onAppear {
|
||||
if stats == nil {
|
||||
loadStorageStats()
|
||||
}
|
||||
}
|
||||
.onReceive(handle_notify(.switched_timeline)) { _ in
|
||||
dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
/// Prepare export text on background thread before showing share sheet
|
||||
@concurrent
|
||||
private func prepareExport() async {
|
||||
// Capture all relevant @State in one MainActor.run
|
||||
let (shouldProceed, statsSnapshot): (Bool, StorageStats?) = await MainActor.run {
|
||||
let hasStats = stats != nil
|
||||
let notAlreadyPreparing = !isPreparingExport
|
||||
if hasStats && notAlreadyPreparing {
|
||||
isPreparingExport = true
|
||||
return (true, stats)
|
||||
} else {
|
||||
return (false, nil)
|
||||
}
|
||||
}
|
||||
guard shouldProceed, let statsSnapshot else { return }
|
||||
|
||||
// Format text on background thread using shared helper
|
||||
let text = await StorageStatsViewHelper.formatStorageStatsAsText(statsSnapshot)
|
||||
|
||||
// Update UI on main thread
|
||||
await MainActor.run {
|
||||
self.exportText = text
|
||||
self.isPreparingExport = false
|
||||
self.showShareSheet = true
|
||||
}
|
||||
}
|
||||
|
||||
/// Load storage statistics on a background thread (for onAppear)
|
||||
private func loadStorageStats() {
|
||||
guard !isLoading else { return }
|
||||
|
||||
isLoading = true
|
||||
error = nil
|
||||
|
||||
Task {
|
||||
await loadStorageStatsAsync()
|
||||
}
|
||||
}
|
||||
|
||||
/// Load storage statistics asynchronously (for refreshable)
|
||||
@concurrent
|
||||
private func loadStorageStatsAsync() async {
|
||||
await MainActor.run {
|
||||
isLoading = true
|
||||
error = nil
|
||||
}
|
||||
|
||||
do {
|
||||
let calculatedStats = try await StorageStatsViewHelper.loadStorageStatsAsync(ndb: damus_state.ndb)
|
||||
await MainActor.run {
|
||||
self.stats = calculatedStats
|
||||
self.isLoading = false
|
||||
}
|
||||
} catch {
|
||||
await MainActor.run {
|
||||
self.error = String(format: NSLocalizedString("Failed to calculate storage: %@", comment: "Error message when storage calculation fails"), error.localizedDescription)
|
||||
self.isLoading = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear cache button action with loading state management
|
||||
func clear_cache_button_action() {
|
||||
cache_clearing_state = .clearing
|
||||
|
||||
let group = DispatchGroup()
|
||||
|
||||
group.enter()
|
||||
DamusCacheManager.shared.clear_cache(damus_state: self.damus_state, completion: {
|
||||
group.leave()
|
||||
})
|
||||
|
||||
// Make clear cache button take at least a second or so to avoid issues with labor perception bias (https://growth.design/case-studies/labor-perception-bias)
|
||||
group.enter()
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + MINIMUM_CACHE_CLEAR_BUTTON_DELAY_IN_SECONDS) {
|
||||
group.leave()
|
||||
}
|
||||
|
||||
group.notify(queue: .main) {
|
||||
cache_clearing_state = .cleared
|
||||
|
||||
// Refresh storage stats after clearing cache
|
||||
loadStorageStats()
|
||||
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + CACHE_CLEAR_BUTTON_RESET_TIME_IN_SECONDS) {
|
||||
cache_clearing_state = .not_cleared
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Clear cache button view with confirmation dialog
|
||||
var ClearCacheButton: some View {
|
||||
Button(action: { self.showing_cache_clear_alert = true }, label: {
|
||||
HStack(spacing: 6) {
|
||||
switch cache_clearing_state {
|
||||
case .not_cleared:
|
||||
Text("Clear Cache", comment: "Button to clear image cache.")
|
||||
case .clearing:
|
||||
ProgressView()
|
||||
Text("Clearing Cache", comment: "Loading message indicating that the cache is being cleared.")
|
||||
case .cleared:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Cache has been cleared", comment: "Message indicating that the cache was successfully cleared.")
|
||||
}
|
||||
}
|
||||
})
|
||||
.disabled(self.cache_clearing_state != .not_cleared)
|
||||
.alert(isPresented: $showing_cache_clear_alert) {
|
||||
Alert(title: Text("Confirmation", comment: "Confirmation dialog title"),
|
||||
message: Text("Are you sure you want to clear the cache? This will free space, but images may take longer to load again.", comment: "Message explaining what it means to clear the cache, asking if user wants to proceed."),
|
||||
primaryButton: .default(Text("OK", comment: "Button label indicating user wants to proceed.")) {
|
||||
self.clear_cache_button_action()
|
||||
},
|
||||
secondaryButton: .cancel())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pie chart displaying storage usage distribution (iOS 17+)
|
||||
@available(iOS 17.0, *)
|
||||
struct StoragePieChart: View {
|
||||
let categories: [StorageCategory]
|
||||
@Binding var selectedAngle: Double?
|
||||
let selectedCategory: StorageCategory?
|
||||
let totalSize: UInt64
|
||||
|
||||
var body: some View {
|
||||
Chart(categories) { category in
|
||||
SectorMark(
|
||||
angle: .value("Size", category.size),
|
||||
innerRadius: .ratio(0.618),
|
||||
angularInset: 1.5
|
||||
)
|
||||
.cornerRadius(4)
|
||||
.foregroundStyle(category.color)
|
||||
.opacity(selectedCategory == nil || selectedCategory?.id == category.id ? 1.0 : 0.5)
|
||||
}
|
||||
.chartAngleSelection(value: $selectedAngle)
|
||||
.chartBackground { chartProxy in
|
||||
GeometryReader { geometry in
|
||||
if let anchor = chartProxy.plotFrame {
|
||||
let frame = geometry[anchor]
|
||||
centerLabel
|
||||
.position(x: frame.midX, y: frame.midY)
|
||||
}
|
||||
}
|
||||
}
|
||||
.chartLegend(.hidden)
|
||||
}
|
||||
|
||||
/// Center label showing selected category or total
|
||||
private var centerLabel: some View {
|
||||
VStack(spacing: 4) {
|
||||
if let selected = selectedCategory {
|
||||
Image(systemName: selected.icon)
|
||||
.font(.title2)
|
||||
.foregroundColor(selected.color)
|
||||
Text(selected.title)
|
||||
.font(.headline)
|
||||
.multilineTextAlignment(.center)
|
||||
Text(StorageStatsManager.formatBytes(selected.size))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
} else {
|
||||
Text("Total", comment: "Label for total storage in pie chart center")
|
||||
.font(.headline)
|
||||
Text(StorageStatsManager.formatBytes(totalSize))
|
||||
.font(.title2)
|
||||
.bold()
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: 120)
|
||||
}
|
||||
}
|
||||
|
||||
/// Row displaying a storage category with icon, name, size, and percentage
|
||||
struct StorageCategoryRow: View {
|
||||
let category: StorageCategory
|
||||
let percentage: Double
|
||||
let isSelected: Bool
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 12) {
|
||||
Image(systemName: category.icon)
|
||||
.foregroundColor(category.color)
|
||||
.frame(width: 24)
|
||||
.font(.title3)
|
||||
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(category.title)
|
||||
.font(.body)
|
||||
Text(String(format: "%.1f%%", percentage))
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
Text(StorageStatsManager.formatBytes(category.size))
|
||||
.font(.subheadline)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
.opacity(isSelected ? 1.0 : 0.9)
|
||||
}
|
||||
}
|
||||
|
||||
/// Text-based ShareSheet wrapper for SwiftUI
|
||||
struct TextShareSheet: UIViewControllerRepresentable {
|
||||
let activityItems: [Any]
|
||||
|
||||
func makeUIViewController(context: Context) -> UIActivityViewController {
|
||||
let controller = UIActivityViewController(
|
||||
activityItems: activityItems,
|
||||
applicationActivities: nil
|
||||
)
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {
|
||||
// No updates needed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Preview
|
||||
#Preview("Storage Settings") {
|
||||
NavigationStack {
|
||||
StorageSettingsView(
|
||||
damus_state: test_damus_state,
|
||||
settings: test_damus_state.settings
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,8 @@ enum Route: Hashable {
|
||||
case SearchSettings(settings: UserSettingsStore)
|
||||
case DeveloperSettings(settings: UserSettingsStore)
|
||||
case FirstAidSettings(settings: UserSettingsStore)
|
||||
case StorageSettings(settings: UserSettingsStore)
|
||||
case NostrDBStorageDetail(stats: StorageStats)
|
||||
case Thread(thread: ThreadModel)
|
||||
case LoadableNostrEvent(note_reference: LoadableNostrEventViewModel.NoteReference)
|
||||
case Reposts(reposts: EventsModel)
|
||||
@@ -100,6 +102,10 @@ enum Route: Hashable {
|
||||
DeveloperSettingsView(settings: settings, damus_state: damusState)
|
||||
case .FirstAidSettings(settings: let settings):
|
||||
FirstAidSettingsView(damus_state: damusState, settings: settings)
|
||||
case .StorageSettings(settings: let settings):
|
||||
StorageSettingsView(damus_state: damusState, settings: settings)
|
||||
case .NostrDBStorageDetail(stats: let stats):
|
||||
NostrDBDetailView(damus_state: damusState, settings: damusState.settings, stats: stats)
|
||||
case .Thread(let thread):
|
||||
ChatroomThreadView(damus: damusState, thread: thread)
|
||||
//ThreadView(state: damusState, thread: thread)
|
||||
@@ -204,6 +210,11 @@ enum Route: Hashable {
|
||||
hasher.combine("developerSettings")
|
||||
case .FirstAidSettings:
|
||||
hasher.combine("firstAidSettings")
|
||||
case .StorageSettings:
|
||||
hasher.combine("storageSettings")
|
||||
case .NostrDBStorageDetail(let stats):
|
||||
hasher.combine("nostrDBStorageDetail")
|
||||
hasher.combine(stats)
|
||||
case .Thread(let threadModel):
|
||||
hasher.combine("thread")
|
||||
hasher.combine(threadModel.original_event.id)
|
||||
|
||||
537
damusTests/StorageStatsManagerTests.swift
Normal file
537
damusTests/StorageStatsManagerTests.swift
Normal file
@@ -0,0 +1,537 @@
|
||||
//
|
||||
// StorageStatsManagerTests.swift
|
||||
// damusTests
|
||||
//
|
||||
// Created by OpenCode on 2026-02-25.
|
||||
//
|
||||
|
||||
import XCTest
|
||||
@testable import damus
|
||||
import Kingfisher
|
||||
|
||||
/// Comprehensive test suite for storage usage calculation logic
|
||||
///
|
||||
/// Tests cover:
|
||||
/// - StorageStats calculations (total size, percentages)
|
||||
/// - File size calculations with temporary test files
|
||||
/// - Async storage stats calculations
|
||||
/// - Byte formatting utilities
|
||||
/// - Ndb.getStats() database statistics
|
||||
/// - Integration between components
|
||||
/// - Thread safety and error handling
|
||||
final class StorageStatsManagerTests: XCTestCase {
|
||||
|
||||
var tempDirectory: URL!
|
||||
var mockNostrDBPath: String!
|
||||
var mockSnapshotPath: String!
|
||||
|
||||
override func setUp() {
|
||||
super.setUp()
|
||||
|
||||
// Create temporary directory for test files
|
||||
tempDirectory = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("StorageStatsManagerTests-\(UUID().uuidString)")
|
||||
|
||||
try? FileManager.default.createDirectory(at: tempDirectory, withIntermediateDirectories: true)
|
||||
|
||||
// Create mock database directories
|
||||
let nostrDBDir = tempDirectory.appendingPathComponent("nostrdb")
|
||||
let snapshotDir = tempDirectory.appendingPathComponent("snapshot")
|
||||
|
||||
try? FileManager.default.createDirectory(at: nostrDBDir, withIntermediateDirectories: true)
|
||||
try? FileManager.default.createDirectory(at: snapshotDir, withIntermediateDirectories: true)
|
||||
|
||||
mockNostrDBPath = nostrDBDir.path
|
||||
mockSnapshotPath = snapshotDir.path
|
||||
}
|
||||
|
||||
override func tearDown() {
|
||||
// Clean up temporary files
|
||||
if let tempDirectory = tempDirectory {
|
||||
try? FileManager.default.removeItem(at: tempDirectory)
|
||||
}
|
||||
|
||||
tempDirectory = nil
|
||||
mockNostrDBPath = nil
|
||||
mockSnapshotPath = nil
|
||||
|
||||
super.tearDown()
|
||||
}
|
||||
|
||||
// MARK: - Helper Methods
|
||||
|
||||
/// Create a temporary file with specified size
|
||||
/// - Parameters:
|
||||
/// - path: Full path for the file
|
||||
/// - size: Size in bytes
|
||||
private func createTestFile(at path: String, size: UInt64) throws {
|
||||
let data = Data(repeating: 0, count: Int(size))
|
||||
try data.write(to: URL(fileURLWithPath: path))
|
||||
}
|
||||
|
||||
/// Get file size using FileManager (reference implementation)
|
||||
private func getActualFileSize(at path: String) -> UInt64? {
|
||||
guard FileManager.default.fileExists(atPath: path) else { return nil }
|
||||
|
||||
do {
|
||||
let attributes = try FileManager.default.attributesOfItem(atPath: path)
|
||||
return attributes[.size] as? UInt64
|
||||
} catch {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 1. StorageStats Structure Tests
|
||||
|
||||
/// Test that totalSize correctly sums all storage components
|
||||
func testTotalSizeCalculation() {
|
||||
let stats = StorageStats(
|
||||
nostrdbDetails: nil,
|
||||
nostrdbSize: 1000,
|
||||
snapshotSize: 500,
|
||||
imageCacheSize: 250
|
||||
)
|
||||
|
||||
XCTAssertEqual(stats.totalSize, 1750, "Total size should sum all components")
|
||||
}
|
||||
|
||||
/// Test percentage calculation accuracy
|
||||
func testPercentageCalculation() {
|
||||
let stats = StorageStats(
|
||||
nostrdbDetails: nil,
|
||||
nostrdbSize: 600,
|
||||
snapshotSize: 300,
|
||||
imageCacheSize: 100
|
||||
)
|
||||
|
||||
// Total = 1000, so 600 should be 60%
|
||||
let nostrdbPercentage = stats.percentage(for: 600)
|
||||
XCTAssertEqual(nostrdbPercentage, 60.0, accuracy: 0.01, "NostrDB should be 60% of total")
|
||||
|
||||
let snapshotPercentage = stats.percentage(for: 300)
|
||||
XCTAssertEqual(snapshotPercentage, 30.0, accuracy: 0.01, "Snapshot should be 30% of total")
|
||||
|
||||
let cachePercentage = stats.percentage(for: 100)
|
||||
XCTAssertEqual(cachePercentage, 10.0, accuracy: 0.01, "Cache should be 10% of total")
|
||||
}
|
||||
|
||||
/// Test percentage calculation when total is zero (edge case)
|
||||
func testPercentageWithZeroTotal() {
|
||||
let stats = StorageStats(
|
||||
nostrdbDetails: nil,
|
||||
nostrdbSize: 0,
|
||||
snapshotSize: 0,
|
||||
imageCacheSize: 0
|
||||
)
|
||||
|
||||
let percentage = stats.percentage(for: 100)
|
||||
XCTAssertEqual(percentage, 0.0, "Percentage should be 0 when total is 0")
|
||||
}
|
||||
|
||||
/// Test that StorageStats conforms to Hashable properly
|
||||
func testStorageStatsHashableConformance() {
|
||||
let stats1 = StorageStats(
|
||||
nostrdbDetails: nil,
|
||||
nostrdbSize: 1000,
|
||||
snapshotSize: 500,
|
||||
imageCacheSize: 250
|
||||
)
|
||||
|
||||
let stats2 = StorageStats(
|
||||
nostrdbDetails: nil,
|
||||
nostrdbSize: 1000,
|
||||
snapshotSize: 500,
|
||||
imageCacheSize: 250
|
||||
)
|
||||
|
||||
let stats3 = StorageStats(
|
||||
nostrdbDetails: nil,
|
||||
nostrdbSize: 2000,
|
||||
snapshotSize: 500,
|
||||
imageCacheSize: 250
|
||||
)
|
||||
|
||||
// Equal stats should be equal and have same hash
|
||||
XCTAssertEqual(stats1, stats2, "Identical stats should be equal")
|
||||
XCTAssertEqual(stats1.hashValue, stats2.hashValue, "Equal stats should have same hash")
|
||||
|
||||
// Different stats should not be equal
|
||||
XCTAssertNotEqual(stats1, stats3, "Different stats should not be equal")
|
||||
|
||||
// Should work in Set
|
||||
let set: Set<StorageStats> = [stats1, stats2, stats3]
|
||||
XCTAssertEqual(set.count, 2, "Set should contain 2 unique stats")
|
||||
}
|
||||
|
||||
// MARK: - 2. File Size Calculation Tests
|
||||
|
||||
/// Test file size calculation with an existing file
|
||||
func testGetFileSizeWithExistingFile() throws {
|
||||
let testFilePath = tempDirectory.appendingPathComponent("test-file.dat").path
|
||||
let expectedSize: UInt64 = 1024 * 1024 // 1 MB
|
||||
|
||||
// Create test file with known size
|
||||
try createTestFile(at: testFilePath, size: expectedSize)
|
||||
|
||||
// Verify file was created correctly
|
||||
let actualSize = getActualFileSize(at: testFilePath)
|
||||
XCTAssertNotNil(actualSize, "Test file should exist")
|
||||
XCTAssertEqual(actualSize, expectedSize, "Test file should have expected size")
|
||||
}
|
||||
|
||||
/// Test file size calculation when file doesn't exist (should return 0)
|
||||
func testGetFileSizeWithNonexistentFile() {
|
||||
let nonexistentPath = tempDirectory.appendingPathComponent("nonexistent.dat").path
|
||||
|
||||
// Verify file doesn't exist
|
||||
XCTAssertFalse(FileManager.default.fileExists(atPath: nonexistentPath), "File should not exist")
|
||||
|
||||
let size = getActualFileSize(at: nonexistentPath)
|
||||
XCTAssertNil(size, "Size should be nil for nonexistent file")
|
||||
}
|
||||
|
||||
/// Test NostrDB file size calculation with valid path
|
||||
func testGetNostrDBSizeWithValidPath() throws {
|
||||
let dbFilePath = "\(mockNostrDBPath!)/\(Ndb.main_db_file_name)"
|
||||
let expectedSize: UInt64 = 5 * 1024 * 1024 // 5 MB
|
||||
|
||||
// Create mock database file
|
||||
try createTestFile(at: dbFilePath, size: expectedSize)
|
||||
|
||||
// Verify file size can be retrieved
|
||||
let actualSize = getActualFileSize(at: dbFilePath)
|
||||
XCTAssertNotNil(actualSize, "DB file should exist")
|
||||
XCTAssertEqual(actualSize, expectedSize, "DB file should have expected size")
|
||||
}
|
||||
|
||||
/// Test snapshot database file size calculation with valid path
|
||||
func testGetSnapshotDBSizeWithValidPath() throws {
|
||||
let dbFilePath = "\(mockSnapshotPath!)/\(Ndb.main_db_file_name)"
|
||||
let expectedSize: UInt64 = 2 * 1024 * 1024 // 2 MB
|
||||
|
||||
// Create mock snapshot database file
|
||||
try createTestFile(at: dbFilePath, size: expectedSize)
|
||||
|
||||
// Verify file size can be retrieved
|
||||
let actualSize = getActualFileSize(at: dbFilePath)
|
||||
XCTAssertNotNil(actualSize, "Snapshot DB file should exist")
|
||||
XCTAssertEqual(actualSize, expectedSize, "Snapshot DB file should have expected size")
|
||||
}
|
||||
|
||||
// MARK: - 3. Byte Formatting Tests
|
||||
|
||||
/// Test formatting of zero bytes
|
||||
func testFormatBytesZero() {
|
||||
let formatted = StorageStatsManager.formatBytes(0)
|
||||
// ByteCountFormatter may format as "Zero bytes", "0 bytes", "0 KB", etc.
|
||||
// We just verify it's a valid non-empty string
|
||||
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
|
||||
// Most common formats include "0" or "Zero"
|
||||
let containsZero = formatted.contains("0") || formatted.uppercased().contains("ZERO")
|
||||
XCTAssertTrue(containsZero, "Zero bytes should contain '0' or 'Zero', got: \(formatted)")
|
||||
}
|
||||
|
||||
/// Test formatting of small byte values (< 1 KB)
|
||||
func testFormatBytesSmall() {
|
||||
let formatted = StorageStatsManager.formatBytes(512)
|
||||
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
|
||||
// Should contain a numeric value
|
||||
XCTAssertTrue(formatted.contains("512") || formatted.contains("0.5"), "Should contain size value")
|
||||
}
|
||||
|
||||
/// Test formatting of kilobyte values
|
||||
func testFormatBytesKilobytes() {
|
||||
let oneKB: UInt64 = 1024
|
||||
let formatted = StorageStatsManager.formatBytes(oneKB * 5) // 5 KB
|
||||
|
||||
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
|
||||
// Should mention KB or kilobytes
|
||||
XCTAssertTrue(formatted.uppercased().contains("KB") || formatted.uppercased().contains("K"),
|
||||
"Should indicate kilobytes: \(formatted)")
|
||||
}
|
||||
|
||||
/// Test formatting of megabyte values
|
||||
func testFormatBytesMegabytes() {
|
||||
let oneMB: UInt64 = 1024 * 1024
|
||||
let formatted = StorageStatsManager.formatBytes(oneMB * 10) // 10 MB
|
||||
|
||||
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
|
||||
// Should mention MB or megabytes
|
||||
XCTAssertTrue(formatted.uppercased().contains("MB") || formatted.uppercased().contains("M"),
|
||||
"Should indicate megabytes: \(formatted)")
|
||||
}
|
||||
|
||||
/// Test formatting of gigabyte values
|
||||
func testFormatBytesGigabytes() {
|
||||
let oneGB: UInt64 = 1024 * 1024 * 1024
|
||||
let formatted = StorageStatsManager.formatBytes(oneGB * 2) // 2 GB
|
||||
|
||||
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
|
||||
// Should mention GB or gigabytes
|
||||
XCTAssertTrue(formatted.uppercased().contains("GB") || formatted.uppercased().contains("G"),
|
||||
"Should indicate gigabytes: \(formatted)")
|
||||
}
|
||||
|
||||
/// Test formatting of very large values
|
||||
func testFormatBytesLarge() {
|
||||
let oneTB: UInt64 = 1024 * 1024 * 1024 * 1024
|
||||
let formatted = StorageStatsManager.formatBytes(oneTB)
|
||||
|
||||
XCTAssertFalse(formatted.isEmpty, "Formatted string should not be empty")
|
||||
// Should handle terabyte values gracefully
|
||||
XCTAssertTrue(formatted.uppercased().contains("TB") || formatted.uppercased().contains("T") ||
|
||||
formatted.uppercased().contains("GB") || formatted.uppercased().contains("G"),
|
||||
"Should format large values: \(formatted)")
|
||||
}
|
||||
|
||||
// MARK: - 4. Async Storage Stats Calculation Tests
|
||||
|
||||
/// Test storage stats calculation without Ndb instance
|
||||
func testCalculateStorageStatsWithoutNdb() async throws {
|
||||
// Note: This test verifies the calculation succeeds and returns valid stats
|
||||
// We don't check exact values since they depend on actual system state
|
||||
|
||||
let stats = try await StorageStatsManager.shared.calculateStorageStats(ndb: nil)
|
||||
|
||||
// Verify stats structure is valid
|
||||
XCTAssertNotNil(stats, "Stats should not be nil")
|
||||
XCTAssertNil(stats.nostrdbDetails, "Details should be nil when no Ndb provided")
|
||||
|
||||
// All sizes should be non-negative
|
||||
XCTAssertGreaterThanOrEqual(stats.nostrdbSize, 0, "NostrDB size should be non-negative")
|
||||
XCTAssertGreaterThanOrEqual(stats.snapshotSize, 0, "Snapshot size should be non-negative")
|
||||
XCTAssertGreaterThanOrEqual(stats.imageCacheSize, 0, "Image cache size should be non-negative")
|
||||
|
||||
// Total should equal sum
|
||||
let expectedTotal = stats.nostrdbSize + stats.snapshotSize + stats.imageCacheSize
|
||||
XCTAssertEqual(stats.totalSize, expectedTotal, "Total should equal sum of components")
|
||||
}
|
||||
|
||||
|
||||
// MARK: - 5. NdbDatabaseStats Tests
|
||||
|
||||
/// Test NdbDatabaseStats total size calculation
|
||||
func testNdbDatabaseStatsCalculations() {
|
||||
let dbStats = NdbDatabaseStats(
|
||||
database: .note,
|
||||
keySize: 1000,
|
||||
valueSize: 5000
|
||||
)
|
||||
|
||||
XCTAssertEqual(dbStats.totalSize, 6000, "Total should be key + value size")
|
||||
XCTAssertEqual(dbStats.database, .note, "Database type should be preserved")
|
||||
XCTAssertEqual(dbStats.keySize, 1000, "Key size should be preserved")
|
||||
XCTAssertEqual(dbStats.valueSize, 5000, "Value size should be preserved")
|
||||
}
|
||||
|
||||
/// Test NdbStats total size calculation
|
||||
func testNdbStatsTotalCalculation() {
|
||||
let stats = NdbStats(databaseStats: [
|
||||
NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000),
|
||||
NdbDatabaseStats(database: .profile, keySize: 500, valueSize: 2000),
|
||||
NdbDatabaseStats(database: .noteId, keySize: 200, valueSize: 800)
|
||||
])
|
||||
|
||||
// Total should be sum of all database totals
|
||||
// (1000+5000) + (500+2000) + (200+800) = 9500
|
||||
XCTAssertEqual(stats.totalSize, 9500, "Total should sum all database sizes")
|
||||
}
|
||||
|
||||
/// Test NdbStats with empty database list
|
||||
func testNdbStatsEmpty() {
|
||||
let stats = NdbStats(databaseStats: [])
|
||||
|
||||
XCTAssertEqual(stats.totalSize, 0, "Empty stats should have zero total")
|
||||
XCTAssertTrue(stats.databaseStats.isEmpty, "Database stats should be empty")
|
||||
}
|
||||
|
||||
/// Test NdbDatabaseStats hashable conformance
|
||||
func testNdbDatabaseStatsHashableConformance() {
|
||||
let stats1 = NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000)
|
||||
let stats2 = NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000)
|
||||
let stats3 = NdbDatabaseStats(database: .profile, keySize: 1000, valueSize: 5000)
|
||||
|
||||
XCTAssertEqual(stats1, stats2, "Identical stats should be equal")
|
||||
XCTAssertNotEqual(stats1, stats3, "Different database type should not be equal")
|
||||
|
||||
// Should work in Set
|
||||
let set: Set<NdbDatabaseStats> = [stats1, stats2, stats3]
|
||||
XCTAssertEqual(set.count, 2, "Set should contain 2 unique stats")
|
||||
}
|
||||
|
||||
/// Test NdbStats hashable conformance
|
||||
func testNdbStatsHashableConformance() {
|
||||
let dbStats1 = NdbDatabaseStats(database: .note, keySize: 1000, valueSize: 5000)
|
||||
let dbStats2 = NdbDatabaseStats(database: .profile, keySize: 500, valueSize: 2000)
|
||||
|
||||
let stats1 = NdbStats(databaseStats: [dbStats1, dbStats2])
|
||||
let stats2 = NdbStats(databaseStats: [dbStats1, dbStats2])
|
||||
let stats3 = NdbStats(databaseStats: [dbStats1])
|
||||
|
||||
XCTAssertEqual(stats1, stats2, "Identical stats should be equal")
|
||||
XCTAssertNotEqual(stats1, stats3, "Different database count should not be equal")
|
||||
|
||||
// Should work in Set
|
||||
let set: Set<NdbStats> = [stats1, stats2, stats3]
|
||||
XCTAssertEqual(set.count, 2, "Set should contain 2 unique stats")
|
||||
}
|
||||
|
||||
// MARK: - 6. NdbDatabase Enum Tests
|
||||
|
||||
/// Test NdbDatabase display names
|
||||
func testNdbDatabaseDisplayNames() {
|
||||
// Display names include the C enum names in parentheses
|
||||
XCTAssertEqual(NdbDatabase.note.displayName, "Notes (NDB_DB_NOTE)", "Note database display name")
|
||||
XCTAssertEqual(NdbDatabase.profile.displayName, "Profiles (NDB_DB_PROFILE)", "Profile database display name")
|
||||
XCTAssertEqual(NdbDatabase.noteBlocks.displayName, "Note Blocks", "Note blocks display name")
|
||||
XCTAssertEqual(NdbDatabase.noteId.displayName, "Note ID Index", "Note ID index display name")
|
||||
XCTAssertEqual(NdbDatabase.meta.displayName, "Metadata (NDB_DB_META)", "Metadata display name")
|
||||
XCTAssertEqual(NdbDatabase.other.displayName, "Other Data", "Other data display name")
|
||||
}
|
||||
|
||||
/// Test NdbDatabase icons
|
||||
func testNdbDatabaseIcons() {
|
||||
// Verify each database has an icon (non-empty string)
|
||||
XCTAssertFalse(NdbDatabase.note.icon.isEmpty, "Note should have icon")
|
||||
XCTAssertFalse(NdbDatabase.profile.icon.isEmpty, "Profile should have icon")
|
||||
XCTAssertFalse(NdbDatabase.noteBlocks.icon.isEmpty, "Note blocks should have icon")
|
||||
XCTAssertFalse(NdbDatabase.other.icon.isEmpty, "Other should have icon")
|
||||
}
|
||||
|
||||
/// Test NdbDatabase colors
|
||||
func testNdbDatabaseColors() {
|
||||
// Verify each database has a color assigned
|
||||
// We can't easily compare Color values, but we can verify they return Color instances
|
||||
_ = NdbDatabase.note.color
|
||||
_ = NdbDatabase.profile.color
|
||||
_ = NdbDatabase.noteBlocks.color
|
||||
_ = NdbDatabase.other.color
|
||||
|
||||
// If we get here without crashes, colors are working
|
||||
XCTAssertTrue(true, "All database colors should be accessible")
|
||||
}
|
||||
|
||||
/// Test NdbDatabase initialization from index
|
||||
func testNdbDatabaseFromIndex() {
|
||||
// Test valid indices
|
||||
let db0 = NdbDatabase(fromIndex: 0)
|
||||
XCTAssertNotEqual(db0, .other, "Index 0 should map to a valid database")
|
||||
|
||||
let db1 = NdbDatabase(fromIndex: 1)
|
||||
XCTAssertNotEqual(db1, .other, "Index 1 should map to a valid database")
|
||||
|
||||
// Test invalid index (should default to .other)
|
||||
let dbInvalid = NdbDatabase(fromIndex: 9999)
|
||||
XCTAssertEqual(dbInvalid, .other, "Invalid index should default to .other")
|
||||
}
|
||||
|
||||
// MARK: - 7. Integration Tests
|
||||
|
||||
/// Test complete storage stats flow with real-ish data
|
||||
func testStorageStatsIntegrationFlow() async throws {
|
||||
// This test verifies the entire flow works end-to-end
|
||||
// We use actual calculation but don't assert specific values
|
||||
|
||||
let stats = try await StorageStatsManager.shared.calculateStorageStats(ndb: nil)
|
||||
|
||||
// Verify structure
|
||||
XCTAssertNotNil(stats, "Stats should be calculated")
|
||||
|
||||
// Verify all components are accessible
|
||||
let _ = stats.nostrdbSize
|
||||
let _ = stats.snapshotSize
|
||||
let _ = stats.imageCacheSize
|
||||
let _ = stats.totalSize
|
||||
|
||||
// Verify percentage calculation works
|
||||
if stats.totalSize > 0 {
|
||||
let percentage = stats.percentage(for: stats.nostrdbSize)
|
||||
XCTAssertGreaterThanOrEqual(percentage, 0.0, "Percentage should be non-negative")
|
||||
XCTAssertLessThanOrEqual(percentage, 100.0, "Percentage should not exceed 100%")
|
||||
}
|
||||
|
||||
// Verify formatting works
|
||||
let formatted = StorageStatsManager.formatBytes(stats.totalSize)
|
||||
XCTAssertFalse(formatted.isEmpty, "Formatted size should not be empty")
|
||||
}
|
||||
|
||||
/// Test concurrent stats calculations (thread safety)
|
||||
func testConcurrentStatsCalculations() async throws {
|
||||
let iterations = 5
|
||||
|
||||
// Launch multiple concurrent calculations
|
||||
try await withThrowingTaskGroup(of: StorageStats.self) { group in
|
||||
for _ in 0..<iterations {
|
||||
group.addTask {
|
||||
return try await StorageStatsManager.shared.calculateStorageStats(ndb: nil)
|
||||
}
|
||||
}
|
||||
|
||||
var results: [StorageStats] = []
|
||||
for try await stats in group {
|
||||
results.append(stats)
|
||||
}
|
||||
|
||||
XCTAssertEqual(results.count, iterations, "Should complete all calculations")
|
||||
|
||||
// All results should have valid structure
|
||||
for stats in results {
|
||||
XCTAssertGreaterThanOrEqual(stats.nostrdbSize, 0, "NostrDB size should be valid")
|
||||
XCTAssertGreaterThanOrEqual(stats.snapshotSize, 0, "Snapshot size should be valid")
|
||||
XCTAssertGreaterThanOrEqual(stats.imageCacheSize, 0, "Cache size should be valid")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Test storage stats with extreme UInt64 values, including sum at UInt64 boundary (no overflow)
|
||||
func testStorageStatsExtremeValues() {
|
||||
// Case: Sum at UInt64 boundary (no overflow)
|
||||
// UInt64.max - 2 + 1 + 1 == UInt64.max
|
||||
let maxStats = StorageStats(
|
||||
nostrdbDetails: nil,
|
||||
nostrdbSize: UInt64.max - 2,
|
||||
snapshotSize: 1,
|
||||
imageCacheSize: 1
|
||||
)
|
||||
// Verify correct summation at UInt64 boundary
|
||||
XCTAssertEqual(maxStats.totalSize, UInt64.max, "Total should be exactly UInt64.max at boundary; no overflow should occur")
|
||||
|
||||
// Verify percentage calculation for each component
|
||||
XCTAssertEqual(maxStats.percentage(for: UInt64.max - 2), (Double(UInt64.max - 2) / Double(UInt64.max)) * 100.0, accuracy: 0.0001)
|
||||
XCTAssertEqual(maxStats.percentage(for: 1), (1.0 / Double(UInt64.max)) * 100.0, accuracy: 0.0001)
|
||||
|
||||
// All zeros case (already tested elsewhere, but included for completeness)
|
||||
let zeroStats = StorageStats(
|
||||
nostrdbDetails: nil,
|
||||
nostrdbSize: 0,
|
||||
snapshotSize: 0,
|
||||
imageCacheSize: 0
|
||||
)
|
||||
XCTAssertEqual(zeroStats.totalSize, 0, "Zero stats should have zero total")
|
||||
XCTAssertEqual(zeroStats.percentage(for: 0), 0.0, "Zero percentage for zero total")
|
||||
|
||||
// If overflow handling should be explicitly tested, add a comment. With current implementation, overflow cannot occur for UInt64 sums with three terms.
|
||||
// If more than three terms or arbitrary user input are ever summed, consider adding explicit overflow guards.
|
||||
}
|
||||
|
||||
/// Test byte formatter with various edge cases
|
||||
func testFormatBytesEdgeCases() {
|
||||
// Powers of 1024
|
||||
let formatted1K = StorageStatsManager.formatBytes(1024)
|
||||
XCTAssertFalse(formatted1K.isEmpty, "Should format 1KB")
|
||||
|
||||
let formatted1M = StorageStatsManager.formatBytes(1024 * 1024)
|
||||
XCTAssertFalse(formatted1M.isEmpty, "Should format 1MB")
|
||||
|
||||
let formatted1G = StorageStatsManager.formatBytes(1024 * 1024 * 1024)
|
||||
XCTAssertFalse(formatted1G.isEmpty, "Should format 1GB")
|
||||
|
||||
// Odd values
|
||||
let formatted999 = StorageStatsManager.formatBytes(999)
|
||||
XCTAssertFalse(formatted999.isEmpty, "Should format 999 bytes")
|
||||
|
||||
let formatted1023 = StorageStatsManager.formatBytes(1023)
|
||||
XCTAssertFalse(formatted1023.isEmpty, "Should format 1023 bytes")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,8 +84,8 @@ class Ndb {
|
||||
return remove_file_prefix(containerURL.appendingPathComponent("snapshot", conformingTo: .directory).absoluteString)
|
||||
}
|
||||
|
||||
static private let main_db_file_name: String = "data.mdb"
|
||||
static private let db_files: [String] = ["data.mdb", "lock.mdb"]
|
||||
static let main_db_file_name: String = "data.mdb"
|
||||
static let db_files: [String] = ["data.mdb", "lock.mdb"]
|
||||
|
||||
static var empty: Ndb {
|
||||
print("txn: NOSTRDB EMPTY")
|
||||
@@ -1151,6 +1151,72 @@ extension Ndb {
|
||||
}
|
||||
}
|
||||
|
||||
extension Ndb {
|
||||
/// Get detailed storage statistics for this database
|
||||
///
|
||||
/// This method calls the C `ndb_stat` function to retrieve per-database
|
||||
/// storage statistics from the underlying LMDB storage. Each database
|
||||
/// (notes, profiles, indices, etc.) is reported with its key and value sizes.
|
||||
///
|
||||
/// Any unaccounted space between the sum of database stats and the physical
|
||||
/// file size is reported as "Other Data".
|
||||
///
|
||||
/// - Parameter physicalSize: The physical file size in bytes from the filesystem
|
||||
/// - Returns: NdbStats with detailed per-database breakdown, or nil if stat collection fails
|
||||
func getStats(physicalSize: UInt64) -> NdbStats? {
|
||||
// All of this must be done under withNdb to avoid races with close()/ndb_destroy
|
||||
let copiedStats: ([NdbDatabaseStats], UInt64)? = try? withNdb({
|
||||
var stat = ndb_stat()
|
||||
// Call C ndb_stat function
|
||||
let result = ndb_stat(self.ndb.ndb, &stat)
|
||||
guard result != 0 else {
|
||||
Log.error("ndb_stat failed", for: .storage)
|
||||
return nil
|
||||
}
|
||||
|
||||
var databaseStats: [NdbDatabaseStats] = []
|
||||
var accountedSize: UInt64 = 0
|
||||
|
||||
// Extract per-database stats from stat.dbs array
|
||||
withUnsafePointer(to: &stat.dbs) { dbsPtr in
|
||||
let dbsBuffer = UnsafeRawPointer(dbsPtr).assumingMemoryBound(to: ndb_stat_counts.self)
|
||||
|
||||
for dbIndex in 0..<Int(NDB_DBS.rawValue) {
|
||||
let dbStat = dbsBuffer[dbIndex]
|
||||
// Skip databases with no data
|
||||
guard dbStat.key_size > 0 || dbStat.value_size > 0 else { continue }
|
||||
// Get database type from index
|
||||
let database = NdbDatabase(fromIndex: dbIndex)
|
||||
let dbStats = NdbDatabaseStats(
|
||||
database: database,
|
||||
keySize: UInt64(dbStat.key_size),
|
||||
valueSize: UInt64(dbStat.value_size)
|
||||
)
|
||||
databaseStats.append(dbStats)
|
||||
accountedSize += dbStats.totalSize
|
||||
}
|
||||
}
|
||||
return (databaseStats, accountedSize)
|
||||
})
|
||||
guard let (databaseStatsRaw, accountedSize) = copiedStats else { return nil }
|
||||
var databaseStats = databaseStatsRaw
|
||||
|
||||
// Add "Other Data" for any unaccounted space
|
||||
if physicalSize > accountedSize {
|
||||
let otherSize = physicalSize - accountedSize
|
||||
databaseStats.append(NdbDatabaseStats(
|
||||
database: .other,
|
||||
keySize: 0,
|
||||
valueSize: otherSize
|
||||
))
|
||||
}
|
||||
// Sort by total size descending to show largest databases first
|
||||
databaseStats.sort { $0.totalSize > $1.totalSize }
|
||||
|
||||
return NdbStats(databaseStats: databaseStats)
|
||||
}
|
||||
}
|
||||
|
||||
/// This callback "trampoline" function will be called when new notes arrive for NostrDB subscriptions.
|
||||
///
|
||||
/// This is needed as a separate global function in order to allow us to pass it to the C code as a callback (We can't pass native Swift fuctions directly as callbacks).
|
||||
@@ -1180,3 +1246,73 @@ func getDebugCheckedRoot<T: FlatBufferObject>(byteBuffer: inout ByteBuffer) thro
|
||||
func remove_file_prefix(_ str: String) -> String {
|
||||
return str.replacingOccurrences(of: "file://", with: "")
|
||||
}
|
||||
|
||||
// MARK: - NostrDB Storage Statistics
|
||||
|
||||
/// NostrDB database types corresponding to the ndb_dbs C enum
|
||||
enum NdbDatabase: Int, Hashable, CaseIterable, Identifiable {
|
||||
case note = 0 // NDB_DB_NOTE
|
||||
case meta = 1 // NDB_DB_META
|
||||
case profile = 2 // NDB_DB_PROFILE
|
||||
case noteId = 3 // NDB_DB_NOTE_ID
|
||||
case profileKey = 4 // NDB_DB_PROFILE_PK
|
||||
case ndbMeta = 5 // NDB_DB_NDB_META
|
||||
case profileSearch = 6 // NDB_DB_PROFILE_SEARCH
|
||||
case profileLastFetch = 7 // NDB_DB_PROFILE_LAST_FETCH
|
||||
case noteKind = 8 // NDB_DB_NOTE_KIND
|
||||
case noteText = 9 // NDB_DB_NOTE_TEXT
|
||||
case noteBlocks = 10 // NDB_DB_NOTE_BLOCKS
|
||||
case noteTags = 11 // NDB_DB_NOTE_TAGS
|
||||
case notePubkey = 12 // NDB_DB_NOTE_PUBKEY
|
||||
case notePubkeyKind = 13 // NDB_DB_NOTE_PUBKEY_KIND
|
||||
case noteRelayKind = 14 // NDB_DB_NOTE_RELAY_KIND
|
||||
case noteRelays = 15 // NDB_DB_NOTE_RELAYS
|
||||
case other // For unaccounted data
|
||||
|
||||
var id: String {
|
||||
return String(self.rawValue)
|
||||
}
|
||||
|
||||
/// Database index matching the C ndb_dbs enum
|
||||
var index: Int {
|
||||
return self.rawValue
|
||||
}
|
||||
|
||||
/// Initialize from database index (matching ndb_dbs C enum order)
|
||||
init(fromIndex index: Int) {
|
||||
if let db = NdbDatabase(rawValue: index) {
|
||||
self = db
|
||||
} else {
|
||||
self = .other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Per-database storage statistics from NostrDB
|
||||
struct NdbDatabaseStats: Hashable {
|
||||
/// Database type
|
||||
let database: NdbDatabase
|
||||
|
||||
/// Total key bytes for this database
|
||||
let keySize: UInt64
|
||||
|
||||
/// Total value bytes for this database
|
||||
let valueSize: UInt64
|
||||
|
||||
/// Total storage used by this database (keys + values)
|
||||
var totalSize: UInt64 {
|
||||
return keySize + valueSize
|
||||
}
|
||||
}
|
||||
|
||||
/// Detailed NostrDB storage statistics with per-database breakdown
|
||||
struct NdbStats: Hashable {
|
||||
/// Per-database breakdown of storage (notes, profiles, indices, etc.)
|
||||
let databaseStats: [NdbDatabaseStats]
|
||||
|
||||
/// Total storage across all databases
|
||||
var totalSize: UInt64 {
|
||||
return databaseStats.reduce(0) { $0 + $1.totalSize }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
91
nostrdb/NdbDatabase+UI.swift
Normal file
91
nostrdb/NdbDatabase+UI.swift
Normal file
@@ -0,0 +1,91 @@
|
||||
//
|
||||
// NdbDatabase+UI.swift
|
||||
// (UI/Features target)
|
||||
//
|
||||
// This extension adds UI-specific properties to NdbDatabase for presentation purposes.
|
||||
// It should only be included in targets involving SwiftUI/UI presentation.
|
||||
//
|
||||
|
||||
import SwiftUI
|
||||
|
||||
extension NdbDatabase {
|
||||
/// Human-readable database name
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .note:
|
||||
return NSLocalizedString("Notes (NDB_DB_NOTE)", comment: "Database name for notes")
|
||||
case .meta:
|
||||
return NSLocalizedString("Metadata (NDB_DB_META)", comment: "Database name for metadata")
|
||||
case .profile:
|
||||
return NSLocalizedString("Profiles (NDB_DB_PROFILE)", comment: "Database name for profiles")
|
||||
case .noteId:
|
||||
return NSLocalizedString("Note ID Index", comment: "Database name for note ID index")
|
||||
case .profileKey:
|
||||
return NSLocalizedString("Profile Key Index", comment: "Database name for profile key index")
|
||||
case .ndbMeta:
|
||||
return NSLocalizedString("NostrDB Metadata", comment: "Database name for NostrDB metadata")
|
||||
case .profileSearch:
|
||||
return NSLocalizedString("Profile Search Index", comment: "Database name for profile search")
|
||||
case .profileLastFetch:
|
||||
return NSLocalizedString("Profile Last Fetch", comment: "Database name for profile last fetch")
|
||||
case .noteKind:
|
||||
return NSLocalizedString("Note Kind Index", comment: "Database name for note kind index")
|
||||
case .noteText:
|
||||
return NSLocalizedString("Note Text Index", comment: "Database name for note text index")
|
||||
case .noteBlocks:
|
||||
return NSLocalizedString("Note Blocks", comment: "Database name for note blocks")
|
||||
case .noteTags:
|
||||
return NSLocalizedString("Note Tags Index", comment: "Database name for note tags index")
|
||||
case .notePubkey:
|
||||
return NSLocalizedString("Note Pubkey Index", comment: "Database name for note pubkey index")
|
||||
case .notePubkeyKind:
|
||||
return NSLocalizedString("Note Pubkey+Kind Index", comment: "Database name for note pubkey+kind index")
|
||||
case .noteRelayKind:
|
||||
return NSLocalizedString("Note Relay+Kind Index", comment: "Database name for note relay+kind index")
|
||||
case .noteRelays:
|
||||
return NSLocalizedString("Note Relays", comment: "Database name for note relays")
|
||||
case .other:
|
||||
return NSLocalizedString("Other Data", comment: "Database name for other/unaccounted data")
|
||||
}
|
||||
}
|
||||
|
||||
/// SF Symbol icon name for this database type
|
||||
var icon: String {
|
||||
switch self {
|
||||
case .note:
|
||||
return "text.bubble.fill"
|
||||
case .profile:
|
||||
return "person.circle.fill"
|
||||
case .meta, .ndbMeta:
|
||||
return "info.circle.fill"
|
||||
case .noteBlocks:
|
||||
return "square.stack.3d.up.fill"
|
||||
case .noteId, .profileKey, .profileSearch, .noteKind, .noteText, .noteTags, .notePubkey, .notePubkeyKind, .noteRelayKind:
|
||||
return "list.bullet.indent"
|
||||
case .noteRelays:
|
||||
return "antenna.radiowaves.left.and.right"
|
||||
case .profileLastFetch, .other:
|
||||
return "internaldrive.fill"
|
||||
}
|
||||
}
|
||||
|
||||
/// Color for chart and UI display
|
||||
var color: Color {
|
||||
switch self {
|
||||
case .note:
|
||||
return .green
|
||||
case .profile:
|
||||
return .blue
|
||||
case .noteBlocks:
|
||||
return .purple
|
||||
case .meta, .ndbMeta:
|
||||
return .orange
|
||||
case .noteId, .profileKey, .profileSearch, .noteKind, .noteText, .noteTags, .notePubkey, .notePubkeyKind, .noteRelayKind:
|
||||
return .gray
|
||||
case .noteRelays:
|
||||
return .cyan
|
||||
case .profileLastFetch, .other:
|
||||
return .secondary
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user