2aaa8eab7b
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.
115 lines
4.7 KiB
Swift
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
|
|
}
|
|
}
|