This commit is contained in:
douboer
2025-09-07 12:39:28 +08:00
parent 1ba01e3c64
commit 4d033257fe
5714 changed files with 15866 additions and 1032 deletions

View File

@@ -0,0 +1,22 @@
import Foundation
@available(macOS 10.15, iOS 13.0, *)
@main
struct CLIMain {
static func main() async {
guard CommandLine.arguments.count > 1 else {
print("用法: IpadReaderCLI <bundle.zip>")
return
}
let zipPath = CommandLine.arguments[1]
let url = URL(fileURLWithPath: zipPath)
let importer = BundleImporter()
do {
let idx = try await importer.importZipFile(at: url)
print("导入成功,书籍数: \(idx.books.count)")
print("统计: 年总时长 \(idx.stats.global.year_total_minutes/60) 小时")
} catch {
print("导入失败: \(error)")
}
}
}

View File

@@ -0,0 +1,51 @@
#if os(iOS)
import SwiftUI
struct ContentView: View {
@EnvironmentObject var importer: BundleImporter
@EnvironmentObject var libraryVM: LibraryViewModel
@State private var tab = 0
var body: some View {
TabView(selection: $tab) {
NavigationStack {
BookListView(importer: importer, vm: libraryVM)
}
.tabItem { Label("书库", systemImage: "books.vertical") }
.tag(0)
NavigationStack {
if let index = importer.index {
StatsView(index: index)
} else {
VStack(spacing: 16) {
Text("尚未导入数据包")
Button("导入 iPad 导出包") { importer.presentPicker() }
}.padding()
}
}
.tabItem { Label("统计", systemImage: "chart.bar") }
.tag(1)
NavigationStack {
ScrollView { VStack(alignment:.leading, spacing: 12) {
Text("关于").font(.title2).bold()
Text("离线阅读与统计查看器。加载 Python 导出的 iPad Bundle。")
Button("重新导入") { importer.presentPicker() }
if let p = importer.lastImportedURL {
Text("最近: \(p.lastPathComponent)").font(.footnote).foregroundColor(.secondary)
}
}.padding() }
}
.tabItem { Label("关于", systemImage: "info.circle") }
.tag(2)
}
.onReceive(importer.$bundleIndex) { newIndex in
if let newIndex {
libraryVM.attach(index: newIndex)
}
}
}
}
#endif

View File

@@ -0,0 +1,17 @@
#if os(iOS)
import SwiftUI
@main
struct IpadReaderApp: App {
@StateObject private var importer = BundleImporter()
@StateObject private var libraryVM = LibraryViewModel()
var body: some Scene {
WindowGroup {
ContentView()
.environmentObject(importer)
.environmentObject(libraryVM)
}
}
}
#endif

View File

@@ -0,0 +1,50 @@
import Foundation
struct BookMeta: Identifiable, Codable {
let id: String
let title: String
let author: String
let type: String
let last_open: Double? // Unix
let readtime30d: [Int]?
let readtime12m: [Int]?
let readtime_year: Int?
let is_finished_this_year: Bool?
}
struct AnnotationRow: Identifiable, Codable {
let id: String // uuid
let creationdate: String?
let idref: String?
let filepos: String?
let selected: String?
let note: String?
init(uuid: String, creationdate: String?, idref: String?, filepos: String?, selected: String?, note: String?) {
self.id = uuid
self.creationdate = creationdate
self.idref = idref
self.filepos = filepos
self.selected = selected
self.note = note
}
}
struct StatsGlobal: Codable {
let year_total_minutes: Int
let month_avg_minutes: Int
let week_total_minutes: Int
let day_avg_minutes: Int
let finished_books_count: Int
}
struct StatsPayload: Codable {
let generated_at: String?
let global: StatsGlobal
}
struct BundleIndex {
let books: [BookMeta]
let stats: StatsPayload
let bookIntro: [String:String]
let annotationsMap: [String:[AnnotationRow]]
}

View File

@@ -0,0 +1,130 @@
// BundleImporter.swift
// ZIP BundleIndex
import Foundation
import ZIPFoundation
import UniformTypeIdentifiers
#if canImport(UIKit)
import UIKit
#endif
@available(macOS 10.15, iOS 13.0, *)
final class BundleImporter: NSObject, ObservableObject {
@Published var bundleIndex: BundleIndex?
@Published var progressMessage: String? = nil
@Published var errorMessage: String? = nil
@Published var lastImportedURL: URL? = nil
var index: BundleIndex? { bundleIndex }
private let fm = FileManager.default
// CLI使
func importZipFile(at url: URL) async throws -> BundleIndex {
let ts = Int(Date().timeIntervalSince1970)
let baseDir = try bundleBaseDirectory()
let target = baseDir.appendingPathComponent("bundle_\(ts)")
try fm.createDirectory(at: target, withIntermediateDirectories: true)
try fm.unzipItem(at: url, to: target)
let idx = try parseBundle(at: target)
await MainActor.run {
self.bundleIndex = idx
self.lastImportedURL = url
}
return idx
}
// iOS
func presentPicker() {
#if canImport(UIKit)
let types: [UTType] = [UTType.filenameExtension("zip")!]
let picker = UIDocumentPickerViewController(forOpeningContentTypes: types, asCopy: true)
picker.delegate = self
picker.allowsMultipleSelection = false
UIApplication.shared.firstKeyWindow?.rootViewController?.present(picker, animated: true)
#else
print("[BundleImporter] presentPicker() unsupported on this platform")
#endif
}
//
private func bundleBaseDirectory() throws -> URL {
let appSup = try fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true)
let dir = appSup.appendingPathComponent("ImportedBundle")
if !fm.fileExists(atPath: dir.path) { try fm.createDirectory(at: dir, withIntermediateDirectories: true) }
return dir
}
@MainActor private func update(message: String?) { self.progressMessage = message }
//
private func parseBundle(at dir: URL) throws -> BundleIndex {
func load<T:Decodable>(_ name: String, _ type: T.Type) throws -> T {
let data = try Data(contentsOf: dir.appendingPathComponent(name))
return try JSONDecoder().decode(T.self, from: data)
}
let books: [BookMeta] = try load("books_meta.json", [BookMeta].self)
let stats: StatsPayload = try load("stats.json", StatsPayload.self)
let introData = try? Data(contentsOf: dir.appendingPathComponent("bookintro.json"))
let bookIntro = (introData.flatMap { try? JSONDecoder().decode([String:String].self, from: $0) }) ?? [:]
let annotationsRaw = try Data(contentsOf: dir.appendingPathComponent("annotations.json"))
let anyMap = (try? JSONSerialization.jsonObject(with: annotationsRaw)) as? [String:Any] ?? [:]
var annMap: [String:[AnnotationRow]] = [:]
for (k,v) in anyMap {
guard let arr = v as? [[String:Any]] else { continue }
let rows: [AnnotationRow] = arr.compactMap { d in
guard let uuid = d["uuid"] as? String else { return nil }
return AnnotationRow(
uuid: uuid,
creationdate: d["creationdate"] as? String,
idref: d["idref"] as? String,
filepos: d["filepos"] as? String,
selected: d["selected"] as? String,
note: d["note"] as? String
)
}
annMap[k] = rows
}
return BundleIndex(books: books, stats: stats, bookIntro: bookIntro, annotationsMap: annMap)
}
//
private func importZip(at url: URL) async {
await update(message: "解压中...")
do {
let ts = Int(Date().timeIntervalSince1970)
let baseDir = try bundleBaseDirectory()
let target = baseDir.appendingPathComponent("bundle_\(ts)")
try fm.createDirectory(at: target, withIntermediateDirectories: true)
try fm.unzipItem(at: url, to: target)
await update(message: "解析 JSON...")
let idx = try parseBundle(at: target)
await MainActor.run {
self.bundleIndex = idx
self.lastImportedURL = url
}
await update(message: nil)
} catch {
await MainActor.run { self.errorMessage = error.localizedDescription }
await update(message: nil)
}
}
}
#if canImport(UIKit)
extension BundleImporter: UIDocumentPickerDelegate {
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
guard let url = urls.first else { return }
lastImportedURL = url
Task { await importZip(at: url) }
}
}
extension UIApplication {
var firstKeyWindow: UIWindow? {
self.connectedScenes.compactMap { $0 as? UIWindowScene }
.flatMap { $0.windows }
.first { $0.isKeyWindow }
}
}
#endif

View File

@@ -0,0 +1,65 @@
#if canImport(UIKit)
import UIKit
#endif
import SwiftUI
#if canImport(UIKit)
typealias PlatformImage = UIImage
#elseif canImport(AppKit)
import AppKit
typealias PlatformImage = NSImage
#endif
#if canImport(UIKit) || canImport(AppKit)
actor ImageCache {
static let shared = ImageCache()
private var cache: [String:PlatformImage] = [:]
func image(for id: String, in baseDir: URL) -> PlatformImage? {
if let c = cache[id] { return c }
let url = baseDir.appendingPathComponent("covers/\(id).jpg")
#if canImport(UIKit)
guard let img = PlatformImage(contentsOfFile: url.path) else { return nil }
#elseif canImport(AppKit)
guard let img = PlatformImage(contentsOf: url) else { return nil }
#endif
cache[id] = img
return img
}
}
#endif
#if os(iOS)
struct CoverImageView: View {
let book: BookMeta
let bundleDir: URL
#if canImport(UIKit) || canImport(AppKit)
@State private var uiImage: PlatformImage? = nil
#endif
var body: some View {
ZStack {
#if canImport(UIKit) || canImport(AppKit)
if let img = uiImage {
#if canImport(UIKit)
Image(uiImage: img).resizable().scaledToFit().cornerRadius(6)
#elseif canImport(AppKit)
Image(nsImage: img).resizable().scaledToFit().cornerRadius(6)
#endif
} else {
Rectangle().fill(Color.gray.opacity(0.2)).overlay(Text("No Cover").font(.caption))
}
#else
Rectangle().fill(Color.gray.opacity(0.2)).overlay(Text("No Cover").font(.caption))
#endif
}
#if canImport(UIKit) || canImport(AppKit)
.task {
if uiImage == nil, let img = await ImageCache.shared.image(for: book.id, in: bundleDir) {
uiImage = img
}
}
#endif
}
}
#endif

View File

@@ -0,0 +1,19 @@
import Foundation
import Combine
@available(macOS 10.15, iOS 13.0, *)
@MainActor
final class LibraryViewModel: ObservableObject {
@Published var bundleIndex: BundleIndex?
@Published var searchText: String = ""
var filteredBooks: [BookMeta] {
guard let books = bundleIndex?.books else { return [] }
let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
if q.isEmpty { return books.sorted { ($0.last_open ?? 0) > ($1.last_open ?? 0) } }
return books.filter { $0.title.localizedCaseInsensitiveContains(q) || $0.author.localizedCaseInsensitiveContains(q) }
.sorted { ($0.last_open ?? 0) > ($1.last_open ?? 0) }
}
func attach(index: BundleIndex) { self.bundleIndex = index }
}

View File

@@ -0,0 +1,55 @@
#if os(iOS)
import SwiftUI
@available(macOS 10.15, iOS 13.0, *)
struct BookDetailView: View {
let book: BookMeta
let index: BundleIndex
let bundleDir: URL
var annotations: [AnnotationRow] { index.annotationsMap[book.id] ?? [] }
var review: String? { index.bookIntro[book.title] }
var body: some View {
NavigationStack {
ScrollView {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment:.top, spacing:16) {
CoverImageView(book: book, bundleDir: bundleDir).frame(width:120,height:170)
VStack(alignment:.leading, spacing:8) {
Text(book.title).font(.title2).bold()
Text(book.author).foregroundColor(.secondary)
if let y = book.readtime_year { Text("全年阅读 \(y/60) 小时").font(.callout) }
if book.is_finished_this_year == true { Text("今年已读完 ✅").font(.caption).foregroundColor(.green) }
}
Spacer()
}
if let review = review, !review.isEmpty {
VStack(alignment:.leading, spacing:8) {
Text("书籍简评").font(.headline)
Text(review).font(.body)
}
}
if !annotations.isEmpty {
VStack(alignment:.leading, spacing:8) {
Text("批注 (\(annotations.count))").font(.headline)
ForEach(annotations) { ann in
VStack(alignment:.leading, spacing:4) {
if let sel = ann.selected { Text(sel).font(.body) }
if let note = ann.note, !note.isEmpty { Text("" + note).font(.footnote).foregroundColor(.secondary) }
Divider()
}
}
}
} else {
Text("无批注").foregroundColor(.secondary)
}
}
.padding()
}
.navigationTitle("详情")
.navigationBarTitleDisplayMode(.inline)
}
}
}
#endif

View File

@@ -0,0 +1,57 @@
#if os(iOS)
import SwiftUI
struct BookListView: View {
@ObservedObject var importer: BundleImporter
@ObservedObject var vm: LibraryViewModel
@State private var selected: BookMeta? = nil
private func bundleDir(of id: String) -> URL? { //
guard let root = try? FileManager.default.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: false).appendingPathComponent("ImportedBundle") else { return nil }
let subs = (try? FileManager.default.contentsOfDirectory(at: root, includingPropertiesForKeys: nil)) ?? []
return subs.sorted { $0.lastPathComponent > $1.lastPathComponent }.first
}
var body: some View {
VStack(spacing: 0) {
HStack {
TextField("搜索书名/作者", text: $vm.searchText)
.textFieldStyle(.roundedBorder)
Button("导入") { importer.presentPicker() }
if let msg = importer.progressMessage { Text(msg).font(.footnote).foregroundColor(.secondary) }
}.padding(.horizontal).padding(.top,8)
Divider()
if vm.filteredBooks.isEmpty {
EmptyStateView(message: "无数据,点击导入按钮加载 ZIP", actionTitle: "导入") { importer.presentPicker() }
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
List(vm.filteredBooks) { book in
Button {
selected = book
} label: {
HStack(alignment:.top, spacing:12) {
if let dir = bundleDir(of: book.id) {
CoverImageView(book: book, bundleDir: dir).frame(width:70,height:100)
}
VStack(alignment:.leading, spacing:4) {
Text(book.title).font(.headline)
Text(book.author).font(.subheadline).foregroundColor(.secondary)
if let rt = book.readtime_year { Text("全年阅读: \(rt/60) 小时").font(.caption).foregroundColor(.secondary) }
if book.is_finished_this_year == true { Text("今年已读完 ✅").font(.caption2).foregroundColor(.green) }
}
Spacer()
}
}
}
}
}
.sheet(item: $selected) { b in
if let idx = importer.bundleIndex, let dir = bundleDir(of: b.id) {
BookDetailView(book: b, index: idx, bundleDir: dir)
} else {
Text("数据缺失")
}
}
}
}
#endif

View File

@@ -0,0 +1,52 @@
#if os(iOS)
import SwiftUI
@available(macOS 10.15, iOS 13.0, *)
struct BubbleMetric: Identifiable {
let id = UUID()
let label: String
let display: String
let valueMinutes: Double //
let color: Color
}
@available(macOS 10.15, iOS 13.0, *)
struct BubbleMetricsView: View {
let metrics: [BubbleMetric]
private func radius(for v: Double, maxV: Double) -> CGFloat {
guard maxV > 0 else { return 20 }
let norm = sqrt(v / maxV)
return 30 + norm * 70
}
var body: some View {
GeometryReader { geo in
let maxV = metrics.map { $0.valueMinutes }.max() ?? 1
ZStack {
ForEach(Array(metrics.enumerated()), id: \.[1].id) { idx, m in
//
let angle = Double(idx) / Double(metrics.count) * Double.pi * 2
let R = min(geo.size.width, geo.size.height) / 2 - 80
let x = geo.size.width/2 + CGFloat(cos(angle)) * R
let y = geo.size.height/2 + CGFloat(sin(angle)) * R
let r = radius(for: m.valueMinutes, maxV: maxV)
Circle()
.fill(m.color.opacity(0.75))
.frame(width: r, height: r)
.overlay(
VStack(spacing:4){
Text(m.display).font(.system(size: min(18, r*0.28))).bold().foregroundColor(.white)
Text(m.label).font(.system(size: min(14, r*0.22))).foregroundColor(.white)
}.padding(4)
)
.position(x: x, y: y)
.shadow(radius: 4, y: 2)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.frame(height: 340)
}
}
#endif

View File

@@ -0,0 +1,19 @@
#if os(iOS)
import SwiftUI
struct EmptyStateView: View {
let message: String
let actionTitle: String
let action: () -> Void
var body: some View {
VStack(spacing: 16) {
Image(systemName: "tray")
.font(.system(size: 48))
.foregroundColor(.secondary)
Text(message).foregroundColor(.secondary)
Button(actionTitle, action: action)
}
.padding(40)
}
}
#endif

View File

@@ -0,0 +1,50 @@
#if os(iOS)
import SwiftUI
import Charts
struct StatsView: View {
let index: BundleIndex
struct DayVal: Identifiable { let id = UUID(); let label: String; let value: Int }
struct MonthVal: Identifiable { let id = UUID(); let label: String; let value: Int }
struct YearVal: Identifiable { let id = UUID(); let label: String; let hours: Double }
var weekSeries: [DayVal] {
let arr = (index.stats.global.week_total_minutes, index.stats.global) // placeholder; real daily array
// week 7
let total = Double(index.stats.global.week_total_minutes)
return (0..<7).map { i in
let frac = (7.0-Double(i))*1.0/28.0 + 0.05
return DayVal(label: ["","","2","3","4","5","6"][i], value: Int(total * frac / 2.0))
}
}
var body: some View {
ScrollView {
VStack(alignment:.leading, spacing: 24) {
Text("阅读统计").font(.title2).bold().padding(.top)
//
let g = index.stats.global
let metrics: [BubbleMetric] = [
BubbleMetric(label: "全年", display: String(format: "%d小时", g.year_total_minutes/60), valueMinutes: Double(g.year_total_minutes), color: .blue),
BubbleMetric(label: "月均", display: String(format: "%d分钟", g.month_avg_minutes), valueMinutes: Double(g.month_avg_minutes), color: .purple),
BubbleMetric(label: "近7天", display: String(format: "%d分钟", g.week_total_minutes), valueMinutes: Double(g.week_total_minutes), color: .pink),
BubbleMetric(label: "日均", display: String(format: "%d分钟", g.day_avg_minutes), valueMinutes: Double(g.day_avg_minutes), color: .yellow),
BubbleMetric(label: "已读", display: String(format: "%d本", g.finished_books_count), valueMinutes: Double(g.finished_books_count*60), color: .green)
]
BubbleMetricsView(metrics: metrics)
//
Chart(weekSeries) { item in
BarMark(x: .value("Day", item.label), y: .value("Min", item.value))
}.frame(height: 180)
Text("说明: 因数据包未携带逐日数组,这里周图为占位模拟。可扩展导出添加 daily 数组。")
.font(.footnote).foregroundColor(.secondary)
// 使 readtime_year
let yearHours = Double(g.year_total_minutes)/60.0
HStack { Text("全年总时长约 \(String(format: "%.1f", yearHours)) 小时").font(.headline); Spacer() }
}
.padding(.horizontal)
}
}
}
#endif