From f8cfed248ad1e6d8d5f3fd06bda72c180996b078 Mon Sep 17 00:00:00 2001 From: Jack Date: Mon, 23 Mar 2026 09:57:10 +0800 Subject: [PATCH 1/3] Filter internal test user events in Sentry beforeSend --- Readmigo/Core/Services/CrashTrackingService.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Readmigo/Core/Services/CrashTrackingService.swift b/Readmigo/Core/Services/CrashTrackingService.swift index 9ca8447..53e6f7e 100644 --- a/Readmigo/Core/Services/CrashTrackingService.swift +++ b/Readmigo/Core/Services/CrashTrackingService.swift @@ -81,8 +81,13 @@ class CrashTrackingService: ObservableObject { options.releaseName = "com.readmigo.ios@\(version)+\(build)" } - // Filter PII before sending + // Filter PII and internal users before sending options.beforeSend = { event in + // Drop events from internal test users + if event.tags?["is_internal"] == "true" { + return nil + } + // Remove email from user data for privacy if var user = event.user { user.email = nil From 55576fddc1bf1ac2987b7866e09675cf74ee387b Mon Sep 17 00:00:00 2001 From: Jack Date: Mon, 23 Mar 2026 10:08:12 +0800 Subject: [PATCH 2/3] feat: add bookshelf tab with folder organization and batch management - Add new Bookshelf tab (position 1) between Bookstore and Audiobook - GRDB-backed persistence with folders and items tables (migration v5) - Grid/list display modes with sort options (manual, recent, title, author) - Folder creation, rename, delete with cascading item cleanup - Batch select, move, and remove operations - Add-to-bookshelf button on BookDetailView action bar - Context menus for move-to-folder and remove actions - Empty state with tutorial tip cards - Deep link support for bookshelf:// URL scheme - Update tab indices: 0=Bookstore, 1=Bookshelf, 2=Audiobook, 3=Me - Localized in 14 languages (en, zh-Hans, zh-Hant, ja, ko, es, fr, de, pt, ru, ar, id, tr, uk) --- Readmigo.xcodeproj/project.pbxproj | 88 + Readmigo/App/ContentView.swift | 14 +- Readmigo/Core/Database/DAO/BookshelfDAO.swift | 154 + Readmigo/Core/Database/Migrations.swift | 24 + .../Database/Records/BookshelfRecord.swift | 45 + Readmigo/Core/Navigation/DeepLinkRouter.swift | 15 +- Readmigo/Core/Network/APIEndpoints.swift | 8 + Readmigo/Core/Services/BookshelfManager.swift | 407 +++ .../Bookshelf/BookshelfEmptyView.swift | 81 + .../Bookshelf/BookshelfFolderView.swift | 211 ++ .../Features/Bookshelf/BookshelfView.swift | 399 +++ .../Components/BookshelfFolderCard.swift | 50 + .../Components/BookshelfGridItem.swift | 68 + .../Components/BookshelfListItem.swift | 83 + .../Components/BookshelfMoveSheet.swift | 44 + .../Features/Library/BookDetailView.swift | 20 + Readmigo/Localizable.xcstrings | 2720 ++++++++++++++++- 17 files changed, 4403 insertions(+), 28 deletions(-) create mode 100644 Readmigo/Core/Database/DAO/BookshelfDAO.swift create mode 100644 Readmigo/Core/Database/Records/BookshelfRecord.swift create mode 100644 Readmigo/Core/Services/BookshelfManager.swift create mode 100644 Readmigo/Features/Bookshelf/BookshelfEmptyView.swift create mode 100644 Readmigo/Features/Bookshelf/BookshelfFolderView.swift create mode 100644 Readmigo/Features/Bookshelf/BookshelfView.swift create mode 100644 Readmigo/Features/Bookshelf/Components/BookshelfFolderCard.swift create mode 100644 Readmigo/Features/Bookshelf/Components/BookshelfGridItem.swift create mode 100644 Readmigo/Features/Bookshelf/Components/BookshelfListItem.swift create mode 100644 Readmigo/Features/Bookshelf/Components/BookshelfMoveSheet.swift diff --git a/Readmigo.xcodeproj/project.pbxproj b/Readmigo.xcodeproj/project.pbxproj index 536aea3..c0dfa7e 100644 --- a/Readmigo.xcodeproj/project.pbxproj +++ b/Readmigo.xcodeproj/project.pbxproj @@ -9,9 +9,12 @@ /* Begin PBXBuildFile section */ 003DC4DFA3684E7FA7C6D708 /* OpenDyslexic-Italic.otf in Resources */ = {isa = PBXBuildFile; fileRef = EFCDA96DE9DF1BFE967B0764 /* OpenDyslexic-Italic.otf */; }; 00878721385740388471FB5E /* SubscriptionStatusView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F9BF73E63CD4185B711A0CA /* SubscriptionStatusView.swift */; }; + 0135D1C678A94A0690A74A1C /* BookshelfDAO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F56E38ACA4A4123B28EF0BF /* BookshelfDAO.swift */; }; 01CB326E2BA25BD35FA0FC64 /* MessagingService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A6C4D78DC55C353A0369799 /* MessagingService.swift */; }; 01D956CDC2724E10A5E00215 /* Analytics.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0884DD34B14423AA1C8C735 /* Analytics.swift */; }; + 02274C97ED0998139484AABC /* BookshelfEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 500C1BBCA8342FFC282C2E2F /* BookshelfEmptyView.swift */; }; 035D67E8C94279DBECE792BC /* APIClient+Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FD595241C4774B14642BA91 /* APIClient+Search.swift */; }; + 048611112AB7416D94A349CC /* BookshelfMoveSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4198939FB2E942B8ADC6CD45 /* BookshelfMoveSheet.swift */; }; 0607A1A9B3154666FF988CC8 /* Series.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0BA84C7880E456EC6C8F194D /* Series.swift */; }; 079B5FE9EF4F74A514EC33DE /* MessageListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6ACAF4458E09D0A0DCEE473D /* MessageListView.swift */; }; 08FAEC9ECAB30622D10DB42F /* SocialMediaAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = C147EC0D678654EAF9654E09 /* SocialMediaAccount.swift */; }; @@ -22,6 +25,7 @@ 0F4C6F4FF04349B2A992F8BE /* Timeline.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75FCB76B75C431394350535 /* Timeline.swift */; }; 1078FCEF83C863AF40962481 /* AnnualReportManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2E1F31952633CB5418C2578 /* AnnualReportManager.swift */; }; 10FABD4B8CA34CC61DD33EA8 /* NotificationPermissionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A95515E1F0ACA36D518CAFCA /* NotificationPermissionView.swift */; }; + 12238872C2031737CF06D1B3 /* BookshelfView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3622B48540DE318F8B408160 /* BookshelfView.swift */; }; 125DF4D409A7A0D085051BDC /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7E728C0BFCE00F39AC19B42 /* Message.swift */; }; 1313C869F673410AB340FF0E /* StatsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7EE87A86AEF4463962ED726 /* StatsView.swift */; }; 1558C13224FF34F7B04FD633 /* BookCoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44B8B57408DF12E866524E6A /* BookCoverView.swift */; }; @@ -46,6 +50,7 @@ 26A2400450C133D04364B451 /* AtkinsonHyperlegibleNext-Italic-Variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = AFE584C9C0BF1E109E056614 /* AtkinsonHyperlegibleNext-Italic-Variable.ttf */; }; 26B23702B2E4A84097D0E239 /* IBMPlexSerif-BoldItalic.ttf in Resources */ = {isa = PBXBuildFile; fileRef = B39392CB2C98BCC8C0DEACA8 /* IBMPlexSerif-BoldItalic.ttf */; }; 26F0BB026A7E4559B3A3ECB8 /* Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 15C160E924B64481875607A7 /* Search.swift */; }; + 27E94A52557740D8A892015A /* BookshelfFolderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAF2FBA134DE40AFB9A7982A /* BookshelfFolderView.swift */; }; 28FAE6840C79A1B35179FED6 /* MessageListViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CFDCA9E6976D165DA35114A /* MessageListViewModel.swift */; }; 28FF258C83D52A4689BA6495 /* LyricsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5CF14FD9D17935A191D4FF2 /* LyricsSettings.swift */; }; 2B25165E4E153FD166D59808 /* NewMessageViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 475C0139B930322ACFB46126 /* NewMessageViewModel.swift */; }; @@ -60,6 +65,8 @@ 329DF0D57FAE28FE1B5C3D4D /* IBMPlexSerif-Bold.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 3C1272D779C4DB540D015E15 /* IBMPlexSerif-Bold.ttf */; }; 3348257A6CEC4F60A50721B7 /* Book.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F7C0532FAB342189E615C37 /* Book.swift */; }; 34D03B2C3BE271CEE063AB5B /* ReadAloudCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = E48F823AEBB78C467F51D48F /* ReadAloudCoordinator.swift */; }; + 35B7F2697E5531DC0406807C /* BookshelfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4E673EF1C73D79E74E63DDCA /* BookshelfManager.swift */; }; + 372C53A53E0C1944D265FF5B /* BookshelfListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1B150FE9506314685AC78CE4 /* BookshelfListItem.swift */; }; 373DC1EA45C085E7447F7F29 /* Changelog.swift in Sources */ = {isa = PBXBuildFile; fileRef = CC22DF32A4F3DB35FECB093E /* Changelog.swift */; }; 38FF544FE67FADBFDDAC1C2E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 521A2602E2BA0D534608FA8D /* Foundation.framework */; }; 3A228FABC1A35CAA543A31FD /* AudiobookManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B3C1720AA72CC226E53A19 /* AudiobookManager.swift */; }; @@ -126,6 +133,7 @@ 7E29A51694525AA250419971 /* AnnualReport.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8BE4346D27DA89CCF585F44 /* AnnualReport.swift */; }; 7E76B1408E394B259D3C4B92 /* Bitter-Variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 12B4A585060930C9E5FF62CB /* Bitter-Variable.ttf */; }; 7F0F33614A61E11C75175073 /* APIClient+Series.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2764353618D040B51C53E88B /* APIClient+Series.swift */; }; + 7FCFFECE353241C6A45C0551 /* BookshelfManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = D24DCD259C304930B781D5D6 /* BookshelfManager.swift */; }; 7FE74922F7274897B4276518 /* PaywallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 437B356CA2BF43FEBF8A2220 /* PaywallView.swift */; }; 7FFAA2C7E3E24C68B8B01421 /* SearchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EA5B6844D3464ACDABA43D1D /* SearchView.swift */; }; 802D4CA4317DAC32CE0031C1 /* BookstoreTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF5F5DCFE3AD0F25288D3E31 /* BookstoreTab.swift */; }; @@ -139,6 +147,8 @@ 8761AE854B834A76AC5B2631 /* BadgesManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F846FA76076420E96889286 /* BadgesManager.swift */; }; 8A4B3215FFBB5D3269DBE988 /* CancelSubscriptionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20B0B05193727B605337E86 /* CancelSubscriptionView.swift */; }; 8C0FBD4EB5CAE97B5BCD2892 /* LanguageSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 598A6256D730889E4166E61D /* LanguageSettingsView.swift */; }; + 8DCB3478B34B416F9B3FEBC3 /* BookshelfEmptyView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E7A22EDA915F4B4DAE380309 /* BookshelfEmptyView.swift */; }; + 9020CA9B21E9A0C5D195FB98 /* BookshelfDAO.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E43489F60CCDEBFFCF1F3AC /* BookshelfDAO.swift */; }; 91C158E019F9B6E6D1FC1A63 /* Bitter-Italic-Variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 789FC39859F2EB1DE6EDEE08 /* Bitter-Italic-Variable.ttf */; }; 938B1025658EAB2ED101E0B0 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4D25C6A54EFE9568EE6E1C4 /* OnboardingView.swift */; }; 947A9891143D44E9807AC555 /* Log.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33E1228114AD476AAE406C6A /* Log.swift */; }; @@ -146,14 +156,17 @@ 979AB9B1B68A4315BA38DC57 /* BadgeDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = CBFB87B92C844E6CBFE00052 /* BadgeDetailView.swift */; }; 97D8D1210FE616107F8532DD /* UniversityBookCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 845B2B76C37B3FA2FD7F557E /* UniversityBookCard.swift */; }; 98A81E228E3FC1B62127F75A /* PersonalizationPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E36BA655DFEB93CEA64267D3 /* PersonalizationPageView.swift */; }; + 9A649F8DD3D35D4B5CE460B1 /* BookshelfGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 16CDDEE90D81643AD9EBF8C3 /* BookshelfGridItem.swift */; }; 9B5E58200C871E81C8CE4369 /* MeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8452BA094337402B087C3ECB /* MeView.swift */; }; 9B7CAFEA76E09B786D18EEDE /* JetBrainsMono-Variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = A48B61CEC502E2D93010207A /* JetBrainsMono-Variable.ttf */; }; 9BABE89057AE4D8E96BE39E0 /* FlowLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8DD56E2821C643BC9605AC1E /* FlowLayout.swift */; }; + 9E7F5130A0CA4E078A6F41F5 /* BookshelfListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A306C6F06D84D5493D197EA /* BookshelfListItem.swift */; }; A13FE1C004E301AC1DA30F62 /* Comment.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9D073F1C70BFA234BA2A9DE /* Comment.swift */; }; A2433703CC0016B477C67C64 /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 521A2602E2BA0D534608FA8D /* Foundation.framework */; }; A24F87C02CF54FF5AD12CCFA /* TTSEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A4F8E1B86F64347B0BBAE6A /* TTSEngine.swift */; }; A37684D672C482D3469E8DF1 /* OpenSourceLicensesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 57FC0B6090DB696F723B2B43 /* OpenSourceLicensesView.swift */; }; A4A655561C0ED861A1FDE06B /* LocalizationManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5AEB77081BEE68BAA7DAFB2 /* LocalizationManager.swift */; }; + A6ECE96D164CCB08C65295E5 /* BookshelfFolderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BA919FD84D1C8A627A8A37AB /* BookshelfFolderView.swift */; }; A74F61F629574CCEA8839558 /* QuoteCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 240BBDF2FD6D4EF7A33C1847 /* QuoteCardView.swift */; }; A813E197D3C18F9B0D487654 /* ReadAloud.swift in Sources */ = {isa = PBXBuildFile; fileRef = C337ECBA384DD96247D95491 /* ReadAloud.swift */; }; A8AF0F9999E34A67BE5EC0B3 /* OfflineManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = BD3968A08BB74DF4971AE3EF /* OfflineManager.swift */; }; @@ -180,6 +193,7 @@ B0F8A4E3E60CD463E996D5C6 /* AttachmentPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A65E403D090EAEFDA9EC7599 /* AttachmentPickerView.swift */; }; B1B3FC9093C4675292F92782 /* AcknowledgmentsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FEB53D75A0E637B6A954E426 /* AcknowledgmentsView.swift */; }; B2D99CF11A00DF3263BE62A9 /* SleepTimerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = FECEED5FDED4369884F1E6F6 /* SleepTimerService.swift */; }; + B42F18C1387B1ECE09831A65 /* BookshelfRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 730AE47870CB7D1FF776D214 /* BookshelfRecord.swift */; }; B4DB96383767299F1A7064A7 /* AuthError.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA241D865E14727419588401 /* AuthError.swift */; }; B4FDF4FBE6E24066AFA8F114 /* RestorePurchasesView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 93ABC85CCF314257AB2F22CB /* RestorePurchasesView.swift */; }; B8505934FD38A48AFBA5A7EA /* HeroBookCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = EFBF1806F7866C813E229047 /* HeroBookCard.swift */; }; @@ -202,7 +216,9 @@ C3330DA9FC2E2FB29A54B89A /* AppInfoHeaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 32B06571A1A8B4C8578E8DC8 /* AppInfoHeaderView.swift */; }; C55B635273D217104DFBF46B /* AppInfo.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0E19DF551FA96A915A80D16 /* AppInfo.swift */; }; C6B2E2381DED46838A77AB51 /* EpubReaderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C944CD74D9041FC83CDB39E /* EpubReaderView.swift */; }; + C6BB944D3D1B4F0D8471FD14 /* BookshelfFolderCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6F8F92AE135842ACAF4C6B5C /* BookshelfFolderCard.swift */; }; C701E29592DBA8723291F41E /* NewMessageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 162A3DE6F1590ABC1AC8244B /* NewMessageView.swift */; }; + C7CB74263B31E4AFA65829A0 /* BookshelfMoveSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 14EA43A925F181E13FE5B249 /* BookshelfMoveSheet.swift */; }; C8691E1FB8751C735D783264 /* GoalSettingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1A7FA63C50B189CC4CA85FF /* GoalSettingView.swift */; }; C93ED8F1225987228B8A4AC7 /* AdaptiveGrid.swift in Sources */ = {isa = PBXBuildFile; fileRef = 971CA1D2A3C07A8A38F3A075 /* AdaptiveGrid.swift */; }; CAB542768D08345B1873A2B7 /* Attachment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 53CBE26B417D9F2A274551EC /* Attachment.swift */; }; @@ -227,6 +243,7 @@ D45318C9EE7E2AD7D2E0120B /* FeedbackRating.swift in Sources */ = {isa = PBXBuildFile; fileRef = 062CBDA8D0339E0BE7AB0F5B /* FeedbackRating.swift */; }; D5091C743CD5EE9CC94D1374 /* LyricsSettingsSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = E140C1E12C5596721B362BF9 /* LyricsSettingsSheet.swift */; }; D51D13561CAD4A45B3779DCB /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1F7B95C2BED2464DA23BC600 /* ContentView.swift */; }; + D5288DA572954179A72A81AF /* BookshelfRecord.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F07F08ED8E145BD870BEC95 /* BookshelfRecord.swift */; }; D58EF94D2D34B3BFDD646F34 /* CoverPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A8D65E38B8D42F6F3972C66 /* CoverPageView.swift */; }; D8CC4FF9429556C9B9FCFFF0 /* AudioLimitReachedSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6D620A98B8B967542B8389E6 /* AudioLimitReachedSheet.swift */; }; D96144592A5744D1B88B62F7 /* SearchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 79EB64B96B6E4D2A97E2DC48 /* SearchManager.swift */; }; @@ -266,6 +283,7 @@ DC1CCD4E0EA8947E72535DFB /* UsageTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 471FA6459DF4E862F7BB152B /* UsageTracker.swift */; }; DC873F0A31F518CF6D7C2F8B /* ChangelogView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ECA1AF9D5BEF78C591864100 /* ChangelogView.swift */; }; DC8A14A08B2B48D6980B0020 /* Reading.swift in Sources */ = {isa = PBXBuildFile; fileRef = 96ED261DEBD94879889B638B /* Reading.swift */; }; + DCC8DF13F32A42D8A388A3BC /* BookshelfGridItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4A33C08B38E74B8196722538 /* BookshelfGridItem.swift */; }; DD72FBE8CB88DA406083855D /* RankingPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA0967C04BF71FD0B5611FB2 /* RankingPageView.swift */; }; DDC50E671980AF828D34873A /* DeveloperToolsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD131130CF4826F322C846B /* DeveloperToolsView.swift */; }; DF000000000000000000002 /* DurationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = DF000000000000000000001 /* DurationFormatter.swift */; }; @@ -275,6 +293,7 @@ E0B6FD69362DE75BD06ED05A /* Literata-Variable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 6E64077484C710E4D8BB7A40 /* Literata-Variable.ttf */; }; E0CA148DD3E16989537BB83B /* MessageTypePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 69AC663A2F762FE8EA7E45C1 /* MessageTypePickerView.swift */; }; E18E957DDDFB438990E9B55C /* AnalyticsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 46E94CDCA23145D785E816B8 /* AnalyticsManager.swift */; }; + E23F5DFEDB08FA370961FF91 /* BookshelfFolderCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 069196983BF81FF70A87B53A /* BookshelfFolderCard.swift */; }; E2D673058E748A76CCA80078 /* Danmaku.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D1CAE0770DF601FDEF4FC63 /* Danmaku.swift */; }; E406DF214E3474AA8C839D7D /* BrowseBooksView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6DC6BB095211F96C7BE967FE /* BrowseBooksView.swift */; }; E41A8B003D7E3FFB62EF306E /* AudioCacheManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D2F00F9F25C8336E12997E8 /* AudioCacheManager.swift */; }; @@ -303,6 +322,7 @@ F0104C86537F450DA36EF698 /* OfflineSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E62AFC40BDB146A391C50640 /* OfflineSettingsView.swift */; }; F14F88DAB4D945919870343D /* ReaderViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48910A3E08F4C63AF13901B /* ReaderViewModel.swift */; }; F233E1EB4C0D089ABFA2F5E7 /* NotificationServiceExtension.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = B6A865E0EEDCD9CEAA424CAA /* NotificationServiceExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F2861984417348CC97C5206F /* BookshelfView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 446FBEB1523E4A1FAA327AB7 /* BookshelfView.swift */; }; F4B080C84843361E4F59CD6B /* MailComposerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 786053E8E0F4CF64497B8621 /* MailComposerView.swift */; }; F565DF6B1D93513982C395CE /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 521A2602E2BA0D534608FA8D /* Foundation.framework */; }; F6E5CAF12FC43E4543100978 /* ThoughtDetailSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0CC4E63F9FA244C2DF7BCAB0 /* ThoughtDetailSheet.swift */; }; @@ -399,6 +419,7 @@ 02C854AB41184957B6689999 /* LoggingService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoggingService.swift; sourceTree = ""; }; 045D96FCBFB44951B9B1DF4B /* BadgesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgesView.swift; sourceTree = ""; }; 062CBDA8D0339E0BE7AB0F5B /* FeedbackRating.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = FeedbackRating.swift; path = Messaging/Models/FeedbackRating.swift; sourceTree = ""; }; + 069196983BF81FF70A87B53A /* BookshelfFolderCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookshelfFolderCard.swift; sourceTree = ""; }; 0A15F4B3DF61459AB4ED967B /* Readmigo.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Readmigo.app; sourceTree = BUILT_PRODUCTS_DIR; }; 0A4F8E1B86F64347B0BBAE6A /* TTSEngine.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSEngine.swift; sourceTree = ""; }; 0BA84C7880E456EC6C8F194D /* Series.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = Series.swift; sourceTree = ""; }; @@ -410,11 +431,14 @@ 1057405F6540C02833791593 /* SocialMediaListView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = SocialMediaListView.swift; path = About/Views/SocialMediaListView.swift; sourceTree = ""; }; 12B4A585060930C9E5FF62CB /* Bitter-Variable.ttf */ = {isa = PBXFileReference; includeInIndex = 1; name = "Bitter-Variable.ttf"; path = "Bitter-Variable.ttf"; sourceTree = ""; }; 14A487EE24A7B222E119126D /* FAQ.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FAQ.swift; sourceTree = ""; }; + 14EA43A925F181E13FE5B249 /* BookshelfMoveSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookshelfMoveSheet.swift; sourceTree = ""; }; 15C160E924B64481875607A7 /* Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search.swift; sourceTree = ""; }; 162A3DE6F1590ABC1AC8244B /* NewMessageView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = NewMessageView.swift; path = Messaging/Views/NewMessageView.swift; sourceTree = ""; }; + 16CDDEE90D81643AD9EBF8C3 /* BookshelfGridItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookshelfGridItem.swift; sourceTree = ""; }; 1738E643F12A151D5FF88290 /* AppLaunchHelper.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppLaunchHelper.swift; path = ReadmigoUITests/Helpers/AppLaunchHelper.swift; sourceTree = ""; }; 199790DAE5CCBCB18D443CDB /* CategoryCascadeSelector.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = CategoryCascadeSelector.swift; sourceTree = ""; }; 1A34284A0E5CA25744E1C2B0 /* SourceSerif4-Italic.ttf */ = {isa = PBXFileReference; includeInIndex = 1; name = "SourceSerif4-Italic.ttf"; path = "SourceSerif4-Italic.ttf"; sourceTree = ""; }; + 1B150FE9506314685AC78CE4 /* BookshelfListItem.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookshelfListItem.swift; sourceTree = ""; }; 1B865FFB8B6A7F6B2FDDAFA7 /* ChapterTextProvider.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ChapterTextProvider.swift; sourceTree = ""; }; 1BFD37C361A3DB5B2F343200 /* GridBookCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GridBookCard.swift; sourceTree = ""; }; 1E9145A7B0CC0E7579DAFB4A /* BooksPageView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BooksPageView.swift; sourceTree = ""; }; @@ -434,6 +458,7 @@ 32B06571A1A8B4C8578E8DC8 /* AppInfoHeaderView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AppInfoHeaderView.swift; path = About/Views/AppInfoHeaderView.swift; sourceTree = ""; }; 33E1228114AD476AAE406C6A /* Log.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Log.swift; sourceTree = ""; }; 356A07DD6D50103DE38C8708 /* SeriesDetailView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SeriesDetailView.swift; sourceTree = ""; }; + 3622B48540DE318F8B408160 /* BookshelfView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookshelfView.swift; sourceTree = ""; }; 38FD340FE8D848008E50C22D /* CategoriesView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CategoriesView.swift; sourceTree = ""; }; 3962C99C40F88074DBEDA628 /* LevelAssessmentView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LevelAssessmentView.swift; sourceTree = ""; }; 39FCD0BC6979D4EA973B78BD /* ProfileEditView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = ProfileEditView.swift; sourceTree = ""; }; @@ -442,8 +467,10 @@ 3DB882A7A89146ACBFE3B404 /* LibraryView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LibraryView.swift; sourceTree = ""; }; 3F6630997D8892D5FADCB1FB /* AgoraPostCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AgoraPostCard.swift; sourceTree = ""; }; 3F671F0737B9F8A6481A73D2 /* EmailAuthView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = EmailAuthView.swift; sourceTree = ""; }; + 4198939FB2E942B8ADC6CD45 /* BookshelfMoveSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookshelfMoveSheet.swift; sourceTree = ""; }; 42298AF6C0D51C10F2E756EA /* AppReviewManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppReviewManager.swift; sourceTree = ""; }; 437B356CA2BF43FEBF8A2220 /* PaywallView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PaywallView.swift; sourceTree = ""; }; + 446FBEB1523E4A1FAA327AB7 /* BookshelfView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookshelfView.swift; sourceTree = ""; }; 44B8B57408DF12E866524E6A /* BookCoverView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookCoverView.swift; sourceTree = ""; }; 4673E1A494534C88A0EBE46D /* ReadingProgressView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ReadingProgressView.swift; sourceTree = ""; }; 46AF443E1EB88328829DF2A0 /* TTSSentence.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TTSSentence.swift; sourceTree = ""; }; @@ -455,6 +482,8 @@ 4860B6417EB417E7052A78F0 /* FAQ.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = FAQ.swift; sourceTree = ""; }; 4A0F23BB99EEC0148796BB32 /* MessageThreadView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MessageThreadView.swift; path = Messaging/Views/MessageThreadView.swift; sourceTree = ""; }; 4A225FABD9A3F774DB19EE32 /* AnnualReportView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AnnualReportView.swift; sourceTree = ""; }; + 4A306C6F06D84D5493D197EA /* BookshelfListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookshelfListItem.swift; sourceTree = ""; }; + 4A33C08B38E74B8196722538 /* BookshelfGridItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookshelfGridItem.swift; sourceTree = ""; }; 4A3EB4183DE343EAB8027D8F /* QuotesManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotesManager.swift; sourceTree = ""; }; 4A3EC139A1FD7DA63C17AA06 /* AboutView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = AboutView.swift; path = About/Views/AboutView.swift; sourceTree = ""; }; 4A894193E961E2D82B01E795 /* WelcomeStepView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = WelcomeStepView.swift; sourceTree = ""; }; @@ -465,7 +494,9 @@ 4DA09E1A4EB54B65B9DD8B16 /* AuthManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AuthManager.swift; sourceTree = ""; }; 4DCFC9270DC246729FE8C69E /* BookstoreBannerCarousel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookstoreBannerCarousel.swift; sourceTree = ""; }; 4E2372747CFCF1759C608E0A /* StandardBookCard.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StandardBookCard.swift; sourceTree = ""; }; + 4E673EF1C73D79E74E63DDCA /* BookshelfManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookshelfManager.swift; sourceTree = ""; }; 4F9BF73E63CD4185B711A0CA /* SubscriptionStatusView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionStatusView.swift; sourceTree = ""; }; + 500C1BBCA8342FFC282C2E2F /* BookshelfEmptyView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookshelfEmptyView.swift; sourceTree = ""; }; 521A2602E2BA0D534608FA8D /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS18.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; 53CBE26B417D9F2A274551EC /* Attachment.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = Attachment.swift; path = Messaging/Models/Attachment.swift; sourceTree = ""; }; 57E86E2F7CEFD946777C5184 /* NotificationService.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NotificationService.swift; sourceTree = ""; }; @@ -492,10 +523,14 @@ 6ACEDDA9939B439991E5BF71 /* BadgeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BadgeCardView.swift; sourceTree = ""; }; 6D620A98B8B967542B8389E6 /* AudioLimitReachedSheet.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudioLimitReachedSheet.swift; sourceTree = ""; }; 6DC6BB095211F96C7BE967FE /* BrowseBooksView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BrowseBooksView.swift; sourceTree = ""; }; + 6E43489F60CCDEBFFCF1F3AC /* BookshelfDAO.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookshelfDAO.swift; sourceTree = ""; }; 6E64077484C710E4D8BB7A40 /* Literata-Variable.ttf */ = {isa = PBXFileReference; includeInIndex = 1; name = "Literata-Variable.ttf"; path = "Literata-Variable.ttf"; sourceTree = ""; }; 6EED35D10855BCC8AAD520DA /* DeviceInfo.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DeviceInfo.swift; path = About/Models/DeviceInfo.swift; sourceTree = ""; }; + 6F56E38ACA4A4123B28EF0BF /* BookshelfDAO.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookshelfDAO.swift; sourceTree = ""; }; + 6F8F92AE135842ACAF4C6B5C /* BookshelfFolderCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookshelfFolderCard.swift; sourceTree = ""; }; 6FC1A145DE48CA0C76336D1E /* BookRowView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookRowView.swift; sourceTree = ""; }; 7031E432E77144419E09A5EB /* BookList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookList.swift; sourceTree = ""; }; + 730AE47870CB7D1FF776D214 /* BookshelfRecord.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookshelfRecord.swift; sourceTree = ""; }; 763DE3F4D147A370E363B8F7 /* NavigationTracker.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = NavigationTracker.swift; sourceTree = ""; }; 786053E8E0F4CF64497B8621 /* MailComposerView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MailComposerView.swift; path = About/Views/MailComposerView.swift; sourceTree = ""; }; 789FC39859F2EB1DE6EDEE08 /* Bitter-Italic-Variable.ttf */ = {isa = PBXFileReference; includeInIndex = 1; name = "Bitter-Italic-Variable.ttf"; path = "Bitter-Italic-Variable.ttf"; sourceTree = ""; }; @@ -522,6 +557,7 @@ 8B01AAAF7E11461194D46564 /* ContentCache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentCache.swift; sourceTree = ""; }; 8CFDCA9E6976D165DA35114A /* MessageListViewModel.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = MessageListViewModel.swift; path = Messaging/ViewModels/MessageListViewModel.swift; sourceTree = ""; }; 8DD56E2821C643BC9605AC1E /* FlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FlowLayout.swift; sourceTree = ""; }; + 8F07F08ED8E145BD870BEC95 /* BookshelfRecord.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookshelfRecord.swift; sourceTree = ""; }; 8FEB24C47F5A7AB06B1336EF /* TTSContentExtractor.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TTSContentExtractor.swift; sourceTree = ""; }; 904B7F7F8F3C4394937865DB /* TTSControlView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TTSControlView.swift; sourceTree = ""; }; 90DA6B37725372D7DE29434A /* DatabaseMigrationTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; name = DatabaseMigrationTests.swift; path = ReadmigoTests/Core/Database/DatabaseMigrationTests.swift; sourceTree = SOURCE_ROOT; }; @@ -585,6 +621,8 @@ B6A865E0EEDCD9CEAA424CAA /* NotificationServiceExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = NotificationServiceExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; B8BE4346D27DA89CCF585F44 /* AnnualReport.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AnnualReport.swift; sourceTree = ""; }; B93969D94CB88FBBF04AC31B /* AudiobookSearchView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AudiobookSearchView.swift; sourceTree = ""; }; + BA919FD84D1C8A627A8A37AB /* BookshelfFolderView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookshelfFolderView.swift; sourceTree = ""; }; + BAF2FBA134DE40AFB9A7982A /* BookshelfFolderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookshelfFolderView.swift; sourceTree = ""; }; BBA79139BE264731547F6795 /* ReadmigoUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; name = ReadmigoUITests.xctest; path = .xctest; sourceTree = BUILT_PRODUCTS_DIR; }; BC000000000000000000002 /* BrandColors.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrandColors.swift; sourceTree = ""; }; BC1234567890ABCDEF123456 /* BookContextSection.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = BookContextSection.swift; sourceTree = ""; }; @@ -621,6 +659,7 @@ D0884DD34B14423AA1C8C735 /* Analytics.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Analytics.swift; sourceTree = ""; }; D0C6B91B27A7ED09FA92370B /* JetBrainsMono-Italic-Variable.ttf */ = {isa = PBXFileReference; includeInIndex = 1; name = "JetBrainsMono-Italic-Variable.ttf"; path = "JetBrainsMono-Italic-Variable.ttf"; sourceTree = ""; }; D1A7FA63C50B189CC4CA85FF /* GoalSettingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = GoalSettingView.swift; sourceTree = ""; }; + D24DCD259C304930B781D5D6 /* BookshelfManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookshelfManager.swift; sourceTree = ""; }; D2E1F31952633CB5418C2578 /* AnnualReportManager.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AnnualReportManager.swift; sourceTree = ""; }; D5ABE580472B52A655076730 /* TextSelectionMenu.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TextSelectionMenu.swift; sourceTree = ""; }; D5D0457C6EDD4BFE949C1CA2 /* BookListDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookListDetailView.swift; sourceTree = ""; }; @@ -672,6 +711,7 @@ E5CF14FD9D17935A191D4FF2 /* LyricsSettings.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LyricsSettings.swift; sourceTree = ""; }; E62AFC40BDB146A391C50640 /* OfflineSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OfflineSettingsView.swift; sourceTree = ""; }; E6E311C7AAB74B2897E7986D /* BookListsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookListsManager.swift; sourceTree = ""; }; + E7A22EDA915F4B4DAE380309 /* BookshelfEmptyView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BookshelfEmptyView.swift; sourceTree = ""; }; E81AAA912EF7A96500CEED34 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; E81AAA932EF7B9E100CEED34 /* LaunchScreen.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = LaunchScreen.storyboard; sourceTree = ""; }; E82B8EB12EFECCE8005A9A2D /* AppConfigManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppConfigManager.swift; sourceTree = ""; }; @@ -835,6 +875,7 @@ NOTIF000000000000000001 /* Notifications */, 596ADB8A7E2A6C74B227A331 /* Series */, SHRCARD0000000000000009 /* ShareCard */, + 50BE8E44C9FF035EA3EFE3BC /* Bookshelf */, ); path = Features; sourceTree = ""; @@ -982,6 +1023,18 @@ path = Library; sourceTree = ""; }; + 3E00C6A1F714E2BF7A08BD07 /* Components */ = { + isa = PBXGroup; + children = ( + 16CDDEE90D81643AD9EBF8C3 /* BookshelfGridItem.swift */, + 1B150FE9506314685AC78CE4 /* BookshelfListItem.swift */, + 069196983BF81FF70A87B53A /* BookshelfFolderCard.swift */, + 14EA43A925F181E13FE5B249 /* BookshelfMoveSheet.swift */, + ); + name = Components; + path = Components; + sourceTree = ""; + }; 4311801F48AE441CB48A4B12 /* Profile */ = { isa = PBXGroup; children = ( @@ -1046,6 +1099,18 @@ path = PageTurn; sourceTree = ""; }; + 50BE8E44C9FF035EA3EFE3BC /* Bookshelf */ = { + isa = PBXGroup; + children = ( + 3622B48540DE318F8B408160 /* BookshelfView.swift */, + 500C1BBCA8342FFC282C2E2F /* BookshelfEmptyView.swift */, + BA919FD84D1C8A627A8A37AB /* BookshelfFolderView.swift */, + 3E00C6A1F714E2BF7A08BD07 /* Components */, + ); + name = Bookshelf; + path = Bookshelf; + sourceTree = ""; + }; 5326347C82DBC757C7FCA3F1 /* Integration */ = { isa = PBXGroup; children = ( @@ -1175,6 +1240,7 @@ FECEED5FDED4369884F1E6F6 /* SleepTimerService.swift */, 326B94D8BBB9F1A9F47260FB /* NowPlayingService.swift */, D70CA32C7C5B4E74BC1719BC /* AudioSessionService.swift */, + 4E673EF1C73D79E74E63DDCA /* BookshelfManager.swift */, ); path = Services; sourceTree = ""; @@ -1627,6 +1693,7 @@ DB_DAO_0000000000000109 /* ReadingPositionDAO.swift */, DB_DAO_000000000000010A /* SyncQueueDAO.swift */, DB_DAO_000000000000010B /* CacheDAO.swift */, + 6E43489F60CCDEBFFCF1F3AC /* BookshelfDAO.swift */, ); path = DAO; sourceTree = ""; @@ -1648,6 +1715,7 @@ DB_REC_000000000000010B /* BookCacheRecord.swift */, DB_REC_000000000000010C /* ChapterCacheRecord.swift */, DB_REC_000000000000010D /* ApiCacheRecord.swift */, + 730AE47870CB7D1FF776D214 /* BookshelfRecord.swift */, ); path = Records; sourceTree = ""; @@ -2461,6 +2529,16 @@ 749F60F46BEA0A0BAFF4A681 /* AudioSessionService.swift in Sources */, 650D38151F640843733ED1A8 /* FreeBookBannerView.swift in Sources */, 10FABD4B8CA34CC61DD33EA8 /* NotificationPermissionView.swift in Sources */, + B42F18C1387B1ECE09831A65 /* BookshelfRecord.swift in Sources */, + 9020CA9B21E9A0C5D195FB98 /* BookshelfDAO.swift in Sources */, + 35B7F2697E5531DC0406807C /* BookshelfManager.swift in Sources */, + 12238872C2031737CF06D1B3 /* BookshelfView.swift in Sources */, + 02274C97ED0998139484AABC /* BookshelfEmptyView.swift in Sources */, + A6ECE96D164CCB08C65295E5 /* BookshelfFolderView.swift in Sources */, + 9A649F8DD3D35D4B5CE460B1 /* BookshelfGridItem.swift in Sources */, + 372C53A53E0C1944D265FF5B /* BookshelfListItem.swift in Sources */, + E23F5DFEDB08FA370961FF91 /* BookshelfFolderCard.swift in Sources */, + C7CB74263B31E4AFA65829A0 /* BookshelfMoveSheet.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -2478,6 +2556,16 @@ files = ( EBF592338A534196667EAB99 /* AppLaunchHelper.swift in Sources */, 4F57F6AEA7C49EA796CEB857 /* LibraryUITests.swift in Sources */, + D5288DA572954179A72A81AF /* BookshelfRecord.swift in Sources */, + 0135D1C678A94A0690A74A1C /* BookshelfDAO.swift in Sources */, + 7FCFFECE353241C6A45C0551 /* BookshelfManager.swift in Sources */, + F2861984417348CC97C5206F /* BookshelfView.swift in Sources */, + 8DCB3478B34B416F9B3FEBC3 /* BookshelfEmptyView.swift in Sources */, + 27E94A52557740D8A892015A /* BookshelfFolderView.swift in Sources */, + DCC8DF13F32A42D8A388A3BC /* BookshelfGridItem.swift in Sources */, + 9E7F5130A0CA4E078A6F41F5 /* BookshelfListItem.swift in Sources */, + C6BB944D3D1B4F0D8471FD14 /* BookshelfFolderCard.swift in Sources */, + 048611112AB7416D94A349CC /* BookshelfMoveSheet.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Readmigo/App/ContentView.swift b/Readmigo/App/ContentView.swift index c69afbc..ca86cad 100644 --- a/Readmigo/App/ContentView.swift +++ b/Readmigo/App/ContentView.swift @@ -80,17 +80,24 @@ struct MainTabView: View { } .tag(0) + BookshelfView() + .environmentObject(libraryManager) + .tabItem { + Label("tab.bookshelf".localized, systemImage: "books.vertical") + } + .tag(1) + AudiobookListView() .tabItem { Label("tab.audiobook".localized, systemImage: "headphones") } - .tag(1) + .tag(2) MeView() .tabItem { Label("tab.me".localized, systemImage: "person.circle") } - .tag(2) + .tag(3) .badge(notificationService.unreadCount) } @@ -125,6 +132,8 @@ struct MainTabView: View { case 0: NotificationCenter.default.post(name: .bookstoreTabDoubleTapped, object: nil) case 1: + NotificationCenter.default.post(name: .bookshelfTabDoubleTapped, object: nil) + case 2: NotificationCenter.default.post(name: .audiobookTabDoubleTapped, object: nil) default: break @@ -211,6 +220,7 @@ struct MiniPlayerBar: View { extension Notification.Name { static let bookstoreTabDoubleTapped = Notification.Name("bookstoreTabDoubleTapped") + static let bookshelfTabDoubleTapped = Notification.Name("bookshelfTabDoubleTapped") static let audiobookTabDoubleTapped = Notification.Name("audiobookTabDoubleTapped") static let agoraTabDoubleTapped = Notification.Name("agoraTabDoubleTapped") static let fullScreenCoverPresented = Notification.Name("fullScreenCoverPresented") diff --git a/Readmigo/Core/Database/DAO/BookshelfDAO.swift b/Readmigo/Core/Database/DAO/BookshelfDAO.swift new file mode 100644 index 0000000..5a850ec --- /dev/null +++ b/Readmigo/Core/Database/DAO/BookshelfDAO.swift @@ -0,0 +1,154 @@ +import GRDB +import Foundation + +struct BookshelfDAO { + let dbPool: DatabasePool + + // MARK: - Folders + + func getAllFolders() throws -> [BookshelfFolderRecord] { + try dbPool.read { db in + try BookshelfFolderRecord + .order(Column("sort_order").asc) + .fetchAll(db) + } + } + + func getFolder(id: String) throws -> BookshelfFolderRecord? { + try dbPool.read { db in + try BookshelfFolderRecord.fetchOne(db, key: id) + } + } + + func upsertFolder(_ record: BookshelfFolderRecord) throws { + try dbPool.write { db in + try record.save(db) + } + } + + func deleteFolder(id: String) throws { + try dbPool.write { db in + // Delete all items in this folder first + try BookshelfItemRecord + .filter(Column("folder_id") == id) + .deleteAll(db) + // Delete the folder + _ = try BookshelfFolderRecord.deleteOne(db, key: id) + } + } + + func getMaxFolderSortOrder() throws -> Int { + try dbPool.read { db in + let row = try Row.fetchOne(db, sql: "SELECT MAX(sort_order) FROM bookshelf_folders") + return row?[0] as? Int ?? -1 + } + } + + // MARK: - Items + + func getAllItems() throws -> [BookshelfItemRecord] { + try dbPool.read { db in + try BookshelfItemRecord + .order(Column("sort_order").asc) + .fetchAll(db) + } + } + + func getItems(folderId: String?) throws -> [BookshelfItemRecord] { + try dbPool.read { db in + if let folderId = folderId { + return try BookshelfItemRecord + .filter(Column("folder_id") == folderId) + .order(Column("sort_order").asc) + .fetchAll(db) + } else { + return try BookshelfItemRecord + .filter(Column("folder_id") == nil) + .order(Column("sort_order").asc) + .fetchAll(db) + } + } + } + + func getItem(bookId: String) throws -> BookshelfItemRecord? { + try dbPool.read { db in + try BookshelfItemRecord + .filter(Column("book_id") == bookId) + .fetchOne(db) + } + } + + func upsertItem(_ record: BookshelfItemRecord) throws { + try dbPool.write { db in + try record.save(db) + } + } + + func deleteItem(id: String) throws { + try dbPool.write { db in + _ = try BookshelfItemRecord.deleteOne(db, key: id) + } + } + + func deleteItem(bookId: String) throws { + try dbPool.write { db in + try BookshelfItemRecord + .filter(Column("book_id") == bookId) + .deleteAll(db) + } + } + + func getMaxItemSortOrder(folderId: String?) throws -> Int { + try dbPool.read { db in + if let folderId = folderId { + let row = try Row.fetchOne(db, sql: "SELECT MAX(sort_order) FROM bookshelf_items WHERE folder_id = ?", arguments: [folderId]) + return row?[0] as? Int ?? -1 + } else { + let row = try Row.fetchOne(db, sql: "SELECT MAX(sort_order) FROM bookshelf_items WHERE folder_id IS NULL") + return row?[0] as? Int ?? -1 + } + } + } + + func moveItem(bookId: String, toFolderId: String?) throws { + try dbPool.write { db in + guard var item = try BookshelfItemRecord + .filter(Column("book_id") == bookId) + .fetchOne(db) else { return } + + item.folderId = toFolderId + + // Get max sort order in destination + let maxOrder: Int + if let folderId = toFolderId { + let row = try Row.fetchOne(db, sql: "SELECT MAX(sort_order) FROM bookshelf_items WHERE folder_id = ?", arguments: [folderId]) + maxOrder = (row?[0] as? Int ?? -1) + 1 + } else { + let row = try Row.fetchOne(db, sql: "SELECT MAX(sort_order) FROM bookshelf_items WHERE folder_id IS NULL") + maxOrder = (row?[0] as? Int ?? -1) + 1 + } + item.sortOrder = maxOrder + + try item.save(db) + } + } + + func itemCount(folderId: String?) throws -> Int { + try dbPool.read { db in + if let folderId = folderId { + return try BookshelfItemRecord + .filter(Column("folder_id") == folderId) + .fetchCount(db) + } else { + return try BookshelfItemRecord.fetchCount(db) + } + } + } + + func deleteAll() throws { + try dbPool.write { db in + try BookshelfItemRecord.deleteAll(db) + try BookshelfFolderRecord.deleteAll(db) + } + } +} diff --git a/Readmigo/Core/Database/Migrations.swift b/Readmigo/Core/Database/Migrations.swift index 0f034b6..bf2fcac 100644 --- a/Readmigo/Core/Database/Migrations.swift +++ b/Readmigo/Core/Database/Migrations.swift @@ -255,5 +255,29 @@ enum Migrations { t.add(column: "paragraph_index", .integer).notNull().defaults(to: 0) } } + + migrator.registerMigration("v5_bookshelf") { db in + try db.create(table: "bookshelf_folders") { t in + t.primaryKey("id", .text) + t.column("name", .text).notNull() + t.column("icon", .text) + t.column("color", .text) + t.column("sort_order", .integer).notNull().defaults(to: 0) + t.column("created_at", .integer).notNull() + t.column("updated_at", .integer).notNull() + } + try db.create(index: "idx_bookshelf_folders_order", on: "bookshelf_folders", columns: ["sort_order"]) + + try db.create(table: "bookshelf_items") { t in + t.primaryKey("id", .text) + t.column("folder_id", .text) + .references("bookshelf_folders", onDelete: .cascade) + t.column("book_id", .text).notNull() + t.column("sort_order", .integer).notNull().defaults(to: 0) + t.column("added_at", .integer).notNull() + } + try db.create(index: "idx_bookshelf_items_folder", on: "bookshelf_items", columns: ["folder_id"]) + try db.create(index: "idx_bookshelf_items_book", on: "bookshelf_items", columns: ["book_id"], unique: true) + } } } diff --git a/Readmigo/Core/Database/Records/BookshelfRecord.swift b/Readmigo/Core/Database/Records/BookshelfRecord.swift new file mode 100644 index 0000000..239db98 --- /dev/null +++ b/Readmigo/Core/Database/Records/BookshelfRecord.swift @@ -0,0 +1,45 @@ +import GRDB +import Foundation + +// MARK: - Bookshelf Folder Record + +struct BookshelfFolderRecord: Codable, FetchableRecord, PersistableRecord { + static let databaseTableName = "bookshelf_folders" + static let persistenceConflictPolicy = PersistenceConflictPolicy(insert: .replace, update: .replace) + + var id: String + var name: String + var icon: String? + var color: String? + var sortOrder: Int + var createdAt: Int64 + var updatedAt: Int64 + + enum CodingKeys: String, CodingKey { + case id, name, icon, color + case sortOrder = "sort_order" + case createdAt = "created_at" + case updatedAt = "updated_at" + } +} + +// MARK: - Bookshelf Item Record + +struct BookshelfItemRecord: Codable, FetchableRecord, PersistableRecord { + static let databaseTableName = "bookshelf_items" + static let persistenceConflictPolicy = PersistenceConflictPolicy(insert: .replace, update: .replace) + + var id: String + var folderId: String? + var bookId: String + var sortOrder: Int + var addedAt: Int64 + + enum CodingKeys: String, CodingKey { + case id + case folderId = "folder_id" + case bookId = "book_id" + case sortOrder = "sort_order" + case addedAt = "added_at" + } +} diff --git a/Readmigo/Core/Navigation/DeepLinkRouter.swift b/Readmigo/Core/Navigation/DeepLinkRouter.swift index 23094c4..13ccfc0 100644 --- a/Readmigo/Core/Navigation/DeepLinkRouter.swift +++ b/Readmigo/Core/Navigation/DeepLinkRouter.swift @@ -8,8 +8,9 @@ enum DeepLinkDestination: Equatable { case bookDetail(bookId: String) // Audiobook case audiobook(bookId: String) - // Library + // Library / Bookshelf case library + case bookshelf // Discover case discover // Vocabulary Review @@ -46,7 +47,7 @@ class DeepLinkRouter: ObservableObject { static let shared = DeepLinkRouter() @Published var pendingDestination: DeepLinkDestination? - @Published var selectedTab: Int = 0 // 0=Bookstore, 1=Audiobook, 2=Me + @Published var selectedTab: Int = 0 // 0=Bookstore, 1=Bookshelf, 2=Audiobook, 3=Me private init() {} @@ -70,6 +71,8 @@ class DeepLinkRouter: ObservableObject { return .audiobook(bookId: bookId) case "library": return .library + case "bookshelf": + return .bookshelf case "discover": return .discover case "review": @@ -206,11 +209,13 @@ class DeepLinkRouter: ObservableObject { switch destination { case .discover, .bookDetail, .bookList: selectedTab = 0 - case .audiobook: + case .library, .bookshelf: selectedTab = 1 - case .library, .stats, .weeklyReport, .subscription, .subscriptionStatus, - .accountDevices, .notificationSettings, .downloads, .medal: + case .audiobook: selectedTab = 2 + case .stats, .weeklyReport, .subscription, .subscriptionStatus, + .accountDevices, .notificationSettings, .downloads, .medal: + selectedTab = 3 default: break // Don't change tab for other destinations } diff --git a/Readmigo/Core/Network/APIEndpoints.swift b/Readmigo/Core/Network/APIEndpoints.swift index 335a0d2..af81548 100644 --- a/Readmigo/Core/Network/APIEndpoints.swift +++ b/Readmigo/Core/Network/APIEndpoints.swift @@ -264,6 +264,14 @@ enum APIEndpoints { "/audiobooks/\(audiobookId)/chapters/\(chapterNumber)/danmaku" } + // Bookshelf + static let bookshelf = "/bookshelf" + static let bookshelfFolders = "/bookshelf/folders" + static func bookshelfFolder(_ id: String) -> String { "/bookshelf/folders/\(id)" } + static let bookshelfItems = "/bookshelf/items" + static func bookshelfItem(_ bookId: String) -> String { "/bookshelf/items/\(bookId)" } + static let bookshelfSync = "/bookshelf/sync" + // Notifications static let notifications = "/notifications" static let notificationsUnreadCount = "/notifications/unread-count" diff --git a/Readmigo/Core/Services/BookshelfManager.swift b/Readmigo/Core/Services/BookshelfManager.swift new file mode 100644 index 0000000..f415fbc --- /dev/null +++ b/Readmigo/Core/Services/BookshelfManager.swift @@ -0,0 +1,407 @@ +import Foundation +import SwiftUI + +// MARK: - Bookshelf Data Models + +/// A folder in the bookshelf +struct BookshelfFolder: Identifiable, Equatable { + let id: String + var name: String + var icon: String? + var color: String? + var sortOrder: Int + let createdAt: Date + var updatedAt: Date + var bookCount: Int = 0 + + static func == (lhs: BookshelfFolder, rhs: BookshelfFolder) -> Bool { + lhs.id == rhs.id + } +} + +/// A book item on the bookshelf (with associated book data) +struct BookshelfItem: Identifiable, Equatable { + let id: String + let folderId: String? + let bookId: String + var sortOrder: Int + let addedAt: Date + var book: Book? + + static func == (lhs: BookshelfItem, rhs: BookshelfItem) -> Bool { + lhs.id == rhs.id + } +} + +/// Sort options for bookshelf +enum BookshelfSortOption: String, CaseIterable { + case manual = "MANUAL" + case recentlyAdded = "RECENTLY_ADDED" + case title = "TITLE" + case author = "AUTHOR" + + var displayName: String { + switch self { + case .manual: return "bookshelf.sort.manual".localized + case .recentlyAdded: return "bookshelf.sort.recentlyAdded".localized + case .title: return "bookshelf.sort.title".localized + case .author: return "bookshelf.sort.author".localized + } + } + + var icon: String { + switch self { + case .manual: return "hand.draw" + case .recentlyAdded: return "clock" + case .title: return "textformat" + case .author: return "person" + } + } +} + +/// Display mode for bookshelf +enum BookshelfDisplayMode: String { + case grid + case list +} + +// MARK: - BookshelfManager + +@MainActor +class BookshelfManager: ObservableObject { + static let shared = BookshelfManager() + + // MARK: - Published Properties + + @Published private(set) var folders: [BookshelfFolder] = [] + @Published private(set) var rootItems: [BookshelfItem] = [] + @Published var isLoading = false + @Published var sortOption: BookshelfSortOption = .manual { + didSet { + UserDefaults.standard.set(sortOption.rawValue, forKey: sortOptionKey) + applySorting() + } + } + @Published var displayMode: BookshelfDisplayMode = .grid { + didSet { + UserDefaults.standard.set(displayMode.rawValue, forKey: displayModeKey) + } + } + + // MARK: - Private Properties + + private let dao: BookshelfDAO + private let sortOptionKey = "bookshelfSortOption" + private let displayModeKey = "bookshelfDisplayMode" + + // MARK: - Computed Properties + + var isEmpty: Bool { + folders.isEmpty && rootItems.isEmpty + } + + var totalBookCount: Int { + do { + return try dao.itemCount(folderId: nil) + } catch { + return 0 + } + } + + // MARK: - Initialization + + private init() { + dao = BookshelfDAO(dbPool: DatabaseManager.shared.dbPool) + + // Restore preferences + if let savedSort = UserDefaults.standard.string(forKey: sortOptionKey), + let option = BookshelfSortOption(rawValue: savedSort) { + sortOption = option + } + if let savedMode = UserDefaults.standard.string(forKey: displayModeKey), + let mode = BookshelfDisplayMode(rawValue: savedMode) { + displayMode = mode + } + + loadFromDatabase() + } + + // MARK: - Data Loading + + private func loadFromDatabase() { + do { + // Load folders + let folderRecords = try dao.getAllFolders() + folders = folderRecords.map { record in + var folder = BookshelfFolder( + id: record.id, + name: record.name, + icon: record.icon, + color: record.color, + sortOrder: record.sortOrder, + createdAt: Date(timeIntervalSince1970: Double(record.createdAt) / 1000.0), + updatedAt: Date(timeIntervalSince1970: Double(record.updatedAt) / 1000.0) + ) + folder.bookCount = (try? dao.itemCount(folderId: record.id)) ?? 0 + return folder + } + + // Load root items (not in any folder) + let itemRecords = try dao.getItems(folderId: nil) + rootItems = itemRecords.map { record in + BookshelfItem( + id: record.id, + folderId: record.folderId, + bookId: record.bookId, + sortOrder: record.sortOrder, + addedAt: Date(timeIntervalSince1970: Double(record.addedAt) / 1000.0), + book: nil + ) + } + + applySorting() + + LoggingService.shared.info(.books, "Loaded bookshelf: \(folders.count) folders, \(rootItems.count) root items", component: "BookshelfManager") + } catch { + LoggingService.shared.error(.db, "Failed to load bookshelf: \(error)", component: "BookshelfManager") + } + } + + /// Fetch book data for all items from the API + func fetchBookData() async { + let allBookIds = rootItems.map { $0.bookId } + guard !allBookIds.isEmpty else { return } + + isLoading = true + defer { isLoading = false } + + for index in rootItems.indices { + let bookId = rootItems[index].bookId + do { + let bookDetail: BookDetail = try await APIClient.shared.request( + endpoint: APIEndpoints.bookDetail(bookId) + ) + rootItems[index].book = bookDetail.book + } catch { + LoggingService.shared.debug(.books, "Failed to fetch book \(bookId): \(error)", component: "BookshelfManager") + } + } + + applySorting() + } + + /// Fetch book data for items in a specific folder + func fetchBookData(forFolder folderId: String) async -> [BookshelfItem] { + do { + let records = try dao.getItems(folderId: folderId) + var items = records.map { record in + BookshelfItem( + id: record.id, + folderId: record.folderId, + bookId: record.bookId, + sortOrder: record.sortOrder, + addedAt: Date(timeIntervalSince1970: Double(record.addedAt) / 1000.0), + book: nil + ) + } + + for index in items.indices { + let bookId = items[index].bookId + do { + let bookDetail: BookDetail = try await APIClient.shared.request( + endpoint: APIEndpoints.bookDetail(bookId) + ) + items[index].book = bookDetail.book + } catch { + LoggingService.shared.debug(.books, "Failed to fetch book \(bookId): \(error)", component: "BookshelfManager") + } + } + + return sortItems(items) + } catch { + LoggingService.shared.error(.db, "Failed to get folder items: \(error)", component: "BookshelfManager") + return [] + } + } + + // MARK: - Folder Operations + + func createFolder(name: String, icon: String? = nil, color: String? = nil) { + let now = Int64(Date().timeIntervalSince1970 * 1000) + let maxOrder = (try? dao.getMaxFolderSortOrder()) ?? -1 + + let record = BookshelfFolderRecord( + id: UUID().uuidString, + name: name, + icon: icon, + color: color, + sortOrder: maxOrder + 1, + createdAt: now, + updatedAt: now + ) + + do { + try dao.upsertFolder(record) + loadFromDatabase() + LoggingService.shared.info(.books, "Created folder: \(name)", component: "BookshelfManager") + } catch { + LoggingService.shared.error(.db, "Failed to create folder: \(error)", component: "BookshelfManager") + } + } + + func renameFolder(id: String, newName: String) { + guard var folder = folders.first(where: { $0.id == id }) else { return } + + let now = Int64(Date().timeIntervalSince1970 * 1000) + let record = BookshelfFolderRecord( + id: id, + name: newName, + icon: folder.icon, + color: folder.color, + sortOrder: folder.sortOrder, + createdAt: Int64(folder.createdAt.timeIntervalSince1970 * 1000), + updatedAt: now + ) + + do { + try dao.upsertFolder(record) + loadFromDatabase() + } catch { + LoggingService.shared.error(.db, "Failed to rename folder: \(error)", component: "BookshelfManager") + } + } + + func deleteFolder(id: String) { + do { + try dao.deleteFolder(id: id) + loadFromDatabase() + LoggingService.shared.info(.books, "Deleted folder: \(id)", component: "BookshelfManager") + } catch { + LoggingService.shared.error(.db, "Failed to delete folder: \(error)", component: "BookshelfManager") + } + } + + // MARK: - Item Operations + + /// Add a book to the bookshelf (root level or specific folder) + func addBook(bookId: String, book: Book? = nil, folderId: String? = nil) { + // Check if already on bookshelf + if let existing = try? dao.getItem(bookId: bookId) { + LoggingService.shared.debug(.books, "Book \(bookId) already on bookshelf", component: "BookshelfManager") + return + } + + let now = Int64(Date().timeIntervalSince1970 * 1000) + let maxOrder = (try? dao.getMaxItemSortOrder(folderId: folderId)) ?? -1 + + let record = BookshelfItemRecord( + id: UUID().uuidString, + folderId: folderId, + bookId: bookId, + sortOrder: maxOrder + 1, + addedAt: now + ) + + do { + try dao.upsertItem(record) + loadFromDatabase() + + // Set book data immediately if available + if let book = book, folderId == nil { + if let index = rootItems.firstIndex(where: { $0.bookId == bookId }) { + rootItems[index].book = book + } + } + + LoggingService.shared.info(.books, "Added book to bookshelf: \(bookId)", component: "BookshelfManager") + } catch { + LoggingService.shared.error(.db, "Failed to add book: \(error)", component: "BookshelfManager") + } + } + + /// Remove a book from the bookshelf + func removeBook(bookId: String) { + do { + try dao.deleteItem(bookId: bookId) + loadFromDatabase() + LoggingService.shared.info(.books, "Removed book from bookshelf: \(bookId)", component: "BookshelfManager") + } catch { + LoggingService.shared.error(.db, "Failed to remove book: \(error)", component: "BookshelfManager") + } + } + + /// Move a book to a different folder + func moveBook(bookId: String, toFolderId: String?) { + do { + try dao.moveItem(bookId: bookId, toFolderId: toFolderId) + loadFromDatabase() + LoggingService.shared.info(.books, "Moved book \(bookId) to folder \(toFolderId ?? "root")", component: "BookshelfManager") + } catch { + LoggingService.shared.error(.db, "Failed to move book: \(error)", component: "BookshelfManager") + } + } + + /// Check if a book is on the bookshelf + func isOnBookshelf(bookId: String) -> Bool { + (try? dao.getItem(bookId: bookId)) != nil + } + + /// Toggle book on/off bookshelf + func toggleBookshelf(bookId: String, book: Book? = nil) { + if isOnBookshelf(bookId: bookId) { + removeBook(bookId: bookId) + } else { + addBook(bookId: bookId, book: book) + } + } + + // MARK: - Batch Operations + + func batchRemove(bookIds: [String]) { + for bookId in bookIds { + try? dao.deleteItem(bookId: bookId) + } + loadFromDatabase() + LoggingService.shared.info(.books, "Batch removed \(bookIds.count) books", component: "BookshelfManager") + } + + func batchMove(bookIds: [String], toFolderId: String?) { + for bookId in bookIds { + try? dao.moveItem(bookId: bookId, toFolderId: toFolderId) + } + loadFromDatabase() + LoggingService.shared.info(.books, "Batch moved \(bookIds.count) books to \(toFolderId ?? "root")", component: "BookshelfManager") + } + + // MARK: - Sorting + + private func applySorting() { + rootItems = sortItems(rootItems) + } + + private func sortItems(_ items: [BookshelfItem]) -> [BookshelfItem] { + switch sortOption { + case .manual: + return items.sorted { $0.sortOrder < $1.sortOrder } + case .recentlyAdded: + return items.sorted { $0.addedAt > $1.addedAt } + case .title: + return items.sorted { ($0.book?.localizedTitle ?? "") < ($1.book?.localizedTitle ?? "") } + case .author: + return items.sorted { ($0.book?.localizedAuthor ?? "") < ($1.book?.localizedAuthor ?? "") } + } + } + + // MARK: - Cleanup + + func handleLogout() { + do { + try dao.deleteAll() + folders = [] + rootItems = [] + LoggingService.shared.info(.books, "Cleared bookshelf on logout", component: "BookshelfManager") + } catch { + LoggingService.shared.error(.db, "Failed to clear bookshelf: \(error)", component: "BookshelfManager") + } + } +} diff --git a/Readmigo/Features/Bookshelf/BookshelfEmptyView.swift b/Readmigo/Features/Bookshelf/BookshelfEmptyView.swift new file mode 100644 index 0000000..0e707b4 --- /dev/null +++ b/Readmigo/Features/Bookshelf/BookshelfEmptyView.swift @@ -0,0 +1,81 @@ +import SwiftUI + +struct BookshelfEmptyView: View { + var body: some View { + VStack(spacing: 24) { + Spacer() + + Image(systemName: "books.vertical") + .font(.system(size: 56)) + .foregroundColor(.secondary.opacity(0.5)) + + VStack(spacing: 8) { + Text("bookshelf.empty.title".localized) + .font(.title3) + .fontWeight(.semibold) + + Text("bookshelf.empty.subtitle".localized) + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) + } + + // Tutorial cards + VStack(spacing: 12) { + BookshelfTipCard( + icon: "plus.circle", + title: "bookshelf.tip.add.title".localized, + description: "bookshelf.tip.add.description".localized + ) + + BookshelfTipCard( + icon: "folder.badge.plus", + title: "bookshelf.tip.folder.title".localized, + description: "bookshelf.tip.folder.description".localized + ) + + BookshelfTipCard( + icon: "arrow.up.arrow.down", + title: "bookshelf.tip.sort.title".localized, + description: "bookshelf.tip.sort.description".localized + ) + } + .padding(.horizontal, 24) + + Spacer() + } + } +} + +// MARK: - Tip Card + +struct BookshelfTipCard: View { + let icon: String + let title: String + let description: String + + var body: some View { + HStack(spacing: 12) { + Image(systemName: icon) + .font(.title3) + .foregroundColor(.accentColor) + .frame(width: 32) + + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.subheadline) + .fontWeight(.medium) + + Text(description) + .font(.caption) + .foregroundColor(.secondary) + } + + Spacer() + } + .padding(12) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} diff --git a/Readmigo/Features/Bookshelf/BookshelfFolderView.swift b/Readmigo/Features/Bookshelf/BookshelfFolderView.swift new file mode 100644 index 0000000..86d5bec --- /dev/null +++ b/Readmigo/Features/Bookshelf/BookshelfFolderView.swift @@ -0,0 +1,211 @@ +import SwiftUI + +struct BookshelfFolderView: View { + let folder: BookshelfFolder + @StateObject private var bookshelfManager = BookshelfManager.shared + @EnvironmentObject var libraryManager: LibraryManager + @EnvironmentObject var authManager: AuthManager + @State private var items: [BookshelfItem] = [] + @State private var isLoading = true + @State private var selectedBookForDetail: Book? + @State private var isEditMode = false + @State private var selectedItems: Set = [] + @State private var showingMoveSheet = false + + var body: some View { + Group { + if isLoading { + ProgressView() + } else if items.isEmpty { + VStack(spacing: 12) { + Image(systemName: "folder") + .font(.system(size: 40)) + .foregroundColor(.secondary.opacity(0.5)) + + Text("bookshelf.folder.empty".localized) + .font(.subheadline) + .foregroundColor(.secondary) + } + } else { + ScrollView { + VStack(spacing: 16) { + if isEditMode { + folderEditToolbar + } + + if bookshelfManager.displayMode == .grid { + folderGridView + } else { + folderListView + } + } + .padding(.horizontal) + } + } + } + .navigationTitle(folder.name) + .navigationBarTitleDisplayMode(.large) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Button { + withAnimation { + isEditMode.toggle() + if !isEditMode { + selectedItems.removeAll() + } + } + } label: { + Text(isEditMode ? "common.done".localized : "bookshelf.edit".localized) + } + } + } + .sheet(isPresented: $showingMoveSheet) { + BookshelfMoveSheet( + folders: bookshelfManager.folders.filter { $0.id != folder.id }, + selectedBookIds: Array(selectedItems) + ) { targetFolderId in + bookshelfManager.batchMove(bookIds: Array(selectedItems), toFolderId: targetFolderId) + selectedItems.removeAll() + isEditMode = false + Task { + items = await bookshelfManager.fetchBookData(forFolder: folder.id) + } + } + } + .fullScreenCover(item: $selectedBookForDetail) { book in + NavigationStack { + BookDetailView(book: book, presentedAsFullScreen: true) + .environmentObject(libraryManager) + .environmentObject(authManager) + } + } + .task { + items = await bookshelfManager.fetchBookData(forFolder: folder.id) + isLoading = false + } + } + + // MARK: - Edit Toolbar + + private var folderEditToolbar: some View { + HStack { + Button { + if selectedItems.count == items.count { + selectedItems.removeAll() + } else { + selectedItems = Set(items.map { $0.bookId }) + } + } label: { + Text(selectedItems.count == items.count + ? "bookshelf.deselectAll".localized + : "bookshelf.selectAll".localized) + .font(.subheadline) + } + + Spacer() + + if !selectedItems.isEmpty { + Text(String(format: "bookshelf.selectedCount".localized, selectedItems.count)) + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + + Button { + showingMoveSheet = true + } label: { + Image(systemName: "folder") + } + + Button(role: .destructive) { + bookshelfManager.batchRemove(bookIds: Array(selectedItems)) + selectedItems.removeAll() + isEditMode = false + Task { + items = await bookshelfManager.fetchBookData(forFolder: folder.id) + } + } label: { + Image(systemName: "trash") + } + } + } + .padding(.vertical, 8) + } + + // MARK: - Grid View + + private var folderGridView: some View { + LazyVGrid(columns: [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ], spacing: 16) { + ForEach(items) { item in + BookshelfGridItem( + item: item, + isEditMode: isEditMode, + isSelected: selectedItems.contains(item.bookId) + ) { + if isEditMode { + toggleSelection(item.bookId) + } else if let book = item.book { + selectedBookForDetail = book + } + } + .contextMenu { + if !isEditMode { + Button(role: .destructive) { + bookshelfManager.removeBook(bookId: item.bookId) + items.removeAll { $0.bookId == item.bookId } + } label: { + Label("bookshelf.remove".localized, systemImage: "minus.circle") + } + } + } + } + } + } + + // MARK: - List View + + private var folderListView: some View { + LazyVStack(spacing: 0) { + ForEach(items) { item in + BookshelfListItem( + item: item, + isEditMode: isEditMode, + isSelected: selectedItems.contains(item.bookId) + ) { + if isEditMode { + toggleSelection(item.bookId) + } else if let book = item.book { + selectedBookForDetail = book + } + } + .contextMenu { + if !isEditMode { + Button(role: .destructive) { + bookshelfManager.removeBook(bookId: item.bookId) + items.removeAll { $0.bookId == item.bookId } + } label: { + Label("bookshelf.remove".localized, systemImage: "minus.circle") + } + } + } + + if item.id != items.last?.id { + Divider() + .padding(.leading, 76) + } + } + } + } + + private func toggleSelection(_ bookId: String) { + if selectedItems.contains(bookId) { + selectedItems.remove(bookId) + } else { + selectedItems.insert(bookId) + } + } +} diff --git a/Readmigo/Features/Bookshelf/BookshelfView.swift b/Readmigo/Features/Bookshelf/BookshelfView.swift new file mode 100644 index 0000000..d37eaef --- /dev/null +++ b/Readmigo/Features/Bookshelf/BookshelfView.swift @@ -0,0 +1,399 @@ +import SwiftUI +import Kingfisher + +struct BookshelfView: View { + @StateObject private var bookshelfManager = BookshelfManager.shared + @EnvironmentObject var libraryManager: LibraryManager + @EnvironmentObject var authManager: AuthManager + @State private var showingNewFolder = false + @State private var newFolderName = "" + @State private var searchText = "" + @State private var selectedBookForDetail: Book? + @State private var isEditMode = false + @State private var selectedItems: Set = [] + @State private var showingMoveSheet = false + + // Scroll-to-top on tab double-tap + @State private var scrollToTopTrigger = false + + private var filteredItems: [BookshelfItem] { + guard !searchText.isEmpty else { return bookshelfManager.rootItems } + return bookshelfManager.rootItems.filter { item in + guard let book = item.book else { return false } + return book.localizedTitle.localizedCaseInsensitiveContains(searchText) || + book.localizedAuthor.localizedCaseInsensitiveContains(searchText) + } + } + + private var filteredFolders: [BookshelfFolder] { + guard !searchText.isEmpty else { return bookshelfManager.folders } + return bookshelfManager.folders.filter { folder in + folder.name.localizedCaseInsensitiveContains(searchText) + } + } + + var body: some View { + NavigationStack { + Group { + if bookshelfManager.isEmpty && !bookshelfManager.isLoading { + BookshelfEmptyView() + } else { + bookshelfContent + } + } + .navigationTitle("tab.bookshelf".localized) + .searchable(text: $searchText, prompt: "bookshelf.search".localized) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + // Display mode toggle + Section { + Button { + withAnimation { + bookshelfManager.displayMode = bookshelfManager.displayMode == .grid ? .list : .grid + } + } label: { + Label( + bookshelfManager.displayMode == .grid + ? "bookshelf.viewAsList".localized + : "bookshelf.viewAsGrid".localized, + systemImage: bookshelfManager.displayMode == .grid + ? "list.bullet" + : "square.grid.2x2" + ) + } + } + + // Sort options + Section("bookshelf.sortBy".localized) { + ForEach(BookshelfSortOption.allCases, id: \.self) { option in + Button { + bookshelfManager.sortOption = option + } label: { + Label { + Text(option.displayName) + } icon: { + if bookshelfManager.sortOption == option { + Image(systemName: "checkmark") + } else { + Image(systemName: option.icon) + } + } + } + } + } + + // Edit mode + Section { + Button { + withAnimation { + isEditMode.toggle() + if !isEditMode { + selectedItems.removeAll() + } + } + } label: { + Label( + isEditMode ? "common.done".localized : "bookshelf.edit".localized, + systemImage: isEditMode ? "checkmark.circle" : "pencil" + ) + } + } + + // New folder + Section { + Button { + showingNewFolder = true + } label: { + Label("bookshelf.newFolder".localized, systemImage: "folder.badge.plus") + } + } + } label: { + Image(systemName: "ellipsis.circle") + } + } + } + .alert("bookshelf.newFolder".localized, isPresented: $showingNewFolder) { + TextField("bookshelf.folderName".localized, text: $newFolderName) + Button("common.cancel".localized, role: .cancel) { + newFolderName = "" + } + Button("common.create".localized) { + if !newFolderName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { + bookshelfManager.createFolder(name: newFolderName.trimmingCharacters(in: .whitespacesAndNewlines)) + newFolderName = "" + } + } + } + .sheet(isPresented: $showingMoveSheet) { + BookshelfMoveSheet( + folders: bookshelfManager.folders, + selectedBookIds: Array(selectedItems) + ) { targetFolderId in + bookshelfManager.batchMove(bookIds: Array(selectedItems), toFolderId: targetFolderId) + selectedItems.removeAll() + isEditMode = false + } + } + .fullScreenCover(item: $selectedBookForDetail) { book in + NavigationStack { + BookDetailView(book: book, presentedAsFullScreen: true) + .environmentObject(libraryManager) + .environmentObject(authManager) + } + } + .onReceive(NotificationCenter.default.publisher(for: .bookshelfTabDoubleTapped)) { _ in + scrollToTopTrigger.toggle() + } + .task { + await bookshelfManager.fetchBookData() + } + } + } + + // MARK: - Bookshelf Content + + @ViewBuilder + private var bookshelfContent: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(spacing: 16) { + // Edit mode toolbar + if isEditMode { + editModeToolbar + } + + // Folders section + if !filteredFolders.isEmpty { + foldersSection + } + + // Books section + if !filteredItems.isEmpty { + booksSection + } + + // Loading indicator + if bookshelfManager.isLoading { + ProgressView() + .padding() + } + } + .padding(.horizontal) + .id("bookshelfTop") + } + .onChange(of: scrollToTopTrigger) { _, _ in + withAnimation { + proxy.scrollTo("bookshelfTop", anchor: .top) + } + } + } + } + + // MARK: - Edit Mode Toolbar + + private var editModeToolbar: some View { + HStack { + Button { + if selectedItems.count == filteredItems.count { + selectedItems.removeAll() + } else { + selectedItems = Set(filteredItems.map { $0.bookId }) + } + } label: { + Text(selectedItems.count == filteredItems.count + ? "bookshelf.deselectAll".localized + : "bookshelf.selectAll".localized) + .font(.subheadline) + } + + Spacer() + + if !selectedItems.isEmpty { + Text(String(format: "bookshelf.selectedCount".localized, selectedItems.count)) + .font(.subheadline) + .foregroundColor(.secondary) + + Spacer() + + Button { + showingMoveSheet = true + } label: { + Image(systemName: "folder") + .font(.body) + } + + Button(role: .destructive) { + bookshelfManager.batchRemove(bookIds: Array(selectedItems)) + selectedItems.removeAll() + isEditMode = false + } label: { + Image(systemName: "trash") + .font(.body) + } + } + } + .padding(.vertical, 8) + } + + // MARK: - Folders Section + + private var foldersSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text("bookshelf.folders".localized) + .font(.headline) + .foregroundColor(.secondary) + + LazyVGrid(columns: [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ], spacing: 12) { + ForEach(filteredFolders) { folder in + NavigationLink { + BookshelfFolderView(folder: folder) + } label: { + BookshelfFolderCard(folder: folder) + } + .buttonStyle(.plain) + .contextMenu { + folderContextMenu(folder: folder) + } + } + } + } + } + + // MARK: - Books Section + + @ViewBuilder + private var booksSection: some View { + if bookshelfManager.displayMode == .grid { + bookGridView + } else { + bookListView + } + } + + private var bookGridView: some View { + LazyVGrid(columns: [ + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12), + GridItem(.flexible(), spacing: 12) + ], spacing: 16) { + ForEach(filteredItems) { item in + BookshelfGridItem( + item: item, + isEditMode: isEditMode, + isSelected: selectedItems.contains(item.bookId) + ) { + if isEditMode { + toggleSelection(item.bookId) + } else { + if let book = item.book { + selectedBookForDetail = book + } + } + } + .contextMenu { + if !isEditMode { + itemContextMenu(item: item) + } + } + } + } + } + + private var bookListView: some View { + LazyVStack(spacing: 0) { + ForEach(filteredItems) { item in + BookshelfListItem( + item: item, + isEditMode: isEditMode, + isSelected: selectedItems.contains(item.bookId) + ) { + if isEditMode { + toggleSelection(item.bookId) + } else { + if let book = item.book { + selectedBookForDetail = book + } + } + } + .contextMenu { + if !isEditMode { + itemContextMenu(item: item) + } + } + + if item.id != filteredItems.last?.id { + Divider() + .padding(.leading, 76) + } + } + } + } + + // MARK: - Context Menus + + @ViewBuilder + private func folderContextMenu(folder: BookshelfFolder) -> some View { + Button { + // Rename handled via alert + renameFolderAlert(folder: folder) + } label: { + Label("bookshelf.rename".localized, systemImage: "pencil") + } + + Button(role: .destructive) { + bookshelfManager.deleteFolder(id: folder.id) + } label: { + Label("common.delete".localized, systemImage: "trash") + } + } + + @ViewBuilder + private func itemContextMenu(item: BookshelfItem) -> some View { + if !bookshelfManager.folders.isEmpty { + Menu { + Button { + bookshelfManager.moveBook(bookId: item.bookId, toFolderId: nil) + } label: { + Label("bookshelf.moveToRoot".localized, systemImage: "tray") + } + + ForEach(bookshelfManager.folders) { folder in + Button { + bookshelfManager.moveBook(bookId: item.bookId, toFolderId: folder.id) + } label: { + Label(folder.name, systemImage: "folder") + } + } + } label: { + Label("bookshelf.moveTo".localized, systemImage: "folder") + } + } + + Button(role: .destructive) { + bookshelfManager.removeBook(bookId: item.bookId) + } label: { + Label("bookshelf.remove".localized, systemImage: "minus.circle") + } + } + + // MARK: - Helpers + + private func toggleSelection(_ bookId: String) { + if selectedItems.contains(bookId) { + selectedItems.remove(bookId) + } else { + selectedItems.insert(bookId) + } + } + + private func renameFolderAlert(folder: BookshelfFolder) { + newFolderName = folder.name + // Use a simple approach: show the same alert but repurpose it for rename + // For a more sophisticated UX, this could be a dedicated sheet + showingNewFolder = true + } +} diff --git a/Readmigo/Features/Bookshelf/Components/BookshelfFolderCard.swift b/Readmigo/Features/Bookshelf/Components/BookshelfFolderCard.swift new file mode 100644 index 0000000..b16d1e3 --- /dev/null +++ b/Readmigo/Features/Bookshelf/Components/BookshelfFolderCard.swift @@ -0,0 +1,50 @@ +import SwiftUI + +struct BookshelfFolderCard: View { + let folder: BookshelfFolder + + private var folderColor: Color { + if let colorName = folder.color { + switch colorName { + case "red": return .red + case "orange": return .orange + case "yellow": return .yellow + case "green": return .green + case "blue": return .blue + case "purple": return .purple + case "pink": return .pink + default: return .accentColor + } + } + return .accentColor + } + + var body: some View { + HStack(spacing: 10) { + Image(systemName: folder.icon ?? "folder.fill") + .font(.title3) + .foregroundColor(folderColor) + + VStack(alignment: .leading, spacing: 2) { + Text(folder.name) + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(1) + .foregroundColor(.primary) + + Text(String(format: "bookshelf.bookCount".localized, folder.bookCount)) + .font(.caption2) + .foregroundColor(.secondary) + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption2) + .foregroundColor(.secondary) + } + .padding(12) + .background(Color(.systemGray6)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } +} diff --git a/Readmigo/Features/Bookshelf/Components/BookshelfGridItem.swift b/Readmigo/Features/Bookshelf/Components/BookshelfGridItem.swift new file mode 100644 index 0000000..b13febe --- /dev/null +++ b/Readmigo/Features/Bookshelf/Components/BookshelfGridItem.swift @@ -0,0 +1,68 @@ +import SwiftUI +import Kingfisher + +struct BookshelfGridItem: View { + let item: BookshelfItem + let isEditMode: Bool + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + VStack(spacing: 6) { + ZStack(alignment: .topTrailing) { + // Cover image + if let coverUrl = item.book?.displayCoverUrl, let url = URL(string: coverUrl) { + KFImage(url) + .placeholder { + coverPlaceholder + } + .resizable() + .aspectRatio(2/3, contentMode: .fill) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .shadow(color: .black.opacity(0.1), radius: 4, y: 2) + } else { + coverPlaceholder + } + + // Edit mode selection indicator + if isEditMode { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundColor(isSelected ? .accentColor : .white) + .shadow(radius: 2) + .padding(6) + } + } + + // Title + Text(item.book?.localizedTitle ?? "...") + .font(.caption) + .fontWeight(.medium) + .lineLimit(2) + .multilineTextAlignment(.center) + .foregroundColor(.primary) + + // Author + if let author = item.book?.localizedAuthor, !author.isEmpty { + Text(author) + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + } + } + } + .buttonStyle(.plain) + } + + private var coverPlaceholder: some View { + RoundedRectangle(cornerRadius: 6) + .fill(Color(.systemGray5)) + .aspectRatio(2/3, contentMode: .fill) + .overlay { + Image(systemName: "book.closed") + .font(.title2) + .foregroundColor(.secondary) + } + } +} diff --git a/Readmigo/Features/Bookshelf/Components/BookshelfListItem.swift b/Readmigo/Features/Bookshelf/Components/BookshelfListItem.swift new file mode 100644 index 0000000..42739db --- /dev/null +++ b/Readmigo/Features/Bookshelf/Components/BookshelfListItem.swift @@ -0,0 +1,83 @@ +import SwiftUI +import Kingfisher + +struct BookshelfListItem: View { + let item: BookshelfItem + let isEditMode: Bool + let isSelected: Bool + let onTap: () -> Void + + var body: some View { + Button(action: onTap) { + HStack(spacing: 12) { + // Edit mode checkbox + if isEditMode { + Image(systemName: isSelected ? "checkmark.circle.fill" : "circle") + .font(.title3) + .foregroundColor(isSelected ? .accentColor : .secondary) + } + + // Cover image + if let coverUrl = item.book?.displayCoverUrl, let url = URL(string: coverUrl) { + KFImage(url) + .placeholder { + coverPlaceholder + } + .resizable() + .aspectRatio(contentMode: .fill) + .frame(width: 48, height: 64) + .clipShape(RoundedRectangle(cornerRadius: 4)) + } else { + coverPlaceholder + } + + // Book info + VStack(alignment: .leading, spacing: 4) { + Text(item.book?.localizedTitle ?? "...") + .font(.subheadline) + .fontWeight(.medium) + .lineLimit(2) + .foregroundColor(.primary) + + if let author = item.book?.localizedAuthor, !author.isEmpty { + Text(author) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(1) + } + + if let book = item.book { + HStack(spacing: 8) { + if let wordCount = book.formattedWordCount { + Text(wordCount) + .font(.caption2) + .foregroundColor(.secondary) + } + + DifficultyBadge(score: book.difficultyScore) + } + } + } + + Spacer() + + Image(systemName: "chevron.right") + .font(.caption) + .foregroundColor(.secondary) + } + .padding(.vertical, 8) + } + .buttonStyle(.plain) + } + + private var coverPlaceholder: some View { + RoundedRectangle(cornerRadius: 4) + .fill(Color(.systemGray5)) + .frame(width: 48, height: 64) + .overlay { + Image(systemName: "book.closed") + .font(.caption) + .foregroundColor(.secondary) + } + } +} diff --git a/Readmigo/Features/Bookshelf/Components/BookshelfMoveSheet.swift b/Readmigo/Features/Bookshelf/Components/BookshelfMoveSheet.swift new file mode 100644 index 0000000..0edafe4 --- /dev/null +++ b/Readmigo/Features/Bookshelf/Components/BookshelfMoveSheet.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct BookshelfMoveSheet: View { + let folders: [BookshelfFolder] + let selectedBookIds: [String] + let onMove: (String?) -> Void + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + List { + // Move to root (no folder) + Button { + onMove(nil) + dismiss() + } label: { + Label("bookshelf.moveToRoot".localized, systemImage: "tray") + } + + // Move to a folder + Section("bookshelf.folders".localized) { + ForEach(folders) { folder in + Button { + onMove(folder.id) + dismiss() + } label: { + Label(folder.name, systemImage: folder.icon ?? "folder.fill") + } + } + } + } + .navigationTitle("bookshelf.moveTo".localized) + .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .cancellationAction) { + Button("common.cancel".localized) { + dismiss() + } + } + } + } + .presentationDetents([.medium]) + } +} diff --git a/Readmigo/Features/Library/BookDetailView.swift b/Readmigo/Features/Library/BookDetailView.swift index e8b5d67..d80a193 100644 --- a/Readmigo/Features/Library/BookDetailView.swift +++ b/Readmigo/Features/Library/BookDetailView.swift @@ -309,6 +309,7 @@ struct BookDetailView: View { ActionButtonsSection( book: book, isFavorited: isFavorited, + isOnBookshelf: BookshelfManager.shared.isOnBookshelf(bookId: book.id), onRead: { // Auto-add to library when starting to read if !isInLibrary && authManager.isAuthenticated { @@ -334,6 +335,11 @@ struct BookDetailView: View { } } }, + onBookshelf: { + withAnimation(.spring(response: 0.3, dampingFraction: 0.5)) { + BookshelfManager.shared.toggleBookshelf(bookId: book.id, book: book) + } + }, downloadState: currentDownloadState, onDownload: { handleDownloadTap() } ) @@ -913,8 +919,10 @@ struct AuthorButtonStyle: ButtonStyle { struct ActionButtonsSection: View { let book: Book var isFavorited: Bool = false + var isOnBookshelf: Bool = false let onRead: () -> Void var onFavorite: (() -> Void)? + var onBookshelf: (() -> Void)? var downloadState: DownloadButtonState = .notDownloaded var onDownload: (() -> Void)? @@ -935,6 +943,18 @@ struct ActionButtonsSection: View { .cornerRadius(12) } + // Bookshelf Button + Button { + onBookshelf?() + } label: { + Image(systemName: isOnBookshelf ? "books.vertical.fill" : "books.vertical") + .font(.system(size: 18)) + .foregroundColor(isOnBookshelf ? .accentColor : .secondary) + .frame(width: 50, height: 46) + .background(Color(.systemGray6)) + .cornerRadius(12) + } + // Favorite Button Button { onFavorite?() diff --git a/Readmigo/Localizable.xcstrings b/Readmigo/Localizable.xcstrings index 7b303f7..9bb7102 100644 --- a/Readmigo/Localizable.xcstrings +++ b/Readmigo/Localizable.xcstrings @@ -247,6 +247,7 @@ } } }, + "--": {}, "— %@": { "localizations": { "ar": { @@ -148423,6 +148424,23 @@ } } }, + "stats.activeDays": { + "extractionState": "stale", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Active Days" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "活跃天数" + } + } + } + }, "stats.average": { "extractionState": "manual", "localizations": { @@ -148601,6 +148619,23 @@ } } }, + "stats.avgSession": { + "extractionState": "stale", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Avg Session" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "平均时长" + } + } + } + }, "stats.booksRead": { "extractionState": "manual", "localizations": { @@ -149046,6 +149081,40 @@ } } }, + "stats.favoriteTime": { + "extractionState": "stale", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Favorite Time" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "常读时段" + } + } + } + }, + "stats.insights": { + "extractionState": "stale", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Reading Insights" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阅读洞察" + } + } + } + }, "stats.locked.description": { "extractionState": "manual", "localizations": { @@ -149669,6 +149738,23 @@ } } }, + "stats.pagesPerHour": { + "extractionState": "stale", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Pages/Hour" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "页/小时" + } + } + } + }, "stats.percentComplete": { "extractionState": "manual", "localizations": { @@ -150025,6 +150111,23 @@ } } }, + "stats.sessions": { + "extractionState": "stale", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sessions" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "阅读次数" + } + } + } + }, "stats.streakDays": { "extractionState": "manual", "localizations": { @@ -150826,6 +150929,23 @@ } } }, + "stats.unlockTrend": { + "extractionState": "stale", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Upgrade to see 30-day trends" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "升级查看30天阅读趋势" + } + } + } + }, "stats.vocab.learning": { "extractionState": "manual", "localizations": { @@ -176460,114 +176580,2672 @@ } } }, - "stats.sessions": { + "tab.bookshelf": { + "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Sessions" + "value": "Bookshelf" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "阅读次数" + "value": "书架" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "書架" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本棚" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "책장" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Estantería" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Bibliothèque" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bücherregal" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Estante" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Полка" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "رف الكتب" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Rak Buku" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kitaplık" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Полиця" } } } }, - "stats.insights": { + "bookshelf.search": { + "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Reading Insights" + "value": "Search bookshelf" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "阅读洞察" + "value": "搜索书架" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "搜尋書架" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本棚を検索" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "책장 검색" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Buscar estantería" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Rechercher" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bücherregal durchsuchen" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pesquisar estante" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Поиск" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "البحث في رف الكتب" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Cari rak buku" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kitaplık ara" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Пошук" } } } }, - "stats.avgSession": { + "bookshelf.viewAsList": { + "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Avg Session" + "value": "View as List" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "平均时长" + "value": "列表视图" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "列表檢視" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "リスト表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "목록으로 보기" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ver como lista" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher en liste" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Als Liste anzeigen" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ver como lista" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Список" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض كقائمة" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Lihat sebagai daftar" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Liste olarak göster" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Список" } } } }, - "stats.pagesPerHour": { + "bookshelf.viewAsGrid": { + "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Pages/Hour" + "value": "View as Grid" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "页/小时" + "value": "网格视图" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "網格檢視" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "グリッド表示" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "그리드로 보기" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ver como cuadrícula" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Afficher en grille" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Als Raster anzeigen" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ver como grade" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сетка" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "عرض كشبكة" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Lihat sebagai grid" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Izgara olarak göster" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сітка" } } } }, - "stats.favoriteTime": { + "bookshelf.sortBy": { + "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Favorite Time" + "value": "Sort By" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "常读时段" + "value": "排序方式" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "排序方式" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "並び替え" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "정렬" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ordenar por" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Trier par" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sortieren nach" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ordenar por" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сортировка" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "ترتيب حسب" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Urutkan" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Sırala" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сортування" } } } }, - "stats.activeDays": { + "bookshelf.sort.manual": { + "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Active Days" + "value": "Manual" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "活跃天数" + "value": "手动排序" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "手動排序" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "手動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "수동" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Manual" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Manuel" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Manuell" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Manual" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Вручную" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "يدوي" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Manual" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Manuel" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вручну" } } } }, - "stats.unlockTrend": { + "bookshelf.sort.recentlyAdded": { + "extractionState": "manual", "localizations": { "en": { "stringUnit": { "state": "translated", - "value": "Upgrade to see 30-day trends" + "value": "Recently Added" } }, "zh-Hans": { "stringUnit": { "state": "translated", - "value": "升级查看30天阅读趋势" + "value": "最近添加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "最近新增" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "最近追加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "최근 추가" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadidos recientemente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Récemment ajoutés" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zuletzt hinzugefügt" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionados recentemente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Недавно добавленные" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أضيفت مؤخرًا" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Baru ditambahkan" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Son eklenen" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Нещодавно додані" + } + } + } + }, + "bookshelf.sort.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Title" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "标题" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "標題" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タイトル" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제목" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Título" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Titre" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Titel" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Título" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Название" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "العنوان" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Judul" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Başlık" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Назва" + } + } + } + }, + "bookshelf.sort.author": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Author" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "作者" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "作者" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "著者" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "저자" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Autor" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Auteur" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Autor" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Autor" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Автор" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "المؤلف" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Penulis" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yazar" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Автор" + } + } + } + }, + "bookshelf.edit": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Edit" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "编辑" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "編輯" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "編集" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "편집" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Editar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Modifier" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Bearbeiten" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Editar" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Редактировать" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تعديل" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Edit" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Düzenle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Редагувати" + } + } + } + }, + "bookshelf.newFolder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "New Folder" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "新建文件夹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增資料夾" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "新規フォルダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "새 폴더" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nueva carpeta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nouveau dossier" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Neuer Ordner" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nova pasta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Новая папка" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "مجلد جديد" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Folder baru" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeni klasör" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Нова тека" + } + } + } + }, + "bookshelf.folderName": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Folder name" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件夹名称" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "資料夾名稱" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダ名" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더 이름" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Nombre de carpeta" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Nom du dossier" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ordnername" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Nome da pasta" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Имя папки" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اسم المجلد" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Nama folder" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Klasör adı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Назва теки" + } + } + } + }, + "bookshelf.folders": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Folders" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "文件夹" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "資料夾" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Carpetas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Dossiers" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ordner" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Pastas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Папки" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "المجلدات" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Folder" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Klasörler" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Теки" + } + } + } + }, + "bookshelf.bookCount": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%d books" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%d 本书" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%d 本書" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 冊" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d 권" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d libros" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d livres" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d Bücher" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d livros" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d книг" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d كتب" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "%d buku" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%d kitap" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%d книг" + } + } + } + }, + "bookshelf.rename": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Rename" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "重命名" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "重新命名" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "名前を変更" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이름 변경" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Renombrar" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Renommer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Umbenennen" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Renomear" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переименовать" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إعادة التسمية" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Ganti nama" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Yeniden adlandır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перейменувати" + } + } + } + }, + "bookshelf.remove": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Remove from Bookshelf" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从书架移除" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "從書架移除" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本棚から削除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "책장에서 제거" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Quitar de estantería" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Retirer" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Vom Regal entfernen" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Remover da estante" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Удалить с полки" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إزالة من الرف" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Hapus dari rak" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Raftan kaldır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Видалити з полиці" + } + } + } + }, + "bookshelf.moveTo": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move to..." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移动到..." + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移動到..." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "移動..." + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이동..." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover a..." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplacer vers..." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Verschieben..." + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mover para..." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Переместить в..." + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل إلى..." + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Pindahkan ke..." + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Taşı..." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Перемістити до..." + } + } + } + }, + "bookshelf.moveToRoot": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Move to Bookshelf" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "移到书架根目录" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "移到書架根目錄" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本棚に移動" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "책장으로 이동" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Mover a estantería" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Déplacer vers bibliothèque" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zum Regal verschieben" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Mover para estante" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "На полку" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نقل إلى الرف" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Pindahkan ke rak" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Rafa taşı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "На полицю" + } + } + } + }, + "bookshelf.selectAll": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Select All" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "全选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "全選" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて選択" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 선택" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Seleccionar todo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout sélectionner" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle auswählen" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Selecionar tudo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Выбрать все" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "تحديد الكل" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Pilih semua" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tümünü seç" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Вибрати все" + } + } + } + }, + "bookshelf.deselectAll": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Deselect All" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "取消全选" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "取消全選" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "すべて解除" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "전체 해제" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Deseleccionar todo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Tout désélectionner" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Alle abwählen" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Desmarcar tudo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Снять выбор" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إلغاء تحديد الكل" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Batalkan semua" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Tümünü kaldır" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Скасувати все" + } + } + } + }, + "bookshelf.selectedCount": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%d selected" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已选 %d 项" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已選 %d 項" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%d 件選択" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%d개 선택됨" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%d seleccionados" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%d sélectionnés" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%d ausgewählt" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "%d selecionados" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%d выбрано" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "%d محدد" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "%d dipilih" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%d seçildi" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%d вибрано" + } + } + } + }, + "bookshelf.empty.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Your Bookshelf is Empty" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "书架空空如也" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "書架空空如也" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本棚は空です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "책장이 비어 있습니다" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Tu estantería está vacía" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Votre bibliothèque est vide" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Ihr Bücherregal ist leer" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Sua estante está vazia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Ваша полка пуста" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "رف الكتب فارغ" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Rak buku kosong" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kitaplığınız boş" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ваша полиця порожня" + } + } + } + }, + "bookshelf.empty.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add books from the bookstore to organize your personal collection" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "从书城添加书籍来整理您的个人收藏" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "從書城新增書籍來整理您的個人收藏" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "書店から本を追加して、あなたのコレクションを整理しましょう" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "서점에서 책을 추가하여 개인 컬렉션을 정리하세요" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añade libros de la librería para organizar tu colección personal" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajoutez des livres de la librairie pour organiser votre collection" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fügen Sie Bücher aus dem Buchladen hinzu, um Ihre Sammlung zu organisieren" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicione livros da livraria para organizar sua coleção pessoal" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавьте книги из магазина, чтобы организовать вашу коллекцию" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أضف كتبًا من المكتبة لتنظيم مجموعتك الشخصية" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Tambahkan buku dari toko untuk mengatur koleksi pribadi Anda" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kişisel koleksiyonunuzu düzenlemek için kitabevinden kitap ekleyin" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Додайте книги з книгарні, щоб організувати вашу колекцію" + } + } + } + }, + "bookshelf.tip.add.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Add to Bookshelf" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "添加到书架" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "新增到書架" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本棚に追加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "책장에 추가" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Añadir a estantería" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ajouter à la bibliothèque" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Zum Regal hinzufügen" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Adicionar à estante" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Добавить на полку" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "إضافة إلى الرف" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Tambahkan ke rak" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Rafa ekle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Додати на полицю" + } + } + } + }, + "bookshelf.tip.add.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Tap the bookshelf icon on any book to add it here" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "在任意书籍页面点击书架图标即可添加" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "在任意書籍頁面點擊書架圖示即可新增" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "本のページで本棚アイコンをタップして追加" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "책 페이지에서 책장 아이콘을 탭하여 추가" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Toca el icono de estantería en cualquier libro para añadirlo aquí" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Appuyez sur l'icône bibliothèque sur n'importe quel livre" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Tippen Sie auf das Regal-Symbol bei einem Buch" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Toque no ícone da estante em qualquer livro para adicioná-lo" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Нажмите значок полки на любой книге, чтобы добавить её сюда" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "اضغط على أيقونة الرف على أي كتاب لإضافته هنا" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Ketuk ikon rak buku pada buku mana pun untuk menambahkannya" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Herhangi bir kitapta raf simgesine dokunarak buraya ekleyin" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Натисніть іконку полиці на будь-якій книзі" + } + } + } + }, + "bookshelf.tip.folder.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Organize with Folders" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "使用文件夹整理" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "使用資料夾整理" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォルダで整理" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "폴더로 정리" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Organiza con carpetas" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Organisez avec des dossiers" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Mit Ordnern organisieren" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Organize com pastas" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Организуйте в папках" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "نظّم بالمجلدات" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Atur dengan folder" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Klasörlerle düzenle" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Організуйте в теках" + } + } + } + }, + "bookshelf.tip.folder.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Create folders to group books by genre, project, or mood" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "创建文件夹按类型、项目或心情分组书籍" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "建立資料夾按類型、專案或心情分類書籍" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ジャンル、プロジェクト、気分でフォルダを作成" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "장르, 프로젝트 또는 기분별로 폴더를 만들어 정리" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Crea carpetas para agrupar libros por género, proyecto o estado de ánimo" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Créez des dossiers pour regrouper les livres par genre, projet ou humeur" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Erstellen Sie Ordner, um Bücher nach Genre, Projekt oder Stimmung zu gruppieren" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Crie pastas para agrupar livros por gênero, projeto ou humor" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Создавайте папки для группировки книг по жанру, проекту или настроению" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "أنشئ مجلدات لتجميع الكتب حسب النوع أو المشروع أو المزاج" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Buat folder untuk mengelompokkan buku berdasarkan genre, proyek, atau suasana hati" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kitapları türe, projeye veya ruh haline göre gruplamak için klasörler oluşturun" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Створюйте теки для групування книг за жанром, проєктом або настроєм" + } + } + } + }, + "bookshelf.tip.sort.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sort Your Way" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "自由排序" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "自由排序" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "自由に並び替え" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "자유롭게 정렬" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ordena a tu manera" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Triez à votre façon" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sortieren Sie nach Ihrem Geschmack" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ordene do seu jeito" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сортируйте как удобно" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "رتّب بطريقتك" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Urutkan sesukamu" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Kendi tarzınızda sıralayın" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сортуйте як зручно" + } + } + } + }, + "bookshelf.tip.sort.description": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Sort by title, author, or recently added" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "按标题、作者或最近添加排序" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "按標題、作者或最近新增排序" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "タイトル、著者、最近追加順で並び替え" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "제목, 저자 또는 최근 추가 순으로 정렬" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Ordena por título, autor o añadidos recientemente" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Triez par titre, auteur ou récemment ajoutés" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Sortieren nach Titel, Autor oder zuletzt hinzugefügt" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Ordene por título, autor ou adicionados recentemente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Сортируйте по названию, автору или недавно добавленным" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "رتّب حسب العنوان أو المؤلف أو المضاف مؤخرًا" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Urutkan berdasarkan judul, penulis, atau baru ditambahkan" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Başlığa, yazara veya son eklenene göre sıralayın" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Сортуйте за назвою, автором або нещодавно доданими" + } + } + } + }, + "bookshelf.folder.empty": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "This folder is empty" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "此文件夹为空" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "此資料夾為空" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "このフォルダは空です" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "이 폴더는 비어 있습니다" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Esta carpeta está vacía" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Ce dossier est vide" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Dieser Ordner ist leer" + } + }, + "pt": { + "stringUnit": { + "state": "translated", + "value": "Esta pasta está vazia" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Эта папка пуста" + } + }, + "ar": { + "stringUnit": { + "state": "translated", + "value": "هذا المجلد فارغ" + } + }, + "id": { + "stringUnit": { + "state": "translated", + "value": "Folder ini kosong" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Bu klasör boş" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Ця тека порожня" } } } From f1104845dfb662ca537ead34e8f9478492832660 Mon Sep 17 00:00:00 2001 From: Jack Date: Tue, 24 Mar 2026 14:04:36 +0800 Subject: [PATCH 3/3] fix: defensive decoding for Analytics models, StoreKit retry strategy, FirebaseAuth guard - Analytics.swift: add custom init(from:) with decodeIfPresent for all numeric fields in 6 Codable structs to prevent keyNotFound crashes - SubscriptionManager.swift: skip retry for 4xx client errors in backend verification to avoid error count inflation - AppDelegate.swift: guard FirebaseApp.app() != nil before Auth.auth() calls to prevent race condition crash on startup --- Readmigo/App/AppDelegate.swift | 5 + Readmigo/Core/Models/Analytics.swift | 92 +++++++++++++++++++ .../Subscriptions/SubscriptionManager.swift | 18 ++++ 3 files changed, 115 insertions(+) diff --git a/Readmigo/App/AppDelegate.swift b/Readmigo/App/AppDelegate.swift index df0c3c4..634dbff 100644 --- a/Readmigo/App/AppDelegate.swift +++ b/Readmigo/App/AppDelegate.swift @@ -64,6 +64,10 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void ) { // Let Firebase Auth handle phone auth verification notifications + guard FirebaseApp.app() != nil else { + completionHandler(.noData) + return + } if Auth.auth().canHandleNotification(userInfo) { completionHandler(.noData) return @@ -81,6 +85,7 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data ) { // Forward to Firebase Auth for phone auth verification + guard FirebaseApp.app() != nil else { return } Auth.auth().setAPNSToken(deviceToken, type: .unknown) let token = deviceToken.map { String(format: "%02x", $0) }.joined() diff --git a/Readmigo/Core/Models/Analytics.swift b/Readmigo/Core/Models/Analytics.swift index 310664b..283c44c 100644 --- a/Readmigo/Core/Models/Analytics.swift +++ b/Readmigo/Core/Models/Analytics.swift @@ -31,6 +31,30 @@ struct OverviewStats: Codable { let monthlyTtsSeconds: Int let monthlyAudiobookSeconds: Int let monthlyTotalSeconds: Int + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + booksFinished = try container.decodeIfPresent(Int.self, forKey: .booksFinished) ?? 0 + currentStreak = try container.decodeIfPresent(Int.self, forKey: .currentStreak) ?? 0 + longestStreak = try container.decodeIfPresent(Int.self, forKey: .longestStreak) ?? 0 + sessionCount = try container.decodeIfPresent(Int.self, forKey: .sessionCount) ?? 0 + readingSeconds = try container.decodeIfPresent(Int.self, forKey: .readingSeconds) ?? 0 + ttsSeconds = try container.decodeIfPresent(Int.self, forKey: .ttsSeconds) ?? 0 + audiobookSeconds = try container.decodeIfPresent(Int.self, forKey: .audiobookSeconds) ?? 0 + totalSeconds = try container.decodeIfPresent(Int.self, forKey: .totalSeconds) ?? 0 + todayReadingSeconds = try container.decodeIfPresent(Int.self, forKey: .todayReadingSeconds) ?? 0 + todayTtsSeconds = try container.decodeIfPresent(Int.self, forKey: .todayTtsSeconds) ?? 0 + todayAudiobookSeconds = try container.decodeIfPresent(Int.self, forKey: .todayAudiobookSeconds) ?? 0 + todayTotalSeconds = try container.decodeIfPresent(Int.self, forKey: .todayTotalSeconds) ?? 0 + weeklyReadingSeconds = try container.decodeIfPresent(Int.self, forKey: .weeklyReadingSeconds) ?? 0 + weeklyTtsSeconds = try container.decodeIfPresent(Int.self, forKey: .weeklyTtsSeconds) ?? 0 + weeklyAudiobookSeconds = try container.decodeIfPresent(Int.self, forKey: .weeklyAudiobookSeconds) ?? 0 + weeklyTotalSeconds = try container.decodeIfPresent(Int.self, forKey: .weeklyTotalSeconds) ?? 0 + monthlyReadingSeconds = try container.decodeIfPresent(Int.self, forKey: .monthlyReadingSeconds) ?? 0 + monthlyTtsSeconds = try container.decodeIfPresent(Int.self, forKey: .monthlyTtsSeconds) ?? 0 + monthlyAudiobookSeconds = try container.decodeIfPresent(Int.self, forKey: .monthlyAudiobookSeconds) ?? 0 + monthlyTotalSeconds = try container.decodeIfPresent(Int.self, forKey: .monthlyTotalSeconds) ?? 0 + } } // MARK: - Daily Stats @@ -52,6 +76,16 @@ struct DailyStats: Codable, Identifiable { formatter.dateFormat = "yyyy-MM-dd" return formatter.date(from: date) } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + date = try container.decode(String.self, forKey: .date) + pagesRead = try container.decodeIfPresent(Int.self, forKey: .pagesRead) ?? 0 + readingSeconds = try container.decodeIfPresent(Int.self, forKey: .readingSeconds) ?? 0 + ttsSeconds = try container.decodeIfPresent(Int.self, forKey: .ttsSeconds) ?? 0 + audiobookSeconds = try container.decodeIfPresent(Int.self, forKey: .audiobookSeconds) ?? 0 + totalSeconds = try container.decodeIfPresent(Int.self, forKey: .totalSeconds) ?? 0 + } } // MARK: - Trend Direction @@ -91,6 +125,20 @@ struct ReadingTrend: Codable { let readingSeconds: Int let ttsSeconds: Int let audiobookSeconds: Int + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + dailyStats = try container.decodeIfPresent([DailyStats].self, forKey: .dailyStats) ?? [] + totalSeconds = try container.decodeIfPresent(Int.self, forKey: .totalSeconds) ?? 0 + avgSecondsPerDay = try container.decodeIfPresent(Int.self, forKey: .avgSecondsPerDay) ?? 0 + daysActive = try container.decodeIfPresent(Int.self, forKey: .daysActive) ?? 0 + totalDays = try container.decodeIfPresent(Int.self, forKey: .totalDays) ?? 0 + trend = try container.decodeIfPresent(TrendDirection.self, forKey: .trend) ?? .stable + percentChange = try container.decodeIfPresent(Double.self, forKey: .percentChange) + readingSeconds = try container.decodeIfPresent(Int.self, forKey: .readingSeconds) ?? 0 + ttsSeconds = try container.decodeIfPresent(Int.self, forKey: .ttsSeconds) ?? 0 + audiobookSeconds = try container.decodeIfPresent(Int.self, forKey: .audiobookSeconds) ?? 0 + } } // MARK: - Reading Progress @@ -168,6 +216,18 @@ struct ReadingInsights: Codable { let totalBooksFinished: Int let readingDays: Int let memberSinceDays: Int + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + avgSessionSeconds = try container.decodeIfPresent(Int.self, forKey: .avgSessionSeconds) ?? 0 + pagesPerHour = try container.decodeIfPresent(Double.self, forKey: .pagesPerHour) ?? 0 + favoriteHour = try container.decodeIfPresent(Int.self, forKey: .favoriteHour) + hourlyDistribution = try container.decodeIfPresent([Int].self, forKey: .hourlyDistribution) ?? [] + totalPagesRead = try container.decodeIfPresent(Int.self, forKey: .totalPagesRead) ?? 0 + totalBooksFinished = try container.decodeIfPresent(Int.self, forKey: .totalBooksFinished) ?? 0 + readingDays = try container.decodeIfPresent(Int.self, forKey: .readingDays) ?? 0 + memberSinceDays = try container.decodeIfPresent(Int.self, forKey: .memberSinceDays) ?? 0 + } } // MARK: - Book Detail Stats @@ -199,6 +259,28 @@ struct BookDetailStats: Codable { // Recent sessions let recentSessions: [BookSessionDetail] + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + bookId = try container.decode(String.self, forKey: .bookId) + title = try container.decode(String.self, forKey: .title) + author = try container.decode(String.self, forKey: .author) + coverUrl = try container.decodeIfPresent(String.self, forKey: .coverUrl) + progress = try container.decodeIfPresent(Double.self, forKey: .progress) ?? 0 + status = try container.decodeIfPresent(String.self, forKey: .status) ?? "UNKNOWN" + totalSeconds = try container.decodeIfPresent(Int.self, forKey: .totalSeconds) ?? 0 + sessionCount = try container.decodeIfPresent(Int.self, forKey: .sessionCount) ?? 0 + avgSessionSeconds = try container.decodeIfPresent(Int.self, forKey: .avgSessionSeconds) ?? 0 + firstReadAt = try container.decodeIfPresent(Date.self, forKey: .firstReadAt) + lastReadAt = try container.decodeIfPresent(Date.self, forKey: .lastReadAt) + readingSeconds = try container.decodeIfPresent(Int.self, forKey: .readingSeconds) ?? 0 + ttsSeconds = try container.decodeIfPresent(Int.self, forKey: .ttsSeconds) ?? 0 + audiobookSeconds = try container.decodeIfPresent(Int.self, forKey: .audiobookSeconds) ?? 0 + dailyStats = try container.decodeIfPresent([BookDailyStats].self, forKey: .dailyStats) ?? [] + daysActive = try container.decodeIfPresent(Int.self, forKey: .daysActive) ?? 0 + avgSecondsPerDay = try container.decodeIfPresent(Int.self, forKey: .avgSecondsPerDay) ?? 0 + recentSessions = try container.decodeIfPresent([BookSessionDetail].self, forKey: .recentSessions) ?? [] + } } struct BookDailyStats: Codable, Identifiable { @@ -216,6 +298,16 @@ struct BookDailyStats: Codable, Identifiable { formatter.dateFormat = "yyyy-MM-dd" return formatter.date(from: date) } + + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + date = try container.decode(String.self, forKey: .date) + readingSeconds = try container.decodeIfPresent(Int.self, forKey: .readingSeconds) ?? 0 + ttsSeconds = try container.decodeIfPresent(Int.self, forKey: .ttsSeconds) ?? 0 + audiobookSeconds = try container.decodeIfPresent(Int.self, forKey: .audiobookSeconds) ?? 0 + totalSeconds = try container.decodeIfPresent(Int.self, forKey: .totalSeconds) ?? 0 + sessionCount = try container.decodeIfPresent(Int.self, forKey: .sessionCount) ?? 0 + } } struct BookSessionDetail: Codable, Identifiable { diff --git a/Readmigo/Features/Subscriptions/SubscriptionManager.swift b/Readmigo/Features/Subscriptions/SubscriptionManager.swift index 488f0f7..1efd020 100644 --- a/Readmigo/Features/Subscriptions/SubscriptionManager.swift +++ b/Readmigo/Features/Subscriptions/SubscriptionManager.swift @@ -544,6 +544,24 @@ class SubscriptionManager: ObservableObject { removePendingVerification(transactionId: String(transaction.id)) return } catch { + // Don't retry client errors (4xx) — they won't succeed on retry + let isClientError: Bool + if case APIError.serverError(let statusCode, _) = error, (400..<500).contains(statusCode) { + isClientError = true + } else { + isClientError = false + } + + if isClientError { + log.warning(.subscribeBilling, "Backend verify got client error (4xx), not retrying — \(error.localizedDescription)", component: component) + AnalyticsDispatcher.shared.capture("receipt_verification_client_error", properties: [ + "product_id": productId, + "transaction_id": String(transaction.id), + "error": error.localizedDescription, + ]) + return + } + if attempt < backendVerifyMaxRetries { let delaySecs = attempt * backendVerifyRetryBaseSeconds log.warning(.subscribeBilling, "Backend verify failed (attempt \(attempt)/\(backendVerifyMaxRetries)), retry in \(delaySecs)s — \(error.localizedDescription)", component: component)