Files
oai-swift/oAI/Services/PaperlessService.swift
T
runeandClaude Sonnet 5 0af644456e Update commercial licensing contact URL to oai.pm
Consolidates the mac.oai.pm subdomain references (introduced in the
PolyForm Noncommercial relicense) to the root oai.pm domain, across
the LICENSE file, README, and all Swift source file headers.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-15 11:34:04 +02:00

495 lines
20 KiB
Swift

//
// PaperlessService.swift
// oAI
//
// Paperless-NGX integration: search, read, and upload documents via REST API
//
// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0
// Copyright (C) 2026 Rune Olsen
//
// This file is part of oAI.
//
// oAI is licensed under the PolyForm Noncommercial License 1.0.0.
// You may use, study, modify, and share it for any noncommercial
// purpose. Commercial use — including selling oAI or any part of
// it, standalone or bundled into another product or service —
// requires a separate commercial license from the copyright holder.
//
// See the LICENSE file or
// <https://polyformproject.org/licenses/noncommercial/1.0.0> for
// the full license text. For commercial licensing, contact Rune
// Olsen via <https://oai.pm>.
import Foundation
import os
@Observable
class PaperlessService {
static let shared = PaperlessService()
private let settings = SettingsService.shared
private let log = Logger(subsystem: "com.oai.oAI", category: "mcp")
private let readTimeout: TimeInterval = 15
private let uploadTimeout: TimeInterval = 60
private(set) var isConnected = false
// In-memory caches for ID → name resolution
private var tagCache: [Int: String] = [:]
private var correspondentCache: [Int: String] = [:]
private var documentTypeCache: [Int: String] = [:]
private init() {}
// MARK: - Connection Test
func testConnection() async -> Result<String, Error> {
do {
let result = try await request(endpoint: "/api/documents/", queryParams: ["page_size": "1"])
if let count = result["count"] as? Int {
isConnected = true
return .success("Connected (\(count) document\(count == 1 ? "" : "s"))")
} else {
isConnected = true
return .success("Connected to Paperless-NGX")
}
} catch {
isConnected = false
return .failure(error)
}
}
// MARK: - Tool Schemas
func getToolSchemas() -> [Tool] {
return [
makeTool(
name: "paperless_search",
description: "Search for documents in Paperless-NGX by title, content, tags, or any text. Returns document metadata and a preview of OCR-extracted content. Use this to find invoices, contracts, letters, or any stored document.",
properties: [
"query": prop("string", "Search query — can be text from document content, title, correspondent name, or tag"),
"page": prop("number", "Page number for pagination (default: 1, each page has 25 results)")
],
required: ["query"]
),
makeTool(
name: "paperless_get_document",
description: "Get the full details and complete OCR-extracted text content of a specific Paperless-NGX document by ID. Use after paperless_search to read the full text of a document.",
properties: [
"document_id": prop("number", "The numeric ID of the document to retrieve")
],
required: ["document_id"]
),
makeTool(
name: "paperless_list_tags",
description: "List all tags defined in Paperless-NGX with their document counts.",
properties: [:],
required: []
),
makeTool(
name: "paperless_list_correspondents",
description: "List all correspondents (senders/recipients) defined in Paperless-NGX with their document counts.",
properties: [:],
required: []
),
makeTool(
name: "paperless_list_document_types",
description: "List all document types defined in Paperless-NGX with their document counts.",
properties: [:],
required: []
),
makeTool(
name: "paperless_upload_document",
description: "Upload a local file to Paperless-NGX for OCR processing and storage. Supports PDF, PNG, JPEG, TIFF, and other image formats.",
properties: [
"file_path": prop("string", "Absolute path to the local file to upload"),
"title": prop("string", "Optional title for the document"),
"tag_ids": prop("string", "Optional comma-separated tag IDs to assign (e.g. '1,3,7')")
],
required: ["file_path"]
)
]
}
// MARK: - Tool Execution
func executeTool(name: String, arguments: String) async -> [String: Any] {
log.info("Executing Paperless tool: \(name)")
guard let argData = arguments.data(using: .utf8),
let args = try? JSONSerialization.jsonObject(with: argData) as? [String: Any] else {
return ["error": "Invalid arguments JSON"]
}
do {
switch name {
case "paperless_search":
guard let query = args["query"] as? String else {
return ["error": "Missing required parameter: query"]
}
let page: Int
if let p = args["page"] as? Int { page = p }
else if let p = args["page"] as? Double { page = Int(p) }
else { page = 1 }
return try await searchDocuments(query: query, page: page)
case "paperless_get_document":
let docId: Int
if let id = args["document_id"] as? Int { docId = id }
else if let id = args["document_id"] as? Double { docId = Int(id) }
else { return ["error": "Missing or invalid parameter: document_id (expected integer)"] }
return try await getDocument(id: docId)
case "paperless_list_tags":
return try await listTags()
case "paperless_list_correspondents":
return try await listCorrespondents()
case "paperless_list_document_types":
return try await listDocumentTypes()
case "paperless_upload_document":
guard let filePath = args["file_path"] as? String else {
return ["error": "Missing required parameter: file_path"]
}
let title = args["title"] as? String
let tagIds = args["tag_ids"] as? String
return try await uploadDocument(filePath: filePath, title: title, tagIds: tagIds)
default:
return ["error": "Unknown Paperless tool: \(name)"]
}
} catch PaperlessError.notConfigured {
return ["error": "Paperless-NGX is not configured. Set your URL and API token in Settings > Paperless."]
} catch PaperlessError.unauthorized {
return ["error": "Invalid API token. Check your Paperless-NGX token in Settings > Paperless."]
} catch PaperlessError.httpError(let code, let msg) {
return ["error": "Paperless-NGX API error \(code): \(msg)"]
} catch {
return ["error": "Paperless error: \(error.localizedDescription)"]
}
}
// MARK: - API Operations
private func searchDocuments(query: String, page: Int) async throws -> [String: Any] {
await prefetchCaches()
let result = try await request(endpoint: "/api/documents/", queryParams: [
"query": query,
"page": String(page)
])
let total = result["count"] as? Int ?? 0
guard let rawResults = result["results"] as? [[String: Any]] else {
return ["total": total, "page": page, "results": []]
}
let formatted = rawResults.map { doc -> [String: Any] in
var item: [String: Any] = [:]
item["id"] = doc["id"] ?? 0
item["title"] = doc["title"] ?? "Untitled"
item["created"] = (doc["created"] as? String).map { String($0.prefix(10)) } ?? ""
if let corrId = doc["correspondent"] as? Int {
item["correspondent"] = correspondentCache[corrId] ?? "ID:\(corrId)"
}
if let dtId = doc["document_type"] as? Int {
item["document_type"] = documentTypeCache[dtId] ?? "ID:\(dtId)"
}
if let tagIds = doc["tags"] as? [Int] {
item["tags"] = tagIds.map { tagCache[$0] ?? "ID:\($0)" }
}
// Content preview capped at 500 chars
if let content = doc["content"] as? String, !content.isEmpty {
let preview = content.trimmingCharacters(in: .whitespacesAndNewlines)
item["content_preview"] = String(preview.prefix(500))
}
return item
}
return ["total": total, "page": page, "results": formatted]
}
private func getDocument(id: Int) async throws -> [String: Any] {
await prefetchCaches()
let doc = try await request(endpoint: "/api/documents/\(id)/")
var result: [String: Any] = [:]
result["id"] = doc["id"] ?? id
result["title"] = doc["title"] ?? "Untitled"
result["created"] = (doc["created"] as? String).map { String($0.prefix(10)) } ?? ""
result["added"] = (doc["added"] as? String).map { String($0.prefix(10)) } ?? ""
result["modified"] = (doc["modified"] as? String).map { String($0.prefix(10)) } ?? ""
if let corrId = doc["correspondent"] as? Int {
result["correspondent"] = correspondentCache[corrId] ?? "ID:\(corrId)"
}
if let dtId = doc["document_type"] as? Int {
result["document_type"] = documentTypeCache[dtId] ?? "ID:\(dtId)"
}
if let tagIds = doc["tags"] as? [Int] {
result["tags"] = tagIds.map { tagCache[$0] ?? "ID:\($0)" }
}
if let asn = doc["archive_serial_number"] as? String {
result["archive_serial_number"] = asn
}
// Full OCR content capped at 30,000 chars
if let content = doc["content"] as? String {
let trimmed = content.trimmingCharacters(in: .whitespacesAndNewlines)
result["content"] = String(trimmed.prefix(30_000))
result["content_length"] = trimmed.count
}
return result
}
private func listTags() async throws -> [String: Any] {
let result = try await request(endpoint: "/api/tags/", queryParams: ["page_size": "250"])
guard let items = result["results"] as? [[String: Any]] else {
return ["count": 0, "tags": []]
}
let formatted = items.map { tag -> [String: Any] in
["id": tag["id"] ?? 0, "name": tag["name"] ?? "Unknown", "count": tag["document_count"] ?? 0]
}
return ["count": formatted.count, "tags": formatted]
}
private func listCorrespondents() async throws -> [String: Any] {
let result = try await request(endpoint: "/api/correspondents/", queryParams: ["page_size": "250"])
guard let items = result["results"] as? [[String: Any]] else {
return ["count": 0, "correspondents": []]
}
let formatted = items.map { c -> [String: Any] in
["id": c["id"] ?? 0, "name": c["name"] ?? "Unknown", "count": c["document_count"] ?? 0]
}
return ["count": formatted.count, "correspondents": formatted]
}
private func listDocumentTypes() async throws -> [String: Any] {
let result = try await request(endpoint: "/api/document_types/", queryParams: ["page_size": "250"])
guard let items = result["results"] as? [[String: Any]] else {
return ["count": 0, "document_types": []]
}
let formatted = items.map { dt -> [String: Any] in
["id": dt["id"] ?? 0, "name": dt["name"] ?? "Unknown", "count": dt["document_count"] ?? 0]
}
return ["count": formatted.count, "document_types": formatted]
}
private func uploadDocument(filePath: String, title: String?, tagIds: String?) async throws -> [String: Any] {
let expanded = (filePath as NSString).expandingTildeInPath
let resolved = (expanded as NSString).standardizingPath
guard FileManager.default.fileExists(atPath: resolved) else {
return ["error": "File not found: \(filePath)"]
}
guard let fileData = FileManager.default.contents(atPath: resolved) else {
return ["error": "Cannot read file: \(filePath)"]
}
let fileName = (resolved as NSString).lastPathComponent
guard let token = settings.paperlessAPIToken, !token.isEmpty else {
throw PaperlessError.notConfigured
}
let baseURL = settings.paperlessURL
guard !baseURL.isEmpty, let url = URL(string: baseURL + "/api/documents/post_document/") else {
throw PaperlessError.notConfigured
}
let boundary = "PaperlessBoundary\(UUID().uuidString.replacingOccurrences(of: "-", with: ""))"
var body = Data()
func appendField(_ name: String, _ value: String) {
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"\(name)\"\r\n\r\n".data(using: .utf8)!)
body.append("\(value)\r\n".data(using: .utf8)!)
}
if let title = title, !title.isEmpty {
appendField("title", title)
}
if let tagIds = tagIds {
let ids = tagIds.split(separator: ",").compactMap { Int($0.trimmingCharacters(in: .whitespaces)) }
for id in ids {
appendField("tags", String(id))
}
}
let mimeType = mimeTypeFor(fileName: fileName)
body.append("--\(boundary)\r\n".data(using: .utf8)!)
body.append("Content-Disposition: form-data; name=\"document\"; filename=\"\(fileName)\"\r\n".data(using: .utf8)!)
body.append("Content-Type: \(mimeType)\r\n\r\n".data(using: .utf8)!)
body.append(fileData)
body.append("\r\n--\(boundary)--\r\n".data(using: .utf8)!)
var urlRequest = URLRequest(url: url, timeoutInterval: uploadTimeout)
urlRequest.httpMethod = "POST"
urlRequest.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("multipart/form-data; boundary=\(boundary)", forHTTPHeaderField: "Content-Type")
urlRequest.httpBody = body
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw PaperlessError.httpError(0, "Invalid response")
}
if httpResponse.statusCode == 401 { throw PaperlessError.unauthorized }
if (200...299).contains(httpResponse.statusCode) {
return ["success": true, "message": "Document uploaded successfully. Paperless-NGX will process it shortly."]
}
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
throw PaperlessError.httpError(httpResponse.statusCode, msg)
}
// MARK: - Cache Prefetch
private func prefetchCaches() async {
if tagCache.isEmpty {
if let result = try? await request(endpoint: "/api/tags/", queryParams: ["page_size": "250"]),
let items = result["results"] as? [[String: Any]] {
for item in items {
if let id = item["id"] as? Int, let name = item["name"] as? String {
tagCache[id] = name
}
}
}
}
if correspondentCache.isEmpty {
if let result = try? await request(endpoint: "/api/correspondents/", queryParams: ["page_size": "250"]),
let items = result["results"] as? [[String: Any]] {
for item in items {
if let id = item["id"] as? Int, let name = item["name"] as? String {
correspondentCache[id] = name
}
}
}
}
if documentTypeCache.isEmpty {
if let result = try? await request(endpoint: "/api/document_types/", queryParams: ["page_size": "250"]),
let items = result["results"] as? [[String: Any]] {
for item in items {
if let id = item["id"] as? Int, let name = item["name"] as? String {
documentTypeCache[id] = name
}
}
}
}
}
// MARK: - HTTP Client
private func request(endpoint: String, queryParams: [String: String] = [:]) async throws -> [String: Any] {
guard let token = settings.paperlessAPIToken, !token.isEmpty else {
throw PaperlessError.notConfigured
}
let baseURL = settings.paperlessURL
guard !baseURL.isEmpty else { throw PaperlessError.notConfigured }
var urlString = baseURL + endpoint
if !queryParams.isEmpty {
var comps = URLComponents(string: urlString) ?? URLComponents()
comps.queryItems = queryParams.map { URLQueryItem(name: $0.key, value: $0.value) }
urlString = comps.url?.absoluteString ?? urlString
}
guard let url = URL(string: urlString) else {
throw PaperlessError.httpError(0, "Invalid URL: \(urlString)")
}
var urlRequest = URLRequest(url: url, timeoutInterval: readTimeout)
urlRequest.httpMethod = "GET"
urlRequest.setValue("Token \(token)", forHTTPHeaderField: "Authorization")
urlRequest.setValue("application/json", forHTTPHeaderField: "Accept")
do {
let (data, response) = try await URLSession.shared.data(for: urlRequest)
guard let httpResponse = response as? HTTPURLResponse else {
throw PaperlessError.httpError(0, "Invalid response")
}
if httpResponse.statusCode == 401 { throw PaperlessError.unauthorized }
guard (200...299).contains(httpResponse.statusCode) else {
let msg = String(data: data, encoding: .utf8) ?? "Unknown error"
throw PaperlessError.httpError(httpResponse.statusCode, msg)
}
guard let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else {
return [:]
}
return json
} catch let error as PaperlessError {
throw error
} catch {
throw PaperlessError.httpError(0, error.localizedDescription)
}
}
// MARK: - Helpers
private func mimeTypeFor(fileName: String) -> String {
let ext = (fileName as NSString).pathExtension.lowercased()
switch ext {
case "pdf": return "application/pdf"
case "png": return "image/png"
case "jpg", "jpeg": return "image/jpeg"
case "tiff", "tif": return "image/tiff"
case "gif": return "image/gif"
case "webp": return "image/webp"
default: return "application/octet-stream"
}
}
private func makeTool(name: String, description: String, properties: [String: Tool.Function.Parameters.Property], required: [String]) -> Tool {
Tool(
type: "function",
function: Tool.Function(
name: name,
description: description,
parameters: Tool.Function.Parameters(
type: "object",
properties: properties,
required: required
)
)
)
}
private func prop(_ type: String, _ description: String) -> Tool.Function.Parameters.Property {
Tool.Function.Parameters.Property(type: type, description: description, enum: nil)
}
}
// MARK: - Error Types
enum PaperlessError: LocalizedError {
case notConfigured
case unauthorized
case httpError(Int, String)
var errorDescription: String? {
switch self {
case .notConfigured:
return "Paperless-NGX is not configured. Set your URL and API token in Settings > Paperless."
case .unauthorized:
return "Invalid API token. Check your Paperless-NGX token in Settings > Paperless."
case .httpError(let code, let msg):
return "Paperless-NGX API error \(code): \(msg)"
}
}
}