Files
andros 2aaa8eab7b Initial commit: iOS Denote client
SwiftUI app for iPhone that connects to a WebDAV server and lists,
searches, reads and edits Denote-format notes (.org). Credentials
stored in the iOS Keychain. Server configured via a setup screen on
first launch.
2026-05-22 15:42:19 +02:00

115 lines
4.7 KiB
Swift

import Foundation
@MainActor
class WebDAVClient: ObservableObject {
private let settings: WebDAVSettings
@Published var notes: [DenoteNote] = []
@Published var isLoading = false
@Published var isPrefetching = false
@Published var prefetchProgress: Double = 0
@Published var errorMessage: String?
private var prefetchTask: Task<Void, Never>?
init(settings: WebDAVSettings) {
self.settings = settings
}
private var config: WebDAVConfig { settings.config }
func loadNotes() async {
prefetchTask?.cancel()
isLoading = true
errorMessage = nil
do {
notes = try await fetchNotesList()
prefetchTask = Task { await prefetchAllContent() }
} catch {
errorMessage = error.localizedDescription
}
isLoading = false
}
func loadContent(for index: Int) async {
guard notes.indices.contains(index), notes[index].content == nil else { return }
do {
let content = try await fetchContent(for: notes[index])
guard notes.indices.contains(index) else { return }
notes[index].content = content
} catch {}
}
func saveContent(_ content: String, for note: DenoteNote) async throws {
let c = config
guard let url = URL(string: c.url + note.path) else { throw URLError(.badURL) }
var request = URLRequest(url: url)
request.httpMethod = "PUT"
request.setValue(c.authHeader, forHTTPHeaderField: "Authorization")
request.setValue("text/plain; charset=utf-8", forHTTPHeaderField: "Content-Type")
request.httpBody = Data(content.utf8)
let (_, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else {
throw URLError(.badServerResponse)
}
if let idx = notes.firstIndex(where: { $0.id == note.id }) {
notes[idx].content = content
}
}
func fetchContent(for note: DenoteNote) async throws -> String {
let c = config
guard let url = URL(string: c.url + note.path) else { throw URLError(.badURL) }
var request = URLRequest(url: url)
request.setValue(c.authHeader, forHTTPHeaderField: "Authorization")
let (data, _) = try await URLSession.shared.data(for: request)
return String(data: data, encoding: .utf8) ?? String(data: data, encoding: .isoLatin1) ?? ""
}
private func fetchNotesList() async throws -> [DenoteNote] {
let c = config
guard let url = URL(string: c.url + c.notesPath) else { throw URLError(.badURL) }
var request = URLRequest(url: url)
request.httpMethod = "PROPFIND"
request.setValue("1", forHTTPHeaderField: "Depth")
request.setValue(c.authHeader, forHTTPHeaderField: "Authorization")
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
let (data, _) = try await URLSession.shared.data(for: request)
return PropfindParser(data: data).parse()
.sorted { ($0.modifiedDate ?? .distantPast) > ($1.modifiedDate ?? .distantPast) }
}
private func prefetchAllContent() async {
isPrefetching = true
let count = notes.count
for i in 0..<count {
guard !Task.isCancelled, i < notes.count else { break }
if notes[i].content == nil,
let content = try? await fetchContent(for: notes[i]) {
guard !Task.isCancelled, i < notes.count else { break }
notes[i].content = content
}
prefetchProgress = Double(i + 1) / Double(count)
}
guard !Task.isCancelled else { return }
isPrefetching = false
prefetchProgress = 0
}
// Lightweight probe used by SetupView to test credentials without creating a full client.
static func probe(config: WebDAVConfig) async throws -> Int {
guard let url = URL(string: config.url + config.notesPath) else { throw URLError(.badURL) }
var request = URLRequest(url: url)
request.httpMethod = "PROPFIND"
request.setValue("1", forHTTPHeaderField: "Depth")
request.setValue(config.authHeader, forHTTPHeaderField: "Authorization")
request.setValue("application/xml", forHTTPHeaderField: "Content-Type")
let (data, response) = try await URLSession.shared.data(for: request)
guard let http = response as? HTTPURLResponse else { throw URLError(.badServerResponse) }
if http.statusCode == 401 { throw URLError(.userAuthenticationRequired) }
guard (200...299).contains(http.statusCode) else { throw URLError(.badServerResponse) }
return PropfindParser(data: data).parse().count
}
}