refactor: restructure API models and services for reading runtime

This commit is contained in:
wangdl 2026-07-08 19:34:13 +08:00
parent 3cf95f1ee1
commit 13688f4290
74 changed files with 7478 additions and 2880 deletions

View File

@ -6,8 +6,19 @@
objectVersion = 77;
objects = {
/* Begin PBXContainerItemProxy section */
7A1B2C3D4E5F60718293A40C /* PBXContainerItemProxy */ = {
isa = PBXContainerItemProxy;
containerPortal = 05F6CD132FA886330043A7BC /* Project object */;
proxyType = 1;
remoteGlobalIDString = 05F6CD1A2FA886330043A7BC;
remoteInfo = AIStudyApp;
};
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
05F6CD1B2FA886330043A7BC /* AIStudyApp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = AIStudyApp.app; sourceTree = BUILT_PRODUCTS_DIR; };
7A1B2C3D4E5F60718293A401 /* AIStudyAppTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AIStudyAppTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
/* End PBXFileReference section */
/* Begin PBXFileSystemSynchronizedRootGroup section */
@ -16,6 +27,11 @@
path = AIStudyApp;
sourceTree = "<group>";
};
7A1B2C3D4E5F60718293A402 /* AIStudyAppReaderTests */ = {
isa = PBXFileSystemSynchronizedRootGroup;
path = AIStudyAppReaderTests;
sourceTree = "<group>";
};
/* End PBXFileSystemSynchronizedRootGroup section */
/* Begin PBXFrameworksBuildPhase section */
@ -26,6 +42,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
7A1B2C3D4E5F60718293A403 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXFrameworksBuildPhase section */
/* Begin PBXGroup section */
@ -33,6 +56,7 @@
isa = PBXGroup;
children = (
05F6CD1D2FA886330043A7BC /* AIStudyApp */,
7A1B2C3D4E5F60718293A402 /* AIStudyAppReaderTests */,
05F6CD1C2FA886330043A7BC /* Products */,
);
sourceTree = "<group>";
@ -41,6 +65,7 @@
isa = PBXGroup;
children = (
05F6CD1B2FA886330043A7BC /* AIStudyApp.app */,
7A1B2C3D4E5F60718293A401 /* AIStudyAppTests.xctest */,
);
name = Products;
sourceTree = "<group>";
@ -70,6 +95,29 @@
productReference = 05F6CD1B2FA886330043A7BC /* AIStudyApp.app */;
productType = "com.apple.product-type.application";
};
7A1B2C3D4E5F60718293A404 /* AIStudyAppTests */ = {
isa = PBXNativeTarget;
buildConfigurationList = 7A1B2C3D4E5F60718293A40A /* Build configuration list for PBXNativeTarget "AIStudyAppTests" */;
buildPhases = (
7A1B2C3D4E5F60718293A405 /* Sources */,
7A1B2C3D4E5F60718293A403 /* Frameworks */,
7A1B2C3D4E5F60718293A406 /* Resources */,
);
buildRules = (
);
dependencies = (
7A1B2C3D4E5F60718293A40B /* PBXTargetDependency */,
);
fileSystemSynchronizedGroups = (
7A1B2C3D4E5F60718293A402 /* AIStudyAppReaderTests */,
);
name = AIStudyAppTests;
packageProductDependencies = (
);
productName = AIStudyAppTests;
productReference = 7A1B2C3D4E5F60718293A401 /* AIStudyAppTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
/* End PBXNativeTarget section */
/* Begin PBXProject section */
@ -83,6 +131,10 @@
05F6CD1A2FA886330043A7BC = {
CreatedOnToolsVersion = 26.4.1;
};
7A1B2C3D4E5F60718293A404 = {
CreatedOnToolsVersion = 26.4.1;
TestTargetID = 05F6CD1A2FA886330043A7BC;
};
};
};
buildConfigurationList = 05F6CD162FA886330043A7BC /* Build configuration list for PBXProject "AIStudyApp" */;
@ -102,6 +154,7 @@
projectRoot = "";
targets = (
05F6CD1A2FA886330043A7BC /* AIStudyApp */,
7A1B2C3D4E5F60718293A404 /* AIStudyAppTests */,
);
};
/* End PBXProject section */
@ -114,6 +167,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
7A1B2C3D4E5F60718293A406 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
@ -124,8 +184,23 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
7A1B2C3D4E5F60718293A405 /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
7A1B2C3D4E5F60718293A40B /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 05F6CD1A2FA886330043A7BC /* AIStudyApp */;
targetProxy = 7A1B2C3D4E5F60718293A40C /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */
05F6CD242FA886350043A7BC /* Debug */ = {
isa = XCBuildConfiguration;
@ -258,6 +333,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = NO;
HEADER_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/AIStudyApp/Core/Services",
@ -270,6 +346,8 @@
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/AIStudyApp/Core/ZxDocumentRuntime.xcframework/ios-arm64-simulator",
"$(PROJECT_DIR)/AIStudyApp/Core/ZxDocumentRuntime.xcframework.bak/ios-arm64",
"$(PROJECT_DIR)/AIStudyApp/Core/ZxDocumentRuntime.xcframework.bak/ios-arm64-simulator",
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
"$(inherited)",
@ -309,6 +387,7 @@
ENABLE_HARDENED_RUNTIME = YES;
ENABLE_PREVIEWS = YES;
ENABLE_USER_SELECTED_FILES = readonly;
GENERATE_INFOPLIST_FILE = NO;
HEADER_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/AIStudyApp/Core/Services",
@ -321,6 +400,8 @@
LIBRARY_SEARCH_PATHS = (
"$(inherited)",
"$(PROJECT_DIR)/AIStudyApp/Core/ZxDocumentRuntime.xcframework/ios-arm64-simulator",
"$(PROJECT_DIR)/AIStudyApp/Core/ZxDocumentRuntime.xcframework.bak/ios-arm64",
"$(PROJECT_DIR)/AIStudyApp/Core/ZxDocumentRuntime.xcframework.bak/ios-arm64-simulator",
);
"LIBRARY_SEARCH_PATHS[sdk=iphoneos*]" = (
"$(inherited)",
@ -347,6 +428,63 @@
};
name = Release;
};
7A1B2C3D4E5F60718293A407 /* Debug */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 88FMP9VK6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = cloud.longde.AIStudyAppTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_INCLUDE_PATHS = "$(inherited) $(PROJECT_DIR)/AIStudyApp/Core/Services";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AIStudyApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/AIStudyApp";
TEST_TARGET_NAME = AIStudyApp;
};
name = Debug;
};
7A1B2C3D4E5F60718293A408 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
CODE_SIGN_STYLE = Automatic;
CURRENT_PROJECT_VERSION = 1;
DEVELOPMENT_TEAM = 88FMP9VK6T;
GENERATE_INFOPLIST_FILE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 17.6;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
"@loader_path/Frameworks",
);
MARKETING_VERSION = 1.0;
PRODUCT_BUNDLE_IDENTIFIER = cloud.longde.AIStudyAppTests;
PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = auto;
SUPPORTED_PLATFORMS = "iphoneos iphonesimulator";
SWIFT_EMIT_LOC_STRINGS = NO;
SWIFT_INCLUDE_PATHS = "$(inherited) $(PROJECT_DIR)/AIStudyApp/Core/Services";
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/AIStudyApp.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/AIStudyApp";
TEST_TARGET_NAME = AIStudyApp;
};
name = Release;
};
/* End XCBuildConfiguration section */
/* Begin XCConfigurationList section */
@ -368,6 +506,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
7A1B2C3D4E5F60718293A40A /* Build configuration list for PBXNativeTarget "AIStudyAppTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
7A1B2C3D4E5F60718293A407 /* Debug */,
7A1B2C3D4E5F60718293A408 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
/* End XCConfigurationList section */
};
rootObject = 05F6CD132FA886330043A7BC /* Project object */;

View File

@ -29,6 +29,17 @@
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "7A1B2C3D4E5F60718293A404"
BuildableName = "AIStudyAppTests.xctest"
BlueprintName = "AIStudyAppTests"
ReferencedContainer = "container:AIStudyApp.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
</TestAction>
<LaunchAction

View File

@ -1,30 +1,14 @@
//
// APIModels.swift - api-server
// APIModels.swift
//
//
// Common.swift, Auth.swift, User.swift, KnowledgeBase.swift,
// KnowledgeSource.swift, KnowledgeItem.swift, Activity.swift,
// FocusItem.swift, Reading.swift, Quiz.swift, Chat.swift
//
import Foundation
// MARK: - API Envelope (ResponseInterceptor wraps all responses)
struct APIEnvelope<T: Decodable>: Decodable {
let success: Bool
let data: T
let timestamp: String?
}
// MARK: - Pagination
struct PaginationMeta: Codable {
let page: Int
let limit: Int
let total: Int
}
struct PaginatedResponse<T: Decodable>: Decodable {
let data: [T]
let meta: PaginationMeta
}
// MARK: - Waitlist
struct WaitlistEntry: Codable, Identifiable {
@ -40,194 +24,21 @@ struct WaitlistEntry: Codable, Identifiable {
struct WaitlistCreateRequest: Codable {
let email: String
let nickname: String?
let devices: [String]?
let interests: [String]?
let painpoint: String?
let willingBeta: Bool?
init(email: String, nickname: String? = nil, devices: [String]? = nil,
interests: [String]? = nil, painpoint: String? = nil, willingBeta: Bool = true) {
self.email = email
self.nickname = nickname
self.devices = devices
self.interests = interests
self.painpoint = painpoint
self.willingBeta = willingBeta
}
let name: String?
}
struct WaitlistResponse: Codable {
let success: Bool?
let success: Bool
let message: String?
let data: WaitlistEntry?
let entry: WaitlistEntry?
}
struct WaitlistStats: Codable {
let total: Int?
let today: Int?
let deviceBreakdown: [String: Int]?
let interestBreakdown: [String: Int]?
let total: Int
let today: Int
}
// MARK: - Auth
struct AuthResponse: Codable {
let accessToken: String
let refreshToken: String?
let user: AuthUser?
}
struct AuthUser: Codable, Identifiable {
let id: String
let email: String?
let nickname: String?
let avatarUrl: String?
let role: String?
let status: String?
let onboardingCompleted: Bool?
}
struct AppleAuthRequest: Codable {
let identityToken: String
let authorizationCode: String?
let fullName: AppleFullName?
let nonce: String?
struct AppleFullName: Codable {
let givenName: String?
let familyName: String?
}
}
struct RefreshRequest: Codable {
let refreshToken: String
}
// MARK: - User Profile (matches GET /users/me with include: profile + preferences)
struct UserProfileResponse: Codable, Identifiable {
let id: String
let email: String?
let nickname: String?
let avatarUrl: String?
let role: String?
let status: String?
let onboardingCompleted: Bool?
let createdAt: String?
let profile: UserProfileData?
let preferences: UserPreferences?
}
struct UserProfileData: Codable {
let learningIdentity: String?
let learningDirection: String?
let bio: String?
let currentGoal: String?
}
struct UserPreferences: Codable, Equatable {
let preferredMethods: [String]?
let defaultFocusMinutes: Int?
let aiSuggestionLevel: String?
let language: String?
let appearance: String?
let notificationEnabled: Bool?
}
struct UpdateProfileRequest: Codable {
let nickname: String?
let avatarUrl: String?
}
struct UpdatePreferencesRequest: Codable {
let preferredMethods: [String]?
let defaultFocusMinutes: Int?
let aiSuggestionLevel: String?
let language: String?
let appearance: String?
let notificationEnabled: Bool?
}
struct UpdateProfileDataRequest: Codable {
let learningIdentity: String?
let learningDirection: String?
let bio: String?
let currentGoal: String?
}
// MARK: - Knowledge Base (matches Prisma KnowledgeBase model)
struct KnowledgeBase: Codable, Identifiable {
let id: String
let userId: String?
let title: String
let description: String?
let coverKey: String?
let coverUrl: String?
let coverType: String?
let visibility: String?
let isPinned: Bool?
let ownerType: String?
let isVerified: Bool?
let status: String?
let itemCount: Int?
let lastStudiedAt: String?
let createdAt: String?
let updatedAt: String?
}
struct CreateKnowledgeBaseRequest: Codable {
let title: String
let description: String?
var coverKey: String? = nil
}
struct KnowledgeBaseCoverUploadResult {
let fileId: String
let objectKey: String
}
// MARK: - Knowledge Items (matches Prisma KnowledgeItem model)
struct GenerateKnowledgeResponse: Codable {
let items: [KnowledgeItem]
let count: Int
let sourceId: String
}
struct KnowledgeItem: Codable, Identifiable, Hashable {
let id: String
let userId: String?
let knowledgeBaseId: String?
let parentId: String?
let itemType: String?
let title: String
let content: String?
let summary: String?
let sourceType: String?
let sourceRef: String?
let orderIndex: Int?
let status: String?
let createdAt: String?
let updatedAt: String?
}
struct CreateKnowledgeItemRequest: Codable {
let knowledgeBaseId: String
let title: String
let content: String?
let itemType: String?
}
struct UpdateKnowledgeItemRequest: Codable {
let title: String?
let content: String?
let summary: String?
}
// MARK: - Active Recall (matches ActiveRecallQuestion / Answer models)
// MARK: - Active Recall
struct ActiveRecallQuestion: Codable, Identifiable {
let id: String
@ -252,7 +63,7 @@ struct SubmitAnswerRequest: Codable {
let answerText: String
}
// MARK: - AI Analysis (matches AiAnalysisResult model)
// MARK: - AI Analysis
struct AIAnalysisRequest: Codable {
let questionText: String?
@ -265,23 +76,23 @@ struct AIAnalysisRequest: Codable {
struct AIAnalysisResult: Codable, Identifiable {
let id: String
let userId: String?
let summary: String?
let knowledgeItemId: String?
let questionText: String?
let userAnswer: String?
let analysis: String?
let score: Int?
let feedback: String?
let masteryScore: Int?
let strengths: [String]?
let weaknesses: [String]?
let suggestions: [String]?
let nextActions: [String]?
let rawResult: AIAnalysisRawResult?
let createdAt: String?
}
struct AIAnalysisRawResult: Codable {
let score: Double?
let analysis: String?
let focusItems: [String]?
let id: String
let resultJson: String?
let createdAt: String?
}
// MARK: - Learning Session (matches Prisma LearningSession model)
// MARK: - Learning Session
struct LearningSession: Codable, Identifiable {
let id: String
@ -294,7 +105,7 @@ struct LearningSession: Codable, Identifiable {
let endedAt: String?
let durationSeconds: Int?
let focusMinutes: Int?
let createdAt: String?
let totalActiveSeconds: Int?
}
struct CreateLearningSessionRequest: Codable {
@ -303,270 +114,101 @@ struct CreateLearningSessionRequest: Codable {
let mode: String?
}
// MARK: - Review (matches ReviewCard / ReviewLog models)
// MARK: - Review
struct ReviewCard: Codable, Identifiable {
let id: String
let userId: String?
let knowledgeItemId: String?
let frontText: String
let frontText: String?
let backText: String?
let difficulty: String?
let status: String?
let nextReviewAt: String?
let scheduleState: String?
let intervalDays: Int?
let easeFactor: Double?
let repetitionCount: Int?
let lapseCount: Int?
}
struct SubmitReviewRequest: Codable {
let rating: String
let responseText: String?
}
// MARK: - Focus Items (matches Prisma FocusItem model)
struct FocusItem: Codable, Identifiable {
let id: String
let userId: String?
let knowledgeBaseId: String?
let knowledgeItemId: String?
let title: String
let reason: String?
let suggestion: String?
let priority: String?
let status: String?
let masteryScore: Int?
let dueAt: String?
let completedAt: String?
let ease: Double?
let nextReviewAt: String?
let createdAt: String?
}
// MARK: - Activity (matches DailyLearningActivity + summary aggregation)
struct ActivitySummary: Codable {
let totalMinutes: Int?
let totalCardsReviewed: Int?
let activeDays: Int?
let dailyAverage: Int?
}
struct ActivityHeatmap: Codable {
// Dictionary of "YYYY-MM-DD" -> durationSeconds
struct SubmitReviewRequest: Codable {
let difficulty: String?
}
// MARK: - Feedback
struct FeedbackCreateRequest: Codable {
let category: String
let category: String?
let content: String
let email: String?
init(category: String = "general", content: String, email: String? = nil) {
self.category = category
self.content = content
self.email = email
}
}
struct FeedbackData: Codable, Identifiable {
let id: String?
let id: String
let userId: String?
let category: String?
let content: String?
let status: String?
let createdAt: String?
}
// MARK: - Notifications (matches Prisma Notification model)
// MARK: - Notifications
struct NotificationItem: Codable, Identifiable {
let id: String
let userId: String?
let type: String
let type: String?
let title: String
let content: String?
let data: [String: String]?
let readAt: String?
let createdAt: String?
}
// MARK: - Generic
struct GenericSuccessResponse: Codable {
let success: Bool?
let message: String?
}
struct BatchDeleteResponse: Codable {
let success: Bool?
let message: String?
let count: Int?
}
// MARK: - Quiz
struct Quiz: Codable, Identifiable {
let id: String
let knowledgeBaseId: String?
let title: String?
let description: String?
let questionCount: Int?
let sourceType: String?
let status: String?
let createdAt: String?
let questions: [QuizQuestion]?
}
struct QuizQuestion: Codable, Identifiable {
let id: String
let quizId: String?
let type: String?
let stem: String?
let options: [String]?
let answer: String?
let explanation: String?
let orderIndex: Int?
/// M-QUIZ-01-CLEANUP-02: type nil
var questionType: QuizQuestionType? { QuizQuestionType.from(rawValue: type) }
}
struct QuizAttempt: Codable, Identifiable {
let id: String
let quizId: String?
let totalQuestions: Int?
let correctCount: Int?
let score: Int?
let startedAt: String?
let finishedAt: String?
let answers: [QuizAnswer]?
let quiz: QuizInfo?
}
// MARK: - Quiz (extras)
struct QuizInfo: Codable { let title: String? }
struct QuizAnswer: Codable, Identifiable {
let id: String
let attemptId: String?
let questionId: String?
let userAnswer: String?
let isCorrect: Bool?
let answeredAt: String?
let question: QuizQuestion?
let id: String; let questionId: String?; let answer: String?
}
struct QuizGenerateRequest: Codable {
let knowledgeBaseId: String
let sourceType: String?
let sourceId: String?
let questionCount: Int
}
struct QuizSubmitRequest: Codable {
let attemptId: String
let answers: [QuizAnswerItem]
let attemptId: String; let answers: [QuizAnswerItem]
}
struct QuizAnswerItem: Codable {
let questionId: String
let answer: String
let questionId: String; let answer: String
}
struct QuizSubmitResponse: Codable {
let score: Int?
let correctCount: Int?
let totalQuestions: Int?
let finishedAt: String?
let score: Int?; let totalQuestions: Int?; let correctCount: Int?
}
// M-QUIZ-01-03: Attempt answer/explanation
struct QuizAttemptStartResponse: Codable {
let id: String
let quizId: String?
let totalQuestions: Int?
let id: String; let quizId: String?; let status: String?
}
// MARK: - RAG Chat
// MARK: - Chat Scope
enum ChatScopeType: String, Codable {
case knowledgeBase = "knowledge_base"
case folder = "folder"
case material = "material"
case knowledgeItem = "knowledge_item"
case global = "global"
}
struct ChatEntryContext: Hashable {
let scopeType: ChatScopeType
let scopeId: String?
let scopeName: String
let parentKnowledgeBaseId: String?
let createdFrom: String
}
struct ChatSession: Codable, Identifiable {
let id: String
let userId: String?
let knowledgeBaseId: String?
let scopeType: String?
let scopeId: String?
let parentKnowledgeBaseId: String?
let title: String?
let createdFrom: String?
let isPinned: Bool?
let isArchived: Bool?
let isDeleted: Bool?
let modelMode: String?
let modelId: String?
let lastMessageAt: String?
let createdAt: String?
let updatedAt: String?
}
struct ChatMessage: Codable, Identifiable {
let id: String
let sessionId: String?
let role: String
let content: String
let tokens: Int?
let scopeSnapshot: ChatScopeSnapshot?
let createdAt: String?
let citations: [ChatCitation]?
}
// MARK: - Chat (extras)
struct ChatScopeSnapshot: Codable {
let scopeType: String?
let scopeId: String?
let parentKnowledgeBaseId: String?
}
struct ChatCitation: Codable, Identifiable {
let id: String
let chunkId: String?
let sourceId: String?
let sourceTitle: String?
let excerptText: String?
let pageNumber: Int?
let lineStart: Int?
let lineEnd: Int?
}
struct CreateSessionRequest: Codable {
let scopeType: String
let scopeId: String?
let parentKnowledgeBaseId: String?
let createdFrom: String
let title: String?
let forceCreate: Bool?
let type: String?
let id: String?
let name: String?
}
struct UpdateChatSessionRequest: Codable {
var title: String?
var isPinned: Bool?
var isArchived: Bool?
var modelMode: String?
var modelId: String?
let title: String?
let isPinned: Bool?
let isArchived: Bool?
}
struct SendMessageRequest: Codable {
@ -574,55 +216,15 @@ struct SendMessageRequest: Codable {
}
struct SendMessageResponse: Codable {
let id: String?
let role: String?
let content: String?
let tokens: Int?
let blocked: Bool?
let message: ChatMessage?
let citations: [ChatCitation]?
let messageId: String?
let sessionId: String?
}
// MARK: - Document Import
struct CreateImportRequest: Codable {
let knowledgeBaseId: String
let fileName: String?
let sourceType: String?
let rawText: String?
}
struct ImportStatusResponse: Codable {
let id: String
let jobId: String?
let status: String?
let fileName: String?
let knowledgeBaseId: String?
let errorMessage: String?
let progress: Int?
let itemCount: Int?
}
struct ParseResponse: Codable {
let id: String?
let jobId: String?
let status: String?
}
// MARK: - Import Candidate
struct ImportCandidate: Codable, Identifiable {
let id: String
let sourceId: String?
let title: String?
let content: String?
let status: String?
let confidence: Double?
let createdAt: String?
}
struct BatchAcceptRequest: Codable {
let sourceId: String
let text: String?
let textLength: Int?
}
// MARK: - Knowledge Folder
@ -630,11 +232,10 @@ struct BatchAcceptRequest: Codable {
struct KnowledgeFolder: Codable, Identifiable {
let id: String
let knowledgeBaseId: String?
let parentId: String?
let name: String
let sortOrder: Int?
let parentId: String?
let orderIndex: Int?
let createdAt: String?
let updatedAt: String?
}
struct CreateFolderRequest: Codable {
@ -642,54 +243,6 @@ struct CreateFolderRequest: Codable {
let parentId: String?
}
// MARK: - Knowledge Source
struct KnowledgeSource: Codable, Identifiable {
let id: String
let knowledgeBaseId: String?
let fileId: String?
let title: String?
let originalFilename: String?
let type: String?
let mimeType: String?
let parseStatus: String?
let indexStatus: String?
let textLength: Int?
let createdAt: String?
}
struct AddSourceRequest: Codable {
let fileId: String?
let type: String?
let title: String?
let originalFilename: String?
let mimeType: String?
let sizeBytes: Int?
let originalObjectKey: String?
}
// MARK: - Learning Activity
struct ActivityTrend: Codable {
let date: String?
let value: Double?
let label: String?
}
struct ActivityStreak: Codable {
let currentStreak: Int?
let longestStreak: Int?
let lastActiveDate: String?
}
struct ActivityRecommendation: Codable, Identifiable {
var id: String { title ?? UUID().uuidString }
let title: String?
let description: String?
let type: String?
let priority: String?
}
// MARK: - File Upload (COS presigned URL flow)
struct FileUploadUrlRequest: Codable {
@ -699,8 +252,8 @@ struct FileUploadUrlRequest: Codable {
}
struct FileUploadUrlResponse: Codable {
let uploadId: String
let uploadUrl: String
let uploadId: String
let objectKey: String
let bucket: String
let region: String
@ -715,22 +268,15 @@ struct FileConfirmUploadRequest: Codable {
struct FileConfirmUploadResponse: Codable {
let id: String
let filename: String
let objectKey: String
let sizeBytes: Int
}
struct FileDetailResponse: Codable {
let file: FileInfo
let downloadUrl: String
}
struct FileInfo: Codable {
let id: String
let filename: String
let mimeType: String?
let sizeBytes: Int
let objectKey: String?
let storagePath: String?
let createdAt: String?
let url: String?
let sizeBytes: Int?
}

View File

@ -0,0 +1,34 @@
import Foundation
struct ActivitySummary: Codable {
let dailyAverage: Int?
let totalMinutes: Int?
let totalCardsReviewed: Int?
let activeDays: Int?
let totalSessions: Int?
let totalEvents: Int?
}
struct ActivityHeatmap: Codable {
let dates: [String: Int]?
}
struct ActivityTrend: Codable, Identifiable {
var id: String { date }
let date: String
let value: Int
}
struct ActivityStreak: Codable {
let currentStreak: Int?
let longestStreak: Int?
let lastActiveDate: String?
}
struct ActivityRecommendation: Codable, Identifiable {
var id: String { title ?? UUID().uuidString }
let type: String?
let title: String?
let description: String?
let priority: String?
}

View File

@ -0,0 +1,30 @@
import Foundation
struct AuthResponse: Codable {
let accessToken: String
let refreshToken: String
let user: AuthUser?
}
struct AuthUser: Codable, Identifiable {
let id: String
let email: String?
let nickname: String?
let avatar: String?
}
struct AppleAuthRequest: Codable {
let identityToken: String
let authorizationCode: String?
let fullName: AppleFullName?
let nonce: String?
struct AppleFullName: Codable {
let givenName: String?
let familyName: String?
}
}
struct RefreshRequest: Codable {
let refreshToken: String
}

View File

@ -0,0 +1,72 @@
import Foundation
// MARK: - Chat Session
struct ChatSession: Codable, Identifiable {
let id: String
let title: String?
let scopeType: String?
let scopeId: String?
let parentKnowledgeBaseId: String?
let messageCount: Int?
let isPinned: Bool?
let createdAt: String?
let updatedAt: String?
}
struct CreateSessionRequest: Codable {
let scopeType: String
let scopeId: String?
let parentKnowledgeBaseId: String?
let createdFrom: String?
let title: String?
let forceCreate: Bool?
}
// MARK: - Chat Message
struct ChatMessage: Codable, Identifiable {
let id: String
let sessionId: String?
let role: String?
let content: String
let toolCalls: String?
let citations: [ChatCitation]?
let scopeSnapshot: ChatScopeSnapshot?
let createdAt: String?
}
struct ChatCitation: Codable {
let id: String
let title: String?
let content: String?
}
struct SaveChatMessageAsKnowledgeResponse: Codable {
let success: Bool
let message: String
let taskId: String?
let sourceId: String?
let chatMessageId: String?
let aiJobId: String?
let status: String?
let knowledgeItemCount: Int?
let errorCode: String?
let errorMessage: String?
}
struct ChatEntryContext: Codable, Hashable {
let scopeType: ChatScopeType
let scopeId: String?
let scopeName: String?
let parentKnowledgeBaseId: String?
let createdFrom: String?
}
enum ChatScopeType: String, Codable {
case knowledgeBase = "knowledge_base"
case knowledgeItem = "knowledge_item"
case material
case folder
case global
}

View File

@ -0,0 +1,39 @@
import Foundation
// MARK: - API Envelope
struct APIEnvelope<T: Decodable>: Decodable {
let success: Bool
let data: T
let timestamp: String?
}
// MARK: - Pagination
struct PaginationMeta: Codable {
let page: Int
let limit: Int
let total: Int
let totalPages: Int
}
struct PaginatedResponse<T: Decodable>: Decodable {
let items: [T]
let total: Int
let page: Int
let limit: Int
let totalPages: Int
}
// MARK: - Generic
struct GenericSuccessResponse: Codable {
let success: Bool
let message: String?
let processed: Int?
}
struct BatchDeleteResponse: Codable {
let deleted: Int
let message: String?
}

View File

@ -0,0 +1,16 @@
import Foundation
struct FocusItem: Codable, Identifiable {
let id: String
let userId: String?
let knowledgeBaseId: String?
let title: String
let content: String?
let masteryScore: Int?
let priority: String?
let status: String?
let sourceType: String?
let sourceId: String?
let createdAt: String?
let updatedAt: String?
}

View File

@ -0,0 +1,31 @@
import Foundation
struct KnowledgeBase: Codable, Identifiable {
let id: String
let userId: String?
let title: String
let description: String?
let coverKey: String?
let coverUrl: String?
let coverType: String?
let visibility: String?
let isPinned: Bool?
let ownerType: String?
let isVerified: Bool?
let status: String?
let itemCount: Int?
let lastStudiedAt: String?
let createdAt: String?
let updatedAt: String?
}
struct CreateKnowledgeBaseRequest: Codable {
let title: String
let description: String?
let coverKey: String?
}
struct KnowledgeBaseCoverUploadResult {
let objectKey: String
let url: String?
}

View File

@ -0,0 +1,69 @@
import Foundation
struct GenerateKnowledgeResponse: Codable {
let taskId: String
let sourceId: String?
let chatMessageId: String?
let aiJobId: String?
let status: String
let knowledgeItemCount: Int?
let errorCode: String?
let errorMessage: String?
let message: String
}
struct GenerateKnowledgeBatchResponse: Codable {
let knowledgeBaseId: String
let totalCount: Int
let queuedCount: Int
let waitingForParseCount: Int
let runningCount: Int
let tasks: [GenerateKnowledgeResponse]
let message: String
}
extension GenerateKnowledgeResponse {
var asTaskStatus: KnowledgeGenerationTaskStatus {
KnowledgeGenerationTaskStatus(
taskId: taskId,
sourceId: sourceId,
chatMessageId: chatMessageId,
aiJobId: aiJobId,
status: status,
knowledgeItemCount: knowledgeItemCount,
errorCode: errorCode,
errorMessage: errorMessage,
message: message
)
}
}
struct KnowledgeItem: Codable, Identifiable, Hashable {
let id: String
let userId: String?
let knowledgeBaseId: String?
let parentId: String?
let itemType: String?
let title: String
let content: String?
let summary: String?
let sourceType: String?
let sourceRef: String?
let orderIndex: Int?
let status: String?
let createdAt: String?
let updatedAt: String?
}
struct CreateKnowledgeItemRequest: Codable {
let knowledgeBaseId: String
let title: String
let content: String?
let itemType: String?
}
struct UpdateKnowledgeItemRequest: Codable {
let title: String?
let content: String?
let summary: String?
}

View File

@ -0,0 +1,47 @@
import Foundation
struct KnowledgeSource: Codable, Identifiable {
let id: String
let knowledgeBaseId: String?
let fileId: String?
let title: String?
let originalFilename: String?
let type: String?
let mimeType: String?
let parseStatus: String?
let indexStatus: String?
let learningStatus: String?
let textLength: Int?
let sizeBytes: Int?
let createdAt: String?
let updatedAt: String?
}
struct KnowledgeGenerationTaskStatus: Codable, Identifiable {
let taskId: String
let sourceId: String?
let chatMessageId: String?
let aiJobId: String?
let status: String
let knowledgeItemCount: Int?
let errorCode: String?
let errorMessage: String?
let message: String
var id: String { taskId }
}
struct KnowledgeGenerationTaskListResponse: Codable {
let knowledgeBaseId: String
let items: [KnowledgeGenerationTaskStatus]
}
struct AddSourceRequest: Codable {
let fileId: String?
let type: String?
let title: String?
let originalFilename: String?
let mimeType: String?
let sizeBytes: Int?
let originalObjectKey: String?
}

View File

@ -0,0 +1,57 @@
import Foundation
struct Quiz: Codable, Identifiable {
let id: String
let userId: String?
let knowledgeBaseId: String?
let title: String?
let description: String?
let questionCount: Int?
let sourceType: String?
let sourceId: String?
let status: String?
let createdAt: String?
let updatedAt: String?
}
struct QuizQuestion: Codable, Identifiable {
let id: String
let quizId: String?
let stem: String?
let questionText: String?
let questionType: String?
var questionTypeEnum: QuizQuestionType? {
guard let t = questionType else { return nil }
return QuizQuestionType(rawValue: t)
}
let options: [String]?
let answer: String?
let correctAnswer: String?
let explanation: String?
let orderIndex: Int?
}
struct QuizAttempt: Codable, Identifiable {
let id: String
let quizId: String?
let userId: String?
let score: Int?
let correctCount: Int?
let totalQuestions: Int?
let answers: [QuizAttemptAnswer]?
let completedAt: String?
}
struct QuizAttemptAnswer: Codable, Identifiable {
let id: String
let questionId: String?
let question: QuizQuestion?
let userAnswer: String?
let isCorrect: Bool?
}
struct SubmitQuizAnswerRequest: Codable {
let questionId: String
let answer: String
}

View File

@ -0,0 +1,60 @@
import Foundation
// MARK: - Reading Position (from old ReadingAPI)
struct ReadingPositionValue: Codable, Equatable, Hashable {
let type: String?
let pageNumber: UInt32?
let blockId: String?
let blockProgress: Float?
let scrollProgress: Float?
let lineNumber: UInt32?
let pageProgress: Float?
let overallProgress: Float?
let zoomScale: Float?
let offsetX: Float?
let offsetY: Float?
let chapterId: String?
let chapterProgress: Float?
let offset: Int?
let percent: Float?
let section: String?
}
// MARK: - Continue Learning
struct ContinueLearningResponse: Codable {
let type: String?
let materialId: String?
let title: String?
let lastPosition: ReadingPositionValue?
let lastProgress: Float?
let totalActiveSeconds: Int?
let lastReadAt: String?
}
struct LearningSummaryResponse: Codable {
let todaySeconds: Int
let weekSeconds: Int
let markedReadCount: Int
let materialsReadCount: Int
}
struct LearningTrendResponse: Codable {
let days: Int
let series: [TrendPoint]
struct TrendPoint: Codable {
let date: String
let value: Int
}
}
struct LearningRecordsResponse: Codable {
let items: [RecordItem]
struct RecordItem: Codable, Identifiable {
let id: String
let recordType: String
let title: String
let durationSeconds: Int
}
}

View File

@ -74,14 +74,56 @@ private struct PositionWrapper: Encodable {
init(from pos: ReadingPosition) {
switch pos {
case .block(let blockId, let blockProgress, let overallProgress):
raw = [
"type": .string("Block"),
"blockId": .string(blockId),
"blockProgress": .float(blockProgress),
"overallProgress": .float(overallProgress)
]
case .markdown(let blockId, let scrollProgress):
raw = ["type": .string("Markdown"), "blockId": .string(blockId), "scrollProgress": .float(scrollProgress)]
case .text(let lineNumber, let scrollProgress):
raw = ["type": .string("Text"), "lineNumber": .int(Int(lineNumber)), "scrollProgress": .float(scrollProgress)]
case .pdf(let pageNumber, let pageProgress, let overallProgress):
raw = ["type": .string("Pdf"), "pageNumber": .int(Int(pageNumber)), "pageProgress": .float(pageProgress), "overallProgress": .float(overallProgress)]
case .pdfViewport(let pageNumber, let anchorX, let anchorY, let zoomScale, let overallProgress):
raw = [
"type": .string("PdfViewport"),
"pageNumber": .int(Int(pageNumber)),
"anchorX": .float(anchorX),
"anchorY": .float(anchorY),
"zoomScale": .float(zoomScale),
"overallProgress": .float(overallProgress)
]
case .image(let zoomScale, let offsetX, let offsetY):
raw = ["type": .string("Image"), "zoomScale": .float(zoomScale), "offsetX": .float(offsetX), "offsetY": .float(offsetY)]
case .imageViewport(let zoomScale, let anchorX, let anchorY):
raw = ["type": .string("ImageViewport"), "zoomScale": .float(zoomScale), "anchorX": .float(anchorX), "anchorY": .float(anchorY)]
case .workbook(let sheetId, let topRow, let leftColumn, let selectedCell, let zoomScale, let overallProgress):
var value: [String: AnyCodableValue] = [
"type": .string("Workbook"),
"sheetId": .string(sheetId),
"topRow": .int(Int(topRow)),
"leftColumn": .int(Int(leftColumn)),
"zoomScale": .float(zoomScale)
]
if let selectedCell {
value["selectedCell"] = .string(selectedCell)
}
if let overallProgress {
value["overallProgress"] = .float(overallProgress)
}
raw = value
case .slide(let slideIndex, let zoomScale, let anchorX, let anchorY, let overallProgress):
raw = [
"type": .string("Slide"),
"slideIndex": .int(Int(slideIndex)),
"zoomScale": .float(zoomScale),
"anchorX": .float(anchorX),
"anchorY": .float(anchorY),
"overallProgress": .float(overallProgress)
]
case .epub(let chapterId, let chapterProgress, let overallProgress):
raw = ["type": .string("Epub"), "chapterId": .string(chapterId), "chapterProgress": .float(chapterProgress), "overallProgress": .float(overallProgress)]
case .unknown:

View File

@ -72,8 +72,8 @@ extension PreviewMode: Codable {
// MARK: - ReadingMaterialContext
/// iOS-side context that supplements Rust's materialId with business fields.
/// Rust only knows materialId; iOS adds readingTargetType, knowledgeBaseId,
/// material type metadata, and local file info for API upload.
/// Carries file metadata needed to build `ReadingMaterialRefV2` for
/// the Rust V2 reading session FFI.
struct ReadingMaterialContext: Codable, Equatable {
let readingTargetType: ReadingTargetType
let materialId: String
@ -83,6 +83,13 @@ struct ReadingMaterialContext: Codable, Equatable {
let materialType: MaterialType?
let previewMode: PreviewMode?
// V2 material metadata (for ReadingMaterialRefV2)
let filePath: String
let fileName: String
let fileType: String // lowercase: "markdown", "pdf", "epub", etc.
let mimeType: String?
let fileSize: UInt64?
init(
readingTargetType: ReadingTargetType,
materialId: String,
@ -90,7 +97,12 @@ struct ReadingMaterialContext: Codable, Equatable {
title: String? = nil,
localFileURL: URL? = nil,
materialType: MaterialType? = nil,
previewMode: PreviewMode? = nil
previewMode: PreviewMode? = nil,
filePath: String = "",
fileName: String = "",
fileType: String = "unknown",
mimeType: String? = nil,
fileSize: UInt64? = nil
) {
self.readingTargetType = readingTargetType
self.materialId = materialId
@ -99,5 +111,29 @@ struct ReadingMaterialContext: Codable, Equatable {
self.localFileURL = localFileURL
self.materialType = materialType
self.previewMode = previewMode
self.filePath = filePath
self.fileName = fileName
self.fileType = fileType
self.mimeType = mimeType
self.fileSize = fileSize
}
}
// MARK: - MaterialType file_type string
extension MaterialType {
/// Lowercase string matching `ReadingMaterialRefV2.fileType` Rust convention.
var fileTypeString: String {
switch self {
case .markdown: return "markdown"
case .text: return "text"
case .pdf: return "pdf"
case .image: return "image"
case .epub: return "epub"
case .word: return "word"
case .excel: return "excel"
case .powerPoint: return "powerpoint"
case .unknown: return "unknown"
}
}
}

View File

@ -0,0 +1,69 @@
import Foundation
// MARK: - User Profile
struct UserProfileResponse: Codable, Identifiable {
let id: String
let email: String?
let nickname: String?
let avatarUrl: String?
let role: String?
let status: String?
let onboardingCompleted: Bool?
let createdAt: String?
let profile: UserProfileData?
let preferences: UserPreferences?
}
struct UserProfileData: Codable {
let learningIdentity: String?
let learningDirection: String?
let bio: String?
let currentGoal: String?
}
struct UserPreferences: Codable, Equatable {
let preferredMethods: [String]?
let defaultFocusMinutes: Int?
let aiSuggestionLevel: String?
let language: String?
let appearance: String?
let notificationEnabled: Bool?
}
struct UpdateProfileRequest: Codable {
let nickname: String?
let avatarUrl: String?
}
struct UpdatePreferencesRequest: Codable {
let preferredMethods: [String]?
let defaultFocusMinutes: Int?
let aiSuggestionLevel: String?
let language: String?
let appearance: String?
let notificationEnabled: Bool?
}
struct UpdateProfileDataRequest: Codable {
let learningIdentity: String?
let learningDirection: String?
let bio: String?
let currentGoal: String?
}
// MARK: - Device
struct UserDevice: Codable, Identifiable {
let id: String
let deviceName: String?
let platform: String?
let lastActiveAt: String?
}
// MARK: - Notification Preferences
struct NotificationPreferences: Codable {
let pushEnabled: Bool?
let emailEnabled: Bool?
}

View File

@ -24,17 +24,14 @@ enum Route: Hashable {
case learningSession(taskTitle: String, taskType: String, taskColorHex: String)
case studyHome
// Import
case importReview(sourceId: String)
// Quiz
case quizList(knowledgeBaseId: String)
case quizTake(quizId: String)
case quizResult(quizId: String, attemptId: String)
// Material Reader
case materialReader(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "")
case materialDetail(knowledgeBaseId: String, fileName: String, fileType: MaterialType, fileSize: UInt64, filePath: String, uploadDate: String?)
case materialReader(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "", resumePosition: ReadingPositionValue? = nil)
case materialDetail(knowledgeBaseId: String, sourceId: String, fileName: String, fileType: MaterialType, fileSize: UInt64, filePath: String, uploadDate: String?)
// Membership
case membership
@ -75,7 +72,6 @@ extension Route {
case .feedbackForm: "FeedbackFormView"
case .membership: "MembershipView"
case .editProfile: "EditProfilePage"
case .importReview: "ImportReviewPage"
case .quizList: "QuizListView"
case .quizTake: "QuizTakerView"
case .quizResult: "QuizResultView"
@ -116,13 +112,12 @@ extension Route {
case .methodPreference: AnyView(MethodPreferenceView())
case .feedbackForm: AnyView(FeedbackFormView())
case .editProfile: AnyView(EditProfilePage())
case .importReview(let sourceId): AnyView(ImportReviewPage(sourceId: sourceId))
case .quizList(let kbId): AnyView(QuizListView(knowledgeBaseId: kbId))
case .quizTake(let id): AnyView(QuizTakerView(quizId: id))
case .quizResult(let qid, let aid): AnyView(QuizResultView(quizId: qid, attemptId: aid))
case .materialReader(let mid, let path, let mt, let kbId, let title): AnyView(MaterialReaderView(materialId: mid, filePath: path, materialType: mt, knowledgeBaseId: kbId, title: title))
case .materialDetail(let kbId, let name, let type, let size, let path, let date):
AnyView(MaterialDetailView(knowledgeBaseId: kbId, fileName: name, fileType: type, fileSize: size, filePath: path, uploadDate: date))
case .materialReader(let mid, let path, let mt, let kbId, let title, let rp): AnyView(MaterialReaderView(materialId: mid, filePath: path, materialType: mt, knowledgeBaseId: kbId, title: title, resumePosition: rp))
case .materialDetail(let kbId, let sourceId, let name, let type, let size, let path, let date):
AnyView(MaterialDetailView(knowledgeBaseId: kbId, sourceId: sourceId, fileName: name, fileType: type, fileSize: size, filePath: path, uploadDate: date))
}
}()

View File

@ -166,12 +166,19 @@ extension APIClient {
private func decodeResponse<T: Decodable>(_ data: Data) throws -> T {
let decoder = JSONDecoder()
// Try unwrapped response first (no envelope), then wrapped
// Check for error envelope first: { success: false, statusCode, message }
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
let success = json["success"] as? Bool, !success {
let message = json["message"] as? String ?? "请求失败"
let code = json["errorCode"] as? String
throw APIError.serverError(message, code: code)
}
// Try unwrapped response (no envelope)
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any],
json["data"] == nil && json["success"] == nil {
return try decoder.decode(T.self, from: data)
}
// Has envelope wrapper
// Has envelope wrapper: { success: true, data: ..., timestamp }
do {
let envelope = try decoder.decode(APIEnvelope<T>.self, from: data)
return envelope.data

View File

@ -0,0 +1,12 @@
import Foundation
@MainActor
final class AIAnalysisService {
static let shared = AIAnalysisService()
private let client = APIClient.shared
func analyze(questionText: String, knowledgeItemContent: String, userAnswer: String) async throws -> AIAnalysisResult {
let body = AIAnalysisRequest(questionText: questionText, knowledgeItemContent: knowledgeItemContent, userAnswer: userAnswer, text: nil, type: nil)
return try await client.request("/ai-analysis", method: "POST", body: body)
}
}

View File

@ -0,0 +1,19 @@
import Foundation
@MainActor
final class ActiveRecallService {
static let shared = ActiveRecallService()
private let client = APIClient.shared
func questions(page: Int = 1, limit: Int = 20) async throws -> [ActiveRecallQuestion] {
try await client.request("/active-recalls", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func submit(questionId: String, answerText: String) async throws -> ActiveRecallAnswer {
let body = SubmitAnswerRequest(answerText: answerText)
return try await client.request("/active-recalls/\(questionId)/submit", method: "POST", body: body)
}
}

View File

@ -0,0 +1,28 @@
import Foundation
@MainActor
final class ActivityService {
static let shared = ActivityService()
private let client = APIClient.shared
func summary() async throws -> ActivitySummary {
try await client.request("/activity/summary")
}
func heatmap() async throws -> [String: Int] {
try await client.request("/activity/heatmap")
}
func trend(days: Int = 7) async throws -> [ActivityTrend] {
try await client.request("/activity/trend?days=\(days)")
}
func streak() async throws -> ActivityStreak {
let offset = TimeZone.current.secondsFromGMT() / 60
return try await client.request("/activity/streak?tzOffset=\(offset)")
}
func recommendations() async throws -> [ActivityRecommendation] {
try await client.request("/activity/recommendations")
}
}

View File

@ -0,0 +1,25 @@
import Foundation
@MainActor
final class AuthService {
static let shared = AuthService()
private let client = APIClient.shared
func appleLogin(
identityToken: String,
authorizationCode: String? = nil,
givenName: String?,
familyName: String?,
nonce: String? = nil
) async throws -> AuthResponse {
let body = AppleAuthRequest(
identityToken: identityToken,
authorizationCode: authorizationCode,
fullName: givenName != nil
? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName)
: nil,
nonce: nonce
)
return try await client.request("/auth/apple", method: "POST", body: body)
}
}

View File

@ -0,0 +1,102 @@
import Foundation
// MARK: - SSE Stream Types
struct SSEChunk: Codable {
let type: String?
let delta: String?
let content: String?
let error: String?
let conversationId: String?
let toolCalls: String?
let citations: String?
}
// MARK: - Service
@MainActor
final class RagChatService {
static let shared = RagChatService()
private let client = APIClient.shared
func createSession(ctx: ChatEntryContext, forceCreate: Bool = false) async throws -> ChatSession {
let body = CreateSessionRequest(
scopeType: ctx.scopeType.rawValue, scopeId: ctx.scopeId,
parentKnowledgeBaseId: ctx.parentKnowledgeBaseId,
createdFrom: ctx.createdFrom, title: nil, forceCreate: forceCreate ? true : nil
)
return try await client.request("/rag-chat/sessions", method: "POST", body: body)
}
func listSessions(scopeType: String? = nil, scopeId: String? = nil, parentKnowledgeBaseId: String? = nil) async throws -> [ChatSession] {
var items: [URLQueryItem] = []
if let st = scopeType { items.append(URLQueryItem(name: "scopeType", value: st)) }
if let si = scopeId { items.append(URLQueryItem(name: "scopeId", value: si)) }
if let pk = parentKnowledgeBaseId { items.append(URLQueryItem(name: "parentKnowledgeBaseId", value: pk)) }
return try await client.request("/rag-chat/sessions", queryItems: items.isEmpty ? nil : items)
}
func messages(sessionId: String) async throws -> [ChatMessage] {
try await client.request("/rag-chat/sessions/\(sessionId)/messages")
}
func getMessages(sessionId: String) async throws -> [ChatMessage] {
try await messages(sessionId: sessionId)
}
func updateSession(id: String, dto: UpdateChatSessionRequest) async throws -> ChatSession {
try await client.request("/rag-chat/sessions/\(id)", method: "PATCH", body: dto)
}
func deleteSession(id: String) async throws -> GenericSuccessResponse {
try await client.request("/rag-chat/sessions/\(id)", method: "DELETE")
}
func saveMessageAsKnowledge(
sessionId: String,
messageId: String,
knowledgeBaseId: String? = nil,
title: String? = nil,
summary: String? = nil
) async throws -> SaveChatMessageAsKnowledgeResponse {
let body: [String: String?] = [
"knowledgeBaseId": knowledgeBaseId,
"title": title,
"summary": summary
]
return try await client.request(
"/rag-chat/sessions/\(sessionId)/messages/\(messageId)/save-as-knowledge",
method: "POST",
body: body
)
}
// MARK: - SSE Streaming
func sendMessageStream(sessionId: String, content: String) -> AsyncThrowingStream<SSEChunk, Error> {
AsyncThrowingStream { continuation in
Task {
let token = await client.getToken() ?? ""
let url = URL(string: "\(APIConfig.baseURL)/rag-chat/sessions/\(sessionId)/stream")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = try? JSONEncoder().encode(SendMessageRequest(content: content))
do {
let (bytes, _) = try await URLSession.shared.bytes(for: request)
for try await line in bytes.lines {
guard let data = line.data(using: .utf8),
let chunk = try? JSONDecoder().decode(SSEChunk.self, from: data) else { continue }
continuation.yield(chunk)
if chunk.type == "done" || chunk.type == "error" { break }
}
continuation.finish()
} catch {
continuation.finish(throwing: error)
}
}
}
}
}

View File

@ -0,0 +1,12 @@
import Foundation
@MainActor
final class FeedbackService {
static let shared = FeedbackService()
private let client = APIClient.shared
func submit(category: String = "general", content: String, email: String? = nil) async throws -> FeedbackData {
let body = FeedbackCreateRequest(category: category, content: content, email: email)
return try await client.request("/feedback", method: "POST", body: body)
}
}

View File

@ -0,0 +1,32 @@
import Foundation
struct FocusItemUpdateRequest: Codable {
let masteryScore: Int?
let title: String?
}
struct FocusItemCompleteRequest: Codable {
let completed: Bool
}
@MainActor
final class FocusItemService {
static let shared = FocusItemService()
private let client = APIClient.shared
func list(page: Int = 1, limit: Int = 20) async throws -> [FocusItem] {
try await client.request("/focus-items", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func update(id: String, masteryScore: Int? = nil, title: String? = nil) async throws -> FocusItem {
let body = FocusItemUpdateRequest(masteryScore: masteryScore, title: title)
return try await client.request("/focus-items/\(id)", method: "PATCH", body: body)
}
func complete(id: String) async throws -> GenericSuccessResponse {
try await client.request("/focus-items/\(id)/complete", method: "POST")
}
}

View File

@ -0,0 +1,38 @@
import Foundation
// MARK: - Document Import
@MainActor
final class DocumentImportService {
static let shared = DocumentImportService()
private let client = APIClient.shared
func status(importId: String) async throws -> ImportStatusResponse {
try await client.request("/document-import/\(importId)")
}
func create(knowledgeBaseId: String, fileName: String? = nil, sourceType: String? = nil, rawText: String? = nil) async throws -> ImportStatusResponse {
let body: [String: String] = [
"knowledgeBaseId": knowledgeBaseId,
"fileName": fileName ?? "",
"sourceType": sourceType ?? "",
"rawText": rawText ?? "",
]
return try await client.request("/document-import", method: "POST", body: body)
}
}
struct ImportStatusResponse: Codable {
let id: String
let status: String?
let progress: Int?
let errorMessage: String?
}
struct CreateImportRequest: Codable {
let knowledgeBaseId: String
let fileId: String?
let sourceUrl: String?
let text: String?
let title: String?
}

View File

@ -0,0 +1,56 @@
import Foundation
@MainActor
final class KnowledgeBaseService {
static let shared = KnowledgeBaseService()
private let client = APIClient.shared
func list(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] {
try await client.request("/knowledge-bases", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func create(title: String, description: String?, coverKey: String? = nil) async throws -> KnowledgeBase {
let body = CreateKnowledgeBaseRequest(title: title, description: description, coverKey: coverKey)
return try await client.request("/knowledge-bases", method: "POST", body: body)
}
func detail(id: String) async throws -> KnowledgeBase {
try await client.request("/knowledge-bases/\(id)")
}
func update(id: String, title: String?, description: String?) async throws -> KnowledgeBase {
let body = CreateKnowledgeBaseRequest(title: title ?? "", description: description, coverKey: nil)
return try await client.request("/knowledge-bases/\(id)", method: "PATCH", body: body)
}
func delete(id: String) async throws -> GenericSuccessResponse {
try await client.request("/knowledge-bases/\(id)", method: "DELETE")
}
func subscribe(kbId: String) async throws -> GenericSuccessResponse {
try await client.request("/knowledge-bases/\(kbId)/subscribe", method: "POST")
}
func unsubscribe(kbId: String) async throws -> GenericSuccessResponse {
try await client.request("/knowledge-bases/\(kbId)/subscribe", method: "DELETE")
}
func listSubscribed(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] {
try await client.request("/knowledge-bases/subscribed", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func discover(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] {
try await client.request("/knowledge-bases", queryItems: [
URLQueryItem(name: "visibility", value: "public"),
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
}

View File

@ -0,0 +1,25 @@
import Foundation
@MainActor
final class KnowledgeFolderService {
static let shared = KnowledgeFolderService()
private let client = APIClient.shared
func list(knowledgeBaseId: String) async throws -> [KnowledgeFolder] {
try await client.request("/knowledge-bases/\(knowledgeBaseId)/folders")
}
func create(kbId: String, name: String, parentId: String? = nil) async throws -> KnowledgeFolder {
let body = CreateFolderRequest(name: name, parentId: parentId)
return try await client.request("/knowledge-bases/\(kbId)/folders", method: "POST", body: body)
}
func update(kbId: String, folderId: String, name: String) async throws -> KnowledgeFolder {
let body = CreateFolderRequest(name: name, parentId: nil)
return try await client.request("/knowledge-bases/\(kbId)/folders/\(folderId)", method: "PATCH", body: body)
}
func delete(kbId: String, folderId: String) async throws -> GenericSuccessResponse {
try await client.request("/knowledge-bases/\(kbId)/folders/\(folderId)", method: "DELETE")
}
}

View File

@ -0,0 +1,36 @@
import Foundation
@MainActor
final class KnowledgeItemService {
static let shared = KnowledgeItemService()
private let client = APIClient.shared
func list(knowledgeBaseId: String, sortBy: String? = nil, order: String? = nil) async throws -> [KnowledgeItem] {
var items = [URLQueryItem(name: "knowledgeBaseId", value: knowledgeBaseId)]
if let s = sortBy { items.append(URLQueryItem(name: "sortBy", value: s)) }
if let o = order { items.append(URLQueryItem(name: "order", value: o)) }
return try await client.request("/knowledge-items", queryItems: items)
}
func detail(id: String) async throws -> KnowledgeItem {
try await client.request("/knowledge-items/\(id)")
}
func create(knowledgeBaseId: String, title: String, content: String?, itemType: String? = nil) async throws -> KnowledgeItem {
let body = CreateKnowledgeItemRequest(knowledgeBaseId: knowledgeBaseId, title: title, content: content, itemType: itemType)
return try await client.request("/knowledge-items", method: "POST", body: body)
}
func update(id: String, title: String?, content: String?, summary: String?) async throws -> KnowledgeItem {
let body = UpdateKnowledgeItemRequest(title: title, content: content, summary: summary)
return try await client.request("/knowledge-items/\(id)", method: "PATCH", body: body)
}
func delete(id: String) async throws {
let _: GenericSuccessResponse = try await client.request("/knowledge-items/\(id)", method: "DELETE")
}
func batchDelete(ids: [String]) async throws -> BatchDeleteResponse {
try await client.request("/knowledge-items/batch-delete", method: "POST", body: ["ids": ids])
}
}

View File

@ -0,0 +1,67 @@
import Foundation
@MainActor
final class KnowledgeSourceService {
static let shared = KnowledgeSourceService()
private let client = APIClient.shared
func list(kbId: String, sortBy: String? = nil, order: String? = nil) async throws -> [KnowledgeSource] {
var items = [URLQueryItem]()
if let s = sortBy { items.append(URLQueryItem(name: "sortBy", value: s)) }
if let o = order { items.append(URLQueryItem(name: "order", value: o)) }
return try await client.request("/knowledge-bases/\(kbId)/sources", queryItems: items.isEmpty ? nil : items)
}
func detail(kbId: String, id: String) async throws -> KnowledgeSource {
try await client.request("/knowledge-bases/\(kbId)/sources/\(id)")
}
func add(
kbId: String,
fileId: String? = nil,
type: String? = nil,
title: String? = nil,
originalFilename: String? = nil,
mimeType: String? = nil,
sizeBytes: Int? = nil,
originalObjectKey: String? = nil
) async throws -> KnowledgeSource {
let body = AddSourceRequest(
fileId: fileId, type: type, title: title,
originalFilename: originalFilename, mimeType: mimeType,
sizeBytes: sizeBytes, originalObjectKey: originalObjectKey
)
return try await client.request("/knowledge-bases/\(kbId)/sources", method: "POST", body: body)
}
func delete(kbId: String, id: String) async throws -> GenericSuccessResponse {
try await client.request("/knowledge-bases/\(kbId)/sources/\(id)", method: "DELETE")
}
func parse(kbId: String, sourceId: String) async throws -> ParseResponse {
try await client.request("/knowledge-bases/\(kbId)/sources/\(sourceId)/parse", method: "POST")
}
func generateKnowledge(kbId: String, sourceId: String) async throws -> GenerateKnowledgeResponse {
try await client.request("/knowledge-bases/\(kbId)/sources/\(sourceId)/generate-knowledge", method: "POST")
}
func generateKnowledgeBatch(kbId: String, sourceIds: [String]) async throws -> GenerateKnowledgeBatchResponse {
try await client.request(
"/knowledge-bases/\(kbId)/sources/generate-knowledge-batch",
method: "POST",
body: ["sourceIds": sourceIds]
)
}
func latestKnowledgeGenerationTask(kbId: String, sourceId: String) async throws -> KnowledgeGenerationTaskStatus? {
try await client.request("/knowledge-bases/\(kbId)/sources/\(sourceId)/knowledge-generation-task")
}
func knowledgeGenerationTasks(kbId: String, sourceIds: [String] = []) async throws -> KnowledgeGenerationTaskListResponse {
let queryItems = sourceIds.isEmpty
? nil
: [URLQueryItem(name: "sourceIds", value: sourceIds.joined(separator: ","))]
return try await client.request("/knowledge-bases/\(kbId)/sources/knowledge-generation-tasks", queryItems: queryItems)
}
}

View File

@ -0,0 +1,23 @@
import Foundation
@MainActor
final class LearningSessionService {
static let shared = LearningSessionService()
private let client = APIClient.shared
func list(page: Int = 1, limit: Int = 20) async throws -> [LearningSession] {
try await client.request("/learning-sessions", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func start(knowledgeBaseId: String? = nil, knowledgeItemId: String? = nil, mode: String? = nil) async throws -> LearningSession {
let body = CreateLearningSessionRequest(knowledgeBaseId: knowledgeBaseId, knowledgeItemId: knowledgeItemId, mode: mode)
return try await client.request("/learning-sessions", method: "POST", body: body)
}
func end(id: String) async throws -> LearningSession {
try await client.request("/learning-sessions/\(id)/end", method: "POST")
}
}

View File

@ -0,0 +1,27 @@
import Foundation
@MainActor
final class MembershipService {
static let shared = MembershipService()
private let client = APIClient.shared
func myMembership() async throws -> MyMembershipResponse {
try await client.request("/membership/my")
}
func plans() async throws -> MembershipPlanResponse {
try await client.request("/membership/plans")
}
func getPlans() async throws -> MembershipPlanResponse {
try await plans()
}
func submitTransaction(_ dto: TransactionSubmitBody) async throws -> GenericSuccessResponse {
try await client.request("/membership/apple/transactions", method: "POST", body: dto)
}
func entitlements() async throws -> Entitlements {
try await client.request("/membership/entitlements")
}
}

View File

@ -0,0 +1,39 @@
import Foundation
@MainActor
final class NotificationService {
static let shared = NotificationService()
private let client = APIClient.shared
func list(page: Int = 1, limit: Int = 20) async throws -> [NotificationItem] {
try await client.request("/notifications", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func markRead(id: String) async throws -> GenericSuccessResponse {
try await client.request("/notifications/\(id)/read", method: "POST")
}
func markAllRead() async throws -> GenericSuccessResponse {
try await client.request("/notifications/read-all", method: "POST")
}
func unreadCount() async throws -> Int {
let resp: UnreadCountResponse = try await client.request("/notifications/unread-count")
return resp.count
}
func registerPushToken(_ token: String) async throws -> GenericSuccessResponse {
try await client.request("/notifications/push-token", method: "POST", body: ["token": token])
}
func removePushToken(_ token: String) async throws -> GenericSuccessResponse {
try await client.request("/notifications/push-tokens/\(token)", method: "DELETE")
}
}
struct UnreadCountResponse: Codable {
let count: Int
}

View File

@ -0,0 +1,36 @@
import Foundation
@MainActor
final class QuizService {
static let shared = QuizService()
private let client = APIClient.shared
func generate(knowledgeBaseId: String, sourceType: String? = nil, sourceId: String? = nil, questionCount: Int = 5) async throws -> Quiz {
let body = QuizGenerateRequest(
knowledgeBaseId: knowledgeBaseId,
sourceType: sourceType,
sourceId: sourceId,
questionCount: questionCount
)
return try await client.request("/quizzes", method: "POST", body: body)
}
func list(knowledgeBaseId: String? = nil) async throws -> [Quiz] {
var items: [URLQueryItem]? = nil
if let kb = knowledgeBaseId { items = [URLQueryItem(name: "knowledgeBaseId", value: kb)] }
return try await client.request("/quizzes", queryItems: items)
}
func detail(id: String) async throws -> Quiz { try await client.request("/quizzes/\(id)") }
func createAttempt(quizId: String) async throws -> QuizAttemptStartResponse { try await client.request("/quizzes/\(quizId)/attempts", method: "POST") }
func getAttemptQuestions(attemptId: String) async throws -> [QuizQuestion] { try await client.request("/quizzes/attempts/\(attemptId)/questions") }
func start(quizId: String) async throws -> QuizAttempt { try await client.request("/quizzes/\(quizId)/start", method: "POST") }
func submit(quizId: String, attemptId: String, answers: [QuizAnswerItem]) async throws -> QuizSubmitResponse {
let body = QuizSubmitRequest(attemptId: attemptId, answers: answers)
return try await client.request("/quizzes/\(quizId)/submit", method: "POST", body: body)
}
func results(quizId: String, attemptId: String) async throws -> QuizAttempt { try await client.request("/quizzes/\(quizId)/results?attemptId=\(attemptId)") }
func history() async throws -> [QuizAttempt] { try await client.request("/quizzes/history/list") }
}

View File

@ -0,0 +1,57 @@
import Foundation
// MARK: - Reading Progress (DTOs)
struct MaterialReadingProgressDTO: Codable {
let status: String?
let isMarkedRead: Bool?
let lastPosition: ReadingPositionValue?
let totalActiveSeconds: Int?
let sessionCount: Int?
}
// MARK: - Service
@MainActor
final class ReadingAPIService {
static let shared = ReadingAPIService()
private let client = APIClient.shared
// Protocol-compatible: ReadingProgressServicing
func getReadingProgress(materialId: String, targetType: String) async throws -> MaterialReadingProgressDTO {
try await client.request("/materials/\(materialId)/reading-progress?readingTargetType=\(targetType)")
}
// Convenience with default
func getReadingProgress(materialId: String, readingTargetType: String = "material") async throws -> MaterialReadingProgressDTO {
try await getReadingProgress(materialId: materialId, targetType: readingTargetType)
}
func uploadReadingEvents(_ events: [ReadingEventUploadItem]) async throws -> GenericSuccessResponse {
let batch = ReadingEventBatchRequest(events: events)
return try await client.request("/learning/reading-events/batch", method: "POST", body: batch)
}
func uploadReadingEvents(_ batch: ReadingEventBatchRequest) async throws -> GenericSuccessResponse {
try await client.request("/learning/reading-events/batch", method: "POST", body: batch)
}
func getContinueLearning() async throws -> ContinueLearningResponse {
try await client.request("/learning/continue")
}
func getLearningSummary() async throws -> LearningSummaryResponse {
try await client.request("/learning/summary")
}
func getLearningTrend(days: Int = 7) async throws -> LearningTrendResponse {
try await client.request("/learning/trend?days=\(days)")
}
func getLearningRecords(cursor: String? = nil, limit: Int = 10, type: String? = nil) async throws -> LearningRecordsResponse {
var items: [URLQueryItem] = [URLQueryItem(name: "limit", value: String(limit))]
if let c = cursor { items.append(URLQueryItem(name: "cursor", value: c)) }
if let t = type { items.append(URLQueryItem(name: "type", value: t)) }
return try await client.request("/learning/records", queryItems: items)
}
}

View File

@ -0,0 +1,24 @@
import Foundation
@MainActor
final class ReviewService {
static let shared = ReviewService()
private let client = APIClient.shared
func dueCards() async throws -> [ReviewCard] {
try await client.request("/reviews/due")
}
func submit(id: String, rating: String, responseText: String? = nil) async throws -> ReviewCard {
var body: [String: String] = ["rating": rating]
if let text = responseText { body["responseText"] = text }
return try await client.request("/reviews/\(id)/submit", method: "POST", body: body)
}
func history(page: Int = 1, limit: Int = 20) async throws -> [ReviewCard] {
try await client.request("/reviews/history", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
}

View File

@ -0,0 +1,53 @@
import Foundation
@MainActor
final class UserService {
static let shared = UserService()
private let client = APIClient.shared
func myProfile() async throws -> UserProfileResponse {
try await client.request("/users/me")
}
func updateProfile(_ dto: UpdateProfileRequest) async throws -> UserProfileResponse {
try await client.request("/users/me", method: "PATCH", body: dto)
}
func updatePreferences(_ dto: UpdatePreferencesRequest) async throws -> UserPreferences {
try await client.request("/users/me/preferences", method: "PATCH", body: dto)
}
func getProfileDetail() async throws -> UserProfileData {
try await client.request("/users/me/profile")
}
func updateProfileDetail(_ dto: UpdateProfileDataRequest) async throws -> UserProfileData {
try await client.request("/users/me/profile", method: "PATCH", body: dto)
}
func requestAccountDeletion() async throws -> GenericSuccessResponse {
try await client.request("/users/me/deletion-request", method: "POST")
}
func cancelDeletion() async throws -> GenericSuccessResponse {
try await client.request("/users/me/deletion-request", method: "DELETE")
}
func listDevices() async throws -> [UserDevice] {
try await client.request("/users/me/devices")
}
func removeDevice(id: String) async throws -> GenericSuccessResponse {
try await client.request("/users/me/devices/\(id)", method: "DELETE")
}
func fetchAssets() async throws -> AssetsResponse {
try await client.request("/users/me/assets-summary")
}
func fetchStorage() async throws -> StorageResponse {
try await client.request("/users/me/storage")
}
}

View File

@ -0,0 +1,20 @@
import Foundation
@MainActor
final class WaitlistService {
static let shared = WaitlistService()
private let client = APIClient.shared
func join(email: String, name: String? = nil) async throws -> WaitlistResponse {
let body = WaitlistCreateRequest(email: email, name: name)
return try await client.request("/waitlist", method: "POST", body: body)
}
func list() async throws -> [WaitlistEntry] {
try await client.request("/waitlist")
}
func stats() async throws -> WaitlistStats {
try await client.request("/waitlist/stats")
}
}

View File

@ -1,684 +1,27 @@
//
// APIService.swift - API
// APIService.swift API
//
// Service Core/Services/API/
//
// AuthAPI.swift AuthService
// UserAPI.swift UserService
// KnowledgeBaseAPI.swift KnowledgeBaseService
// KnowledgeSourceAPI.swift KnowledgeSourceService
// KnowledgeItemAPI.swift KnowledgeItemService
// ActiveRecallAPI.swift ActiveRecallService
// AIAnalysisAPI.swift AIAnalysisService
// LearningSessionAPI.swift LearningSessionService
// ReviewAPI.swift ReviewService
// FocusItemAPI.swift FocusItemService
// ActivityAPI.swift ActivityService
// FeedbackAPI.swift FeedbackService
// ChatAPI.swift RagChatService
// QuizAPI.swift QuizService
// NotificationAPI.swift NotificationService
// MembershipAPI.swift MembershipService
// ImportAPI.swift DocumentImportService
// WaitlistAPI.swift WaitlistService
// ReadingAPI.swift ReadingAPIService
//
import Foundation
// MARK: - Waitlist
@MainActor
class WaitlistService {
static let shared = WaitlistService()
private let client = APIClient.shared
func join(email: String, nickname: String?, devices: [String]?,
interests: [String]?, painpoint: String?, willingBeta: Bool = true) async throws -> WaitlistEntry {
let body = WaitlistCreateRequest(email: email, nickname: nickname, devices: devices,
interests: interests, painpoint: painpoint, willingBeta: willingBeta)
return try await client.request("/waitlist", method: "POST", body: body)
}
func stats() async throws -> WaitlistStats {
return try await client.request("/waitlist/stats")
}
}
// MARK: - Auth
@MainActor
class AuthService {
static let shared = AuthService()
private let client = APIClient.shared
func appleLogin(identityToken: String, authorizationCode: String? = nil, givenName: String?, familyName: String?, nonce: String? = nil) async throws -> AuthResponse {
let body = AppleAuthRequest(
identityToken: identityToken,
authorizationCode: authorizationCode,
fullName: givenName != nil
? AppleAuthRequest.AppleFullName(givenName: givenName, familyName: familyName)
: nil,
nonce: nonce
)
return try await client.request("/auth/apple", method: "POST", body: body)
}
}
// MARK: - User
@MainActor
class UserService {
static let shared = UserService()
private let client = APIClient.shared
func myProfile() async throws -> UserProfileResponse {
return try await client.request("/users/me")
}
func updateProfile(_ dto: UpdateProfileRequest) async throws -> UserProfileResponse {
return try await client.request("/users/me", method: "PATCH", body: dto)
}
func updatePreferences(_ dto: UpdatePreferencesRequest) async throws -> UserPreferences {
return try await client.request("/users/me/preferences", method: "PATCH", body: dto)
}
func getProfileDetail() async throws -> UserProfileData {
return try await client.request("/users/me/profile")
}
func updateProfileDetail(_ dto: UpdateProfileDataRequest) async throws -> UserProfileData {
return try await client.request("/users/me/profile", method: "PATCH", body: dto)
}
func requestAccountDeletion() async throws -> GenericSuccessResponse {
return try await client.request("/users/me/deletion-request", method: "POST")
}
func cancelDeletion() async throws -> GenericSuccessResponse {
return try await client.request("/users/me/deletion-request", method: "DELETE")
}
func listDevices() async throws -> [UserDevice] {
return try await client.request("/users/me/devices")
}
func removeDevice(id: String) async throws -> GenericSuccessResponse {
return try await client.request("/users/me/devices/\(id)", method: "DELETE")
}
func fetchAssets() async throws -> AssetsResponse {
return try await client.request("/users/me/assets-summary")
}
func fetchStorage() async throws -> StorageResponse {
return try await client.request("/users/me/storage")
}
}
struct UserDevice: Codable, Identifiable {
let id: String
let deviceName: String?
let deviceType: String?
let lastSeenAt: String?
}
// MARK: - Knowledge Base
@MainActor
class KnowledgeBaseService {
static let shared = KnowledgeBaseService()
private let client = APIClient.shared
func list(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] {
return try await client.request("/knowledge-bases", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func create(title: String, description: String?, coverKey: String? = nil) async throws -> KnowledgeBase {
let body = CreateKnowledgeBaseRequest(title: title, description: description, coverKey: coverKey)
return try await client.request("/knowledge-bases", method: "POST", body: body)
}
func detail(id: String) async throws -> KnowledgeBase {
return try await client.request("/knowledge-bases/\(id)")
}
func update(id: String, title: String?, description: String?) async throws -> KnowledgeBase {
let body = CreateKnowledgeBaseRequest(title: title ?? "", description: description)
return try await client.request("/knowledge-bases/\(id)", method: "PATCH", body: body)
}
func listSubscribed(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] {
return try await client.request("/knowledge-bases/subscribed", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func discover(page: Int = 1, limit: Int = 20) async throws -> [KnowledgeBase] {
return try await client.request("/knowledge-bases", queryItems: [
URLQueryItem(name: "visibility", value: "public"),
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func subscribe(kbId: String) async throws -> GenericSuccessResponse {
return try await client.request("/knowledge-bases/\(kbId)/subscribe", method: "POST")
}
func unsubscribe(kbId: String) async throws -> GenericSuccessResponse {
return try await client.request("/knowledge-bases/\(kbId)/subscribe", method: "DELETE")
}
func delete(id: String) async throws -> GenericSuccessResponse {
return try await client.request("/knowledge-bases/\(id)", method: "DELETE")
}
}
// MARK: - Knowledge Folder
@MainActor
class KnowledgeFolderService {
static let shared = KnowledgeFolderService()
private let client = APIClient.shared
func list(kbId: String) async throws -> [KnowledgeFolder] {
return try await client.request("/knowledge-bases/\(kbId)/folders")
}
func create(kbId: String, name: String, parentId: String? = nil) async throws -> KnowledgeFolder {
let body = CreateFolderRequest(name: name, parentId: parentId)
return try await client.request("/knowledge-bases/\(kbId)/folders", method: "POST", body: body)
}
func update(kbId: String, folderId: String, name: String) async throws -> KnowledgeFolder {
return try await client.request("/knowledge-bases/\(kbId)/folders/\(folderId)", method: "PATCH", body: ["name": name])
}
func delete(kbId: String, folderId: String) async throws -> GenericSuccessResponse {
return try await client.request("/knowledge-bases/\(kbId)/folders/\(folderId)", method: "DELETE")
}
}
// MARK: - Knowledge Items
@MainActor
class KnowledgeItemService {
static let shared = KnowledgeItemService()
private let client = APIClient.shared
func list(knowledgeBaseId: String, sortBy: String? = nil, order: String? = nil) async throws -> [KnowledgeItem] {
var items = [URLQueryItem(name: "knowledgeBaseId", value: knowledgeBaseId)]
if let s = sortBy { items.append(URLQueryItem(name: "sortBy", value: s)) }
if let o = order { items.append(URLQueryItem(name: "order", value: o)) }
return try await client.request("/knowledge-items", queryItems: items)
}
func detail(id: String) async throws -> KnowledgeItem {
return try await client.request("/knowledge-items/\(id)")
}
func create(knowledgeBaseId: String, title: String, content: String?, itemType: String? = nil) async throws -> KnowledgeItem {
let body = CreateKnowledgeItemRequest(
knowledgeBaseId: knowledgeBaseId,
title: title,
content: content,
itemType: itemType
)
return try await client.request("/knowledge-items", method: "POST", body: body)
}
func update(id: String, title: String?, content: String?, summary: String?) async throws -> KnowledgeItem {
let body = UpdateKnowledgeItemRequest(title: title, content: content, summary: summary)
return try await client.request("/knowledge-items/\(id)", method: "PATCH", body: body)
}
func delete(id: String) async throws {
let _: GenericSuccessResponse = try await client.request("/knowledge-items/\(id)", method: "DELETE")
}
func batchDelete(ids: [String]) async throws -> BatchDeleteResponse {
return try await client.request("/knowledge-items/batch-delete", method: "POST", body: ["ids": ids])
}
}
// MARK: - Active Recall
@MainActor
class ActiveRecallService {
static let shared = ActiveRecallService()
private let client = APIClient.shared
func questions(page: Int = 1, limit: Int = 20) async throws -> [ActiveRecallQuestion] {
return try await client.request("/active-recalls", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func submit(questionId: String, answerText: String) async throws -> ActiveRecallAnswer {
let body = SubmitAnswerRequest(answerText: answerText)
return try await client.request("/active-recalls/\(questionId)/submit", method: "POST", body: body)
}
}
// MARK: - AI Analysis
@MainActor
class AIAnalysisService {
static let shared = AIAnalysisService()
private let client = APIClient.shared
func analyze(questionText: String, knowledgeItemContent: String, userAnswer: String) async throws -> AIAnalysisResult {
let body = AIAnalysisRequest(
questionText: questionText,
knowledgeItemContent: knowledgeItemContent,
userAnswer: userAnswer,
text: nil,
type: nil
)
return try await client.request("/ai-analysis", method: "POST", body: body)
}
}
// MARK: - Learning Sessions
@MainActor
class LearningSessionService {
static let shared = LearningSessionService()
private let client = APIClient.shared
func list(page: Int = 1, limit: Int = 20) async throws -> [LearningSession] {
return try await client.request("/learning-sessions", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func start(knowledgeBaseId: String? = nil, knowledgeItemId: String? = nil, mode: String? = nil) async throws -> LearningSession {
let body = CreateLearningSessionRequest(
knowledgeBaseId: knowledgeBaseId,
knowledgeItemId: knowledgeItemId,
mode: mode
)
return try await client.request("/learning-sessions", method: "POST", body: body)
}
func end(id: String) async throws -> LearningSession {
return try await client.request("/learning-sessions/\(id)/end", method: "POST")
}
}
// MARK: - Review
@MainActor
class ReviewService {
static let shared = ReviewService()
private let client = APIClient.shared
func dueCards() async throws -> [ReviewCard] {
return try await client.request("/reviews/due")
}
func submit(id: String, rating: String, responseText: String? = nil) async throws -> GenericSuccessResponse {
let body = SubmitReviewRequest(rating: rating, responseText: responseText)
return try await client.request("/reviews/\(id)/submit", method: "POST", body: body)
}
func generateCards() async throws -> GenericSuccessResponse {
return try await client.request("/reviews/generate-cards", method: "POST")
}
}
// MARK: - Focus Items
@MainActor
class FocusItemService {
static let shared = FocusItemService()
private let client = APIClient.shared
func list(page: Int = 1, limit: Int = 20) async throws -> [FocusItem] {
return try await client.request("/focus-items", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func update(id: String, masteryScore: Int? = nil, title: String? = nil) async throws -> FocusItem {
let body = FocusItemUpdateRequest(masteryScore: masteryScore, title: title)
return try await client.request("/focus-items/\(id)", method: "PATCH", body: body)
}
func complete(id: String) async throws -> GenericSuccessResponse {
return try await client.request("/focus-items/\(id)/complete", method: "POST")
}
}
// MARK: - Activity & Stats
@MainActor
class ActivityService {
static let shared = ActivityService()
private let client = APIClient.shared
func summary() async throws -> ActivitySummary {
return try await client.request("/activity/summary")
}
func heatmap() async throws -> [String: Int] {
return try await client.request("/activity/heatmap")
}
func trend(days: Int = 7) async throws -> [ActivityTrend] {
return try await client.request("/activity/trend?days=\(days)")
}
func streak() async throws -> ActivityStreak {
return try await client.request("/activity/streak")
}
func recommendations() async throws -> [ActivityRecommendation] {
return try await client.request("/activity/recommendations")
}
}
// MARK: - Feedback
@MainActor
class FeedbackService {
static let shared = FeedbackService()
private let client = APIClient.shared
func submit(category: String = "general", content: String, email: String? = nil) async throws -> FeedbackData {
let body = FeedbackCreateRequest(category: category, content: content, email: email)
return try await client.request("/feedback", method: "POST", body: body)
}
}
// MARK: - RAG Chat
@MainActor
class RagChatService {
static let shared = RagChatService()
private let client = APIClient.shared
func createSession(ctx: ChatEntryContext, forceCreate: Bool = false) async throws -> ChatSession {
let body = CreateSessionRequest(
scopeType: ctx.scopeType.rawValue,
scopeId: ctx.scopeId,
parentKnowledgeBaseId: ctx.parentKnowledgeBaseId,
createdFrom: ctx.createdFrom,
title: nil,
forceCreate: forceCreate ? true : nil
)
print("[RagChat] POST /rag-chat/sessions — scopeType=\(body.scopeType), scopeId=\(body.scopeId ?? "nil"), parentKB=\(body.parentKnowledgeBaseId ?? "nil"), forceCreate=\(forceCreate)")
do {
let session = try await client.request("/rag-chat/sessions", method: "POST", body: body) as ChatSession
print("[RagChat] createSession OK — id=\(session.id), scopeType=\(session.scopeType ?? "nil")")
return session
} catch {
print("[RagChat] createSession FAILED: \(error)")
throw error
}
}
func listSessions(scopeType: String? = nil, scopeId: String? = nil, parentKnowledgeBaseId: String? = nil) async throws -> [ChatSession] {
var items: [URLQueryItem] = []
if let st = scopeType { items.append(URLQueryItem(name: "scopeType", value: st)) }
if let si = scopeId { items.append(URLQueryItem(name: "scopeId", value: si)) }
if let pk = parentKnowledgeBaseId { items.append(URLQueryItem(name: "parentKnowledgeBaseId", value: pk)) }
let page: PaginatedResponse<ChatSession> = try await client.request("/rag-chat/sessions", queryItems: items.isEmpty ? nil : items)
return page.data
}
func getMessages(sessionId: String) async throws -> [ChatMessage] {
return try await client.request("/rag-chat/sessions/\(sessionId)/messages")
}
func sendMessage(sessionId: String, content: String) async throws -> SendMessageResponse {
let body = SendMessageRequest(content: content)
return try await client.request("/rag-chat/sessions/\(sessionId)/messages", method: "POST", body: body)
}
func updateSession(id: String, dto: UpdateChatSessionRequest) async throws -> ChatSession {
return try await client.request("/rag-chat/sessions/\(id)", method: "PATCH", body: dto)
}
func deleteSession(id: String) async throws {
let _: GenericSuccessResponse = try await client.request("/rag-chat/sessions/\(id)", method: "DELETE")
}
struct SSEChunk: Decodable {
let type: String
let content: String?
}
func sendMessageStream(sessionId: String, content: String) -> AsyncThrowingStream<SSEChunk, Error> {
AsyncThrowingStream { continuation in
Task {
let token = await APIClient.shared.getToken() ?? ""
let url = URL(string: "\(APIConfig.baseURL)/rag-chat/sessions/\(sessionId)/stream")!
var request = URLRequest(url: url)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
request.httpBody = try? JSONEncoder().encode(SendMessageRequest(content: content))
let session = URLSession(configuration: .default)
let (bytes, _) = try await session.bytes(for: request)
// Read SSE lines properly URLSession.bytes is character-based (UTF-8 safe)
var lineBuffer = ""
for try await line in bytes.lines {
if line.hasPrefix("data: "), let jsonData = line.dropFirst(6).data(using: .utf8) {
if let chunk = try? JSONDecoder().decode(SSEChunk.self, from: jsonData) {
continuation.yield(chunk)
if chunk.type == "done" || chunk.type == "error" {
continuation.finish()
return
}
}
}
}
continuation.finish()
}
}
}
}
// MARK: - Document Import
@MainActor
class DocumentImportService {
static let shared = DocumentImportService()
private let client = APIClient.shared
func create(knowledgeBaseId: String, fileName: String? = nil, sourceType: String? = "file", rawText: String? = nil) async throws -> ImportStatusResponse {
let body = CreateImportRequest(knowledgeBaseId: knowledgeBaseId, fileName: fileName, sourceType: sourceType, rawText: rawText)
return try await client.request("/imports", method: "POST", body: body)
}
func getStatus(id: String) async throws -> ImportStatusResponse {
return try await client.request("/imports/\(id)/status")
}
}
// MARK: - Import Candidate
@MainActor
class ImportCandidateService {
static let shared = ImportCandidateService()
private let client = APIClient.shared
func listBySource(sourceId: String) async throws -> [ImportCandidate] {
return try await client.request("/knowledge-sources/\(sourceId)/import-candidates")
}
func detail(id: String) async throws -> ImportCandidate {
return try await client.request("/import-candidates/\(id)")
}
func update(id: String, title: String?, content: String?) async throws -> ImportCandidate {
var body: [String: String] = [:]
if let t = title { body["title"] = t }
if let c = content { body["content"] = c }
return try await client.request("/import-candidates/\(id)", method: "PATCH", body: body)
}
func accept(id: String) async throws -> GenericSuccessResponse {
return try await client.request("/import-candidates/\(id)/accept", method: "POST")
}
func reject(id: String) async throws -> GenericSuccessResponse {
return try await client.request("/import-candidates/\(id)/reject", method: "POST")
}
func batchAccept(sourceId: String) async throws -> GenericSuccessResponse {
let body = BatchAcceptRequest(sourceId: sourceId)
return try await client.request("/import-candidates/batch-accept", method: "POST", body: body)
}
}
// MARK: - Knowledge Source
@MainActor
class KnowledgeSourceService {
static let shared = KnowledgeSourceService()
private let client = APIClient.shared
func list(kbId: String, sortBy: String? = nil, order: String? = nil) async throws -> [KnowledgeSource] {
var items = [URLQueryItem]()
if let s = sortBy { items.append(URLQueryItem(name: "sortBy", value: s)) }
if let o = order { items.append(URLQueryItem(name: "order", value: o)) }
return try await client.request("/knowledge-bases/\(kbId)/sources", queryItems: items.isEmpty ? nil : items)
}
func detail(kbId: String, id: String) async throws -> KnowledgeSource {
return try await client.request("/knowledge-bases/\(kbId)/sources/\(id)")
}
func add(kbId: String, fileId: String? = nil, type: String? = nil, title: String? = nil, originalFilename: String? = nil, mimeType: String? = nil, sizeBytes: Int? = nil, originalObjectKey: String? = nil) async throws -> KnowledgeSource {
let body = AddSourceRequest(fileId: fileId, type: type, title: title, originalFilename: originalFilename, mimeType: mimeType, sizeBytes: sizeBytes, originalObjectKey: originalObjectKey)
return try await client.request("/knowledge-bases/\(kbId)/sources", method: "POST", body: body)
}
func delete(kbId: String, id: String) async throws -> GenericSuccessResponse {
return try await client.request("/knowledge-bases/\(kbId)/sources/\(id)", method: "DELETE")
}
func parse(kbId: String, sourceId: String) async throws -> ParseResponse {
return try await client.request("/knowledge-bases/\(kbId)/sources/\(sourceId)/parse", method: "POST")
}
func generateKnowledge(kbId: String, sourceId: String) async throws -> GenerateKnowledgeResponse {
return try await client.request("/knowledge-bases/\(kbId)/sources/\(sourceId)/generate-knowledge", method: "POST")
}
}
// MARK: - Quiz
@MainActor
class QuizService {
static let shared = QuizService()
private let client = APIClient.shared
func generate(knowledgeBaseId: String, questionCount: Int = 5) async throws -> Quiz {
let body = QuizGenerateRequest(knowledgeBaseId: knowledgeBaseId, questionCount: questionCount)
return try await client.request("/quizzes", method: "POST", body: body)
}
func list(knowledgeBaseId: String? = nil) async throws -> [Quiz] {
var items: [URLQueryItem]? = nil
if let kb = knowledgeBaseId { items = [URLQueryItem(name: "knowledgeBaseId", value: kb)] }
return try await client.request("/quizzes", queryItems: items)
}
func detail(id: String) async throws -> Quiz { return try await client.request("/quizzes/\(id)") }
// M-QUIZ-01-03: 使 Attempt API
func createAttempt(quizId: String) async throws -> QuizAttemptStartResponse {
return try await client.request("/quizzes/\(quizId)/attempts", method: "POST")
}
// M-QUIZ-01-03: Attempt
func getAttemptQuestions(attemptId: String) async throws -> [QuizQuestion] {
return try await client.request("/quizzes/attempts/\(attemptId)/questions")
}
//
func start(quizId: String) async throws -> QuizAttempt { return try await client.request("/quizzes/\(quizId)/start", method: "POST") }
func submit(quizId: String, attemptId: String, answers: [QuizAnswerItem]) async throws -> QuizSubmitResponse {
let body = QuizSubmitRequest(attemptId: attemptId, answers: answers)
return try await client.request("/quizzes/\(quizId)/submit", method: "POST", body: body)
}
func results(quizId: String, attemptId: String) async throws -> QuizAttempt { return try await client.request("/quizzes/\(quizId)/results?attemptId=\(attemptId)") }
func history() async throws -> [QuizAttempt] { return try await client.request("/quizzes/history/list") }
}
// MARK: - Notifications
@MainActor
class NotificationService {
static let shared = NotificationService()
private let client = APIClient.shared
func list(page: Int = 1, limit: Int = 20) async throws -> [NotificationItem] {
return try await client.request("/notifications", queryItems: [
URLQueryItem(name: "page", value: String(page)),
URLQueryItem(name: "limit", value: String(limit)),
])
}
func markRead(id: String) async throws -> NotificationItem {
return try await client.request("/notifications/\(id)/read", method: "PATCH")
}
func markAllRead() async throws -> GenericSuccessResponse {
return try await client.request("/notifications/read-all", method: "POST")
}
func getPreferences() async throws -> NotificationPreferences {
return try await client.request("/notifications/preferences")
}
func updatePreferences(_ dto: NotificationPreferences) async throws -> NotificationPreferences {
return try await client.request("/notifications/preferences", method: "PATCH", body: dto)
}
func registerPushToken(_ token: String) async throws -> GenericSuccessResponse {
return try await client.request("/notifications/push-token", method: "POST", body: ["token": token])
}
func removePushToken(_ token: String) async throws -> GenericSuccessResponse {
return try await client.request("/notifications/push-tokens/\(token)", method: "DELETE")
}
}
// MARK: - Membership (M-MEMBER-01-09)
@MainActor
class MembershipService {
static let shared = MembershipService()
private let client = APIClient.shared
func getPlans() async throws -> MembershipPlanResponse {
try await client.request("/membership/plans")
}
func myMembership() async throws -> MyMembershipResponse {
try await client.request("/membership/me")
}
func getUsage() async throws -> UsageSummary {
try await client.request("/membership/usage")
}
func submitTransaction(signedTransactionInfo: String) async throws -> TransactionSubmitResponse {
try await client.request(
"/membership/apple/transactions",
method: "POST",
body: ["signedTransactionInfo": signedTransactionInfo] as [String: String]
)
}
}
struct NotificationPreferences: Codable {
var reviewReminder: Bool?
var newFeatures: Bool?
var systemNotice: Bool?
}
struct FocusItemUpdateRequest: Codable {
let masteryScore: Int?
let title: String?
}
struct FocusItemCompleteRequest: Codable {
let id: String
}

View File

@ -7,8 +7,19 @@ struct MaterialPathResolver {
static func resolve(materialId: String, apiType: String?, title: String?) -> (filePath: String, materialType: MaterialType)? {
let mt = materialTypeFrom(apiType: apiType, title: title)
// 1. knowledge_source: look up by source ID in local KnowledgeSourceService
// 1. knowledge_source: check actual download paths first (tmp/), then legacy paths
if apiType == "knowledge_source" {
// Priority 1: tmp/ where MaterialReaderView and KnowledgeDetailVM actually download to
let tmpDir = FileManager.default.temporaryDirectory
if let files = try? FileManager.default.contentsOfDirectory(at: tmpDir, includingPropertiesForKeys: nil) {
for f in files {
let name = f.lastPathComponent
if name.hasPrefix("source_\(materialId).") || name.hasPrefix("kd_\(materialId).") {
return (f.path, mt)
}
}
}
// Priority 2: legacy path Documents/knowledge_sources/<id>/
if let path = resolveKnowledgeSource(id: materialId) {
return (path, mt)
}

View File

@ -1,131 +0,0 @@
import Foundation
// MARK: - Reading API Service
@MainActor
class ReadingAPIService {
static let shared = ReadingAPIService()
private let client = APIClient.shared
/// POST /learning/reading-events/batch
func uploadReadingEvents(_ events: [ReadingEventUploadItem]) async throws -> ReadingEventBatchResponse {
let body = ReadingEventBatchRequest(events: events)
return try await client.request("/learning/reading-events/batch", method: "POST", body: body)
}
/// GET /materials/:id/reading-progress
func getReadingProgress(materialId: String, targetType: String) async throws -> MaterialReadingProgressDTO {
return try await client.request(
"/materials/\(materialId)/reading-progress",
queryItems: [URLQueryItem(name: "readingTargetType", value: targetType)]
)
}
/// GET /learning/continue
func getContinueLearning() async throws -> ContinueLearningResponse {
return try await client.request("/learning/continue")
}
/// GET /learning/summary
func getLearningSummary() async throws -> LearningSummaryResponse {
return try await client.request("/learning/summary")
}
/// GET /learning/trend?days=7
func getLearningTrend(days: Int = 7) async throws -> LearningTrendResponse {
return try await client.request(
"/learning/trend",
queryItems: [URLQueryItem(name: "days", value: String(days))]
)
}
/// GET /learning/records
func getLearningRecords(cursor: String? = nil, limit: Int = 20, type: String? = nil) async throws -> LearningRecordsResponse {
var items = [URLQueryItem(name: "limit", value: String(limit))]
if let c = cursor { items.append(URLQueryItem(name: "cursor", value: c)) }
if let t = type { items.append(URLQueryItem(name: "type", value: t)) }
return try await client.request("/learning/records", queryItems: items)
}
}
// MARK: - Response DTOs
/// Codable wrapper for API's lastPosition (JSON object matching the server's ReadingPosition schema).
struct ReadingPositionValue: Codable {
let type: String?
let blockId: String?
let lineNumber: UInt32?
let pageNumber: UInt32?
let chapterId: String?
let scrollProgress: Float?
let pageProgress: Float?
let overallProgress: Float?
let zoomScale: Float?
let offsetX: Float?
let offsetY: Float?
let chapterProgress: Float?
}
struct MaterialReadingProgressDTO: Codable {
let status: String
let lastPosition: ReadingPositionValue?
let lastProgress: Float?
let totalActiveSeconds: Int
let isMarkedRead: Bool
let sessionCount: Int?
let firstOpenedAt: String?
let lastReadAt: String?
}
struct ContinueLearningResponse: Codable {
let type: String?
let materialId: String?
let title: String?
let lastProgress: Float?
let totalActiveSeconds: Int?
let lastReadAt: String?
}
struct LearningSummaryResponse: Codable {
let todaySeconds: Int
let weekSeconds: Int
let totalSeconds: Int
let activeDays: Int
let sessionsCount: Int
let materialsReadCount: Int
let markedReadCount: Int
let dailyAverageSeconds: Int
}
struct LearningTrendResponse: Codable {
let days: Int
let series: [TrendPoint]
struct TrendPoint: Codable {
let date: String
let value: Int
}
}
struct LearningRecordsResponse: Codable {
let items: [RecordItem]
let nextCursor: String?
struct RecordItem: Codable {
let id: String
let recordType: String
let title: String
let description: String?
let durationSeconds: Int
let occurredAt: String
let metadata: RecordMetadata?
let createdAt: String
}
struct RecordMetadata: Codable {
let materialId: String?
let readingTargetType: String?
let knowledgeBaseId: String?
let totalActiveSeconds: Int?
}
}

View File

@ -16,17 +16,19 @@ struct ReadingRuntimeSession {
protocol ReadingRuntimeAdapter {
// Session Lifecycle
func startSession(material: ReadingMaterialRef, timestampMs: Int64) throws -> String
func startSession(material: ReadingMaterialRefV2, timestampMs: Int64) throws -> String
func pauseSession(_ sessionId: String) throws
func resumeSession(_ sessionId: String) throws
func closeSession(_ sessionId: String) throws
func closeSession(_ sessionId: String, timestampMs: Int64) throws
// Event Push
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2
func pushClosed(sessionId: String, materialId: String, delta: UInt32, timestampMs: Int64) throws -> ReadingEventV2
func pushPositionChanged(sessionId: String, materialId: String, position: ReadingPosition, timestampMs: Int64) throws -> ReadingEventV2
func pushCheckpointChanged(sessionId: String, materialId: String, checkpoint: ReadingCheckpoint, timestampMs: Int64) throws -> ReadingEventV2
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2
func pushHeartbeatCheckpoint(sessionId: String, materialId: String, delta: UInt32, checkpoint: ReadingCheckpoint?, timestampMs: Int64) throws -> ReadingEventV2
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2
// Buffer Management
@ -36,12 +38,14 @@ protocol ReadingRuntimeAdapter {
func markFailed(_ eventIds: [String]) -> UInt32
func reloadStaleEvents() -> UInt32
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32
func runtimeComputeFileFingerprint(filePath: String) throws -> String
func runtimeBuildRepresentationFingerprint(sourceFingerprint: String, rendererKind: RendererKind, parserVersion: String, schemaVersion: UInt32) -> String
}
// MARK: - Default Implementation (Rust FFI)
final class RustReadingRuntimeAdapter: ReadingRuntimeAdapter {
func startSession(material: ReadingMaterialRef, timestampMs: Int64) throws -> String {
func startSession(material: ReadingMaterialRefV2, timestampMs: Int64) throws -> String {
let result = try startReadingSessionV2(material: material, timestampMs: timestampMs)
return result
}
@ -54,8 +58,8 @@ final class RustReadingRuntimeAdapter: ReadingRuntimeAdapter {
try resumeReadingSessionV2(sessionId: sessionId)
}
func closeSession(_ sessionId: String) throws {
try closeReadingSessionV2(sessionId: sessionId)
func closeSession(_ sessionId: String, timestampMs: Int64) throws {
try closeReadingSessionV2(sessionId: sessionId, timestampMs: timestampMs)
}
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
@ -70,10 +74,18 @@ final class RustReadingRuntimeAdapter: ReadingRuntimeAdapter {
return try pushPositionChangedV2(sessionId: sessionId, materialId: materialId, position: position, timestampMs: timestampMs)
}
func pushCheckpointChanged(sessionId: String, materialId: String, checkpoint: ReadingCheckpoint, timestampMs: Int64) throws -> ReadingEventV2 {
return try pushCheckpointChangedV2(sessionId: sessionId, materialId: materialId, checkpoint: checkpoint, timestampMs: timestampMs)
}
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2 {
return try pushHeartbeatV2(sessionId: sessionId, materialId: materialId, activeSecondsDelta: delta, position: position, timestampMs: timestampMs)
}
func pushHeartbeatCheckpoint(sessionId: String, materialId: String, delta: UInt32, checkpoint: ReadingCheckpoint?, timestampMs: Int64) throws -> ReadingEventV2 {
return try pushHeartbeatCheckpointV2(sessionId: sessionId, materialId: materialId, activeSecondsDelta: delta, checkpoint: checkpoint, timestampMs: timestampMs)
}
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
return try pushMarkedAsReadV2(sessionId: sessionId, materialId: materialId, timestampMs: timestampMs)
}
@ -97,6 +109,19 @@ final class RustReadingRuntimeAdapter: ReadingRuntimeAdapter {
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32 {
return cleanupStaleSessionsFfi(nowMs: nowMs, maxAgeMs: maxAgeMs)
}
func runtimeComputeFileFingerprint(filePath: String) throws -> String {
return try computeFileFingerprint(filePath: filePath)
}
func runtimeBuildRepresentationFingerprint(sourceFingerprint: String, rendererKind: RendererKind, parserVersion: String, schemaVersion: UInt32) -> String {
return buildRepresentationFingerprint(
sourceFingerprint: sourceFingerprint,
rendererKind: rendererKind,
parserVersion: parserVersion,
schemaVersion: schemaVersion
)
}
}
// MARK: - V1 Adapter (backward compatibility)
@ -112,7 +137,7 @@ final class RustReadingRuntimeAdapter: ReadingRuntimeAdapter {
@MainActor
final class V1ReadingRuntimeAdapter: ReadingRuntimeAdapter {
func startSession(material: ReadingMaterialRef, timestampMs: Int64) throws -> String {
func startSession(material: ReadingMaterialRefV2, timestampMs: Int64) throws -> String {
let uuid = UUID().uuidString
let event = ReadingEvent.materialOpened(
materialId: material.materialId,
@ -126,7 +151,7 @@ final class V1ReadingRuntimeAdapter: ReadingRuntimeAdapter {
func resumeSession(_ sessionId: String) throws { /* V1: no-op */ }
func closeSession(_ sessionId: String) throws { /* V1: no-op */ }
func closeSession(_ sessionId: String, timestampMs: Int64) throws { /* V1: no-op */ }
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
@ -140,10 +165,18 @@ final class V1ReadingRuntimeAdapter: ReadingRuntimeAdapter {
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
}
func pushCheckpointChanged(sessionId: String, materialId: String, checkpoint: ReadingCheckpoint, timestampMs: Int64) throws -> ReadingEventV2 {
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
}
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2 {
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
}
func pushHeartbeatCheckpoint(sessionId: String, materialId: String, delta: UInt32, checkpoint: ReadingCheckpoint?, timestampMs: Int64) throws -> ReadingEventV2 {
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
}
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
throw ReadingRuntimeAdapterError.v1ReturnTypeUnsupported
}
@ -167,6 +200,14 @@ final class V1ReadingRuntimeAdapter: ReadingRuntimeAdapter {
func reloadStaleEvents() -> UInt32 { return 0 }
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32 { return 0 }
func runtimeComputeFileFingerprint(filePath: String) throws -> String {
throw ReadingRuntimeAdapterError.unavailable
}
func runtimeBuildRepresentationFingerprint(sourceFingerprint: String, rendererKind: RendererKind, parserVersion: String, schemaVersion: UInt32) -> String {
return "\(rendererKind)-\(parserVersion)-\(schemaVersion)-\(sourceFingerprint)"
}
}
// MARK: - Noop Adapter (test / fallback)
@ -176,7 +217,7 @@ final class V1ReadingRuntimeAdapter: ReadingRuntimeAdapter {
@MainActor
final class NoopReadingRuntimeAdapter: ReadingRuntimeAdapter {
func startSession(material: ReadingMaterialRef, timestampMs: Int64) throws -> String {
func startSession(material: ReadingMaterialRefV2, timestampMs: Int64) throws -> String {
return "noop-\(UUID().uuidString)"
}
@ -184,7 +225,7 @@ final class NoopReadingRuntimeAdapter: ReadingRuntimeAdapter {
func resumeSession(_ sessionId: String) throws { }
func closeSession(_ sessionId: String) throws { }
func closeSession(_ sessionId: String, timestampMs: Int64) throws { }
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
throw ReadingRuntimeAdapterError.unavailable
@ -198,10 +239,18 @@ final class NoopReadingRuntimeAdapter: ReadingRuntimeAdapter {
throw ReadingRuntimeAdapterError.unavailable
}
func pushCheckpointChanged(sessionId: String, materialId: String, checkpoint: ReadingCheckpoint, timestampMs: Int64) throws -> ReadingEventV2 {
throw ReadingRuntimeAdapterError.unavailable
}
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2 {
throw ReadingRuntimeAdapterError.unavailable
}
func pushHeartbeatCheckpoint(sessionId: String, materialId: String, delta: UInt32, checkpoint: ReadingCheckpoint?, timestampMs: Int64) throws -> ReadingEventV2 {
throw ReadingRuntimeAdapterError.unavailable
}
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
throw ReadingRuntimeAdapterError.unavailable
}
@ -215,6 +264,14 @@ final class NoopReadingRuntimeAdapter: ReadingRuntimeAdapter {
func reloadStaleEvents() -> UInt32 { return 0 }
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32 { return 0 }
func runtimeComputeFileFingerprint(filePath: String) throws -> String {
throw ReadingRuntimeAdapterError.unavailable
}
func runtimeBuildRepresentationFingerprint(sourceFingerprint: String, rendererKind: RendererKind, parserVersion: String, schemaVersion: UInt32) -> String {
return "\(rendererKind)-\(parserVersion)-\(schemaVersion)-\(sourceFingerprint)"
}
}
// MARK: - Runtime Error

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -248,6 +248,11 @@ typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureRes
uint32_t uniffi_zx_document_ffi_fn_func_ack_events_v2(RustBuffer event_ids, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_BUILD_REPRESENTATION_FINGERPRINT
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_BUILD_REPRESENTATION_FINGERPRINT
RustBuffer uniffi_zx_document_ffi_fn_func_build_representation_fingerprint(RustBuffer source_fingerprint, RustBuffer renderer_kind, RustBuffer parser_version, uint32_t schema_version, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEANUP_STALE_SESSIONS_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEANUP_STALE_SESSIONS_FFI
uint32_t uniffi_zx_document_ffi_fn_func_cleanup_stale_sessions_ffi(int64_t now_ms, int64_t max_age_ms, RustCallStatus *_Nonnull out_status
@ -260,7 +265,12 @@ void uniffi_zx_document_ffi_fn_func_clear_exported_events(uint32_t count, RustCa
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLOSE_READING_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLOSE_READING_SESSION_V2
void uniffi_zx_document_ffi_fn_func_close_reading_session_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
void uniffi_zx_document_ffi_fn_func_close_reading_session_v2(RustBuffer session_id, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_COMPUTE_FILE_FINGERPRINT
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_COMPUTE_FILE_FINGERPRINT
RustBuffer uniffi_zx_document_ffi_fn_func_compute_file_fingerprint(RustBuffer file_path, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CREATE_NOTE_ANCHOR
@ -292,6 +302,17 @@ RustBuffer uniffi_zx_document_ffi_fn_func_export_pending_events_v2(uint32_t limi
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXTRACT_PDF_TEXT_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXTRACT_PDF_TEXT_FFI
RustBuffer uniffi_zx_document_ffi_fn_func_extract_pdf_text_ffi(RustBuffer file_path, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_ACTIVE_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_ACTIVE_SESSION_V2
RustBuffer uniffi_zx_document_ffi_fn_func_get_active_session_v2(RustBuffer material_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_EVENT_BUFFER_STATE_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_EVENT_BUFFER_STATE_V2
RustBuffer uniffi_zx_document_ffi_fn_func_get_event_buffer_state_v2(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
@ -299,6 +320,11 @@ RustBuffer uniffi_zx_document_ffi_fn_func_extract_pdf_text_ffi(RustBuffer file_p
RustBuffer uniffi_zx_document_ffi_fn_func_get_office_preview_config_ffi(RustBuffer material_type, uint64_t file_size, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_SESSION_V2_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_SESSION_V2_FFI
RustBuffer uniffi_zx_document_ffi_fn_func_get_session_v2_ffi(RustBuffer session_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_IS_OFFICE_TYPE_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_IS_OFFICE_TYPE_FFI
int8_t uniffi_zx_document_ffi_fn_func_is_office_type_ffi(RustBuffer material_type, RustCallStatus *_Nonnull out_status
@ -309,6 +335,16 @@ int8_t uniffi_zx_document_ffi_fn_func_is_office_type_ffi(RustBuffer material_typ
uint32_t uniffi_zx_document_ffi_fn_func_mark_events_failed_v2(RustBuffer event_ids, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_FAILED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_FAILED_V2
void uniffi_zx_document_ffi_fn_func_mark_session_failed_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_INTERRUPTED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_SESSION_INTERRUPTED_V2
void uniffi_zx_document_ffi_fn_func_mark_session_interrupted_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_MARKDOWN
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_MARKDOWN
RustBuffer uniffi_zx_document_ffi_fn_func_parse_markdown(RustBuffer content, RustCallStatus *_Nonnull out_status
@ -324,6 +360,16 @@ RustBuffer uniffi_zx_document_ffi_fn_func_parse_text(RustBuffer content, RustCal
void uniffi_zx_document_ffi_fn_func_pause_reading_session_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_CHECKPOINT_CHANGED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_CHECKPOINT_CHANGED_V2
RustBuffer uniffi_zx_document_ffi_fn_func_push_checkpoint_changed_v2(RustBuffer session_id, RustBuffer material_id, RustBuffer checkpoint, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2
RustBuffer uniffi_zx_document_ffi_fn_func_push_heartbeat_checkpoint_v2(RustBuffer session_id, RustBuffer material_id, uint32_t active_seconds_delta, RustBuffer checkpoint, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_V2
RustBuffer uniffi_zx_document_ffi_fn_func_push_heartbeat_v2(RustBuffer session_id, RustBuffer material_id, uint32_t active_seconds_delta, RustBuffer position, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
@ -415,6 +461,11 @@ RustBuffer uniffi_zx_document_ffi_fn_func_search_pdf_pages(RustBuffer page_numbe
RustBuffer uniffi_zx_document_ffi_fn_func_search_text_content(RustBuffer content, RustBuffer query, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SET_SESSION_CLOSED_AT_MS_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SET_SESSION_CLOSED_AT_MS_V2
void uniffi_zx_document_ffi_fn_func_set_session_closed_at_ms_v2(RustBuffer session_id, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_START_READING_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_START_READING_SESSION_V2
RustBuffer uniffi_zx_document_ffi_fn_func_start_reading_session_v2(RustBuffer material, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
@ -689,6 +740,12 @@ void ffi_zx_document_ffi_rust_future_complete_void(uint64_t handle, RustCallStat
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_ACK_EVENTS_V2
uint16_t uniffi_zx_document_ffi_checksum_func_ack_events_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_BUILD_REPRESENTATION_FINGERPRINT
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_BUILD_REPRESENTATION_FINGERPRINT
uint16_t uniffi_zx_document_ffi_checksum_func_build_representation_fingerprint(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLEANUP_STALE_SESSIONS_FFI
@ -707,6 +764,12 @@ uint16_t uniffi_zx_document_ffi_checksum_func_clear_exported_events(void
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLOSE_READING_SESSION_V2
uint16_t uniffi_zx_document_ffi_checksum_func_close_reading_session_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_COMPUTE_FILE_FINGERPRINT
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_COMPUTE_FILE_FINGERPRINT
uint16_t uniffi_zx_document_ffi_checksum_func_compute_file_fingerprint(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CREATE_NOTE_ANCHOR
@ -743,12 +806,30 @@ uint16_t uniffi_zx_document_ffi_checksum_func_export_pending_events_v2(void
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXTRACT_PDF_TEXT_FFI
uint16_t uniffi_zx_document_ffi_checksum_func_extract_pdf_text_ffi(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_ACTIVE_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_ACTIVE_SESSION_V2
uint16_t uniffi_zx_document_ffi_checksum_func_get_active_session_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_EVENT_BUFFER_STATE_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_EVENT_BUFFER_STATE_V2
uint16_t uniffi_zx_document_ffi_checksum_func_get_event_buffer_state_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
uint16_t uniffi_zx_document_ffi_checksum_func_get_office_preview_config_ffi(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_SESSION_V2_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_SESSION_V2_FFI
uint16_t uniffi_zx_document_ffi_checksum_func_get_session_v2_ffi(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_IS_OFFICE_TYPE_FFI
@ -761,6 +842,18 @@ uint16_t uniffi_zx_document_ffi_checksum_func_is_office_type_ffi(void
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_EVENTS_FAILED_V2
uint16_t uniffi_zx_document_ffi_checksum_func_mark_events_failed_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_FAILED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_FAILED_V2
uint16_t uniffi_zx_document_ffi_checksum_func_mark_session_failed_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_INTERRUPTED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_SESSION_INTERRUPTED_V2
uint16_t uniffi_zx_document_ffi_checksum_func_mark_session_interrupted_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PARSE_MARKDOWN
@ -779,6 +872,18 @@ uint16_t uniffi_zx_document_ffi_checksum_func_parse_text(void
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PAUSE_READING_SESSION_V2
uint16_t uniffi_zx_document_ffi_checksum_func_pause_reading_session_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_CHECKPOINT_CHANGED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_CHECKPOINT_CHANGED_V2
uint16_t uniffi_zx_document_ffi_checksum_func_push_checkpoint_changed_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_CHECKPOINT_V2
uint16_t uniffi_zx_document_ffi_checksum_func_push_heartbeat_checkpoint_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_V2
@ -887,6 +992,12 @@ uint16_t uniffi_zx_document_ffi_checksum_func_search_pdf_pages(void
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_TEXT_CONTENT
uint16_t uniffi_zx_document_ffi_checksum_func_search_text_content(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SET_SESSION_CLOSED_AT_MS_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SET_SESSION_CLOSED_AT_MS_V2
uint16_t uniffi_zx_document_ffi_checksum_func_set_session_closed_at_ms_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_START_READING_SESSION_V2

View File

@ -0,0 +1,7 @@
module zx_documentFFI {
header "zx_documentFFI.h"
export *
use "Darwin"
use "_Builtin_stdbool"
use "_Builtin_stdint"
}

View File

@ -8,12 +8,13 @@
<key>BinaryPath</key>
<string>libzx_document_ffi.a</string>
<key>LibraryIdentifier</key>
<string>ios-arm64-simulator</string>
<string>ios-arm64_x86_64-simulator</string>
<key>LibraryPath</key>
<string>libzx_document_ffi.a</string>
<key>SupportedArchitectures</key>
<array>
<string>arm64</string>
<string>x86_64</string>
</array>
<key>SupportedPlatform</key>
<string>ios</string>

View File

@ -1 +0,0 @@
module zx_documentFFI { header "zx_documentFFI.h"; export * }

View File

@ -1,910 +0,0 @@
// This file was autogenerated by some hot garbage in the `uniffi` crate.
// Trust me, you don't want to mess with it!
#pragma once
#include <stdbool.h>
#include <stddef.h>
#include <stdint.h>
// The following structs are used to implement the lowest level
// of the FFI, and thus useful to multiple uniffied crates.
// We ensure they are declared exactly once, with a header guard, UNIFFI_SHARED_H.
#ifdef UNIFFI_SHARED_H
// We also try to prevent mixing versions of shared uniffi header structs.
// If you add anything to the #else block, you must increment the version suffix in UNIFFI_SHARED_HEADER_V4
#ifndef UNIFFI_SHARED_HEADER_V4
#error Combining helper code from multiple versions of uniffi is not supported
#endif // ndef UNIFFI_SHARED_HEADER_V4
#else
#define UNIFFI_SHARED_H
#define UNIFFI_SHARED_HEADER_V4
// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️
// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️
typedef struct RustBuffer
{
uint64_t capacity;
uint64_t len;
uint8_t *_Nullable data;
} RustBuffer;
typedef struct ForeignBytes
{
int32_t len;
const uint8_t *_Nullable data;
} ForeignBytes;
// Error definitions
typedef struct RustCallStatus {
int8_t code;
RustBuffer errorBuf;
} RustCallStatus;
// ⚠️ Attention: If you change this #else block (ending in `#endif // def UNIFFI_SHARED_H`) you *must* ⚠️
// ⚠️ increment the version suffix in all instances of UNIFFI_SHARED_HEADER_V4 in this file. ⚠️
#endif // def UNIFFI_SHARED_H
#ifndef UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK
#define UNIFFI_FFIDEF_RUST_FUTURE_CONTINUATION_CALLBACK
typedef void (*UniffiRustFutureContinuationCallback)(uint64_t, int8_t
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK
typedef void (*UniffiForeignFutureDroppedCallback)(uint64_t
);
#endif
#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE
#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_FREE
typedef void (*UniffiCallbackInterfaceFree)(uint64_t
);
#endif
#ifndef UNIFFI_FFIDEF_CALLBACK_INTERFACE_CLONE
#define UNIFFI_FFIDEF_CALLBACK_INTERFACE_CLONE
typedef uint64_t (*UniffiCallbackInterfaceClone)(uint64_t
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK_STRUCT
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_DROPPED_CALLBACK_STRUCT
typedef struct UniffiForeignFutureDroppedCallbackStruct {
uint64_t handle;
UniffiForeignFutureDroppedCallback _Nonnull free;
} UniffiForeignFutureDroppedCallbackStruct;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U8
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U8
typedef struct UniffiForeignFutureResultU8 {
uint8_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultU8;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U8
typedef void (*UniffiForeignFutureCompleteU8)(uint64_t, UniffiForeignFutureResultU8
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I8
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I8
typedef struct UniffiForeignFutureResultI8 {
int8_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultI8;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I8
typedef void (*UniffiForeignFutureCompleteI8)(uint64_t, UniffiForeignFutureResultI8
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U16
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U16
typedef struct UniffiForeignFutureResultU16 {
uint16_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultU16;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U16
typedef void (*UniffiForeignFutureCompleteU16)(uint64_t, UniffiForeignFutureResultU16
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I16
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I16
typedef struct UniffiForeignFutureResultI16 {
int16_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultI16;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I16
typedef void (*UniffiForeignFutureCompleteI16)(uint64_t, UniffiForeignFutureResultI16
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U32
typedef struct UniffiForeignFutureResultU32 {
uint32_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultU32;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U32
typedef void (*UniffiForeignFutureCompleteU32)(uint64_t, UniffiForeignFutureResultU32
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I32
typedef struct UniffiForeignFutureResultI32 {
int32_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultI32;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I32
typedef void (*UniffiForeignFutureCompleteI32)(uint64_t, UniffiForeignFutureResultI32
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_U64
typedef struct UniffiForeignFutureResultU64 {
uint64_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultU64;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_U64
typedef void (*UniffiForeignFutureCompleteU64)(uint64_t, UniffiForeignFutureResultU64
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_I64
typedef struct UniffiForeignFutureResultI64 {
int64_t returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultI64;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_I64
typedef void (*UniffiForeignFutureCompleteI64)(uint64_t, UniffiForeignFutureResultI64
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F32
typedef struct UniffiForeignFutureResultF32 {
float returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultF32;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F32
typedef void (*UniffiForeignFutureCompleteF32)(uint64_t, UniffiForeignFutureResultF32
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_F64
typedef struct UniffiForeignFutureResultF64 {
double returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultF64;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_F64
typedef void (*UniffiForeignFutureCompleteF64)(uint64_t, UniffiForeignFutureResultF64
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_RUST_BUFFER
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_RUST_BUFFER
typedef struct UniffiForeignFutureResultRustBuffer {
RustBuffer returnValue;
RustCallStatus callStatus;
} UniffiForeignFutureResultRustBuffer;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_RUST_BUFFER
typedef void (*UniffiForeignFutureCompleteRustBuffer)(uint64_t, UniffiForeignFutureResultRustBuffer
);
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_VOID
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_RESULT_VOID
typedef struct UniffiForeignFutureResultVoid {
RustCallStatus callStatus;
} UniffiForeignFutureResultVoid;
#endif
#ifndef UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID
#define UNIFFI_FFIDEF_FOREIGN_FUTURE_COMPLETE_VOID
typedef void (*UniffiForeignFutureCompleteVoid)(uint64_t, UniffiForeignFutureResultVoid
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_ACK_EVENTS_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_ACK_EVENTS_V2
uint32_t uniffi_zx_document_ffi_fn_func_ack_events_v2(RustBuffer event_ids, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEANUP_STALE_SESSIONS_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEANUP_STALE_SESSIONS_FFI
uint32_t uniffi_zx_document_ffi_fn_func_cleanup_stale_sessions_ffi(int64_t now_ms, int64_t max_age_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEAR_EXPORTED_EVENTS
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLEAR_EXPORTED_EVENTS
void uniffi_zx_document_ffi_fn_func_clear_exported_events(uint32_t count, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLOSE_READING_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CLOSE_READING_SESSION_V2
void uniffi_zx_document_ffi_fn_func_close_reading_session_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CREATE_NOTE_ANCHOR
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CREATE_NOTE_ANCHOR
RustBuffer uniffi_zx_document_ffi_fn_func_create_note_anchor(RustBuffer material_id, RustBuffer position, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CREATE_NOTE_ANCHOR_FROM_SEARCH
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_CREATE_NOTE_ANCHOR_FROM_SEARCH
RustBuffer uniffi_zx_document_ffi_fn_func_create_note_anchor_from_search(RustBuffer material_id, RustBuffer result, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_DETECT_MATERIAL_TYPE
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_DETECT_MATERIAL_TYPE
RustBuffer uniffi_zx_document_ffi_fn_func_detect_material_type(RustBuffer file_path, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXPORT_PENDING_EVENTS
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXPORT_PENDING_EVENTS
RustBuffer uniffi_zx_document_ffi_fn_func_export_pending_events(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXPORT_PENDING_EVENTS_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXPORT_PENDING_EVENTS_V2
RustBuffer uniffi_zx_document_ffi_fn_func_export_pending_events_v2(uint32_t limit, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXTRACT_PDF_TEXT_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_EXTRACT_PDF_TEXT_FFI
RustBuffer uniffi_zx_document_ffi_fn_func_extract_pdf_text_ffi(RustBuffer file_path, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
RustBuffer uniffi_zx_document_ffi_fn_func_get_office_preview_config_ffi(RustBuffer material_type, uint64_t file_size, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_IS_OFFICE_TYPE_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_IS_OFFICE_TYPE_FFI
int8_t uniffi_zx_document_ffi_fn_func_is_office_type_ffi(RustBuffer material_type, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_EVENTS_FAILED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_MARK_EVENTS_FAILED_V2
uint32_t uniffi_zx_document_ffi_fn_func_mark_events_failed_v2(RustBuffer event_ids, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_MARKDOWN
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_MARKDOWN
RustBuffer uniffi_zx_document_ffi_fn_func_parse_markdown(RustBuffer content, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_TEXT
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PARSE_TEXT
RustBuffer uniffi_zx_document_ffi_fn_func_parse_text(RustBuffer content, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PAUSE_READING_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PAUSE_READING_SESSION_V2
void uniffi_zx_document_ffi_fn_func_pause_reading_session_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_HEARTBEAT_V2
RustBuffer uniffi_zx_document_ffi_fn_func_push_heartbeat_v2(RustBuffer session_id, RustBuffer material_id, uint32_t active_seconds_delta, RustBuffer position, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_MARKED_AS_READ_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_MARKED_AS_READ_V2
RustBuffer uniffi_zx_document_ffi_fn_func_push_marked_as_read_v2(RustBuffer session_id, RustBuffer material_id, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_MATERIAL_CLOSED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_MATERIAL_CLOSED_V2
RustBuffer uniffi_zx_document_ffi_fn_func_push_material_closed_v2(RustBuffer session_id, RustBuffer material_id, uint32_t active_seconds_delta, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_MATERIAL_OPENED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_MATERIAL_OPENED_V2
RustBuffer uniffi_zx_document_ffi_fn_func_push_material_opened_v2(RustBuffer session_id, RustBuffer material_id, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_POSITION_CHANGED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_POSITION_CHANGED_V2
RustBuffer uniffi_zx_document_ffi_fn_func_push_position_changed_v2(RustBuffer session_id, RustBuffer material_id, RustBuffer position, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_READING_EVENT
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_PUSH_READING_EVENT
void uniffi_zx_document_ffi_fn_func_push_reading_event(RustBuffer event, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_EPUB_CHAPTERS_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_EPUB_CHAPTERS_FFI
RustBuffer uniffi_zx_document_ffi_fn_func_read_epub_chapters_ffi(RustBuffer file_path, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_EPUB_METADATA_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_EPUB_METADATA_FFI
RustBuffer uniffi_zx_document_ffi_fn_func_read_epub_metadata_ffi(RustBuffer file_path, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_IMAGE_META
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_IMAGE_META
RustBuffer uniffi_zx_document_ffi_fn_func_read_image_meta(RustBuffer file_path, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_PDF_METADATA_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_PDF_METADATA_FFI
RustBuffer uniffi_zx_document_ffi_fn_func_read_pdf_metadata_ffi(RustBuffer file_path, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_TEXT_STATS
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_READ_TEXT_STATS
RustBuffer uniffi_zx_document_ffi_fn_func_read_text_stats(RustBuffer file_path, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_RELOAD_STALE_EVENTS_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_RELOAD_STALE_EVENTS_V2
uint32_t uniffi_zx_document_ffi_fn_func_reload_stale_events_v2(RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_RESTORE_POSITION_FROM_ANCHOR
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_RESTORE_POSITION_FROM_ANCHOR
RustBuffer uniffi_zx_document_ffi_fn_func_restore_position_from_anchor(RustBuffer anchor, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_RESUME_READING_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_RESUME_READING_SESSION_V2
void uniffi_zx_document_ffi_fn_func_resume_reading_session_v2(RustBuffer session_id, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_EPUB_CHAPTERS_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_EPUB_CHAPTERS_FFI
RustBuffer uniffi_zx_document_ffi_fn_func_search_epub_chapters_ffi(RustBuffer chapter_ids, RustBuffer chapter_texts, RustBuffer query, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_MARKDOWN_BLOCKS
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_MARKDOWN_BLOCKS
RustBuffer uniffi_zx_document_ffi_fn_func_search_markdown_blocks(RustBuffer blocks, RustBuffer query, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_PDF_PAGES
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_PDF_PAGES
RustBuffer uniffi_zx_document_ffi_fn_func_search_pdf_pages(RustBuffer page_numbers, RustBuffer page_texts, RustBuffer query, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_TEXT_CONTENT
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_SEARCH_TEXT_CONTENT
RustBuffer uniffi_zx_document_ffi_fn_func_search_text_content(RustBuffer content, RustBuffer query, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_START_READING_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_START_READING_SESSION_V2
RustBuffer uniffi_zx_document_ffi_fn_func_start_reading_session_v2(RustBuffer material, int64_t timestamp_ms, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_UPDATE_READING_POSITION
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_FN_FUNC_UPDATE_READING_POSITION
void uniffi_zx_document_ffi_fn_func_update_reading_position(RustBuffer material_id, RustBuffer position, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_ALLOC
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_ALLOC
RustBuffer ffi_zx_document_ffi_rustbuffer_alloc(uint64_t size, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_FROM_BYTES
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_FROM_BYTES
RustBuffer ffi_zx_document_ffi_rustbuffer_from_bytes(ForeignBytes bytes, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_FREE
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_FREE
void ffi_zx_document_ffi_rustbuffer_free(RustBuffer buf, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_RESERVE
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUSTBUFFER_RESERVE
RustBuffer ffi_zx_document_ffi_rustbuffer_reserve(RustBuffer buf, uint64_t additional, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U8
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U8
void ffi_zx_document_ffi_rust_future_poll_u8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U8
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U8
void ffi_zx_document_ffi_rust_future_cancel_u8(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U8
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U8
void ffi_zx_document_ffi_rust_future_free_u8(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U8
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U8
uint8_t ffi_zx_document_ffi_rust_future_complete_u8(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I8
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I8
void ffi_zx_document_ffi_rust_future_poll_i8(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I8
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I8
void ffi_zx_document_ffi_rust_future_cancel_i8(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I8
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I8
void ffi_zx_document_ffi_rust_future_free_i8(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I8
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I8
int8_t ffi_zx_document_ffi_rust_future_complete_i8(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U16
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U16
void ffi_zx_document_ffi_rust_future_poll_u16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U16
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U16
void ffi_zx_document_ffi_rust_future_cancel_u16(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U16
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U16
void ffi_zx_document_ffi_rust_future_free_u16(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U16
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U16
uint16_t ffi_zx_document_ffi_rust_future_complete_u16(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I16
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I16
void ffi_zx_document_ffi_rust_future_poll_i16(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I16
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I16
void ffi_zx_document_ffi_rust_future_cancel_i16(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I16
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I16
void ffi_zx_document_ffi_rust_future_free_i16(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I16
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I16
int16_t ffi_zx_document_ffi_rust_future_complete_i16(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U32
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U32
void ffi_zx_document_ffi_rust_future_poll_u32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U32
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U32
void ffi_zx_document_ffi_rust_future_cancel_u32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U32
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U32
void ffi_zx_document_ffi_rust_future_free_u32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U32
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U32
uint32_t ffi_zx_document_ffi_rust_future_complete_u32(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I32
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I32
void ffi_zx_document_ffi_rust_future_poll_i32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I32
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I32
void ffi_zx_document_ffi_rust_future_cancel_i32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I32
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I32
void ffi_zx_document_ffi_rust_future_free_i32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I32
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I32
int32_t ffi_zx_document_ffi_rust_future_complete_i32(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U64
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_U64
void ffi_zx_document_ffi_rust_future_poll_u64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U64
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_U64
void ffi_zx_document_ffi_rust_future_cancel_u64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U64
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_U64
void ffi_zx_document_ffi_rust_future_free_u64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U64
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_U64
uint64_t ffi_zx_document_ffi_rust_future_complete_u64(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I64
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_I64
void ffi_zx_document_ffi_rust_future_poll_i64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I64
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_I64
void ffi_zx_document_ffi_rust_future_cancel_i64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I64
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_I64
void ffi_zx_document_ffi_rust_future_free_i64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I64
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_I64
int64_t ffi_zx_document_ffi_rust_future_complete_i64(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_F32
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_F32
void ffi_zx_document_ffi_rust_future_poll_f32(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_F32
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_F32
void ffi_zx_document_ffi_rust_future_cancel_f32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_F32
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_F32
void ffi_zx_document_ffi_rust_future_free_f32(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_F32
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_F32
float ffi_zx_document_ffi_rust_future_complete_f32(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_F64
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_F64
void ffi_zx_document_ffi_rust_future_poll_f64(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_F64
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_F64
void ffi_zx_document_ffi_rust_future_cancel_f64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_F64
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_F64
void ffi_zx_document_ffi_rust_future_free_f64(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_F64
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_F64
double ffi_zx_document_ffi_rust_future_complete_f64(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_RUST_BUFFER
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_RUST_BUFFER
void ffi_zx_document_ffi_rust_future_poll_rust_buffer(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_RUST_BUFFER
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_RUST_BUFFER
void ffi_zx_document_ffi_rust_future_cancel_rust_buffer(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_RUST_BUFFER
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_RUST_BUFFER
void ffi_zx_document_ffi_rust_future_free_rust_buffer(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_RUST_BUFFER
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_RUST_BUFFER
RustBuffer ffi_zx_document_ffi_rust_future_complete_rust_buffer(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_VOID
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_POLL_VOID
void ffi_zx_document_ffi_rust_future_poll_void(uint64_t handle, UniffiRustFutureContinuationCallback _Nonnull callback, uint64_t callback_data
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_VOID
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_CANCEL_VOID
void ffi_zx_document_ffi_rust_future_cancel_void(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_VOID
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_FREE_VOID
void ffi_zx_document_ffi_rust_future_free_void(uint64_t handle
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_VOID
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_RUST_FUTURE_COMPLETE_VOID
void ffi_zx_document_ffi_rust_future_complete_void(uint64_t handle, RustCallStatus *_Nonnull out_status
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_ACK_EVENTS_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_ACK_EVENTS_V2
uint16_t uniffi_zx_document_ffi_checksum_func_ack_events_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLEANUP_STALE_SESSIONS_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLEANUP_STALE_SESSIONS_FFI
uint16_t uniffi_zx_document_ffi_checksum_func_cleanup_stale_sessions_ffi(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLEAR_EXPORTED_EVENTS
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLEAR_EXPORTED_EVENTS
uint16_t uniffi_zx_document_ffi_checksum_func_clear_exported_events(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLOSE_READING_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CLOSE_READING_SESSION_V2
uint16_t uniffi_zx_document_ffi_checksum_func_close_reading_session_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CREATE_NOTE_ANCHOR
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CREATE_NOTE_ANCHOR
uint16_t uniffi_zx_document_ffi_checksum_func_create_note_anchor(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CREATE_NOTE_ANCHOR_FROM_SEARCH
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_CREATE_NOTE_ANCHOR_FROM_SEARCH
uint16_t uniffi_zx_document_ffi_checksum_func_create_note_anchor_from_search(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_DETECT_MATERIAL_TYPE
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_DETECT_MATERIAL_TYPE
uint16_t uniffi_zx_document_ffi_checksum_func_detect_material_type(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXPORT_PENDING_EVENTS
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXPORT_PENDING_EVENTS
uint16_t uniffi_zx_document_ffi_checksum_func_export_pending_events(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXPORT_PENDING_EVENTS_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXPORT_PENDING_EVENTS_V2
uint16_t uniffi_zx_document_ffi_checksum_func_export_pending_events_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXTRACT_PDF_TEXT_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_EXTRACT_PDF_TEXT_FFI
uint16_t uniffi_zx_document_ffi_checksum_func_extract_pdf_text_ffi(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_GET_OFFICE_PREVIEW_CONFIG_FFI
uint16_t uniffi_zx_document_ffi_checksum_func_get_office_preview_config_ffi(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_IS_OFFICE_TYPE_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_IS_OFFICE_TYPE_FFI
uint16_t uniffi_zx_document_ffi_checksum_func_is_office_type_ffi(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_EVENTS_FAILED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_MARK_EVENTS_FAILED_V2
uint16_t uniffi_zx_document_ffi_checksum_func_mark_events_failed_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PARSE_MARKDOWN
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PARSE_MARKDOWN
uint16_t uniffi_zx_document_ffi_checksum_func_parse_markdown(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PARSE_TEXT
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PARSE_TEXT
uint16_t uniffi_zx_document_ffi_checksum_func_parse_text(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PAUSE_READING_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PAUSE_READING_SESSION_V2
uint16_t uniffi_zx_document_ffi_checksum_func_pause_reading_session_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_HEARTBEAT_V2
uint16_t uniffi_zx_document_ffi_checksum_func_push_heartbeat_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_MARKED_AS_READ_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_MARKED_AS_READ_V2
uint16_t uniffi_zx_document_ffi_checksum_func_push_marked_as_read_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_MATERIAL_CLOSED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_MATERIAL_CLOSED_V2
uint16_t uniffi_zx_document_ffi_checksum_func_push_material_closed_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_MATERIAL_OPENED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_MATERIAL_OPENED_V2
uint16_t uniffi_zx_document_ffi_checksum_func_push_material_opened_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_POSITION_CHANGED_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_POSITION_CHANGED_V2
uint16_t uniffi_zx_document_ffi_checksum_func_push_position_changed_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_READING_EVENT
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_PUSH_READING_EVENT
uint16_t uniffi_zx_document_ffi_checksum_func_push_reading_event(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_EPUB_CHAPTERS_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_EPUB_CHAPTERS_FFI
uint16_t uniffi_zx_document_ffi_checksum_func_read_epub_chapters_ffi(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_EPUB_METADATA_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_EPUB_METADATA_FFI
uint16_t uniffi_zx_document_ffi_checksum_func_read_epub_metadata_ffi(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_IMAGE_META
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_IMAGE_META
uint16_t uniffi_zx_document_ffi_checksum_func_read_image_meta(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_PDF_METADATA_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_PDF_METADATA_FFI
uint16_t uniffi_zx_document_ffi_checksum_func_read_pdf_metadata_ffi(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_TEXT_STATS
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_READ_TEXT_STATS
uint16_t uniffi_zx_document_ffi_checksum_func_read_text_stats(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_RELOAD_STALE_EVENTS_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_RELOAD_STALE_EVENTS_V2
uint16_t uniffi_zx_document_ffi_checksum_func_reload_stale_events_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_RESTORE_POSITION_FROM_ANCHOR
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_RESTORE_POSITION_FROM_ANCHOR
uint16_t uniffi_zx_document_ffi_checksum_func_restore_position_from_anchor(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_RESUME_READING_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_RESUME_READING_SESSION_V2
uint16_t uniffi_zx_document_ffi_checksum_func_resume_reading_session_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_EPUB_CHAPTERS_FFI
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_EPUB_CHAPTERS_FFI
uint16_t uniffi_zx_document_ffi_checksum_func_search_epub_chapters_ffi(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_MARKDOWN_BLOCKS
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_MARKDOWN_BLOCKS
uint16_t uniffi_zx_document_ffi_checksum_func_search_markdown_blocks(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_PDF_PAGES
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_PDF_PAGES
uint16_t uniffi_zx_document_ffi_checksum_func_search_pdf_pages(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_TEXT_CONTENT
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_SEARCH_TEXT_CONTENT
uint16_t uniffi_zx_document_ffi_checksum_func_search_text_content(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_START_READING_SESSION_V2
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_START_READING_SESSION_V2
uint16_t uniffi_zx_document_ffi_checksum_func_start_reading_session_v2(void
);
#endif
#ifndef UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_UPDATE_READING_POSITION
#define UNIFFI_FFIDEF_UNIFFI_ZX_DOCUMENT_FFI_CHECKSUM_FUNC_UPDATE_READING_POSITION
uint16_t uniffi_zx_document_ffi_checksum_func_update_reading_position(void
);
#endif
#ifndef UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_UNIFFI_CONTRACT_VERSION
#define UNIFFI_FFIDEF_FFI_ZX_DOCUMENT_FFI_UNIFFI_CONTRACT_VERSION
uint32_t ffi_zx_document_ffi_uniffi_contract_version(void
);
#endif

View File

@ -48,7 +48,14 @@ struct AIChatPage: View {
}
ForEach(vm.messages) { m in
MessageCard(message: m, onRegenerate: { Task { vm.send() } })
MessageCard(
message: m,
canSaveAsKnowledge: m.role == .ai && vm.saveTargetKnowledgeBaseId != nil,
onRegenerate: { Task { vm.send() } },
onSaveAsKnowledge: {
Task { await saveMessageAsKnowledge(m) }
}
)
.id(m.id)
.padding(.horizontal, 20)
}
@ -133,7 +140,7 @@ struct AIChatPage: View {
} label: { Label("删除", systemImage: "trash") }
Button {
Task {
try? await RagChatService.shared.updateSession(id: s.id, dto: UpdateChatSessionRequest(isArchived: true))
try? await RagChatService.shared.updateSession(id: s.id, dto: UpdateChatSessionRequest(title: nil, isPinned: nil, isArchived: true))
sessions.removeAll { $0.id == s.id }
}
} label: { Label("归档", systemImage: "archivebox") }
@ -142,10 +149,13 @@ struct AIChatPage: View {
.swipeActions(edge: .leading, allowsFullSwipe: true) {
Button {
Task {
try? await RagChatService.shared.updateSession(id: s.id, dto: UpdateChatSessionRequest(isPinned: !(s.isPinned ?? false)))
try? await RagChatService.shared.updateSession(id: s.id, dto: UpdateChatSessionRequest(title: nil, isPinned: !(s.isPinned ?? false), isArchived: nil))
if let updated = try? await RagChatService.shared.listSessions() { sessions = updated }
}
} label: { Label(s.isPinned == true ? "取消置顶" : "置顶", systemImage: s.isPinned == true ? "pin.slash" : "pin") }
} label: {
let pinned = s.isPinned == true
Label(pinned ? "取消置顶" : "置顶", systemImage: pinned ? "pin.slash" : "pin")
}
.tint(Color.zxPurple)
}
}
@ -175,6 +185,43 @@ struct AIChatPage: View {
}
}
private func saveMessageAsKnowledge(_ message: AIMessage) async {
guard let knowledgeBaseId = vm.saveTargetKnowledgeBaseId else {
ZXToastManager.shared.error("当前范围不支持保存为知识点")
return
}
guard let currentSessionId = vm.currentSessionId else {
ZXToastManager.shared.error("当前对话尚未就绪")
return
}
let content = message.content.trimmingCharacters(in: .whitespacesAndNewlines)
guard !content.isEmpty else {
ZXToastManager.shared.error("内容为空,无法保存")
return
}
let scopeName = vm.saveTargetScopeName ?? "AI 对话"
let titleSeed = content
.components(separatedBy: .newlines)
.first?
.trimmingCharacters(in: .whitespacesAndNewlines) ?? "AI 对话知识点"
let title = String(titleSeed.prefix(40)).isEmpty ? "AI 对话知识点" : String(titleSeed.prefix(40))
do {
let result = try await RagChatService.shared.saveMessageAsKnowledge(
sessionId: currentSessionId,
messageId: message.id,
knowledgeBaseId: knowledgeBaseId,
title: title,
summary: nil
)
ZXToastManager.shared.success(result.message)
} catch {
ZXToastManager.shared.error("保存失败")
}
}
private func scopeIcon(for type: String?) -> String {
switch type {
case "knowledge_base": return "books.vertical"
@ -199,7 +246,9 @@ struct AIChatPage: View {
struct MessageCard: View {
let message: AIMessage
let canSaveAsKnowledge: Bool
let onRegenerate: () -> Void
let onSaveAsKnowledge: () -> Void
@State private var thinkingExpanded = true
var body: some View {
@ -232,6 +281,11 @@ struct MessageCard: View {
Button { UIPasteboard.general.string = message.content } label: {
Label("复制", systemImage: "doc.on.doc").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
if canSaveAsKnowledge {
Button { onSaveAsKnowledge() } label: {
Label("存为知识点", systemImage: "square.and.arrow.down").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
}
Button { onRegenerate() } label: {
Label("重新生成", systemImage: "arrow.clockwise").font(.system(size: 12)).foregroundColor(Color.zxF04)
}

View File

@ -2,14 +2,14 @@ import Combine
import Foundation
struct AIMessage: Identifiable {
let id: UUID
let id: String
let role: AIMessageRole
let content: String
let thinkingContent: String?
let citations: [ChatCitation]?
let isStreaming: Bool
init(role: AIMessageRole, content: String, thinkingContent: String? = nil, citations: [ChatCitation]? = nil, isStreaming: Bool = false, id: UUID = UUID()) {
init(role: AIMessageRole, content: String, thinkingContent: String? = nil, citations: [ChatCitation]? = nil, isStreaming: Bool = false, id: String = UUID().uuidString) {
self.id = id
self.role = role
self.content = content
@ -61,6 +61,24 @@ final class AIChatViewModel: ObservableObject {
}
}
var saveTargetKnowledgeBaseId: String? {
if let kbId = entryContext?.parentKnowledgeBaseId, !kbId.isEmpty {
return kbId
}
if entryContext?.scopeType == .knowledgeBase {
return entryContext?.scopeId
}
return nil
}
var saveTargetScopeName: String? {
entryContext?.scopeName
}
var currentSessionId: String? {
sessionId
}
func load() async {
guard sessionId == nil else { return }
@ -127,7 +145,7 @@ final class AIChatViewModel: ObservableObject {
inputText = ""
isSending = true
let placeholderId = UUID()
let placeholderId = UUID().uuidString
messages.append(AIMessage(role: .ai, content: "", isStreaming: true, id: placeholderId))
Task {
@ -167,6 +185,7 @@ final class AIChatViewModel: ObservableObject {
messages[idx] = AIMessage(role: .ai, content: "发送失败: \(error.localizedDescription)")
}
}
await loadSession(sid)
isSending = false
}
}
@ -203,7 +222,12 @@ final class AIChatViewModel: ObservableObject {
messages = [AIMessage(role: .ai, content: "你好!我是你的 AI 学习助手,基于\(scopePrefix)内容回答问题。")]
} else {
messages = msgs.map { m in
AIMessage(role: m.role == "user" ? .user : .ai, content: m.content, citations: m.citations)
AIMessage(
role: m.role == "user" ? .user : .ai,
content: m.content,
citations: m.citations,
id: m.id
)
}
}
} catch {

View File

@ -64,39 +64,24 @@ class ActivityViewModel: ObservableObject {
self.activityService = activityService
self.focusService = focusService
self.readingService = readingService
// onAppear
Task { await loadAll() }
}
func loadAll() async {
isLoading = true
errorMessage = nil
async let s = try? activityService.summary()
async let f = try? focusService.listAll()
async let h = try? activityService.heatmap()
async let st = try? activityService.streak()
async let t = try? activityService.trend(days: 7)
async let r = try? activityService.recommendations()
async let cr = try? readingService.getContinueLearning()
async let ls = try? readingService.getLearningSummary()
async let ltr = try? readingService.getLearningTrend(days: 7)
async let recs = try? readingService.getLearningRecords(limit: 10, type: "reading")
let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, lsResult, ltrResult, recordsResult) = await (s, f, h, st, t, r, cr, ls, ltr, recs)
summary = summaryResult
learningSummary = lsResult
readingTrend = ltrResult
focusItems = focusResult ?? []
heatmap = heatmapResult ?? [:]
streak = streakResult
trends = trendResult ?? []
recommendations = recResult ?? []
continueReading = continueResult
recentRecords = recordsResult?.items ?? []
if summary == nil {
errorMessage = "加载分析数据失败,请下拉刷新重试"
}
Task { summary = try? await activityService.summary() }
Task { focusItems = (try? await focusService.listAll()) ?? [] }
Task { heatmap = (try? await activityService.heatmap()) ?? [:] }
Task { streak = try? await activityService.streak() }
Task { trends = (try? await activityService.trend(days: 7)) ?? [] }
Task { recommendations = (try? await activityService.recommendations()) ?? [] }
Task { continueReading = try? await readingService.getContinueLearning() }
Task { learningSummary = try? await readingService.getLearningSummary() }
Task { readingTrend = try? await readingService.getLearningTrend(days: 7) }
Task { recentRecords = (try? await readingService.getLearningRecords(limit: 10, type: "reading"))?.items ?? [] }
isLoading = false
}
@ -104,27 +89,15 @@ class ActivityViewModel: ObservableObject {
func refresh() async {
errorMessage = nil
async let s = try? activityService.summary()
async let f = try? focusService.listAll()
async let h = try? activityService.heatmap()
async let st = try? activityService.streak()
async let t = try? activityService.trend(days: 7)
async let r = try? activityService.recommendations()
async let cr = try? readingService.getContinueLearning()
async let ls = try? readingService.getLearningSummary()
async let ltr = try? readingService.getLearningTrend(days: 7)
async let recs = try? readingService.getLearningRecords(limit: 10, type: "reading")
let (summaryResult, focusResult, heatmapResult, streakResult, trendResult, recResult, continueResult, lsResult, ltrResult, recordsResult) = await (s, f, h, st, t, r, cr, ls, ltr, recs)
summary = summaryResult
learningSummary = lsResult
readingTrend = ltrResult
focusItems = focusResult ?? []
heatmap = heatmapResult ?? [:]
streak = streakResult
trends = trendResult ?? []
recommendations = recResult ?? []
continueReading = continueResult
Task { summary = try? await activityService.summary() }
Task { focusItems = (try? await focusService.listAll()) ?? [] }
Task { heatmap = (try? await activityService.heatmap()) ?? [:] }
Task { streak = try? await activityService.streak() }
Task { trends = (try? await activityService.trend(days: 7)) ?? [] }
Task { recommendations = (try? await activityService.recommendations()) ?? [] }
Task { continueReading = try? await readingService.getContinueLearning() }
Task { learningSummary = try? await readingService.getLearningSummary() }
Task { readingTrend = try? await readingService.getLearningTrend(days: 7) }
Task { recentRecords = (try? await readingService.getLearningRecords(limit: 10, type: "reading"))?.items ?? [] }
}
}

View File

@ -2,6 +2,7 @@ import SwiftUI
struct AnalysisHomeView: View {
@StateObject private var viewModel = ActivityViewModel()
var body: some View {
ZStack {
Color.zxCanvas.ignoresSafeArea()
@ -166,7 +167,7 @@ struct AnalysisHomeView: View {
.refreshable { await viewModel.refresh() }
}
}
.task { await viewModel.loadAll() }
.refreshable { await viewModel.refresh() }
.navigationDestination(for: Route.self) { $0.destination }
}

View File

@ -96,7 +96,7 @@ struct FolderManageView: View {
private func load() async {
isLoading = true
folders = (try? await KnowledgeFolderService.shared.list(kbId: knowledgeBaseId)) ?? []
folders = (try? await KnowledgeFolderService.shared.list(knowledgeBaseId: knowledgeBaseId)) ?? []
isLoading = false
}
}

View File

@ -139,26 +139,46 @@ struct LibraryDetailPage: View {
@State private var showManageView = false
@State private var showDeleteConfirm = false
@State private var isDeleting = false
@State private var isSelectMode = false
@State private var selectedIds: Set<String> = []
@State private var showBatchDeleteConfirm = false
@State private var detailTab = 1
// Tab
@State private var sourceSelectMode = false
@State private var selectedSourceIds: Set<String> = []
@State private var showSourceDeleteConfirm = false
@State private var sourceToDelete: KnowledgeSource?
@State private var showSourceBatchDelete = false
@State private var showSourceBatchGenerate = false
@State private var isBatchGeneratingSources = false
@State private var itemSelectMode = false
@State private var selectedItemIds: Set<String> = []
@State private var showItemBatchDelete = false
@State private var detailTab = 0 // 0=, 1=
private func appear() async {
print("[LibraryDetail] appear() START, kbId=\(knowledgeBaseId)")
viewModel.knowledgeBase = try? await KnowledgeBaseService.shared.detail(id: knowledgeBaseId)
print("[LibraryDetail] KB loaded: \(viewModel.knowledgeBase?.title ?? "nil")")
await loadSources()
print("[LibraryDetail] appear() DONE")
}
@State private var sortOption = 0
@State private var sortOption = 1
@State private var showFolderManager = false
@State private var sources: [KnowledgeSource] = []
@State private var sourceReadingStatus: [String: String] = [:]
@State private var sourceTaskStatusMap: [String: KnowledgeGenerationTaskStatus] = [:]
@State private var isLoadingSources = true
@State private var sourceTaskPollingTask: Task<Void, Never>?
private var allSelected: Bool {
!viewModel.items.isEmpty && selectedIds.count == viewModel.items.count
private var sourceAllSelected: Bool {
!sources.isEmpty && selectedSourceIds.count == sources.count
}
private var itemAllSelected: Bool {
!viewModel.items.isEmpty && selectedItemIds.count == viewModel.items.count
}
// Tab
private var isInSelectMode: Bool {
detailTab == 0 ? sourceSelectMode : itemSelectMode
}
//
private var currentSelectedCount: Int {
detailTab == 0 ? selectedSourceIds.count : selectedItemIds.count
}
var body: some View {
@ -185,126 +205,20 @@ struct LibraryDetailPage: View {
.padding(.horizontal, 20).padding(.top, 8)
}
// Tab
Picker("", selection: $detailTab) {
Text("资料来源").tag(0)
Text("知识点").tag(1)
}
.pickerStyle(.segmented)
.padding(.horizontal, 20).padding(.vertical, 8)
ScrollView {
VStack(spacing: 0) {
if detailTab == 0 {
if viewModel.isLoading && viewModel.items.isEmpty {
VStack(spacing: 12) {
ProgressView()
Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
}
.frame(maxWidth: .infinity).padding(.top, 80)
}
ForEach(viewModel.items) { item in
let icon = fileTypeIcon(for: item)
let type = fileTypeText(for: item)
let date = formatShortDate(item.updatedAt)
let progress = progressFor(item)
if isSelectMode {
Button {
if selectedIds.contains(item.id) { selectedIds.remove(item.id) }
else { selectedIds.insert(item.id) }
} label: {
HStack(spacing: 10) {
Image(systemName: selectedIds.contains(item.id) ? "checkmark.circle" : "circle")
.font(.system(size: 20))
.foregroundColor(selectedIds.contains(item.id) ? Color.zxPrimary : Color.zxF03)
itemRow(icon: icon, title: item.title, type: type, date: date, progress: progress)
}
}
.foregroundColor(.primary)
if detailTab == 1 {
itemTabContent()
} else {
NavigationLink(value: Route.knowledgeDetail(item: item)) {
itemRow(icon: icon, title: item.title, type: type, date: date, progress: progress)
}
.contextMenu {
Button {
isSelectMode = true
selectedIds.insert(item.id)
} label: {
Label("多选", systemImage: "checkmark.circle")
}
Button {
// TODO: rename
} label: {
Label("重命名", image: "icon-pencil")
}
Button {
// TODO: move to folder
} label: {
Label("移动到", image: "icon-folder")
}
Divider()
Button(role: .destructive) {
Task {
await viewModel.batchDeleteItems(ids: [item.id])
await viewModel.refresh(knowledgeBaseId: knowledgeBaseId)
}
} label: {
Label("删除", image: "icon-trash")
}
}
}
}
if viewModel.items.isEmpty && !viewModel.isLoading {
Text("暂无知识点").font(.system(size: 14)).foregroundColor(Color.zxF03).padding(.top, 40)
}
if viewModel.hasMore {
ZXLoadMoreFooter { await viewModel.loadMore(knowledgeBaseId: knowledgeBaseId) }
}
} else {
if isLoadingSources {
VStack(spacing: 12) {
ProgressView().tint(Color.zxPurple)
Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
}.padding(.top, 80)
} else if sources.isEmpty {
Text("暂无资料来源").font(.system(size: 14)).foregroundColor(Color.zxF03).padding(.top, 40)
} else {
ForEach(sources) { src in
NavigationLink(value: Route.materialReader(
materialId: src.id,
filePath: "",
materialType: materialTypeFromMime(src.mimeType, filename: src.originalFilename),
knowledgeBaseId: knowledgeBaseId,
title: src.originalFilename ?? src.title ?? "未命名"
)) {
HStack(spacing: 12) {
Image(systemName: src.type == "file" ? "doc" : "link")
.font(.system(size: 18)).foregroundColor(Color.zxPurple)
.frame(width: 36, height: 36)
VStack(alignment: .leading, spacing: 2) {
Text(src.originalFilename ?? src.title ?? "未命名")
.font(.system(size: 15, weight: .medium)).foregroundColor(Color.zxF0).lineLimit(1)
if let len = src.textLength, len > 0 {
Text("\(len)")
.font(.system(size: 13)).foregroundColor(Color.zxF04)
}
// Reading status badge
if let status = sourceReadingStatus[src.id], status != "not_started" {
Text(status == "read" ? "已读" : "阅读中")
.font(.system(size: 10, weight: .medium))
.foregroundColor(status == "read" ? Color.green : Color.zxPurple)
.padding(.horizontal, 6).padding(.vertical, 2)
.background((status == "read" ? Color.green : Color.zxPurple).opacity(0.1))
.clipShape(Capsule())
}
}
Spacer()
Button {
Task { await deleteSource(src) }
} label: {
Image("icon-trash").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03)
}
}
.padding(.vertical, 14)
.overlay(alignment: .bottom) {
Color.zxHairline.frame(height: 0.5)
}
}
}
}
sourceTabContent()
}
}
.padding(.horizontal, 20).padding(.bottom, 80)
@ -315,33 +229,59 @@ struct LibraryDetailPage: View {
}
.navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
.onChange(of: detailTab) { _, newTab in
if newTab == 1 && sources.isEmpty { Task { await loadSources() } }
// Tab 退
sourceSelectMode = false; selectedSourceIds.removeAll()
itemSelectMode = false; selectedItemIds.removeAll()
if newTab == 0 && sources.isEmpty { Task { await loadSources() } }
}
.toolbar {
if isSelectMode {
if isInSelectMode {
//
ToolbarItem(placement: .topBarLeading) {
Button("取消") { isSelectMode = false; selectedIds.removeAll() }
}
ToolbarItem(placement: .topBarTrailing) {
Button { selectedIds = allSelected ? [] : Set(viewModel.items.map(\.id)) } label: {
Text(allSelected ? "取消全选" : "全选").font(.system(size: 14))
Button("取消") {
if detailTab == 0 { sourceSelectMode = false; selectedSourceIds.removeAll() }
else { itemSelectMode = false; selectedItemIds.removeAll() }
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
showBatchDeleteConfirm = true
} label: {
Image("icon-trash").resizable().scaledToFit().frame(width: 18, height: 18).foregroundColor(selectedIds.isEmpty ? Color.zxF03 : Color.zxCoral)
if detailTab == 0 {
selectedSourceIds = sourceAllSelected ? [] : Set(sources.map(\.id))
} else {
selectedItemIds = itemAllSelected ? [] : Set(viewModel.items.map(\.id))
}
} label: {
let all = detailTab == 0 ? sourceAllSelected : itemAllSelected
Text(all ? "取消全选(\(currentSelectedCount))" : "全选(\(currentSelectedCount))").font(.system(size: 14))
}
}
ToolbarItem(placement: .topBarTrailing) {
Button {
if detailTab == 0 { showSourceBatchGenerate = true }
else { showItemBatchDelete = true }
} label: {
Image(systemName: detailTab == 0 ? "brain.head.profile" : "trash")
.font(.system(size: 18))
.foregroundColor(currentSelectedCount == 0 ? Color.zxF03 : (detailTab == 0 ? Color.zxPurple : Color.zxCoral))
}
.disabled(currentSelectedCount == 0)
}
if detailTab == 0 {
ToolbarItem(placement: .topBarTrailing) {
Button {
showSourceBatchDelete = true
} label: {
Image("icon-trash").resizable().scaledToFit().frame(width: 18, height: 18)
.foregroundColor(currentSelectedCount == 0 ? Color.zxF03 : Color.zxCoral)
}
.disabled(currentSelectedCount == 0)
}
.disabled(selectedIds.isEmpty)
}
} else {
//
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 4) {
Menu {
Button { sortOption = 0 } label: {
Label("默认排序", systemImage: sortOption == 0 ? "checkmark" : "")
}
Button { sortOption = 1 } label: {
Label("文件名排序", systemImage: sortOption == 1 ? "checkmark" : "")
}
@ -372,8 +312,17 @@ struct LibraryDetailPage: View {
NavigationLink(value: Route.quizList(knowledgeBaseId: knowledgeBaseId)) {
Label("答题测验", image: "icon-pencil")
}
if detailTab == 0 {
Button {
isSelectMode = true
sourceSelectMode = true
selectedSourceIds.removeAll()
} label: {
Label("批量生成知识点", systemImage: "brain.head.profile")
}
}
Button {
if detailTab == 0 { sourceSelectMode = true }
else { itemSelectMode = true }
} label: {
Label("批量选择", systemImage: "checkmark.circle")
}
@ -407,19 +356,49 @@ struct LibraryDetailPage: View {
.sheet(isPresented: $showManageView) {
KnowledgeBaseManageView(knowledgeBaseId: knowledgeBaseId)
}
.alert("批量删除", isPresented: $showBatchDeleteConfirm) {
.alert("删除资料来源", isPresented: $showSourceDeleteConfirm) {
Button("取消", role: .cancel) { sourceToDelete = nil }
Button("删除", role: .destructive) {
if let src = sourceToDelete { Task { await deleteSource(src) } }
sourceToDelete = nil
}
} message: {
Text("确定要删除「\(sourceToDelete?.originalFilename ?? sourceToDelete?.title ?? "")」吗?关联文件也将被删除。")
}
.alert("批量删除资料来源", isPresented: $showSourceBatchDelete) {
Button("取消", role: .cancel) {}
Button("删除 \(selectedIds.count)", role: .destructive) {
Button("删除 \(selectedSourceIds.count)", role: .destructive) {
Task {
await viewModel.batchDeleteItems(ids: Array(selectedIds))
isSelectMode = false
selectedIds.removeAll()
for id in selectedSourceIds { await deleteSource(byId: id) }
selectedSourceIds.removeAll(); sourceSelectMode = false
await loadSources()
}
}
} message: {
Text("确定要删除选中的 \(selectedIds.count) 个知识点吗?此操作不可恢复。")
Text("确定要删除选中的 \(selectedSourceIds.count) 个资料来源吗?关联文件也将被删除。此操作不可恢复。")
}
.alert("批量生成知识点", isPresented: $showSourceBatchGenerate) {
Button("取消", role: .cancel) {}
Button(isBatchGeneratingSources ? "提交中..." : "生成 \(selectedSourceIds.count)") {
Task { await generateKnowledgeForSelectedSources() }
}
.disabled(selectedSourceIds.isEmpty || isBatchGeneratingSources)
} message: {
Text("选中的资料会提交到异步队列。已解析的资料会立即入队,未解析完成的资料会先进入等待状态。")
}
.alert("批量删除知识点", isPresented: $showItemBatchDelete) {
Button("取消", role: .cancel) {}
Button("删除 \(selectedItemIds.count)", role: .destructive) {
Task {
await viewModel.batchDeleteItems(ids: Array(selectedItemIds))
selectedItemIds.removeAll(); itemSelectMode = false
}
}
} message: {
Text("确定要删除选中的 \(selectedItemIds.count) 个知识点吗?此操作不可恢复。")
}
.task { await appear() }
.onDisappear { stopSourceTaskPolling() }
.sheet(isPresented: $showFolderManager) {
FolderManageView(knowledgeBaseId: knowledgeBaseId)
}
@ -611,6 +590,26 @@ struct LibraryDetailPage: View {
}
}
private func formatFileSize(_ bytes: Int) -> String {
if bytes >= 1_048_576 { return String(format: "%.1f MB", Double(bytes) / 1_048_576) }
if bytes >= 1024 { return String(format: "%.1f KB", Double(bytes) / 1024) }
return "\(bytes) B"
}
private func formatSourceDate(_ iso: String?) -> String? {
guard let iso else { return nil }
let df = ISO8601DateFormatter()
df.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
let date = df.date(from: iso) ?? {
df.formatOptions = [.withInternetDateTime]
return df.date(from: iso)
}()
guard let date else { return nil }
let cal = Calendar.current
let fmt = DateFormatter()
fmt.dateFormat = cal.isDate(date, equalTo: Date(), toGranularity: .year) ? "M/d" : "yyyy/M/d"
return fmt.string(from: date)
}
private func formatShortDate(_ iso: String?) -> String {
guard let iso else { return "" }
let formatter = ISO8601DateFormatter()
@ -650,27 +649,327 @@ struct LibraryDetailPage: View {
private func loadSources(sortBy: String? = nil, order: String? = nil) async {
isLoadingSources = true
print("[LibraryDetail] loadSources START, kbId=\(knowledgeBaseId)")
do {
sources = try await KnowledgeSourceService.shared.list(kbId: knowledgeBaseId, sortBy: sortBy, order: order)
print("[LibraryDetail] loadSources OK, count=\(sources.count)")
for src in sources {
print("[LibraryDetail] source: id=\(src.id), title=\(src.title ?? "-"), parseStatus=\(src.parseStatus ?? "-")")
}
await refreshSourceTaskStatuses()
} catch {
print("[LibraryDetail] loadSources ERROR: \(error.localizedDescription)")
print("[LibraryDetail] Full error: \(error)")
}
isLoadingSources = false
restartSourceTaskPollingIfNeeded()
}
private func deleteSource(_ src: KnowledgeSource) async {
await deleteSource(byId: src.id)
}
private func deleteSource(byId id: String) async {
do {
let _ = try await KnowledgeSourceService.shared.delete(kbId: knowledgeBaseId, id: src.id)
sources.removeAll { $0.id == src.id }
let _ = try await KnowledgeSourceService.shared.delete(kbId: knowledgeBaseId, id: id)
sources.removeAll { $0.id == id }
ZXToastManager.shared.success("已删除")
} catch { ZXToastManager.shared.error("删除失败") }
}
private func generateKnowledgeForSelectedSources() async {
guard !selectedSourceIds.isEmpty, !isBatchGeneratingSources else { return }
isBatchGeneratingSources = true
defer { isBatchGeneratingSources = false }
do {
let result = try await KnowledgeSourceService.shared.generateKnowledgeBatch(
kbId: knowledgeBaseId,
sourceIds: Array(selectedSourceIds)
)
let queued = result.queuedCount
let waiting = result.waitingForParseCount
let message = waiting > 0
? "已提交 \(result.totalCount) 个任务,\(queued) 个已入队,\(waiting) 个等待解析"
: "已提交 \(result.totalCount) 个知识点生成任务"
ZXToastManager.shared.success(message)
selectedSourceIds.removeAll()
sourceSelectMode = false
await loadSources()
restartSourceTaskPollingIfNeeded()
} catch {
ZXToastManager.shared.error("批量生成失败")
}
}
private func sourceStatusBadge(for source: KnowledgeSource) -> (text: String, color: Color)? {
let effectiveStatus = sourceTaskStatusMap[source.id]?.status ?? source.learningStatus
switch effectiveStatus {
case "waiting_for_parse":
return ("等待解析", Color.orange)
case "queued":
return ("已入队", Color.zxPurple)
case "running":
return ("生成中", Color.blue)
case "completed":
return ("已生成", Color.green)
case "failed":
return ("生成失败", Color.zxCoral)
default:
break
}
switch source.parseStatus {
case "pending", "processing", "queued":
return ("解析中", Color.orange)
case "failed":
return ("解析失败", Color.zxCoral)
default:
return nil
}
}
private func statusBadge(text: String, color: Color) -> some View {
Text(text)
.font(.system(size: 10, weight: .medium))
.foregroundColor(color)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.12))
.clipShape(Capsule())
}
private func readingStatusBadge(_ status: String) -> some View {
let isRead = status == "read"
let color = isRead ? Color.green : Color.zxPurple
let label = isRead ? "已读" : "阅读中"
return Text(label)
.font(.system(size: 10, weight: .medium))
.foregroundColor(color)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(color.opacity(0.1))
.clipShape(Capsule())
}
private func refreshSourceTaskStatuses() async {
do {
let response = try await KnowledgeSourceService.shared.knowledgeGenerationTasks(
kbId: knowledgeBaseId,
sourceIds: sources.map(\.id)
)
sourceTaskStatusMap = Dictionary(
uniqueKeysWithValues: response.items.compactMap { item in
guard let sourceId = item.sourceId else { return nil }
return (sourceId, item)
}
)
} catch {
print("[LibraryDetail] refreshSourceTaskStatuses ERROR: \(error.localizedDescription)")
}
}
private func restartSourceTaskPollingIfNeeded() {
stopSourceTaskPolling()
let hasActiveTask = sourceTaskStatusMap.values.contains { isKnowledgeTaskActive($0.status) }
guard hasActiveTask else { return }
sourceTaskPollingTask = Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 3_000_000_000)
if Task.isCancelled { break }
await refreshSourceTaskStatuses()
let stillActive = sourceTaskStatusMap.values.contains { isKnowledgeTaskActive($0.status) }
if !stillActive { break }
do {
sources = try await KnowledgeSourceService.shared.list(kbId: knowledgeBaseId)
} catch {
print("[LibraryDetail] polling source refresh ERROR: \(error.localizedDescription)")
}
}
sourceTaskPollingTask = nil
}
}
private func stopSourceTaskPolling() {
sourceTaskPollingTask?.cancel()
sourceTaskPollingTask = nil
}
private func isKnowledgeTaskActive(_ status: String) -> Bool {
["waiting_for_parse", "queued", "running"].contains(status)
}
@ViewBuilder
private func itemTabContent() -> some View {
if viewModel.isLoading && viewModel.items.isEmpty {
VStack(spacing: 12) {
ProgressView()
Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
}
.frame(maxWidth: .infinity)
.padding(.top, 80)
}
ForEach(viewModel.items) { item in
itemRowView(item)
}
if viewModel.items.isEmpty && !viewModel.isLoading {
Text("暂无知识点")
.font(.system(size: 14))
.foregroundColor(Color.zxF03)
.padding(.top, 40)
}
if viewModel.hasMore {
ZXLoadMoreFooter { await viewModel.loadMore(knowledgeBaseId: knowledgeBaseId) }
}
}
@ViewBuilder
private func itemRowView(_ item: KnowledgeItem) -> some View {
let icon = fileTypeIcon(for: item)
let type = fileTypeText(for: item)
let date = formatShortDate(item.updatedAt)
let progress = progressFor(item)
if itemSelectMode {
Button {
if selectedItemIds.contains(item.id) { selectedItemIds.remove(item.id) }
else { selectedItemIds.insert(item.id) }
} label: {
HStack(spacing: 10) {
Image(systemName: selectedItemIds.contains(item.id) ? "checkmark.circle" : "circle")
.font(.system(size: 20))
.foregroundColor(selectedItemIds.contains(item.id) ? Color.zxPurple : Color.zxF03)
itemRow(icon: icon, title: item.title, type: type, date: date, progress: progress)
}
}
.foregroundColor(.primary)
} else {
NavigationLink(value: Route.knowledgeDetail(item: item)) {
itemRow(icon: icon, title: item.title, type: type, date: date, progress: progress)
}
.contextMenu {
Button {
itemSelectMode = true
selectedItemIds.insert(item.id)
} label: {
Label("多选", systemImage: "checkmark.circle")
}
Button {
} label: {
Label("重命名", image: "icon-pencil")
}
Button {
} label: {
Label("移动到", image: "icon-folder")
}
Divider()
Button(role: .destructive) {
Task {
await viewModel.batchDeleteItems(ids: [item.id])
await viewModel.refresh(knowledgeBaseId: knowledgeBaseId)
}
} label: {
Label("删除", image: "icon-trash")
}
}
}
}
@ViewBuilder
private func sourceTabContent() -> some View {
if isLoadingSources {
VStack(spacing: 12) {
ProgressView().tint(Color.zxPurple)
Text("加载中…").font(.system(size: 14)).foregroundColor(Color.zxF04)
}
.padding(.top, 80)
} else if sources.isEmpty {
Text("暂无资料来源").font(.system(size: 14)).foregroundColor(Color.zxF03).padding(.top, 40)
} else {
ForEach(sources) { src in
sourceRowView(src)
}
}
}
@ViewBuilder
private func sourceRowView(_ src: KnowledgeSource) -> some View {
if sourceSelectMode {
Button {
if selectedSourceIds.contains(src.id) { selectedSourceIds.remove(src.id) }
else { selectedSourceIds.insert(src.id) }
} label: {
sourceSelectRow(src)
}
.foregroundColor(.primary)
} else {
NavigationLink(value: Route.materialReader(
materialId: src.id,
filePath: "",
materialType: materialTypeFromMime(src.mimeType, filename: src.originalFilename),
knowledgeBaseId: knowledgeBaseId,
title: src.originalFilename ?? src.title ?? "未命名"
)) {
sourceNavigationRow(src)
}
}
}
private func sourceSelectRow(_ src: KnowledgeSource) -> some View {
HStack(spacing: 12) {
Image(systemName: selectedSourceIds.contains(src.id) ? "checkmark.circle.fill" : "circle")
.font(.system(size: 20))
.foregroundColor(selectedSourceIds.contains(src.id) ? Color.zxPurple : Color.zxF03)
Image(systemName: src.type == "file" ? "doc" : "link")
.font(.system(size: 18)).foregroundColor(Color.zxPurple)
.frame(width: 36, height: 36)
sourceMetaColumn(src)
Spacer()
}
.padding(.vertical, 14)
.overlay(alignment: .bottom) { Color.zxHairline.frame(height: 0.5) }
}
private func sourceNavigationRow(_ src: KnowledgeSource) -> some View {
HStack(spacing: 12) {
Image(systemName: src.type == "file" ? "doc" : "link")
.font(.system(size: 18)).foregroundColor(Color.zxPurple)
.frame(width: 36, height: 36)
sourceMetaColumn(src)
Spacer()
Button {
sourceToDelete = src
showSourceDeleteConfirm = true
} label: {
Image("icon-trash").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03)
}
}
.padding(.vertical, 14)
.overlay(alignment: .bottom) { Color.zxHairline.frame(height: 0.5) }
}
private func sourceMetaColumn(_ src: KnowledgeSource) -> some View {
VStack(alignment: .leading, spacing: 2) {
Text(src.originalFilename ?? src.title ?? "未命名")
.font(.system(size: 15, weight: .medium))
.foregroundColor(Color.zxF0)
.lineLimit(1)
HStack(spacing: 8) {
if let len = src.textLength, len > 0 {
Text("\(len)").font(.system(size: 12)).foregroundColor(Color.zxF04)
}
if let size = src.sizeBytes, size > 0 {
Text(formatFileSize(size)).font(.system(size: 12)).foregroundColor(Color.zxF04)
}
if let date = formatSourceDate(src.updatedAt ?? src.createdAt) {
Text(date).font(.system(size: 12)).foregroundColor(Color.zxF04)
}
}
if let badge = sourceStatusBadge(for: src) {
statusBadge(text: badge.text, color: badge.color)
}
if let status = sourceReadingStatus[src.id], status != "not_started" {
readingStatusBadge(status)
}
}
}
}
struct ZXCardRow: View { let icon: String; let title: String; let desc: String; let status: String; let c: Color
var body: some View { HStack(spacing: 12) { Image(systemName: icon).font(.system(size: 18)).foregroundColor(c).frame(width: 40, height: 40).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 10)); VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF03) }; Spacer(); Text(status).font(.system(size: 10, weight: .semibold)).foregroundColor(c).padding(.horizontal, 8).padding(.vertical, 2).background(c.opacity(0.12)).clipShape(Capsule()) }
@ -1111,7 +1410,15 @@ struct KnowledgeDetailPage: View {
}.frame(maxWidth: .infinity, maxHeight: .infinity).background(Color.zxCanvas)
case .ready:
if let path = vm.localPath, let mt = vm.detectedType {
MaterialReaderView(materialId: item.id, filePath: path, materialType: mt, title: item.title, showQuickLook: $showQuickLook, showNoteSheet: $showNoteSheet)
MaterialReaderView(
materialId: item.id,
filePath: path,
materialType: mt,
knowledgeBaseId: item.knowledgeBaseId,
title: item.title,
showQuickLook: $showQuickLook,
showNoteSheet: $showNoteSheet
)
} else if let content = item.content {
// Plain text content render directly
ZStack { Color.zxCanvas.ignoresSafeArea()
@ -1350,137 +1657,6 @@ struct ZXImportRow: View { let icon: String; let title: String; let desc: String
; VStack(alignment: .leading, spacing: 2) { Text(title).font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0); Text(desc).font(.system(size: 12)).foregroundColor(Color.zxF04) }; Spacer(); Image("icon-chevron-right").resizable().scaledToFit().frame(width: 16, height: 16).foregroundColor(Color.zxF03) }.padding(16).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 16)).overlay(RoundedRectangle(cornerRadius: 16).stroke(Color.zxBorder006, lineWidth: 1)) }
}
// MARK: - Import Review
struct ImportReviewPage: View {
let sourceId: String
@Environment(\.dismiss) private var dismiss
@State private var candidates: [ImportCandidate] = []
@State private var isLoading = true
@State private var isProcessing = false
@State private var errorMessage: String?
@State private var expandedId: String?
var body: some View {
ZStack { Color.zxBg0.ignoresSafeArea(); VStack(spacing: 0) {
if isLoading {
VStack(spacing: 12) { ProgressView().tint(Color.zxPurple); Text("加载候选...").font(.system(size: 14)).foregroundColor(Color.zxF04) }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if candidates.isEmpty {
VStack(spacing: 12) {
Image(systemName: "checkmark.circle").font(.system(size: 36)).foregroundColor(Color.zxGreen)
Text("没有待审批的候选").font(.system(size: 14)).foregroundColor(Color.zxF04)
}.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
ScrollView {
VStack(spacing: 12) {
HStack {
Text("\(candidates.count) 个候选知识点").font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxF04)
Spacer()
Button {
Task { await batchAccept() }
} label: {
Text("全部接受").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxPrimary)
}
.disabled(isProcessing)
}
ForEach(candidates) { c in
VStack(spacing: 0) {
Button {
withAnimation { expandedId = expandedId == c.id ? nil : c.id }
} label: {
HStack(spacing: 12) {
if let conf = c.confidence {
Text("\(Int(conf * 100))%").font(.system(size: 10, weight: .bold))
.foregroundColor(conf > 0.7 ? Color.zxGreen : Color.zxAmber)
.padding(.horizontal, 6).padding(.vertical, 2)
.background((conf > 0.7 ? Color.zxGreen : Color.zxAmber).opacity(0.12))
.clipShape(Capsule())
}
VStack(alignment: .leading, spacing: 4) {
Text(c.title ?? "无标题").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF0).lineLimit(1)
if let content = c.content {
Text(content).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(2)
}
}
Spacer()
Image(systemName: expandedId == c.id ? "chevron.up" : "chevron.down").font(.system(size: 12)).foregroundColor(Color.zxF03)
}
.padding(12).background(Color.zxFill003).clipShape(RoundedRectangle(cornerRadius: 14))
}
.foregroundColor(.primary)
if expandedId == c.id {
HStack(spacing: 8) {
Button {
Task { await rejectCandidate(c) }
} label: {
Label("拒绝", systemImage: "xmark").font(.system(size: 14, weight: .medium))
.foregroundColor(Color.zxCoral).frame(maxWidth: .infinity).frame(height: 40)
}
Button {
Task { await acceptCandidate(c) }
} label: {
Label("接受", systemImage: "checkmark").font(.system(size: 14, weight: .medium))
.foregroundColor(Color.zxGreen).frame(maxWidth: .infinity).frame(height: 40)
}
}
.padding(.horizontal, 12).padding(.top, 8)
}
}
}
}
.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
}
.scrollIndicators(.hidden)
}
}}
.navigationTitle("候选审批").navigationBarTitleDisplayMode(.inline).toolbarBackground(.hidden, for: .navigationBar).toolbar(.hidden, for: .tabBar)
.disabled(isProcessing)
.task { await load() }
}
private func load() async {
isLoading = true; errorMessage = nil
do { candidates = try await ImportCandidateService.shared.listBySource(sourceId: sourceId) } catch { errorMessage = error.localizedDescription }
isLoading = false
}
private func acceptCandidate(_ c: ImportCandidate) async {
isProcessing = true
do {
let _ = try await ImportCandidateService.shared.accept(id: c.id)
candidates.removeAll { $0.id == c.id }
ZXToastManager.shared.success("已接受")
} catch { ZXToastManager.shared.error("操作失败") }
isProcessing = false
}
private func rejectCandidate(_ c: ImportCandidate) async {
isProcessing = true
do {
let _ = try await ImportCandidateService.shared.reject(id: c.id)
candidates.removeAll { $0.id == c.id }
ZXToastManager.shared.success("已拒绝")
} catch { ZXToastManager.shared.error("操作失败") }
isProcessing = false
}
private func batchAccept() async {
isProcessing = true
do {
let _ = try await ImportCandidateService.shared.batchAccept(sourceId: sourceId)
candidates.removeAll()
ZXToastManager.shared.success("全部接受")
} catch { ZXToastManager.shared.error("操作失败") }
isProcessing = false
}
}
struct EditKnowledgePage: View {
let item: KnowledgeItem
@State private var title: String; @State private var content: String

View File

@ -91,8 +91,8 @@ final class MaterialDetailViewModel: ObservableObject {
quizCount = quizzesR?.count ?? 0
if let p = progR {
readingStatus = p.status
readingSeconds = p.totalActiveSeconds
isMarkedRead = p.isMarkedRead
readingSeconds = p.totalActiveSeconds ?? 0
isMarkedRead = p.isMarkedRead ?? false
}
aiStatus = itemCount > 0 ? .ready : .processing
@ -104,23 +104,28 @@ final class MaterialDetailViewModel: ObservableObject {
struct MaterialDetailView: View {
@StateObject private var vm: MaterialDetailViewModel
@State private var isGeneratingQuiz = false
@State private var generatedQuizId: String?
@State private var showGeneratedQuiz = false
let knowledgeBaseId: String
let sourceId: String
let fileName: String
let fileType: MaterialType
let fileSize: UInt64
let filePath: String
let uploadDate: String?
init(knowledgeBaseId: String, fileName: String, fileType: MaterialType,
init(knowledgeBaseId: String, sourceId: String, fileName: String, fileType: MaterialType,
fileSize: UInt64, filePath: String, uploadDate: String? = nil) {
self.knowledgeBaseId = knowledgeBaseId
self.sourceId = sourceId
self.fileName = fileName
self.fileType = fileType
self.fileSize = fileSize
self.filePath = filePath
self.uploadDate = uploadDate
_vm = StateObject(wrappedValue: MaterialDetailViewModel(knowledgeBaseId: knowledgeBaseId))
_vm = StateObject(wrappedValue: MaterialDetailViewModel(knowledgeBaseId: knowledgeBaseId, materialId: sourceId))
}
var body: some View {
@ -146,6 +151,13 @@ struct MaterialDetailView: View {
.toolbar(.hidden, for: .tabBar)
.toolbarBackground(.hidden, for: .navigationBar)
.task { await vm.load() }
.navigationDestination(isPresented: $showGeneratedQuiz) {
if let quizId = generatedQuizId {
QuizTakerView(quizId: quizId)
} else {
EmptyView()
}
}
}
// MARK: - File section
@ -300,6 +312,27 @@ struct MaterialDetailView: View {
statBox(count: vm.quizCount, label: "测验", icon: "list.bullet.clipboard", color: Color.zxAccent)
}
Button {
Task { await generateSourceQuiz() }
} label: {
HStack(spacing: 10) {
if isGeneratingQuiz {
ProgressView().tint(.white)
} else {
Image(systemName: "sparkles.rectangle.stack")
.font(.system(size: 14, weight: .semibold))
}
Text("基于当前资料生成测验")
.font(.system(size: 14, weight: .semibold))
}
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: 46)
.background(ZXGradient.brand)
.clipShape(RoundedRectangle(cornerRadius: 12))
}
.disabled(isGeneratingQuiz)
if vm.aiStatus == .processing {
Text("知习正在整理知识点,完成后可开始学习和自测")
.font(.system(size: 12)).foregroundColor(Color.zxF03)
@ -322,6 +355,24 @@ struct MaterialDetailView: View {
.background(Color.zxSurface).clipShape(RoundedRectangle(cornerRadius: 14))
.overlay(RoundedRectangle(cornerRadius: 14).stroke(Color.zxBorder008, lineWidth: 1))
}
private func generateSourceQuiz() async {
isGeneratingQuiz = true
defer { isGeneratingQuiz = false }
do {
let quiz = try await QuizService.shared.generate(
knowledgeBaseId: knowledgeBaseId,
sourceType: "source",
sourceId: sourceId,
questionCount: 5
)
generatedQuizId = quiz.id
showGeneratedQuiz = true
} catch {
ZXToastManager.shared.error("生成失败")
}
}
}
// MARK: - File size formatter

View File

@ -94,28 +94,40 @@ final class MaterialReaderViewModel: ObservableObject {
// MARK: - Main View
struct MaterialReaderView: View {
private enum BootstrapState: Equatable {
case idle
case bootstrapping
case ready
case failed(String)
}
@StateObject private var vm: MaterialReaderViewModel
@Binding var showQuickLook: Bool
@Binding var showNoteSheet: Bool
@State private var scrollProgress: CGFloat = 0
@State private var hasRestoredPosition = false
@State private var restoreBlockId: String?
@State private var bootstrapState: BootstrapState = .idle
@State private var pendingRestoreBlockId: String?
@State private var isInitialRestoreComplete = false
@State private var isProgrammaticScrollInFlight = false
@State private var actualContentHeight: CGFloat = 1
@State private var isMarkedRead = false
@State private var readingStatus: String?
@State private var readingProgressSeconds: Int = 0
@State private var apiRestorePosition: ReadingPosition?
@State private var localRestoreCheckpoint: ReadingCheckpoint?
@State private var flushTimer: Timer?
@Environment(\.scenePhase) private var scenePhase
@State private var isParsing = false
@State private var parseMessage = ""
@State private var showParseOverlay = false
@State private var latestKnowledgeTask: KnowledgeGenerationTaskStatus?
@State private var knowledgeTaskPollingTask: Task<Void, Never>?
private let title: String
private let knowledgeBaseId: String?
// V2 session manager (primary) + V1 collector (fallback)
// V2 session manager
private let sessionManager = ReadingRuntimeSessionManager.shared
private let collector = ReadingEventCollector.shared
private let positionStore = ReadingPositionStore.shared
private let readingAPI = ReadingAPIService.shared
@ -124,13 +136,16 @@ struct MaterialReaderView: View {
sessionManager.state == .active || sessionManager.state == .paused
}
init(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "", showQuickLook: Binding<Bool> = .constant(false), showNoteSheet: Binding<Bool> = .constant(false)) {
init(materialId: String, filePath: String, materialType: MaterialType, knowledgeBaseId: String? = nil, title: String = "", resumePosition: ReadingPositionValue? = nil, showQuickLook: Binding<Bool> = .constant(false), showNoteSheet: Binding<Bool> = .constant(false)) {
self.title = title
self.knowledgeBaseId = knowledgeBaseId
self._showQuickLook = showQuickLook
self._showNoteSheet = showNoteSheet
_vm = StateObject(wrappedValue: MaterialReaderViewModel(
materialId: materialId, filePath: filePath, materialType: materialType))
if let rp = resumePosition {
_apiRestorePosition = State(initialValue: ReadingPositionAdapter.fromValue(rp))
}
}
var body: some View {
@ -147,7 +162,7 @@ struct MaterialReaderView: View {
VStack(spacing: 16) {
Image(systemName: "exclamationmark.triangle").font(.system(size: 40)).foregroundColor(Color.zxCoral)
Text(msg).font(.system(size: 14)).foregroundColor(Color.zxF04)
Button("重试") { Task { await vm.load() } }
Button("重试") { Task { await bootstrapReader(forceReload: true) } }
.font(.system(size: 14, weight: .medium)).foregroundColor(Color.zxPrimary)
.padding(.horizontal, 24).padding(.vertical, 10)
.background(Color.zxPrimarySoft).clipShape(RoundedRectangle(cornerRadius: 10))
@ -171,56 +186,41 @@ struct MaterialReaderView: View {
.toolbarBackground(.hidden, for: .navigationBar)
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
HStack(spacing: 16) {
Button {
markAsRead()
} label: {
HStack(spacing: 4) {
Button { markAsRead() } label: {
Image(systemName: isMarkedRead ? "checkmark.circle.fill" : "checkmark.circle")
.font(.system(size: 16))
.foregroundColor(isMarkedRead ? Color.green : Color.zxF05)
}
NavigationLink(value: Route.aiChat(context: ChatEntryContext(
scopeType: .material,
scopeId: vm.materialId,
scopeName: title,
parentKnowledgeBaseId: knowledgeBaseId,
createdFrom: "material_reader"
))) {
Image(systemName: "bubble.left.and.bubble.right")
.font(.system(size: 16))
.foregroundColor(Color.zxF05)
}
if let kbId = knowledgeBaseId {
Menu {
NavigationLink(value: Route.aiChat(context: ChatEntryContext(
scopeType: .material, scopeId: vm.materialId, scopeName: title,
parentKnowledgeBaseId: knowledgeBaseId, createdFrom: "material_reader"
))) {
Label("AI 对话", systemImage: "bubble.left.and.bubble.right")
}
NavigationLink(value: Route.materialDetail(
knowledgeBaseId: kbId,
fileName: title.isEmpty ? "资料" : title,
fileType: vm.materialType,
fileSize: 0,
filePath: vm.filePath,
uploadDate: nil
knowledgeBaseId: kbId, sourceId: vm.materialId, fileName: title.isEmpty ? "资料" : title,
fileType: vm.materialType, fileSize: 0, filePath: vm.filePath, uploadDate: nil
)) {
Image(systemName: "info.circle")
.font(.system(size: 16))
.foregroundColor(Color.zxF05)
Label("资料详情", systemImage: "info.circle")
}
NavigationLink(value: Route.learningSession(
taskTitle: title.isEmpty ? "学习资料" : title,
taskType: "study",
taskColorHex: "#3D7FFB"
taskTitle: title.isEmpty ? "学习资料" : title, taskType: "study", taskColorHex: "#3D7FFB"
)) {
Image(systemName: "arrow.triangle.2.circlepath")
.font(.system(size: 16))
.foregroundColor(Color.zxF05)
Label("学习会话", systemImage: "arrow.triangle.2.circlepath")
}
Button {
Task { await triggerParse(kbId: kbId, sourceId: vm.materialId) }
Task { await generateKnowledgePoints(kbId: kbId, sourceId: vm.materialId) }
} label: {
Image(systemName: "brain.head.profile")
.font(.system(size: 16))
Label(generateKnowledgeMenuTitle, systemImage: "brain.head.profile")
}
.disabled(isKnowledgeTaskActive)
} label: {
Image(systemName: "ellipsis.circle")
.font(.system(size: 20))
.foregroundColor(Color.zxF05)
}
}
@ -228,9 +228,9 @@ struct MaterialReaderView: View {
}
}
.overlay(alignment: .bottom) {
if isParsing {
if showParseOverlay {
HStack(spacing: 8) {
ProgressView().tint(.white)
if isParsing { ProgressView().tint(.white) }
Text(parseMessage).font(.system(size: 13, weight: .medium)).foregroundColor(.white)
}
.padding(.horizontal, 16).padding(.vertical, 10)
@ -240,29 +240,11 @@ struct MaterialReaderView: View {
}
}
.task {
if vm.filePath.isEmpty || !FileManager.default.fileExists(atPath: vm.filePath) {
if let kbId = knowledgeBaseId {
print("[MaterialReader] No local file, downloading from COS: \(vm.materialId)")
do {
let source = try await KnowledgeSourceService.shared.detail(kbId: kbId, id: vm.materialId)
if let fileId = source.fileId {
let downloadUrl = try await FileUploadService.shared.getDownloadUrl(fileId: fileId)
let url = URL(string: downloadUrl)!
let (data, _) = try await URLSession.shared.data(from: url)
let ext = source.originalFilename?.split(separator: ".").last.map(String.init) ?? "md"
let localURL = FileManager.default.temporaryDirectory
.appendingPathComponent("source_\(vm.materialId).\(ext)")
try data.write(to: localURL)
print("[MaterialReader] Downloaded to: \(localURL.path), size: \(data.count)")
vm.filePath = localURL.path
}
} catch {
print("[MaterialReader] COS download failed: \(error.localizedDescription)")
}
}
}
await vm.load()
await bootstrapReader()
await refreshKnowledgeTaskStatus()
restartKnowledgeTaskPollingIfNeeded()
}
.onDisappear { stopKnowledgeTaskPolling() }
.sheet(isPresented: $showQuickLook) {
QuickLookPreview(url: URL(fileURLWithPath: vm.filePath))
}
@ -274,16 +256,16 @@ struct MaterialReaderView: View {
)
}
.onAppear {
openReadingSession()
hasRestoredPosition = false
// 2 退
if flushTimer == nil {
flushTimer = Timer.scheduledTimer(withTimeInterval: 120, repeats: true) { _ in
Task {
ReadingEventUploadPipeline.shared.exportAndEnqueue()
await ReadingEventUploadPipeline.shared.exportAndEnqueue()
await ReadingEventUploadPipeline.shared.flush()
}
}
}
}
.onDisappear {
flushTimer?.invalidate()
flushTimer = nil
@ -292,7 +274,7 @@ struct MaterialReaderView: View {
// 2. material_closed+
// 3. context
Task {
ReadingEventUploadPipeline.shared.exportAndEnqueue()
await ReadingEventUploadPipeline.shared.exportAndEnqueue()
await ReadingEventUploadPipeline.shared.flush()
sessionManager.cleanup()
}
@ -306,7 +288,7 @@ struct MaterialReaderView: View {
}
// Flush: export enqueue
Task {
ReadingEventUploadPipeline.shared.exportAndEnqueue()
await ReadingEventUploadPipeline.shared.exportAndEnqueue()
await ReadingEventUploadPipeline.shared.flush()
}
case .active:
@ -320,10 +302,12 @@ struct MaterialReaderView: View {
break
}
}
.onChange(of: vm.loadingState) { _, newState in
if newState == .loaded, !hasRestoredPosition {
restorePosition()
hasRestoredPosition = true
.onChange(of: apiRestorePosition) { _, newPos in
if newPos != nil,
vm.loadingState == .loaded,
!isInitialRestoreComplete,
pendingRestoreBlockId == nil {
restoreInitialPositionIfNeeded()
}
}
}
@ -393,13 +377,12 @@ struct MaterialReaderView: View {
scrollProgress = min(1, max(0, -offset / max(actualContentHeight, 1)))
reportScrollPosition()
}
.onChange(of: restoreBlockId) { _, target in
if let id = target {
DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) {
withAnimation { scrollProxy.scrollTo(id, anchor: .top) }
}
restoreBlockId = nil
.onAppear {
consumePendingRestore(using: scrollProxy)
}
.onChange(of: pendingRestoreBlockId) { _, target in
guard target != nil else { return }
consumePendingRestore(using: scrollProxy)
}
.scrollIndicators(.hidden)
}
@ -408,65 +391,68 @@ struct MaterialReaderView: View {
// MARK: - Reading Session Lifecycle
private func openReadingSession() {
// Prevent duplicate open
guard !isV2Active else {
print("[READER] V2 session already active, skipping open")
return
}
let targetType = knowledgeBaseId != nil ? "knowledge_source" : "temporary_file"
let fileName = URL(fileURLWithPath: vm.filePath).lastPathComponent
let context = ReadingMaterialContext(
readingTargetType: knowledgeBaseId != nil ? .knowledgeSource : .temporaryFile,
materialId: vm.materialId,
knowledgeBaseId: knowledgeBaseId,
title: title.isEmpty ? nil : title
title: title.isEmpty ? nil : title,
materialType: vm.materialType,
filePath: vm.filePath,
fileName: fileName,
fileType: vm.materialType.fileTypeString
)
// V2 primary
if let _ = try? sessionManager.openMaterial(context) {
// V2 session
do {
try sessionManager.openMaterial(context)
print("[READER] V2 session opened — materialId=\(vm.materialId)")
} else {
print("[READER] V2 unavailable, falling back to V1")
collector.open(materialId: vm.materialId)
} catch {
print("[READER] V2 session open FAILED: \(error) — materialId=\(vm.materialId)")
}
}
// Query reading progress from API
Task {
do {
let progress = try await readingAPI.getReadingProgress(materialId: vm.materialId, targetType: targetType)
await MainActor.run {
readingStatus = progress.status
readingProgressSeconds = progress.totalActiveSeconds
if progress.isMarkedRead { isMarkedRead = true; MarkedReadStore.shared.mark(vm.materialId) }
if let lp = progress.lastPosition {
apiRestorePosition = ReadingPositionAdapter.fromValue(lp)
private func bootstrapReader(forceReload: Bool = false) async {
if bootstrapState == .bootstrapping {
return
}
if bootstrapState == .ready && !forceReload {
return
}
} catch {
// Fallback to local cache when API unavailable
if MarkedReadStore.shared.contains(vm.materialId) { isMarkedRead = true }
apiRestorePosition = positionStore.load(materialId: vm.materialId)
print("[READER] Progress query failed, using local position: \(error)")
bootstrapState = .bootstrapping
pendingRestoreBlockId = nil
isProgrammaticScrollInFlight = false
isInitialRestoreComplete = false
await ensureLocalFileIfNeeded()
await vm.load()
guard vm.loadingState == .loaded else {
if case .error(let message) = vm.loadingState {
bootstrapState = .failed(message)
}
return
}
openReadingSession()
await fetchReadingProgress()
restoreInitialPositionIfNeeded()
if pendingRestoreBlockId == nil {
isInitialRestoreComplete = true
}
bootstrapState = .ready
}
private func closeReadingSession() {
// V2 primary
// V2 session
if sessionManager.state == .active || sessionManager.state == .paused {
if let lastPos = sessionManager.lastPosition {
positionStore.save(materialId: vm.materialId, position: lastPos)
}
sessionManager.closeMaterial()
print("[READER] V2 session closed")
return
}
// V1 fallback
if let lastPos = collector.lastPosition {
positionStore.save(materialId: vm.materialId, position: lastPos)
}
_ = collector.close(materialId: vm.materialId)
print("[READER] V1 session closed")
}
/// Mark the current material as read optimistic update + event push.
@ -474,58 +460,165 @@ struct MaterialReaderView: View {
guard !isMarkedRead else { return }
isMarkedRead = true
MarkedReadStore.shared.mark(vm.materialId)
if isV2Active {
sessionManager.markAsRead()
} else {
collector.markAsRead(materialId: vm.materialId)
}
}
/// Build a NoteAnchor from the current scroll position (for quick note).
private func buildAnchor() -> NoteAnchor? {
let pos = sessionManager.lastPosition ?? collector.lastPosition
let pos = sessionManager.lastPosition
guard let pos else { return nil }
return createNoteAnchor(materialId: vm.materialId, position: pos)
}
/// Restore saved reading position on re-entry.
/// Priority: API lastPosition > local positionStore > session lastPosition
private func restorePosition() {
// If API already fetched a position, use it immediately
if let apiPos = apiRestorePosition {
applyPosition(apiPos)
private func ensureLocalFileIfNeeded() async {
guard vm.filePath.isEmpty || !FileManager.default.fileExists(atPath: vm.filePath) else {
return
}
guard let kbId = knowledgeBaseId else {
return
}
// Fallback: local store
print("[MaterialReader] No local file, downloading from COS: \(vm.materialId)")
do {
let source = try await KnowledgeSourceService.shared.detail(kbId: kbId, id: vm.materialId)
if let fileId = source.fileId {
let downloadUrl = try await FileUploadService.shared.getDownloadUrl(fileId: fileId)
let url = URL(string: downloadUrl)!
let (data, _) = try await URLSession.shared.data(from: url)
let ext = source.originalFilename?.split(separator: ".").last.map(String.init) ?? "md"
let localURL = FileManager.default.temporaryDirectory
.appendingPathComponent("source_\(vm.materialId).\(ext)")
try data.write(to: localURL)
print("[MaterialReader] Downloaded to: \(localURL.path), size: \(data.count)")
vm.filePath = localURL.path
}
} catch {
print("[MaterialReader] COS download failed: \(error.localizedDescription)")
}
}
private func fetchReadingProgress() async {
let targetType = knowledgeBaseId != nil ? "knowledge_source" : "temporary_file"
do {
let progress = try await readingAPI.getReadingProgress(materialId: vm.materialId, targetType: targetType)
readingStatus = progress.status
readingProgressSeconds = progress.totalActiveSeconds ?? 0
if progress.isMarkedRead == true {
isMarkedRead = true
MarkedReadStore.shared.mark(vm.materialId)
}
if let lp = progress.lastPosition {
apiRestorePosition = ReadingPositionAdapter.fromValue(lp)
}
} catch {
if MarkedReadStore.shared.contains(vm.materialId) {
isMarkedRead = true
}
localRestoreCheckpoint = positionStore.loadCheckpoint(materialId: vm.materialId)
apiRestorePosition = localRestoreCheckpoint?.position ?? positionStore.load(materialId: vm.materialId)
print("[READER] Progress query failed, using local position: \(error)")
}
}
/// Restore saved reading position on re-entry.
/// Same-device local checkpoints are preferred over API progress because
/// the home "continue learning" flow can reopen the reader before the
/// latest uploaded position has been processed server-side.
private func restoreInitialPositionIfNeeded() {
guard !isInitialRestoreComplete else { return }
if let checkpoint = localRestoreCheckpoint,
sessionManager.isCompatibleRestoreCheckpoint(checkpoint) {
applyPosition(checkpoint.position)
return
}
if let checkpoint = positionStore.loadCheckpoint(materialId: vm.materialId),
sessionManager.isCompatibleRestoreCheckpoint(checkpoint) {
localRestoreCheckpoint = checkpoint
applyPosition(checkpoint.position)
return
}
if localRestoreCheckpoint != nil {
print("[READER] Skip incompatible local checkpoint — materialId=\(vm.materialId)")
localRestoreCheckpoint = nil
}
if let saved = positionStore.load(materialId: vm.materialId) {
applyPosition(saved)
return
}
// Last resort: session/collector
if let pos = sessionManager.lastPosition ?? collector.lastPosition {
applyPosition(pos)
if let apiPos = apiRestorePosition {
applyPosition(apiPos)
return
}
if let pos = sessionManager.lastPosition {
applyPosition(pos)
return
}
isInitialRestoreComplete = true
}
private func applyPosition(_ pos: ReadingPosition) {
switch pos {
case .markdown(let blockId, _):
restoreBlockId = blockId
case .text(let lineNumber, _):
let idx = Int(lineNumber) - 1
if idx >= 0, idx < vm.blocks.count {
restoreBlockId = ReadingPositionAdapter.blockId(from: vm.blocks[idx])
if let restoreBlockId = ReadingPositionAdapter.blockIdForRestore(from: pos, in: vm.blocks) {
pendingRestoreBlockId = restoreBlockId
return
}
switch pos {
case .block, .markdown, .text:
isInitialRestoreComplete = true
print("[READER] Restore target missing, skipped — materialId=\(vm.materialId)")
case .pdf, .pdfViewport, .image, .imageViewport, .workbook, .slide, .epub, .unknown:
isInitialRestoreComplete = true
}
}
private func confirmRestoredPosition() {
guard let restoreBlockId = pendingRestoreBlockId else { return }
guard let restoredIndex = ReadingPositionAdapter.blockIndex(for: restoreBlockId, in: vm.blocks) else { return }
let restoredProgress: Float
if vm.blocks.count <= 1 {
restoredProgress = 0
} else {
restoredProgress = Float(restoredIndex) / Float(vm.blocks.count - 1)
}
guard let position = ReadingPositionAdapter.fromBlockScroll(
materialType: vm.materialType,
blocks: vm.blocks,
scrollProgress: restoredProgress,
blockIndex: restoredIndex
) else { return }
if sessionManager.state == .active {
sessionManager.updatePosition(position)
}
}
private func consumePendingRestore(using scrollProxy: ScrollViewProxy) {
guard let restoreBlockId = pendingRestoreBlockId else { return }
isProgrammaticScrollInFlight = true
DispatchQueue.main.async {
scrollProxy.scrollTo(restoreBlockId, anchor: .top)
DispatchQueue.main.async {
confirmRestoredPosition()
pendingRestoreBlockId = nil
isProgrammaticScrollInFlight = false
isInitialRestoreComplete = true
}
case .pdf, .image, .epub, .unknown:
break
}
}
/// Report current scroll position to the active session manager.
private func reportScrollPosition() {
guard !vm.blocks.isEmpty else { return }
guard isInitialRestoreComplete else { return }
guard !isProgrammaticScrollInFlight else { return }
let idx = max(0, min(vm.blocks.count - 1,
Int(scrollProgress * CGFloat(vm.blocks.count))))
@ -541,9 +634,6 @@ struct MaterialReaderView: View {
// V2 primary
if sessionManager.state == .active {
sessionManager.updatePosition(pos)
} else {
// V1 fallback
collector.updatePosition(materialId: vm.materialId, position: pos)
}
}
@ -612,19 +702,80 @@ struct MaterialReaderView: View {
// MARK: - Parse Trigger
private func triggerParse(kbId: String, sourceId: String) async {
guard !isParsing else { return }
private func generateKnowledgePoints(kbId: String, sourceId: String) async {
guard !isParsing, !isKnowledgeTaskActive else { return }
isParsing = true
parseMessage = "AI 正在整理知识点..."
showParseOverlay = true
parseMessage = "正在提交知识点生成任务..."
do {
let result = try await KnowledgeSourceService.shared.generateKnowledge(kbId: kbId, sourceId: sourceId)
parseMessage = result.count > 0
? "整理完成,生成了 \(result.count) 个知识点"
: "未识别到可提取的知识点"
parseMessage = result.message
latestKnowledgeTask = result.asTaskStatus
ZXToastManager.shared.success(result.message)
restartKnowledgeTaskPollingIfNeeded()
} catch {
parseMessage = "整理失败: \(error.localizedDescription)"
parseMessage = "生成失败: \(error.localizedDescription)"
ZXToastManager.shared.error(parseMessage)
}
isParsing = false
try? await Task.sleep(nanoseconds: 1_500_000_000)
if !isParsing { showParseOverlay = false }
}
private var generateKnowledgeMenuTitle: String {
switch latestKnowledgeTask?.status {
case "waiting_for_parse":
return "等待解析中"
case "queued":
return "已入队"
case "running":
return "生成中"
case "completed":
return "重新生成知识点"
case "failed":
return "重新生成知识点"
default:
return "生成知识点"
}
}
private var isKnowledgeTaskActive: Bool {
guard let status = latestKnowledgeTask?.status else { return false }
return ["waiting_for_parse", "queued", "running"].contains(status)
}
private func refreshKnowledgeTaskStatus() async {
guard let kbId = knowledgeBaseId else { return }
do {
latestKnowledgeTask = try await KnowledgeSourceService.shared.latestKnowledgeGenerationTask(
kbId: kbId,
sourceId: vm.materialId
)
if let latestKnowledgeTask {
parseMessage = latestKnowledgeTask.message
}
} catch {
print("[MaterialReader] refreshKnowledgeTaskStatus ERROR: \(error.localizedDescription)")
}
}
private func restartKnowledgeTaskPollingIfNeeded() {
stopKnowledgeTaskPolling()
guard isKnowledgeTaskActive else { return }
knowledgeTaskPollingTask = Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 3_000_000_000)
if Task.isCancelled { break }
await refreshKnowledgeTaskStatus()
if !isKnowledgeTaskActive { break }
}
knowledgeTaskPollingTask = nil
}
}
private func stopKnowledgeTaskPolling() {
knowledgeTaskPollingTask?.cancel()
knowledgeTaskPollingTask = nil
}
// MARK: - Unsupported

View File

@ -24,10 +24,21 @@ struct ReadingPositionAdapter {
switch materialType {
case .markdown:
guard let bid = blockId else { return nil }
return .markdown(blockId: bid, scrollProgress: clamp(scrollProgress ?? 0))
return .block(
blockId: bid,
blockProgress: clamp(scrollProgress ?? 0),
overallProgress: clamp(overallProgress ?? scrollProgress ?? 0)
)
case .text:
guard let bid = blockId else {
return .text(lineNumber: lineNumber ?? 1, scrollProgress: clamp(scrollProgress ?? 0))
}
return .block(
blockId: bid,
blockProgress: clamp(scrollProgress ?? 0),
overallProgress: clamp(overallProgress ?? scrollProgress ?? 0)
)
case .pdf:
return .pdf(
@ -77,6 +88,13 @@ struct ReadingPositionAdapter {
/// Convert an API `ReadingPositionValue` DTO into a `ReadingPosition` enum.
static func fromValue(_ v: ReadingPositionValue) -> ReadingPosition? {
switch v.type {
case "Block":
guard let bid = v.blockId else { return nil }
return .block(
blockId: bid,
blockProgress: clamp(v.blockProgress ?? 0),
overallProgress: clamp(v.overallProgress ?? 0)
)
case "Markdown":
guard let bid = v.blockId else { return nil }
return .markdown(blockId: bid, scrollProgress: clamp(v.scrollProgress ?? 0))
@ -94,6 +112,50 @@ struct ReadingPositionAdapter {
}
}
static func checkpointPosition(from position: ReadingPosition) -> ReadingPosition {
switch position {
case .markdown(let blockId, let scrollProgress):
return .block(
blockId: blockId,
blockProgress: clamp(scrollProgress),
overallProgress: clamp(scrollProgress)
)
default:
return position
}
}
static func blockIndex(for blockId: String, in blocks: [DocumentBlock]) -> Int? {
blocks.firstIndex { candidate in
self.blockId(from: candidate) == blockId
}
}
static func blockIdForRestore(from position: ReadingPosition, in blocks: [DocumentBlock]) -> String? {
guard !blocks.isEmpty else { return nil }
switch position {
case .block(let blockId, _, let overallProgress):
if blockIndex(for: blockId, in: blocks) != nil {
return blockId
}
return fallbackBlockId(for: overallProgress, in: blocks)
case .markdown(let blockId, let scrollProgress):
if blockIndex(for: blockId, in: blocks) != nil {
return blockId
}
return fallbackBlockId(for: scrollProgress, in: blocks)
case .text(let lineNumber, let scrollProgress):
let lineIndex = Int(lineNumber) - 1
if lineIndex >= 0, lineIndex < blocks.count {
return blockId(from: blocks[lineIndex])
}
return fallbackBlockId(for: scrollProgress, in: blocks)
default:
return nil
}
}
// MARK: - Helpers
private static func clamp(_ value: Float) -> Float {
@ -102,6 +164,14 @@ struct ReadingPositionAdapter {
return value
}
private static func fallbackBlockId(for progress: Float, in blocks: [DocumentBlock]) -> String? {
guard !blocks.isEmpty else { return nil }
let normalized = clamp(progress)
let scaled = normalized * Float(max(blocks.count - 1, 0))
let index = min(blocks.count - 1, max(0, Int(round(scaled))))
return blockId(from: blocks[index])
}
static func blockId(from block: DocumentBlock) -> String {
switch block {
case .heading(let id, _, _): return id

View File

@ -21,12 +21,24 @@ final class ReadingPositionStore {
defaults.set(dict, forKey: key(materialId))
}
func save(materialId: String, checkpoint: ReadingCheckpoint) {
defaults.set(checkpointToDict(checkpoint), forKey: key(materialId))
}
/// Load the last reading position, if any.
func load(materialId: String) -> ReadingPosition? {
guard let dict = defaults.dictionary(forKey: key(materialId)) else { return nil }
if let checkpoint = checkpointFromDict(dict) {
return checkpoint.position
}
return positionFromDict(dict)
}
func loadCheckpoint(materialId: String) -> ReadingCheckpoint? {
guard let dict = defaults.dictionary(forKey: key(materialId)) else { return nil }
return checkpointFromDict(dict)
}
/// Remove saved position (e.g. when material is deleted).
func remove(materialId: String) {
defaults.removeObject(forKey: key(materialId))
@ -45,6 +57,8 @@ final class ReadingPositionStore {
private func positionToDict(_ pos: ReadingPosition) -> [String: Any] {
switch pos {
case .block(let blockId, let blockProgress, let overallProgress):
return ["t": "block", "b": blockId, "bp": blockProgress, "op": overallProgress]
case .markdown(let blockId, let scrollProgress):
return ["t": "markdown", "b": blockId, "s": scrollProgress]
case .text(let lineNumber, let scrollProgress):
@ -53,6 +67,14 @@ final class ReadingPositionStore {
return ["t": "pdf", "p": pageNumber, "pp": pageProgress, "op": overallProgress]
case .image(let zoomScale, let offsetX, let offsetY):
return ["t": "image", "z": zoomScale, "x": offsetX, "y": offsetY]
case .pdfViewport(let pageNumber, let anchorX, let anchorY, let zoomScale, let overallProgress):
return ["t": "pdf_viewport", "p": pageNumber, "ax": anchorX, "ay": anchorY, "z": zoomScale, "op": overallProgress]
case .imageViewport(let zoomScale, let anchorX, let anchorY):
return ["t": "image_viewport", "z": zoomScale, "ax": anchorX, "ay": anchorY]
case .workbook(let sheetId, let topRow, let leftColumn, let selectedCell, let zoomScale, let overallProgress):
return ["t": "workbook", "sid": sheetId, "tr": topRow, "lc": leftColumn, "sc": selectedCell as Any, "z": zoomScale, "op": overallProgress as Any]
case .slide(let slideIndex, let zoomScale, let anchorX, let anchorY, let overallProgress):
return ["t": "slide", "si": slideIndex, "z": zoomScale, "ax": anchorX, "ay": anchorY, "op": overallProgress]
case .epub(let chapterId, let chapterProgress, let overallProgress):
return ["t": "epub", "c": chapterId, "cp": chapterProgress, "op": overallProgress]
case .unknown:
@ -63,6 +85,11 @@ final class ReadingPositionStore {
private func positionFromDict(_ dict: [String: Any]) -> ReadingPosition? {
guard let type = dict["t"] as? String else { return nil }
switch type {
case "block":
guard let b = dict["b"] as? String else { return nil }
let bp = (dict["bp"] as? Float) ?? 0
let op = (dict["op"] as? Float) ?? 0
return .block(blockId: b, blockProgress: bp, overallProgress: op)
case "markdown":
guard let b = dict["b"] as? String else { return nil }
let s = (dict["s"] as? Float) ?? 0
@ -81,6 +108,33 @@ final class ReadingPositionStore {
let x = (dict["x"] as? Float) ?? 0
let y = (dict["y"] as? Float) ?? 0
return .image(zoomScale: z, offsetX: x, offsetY: y)
case "pdf_viewport":
let p = (dict["p"] as? UInt32) ?? 1
let ax = (dict["ax"] as? Float) ?? 0.5
let ay = (dict["ay"] as? Float) ?? 0
let z = (dict["z"] as? Float) ?? 1
let op = (dict["op"] as? Float) ?? 0
return .pdfViewport(pageNumber: p, anchorX: ax, anchorY: ay, zoomScale: z, overallProgress: op)
case "image_viewport":
let z = (dict["z"] as? Float) ?? 1
let ax = (dict["ax"] as? Float) ?? 0.5
let ay = (dict["ay"] as? Float) ?? 0.5
return .imageViewport(zoomScale: z, anchorX: ax, anchorY: ay)
case "workbook":
guard let sid = dict["sid"] as? String else { return nil }
let tr = (dict["tr"] as? UInt32) ?? 0
let lc = (dict["lc"] as? UInt32) ?? 0
let sc = dict["sc"] as? String
let z = (dict["z"] as? Float) ?? 1
let op = dict["op"] as? Float
return .workbook(sheetId: sid, topRow: tr, leftColumn: lc, selectedCell: sc, zoomScale: z, overallProgress: op)
case "slide":
let si = (dict["si"] as? UInt32) ?? 0
let z = (dict["z"] as? Float) ?? 1
let ax = (dict["ax"] as? Float) ?? 0.5
let ay = (dict["ay"] as? Float) ?? 0.5
let op = (dict["op"] as? Float) ?? 0
return .slide(slideIndex: si, zoomScale: z, anchorX: ax, anchorY: ay, overallProgress: op)
case "epub":
guard let c = dict["c"] as? String else { return nil }
let cp = (dict["cp"] as? Float) ?? 0
@ -90,4 +144,70 @@ final class ReadingPositionStore {
return .unknown
}
}
private func checkpointToDict(_ checkpoint: ReadingCheckpoint) -> [String: Any] {
[
"schemaVersion": checkpoint.schemaVersion,
"materialId": checkpoint.materialId,
"representationFingerprint": checkpoint.representationFingerprint,
"rendererKind": rendererKindRaw(checkpoint.rendererKind),
"capturedAtMs": checkpoint.capturedAtMs,
"deviceId": checkpoint.deviceId,
"deviceSequence": checkpoint.deviceSequence,
"position": positionToDict(checkpoint.position)
]
}
private func checkpointFromDict(_ dict: [String: Any]) -> ReadingCheckpoint? {
guard
let schemaVersion = dict["schemaVersion"] as? UInt32,
let materialId = dict["materialId"] as? String,
let representationFingerprint = dict["representationFingerprint"] as? String,
let rendererKindRaw = dict["rendererKind"] as? String,
let capturedAtMs = dict["capturedAtMs"] as? UInt64,
let deviceId = dict["deviceId"] as? String,
let deviceSequence = dict["deviceSequence"] as? UInt64,
let positionDict = dict["position"] as? [String: Any],
let position = positionFromDict(positionDict),
let rendererKind = rendererKind(from: rendererKindRaw)
else {
return nil
}
return ReadingCheckpoint(
schemaVersion: schemaVersion,
materialId: materialId,
representationFingerprint: representationFingerprint,
rendererKind: rendererKind,
capturedAtMs: capturedAtMs,
deviceId: deviceId,
deviceSequence: deviceSequence,
position: position
)
}
private func rendererKindRaw(_ kind: RendererKind) -> String {
switch kind {
case .block: return "block"
case .pdf: return "pdf"
case .image: return "image"
case .workbook: return "workbook"
case .slide: return "slide"
case .epub: return "epub"
case .unknown: return "unknown"
}
}
private func rendererKind(from raw: String) -> RendererKind? {
switch raw {
case "block": return .block
case "pdf": return .pdf
case "image": return .image
case "workbook": return .workbook
case "slide": return .slide
case "epub": return .epub
case "unknown": return .unknown
default: return nil
}
}
}

View File

@ -39,6 +39,7 @@ final class ReadingRuntimeSessionManager {
private(set) var activeSessionId: String?
private(set) var activeContext: ReadingMaterialContext?
private(set) var lastPosition: ReadingPosition?
private(set) var lastCheckpoint: ReadingCheckpoint?
// MARK: - Dependencies
@ -50,9 +51,16 @@ final class ReadingRuntimeSessionManager {
private var positionDebounceTask: Task<Void, Never>?
private var lastHeartbeatAtMs: Int64 = 0
private var backgroundedAtMs: Int64 = 0
private var activeRendererKind: RendererKind = .unknown
private var activeRepresentationFingerprint = ""
private let positionStore = ReadingPositionStore.shared
private let defaults = UserDefaults.standard
/// Max allowed background time before session is considered stale (5 minutes).
private let sessionResumeMaxAgeMs: Int64 = 300_000
private let checkpointSchemaVersion: UInt32 = 2
private let deviceIdKey = "reading_checkpoint.device_id"
private let deviceSequenceKey = "reading_checkpoint.device_sequence"
private init() {}
@ -66,23 +74,41 @@ final class ReadingRuntimeSessionManager {
// MARK: - Session Lifecycle
/// Start a reading session for a material.
/// - Throws if a session is already active (must close first).
/// If a session is already active for the same material, reuses it.
/// If active for a different material, closes the old one first.
func openMaterial(_ context: ReadingMaterialContext) throws {
guard state == .idle || state == .closed else {
throw SessionError.alreadyActive
if state == .active || state == .paused {
if activeContext?.materialId == context.materialId {
return // same material, reuse existing session
}
closeMaterial() // different material, close old session first
}
state = .opening
let material = ReadingMaterialRef(materialId: context.materialId)
let now = nowMs()
// Build V2 material ref with full file metadata
let materialV2 = ReadingMaterialRefV2(
materialId: context.materialId,
filePath: context.filePath,
fileName: context.fileName,
fileType: context.fileType,
mimeType: context.mimeType,
fileSize: context.fileSize,
contentHash: nil,
createdAtMs: nil
)
do {
let sessionId = try adapter.startSession(material: material, timestampMs: now)
activeRendererKind = rendererKind(for: context)
activeRepresentationFingerprint = try buildRepresentationFingerprint(for: context, rendererKind: activeRendererKind)
let sessionId = try adapter.startSession(material: materialV2, timestampMs: now)
activeSessionId = sessionId
activeContext = context
state = .active
ReadingContextRegistry.shared.register(context)
lastPosition = nil
lastCheckpoint = nil
lastHeartbeatAtMs = now
// Push MaterialOpened
@ -113,10 +139,11 @@ final class ReadingRuntimeSessionManager {
let now = nowMs()
let sinceLastTick = lastHeartbeatAtMs > 0 ? UInt32((now - lastHeartbeatAtMs) / 1000) : 0
_ = try? adapter.pushClosed(sessionId: sessionId, materialId: ctx.materialId, delta: sinceLastTick, timestampMs: now)
_ = try? adapter.closeSession(sessionId)
_ = try? adapter.closeSession(sessionId, timestampMs: now)
activeSessionId = nil
lastPosition = nil
lastCheckpoint = nil
state = .closed
}
@ -158,15 +185,17 @@ final class ReadingRuntimeSessionManager {
func updatePosition(_ position: ReadingPosition) {
guard state == .active, let sessionId = activeSessionId, let ctx = activeContext else { return }
lastPosition = position
lastCheckpoint = makeCheckpoint(position: position, context: ctx)
positionDebounceTask?.cancel()
positionDebounceTask = Task { [weak self] in
try? await Task.sleep(nanoseconds: 2_000_000_000)
guard let self, !Task.isCancelled, let pos = self.lastPosition,
guard let self, !Task.isCancelled, let checkpoint = self.lastCheckpoint,
let sid = self.activeSessionId, let c = self.activeContext else { return }
_ = try? self.adapter.pushPositionChanged(
self.positionStore.save(materialId: c.materialId, checkpoint: checkpoint)
_ = try? self.adapter.pushCheckpointChanged(
sessionId: sid, materialId: c.materialId,
position: pos, timestampMs: self.nowMs()
checkpoint: checkpoint, timestampMs: self.nowMs()
)
}
}
@ -176,6 +205,12 @@ final class ReadingRuntimeSessionManager {
_ = try? adapter.pushMarkedAsRead(sessionId: sessionId, materialId: ctx.materialId, timestampMs: nowMs())
}
func isCompatibleRestoreCheckpoint(_ checkpoint: ReadingCheckpoint) -> Bool {
checkpoint.materialId == activeContext?.materialId &&
checkpoint.rendererKind == activeRendererKind &&
checkpoint.representationFingerprint == activeRepresentationFingerprint
}
// MARK: - Heartbeat
private func startHeartbeat(interval: TimeInterval = 15) {
@ -187,9 +222,9 @@ final class ReadingRuntimeSessionManager {
let sid = self.activeSessionId,
let ctx = self.activeContext else { return }
let now = self.nowMs()
_ = try? self.adapter.pushHeartbeat(
_ = try? self.adapter.pushHeartbeatCheckpoint(
sessionId: sid, materialId: ctx.materialId,
delta: UInt32(interval), position: self.lastPosition,
delta: UInt32(interval), checkpoint: self.lastCheckpoint,
timestampMs: now
)
self.lastHeartbeatAtMs = now
@ -205,10 +240,11 @@ final class ReadingRuntimeSessionManager {
private func flushPosition() {
positionDebounceTask?.cancel()
// Force-send last position before clearing
if let pos = lastPosition, let sid = activeSessionId, let ctx = activeContext {
_ = try? adapter.pushPositionChanged(
if let checkpoint = lastCheckpoint, let sid = activeSessionId, let ctx = activeContext {
positionStore.save(materialId: ctx.materialId, checkpoint: checkpoint)
_ = try? adapter.pushCheckpointChanged(
sessionId: sid, materialId: ctx.materialId,
position: pos, timestampMs: nowMs()
checkpoint: checkpoint, timestampMs: nowMs()
)
}
positionDebounceTask = nil
@ -219,6 +255,88 @@ final class ReadingRuntimeSessionManager {
private func nowMs() -> Int64 {
Int64(Date().timeIntervalSince1970 * 1000)
}
private func makeCheckpoint(position: ReadingPosition, context: ReadingMaterialContext) -> ReadingCheckpoint {
let normalizedPosition = ReadingPositionAdapter.checkpointPosition(from: position)
let capturedAtMs = UInt64(max(0, nowMs()))
let sequence = nextDeviceSequence()
return ReadingCheckpoint(
schemaVersion: checkpointSchemaVersion,
materialId: context.materialId,
representationFingerprint: activeRepresentationFingerprint,
rendererKind: activeRendererKind,
capturedAtMs: capturedAtMs,
deviceId: deviceId(),
deviceSequence: sequence,
position: normalizedPosition
)
}
private func buildRepresentationFingerprint(for context: ReadingMaterialContext, rendererKind: RendererKind) throws -> String {
guard !context.filePath.isEmpty else {
return "\(context.materialId):\(rendererKind)"
}
let sourceFingerprint = try adapter.runtimeComputeFileFingerprint(filePath: context.filePath)
return adapter.runtimeBuildRepresentationFingerprint(
sourceFingerprint: sourceFingerprint,
rendererKind: rendererKind,
parserVersion: parserVersion(for: rendererKind),
schemaVersion: checkpointSchemaVersion
)
}
private func rendererKind(for context: ReadingMaterialContext) -> RendererKind {
switch context.fileType.lowercased() {
case "markdown", "text", "word":
return .block
case "pdf":
return .pdf
case "image":
return .image
case "excel":
return .workbook
case "powerpoint":
return .slide
case "epub":
return .epub
default:
return .unknown
}
}
private func parserVersion(for rendererKind: RendererKind) -> String {
switch rendererKind {
case .block:
return "block-parser-v1"
case .pdf:
return "pdf-locator-v1"
case .image:
return "image-locator-v1"
case .workbook:
return "workbook-parser-v1"
case .slide:
return "slide-parser-v1"
case .epub:
return "epub-locator-v1"
case .unknown:
return "unknown-v1"
}
}
private func deviceId() -> String {
if let existing = defaults.string(forKey: deviceIdKey) {
return existing
}
let generated = UUID().uuidString
defaults.set(generated, forKey: deviceIdKey)
return generated
}
private func nextDeviceSequence() -> UInt64 {
let next = UInt64(defaults.integer(forKey: deviceSequenceKey)) + 1
defaults.set(Int(next), forKey: deviceSequenceKey)
return next
}
}
// MARK: - Errors

View File

@ -18,7 +18,7 @@ struct QuizListView: View {
VStack(spacing: 16) {
Image("icon-question").font(.system(size: 40)).foregroundColor(Color.zxF03)
Text("暂无测验").font(.system(size: 14, weight: .semibold)).foregroundColor(Color.zxF04)
Text("基于知识库内容自动生成测验题目").font(.system(size: 12)).foregroundColor(Color.zxF03)
Text("基于资料内容 AI 生成测验题目").font(.system(size: 12)).foregroundColor(Color.zxF03)
Button {
Task { await generateQuiz() }
} label: {
@ -125,8 +125,8 @@ struct QuizTakerView: View {
Text(question.stem ?? "").font(.system(size: 16, weight: .semibold)).foregroundColor(Color.zxF0).lineSpacing(4)
// M-QUIZ-01-CLEANUP-02: 使 QuizQuestionType
switch question.questionType {
case .choice:
switch question.questionType ?? "" {
case "choice":
if let options = question.options {
VStack(spacing: 8) {
ForEach(Array(options.enumerated()), id: \.offset) { i, opt in
@ -142,7 +142,7 @@ struct QuizTakerView: View {
}
}
}
case .judge:
case "judge":
HStack(spacing: 12) {
ForEach(["true", "false"], id: \.self) { v in
Button {
@ -152,9 +152,9 @@ struct QuizTakerView: View {
}.foregroundColor(.primary)
}
}
case .fill:
case "fill":
TextField("输入你的答案", text: Binding(get: { answers[question.id] ?? "" }, set: { setAnswer(question.id, $0) })).font(.system(size: 14)).tint(Color.zxPurple).padding(.horizontal, 16).frame(height: 48).background(Color.zxFill004).clipShape(RoundedRectangle(cornerRadius: 12)).overlay(RoundedRectangle(cornerRadius: 12).stroke(Color.zxBorder008, lineWidth: 1))
case nil:
default:
// M-QUIZ-01-CLEANUP-02: fail-closed
VStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle").font(.system(size: 24)).foregroundColor(Color.zxCoral)
@ -271,36 +271,8 @@ struct QuizResultView: View {
VStack(alignment: .leading, spacing: 12) {
Text("答题详情").font(.system(size: 16, weight: .bold)).foregroundColor(Color.zxF0)
ForEach(r.answers ?? []) { a in
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
Image(systemName: a.isCorrect == true ? "checkmark.circle" : "xmark.circle").font(.system(size: 18)).foregroundColor(a.isCorrect == true ? Color.zxGreen : Color.zxCoral)
Text(a.question?.stem ?? "").font(.system(size: 14)).foregroundColor(Color.zxF0).lineLimit(3)
Spacer()
}
// M-QUIZ-01-03 / M-QUIZ-01-CLEANUP-02: 使 QuizQuestionType
let qType = a.question?.questionType
let options = a.question?.options ?? []
let displayUserAnswer = Self.answerDisplayText(raw: a.userAnswer ?? "", type: qType, options: options)
let displayCorrectAnswer = Self.answerDisplayText(raw: a.question?.answer ?? "", type: qType, options: options)
HStack(spacing: 16) {
if !displayUserAnswer.isEmpty {
HStack(spacing: 4) {
Text("你的答案").font(.system(size: 11)).foregroundColor(Color.zxF04)
Text(displayUserAnswer).font(.system(size: 12, weight: .medium)).foregroundColor(a.isCorrect == true ? Color.zxGreen : Color.zxCoral)
}
}
if !displayCorrectAnswer.isEmpty {
HStack(spacing: 4) {
Text("正确答案").font(.system(size: 11)).foregroundColor(Color.zxF04)
Text(displayCorrectAnswer).font(.system(size: 12, weight: .medium)).foregroundColor(Color.zxGreen)
}
}
}
if let exp = a.question?.explanation {
Text(exp).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(4)
}
}.padding(12).background(a.isCorrect == true ? Color.zxGreen.opacity(0.05) : Color.zxCoral.opacity(0.05)).clipShape(RoundedRectangle(cornerRadius: 12))
AnswerDetailRow(a: a)
.padding(12).background(a.isCorrect == true ? Color.zxGreen.opacity(0.05) : Color.zxCoral.opacity(0.05)).clipShape(RoundedRectangle(cornerRadius: 12))
}
}
}.padding(.horizontal, 20).padding(.top, 8).padding(.bottom, 100)
@ -318,6 +290,47 @@ struct QuizResultView: View {
}
// M-QUIZ-01-CLEANUP-02: 使 QuizQuestionType.displayAnswer
fileprivate struct AnswerDetailRow: View {
let a: QuizAttemptAnswer
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 12) {
let correct = a.isCorrect == true
let stem = a.question?.stem ?? ""
Image(systemName: correct ? "checkmark.circle" : "xmark.circle").font(.system(size: 18)).foregroundColor(correct ? Color.zxGreen : Color.zxCoral)
Text(stem).font(.system(size: 14)).foregroundColor(Color.zxF0).lineLimit(3)
Spacer()
}
let qType = a.question?.questionType
let options = a.question?.options ?? []
let displayUserAnswer = Self.answerDisplayText(raw: a.userAnswer ?? "", type: qType, options: options)
let displayCorrectAnswer = Self.answerDisplayText(raw: a.question?.answer ?? "", type: qType, options: options)
HStack(spacing: 16) {
if !displayUserAnswer.isEmpty {
HStack(spacing: 4) {
Text("你的答案").font(.system(size: 11)).foregroundColor(Color.zxF04)
Text(displayUserAnswer).font(.system(size: 12, weight: .medium)).foregroundColor(a.isCorrect == true ? Color.zxGreen : Color.zxCoral)
}
}
if !displayCorrectAnswer.isEmpty {
HStack(spacing: 4) {
Text("正确答案").font(.system(size: 11)).foregroundColor(Color.zxF04)
Text(displayCorrectAnswer).font(.system(size: 12, weight: .medium)).foregroundColor(Color.zxGreen)
}
}
}
if let exp = a.question?.explanation {
Text(exp).font(.system(size: 12)).foregroundColor(Color.zxF04).lineLimit(4)
}
}
}
static func answerDisplayText(raw: String, type: String?, options: [String]) -> String {
guard let t = type, let qType = QuizQuestionType(rawValue: t) else { return raw }
return qType.displayAnswer(raw: raw, options: options)
}
}
static func answerDisplayText(raw: String, type: QuizQuestionType?, options: [String]) -> String {
guard let qType = type else { return raw } //
return qType.displayAnswer(raw: raw, options: options)

View File

@ -18,6 +18,7 @@ struct StudyHomeView: View {
}
}
.task { await vm.loadAll() }
.onAppear { Task { await vm.refresh() } }
}
// MARK: - Loading / Error
@ -121,14 +122,14 @@ struct StudyHomeView: View {
@ViewBuilder
private var mainActionCard: some View {
switch vm.mainAction {
case .continueSession(let materialId, let filePath, let materialType, let kbTitle, let elapsed, _):
case .continueSession(let materialId, let filePath, let materialType, let kbTitle, let elapsed, let resumePosition):
actionCard(
title: "继续上次学习",
subtitle: "\(kbTitle)》· \(elapsed)",
icon: "arrow.triangle.2.circlepath",
color: Color.zxPurple,
cta: "继续学习",
route: .materialReader(materialId: materialId, filePath: filePath, materialType: materialType, title: kbTitle)
route: .materialReader(materialId: materialId, filePath: filePath, materialType: materialType, title: kbTitle, resumePosition: resumePosition)
)
case .todaysReview(let count, let minutes):
actionCard(

View File

@ -43,11 +43,29 @@ extension ReadingAPIService: ContinueLearningServicing {}
// MARK: - Action states
enum MainAction: Equatable {
case continueSession(materialId: String, filePath: String, materialType: MaterialType, kbTitle: String, elapsed: String, resumePosition: ReadingPosition?)
case continueSession(materialId: String, filePath: String, materialType: MaterialType, kbTitle: String, elapsed: String, resumePosition: ReadingPositionValue?)
case todaysReview(count: Int, estimatedMinutes: Int)
case selfTest(quizId: String, count: Int)
case startLearning(knowledgeBaseId: String, kbCount: Int)
case empty
static func == (lhs: MainAction, rhs: MainAction) -> Bool {
switch (lhs, rhs) {
case (.continueSession(let lmid, let lpath, let lmt, let lkb, let lel, let lpos),
.continueSession(let rmid, let rpath, let rmt, let rkb, let rel, let rpos)):
return lmid == rmid && lpath == rpath && lmt == rmt && lkb == rkb && lel == rel && lpos == rpos
case (.todaysReview(let lc, let lm), .todaysReview(let rc, let rm)):
return lc == rc && lm == rm
case (.selfTest(let lq, let lc), .selfTest(let rq, let rc)):
return lq == rq && lc == rc
case (.startLearning(let lkb, let lc), .startLearning(let rkb, let rc)):
return lkb == rkb && lc == rc
case (.empty, .empty):
return true
default:
return false
}
}
}
enum HomeLoadingState: Equatable {
@ -180,7 +198,7 @@ final class StudyHomeViewModel: ObservableObject {
let resolved = MaterialPathResolver.resolve(materialId: materialId, apiType: cl.type, title: cl.title) {
let kbTitle = cl.title ?? knowledgeBases.first?.title ?? "学习"
let elapsed = formatElapsedSince(cl.lastReadAt)
return .continueSession(materialId: materialId, filePath: resolved.filePath, materialType: resolved.materialType, kbTitle: kbTitle, elapsed: elapsed, resumePosition: nil)
return .continueSession(materialId: materialId, filePath: resolved.filePath, materialType: resolved.materialType, kbTitle: kbTitle, elapsed: elapsed, resumePosition: cl.lastPosition)
}
if !dueCards.isEmpty {

View File

@ -0,0 +1,135 @@
import XCTest
@testable import AIStudyApp
final class ReadingPositionAdapterTests: XCTestCase {
private func makeValue(
type: String?,
blockId: String? = nil,
blockProgress: Float? = nil,
lineNumber: UInt32? = nil,
pageNumber: UInt32? = nil,
scrollProgress: Float? = nil,
pageProgress: Float? = nil,
overallProgress: Float? = nil,
zoomScale: Float? = nil,
offsetX: Float? = nil,
offsetY: Float? = nil,
chapterId: String? = nil,
chapterProgress: Float? = nil
) -> ReadingPositionValue {
ReadingPositionValue(
type: type,
pageNumber: pageNumber,
blockId: blockId,
blockProgress: blockProgress,
scrollProgress: scrollProgress,
lineNumber: lineNumber,
pageProgress: pageProgress,
overallProgress: overallProgress,
zoomScale: zoomScale,
offsetX: offsetX,
offsetY: offsetY,
chapterId: chapterId,
chapterProgress: chapterProgress,
offset: nil,
percent: nil,
section: nil
)
}
func test_fromValue_markdown_with_blockId() {
let v = makeValue(type: "Markdown", blockId: "b1", scrollProgress: 0.5)
let pos = ReadingPositionAdapter.fromValue(v)
guard case let .markdown(blockId, scrollProgress) = pos else {
return XCTFail("Expected .markdown, got \(String(describing: pos))")
}
XCTAssertEqual(blockId, "b1")
XCTAssertEqual(scrollProgress, 0.5)
}
func test_fromValue_markdown_nil_blockId_returns_nil() {
let v = makeValue(type: "Markdown")
XCTAssertNil(ReadingPositionAdapter.fromValue(v))
}
func test_fromValue_text_with_optional_fields() {
let v = makeValue(type: "Text", lineNumber: 42, scrollProgress: 0.8)
guard case let .text(lineNumber, scrollProgress) = ReadingPositionAdapter.fromValue(v) else {
return XCTFail("Expected .text")
}
XCTAssertEqual(lineNumber, 42)
XCTAssertEqual(scrollProgress, 0.8)
}
func test_fromValue_pdf_with_all_fields() {
let v = makeValue(type: "Pdf", pageNumber: 5, pageProgress: 0.3, overallProgress: 0.15)
guard case let .pdf(pageNumber, pageProgress, overallProgress) = ReadingPositionAdapter.fromValue(v) else {
return XCTFail("Expected .pdf")
}
XCTAssertEqual(pageNumber, 5)
XCTAssertEqual(pageProgress, 0.3)
XCTAssertEqual(overallProgress, 0.15)
}
func test_fromBlockScroll_markdown_returns_position() {
let blocks: [DocumentBlock] = [
.heading(id: "h1", level: 1, text: "Title"),
.paragraph(id: "p1", text: "Content"),
]
let pos = ReadingPositionAdapter.fromBlockScroll(
materialType: .markdown, blocks: blocks, scrollProgress: 0.5, blockIndex: 0
)
guard case .block(let blockId, let blockProgress, let overallProgress) = pos else {
return XCTFail("Expected .block")
}
XCTAssertEqual(blockId, "h1")
XCTAssertEqual(blockProgress, 0.5)
XCTAssertEqual(overallProgress, 0.5)
}
func test_blockIdForRestore_prefers_existing_blockId() {
let blocks: [DocumentBlock] = [
.heading(id: "h1", level: 1, text: "Title"),
.paragraph(id: "p1", text: "Content"),
.paragraph(id: "p2", text: "Tail"),
]
let restored = ReadingPositionAdapter.blockIdForRestore(
from: .block(blockId: "p1", blockProgress: 0.25, overallProgress: 0.8),
in: blocks
)
XCTAssertEqual(restored, "p1")
}
func test_blockIdForRestore_fallsBack_to_progress_when_blockId_missing() {
let blocks: [DocumentBlock] = [
.heading(id: "h1", level: 1, text: "Title"),
.paragraph(id: "p1", text: "Content"),
.paragraph(id: "p2", text: "Tail"),
]
let restored = ReadingPositionAdapter.blockIdForRestore(
from: .block(blockId: "missing", blockProgress: 0.1, overallProgress: 0.9),
in: blocks
)
XCTAssertEqual(restored, "p2")
}
func test_blockIdForRestore_text_out_of_range_fallsBack_to_scroll_progress() {
let blocks: [DocumentBlock] = [
.heading(id: "h1", level: 1, text: "Title"),
.paragraph(id: "p1", text: "Content"),
.paragraph(id: "p2", text: "Tail"),
]
let restored = ReadingPositionAdapter.blockIdForRestore(
from: .text(lineNumber: 99, scrollProgress: 0.45),
in: blocks
)
XCTAssertEqual(restored, "p1")
}
}

View File

@ -0,0 +1,189 @@
import XCTest
@testable import AIStudyApp
@MainActor
private final class SpyReadingRuntimeAdapter: ReadingRuntimeAdapter {
var fingerprint = "fingerprint-1"
private var nextSequence: UInt64 = 1
func startSession(material: ReadingMaterialRefV2, timestampMs: Int64) throws -> String {
"spy-session"
}
func pauseSession(_ sessionId: String) throws {}
func resumeSession(_ sessionId: String) throws {}
func closeSession(_ sessionId: String, timestampMs: Int64) throws {}
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .materialOpened)
}
func pushClosed(sessionId: String, materialId: String, delta: UInt32, timestampMs: Int64) throws -> ReadingEventV2 {
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .materialClosed)
}
func pushPositionChanged(sessionId: String, materialId: String, position: ReadingPosition, timestampMs: Int64) throws -> ReadingEventV2 {
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .positionChanged, position: position)
}
func pushCheckpointChanged(sessionId: String, materialId: String, checkpoint: ReadingCheckpoint, timestampMs: Int64) throws -> ReadingEventV2 {
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .positionChanged, checkpoint: checkpoint)
}
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2 {
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .heartbeat, position: position)
}
func pushHeartbeatCheckpoint(sessionId: String, materialId: String, delta: UInt32, checkpoint: ReadingCheckpoint?, timestampMs: Int64) throws -> ReadingEventV2 {
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .heartbeat, checkpoint: checkpoint)
}
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .markedAsRead)
}
func exportEvents(limit: UInt32, timestampMs: Int64) -> [ReadingEventV2] { [] }
func ackEvents(_ eventIds: [String]) -> UInt32 { UInt32(eventIds.count) }
func markFailed(_ eventIds: [String]) -> UInt32 { UInt32(eventIds.count) }
func reloadStaleEvents() -> UInt32 { 0 }
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32 { 0 }
func runtimeComputeFileFingerprint(filePath: String) throws -> String {
fingerprint
}
func runtimeBuildRepresentationFingerprint(sourceFingerprint: String, rendererKind: RendererKind, parserVersion: String, schemaVersion: UInt32) -> String {
"\(sourceFingerprint)|\(rendererKind)|\(parserVersion)|\(schemaVersion)"
}
private func makeEvent(
sessionId: String,
materialId: String,
eventType: ReadingEventTypeV2,
position: ReadingPosition? = nil,
checkpoint: ReadingCheckpoint? = nil
) -> ReadingEventV2 {
defer { nextSequence += 1 }
return ReadingEventV2(
eventId: "evt-\(nextSequence)",
clientSessionId: sessionId,
materialId: materialId,
eventType: eventType,
position: position,
checkpoint: checkpoint,
activeSecondsDelta: 0,
timestampMs: 1_700_000_000_000,
sequence: nextSequence
)
}
}
@MainActor
final class SessionManagerTests: XCTestCase {
var manager: ReadingRuntimeSessionManager!
var noopAdapter: NoopReadingRuntimeAdapter!
fileprivate var spyAdapter: SpyReadingRuntimeAdapter!
override func setUp() {
super.setUp()
manager = ReadingRuntimeSessionManager.shared
noopAdapter = NoopReadingRuntimeAdapter()
spyAdapter = SpyReadingRuntimeAdapter()
manager.adapter = noopAdapter
manager.closeMaterial()
}
override func tearDown() {
manager.closeMaterial()
super.tearDown()
}
func test_initial_state_is_idle() {
XCTAssertTrue(manager.state == .idle || manager.state == .closed)
}
func test_markAsRead_guards_against_inactive() {
manager.closeMaterial()
manager.markAsRead()
XCTAssertTrue(true)
}
func test_isCompatibleRestoreCheckpoint_matches_active_representation() throws {
manager.adapter = spyAdapter
let context = ReadingMaterialContext(
readingTargetType: .knowledgeSource,
materialId: "restore-match",
materialType: .markdown,
filePath: "",
fileName: "doc.md",
fileType: "markdown"
)
try manager.openMaterial(context)
let checkpoint = ReadingCheckpoint(
schemaVersion: 2,
materialId: "restore-match",
representationFingerprint: "restore-match:block",
rendererKind: .block,
capturedAtMs: 1,
deviceId: "device-1",
deviceSequence: 1,
position: .block(blockId: "b1", blockProgress: 0.2, overallProgress: 0.2)
)
XCTAssertTrue(manager.isCompatibleRestoreCheckpoint(checkpoint))
}
func test_isCompatibleRestoreCheckpoint_rejects_mismatched_fingerprint() throws {
manager.adapter = spyAdapter
let context = ReadingMaterialContext(
readingTargetType: .knowledgeSource,
materialId: "restore-mismatch",
materialType: .markdown,
filePath: "",
fileName: "doc.md",
fileType: "markdown"
)
try manager.openMaterial(context)
let checkpoint = ReadingCheckpoint(
schemaVersion: 2,
materialId: "restore-mismatch",
representationFingerprint: "stale-fingerprint",
rendererKind: .block,
capturedAtMs: 1,
deviceId: "device-1",
deviceSequence: 1,
position: .block(blockId: "b1", blockProgress: 0.2, overallProgress: 0.2)
)
XCTAssertFalse(manager.isCompatibleRestoreCheckpoint(checkpoint))
}
func test_isCompatibleRestoreCheckpoint_rejects_mismatched_renderer_kind() throws {
manager.adapter = spyAdapter
let context = ReadingMaterialContext(
readingTargetType: .knowledgeSource,
materialId: "restore-renderer",
materialType: .markdown,
filePath: "",
fileName: "doc.md",
fileType: "markdown"
)
try manager.openMaterial(context)
let checkpoint = ReadingCheckpoint(
schemaVersion: 2,
materialId: "restore-renderer",
representationFingerprint: "restore-renderer:block",
rendererKind: .pdf,
capturedAtMs: 1,
deviceId: "device-1",
deviceSequence: 1,
position: .pdf(pageNumber: 1, pageProgress: 0, overallProgress: 0)
)
XCTAssertFalse(manager.isCompatibleRestoreCheckpoint(checkpoint))
}
}

View File

@ -11,10 +11,11 @@ final class PositionAdapterTests: XCTestCase {
blockId: "b1",
scrollProgress: 0.5
)
if case .markdown(_, let progress) = pos! {
if case .block(_, let progress, let overallProgress) = pos! {
XCTAssertEqual(progress, 0.5)
XCTAssertEqual(overallProgress, 0.5)
} else {
XCTFail("Expected markdown position")
XCTFail("Expected block position")
}
}
@ -24,10 +25,11 @@ final class PositionAdapterTests: XCTestCase {
blockId: "b1",
scrollProgress: -0.5
)
if case .markdown(_, let progress) = pos! {
if case .block(_, let progress, let overallProgress) = pos! {
XCTAssertEqual(progress, 0.0, "Negative progress should clamp to 0")
XCTAssertEqual(overallProgress, 0.0)
} else {
XCTFail("Expected markdown position")
XCTFail("Expected block position")
}
}
@ -37,10 +39,11 @@ final class PositionAdapterTests: XCTestCase {
blockId: "b1",
scrollProgress: 1.5
)
if case .markdown(_, let progress) = pos! {
if case .block(_, let progress, let overallProgress) = pos! {
XCTAssertEqual(progress, 1.0, "Progress >1 should clamp to 1")
XCTAssertEqual(overallProgress, 1.0)
} else {
XCTFail("Expected markdown position")
XCTFail("Expected block position")
}
}
@ -50,10 +53,11 @@ final class PositionAdapterTests: XCTestCase {
blockId: "b1",
scrollProgress: Float.nan
)
if case .markdown(_, let progress) = pos! {
if case .block(_, let progress, let overallProgress) = pos! {
XCTAssertEqual(progress, 0.0, "NaN progress should clamp to 0")
XCTAssertEqual(overallProgress, 0.0)
} else {
XCTFail("Expected markdown position")
XCTFail("Expected block position")
}
}
@ -78,8 +82,8 @@ final class PositionAdapterTests: XCTestCase {
func test_text_position() {
let pos = ReadingPositionAdapter.buildPosition(
materialType: .text,
lineNumber: 42,
scrollProgress: 0.7
scrollProgress: 0.7,
lineNumber: 42
)
if case .text(let line, let progress) = pos! {
XCTAssertEqual(line, 42)
@ -108,9 +112,9 @@ final class PositionAdapterTests: XCTestCase {
func test_epub_position() {
let pos = ReadingPositionAdapter.buildPosition(
materialType: .epub,
chapterId: "ch3",
chapterProgress: 0.4,
overallProgress: 0.2
overallProgress: 0.2,
chapterId: "ch3"
)
if case .epub(let ch, let chProg, let overall) = pos! {
XCTAssertEqual(ch, "ch3")

View File

@ -80,18 +80,18 @@ final class QuizQuestionTypeTests: XCTestCase {
//
func testQuizQuestionTypeProperty() {
let q = QuizQuestion(id: "1", quizId: nil, type: "choice", stem: nil, options: nil, answer: nil, explanation: nil, orderIndex: nil)
XCTAssertEqual(q.questionType, .choice)
let q = QuizQuestion(id: "1", quizId: nil, stem: nil, questionText: nil, questionType: "choice", options: nil, answer: nil, correctAnswer: nil, explanation: nil, orderIndex: nil)
XCTAssertEqual(q.questionTypeEnum, .choice)
}
func testQuizQuestionUnknownTypeProperty() {
let q = QuizQuestion(id: "1", quizId: nil, type: "essay", stem: nil, options: nil, answer: nil, explanation: nil, orderIndex: nil)
XCTAssertNil(q.questionType)
let q = QuizQuestion(id: "1", quizId: nil, stem: nil, questionText: nil, questionType: "essay", options: nil, answer: nil, correctAnswer: nil, explanation: nil, orderIndex: nil)
XCTAssertNil(q.questionTypeEnum)
}
func testQuizQuestionNilTypeProperty() {
let q = QuizQuestion(id: "1", quizId: nil, type: nil, stem: nil, options: nil, answer: nil, explanation: nil, orderIndex: nil)
XCTAssertNil(q.questionType)
let q = QuizQuestion(id: "1", quizId: nil, stem: nil, questionText: nil, questionType: nil, options: nil, answer: nil, correctAnswer: nil, explanation: nil, orderIndex: nil)
XCTAssertNil(q.questionTypeEnum)
}
//

View File

@ -239,4 +239,51 @@ final class ReadingPositionAdapterTests: XCTestCase {
)
XCTAssertNil(pos)
}
// MARK: - Restore fallback
func test_blockIdForRestore_prefers_existing_blockId() {
let blocks: [DocumentBlock] = [
.heading(id: "h1", level: 1, text: "Title"),
.paragraph(id: "p1", text: "Content"),
.paragraph(id: "p2", text: "Tail"),
]
let restored = ReadingPositionAdapter.blockIdForRestore(
from: .block(blockId: "p1", blockProgress: 0.25, overallProgress: 0.8),
in: blocks
)
XCTAssertEqual(restored, "p1")
}
func test_blockIdForRestore_fallsBack_to_progress_when_blockId_missing() {
let blocks: [DocumentBlock] = [
.heading(id: "h1", level: 1, text: "Title"),
.paragraph(id: "p1", text: "Content"),
.paragraph(id: "p2", text: "Tail"),
]
let restored = ReadingPositionAdapter.blockIdForRestore(
from: .block(blockId: "missing", blockProgress: 0.1, overallProgress: 0.9),
in: blocks
)
XCTAssertEqual(restored, "p2")
}
func test_blockIdForRestore_text_out_of_range_fallsBack_to_scroll_progress() {
let blocks: [DocumentBlock] = [
.heading(id: "h1", level: 1, text: "Title"),
.paragraph(id: "p1", text: "Content"),
.paragraph(id: "p2", text: "Tail"),
]
let restored = ReadingPositionAdapter.blockIdForRestore(
from: .text(lineNumber: 99, scrollProgress: 0.45),
in: blocks
)
XCTAssertEqual(restored, "p1")
}
}

View File

@ -1,16 +1,95 @@
import XCTest
@testable import AIStudyApp
@MainActor
private final class SpyReadingRuntimeAdapter: ReadingRuntimeAdapter {
var fingerprint = "fingerprint-1"
private var nextSequence: UInt64 = 1
func startSession(material: ReadingMaterialRefV2, timestampMs: Int64) throws -> String {
"spy-session"
}
func pauseSession(_ sessionId: String) throws {}
func resumeSession(_ sessionId: String) throws {}
func closeSession(_ sessionId: String, timestampMs: Int64) throws {}
func pushOpened(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .materialOpened)
}
func pushClosed(sessionId: String, materialId: String, delta: UInt32, timestampMs: Int64) throws -> ReadingEventV2 {
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .materialClosed)
}
func pushPositionChanged(sessionId: String, materialId: String, position: ReadingPosition, timestampMs: Int64) throws -> ReadingEventV2 {
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .positionChanged, position: position)
}
func pushCheckpointChanged(sessionId: String, materialId: String, checkpoint: ReadingCheckpoint, timestampMs: Int64) throws -> ReadingEventV2 {
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .positionChanged, checkpoint: checkpoint)
}
func pushHeartbeat(sessionId: String, materialId: String, delta: UInt32, position: ReadingPosition?, timestampMs: Int64) throws -> ReadingEventV2 {
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .heartbeat, position: position)
}
func pushHeartbeatCheckpoint(sessionId: String, materialId: String, delta: UInt32, checkpoint: ReadingCheckpoint?, timestampMs: Int64) throws -> ReadingEventV2 {
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .heartbeat, checkpoint: checkpoint)
}
func pushMarkedAsRead(sessionId: String, materialId: String, timestampMs: Int64) throws -> ReadingEventV2 {
makeEvent(sessionId: sessionId, materialId: materialId, eventType: .markedAsRead)
}
func exportEvents(limit: UInt32, timestampMs: Int64) -> [ReadingEventV2] { [] }
func ackEvents(_ eventIds: [String]) -> UInt32 { UInt32(eventIds.count) }
func markFailed(_ eventIds: [String]) -> UInt32 { UInt32(eventIds.count) }
func reloadStaleEvents() -> UInt32 { 0 }
func cleanupStaleSessions(nowMs: Int64, maxAgeMs: Int64) -> UInt32 { 0 }
func runtimeComputeFileFingerprint(filePath: String) throws -> String {
fingerprint
}
func runtimeBuildRepresentationFingerprint(sourceFingerprint: String, rendererKind: RendererKind, parserVersion: String, schemaVersion: UInt32) -> String {
"\(sourceFingerprint)|\(rendererKind)|\(parserVersion)|\(schemaVersion)"
}
private func makeEvent(
sessionId: String,
materialId: String,
eventType: ReadingEventTypeV2,
position: ReadingPosition? = nil,
checkpoint: ReadingCheckpoint? = nil
) -> ReadingEventV2 {
defer { nextSequence += 1 }
return ReadingEventV2(
eventId: "evt-\(nextSequence)",
clientSessionId: sessionId,
materialId: materialId,
eventType: eventType,
position: position,
checkpoint: checkpoint,
activeSecondsDelta: 0,
timestampMs: 1_700_000_000_000,
sequence: nextSequence
)
}
}
@MainActor
final class SessionManagerTests: XCTestCase {
var manager: ReadingRuntimeSessionManager!
var noopAdapter: NoopReadingRuntimeAdapter!
var spyAdapter: SpyReadingRuntimeAdapter!
override func setUp() {
super.setUp()
manager = ReadingRuntimeSessionManager.shared
noopAdapter = NoopReadingRuntimeAdapter()
spyAdapter = SpyReadingRuntimeAdapter()
manager.adapter = noopAdapter
// Ensure clean state
manager.closeMaterial()
@ -122,4 +201,84 @@ final class SessionManagerTests: XCTestCase {
// Also acceptable: state was reset
}
}
// MARK: - Restore checkpoint compatibility
func test_isCompatibleRestoreCheckpoint_matches_active_representation() throws {
manager.adapter = spyAdapter
let context = ReadingMaterialContext(
readingTargetType: .knowledgeSource,
materialId: "restore-match",
materialType: .markdown,
filePath: "",
fileName: "doc.md",
fileType: "markdown"
)
try manager.openMaterial(context)
let checkpoint = ReadingCheckpoint(
schemaVersion: 2,
materialId: "restore-match",
representationFingerprint: "restore-match|block|block-parser-v1|2",
rendererKind: .block,
capturedAtMs: 1,
deviceId: "device-1",
deviceSequence: 1,
position: .block(blockId: "b1", blockProgress: 0.2, overallProgress: 0.2)
)
XCTAssertTrue(manager.isCompatibleRestoreCheckpoint(checkpoint))
}
func test_isCompatibleRestoreCheckpoint_rejects_mismatched_fingerprint() throws {
manager.adapter = spyAdapter
let context = ReadingMaterialContext(
readingTargetType: .knowledgeSource,
materialId: "restore-mismatch",
materialType: .markdown,
filePath: "",
fileName: "doc.md",
fileType: "markdown"
)
try manager.openMaterial(context)
let checkpoint = ReadingCheckpoint(
schemaVersion: 2,
materialId: "restore-mismatch",
representationFingerprint: "stale-fingerprint",
rendererKind: .block,
capturedAtMs: 1,
deviceId: "device-1",
deviceSequence: 1,
position: .block(blockId: "b1", blockProgress: 0.2, overallProgress: 0.2)
)
XCTAssertFalse(manager.isCompatibleRestoreCheckpoint(checkpoint))
}
func test_isCompatibleRestoreCheckpoint_rejects_mismatched_renderer_kind() throws {
manager.adapter = spyAdapter
let context = ReadingMaterialContext(
readingTargetType: .knowledgeSource,
materialId: "restore-renderer",
materialType: .markdown,
filePath: "",
fileName: "doc.md",
fileType: "markdown"
)
try manager.openMaterial(context)
let checkpoint = ReadingCheckpoint(
schemaVersion: 2,
materialId: "restore-renderer",
representationFingerprint: "restore-renderer|block|block-parser-v1|2",
rendererKind: .pdf,
capturedAtMs: 1,
deviceId: "device-1",
deviceSequence: 1,
position: .pdf(pageNumber: 1, pageProgress: 0, overallProgress: 0)
)
XCTAssertFalse(manager.isCompatibleRestoreCheckpoint(checkpoint))
}
}

View File

@ -24,14 +24,23 @@ final class V2AdapterSmokeTests: XCTestCase {
func test_noopAdapter_startSession_returnsSessionId() {
let adapter = NoopReadingRuntimeAdapter()
let ref = ReadingMaterialRef(materialId: "test-material")
let ref = ReadingMaterialRefV2(
materialId: "test-material",
filePath: "/tmp/test.md",
fileName: "test.md",
fileType: "markdown",
mimeType: nil,
fileSize: nil,
contentHash: nil,
createdAtMs: nil
)
let sid = try! adapter.startSession(material: ref, timestampMs: 1_700_000_000_000)
XCTAssertTrue(sid.hasPrefix("noop-"))
}
func test_noopAdapter_closeSession_doesNotThrow() {
let adapter = NoopReadingRuntimeAdapter()
XCTAssertNoThrow(try adapter.closeSession("noop-session"))
XCTAssertNoThrow(try adapter.closeSession("noop-session", timestampMs: 1_700_000_000_000))
}
// MARK: - Buffer (Noop)