First Commit

This commit is contained in:
2026-03-20 19:34:06 +01:00
parent 4c5415b4b8
commit 677b927edf
35 changed files with 5064 additions and 10 deletions
+2
View File
@@ -91,6 +91,8 @@
knownRegions = (
en,
Base,
de,
es,
);
mainGroup = 261299CD2F6C686D00EC1C97;
minimizedProjectReferenceProxies = 1;
+7 -7
View File
@@ -5,20 +5,20 @@
// Created by Sven Hanold on 19.03.26.
//
// ContentView.swift
// This file is kept for compatibility but is no longer the root view.
// The app entry point in bookstaxApp.swift routes to OnboardingView or MainTabView.
import SwiftUI
struct ContentView: View {
var body: some View {
VStack {
Image(systemName: "globe")
.imageScale(.large)
.foregroundStyle(.tint)
Text("Hello, world!")
}
.padding()
MainTabView()
.environment(ConnectivityMonitor.shared)
}
}
#Preview {
ContentView()
.environment(ConnectivityMonitor.shared)
}
+37
View File
@@ -0,0 +1,37 @@
import Foundation
extension DateFormatter {
static let bookStackDisplay: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .none
return f
}()
static let bookStackDateTime: DateFormatter = {
let f = DateFormatter()
f.dateStyle = .medium
f.timeStyle = .short
return f
}()
static let bookStackRelative: RelativeDateTimeFormatter = {
let f = RelativeDateTimeFormatter()
f.unitsStyle = .abbreviated
return f
}()
}
extension Date {
var bookStackFormatted: String {
DateFormatter.bookStackDisplay.string(from: self)
}
var bookStackFormattedWithTime: String {
DateFormatter.bookStackDateTime.string(from: self)
}
var bookStackRelative: String {
DateFormatter.bookStackRelative.localizedString(for: self, relativeTo: Date())
}
}
+113
View File
@@ -0,0 +1,113 @@
import Foundation
// MARK: - Mock data for SwiftUI Previews and unit tests
extension ShelfDTO {
static let mock = ShelfDTO(
id: 1,
name: "Engineering Docs",
slug: "engineering-docs",
description: "Internal technical documentation for all engineering teams",
createdAt: Date(),
updatedAt: Date(),
cover: nil
)
static let mockList: [ShelfDTO] = [
mock,
ShelfDTO(id: 2, name: "Project Alpha", slug: "project-alpha", description: "Documentation for Project Alpha", createdAt: Date(), updatedAt: Date(), cover: nil),
ShelfDTO(id: 3, name: "HR Policies", slug: "hr-policies", description: "Company policies and procedures", createdAt: Date(), updatedAt: Date(), cover: nil)
]
}
extension BookDTO {
static let mock = BookDTO(
id: 1,
name: "iOS Development Guide",
slug: "ios-dev",
description: "Best practices for building iOS apps",
createdAt: Date(),
updatedAt: Date(),
cover: nil
)
static let mockList: [BookDTO] = [
mock,
BookDTO(id: 2, name: "API Reference", slug: "api-reference", description: "Complete API documentation", createdAt: Date(), updatedAt: Date(), cover: nil),
BookDTO(id: 3, name: "Onboarding Guide", slug: "onboarding", description: "Getting started for new team members", createdAt: Date(), updatedAt: Date(), cover: nil)
]
}
extension ChapterDTO {
static let mock = ChapterDTO(
id: 1,
bookId: 1,
name: "Getting Started",
slug: "getting-started",
description: "Introduction and setup",
priority: 1,
createdAt: Date(),
updatedAt: Date()
)
}
extension PageDTO {
static let mock = PageDTO(
id: 1,
bookId: 1,
chapterId: 1,
name: "Installation",
slug: "installation",
html: "<h1>Installation</h1><p>Welcome to the guide. Follow these steps to get started.</p><ul><li>Install Xcode</li><li>Clone the repository</li><li>Run the app</li></ul>",
markdown: "# Installation\n\nWelcome to the guide. Follow these steps to get started.\n\n- Install Xcode\n- Clone the repository\n- Run the app",
priority: 1,
draftStatus: false,
createdAt: Date(),
updatedAt: Date()
)
static let mockList: [PageDTO] = [
mock,
PageDTO(id: 2, bookId: 1, chapterId: 1, name: "Configuration", slug: "configuration", html: "<h1>Configuration</h1><p>Configure your environment.</p>", markdown: "# Configuration\n\nConfigure your environment.", priority: 2, draftStatus: false, createdAt: Date(), updatedAt: Date()),
PageDTO(id: 3, bookId: 1, chapterId: nil, name: "Deployment", slug: "deployment", html: "<h1>Deployment</h1><p>Deploy to production.</p>", markdown: "# Deployment\n\nDeploy to production.", priority: 3, draftStatus: false, createdAt: Date(), updatedAt: Date())
]
}
extension SearchResultDTO {
static let mock = SearchResultDTO(
id: 1,
name: "Installation Guide",
slug: "installation",
type: .page,
url: "/books/1/page/installation",
preview: "Welcome to the guide. Follow these steps to get started..."
)
static let mockList: [SearchResultDTO] = [
mock,
SearchResultDTO(id: 2, name: "iOS Development Guide", slug: "ios-dev", type: .book, url: "/books/2", preview: nil),
SearchResultDTO(id: 3, name: "Getting Started", slug: "getting-started", type: .chapter, url: "/books/1/chapter/getting-started", preview: nil),
SearchResultDTO(id: 4, name: "Engineering Docs", slug: "engineering-docs", type: .shelf, url: "/shelves/engineering-docs", preview: nil)
]
}
extension CommentDTO {
static let mock = CommentDTO(
id: 1,
text: "Great documentation! Very helpful.",
html: "<p>Great documentation! Very helpful.</p>",
pageId: 1,
createdBy: UserSummaryDTO(id: 1, name: "Alice Johnson", avatarUrl: nil),
createdAt: Date(),
updatedAt: Date()
)
}
extension UserDTO {
static let mock = UserDTO(
id: 1,
name: "Alice Johnson",
email: "alice@example.com",
avatarUrl: nil
)
}
+51
View File
@@ -0,0 +1,51 @@
import Foundation
enum BookStackError: LocalizedError, Sendable {
case invalidURL
case notAuthenticated
case unauthorized
case forbidden
case notFound(resource: String)
case httpError(statusCode: Int, message: String?)
case decodingError(String)
case networkUnavailable
case keychainError(OSStatus)
case sslError
case timeout
case notReachable(host: String)
case notBookStack(host: String)
case unknown(String)
var errorDescription: String? {
switch self {
case .invalidURL:
return "The server URL is invalid. Make sure it starts with https://."
case .notAuthenticated:
return "Please sign in to continue. Go to Settings to reconnect."
case .unauthorized:
return "Invalid Token ID or Secret. Double-check both values — the secret is only shown once in BookStack."
case .forbidden:
return "Your account doesn't have API access. Ask your BookStack administrator to enable the \"Access System API\" permission for your role."
case .notFound(let resource):
return "\(resource) could not be found. It may have been deleted or moved."
case .httpError(let code, let message):
return message ?? "Server returned error \(code). Check your server URL and try again."
case .decodingError(let detail):
return "Unexpected response from server. The BookStack version may not be compatible. (\(detail))"
case .networkUnavailable:
return "No internet connection. Showing cached content."
case .keychainError(let status):
return "Credential storage failed (code \(status))."
case .sslError:
return "SSL certificate error. If your server uses a self-signed certificate, contact your admin to install a trusted certificate."
case .timeout:
return "Request timed out. Make sure your device can reach the server."
case .notReachable(let host):
return "Could not reach \(host). Make sure the URL is correct and the server is running."
case .notBookStack(let host):
return "\(host) does not appear to be a BookStack server. Check the URL and try again."
case .unknown(let msg):
return msg
}
}
}
+196
View File
@@ -0,0 +1,196 @@
import Foundation
// All DTO types are explicitly nonisolated to ensure their Decodable conformances
// are not infected by the module-wide @MainActor default isolation, allowing them
// to be decoded freely inside non-MainActor contexts (e.g. actor BookStackAPI).
// MARK: - Shared
nonisolated struct CoverDTO: Codable, Sendable, Hashable {
let url: String
}
nonisolated struct PaginatedResponse<T: Codable & Sendable>: Codable, Sendable {
let data: [T]
let total: Int
enum CodingKeys: String, CodingKey {
case data, total
}
}
// MARK: - Shelf
nonisolated struct ShelfDTO: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let slug: String
let description: String
let createdAt: Date
let updatedAt: Date
let cover: CoverDTO?
enum CodingKeys: String, CodingKey {
case id, name, slug, description, cover
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
// MARK: - Book
nonisolated struct BookDTO: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let slug: String
let description: String
let createdAt: Date
let updatedAt: Date
let cover: CoverDTO?
enum CodingKeys: String, CodingKey {
case id, name, slug, description, cover
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
// MARK: - Chapter
nonisolated struct ChapterDTO: Codable, Sendable, Identifiable, Hashable {
let id: Int
let bookId: Int
let name: String
let slug: String
let description: String
let priority: Int
let createdAt: Date
let updatedAt: Date
enum CodingKeys: String, CodingKey {
case id, name, slug, description, priority
case bookId = "book_id"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
// MARK: - Page
nonisolated struct PageDTO: Codable, Sendable, Identifiable, Hashable {
let id: Int
let bookId: Int
let chapterId: Int?
let name: String
let slug: String
let html: String?
let markdown: String?
let priority: Int
let draftStatus: Bool
let createdAt: Date
let updatedAt: Date
enum CodingKeys: String, CodingKey {
case id, name, slug, html, markdown, priority
case bookId = "book_id"
case chapterId = "chapter_id"
case draftStatus = "draft"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
// MARK: - Search
nonisolated struct SearchResultDTO: Codable, Sendable, Identifiable, Hashable {
let id: Int
let name: String
let slug: String
let type: ContentType
let url: String
let preview: String?
enum ContentType: String, Codable, Sendable, CaseIterable {
case page, book, chapter, shelf
var displayName: String {
switch self {
case .page: return "Pages"
case .book: return "Books"
case .chapter: return "Chapters"
case .shelf: return "Shelves"
}
}
var systemImage: String {
switch self {
case .page: return "doc.text"
case .book: return "book.closed"
case .chapter: return "list.bullet.rectangle"
case .shelf: return "books.vertical"
}
}
}
}
nonisolated struct SearchResponseDTO: Codable, Sendable {
let data: [SearchResultDTO]
let total: Int
}
// MARK: - Comment
nonisolated struct CommentDTO: Codable, Sendable, Identifiable, Hashable {
let id: Int
let text: String
let html: String
let pageId: Int
let createdBy: UserSummaryDTO
let createdAt: Date
let updatedAt: Date
enum CodingKeys: String, CodingKey {
case id, text, html
case pageId = "entity_id"
case createdBy = "created_by"
case createdAt = "created_at"
case updatedAt = "updated_at"
}
}
nonisolated struct UserSummaryDTO: Codable, Sendable, Hashable {
let id: Int
let name: String
let avatarUrl: String?
enum CodingKeys: String, CodingKey {
case id, name
case avatarUrl = "avatar_url"
}
}
// MARK: - API Info
nonisolated struct APIInfo: Codable, Sendable {
let version: String
let appName: String?
enum CodingKeys: String, CodingKey {
case version
case appName = "app_name"
}
}
// MARK: - User
nonisolated struct UserDTO: Codable, Sendable {
let id: Int
let name: String
let email: String
let avatarUrl: String?
enum CodingKeys: String, CodingKey {
case id, name, email
case avatarUrl = "avatar_url"
}
}
+95
View File
@@ -0,0 +1,95 @@
import Foundation
import SwiftData
@Model
final class CachedShelf {
@Attribute(.unique) var id: Int
var name: String
var slug: String
var shelfDescription: String
var coverURL: String?
var lastFetched: Date
init(id: Int, name: String, slug: String, shelfDescription: String, coverURL: String? = nil) {
self.id = id
self.name = name
self.slug = slug
self.shelfDescription = shelfDescription
self.coverURL = coverURL
self.lastFetched = Date()
}
convenience init(from dto: ShelfDTO) {
self.init(
id: dto.id,
name: dto.name,
slug: dto.slug,
shelfDescription: dto.description,
coverURL: dto.cover?.url
)
}
}
@Model
final class CachedBook {
@Attribute(.unique) var id: Int
var name: String
var slug: String
var bookDescription: String
var coverURL: String?
var lastFetched: Date
init(id: Int, name: String, slug: String, bookDescription: String, coverURL: String? = nil) {
self.id = id
self.name = name
self.slug = slug
self.bookDescription = bookDescription
self.coverURL = coverURL
self.lastFetched = Date()
}
convenience init(from dto: BookDTO) {
self.init(
id: dto.id,
name: dto.name,
slug: dto.slug,
bookDescription: dto.description,
coverURL: dto.cover?.url
)
}
}
@Model
final class CachedPage {
@Attribute(.unique) var id: Int
var bookId: Int
var chapterId: Int?
var name: String
var slug: String
var html: String?
var markdown: String?
var lastFetched: Date
init(id: Int, bookId: Int, chapterId: Int? = nil, name: String, slug: String, html: String? = nil, markdown: String? = nil) {
self.id = id
self.bookId = bookId
self.chapterId = chapterId
self.name = name
self.slug = slug
self.html = html
self.markdown = markdown
self.lastFetched = Date()
}
convenience init(from dto: PageDTO) {
self.init(
id: dto.id,
bookId: dto.bookId,
chapterId: dto.chapterId,
name: dto.name,
slug: dto.slug,
html: dto.html,
markdown: dto.markdown
)
}
}
+412
View File
@@ -0,0 +1,412 @@
import Foundation
actor BookStackAPI {
static let shared = BookStackAPI()
private var serverURL: String = UserDefaults.standard.string(forKey: "serverURL") ?? ""
private var tokenId: String = KeychainService.loadSync(key: "tokenId") ?? ""
private var tokenSecret: String = KeychainService.loadSync(key: "tokenSecret") ?? ""
private let decoder: JSONDecoder = {
let d = JSONDecoder()
// BookStack uses microsecond-precision ISO8601: "2024-01-15T10:30:00.000000Z"
let formatter = DateFormatter()
formatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSSZ"
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = TimeZone(abbreviation: "UTC")
d.dateDecodingStrategy = .formatted(formatter)
return d
}()
// MARK: - Configuration
func configure(serverURL: String, tokenId: String, tokenSecret: String) {
self.serverURL = serverURL
self.tokenId = tokenId
self.tokenSecret = tokenSecret
AppLog(.info, "API configured for \(serverURL)", category: "API")
}
func getServerURL() -> String { serverURL }
// MARK: - Core Request (no body)
func request<T: Decodable & Sendable>(
endpoint: String,
method: String = "GET"
) async throws -> T {
try await performRequest(endpoint: endpoint, method: method, bodyData: nil)
}
// MARK: - Core Request (with body)
func request<T: Decodable & Sendable, Body: Encodable & Sendable>(
endpoint: String,
method: String,
body: Body
) async throws -> T {
let bodyData = try JSONEncoder().encode(body)
return try await performRequest(endpoint: endpoint, method: method, bodyData: bodyData)
}
// MARK: - Shared implementation
private func performRequest<T: Decodable & Sendable>(
endpoint: String,
method: String,
bodyData: Data?
) async throws -> T {
guard !serverURL.isEmpty else {
AppLog(.error, "\(method) \(endpoint) — not authenticated (no server URL)", category: "API")
throw BookStackError.notAuthenticated
}
guard let url = URL(string: "\(serverURL)/api/\(endpoint)") else {
AppLog(.error, "\(method) \(endpoint) — invalid URL", category: "API")
throw BookStackError.invalidURL
}
AppLog(.debug, "\(method) /api/\(endpoint)", category: "API")
var req = URLRequest(url: url)
req.httpMethod = method
req.setValue("Token \(tokenId):\(tokenSecret)", forHTTPHeaderField: "Authorization")
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.timeoutInterval = 30
if let bodyData {
req.setValue("application/json", forHTTPHeaderField: "Content-Type")
req.httpBody = bodyData
}
let (data, response): (Data, URLResponse)
do {
(data, response) = try await URLSession.shared.data(for: req)
} catch let urlError as URLError {
let mapped: BookStackError
switch urlError.code {
case .timedOut:
mapped = .timeout
case .notConnectedToInternet, .networkConnectionLost:
mapped = .networkUnavailable
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
mapped = .sslError
default:
mapped = .unknown(urlError.localizedDescription)
}
AppLog(.error, "\(method) /api/\(endpoint) — network error: \(urlError.localizedDescription)", category: "API")
throw mapped
}
guard let http = response as? HTTPURLResponse else {
AppLog(.error, "\(method) /api/\(endpoint) — invalid response type", category: "API")
throw BookStackError.unknown("Invalid response type")
}
AppLog(.debug, "\(method) /api/\(endpoint) → HTTP \(http.statusCode)", category: "API")
guard (200..<300).contains(http.statusCode) else {
let errorMessage = parseErrorMessage(from: data)
let mapped: BookStackError
switch http.statusCode {
case 401: mapped = .unauthorized
case 403: mapped = .forbidden
case 404: mapped = .notFound(resource: "Resource")
default: mapped = .httpError(statusCode: http.statusCode, message: errorMessage)
}
AppLog(.error, "\(method) /api/\(endpoint) → HTTP \(http.statusCode)\(errorMessage.map { ": \($0)" } ?? "")", category: "API")
throw mapped
}
// Handle 204 No Content
if http.statusCode == 204 {
guard let empty = EmptyResponse() as? T else {
throw BookStackError.decodingError("Expected empty response for 204")
}
return empty
}
do {
let result = try decoder.decode(T.self, from: data)
return result
} catch {
AppLog(.error, "\(method) /api/\(endpoint) — decode error: \(error.localizedDescription)", category: "API")
throw BookStackError.decodingError(error.localizedDescription)
}
}
private func parseErrorMessage(from data: Data) -> String? {
struct APIErrorEnvelope: Codable {
struct Inner: Codable { let message: String? }
let error: Inner?
}
return try? JSONDecoder().decode(APIErrorEnvelope.self, from: data).error?.message
}
// MARK: - Shelves
func fetchShelves(offset: Int = 0, count: Int = 100) async throws -> [ShelfDTO] {
let response: PaginatedResponse<ShelfDTO> = try await request(
endpoint: "shelves?offset=\(offset)&count=\(count)&sort=+name"
)
return response.data
}
func fetchShelf(id: Int) async throws -> ShelfBooksResponse {
return try await request(endpoint: "shelves/\(id)")
}
func createShelf(name: String, shelfDescription: String) async throws -> ShelfDTO {
struct Body: Encodable, Sendable {
let name: String
let description: String
}
return try await request(endpoint: "shelves", method: "POST",
body: Body(name: name, description: shelfDescription))
}
/// Assign a list of book IDs to a shelf. Replaces the shelf's current book list.
func updateShelfBooks(shelfId: Int, bookIds: [Int]) async throws {
struct BookRef: Encodable, Sendable { let id: Int }
struct Body: Encodable, Sendable { let books: [BookRef] }
let _: ShelfDTO = try await request(
endpoint: "shelves/\(shelfId)",
method: "PUT",
body: Body(books: bookIds.map { BookRef(id: $0) })
)
}
func deleteShelf(id: Int) async throws {
let _: EmptyResponse = try await request(endpoint: "shelves/\(id)", method: "DELETE")
}
// MARK: - Books
func fetchBooks(offset: Int = 0, count: Int = 100) async throws -> [BookDTO] {
let response: PaginatedResponse<BookDTO> = try await request(
endpoint: "books?offset=\(offset)&count=\(count)&sort=+name"
)
return response.data
}
func fetchBook(id: Int) async throws -> BookDTO {
return try await request(endpoint: "books/\(id)")
}
func createBook(name: String, bookDescription: String) async throws -> BookDTO {
struct Body: Encodable, Sendable {
let name: String
let description: String
}
return try await request(endpoint: "books", method: "POST",
body: Body(name: name, description: bookDescription))
}
func deleteBook(id: Int) async throws {
let _: EmptyResponse = try await request(endpoint: "books/\(id)", method: "DELETE")
}
// MARK: - Chapters
func fetchChapter(id: Int) async throws -> ChapterDTO {
return try await request(endpoint: "chapters/\(id)")
}
func fetchChapters(bookId: Int) async throws -> [ChapterDTO] {
let response: PaginatedResponse<ChapterDTO> = try await request(
endpoint: "chapters?book_id=\(bookId)&count=100&sort=+priority"
)
return response.data
}
func createChapter(bookId: Int, name: String, chapterDescription: String) async throws -> ChapterDTO {
struct Body: Encodable, Sendable {
let bookId: Int
let name: String
let description: String
enum CodingKeys: String, CodingKey {
case bookId = "book_id"
case name, description
}
}
return try await request(endpoint: "chapters", method: "POST",
body: Body(bookId: bookId, name: name, description: chapterDescription))
}
func deleteChapter(id: Int) async throws {
let _: EmptyResponse = try await request(endpoint: "chapters/\(id)", method: "DELETE")
}
// MARK: - Pages
func fetchPages(bookId: Int) async throws -> [PageDTO] {
let response: PaginatedResponse<PageDTO> = try await request(
endpoint: "pages?book_id=\(bookId)&count=100&sort=+priority"
)
return response.data
}
func fetchPage(id: Int) async throws -> PageDTO {
return try await request(endpoint: "pages/\(id)")
}
func createPage(bookId: Int, chapterId: Int? = nil, name: String, markdown: String) async throws -> PageDTO {
struct Body: Encodable, Sendable {
let bookId: Int
let chapterId: Int?
let name: String
let markdown: String
enum CodingKeys: String, CodingKey {
case bookId = "book_id"
case chapterId = "chapter_id"
case name, markdown
}
}
return try await request(endpoint: "pages", method: "POST",
body: Body(bookId: bookId, chapterId: chapterId, name: name, markdown: markdown))
}
func updatePage(id: Int, name: String, markdown: String) async throws -> PageDTO {
struct Body: Encodable, Sendable {
let name: String
let markdown: String
}
return try await request(endpoint: "pages/\(id)", method: "PUT",
body: Body(name: name, markdown: markdown))
}
func deletePage(id: Int) async throws {
let _: EmptyResponse = try await request(endpoint: "pages/\(id)", method: "DELETE")
}
// MARK: - Search
func search(query: String, type: SearchResultDTO.ContentType? = nil) async throws -> SearchResponseDTO {
var queryString = "search?query=\(query.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? query)"
if let type {
queryString += "%20[type:\(type.rawValue)]"
}
return try await request(endpoint: queryString)
}
// MARK: - Comments
func fetchComments(pageId: Int) async throws -> [CommentDTO] {
let response: PaginatedResponse<CommentDTO> = try await request(
endpoint: "comments?entity_type=page&entity_id=\(pageId)"
)
return response.data
}
func postComment(pageId: Int, text: String) async throws -> CommentDTO {
struct Body: Encodable, Sendable {
let text: String
let entityId: Int
let entityType: String
enum CodingKeys: String, CodingKey {
case text
case entityId = "entity_id"
case entityType = "entity_type"
}
}
return try await request(endpoint: "comments", method: "POST",
body: Body(text: text, entityId: pageId, entityType: "page"))
}
func deleteComment(id: Int) async throws {
let _: EmptyResponse = try await request(endpoint: "comments/\(id)", method: "DELETE")
}
// MARK: - System Info & Users
/// Phase 1: Check the server is reachable and is a BookStack instance.
/// Uses a plain URLSession request (no auth header) so it works before credentials are set.
func verifyServerReachable(url: String) async throws -> APIInfo {
AppLog(.info, "Verifying server reachability: \(url)", category: "Auth")
guard let requestURL = URL(string: "\(url)/api/info") else {
AppLog(.error, "Invalid server URL: \(url)", category: "Auth")
throw BookStackError.invalidURL
}
var req = URLRequest(url: requestURL)
req.httpMethod = "GET"
req.setValue("application/json", forHTTPHeaderField: "Accept")
req.timeoutInterval = 15
let (data, response): (Data, URLResponse)
do {
(data, response) = try await URLSession.shared.data(for: req)
} catch let urlError as URLError {
switch urlError.code {
case .timedOut: throw BookStackError.timeout
case .notConnectedToInternet, .networkConnectionLost: throw BookStackError.networkUnavailable
case .serverCertificateUntrusted, .serverCertificateHasBadDate,
.serverCertificateNotYetValid, .serverCertificateHasUnknownRoot:
throw BookStackError.sslError
case .cannotConnectToHost, .cannotFindHost:
throw BookStackError.notReachable(host: url)
default: throw BookStackError.notReachable(host: url)
}
}
guard let http = response as? HTTPURLResponse else {
throw BookStackError.notReachable(host: url)
}
// Server is completely unreachable (5xx or no response)
guard http.statusCode < 500 else {
throw BookStackError.notReachable(host: url)
}
// Best case: /api/info returned valid JSON decode and return
if let info = try? decoder.decode(APIInfo.self, from: data) {
AppLog(.info, "Server identified as BookStack \(info.version) (\(info.appName))", category: "Auth")
return info
}
// /api/info returned HTML (common when API access is restricted or endpoint is 404'd).
// Check if the HTML looks like it came from BookStack by looking for its meta tag.
let body = String(data: data, encoding: .utf8) ?? ""
if body.contains("meta name=\"base-url\"") || body.contains("BookStack") {
AppLog(.info, "Server identified as BookStack via HTML fingerprint", category: "Auth")
return APIInfo(version: "unknown", appName: "BookStack")
}
AppLog(.error, "Server at \(url) does not appear to be BookStack", category: "Auth")
throw BookStackError.notBookStack(host: url)
}
/// Phase 2: Verify the token works by calling an authenticated endpoint.
func verifyToken() async throws {
AppLog(.info, "Verifying API token", category: "Auth")
// GET /api/books?count=1 requires auth 401 = bad token, 403 = no API permission
let _: PaginatedResponse<BookDTO> = try await request(endpoint: "books?count=1")
AppLog(.info, "API token verified successfully", category: "Auth")
}
func verifyConnection() async throws -> APIInfo {
return try await request(endpoint: "info")
}
func fetchCurrentUser() async throws -> UserDTO {
// Fetch the users list to get current user info
let response: PaginatedResponse<UserDTO> = try await request(endpoint: "users?count=1")
guard let user = response.data.first else {
throw BookStackError.notFound(resource: "Current user")
}
return user
}
}
// MARK: - Helper
nonisolated struct EmptyResponse: Codable, Sendable {
init() {}
}
nonisolated struct ShelfBooksResponse: Codable, Sendable {
let id: Int
let name: String
let books: [BookDTO]
}
@@ -0,0 +1,34 @@
import Network
import Foundation
import Observation
@Observable
@MainActor
final class ConnectivityMonitor {
static let shared = ConnectivityMonitor()
private(set) var isConnected: Bool = true
private let monitor = NWPathMonitor()
private let queue = DispatchQueue(label: "com.bookstax.connectivity", qos: .utility)
private init() {
monitor.pathUpdateHandler = { [weak self] path in
let connected = path.status == .satisfied
Task { @MainActor [weak self] in
guard let self else { return }
if self.isConnected != connected {
AppLog(connected ? .info : .warning,
connected ? "Network connection restored" : "Network connection lost",
category: "Network")
}
self.isConnected = connected
}
}
monitor.start(queue: queue)
}
deinit {
monitor.cancel()
}
}
+103
View File
@@ -0,0 +1,103 @@
import Foundation
import Security
actor KeychainService {
static let shared = KeychainService()
private let service = "com.bookstax.credentials"
private let tokenIdKey = "tokenId"
private let tokenSecretKey = "tokenSecret"
// MARK: - Public API
func saveCredentials(tokenId: String, tokenSecret: String) throws {
try save(value: tokenId, key: tokenIdKey)
try save(value: tokenSecret, key: tokenSecretKey)
}
func loadCredentials() throws -> (tokenId: String, tokenSecret: String)? {
guard let id = try load(key: tokenIdKey),
let secret = try load(key: tokenSecretKey) else { return nil }
return (id, secret)
}
func deleteCredentials() throws {
try delete(key: tokenIdKey)
try delete(key: tokenSecretKey)
}
// MARK: - Synchronous static helper (for use at app init before async context)
static func loadSync(key: String) -> String? {
let service = "com.bookstax.credentials"
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
guard status == errSecSuccess,
let data = result as? Data,
let string = String(data: data, encoding: .utf8) else { return nil }
return string
}
// MARK: - Private helpers
private func save(value: String, key: String) throws {
guard let data = value.data(using: .utf8) else { return }
// Delete existing item first to allow update
let deleteQuery: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key
]
SecItemDelete(deleteQuery as CFDictionary)
let addQuery: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
kSecAttrAccessible: kSecAttrAccessibleWhenUnlockedThisDeviceOnly,
kSecValueData: data
]
let status = SecItemAdd(addQuery as CFDictionary, nil)
guard status == errSecSuccess else {
throw BookStackError.keychainError(status)
}
}
private func load(key: String) throws -> String? {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key,
kSecReturnData: true,
kSecMatchLimit: kSecMatchLimitOne
]
var result: CFTypeRef?
let status = SecItemCopyMatching(query as CFDictionary, &result)
if status == errSecItemNotFound { return nil }
guard status == errSecSuccess,
let data = result as? Data,
let string = String(data: data, encoding: .utf8) else {
throw BookStackError.keychainError(status)
}
return string
}
private func delete(key: String) throws {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: key
]
let status = SecItemDelete(query as CFDictionary)
guard status == errSecSuccess || status == errSecItemNotFound else {
throw BookStackError.keychainError(status)
}
}
}
+59
View File
@@ -0,0 +1,59 @@
import Foundation
import Observation
/// Manages in-app language selection independently of the system locale.
@Observable
final class LanguageManager {
static let shared = LanguageManager()
enum Language: String, CaseIterable, Identifiable {
case english = "en"
case german = "de"
case spanish = "es"
var id: String { rawValue }
var displayName: String {
switch self {
case .english: return "English"
case .german: return "Deutsch"
case .spanish: return "Español"
}
}
var flag: String {
switch self {
case .english: return "🇬🇧"
case .german: return "🇩🇪"
case .spanish: return "🇪🇸"
}
}
}
private(set) var current: Language
private init() {
let saved = UserDefaults.standard.string(forKey: "appLanguage") ?? ""
current = Language(rawValue: saved) ?? .english
}
func set(_ language: Language) {
current = language
UserDefaults.standard.set(language.rawValue, forKey: "appLanguage")
}
/// Returns the localised string for key in the currently selected language.
func string(_ key: String) -> String {
guard let path = Bundle.main.path(forResource: current.rawValue, ofType: "lproj"),
let bundle = Bundle(path: path) else {
return NSLocalizedString(key, comment: "")
}
return bundle.localizedString(forKey: key, value: key, table: nil)
}
}
/// Convenience shorthand
func L(_ key: String) -> String {
LanguageManager.shared.string(key)
}
+112
View File
@@ -0,0 +1,112 @@
import Foundation
import Observation
// MARK: - Log Entry
struct LogEntry: Identifiable, Sendable {
enum Level: String, Sendable {
case debug = "DEBUG"
case info = "INFO"
case warning = "WARN"
case error = "ERROR"
var emoji: String {
switch self {
case .debug: return "🔍"
case .info: return ""
case .warning: return "⚠️"
case .error: return ""
}
}
}
let id = UUID()
let timestamp: Date
let level: Level
let category: String
let message: String
var formatted: String {
let ts = LogEntry.formatter.string(from: timestamp)
return "\(ts) \(level.emoji) [\(category)] \(message)"
}
private static let formatter: DateFormatter = {
let f = DateFormatter()
f.dateFormat = "yyyy-MM-dd HH:mm:ss.SSS"
return f
}()
}
// MARK: - LogManager
@Observable
@MainActor
final class LogManager {
static let shared = LogManager()
private(set) var entries: [LogEntry] = []
private let maxEntries = 2000
/// Controlled via Settings toggle. Stored in UserDefaults so it survives restarts.
var isEnabled: Bool {
get { UserDefaults.standard.bool(forKey: "loggingEnabled") }
set { UserDefaults.standard.set(newValue, forKey: "loggingEnabled") }
}
private init() {}
// MARK: - Logging
func log(_ level: LogEntry.Level, category: String, _ message: String) {
guard isEnabled else { return }
let entry = LogEntry(timestamp: Date(), level: level, category: category, message: message)
entries.append(entry)
if entries.count > maxEntries {
entries.removeFirst(entries.count - maxEntries)
}
}
func debug(_ message: String, category: String = "App") {
log(.debug, category: category, message)
}
func info(_ message: String, category: String = "App") {
log(.info, category: category, message)
}
func warning(_ message: String, category: String = "App") {
log(.warning, category: category, message)
}
func error(_ message: String, category: String = "App") {
log(.error, category: category, message)
}
// MARK: - Export
func clear() {
entries.removeAll()
}
/// Returns the full log as a plain-text string ready for sharing.
func exportText() -> String {
guard !entries.isEmpty else { return "No log entries." }
let header = """
BookStax Log Export
Generated: \(Date().bookStackFormattedWithTime)
Entries: \(entries.count)
────────────────────────────────────────
"""
return header + entries.map(\.formatted).joined(separator: "\n")
}
}
// MARK: - Global shorthand (nonisolated dispatches to MainActor)
func AppLog(_ level: LogEntry.Level = .info, _ message: String, category: String = "App") {
Task { @MainActor in
LogManager.shared.log(level, category: category, message)
}
}
+80
View File
@@ -0,0 +1,80 @@
import Foundation
import SwiftData
/// SyncService handles upserting API DTOs into the local SwiftData cache.
/// All methods are @MainActor because ModelContext must be used on the main actor.
@MainActor
final class SyncService {
static let shared = SyncService()
private let api = BookStackAPI.shared
private init() {}
// MARK: - Sync Shelves
func syncShelves(context: ModelContext) async throws {
let dtos = try await api.fetchShelves()
for dto in dtos {
let id = dto.id
let descriptor = FetchDescriptor<CachedShelf>(
predicate: #Predicate { $0.id == id }
)
if let existing = try context.fetch(descriptor).first {
existing.name = dto.name
existing.shelfDescription = dto.description
existing.coverURL = dto.cover?.url
existing.lastFetched = Date()
} else {
context.insert(CachedShelf(from: dto))
}
}
try context.save()
}
// MARK: - Sync Books
func syncBooks(context: ModelContext) async throws {
let dtos = try await api.fetchBooks()
for dto in dtos {
let id = dto.id
let descriptor = FetchDescriptor<CachedBook>(
predicate: #Predicate { $0.id == id }
)
if let existing = try context.fetch(descriptor).first {
existing.name = dto.name
existing.bookDescription = dto.description
existing.coverURL = dto.cover?.url
existing.lastFetched = Date()
} else {
context.insert(CachedBook(from: dto))
}
}
try context.save()
}
// MARK: - Sync Page (on demand, after viewing)
func cachePageContent(_ dto: PageDTO, context: ModelContext) throws {
let id = dto.id
let descriptor = FetchDescriptor<CachedPage>(
predicate: #Predicate { $0.id == id }
)
if let existing = try context.fetch(descriptor).first {
existing.html = dto.html
existing.markdown = dto.markdown
existing.lastFetched = Date()
} else {
context.insert(CachedPage(from: dto))
}
try context.save()
}
// MARK: - Full sync
func syncAll(context: ModelContext) async throws {
async let shelvesTask: Void = syncShelves(context: context)
async let booksTask: Void = syncBooks(context: context)
_ = try await (shelvesTask, booksTask)
}
}
+112
View File
@@ -0,0 +1,112 @@
import Foundation
import Observation
@Observable
final class LibraryViewModel {
var shelves: [ShelfDTO] = []
var books: [BookDTO] = []
var chapters: [ChapterDTO] = []
var pages: [PageDTO] = []
var isLoadingShelves: Bool = false
var isLoadingBooks: Bool = false
var isLoadingContent: Bool = false
var error: BookStackError? = nil
// MARK: - Shelves
func loadShelves() async {
isLoadingShelves = true
error = nil
AppLog(.info, "Loading shelves", category: "Library")
do {
shelves = try await BookStackAPI.shared.fetchShelves()
AppLog(.info, "Loaded \(shelves.count) shelf(ves)", category: "Library")
} catch let e as BookStackError {
AppLog(.error, "Failed to load shelves: \(e.localizedDescription)", category: "Library")
error = e
} catch {
AppLog(.error, "Failed to load shelves: \(error.localizedDescription)", category: "Library")
self.error = .unknown(error.localizedDescription)
}
isLoadingShelves = false
}
// MARK: - Books
func loadBooks() async {
isLoadingBooks = true
error = nil
do {
books = try await BookStackAPI.shared.fetchBooks()
} catch let e as BookStackError {
error = e
} catch {
self.error = .unknown(error.localizedDescription)
}
isLoadingBooks = false
}
func loadBooksForShelf(shelfId: Int) async {
isLoadingBooks = true
error = nil
do {
let shelfDetail = try await BookStackAPI.shared.fetchShelf(id: shelfId)
books = shelfDetail.books
} catch let e as BookStackError {
error = e
} catch {
self.error = .unknown(error.localizedDescription)
}
isLoadingBooks = false
}
// MARK: - Chapters + Pages
func loadChaptersAndPages(bookId: Int) async {
isLoadingContent = true
error = nil
AppLog(.info, "Loading chapters and pages for book \(bookId)", category: "Library")
do {
async let chaptersTask = BookStackAPI.shared.fetchChapters(bookId: bookId)
async let pagesTask = BookStackAPI.shared.fetchPages(bookId: bookId)
let (ch, pg) = try await (chaptersTask, pagesTask)
chapters = ch
pages = pg
AppLog(.info, "Loaded \(ch.count) chapter(s), \(pg.count) page(s) for book \(bookId)", category: "Library")
} catch let e as BookStackError {
AppLog(.error, "Failed to load book \(bookId) content: \(e.localizedDescription)", category: "Library")
error = e
} catch {
AppLog(.error, "Failed to load book \(bookId) content: \(error.localizedDescription)", category: "Library")
self.error = .unknown(error.localizedDescription)
}
isLoadingContent = false
}
// MARK: - Delete
func deletePage(id: Int) async {
do {
try await BookStackAPI.shared.deletePage(id: id)
pages.removeAll { $0.id == id }
} catch let e as BookStackError {
error = e
} catch {
self.error = .unknown(error.localizedDescription)
}
}
func deleteBook(id: Int) async {
do {
try await BookStackAPI.shared.deleteBook(id: id)
books.removeAll { $0.id == id }
} catch let e as BookStackError {
error = e
} catch {
self.error = .unknown(error.localizedDescription)
}
}
}
@@ -0,0 +1,177 @@
import Foundation
import Observation
@Observable
final class OnboardingViewModel {
enum Step: Int, CaseIterable {
case language = 0
case welcome = 1
case serverURL = 2
case apiToken = 3
case verify = 4
case ready = 5
}
// Navigation
var currentStep: Step = .welcome
// Input
var serverURLInput: String = "https://bs-test.hanold.online"
var tokenIdInput: String = ""
var tokenSecretInput: String = ""
// URL validation
var serverURLError: String? = nil
// Verification phases
enum VerifyPhase {
case idle
case checkingServer
case serverOK(appName: String)
case checkingToken
case done(appName: String, userName: String?)
case failed(phase: String, error: BookStackError)
}
var verifyPhase: VerifyPhase = .idle
// Backwards-compat helpers used by VerifyStepView
var isVerifying: Bool {
switch verifyPhase {
case .checkingServer, .checkingToken: return true
default: return false
}
}
var verificationError: BookStackError? {
if case .failed(_, let error) = verifyPhase { return error }
return nil
}
var verifiedAppName: String? {
switch verifyPhase {
case .serverOK(let n), .done(let n, _): return n
default: return nil
}
}
var verifiedUserName: String? {
if case .done(_, let u) = verifyPhase { return u }
return nil
}
// Completion
var isComplete: Bool = false
// MARK: - Navigation
func advance() {
guard let next = Step(rawValue: currentStep.rawValue + 1) else { return }
currentStep = next
}
func goBack() {
guard let prev = Step(rawValue: currentStep.rawValue - 1) else { return }
currentStep = prev
verifyPhase = .idle
}
// MARK: - URL Validation
func validateServerURL() -> Bool {
var url = serverURLInput.trimmingCharacters(in: .whitespacesAndNewlines)
guard !url.isEmpty else {
serverURLError = L("onboarding.server.error.empty")
return false
}
// Auto-prefix https:// if missing
if !url.hasPrefix("http://") && !url.hasPrefix("https://") {
url = "https://" + url
serverURLInput = url
}
// Strip trailing slash
if url.hasSuffix("/") {
url = String(url.dropLast())
serverURLInput = url
}
guard URL(string: url) != nil else {
serverURLError = L("onboarding.server.error.invalid")
return false
}
serverURLError = nil
return true
}
var isHTTP: Bool {
serverURLInput.hasPrefix("http://") && !serverURLInput.hasPrefix("https://")
}
// MARK: - Verification
func verifyAndSave() async {
let url = serverURLInput
let tokenId = tokenIdInput
let tokenSecret = tokenSecretInput
AppLog(.info, "Starting onboarding verification for \(url)", category: "Onboarding")
// Phase 1: check the server is reachable and is BookStack
verifyPhase = .checkingServer
let info: APIInfo
do {
info = try await BookStackAPI.shared.verifyServerReachable(url: url)
} catch let error as BookStackError {
AppLog(.error, "Server check failed: \(error.localizedDescription)", category: "Onboarding")
verifyPhase = .failed(phase: "server", error: error)
return
} catch {
AppLog(.error, "Server check failed: \(error.localizedDescription)", category: "Onboarding")
verifyPhase = .failed(phase: "server", error: .unknown(error.localizedDescription))
return
}
let appName = info.appName ?? "BookStack \(info.version)"
verifyPhase = .serverOK(appName: appName)
// Phase 2: configure credentials and verify the token
await BookStackAPI.shared.configure(serverURL: url, tokenId: tokenId, tokenSecret: tokenSecret)
verifyPhase = .checkingToken
do {
try await BookStackAPI.shared.verifyToken()
} catch let error as BookStackError {
AppLog(.error, "Token verification failed: \(error.localizedDescription)", category: "Onboarding")
verifyPhase = .failed(phase: "token", error: error)
return
} catch {
AppLog(.error, "Token verification failed: \(error.localizedDescription)", category: "Onboarding")
verifyPhase = .failed(phase: "token", error: .unknown(error.localizedDescription))
return
}
// Attempt to fetch user info (non-fatal)
let userName = try? await BookStackAPI.shared.fetchCurrentUser().name
// Persist server URL and credentials
UserDefaults.standard.set(url, forKey: "serverURL")
do {
try await KeychainService.shared.saveCredentials(tokenId: tokenId, tokenSecret: tokenSecret)
} catch let error as BookStackError {
AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding")
verifyPhase = .failed(phase: "keychain", error: error)
return
} catch {
AppLog(.error, "Keychain save failed: \(error.localizedDescription)", category: "Onboarding")
verifyPhase = .failed(phase: "keychain", error: .unknown(error.localizedDescription))
return
}
AppLog(.info, "Onboarding complete — connected to \(appName)\(userName.map { " as \($0)" } ?? "")", category: "Onboarding")
verifyPhase = .done(appName: appName, userName: userName)
advance() // .ready
}
// MARK: - Complete
func completeOnboarding() {
UserDefaults.standard.set(true, forKey: "onboardingComplete")
AppLog(.info, "Onboarding marked complete", category: "Onboarding")
isComplete = true
}
}
@@ -0,0 +1,79 @@
import Foundation
import Observation
@Observable
final class PageEditorViewModel {
enum Mode {
case create(bookId: Int, chapterId: Int? = nil)
case edit(page: PageDTO)
}
enum EditorTab {
case write, preview
}
let mode: Mode
var title: String = ""
var markdownContent: String = ""
var activeTab: EditorTab = .write
var isSaving: Bool = false
var saveError: BookStackError? = nil
var savedPage: PageDTO? = nil
var hasUnsavedChanges: Bool {
switch mode {
case .create:
return !title.isEmpty || !markdownContent.isEmpty
case .edit(let page):
return title != page.name || markdownContent != (page.markdown ?? "")
}
}
init(mode: Mode) {
self.mode = mode
if case .edit(let page) = mode {
title = page.name
markdownContent = page.markdown ?? ""
}
}
// MARK: - Save
func save() async {
guard !title.isEmpty, !markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return }
isSaving = true
saveError = nil
do {
switch mode {
case .create(let bookId, let chapterId):
AppLog(.info, "Creating page '\(title)' in book \(bookId)", category: "Editor")
savedPage = try await BookStackAPI.shared.createPage(
bookId: bookId,
chapterId: chapterId,
name: title,
markdown: markdownContent
)
AppLog(.info, "Page '\(title)' created (id: \(savedPage?.id ?? -1))", category: "Editor")
case .edit(let page):
AppLog(.info, "Saving edits to page '\(title)' (id: \(page.id))", category: "Editor")
savedPage = try await BookStackAPI.shared.updatePage(
id: page.id,
name: title,
markdown: markdownContent
)
AppLog(.info, "Page '\(title)' saved successfully", category: "Editor")
}
} catch let e as BookStackError {
AppLog(.error, "Save failed for '\(title)': \(e.localizedDescription)", category: "Editor")
saveError = e
} catch {
AppLog(.error, "Save failed for '\(title)': \(error.localizedDescription)", category: "Editor")
saveError = .unknown(error.localizedDescription)
}
isSaving = false
}
}
+81
View File
@@ -0,0 +1,81 @@
import Foundation
import Observation
@Observable
final class SearchViewModel {
var query: String = ""
var results: [SearchResultDTO] = []
var isSearching: Bool = false
var error: BookStackError? = nil
var selectedTypeFilter: SearchResultDTO.ContentType? = nil
var recentSearches: [String] {
get { UserDefaults.standard.stringArray(forKey: "recentSearches") ?? [] }
set {
var trimmed = newValue.prefix(10).map { $0 }
UserDefaults.standard.set(trimmed, forKey: "recentSearches")
}
}
private var searchTask: Task<Void, Never>?
// MARK: - Search trigger (call from onChange of query)
func onQueryChanged() {
searchTask?.cancel()
guard query.count >= 2 else {
results = []
return
}
searchTask = Task {
try? await Task.sleep(for: .milliseconds(400))
guard !Task.isCancelled else { return }
await performSearch()
}
}
// MARK: - Filter change
func onFilterChanged() {
guard query.count >= 2 else { return }
searchTask?.cancel()
searchTask = Task {
await performSearch()
}
}
// MARK: - Recent searches
func addToRecent(_ query: String) {
var recent = recentSearches.filter { $0 != query }
recent.insert(query, at: 0)
recentSearches = recent
}
func clearRecentSearches() {
recentSearches = []
}
// MARK: - Private
private func performSearch() async {
isSearching = true
error = nil
let filter = selectedTypeFilter.map { " [type:\($0.rawValue)]" } ?? ""
AppLog(.info, "Search: \"\(query)\"\(filter)", category: "Search")
do {
let response = try await BookStackAPI.shared.search(query: query, type: selectedTypeFilter)
results = response.data
AppLog(.info, "Search returned \(results.count) result(s) for \"\(query)\"", category: "Search")
addToRecent(query)
} catch let e as BookStackError {
AppLog(.error, "Search failed for \"\(query)\": \(e.localizedDescription)", category: "Search")
error = e
} catch {
AppLog(.error, "Search failed for \"\(query)\": \(error.localizedDescription)", category: "Search")
self.error = .unknown(error.localizedDescription)
}
isSearching = false
}
}
+469
View File
@@ -0,0 +1,469 @@
import SwiftUI
import UIKit
import WebKit
// MARK: - UITextView wrapper that exposes selection-aware formatting
struct MarkdownTextEditor: UIViewRepresentable {
@Binding var text: String
/// Called with the UITextView so the parent can apply formatting
var onTextViewReady: (UITextView) -> Void
func makeUIView(context: Context) -> UITextView {
let tv = UITextView()
tv.font = UIFont.monospacedSystemFont(ofSize: 16, weight: .regular)
tv.autocorrectionType = .no
tv.autocapitalizationType = .none
tv.delegate = context.coordinator
tv.backgroundColor = .clear
tv.textContainerInset = UIEdgeInsets(top: 8, left: 8, bottom: 8, right: 8)
// Set initial text (e.g. when editing an existing page)
tv.text = text
onTextViewReady(tv)
return tv
}
func updateUIView(_ tv: UITextView, context: Context) {
// Only push changes that originated outside the UITextView (e.g. formatting toolbar).
// Skip updates triggered by the user typing to avoid cursor position resets.
guard !context.coordinator.isEditing, tv.text != text else { return }
let sel = tv.selectedRange
tv.text = text
// Clamp selection to new text length
let len = tv.text.utf16.count
tv.selectedRange = NSRange(location: min(sel.location, len), length: 0)
}
func makeCoordinator() -> Coordinator { Coordinator(self) }
final class Coordinator: NSObject, UITextViewDelegate {
var parent: MarkdownTextEditor
/// True while the user is actively editing, so updateUIView won't fight the keyboard.
var isEditing = false
init(_ parent: MarkdownTextEditor) { self.parent = parent }
func textViewDidBeginEditing(_ textView: UITextView) {
isEditing = true
}
func textViewDidEndEditing(_ textView: UITextView) {
isEditing = false
}
func textViewDidChange(_ textView: UITextView) {
parent.text = textView.text
}
}
}
// MARK: - Page Editor
struct PageEditorView: View {
@State private var viewModel: PageEditorViewModel
@Environment(\.dismiss) private var dismiss
@State private var showDiscardAlert = false
/// Reference to the underlying UITextView for formatting operations
@State private var textView: UITextView? = nil
init(mode: PageEditorViewModel.Mode) {
_viewModel = State(initialValue: PageEditorViewModel(mode: mode))
}
var body: some View {
NavigationStack {
VStack(spacing: 0) {
// Title field
TextField(L("editor.title.placeholder"), text: $viewModel.title)
.font(.title2.bold())
.padding(.horizontal)
.padding(.vertical, 12)
Divider()
// Write / Preview toggle
Picker("", selection: $viewModel.activeTab) {
Text(L("editor.tab.write")).tag(PageEditorViewModel.EditorTab.write)
Text(L("editor.tab.preview")).tag(PageEditorViewModel.EditorTab.preview)
}
.pickerStyle(.segmented)
.padding()
Divider()
// Content area
if viewModel.activeTab == .write {
VStack(spacing: 0) {
MarkdownTextEditor(text: $viewModel.markdownContent) { tv in
textView = tv
}
Divider()
FormattingToolbar { action in
applyFormat(action)
}
}
} else {
MarkdownPreviewView(markdown: viewModel.markdownContent)
}
// Save error
if let error = viewModel.saveError {
ErrorBanner(error: error) {
Task { await viewModel.save() }
}
.padding()
}
}
.navigationTitle(navigationTitle)
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L("editor.cancel")) {
if viewModel.hasUnsavedChanges {
showDiscardAlert = true
} else {
dismiss()
}
}
}
ToolbarItem(placement: .confirmationAction) {
Button(L("editor.save")) {
Task {
await viewModel.save()
if viewModel.saveError == nil {
dismiss()
}
}
}
.disabled(viewModel.title.isEmpty || viewModel.markdownContent.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || viewModel.isSaving)
.overlay {
if viewModel.isSaving {
ProgressView().scaleEffect(0.7)
}
}
}
}
.alert(L("editor.discard.title"), isPresented: $showDiscardAlert) {
Button(L("editor.discard.confirm"), role: .destructive) { dismiss() }
Button(L("editor.discard.keepediting"), role: .cancel) {}
} message: {
Text(L("editor.discard.message"))
}
}
}
private var navigationTitle: String {
switch viewModel.mode {
case .create: return L("editor.new.title")
case .edit: return L("editor.edit.title")
}
}
// MARK: - Apply formatting to selected text (or insert at cursor)
private func applyFormat(_ action: FormatAction) {
guard let tv = textView else { return }
let text = tv.text ?? ""
let nsText = text as NSString
let range = tv.selectedRange
switch action {
// Inline wrap: surround selection or insert markers
case .bold, .italic, .strikethrough, .inlineCode:
let marker = action.inlineMarker
if range.length > 0 {
let selected = nsText.substring(with: range)
let replacement = "\(marker)\(selected)\(marker)"
replace(in: tv, range: range, with: replacement, cursorOffset: replacement.count)
} else {
let placeholder = action.placeholder
let insertion = "\(marker)\(placeholder)\(marker)"
replace(in: tv, range: range, with: insertion,
selectRange: NSRange(location: range.location + marker.count,
length: placeholder.count))
}
// Line prefix: prepend to each selected line
case .h1, .h2, .h3, .bulletList, .numberedList, .blockquote:
let prefix = action.linePrefix
applyLinePrefix(tv: tv, text: text, nsText: nsText, range: range, prefix: prefix)
// Block insert at cursor
case .codeBlock:
let block = "```\n\(range.length > 0 ? nsText.substring(with: range) : "code")\n```"
replace(in: tv, range: range, with: block, cursorOffset: block.count)
case .link:
if range.length > 0 {
let selected = nsText.substring(with: range)
let insertion = "[\(selected)](url)"
replace(in: tv, range: range, with: insertion, cursorOffset: insertion.count)
} else {
let insertion = "[text](url)"
replace(in: tv, range: range, with: insertion,
selectRange: NSRange(location: range.location + 1, length: 4))
}
case .horizontalRule:
// Insert on its own line
let before = range.location > 0 ? "\n" : ""
let insertion = "\(before)---\n"
replace(in: tv, range: range, with: insertion, cursorOffset: insertion.count)
}
}
/// Replace a range in the UITextView and sync back to the binding.
private func replace(in tv: UITextView, range: NSRange, with string: String,
cursorOffset: Int? = nil, selectRange: NSRange? = nil) {
guard let swiftRange = Range(range, in: tv.text) else { return }
var newText = tv.text!
newText.replaceSubrange(swiftRange, with: string)
tv.text = newText
viewModel.markdownContent = newText
// Position cursor
if let sel = selectRange {
tv.selectedRange = sel
} else if let offset = cursorOffset {
let newPos = range.location + offset
tv.selectedRange = NSRange(location: min(newPos, newText.utf16.count), length: 0)
}
}
/// Toggle a line prefix (e.g. `## `) on all lines touched by the selection.
private func applyLinePrefix(tv: UITextView, text: String, nsText: NSString,
range: NSRange, prefix: String) {
// Expand range to full lines
let lineRange = nsText.lineRange(for: range)
let linesString = nsText.substring(with: lineRange)
let lines = linesString.components(separatedBy: "\n")
// Determine if ALL non-empty lines already have this prefix toggle off
let nonEmpty = lines.filter { !$0.isEmpty }
let allPrefixed = !nonEmpty.isEmpty && nonEmpty.allSatisfy { $0.hasPrefix(prefix) }
let transformed = lines.map { line -> String in
if line.isEmpty { return line }
if allPrefixed {
return String(line.dropFirst(prefix.count))
} else {
return line.hasPrefix(prefix) ? line : prefix + line
}
}.joined(separator: "\n")
replace(in: tv, range: lineRange, with: transformed, cursorOffset: transformed.count)
}
}
// MARK: - Format Actions
enum FormatAction {
case h1, h2, h3
case bold, italic, strikethrough, inlineCode
case bulletList, numberedList, blockquote
case link, codeBlock, horizontalRule
var inlineMarker: String {
switch self {
case .bold: return "**"
case .italic: return "*"
case .strikethrough: return "~~"
case .inlineCode: return "`"
default: return ""
}
}
var linePrefix: String {
switch self {
case .h1: return "# "
case .h2: return "## "
case .h3: return "### "
case .bulletList: return "- "
case .numberedList: return "1. "
case .blockquote: return "> "
default: return ""
}
}
var placeholder: String {
switch self {
case .bold: return "bold"
case .italic: return "italic"
case .strikethrough: return "text"
case .inlineCode: return "code"
default: return ""
}
}
}
// MARK: - Formatting Toolbar
struct FormattingToolbar: View {
let onAction: (FormatAction) -> Void
var body: some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 2) {
Group {
FormatButton("H1", action: .h1, onAction: onAction)
FormatButton("H2", action: .h2, onAction: onAction)
FormatButton("H3", action: .h3, onAction: onAction)
toolbarDivider
FormatButton(systemImage: "bold", action: .bold, onAction: onAction)
FormatButton(systemImage: "italic", action: .italic, onAction: onAction)
FormatButton(systemImage: "strikethrough", action: .strikethrough, onAction: onAction)
FormatButton(systemImage: "chevron.left.forwardslash.chevron.right", action: .inlineCode, onAction: onAction)
}
toolbarDivider
Group {
FormatButton(systemImage: "list.bullet", action: .bulletList, onAction: onAction)
FormatButton(systemImage: "list.number", action: .numberedList, onAction: onAction)
FormatButton(systemImage: "text.quote", action: .blockquote, onAction: onAction)
toolbarDivider
FormatButton(systemImage: "link", action: .link, onAction: onAction)
FormatButton(systemImage: "curlybraces", action: .codeBlock, onAction: onAction)
FormatButton(systemImage: "minus", action: .horizontalRule, onAction: onAction)
}
}
.padding(.horizontal, 8)
.padding(.vertical, 6)
}
.background(Color(.secondarySystemBackground))
}
private var toolbarDivider: some View {
Divider()
.frame(height: 20)
.padding(.horizontal, 4)
}
}
struct FormatButton: View {
let label: String?
let systemImage: String?
let action: FormatAction
let onAction: (FormatAction) -> Void
init(_ label: String, action: FormatAction, onAction: @escaping (FormatAction) -> Void) {
self.label = label
self.systemImage = nil
self.action = action
self.onAction = onAction
}
init(systemImage: String, action: FormatAction, onAction: @escaping (FormatAction) -> Void) {
self.label = nil
self.systemImage = systemImage
self.action = action
self.onAction = onAction
}
var body: some View {
// Use onTapGesture instead of Button so the toolbar tap doesn't
// resign the UITextView's first responder (which would clear the selection).
Group {
if let label {
Text(label)
.font(.system(size: 13, weight: .semibold, design: .rounded))
} else if let systemImage {
Image(systemName: systemImage)
.font(.system(size: 15, weight: .regular))
}
}
.frame(width: 36, height: 32)
.foregroundStyle(.primary)
.background(Color(.tertiarySystemBackground), in: RoundedRectangle(cornerRadius: 6))
.contentShape(Rectangle())
.onTapGesture {
onAction(action)
}
}
}
// MARK: - Markdown Preview
struct MarkdownPreviewView: View {
let markdown: String
@State private var webPage = WebPage()
@Environment(\.colorScheme) private var colorScheme
var body: some View {
WebView(webPage)
.onAppear { loadPreview() }
.onChange(of: markdown) { loadPreview() }
.onChange(of: colorScheme) { loadPreview() }
}
private func loadPreview() {
let html = markdownToHTML(markdown)
let isDark = colorScheme == .dark
let bg = isDark ? "#1c1c1e" : "#ffffff"
let fg = isDark ? "#f2f2f7" : "#000000"
let codeBg = isDark ? "#2c2c2e" : "#f2f2f7"
let fullHTML = """
<!DOCTYPE html><html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: -apple-system; font-size: 16px; line-height: 1.6; padding: 16px;
background: \(bg); color: \(fg); }
pre { background: \(codeBg); padding: 12px; border-radius: 8px; overflow-x: auto; }
code { font-family: "SF Mono", monospace; font-size: 14px; background: \(codeBg);
padding: 2px 4px; border-radius: 4px; }
pre code { background: transparent; padding: 0; }
blockquote { border-left: 3px solid #0a84ff; padding-left: 12px; color: #8e8e93; margin-left: 0; }
</style>
</head>
<body>\(html)</body></html>
"""
webPage.load(html: fullHTML, baseURL: URL(string: "https://bookstack.example.com")!)
}
/// Minimal Markdown HTML converter for preview purposes.
/// For editing purposes the full rendering happens server-side.
private func markdownToHTML(_ md: String) -> String {
var html = md
// Code blocks (must come before inline code)
let codeBlockPattern = #"```[\w]*\n([\s\S]*?)```"#
html = html.replacingOccurrences(of: codeBlockPattern, with: "<pre><code>$1</code></pre>",
options: .regularExpression)
// Inline code
html = html.replacingOccurrences(of: "`([^`]+)`", with: "<code>$1</code>",
options: .regularExpression)
// Bold + italic
html = html.replacingOccurrences(of: "\\*\\*\\*(.+?)\\*\\*\\*", with: "<strong><em>$1</em></strong>",
options: .regularExpression)
html = html.replacingOccurrences(of: "\\*\\*(.+?)\\*\\*", with: "<strong>$1</strong>",
options: .regularExpression)
html = html.replacingOccurrences(of: "\\*(.+?)\\*", with: "<em>$1</em>",
options: .regularExpression)
// Headings (use (?m) inline flag for multiline anchors)
for h in stride(from: 6, through: 1, by: -1) {
html = html.replacingOccurrences(of: "(?m)^#{" + "\(h)" + "} (.+)$",
with: "<h\(h)>$1</h\(h)>",
options: .regularExpression)
}
// Horizontal rule
html = html.replacingOccurrences(of: "(?m)^---$", with: "<hr>",
options: .regularExpression)
// Unordered list items
html = html.replacingOccurrences(of: "(?m)^[\\-\\*] (.+)$", with: "<li>$1</li>",
options: .regularExpression)
// Blockquote
html = html.replacingOccurrences(of: "(?m)^> (.+)$", with: "<blockquote>$1</blockquote>",
options: .regularExpression)
// Paragraphs: double newlines become <br><br>
html = html.replacingOccurrences(of: "\n\n", with: "<br><br>")
return html
}
}
#Preview("New Page") {
PageEditorView(mode: .create(bookId: 1))
}
#Preview("Edit Page") {
PageEditorView(mode: .edit(page: .mock))
}
+209
View File
@@ -0,0 +1,209 @@
import SwiftUI
struct BookDetailView: View {
let book: BookDTO
@State private var viewModel = LibraryViewModel()
@State private var showNewPage = false
@State private var showNewChapter = false
var body: some View {
Group {
if viewModel.isLoadingContent && viewModel.chapters.isEmpty && viewModel.pages.isEmpty {
LoadingView(message: L("book.loading"))
} else if viewModel.chapters.isEmpty && viewModel.pages.isEmpty && viewModel.error == nil {
EmptyStateView(
systemImage: "doc.text",
title: L("book.empty.title"),
message: L("book.empty.message"),
actionTitle: L("book.addpage"),
action: { showNewPage = true }
)
} else {
List {
if let error = viewModel.error {
ErrorBanner(error: error) {
Task { await viewModel.loadChaptersAndPages(bookId: book.id) }
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
// Chapters with their pages
ForEach(viewModel.chapters) { chapter in
Section {
ForEach(pagesInChapter(chapter.id)) { page in
NavigationLink(value: page) {
ContentRowView(
icon: "doc.text",
name: page.name,
description: "",
updatedAt: page.updatedAt
)
}
.accessibilityLabel("Page: \(page.name)")
.contextMenu { pageContextMenu(page) }
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
Task { await viewModel.deletePage(id: page.id) }
} label: {
Label(L("book.delete"), systemImage: "trash")
}
}
}
} header: {
Label(chapter.name, systemImage: "list.bullet.rectangle")
}
}
// Uncategorised pages
let uncategorised = pagesWithoutChapter
if !uncategorised.isEmpty {
Section(L("book.pages")) {
ForEach(uncategorised) { page in
NavigationLink(value: page) {
ContentRowView(
icon: "doc.text",
name: page.name,
description: "",
updatedAt: page.updatedAt
)
}
.accessibilityLabel("Page: \(page.name)")
.contextMenu { pageContextMenu(page) }
.swipeActions(edge: .trailing) {
Button(role: .destructive) {
Task { await viewModel.deletePage(id: page.id) }
} label: {
Label(L("book.delete"), systemImage: "trash")
}
}
}
}
}
}
.listStyle(.insetGrouped)
.refreshable { await viewModel.loadChaptersAndPages(bookId: book.id) }
.animation(.easeInOut, value: viewModel.pages.map(\.id))
}
}
.navigationTitle(book.name)
.navigationBarTitleDisplayMode(.large)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Menu {
Button { showNewPage = true } label: {
Label(L("book.newpage"), systemImage: "doc.text.badge.plus")
}
Button { showNewChapter = true } label: {
Label(L("book.newchapter"), systemImage: "list.bullet.rectangle.portrait.badge.plus")
}
} label: {
Image(systemName: "plus")
}
.accessibilityLabel("Add content")
}
}
.sheet(isPresented: $showNewPage) {
PageEditorView(mode: .create(bookId: book.id))
}
.sheet(isPresented: $showNewChapter) {
NewChapterView(bookId: book.id)
}
.task { await viewModel.loadChaptersAndPages(bookId: book.id) }
}
private func pagesInChapter(_ chapterId: Int) -> [PageDTO] {
viewModel.pages.filter { $0.chapterId == chapterId }
.sorted { $0.priority < $1.priority }
}
private var pagesWithoutChapter: [PageDTO] {
viewModel.pages.filter { $0.chapterId == nil }
.sorted { $0.priority < $1.priority }
}
@ViewBuilder
private func pageContextMenu(_ page: PageDTO) -> some View {
NavigationLink(value: page) {
Label(L("book.open"), systemImage: "arrow.up.right.square")
}
Button {
let url = "\(UserDefaults.standard.string(forKey: "serverURL") ?? "")/books/\(book.slug)/page/\(page.slug)"
let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil)
if let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first?.keyWindow {
window.rootViewController?.present(activity, animated: true)
}
} label: {
Label(L("book.sharelink"), systemImage: "square.and.arrow.up")
}
Divider()
Button(role: .destructive) {
Task { await viewModel.deletePage(id: page.id) }
} label: {
Label(L("book.delete"), systemImage: "trash")
}
}
}
// MARK: - New Chapter sheet
struct NewChapterView: View {
let bookId: Int
@State private var name = ""
@State private var chapterDescription = ""
@State private var isSaving = false
@State private var error: BookStackError?
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section(L("chapter.details")) {
TextField(L("chapter.new.name"), text: $name)
TextField(L("chapter.new.description"), text: $chapterDescription)
}
if let error {
Section {
ErrorBanner(error: error)
.listRowBackground(Color.clear)
}
}
}
.navigationTitle(L("chapter.new.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L("chapter.cancel")) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(L("chapter.create")) {
Task { await save() }
}
.disabled(name.isEmpty || isSaving)
}
}
}
}
private func save() async {
isSaving = true
error = nil
do {
_ = try await BookStackAPI.shared.createChapter(bookId: bookId, name: name, chapterDescription: chapterDescription)
dismiss()
} catch let e as BookStackError {
self.error = e
} catch {
self.error = .unknown(error.localizedDescription)
}
isSaving = false
}
}
#Preview {
NavigationStack {
BookDetailView(book: .mock)
}
}
@@ -0,0 +1,63 @@
import SwiftUI
struct BooksInShelfView: View {
let shelf: ShelfDTO
@State private var viewModel = LibraryViewModel()
var body: some View {
Group {
if viewModel.isLoadingBooks && viewModel.books.isEmpty {
LoadingView(message: L("shelf.loading"))
} else if viewModel.books.isEmpty && viewModel.error == nil {
EmptyStateView(
systemImage: "book.closed",
title: L("shelf.empty.title"),
message: L("shelf.empty.message"),
actionTitle: L("library.refresh"),
action: { Task { await viewModel.loadBooksForShelf(shelfId: shelf.id) } }
)
} else {
List {
if let error = viewModel.error {
ErrorBanner(error: error) {
Task { await viewModel.loadBooksForShelf(shelfId: shelf.id) }
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
ForEach(viewModel.books) { book in
NavigationLink(value: book) {
ContentRowView(
icon: "book.closed",
name: book.name,
description: book.description,
updatedAt: book.updatedAt
)
}
.accessibilityLabel("Book: \(book.name)")
.contextMenu {
Button(role: .destructive) {
Task { await viewModel.deleteBook(id: book.id) }
} label: {
Label(L("book.delete"), systemImage: "trash")
}
}
}
}
.listStyle(.insetGrouped)
.refreshable { await viewModel.loadBooksForShelf(shelfId: shelf.id) }
.animation(.easeInOut, value: viewModel.books.map(\.id))
}
}
.navigationTitle(shelf.name)
.navigationBarTitleDisplayMode(.large)
.task { await viewModel.loadBooksForShelf(shelfId: shelf.id) }
}
}
#Preview {
NavigationStack {
BooksInShelfView(shelf: .mock)
}
}
+108
View File
@@ -0,0 +1,108 @@
import SwiftUI
struct LibraryView: View {
@State private var viewModel = LibraryViewModel()
@Environment(ConnectivityMonitor.self) private var connectivity
var body: some View {
NavigationStack {
Group {
if viewModel.isLoadingShelves && viewModel.shelves.isEmpty {
LoadingView(message: L("library.loading"))
} else if viewModel.shelves.isEmpty && viewModel.error == nil {
EmptyStateView(
systemImage: "books.vertical",
title: L("library.empty.title"),
message: L("library.empty.message"),
actionTitle: L("library.refresh"),
action: { Task { await viewModel.loadShelves() } }
)
} else {
List {
if let error = viewModel.error {
ErrorBanner(error: error) {
Task { await viewModel.loadShelves() }
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
Section(L("library.shelves")) {
ForEach(viewModel.shelves) { shelf in
NavigationLink(value: shelf) {
ContentRowView(
icon: "books.vertical",
name: shelf.name,
description: shelf.description,
updatedAt: shelf.updatedAt
)
}
.accessibilityLabel("Shelf: \(shelf.name)")
}
}
}
.listStyle(.insetGrouped)
.refreshable { await viewModel.loadShelves() }
.animation(.easeInOut, value: viewModel.shelves.map(\.id))
}
}
.navigationTitle(L("library.title"))
.navigationDestination(for: ShelfDTO.self) { shelf in
BooksInShelfView(shelf: shelf)
}
.navigationDestination(for: BookDTO.self) { book in
BookDetailView(book: book)
}
.navigationDestination(for: PageDTO.self) { page in
PageReaderView(page: page)
}
.safeAreaInset(edge: .top) {
if !connectivity.isConnected {
OfflineBanner()
}
}
}
.task { await viewModel.loadShelves() }
}
}
// MARK: - Reusable content row
struct ContentRowView: View {
let icon: String
let name: String
let description: String
let updatedAt: Date
var body: some View {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.title3)
.foregroundStyle(.blue)
.frame(width: 32)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 3) {
Text(name)
.font(.body.weight(.medium))
if !description.isEmpty {
Text(description)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Text(String(format: L("library.updated"), updatedAt.bookStackRelative))
.font(.caption)
.foregroundStyle(.tertiary)
}
}
.padding(.vertical, 4)
}
}
#Preview {
LibraryView()
.environment(ConnectivityMonitor.shared)
}
+30
View File
@@ -0,0 +1,30 @@
import SwiftUI
struct MainTabView: View {
@Environment(ConnectivityMonitor.self) private var connectivity
var body: some View {
TabView {
Tab(L("tab.library"), systemImage: "books.vertical") {
LibraryView()
}
Tab(L("tab.search"), systemImage: "magnifyingglass") {
SearchView()
}
Tab(L("tab.create"), systemImage: "square.and.pencil") {
NewContentView()
}
Tab(L("tab.settings"), systemImage: "gear") {
SettingsView()
}
}
}
}
#Preview {
MainTabView()
.environment(ConnectivityMonitor.shared)
}
@@ -0,0 +1,350 @@
import SwiftUI
struct NewContentView: View {
@State private var showNewPage = false
@State private var showNewBook = false
@State private var showNewShelf = false
var body: some View {
NavigationStack {
List {
Section(L("create.section")) {
NewContentButton(
icon: "square.and.pencil",
title: L("create.page.title"),
description: L("create.page.desc")
) {
showNewPage = true
}
NewContentButton(
icon: "book.closed.fill",
title: L("create.book.title"),
description: L("create.book.desc")
) {
showNewBook = true
}
NewContentButton(
icon: "books.vertical.fill",
title: L("create.shelf.title"),
description: L("create.shelf.desc")
) {
showNewShelf = true
}
}
}
.listStyle(.insetGrouped)
.navigationTitle(L("create.title"))
.sheet(isPresented: $showNewPage) {
BookPickerThenEditorView()
}
.sheet(isPresented: $showNewBook) {
NewBookView()
}
.sheet(isPresented: $showNewShelf) {
NewShelfView()
}
}
}
}
// MARK: - New Content Button
struct NewContentButton: View {
let icon: String
let title: String
let description: String
let action: () -> Void
var body: some View {
Button(action: action) {
HStack(spacing: 14) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(.blue)
.frame(width: 40)
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 2) {
Text(title)
.font(.body.weight(.medium))
.foregroundStyle(.primary)
Text(description)
.font(.footnote)
.foregroundStyle(.secondary)
}
Spacer()
Image(systemName: "chevron.right")
.font(.footnote)
.foregroundStyle(.tertiary)
}
.padding(.vertical, 6)
}
.accessibilityLabel(title)
.accessibilityHint(description)
}
}
// MARK: - New Page: Shelf Book Editor
struct BookPickerThenEditorView: View {
@State private var shelves: [ShelfDTO] = []
@State private var allBooks: [BookDTO] = []
@State private var filteredBooks: [BookDTO] = []
@State private var isLoading = true
@State private var isLoadingBooks = false
@State private var selectedShelf: ShelfDTO? = nil
@State private var selectedBook: BookDTO? = nil
@State private var openEditor = false
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
if isLoading {
Section {
HStack {
ProgressView()
Text(L("create.page.loading"))
.foregroundStyle(.secondary)
.padding(.leading, 8)
}
}
} else {
// Shelf picker (optional filter)
Section {
Picker(L("create.shelf.title"), selection: $selectedShelf) {
Text(L("create.any.shelf")).tag(ShelfDTO?.none)
ForEach(shelves) { shelf in
Text(shelf.name).tag(ShelfDTO?.some(shelf))
}
}
.onChange(of: selectedShelf) { _, shelf in
selectedBook = nil
Task { await filterBooks(for: shelf) }
}
} header: {
Text(L("create.page.filter.shelf"))
}
// Book picker (required)
Section {
if isLoadingBooks {
HStack {
ProgressView().controlSize(.small)
Text(L("create.loading.books"))
.foregroundStyle(.secondary)
.padding(.leading, 8)
}
} else if filteredBooks.isEmpty {
Text(selectedShelf == nil ? L("create.page.nobooks") : L("create.page.nobooks.shelf"))
.foregroundStyle(.secondary)
} else {
Picker(L("create.page.book.header"), selection: $selectedBook) {
Text(L("create.page.book.select")).tag(BookDTO?.none)
ForEach(filteredBooks) { book in
Text(book.name).tag(BookDTO?.some(book))
}
}
}
} header: {
Text(L("create.page.book.header"))
} footer: {
Text(L("create.page.book.footer"))
}
}
}
.navigationTitle(L("create.page.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L("create.cancel")) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(L("create.page.next")) { openEditor = true }
.disabled(selectedBook == nil)
}
}
.task {
async let fetchedShelves = (try? await BookStackAPI.shared.fetchShelves()) ?? []
async let fetchedBooks = (try? await BookStackAPI.shared.fetchBooks()) ?? []
shelves = await fetchedShelves
allBooks = await fetchedBooks
filteredBooks = allBooks
isLoading = false
}
.navigationDestination(isPresented: $openEditor) {
if let book = selectedBook {
PageEditorView(mode: .create(bookId: book.id))
}
}
}
}
private func filterBooks(for shelf: ShelfDTO?) async {
guard let shelf else {
filteredBooks = allBooks
return
}
isLoadingBooks = true
let shelfDetail = try? await BookStackAPI.shared.fetchShelf(id: shelf.id)
let shelfBookIds = Set(shelfDetail?.books.map(\.id) ?? [])
filteredBooks = allBooks.filter { shelfBookIds.contains($0.id) }
isLoadingBooks = false
}
}
// MARK: - New Book View
struct NewBookView: View {
@State private var name = ""
@State private var bookDescription = ""
@State private var shelves: [ShelfDTO] = []
@State private var selectedShelf: ShelfDTO? = nil
@State private var isLoadingShelves = true
@State private var isSaving = false
@State private var error: BookStackError?
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section(L("create.book.details")) {
TextField(L("create.book.name"), text: $name)
TextField(L("create.description"), text: $bookDescription)
}
Section {
if isLoadingShelves {
HStack {
ProgressView().controlSize(.small)
Text(L("create.book.shelf.loading"))
.foregroundStyle(.secondary)
.padding(.leading, 8)
}
} else {
Picker(L("create.shelf.title"), selection: $selectedShelf) {
Text(L("create.book.shelf.none")).tag(ShelfDTO?.none)
ForEach(shelves) { shelf in
Text(shelf.name).tag(ShelfDTO?.some(shelf))
}
}
}
} header: {
Text(L("create.book.shelf.header"))
} footer: {
Text(L("create.book.shelf.footer"))
}
if let error {
Section {
ErrorBanner(error: error)
.listRowBackground(Color.clear)
}
}
}
.navigationTitle(L("create.book.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L("create.cancel")) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
if isSaving {
ProgressView().controlSize(.small)
} else {
Button(L("create.create")) {
Task { await save() }
}
.disabled(name.isEmpty)
}
}
}
.task {
shelves = (try? await BookStackAPI.shared.fetchShelves()) ?? []
isLoadingShelves = false
}
}
}
private func save() async {
isSaving = true
error = nil
do {
let book = try await BookStackAPI.shared.createBook(name: name, bookDescription: bookDescription)
// If a shelf was selected, append this book to it
if let shelf = selectedShelf {
// Fetch current book IDs on the shelf, then append the new one
let current = (try? await BookStackAPI.shared.fetchShelf(id: shelf.id))?.books.map(\.id) ?? []
try await BookStackAPI.shared.updateShelfBooks(shelfId: shelf.id, bookIds: current + [book.id])
}
dismiss()
} catch let e as BookStackError {
self.error = e
} catch {
self.error = .unknown(error.localizedDescription)
}
isSaving = false
}
}
// MARK: - New Shelf View
struct NewShelfView: View {
@State private var name = ""
@State private var shelfDescription = ""
@State private var isSaving = false
@State private var error: BookStackError?
@Environment(\.dismiss) private var dismiss
var body: some View {
NavigationStack {
Form {
Section(L("create.shelf.details")) {
TextField(L("create.shelf.name"), text: $name)
TextField(L("create.description"), text: $shelfDescription)
}
if let error {
Section {
ErrorBanner(error: error)
.listRowBackground(Color.clear)
}
}
}
.navigationTitle(L("create.shelf.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L("create.cancel")) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(L("create.create")) {
Task { await save() }
}
.disabled(name.isEmpty || isSaving)
}
}
}
}
private func save() async {
isSaving = true
error = nil
do {
_ = try await BookStackAPI.shared.createShelf(name: name, shelfDescription: shelfDescription)
dismiss()
} catch let e as BookStackError {
self.error = e
} catch {
self.error = .unknown(error.localizedDescription)
}
isSaving = false
}
}
#Preview {
NewContentView()
}
@@ -0,0 +1,619 @@
import SwiftUI
struct OnboardingView: View {
@State private var viewModel = OnboardingViewModel()
@State private var langManager = LanguageManager.shared
var body: some View {
Group {
if viewModel.isComplete {
Color.clear
} else {
TabView(selection: $viewModel.currentStep) {
LanguageStepView(onNext: viewModel.advance)
.tag(OnboardingViewModel.Step.language)
WelcomeStepView(onNext: viewModel.advance)
.tag(OnboardingViewModel.Step.welcome)
ServerURLStepView(viewModel: viewModel)
.tag(OnboardingViewModel.Step.serverURL)
APITokenStepView(viewModel: viewModel)
.tag(OnboardingViewModel.Step.apiToken)
VerifyStepView(viewModel: viewModel)
.tag(OnboardingViewModel.Step.verify)
ReadyStepView(onComplete: viewModel.completeOnboarding)
.tag(OnboardingViewModel.Step.ready)
}
.tabViewStyle(.page(indexDisplayMode: .always))
.indexViewStyle(.page(backgroundDisplayMode: .always))
.animation(.easeInOut, value: viewModel.currentStep)
.ignoresSafeArea()
}
}
.environment(langManager)
}
}
// MARK: - Step 0: Language
struct LanguageStepView: View {
let onNext: () -> Void
@State private var selected: LanguageManager.Language = LanguageManager.shared.current
var body: some View {
VStack(spacing: 32) {
Spacer()
VStack(spacing: 16) {
ZStack {
Circle()
.fill(.blue.opacity(0.12))
.frame(width: 120, height: 120)
Image(systemName: "globe")
.font(.system(size: 52))
.foregroundStyle(.blue)
}
VStack(spacing: 8) {
Text(L("onboarding.language.title"))
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
Text(L("onboarding.language.subtitle"))
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
VStack(spacing: 12) {
ForEach(LanguageManager.Language.allCases) { lang in
Button {
selected = lang
LanguageManager.shared.set(lang)
} label: {
HStack(spacing: 14) {
Text(lang.flag)
.font(.title2)
Text(lang.displayName)
.font(.body.weight(.medium))
.foregroundStyle(.primary)
Spacer()
if selected == lang {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.blue)
}
}
.padding()
.background(
selected == lang
? Color.blue.opacity(0.1)
: Color(.secondarySystemBackground),
in: RoundedRectangle(cornerRadius: 12)
)
.overlay(
RoundedRectangle(cornerRadius: 12)
.stroke(selected == lang ? Color.blue : Color.clear, lineWidth: 1.5)
)
}
.accessibilityLabel(lang.displayName)
}
}
.padding(.horizontal, 32)
Spacer()
Button(action: onNext) {
Text(L("onboarding.welcome.cta"))
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal, 32)
.padding(.bottom, 48)
}
.padding()
}
}
// MARK: - Step 1: Welcome
struct WelcomeStepView: View {
let onNext: () -> Void
var body: some View {
VStack(spacing: 32) {
Spacer()
VStack(spacing: 16) {
ZStack {
Circle()
.fill(.blue.opacity(0.12))
.frame(width: 120, height: 120)
Image(systemName: "books.vertical.fill")
.font(.system(size: 52))
.foregroundStyle(.blue)
}
VStack(spacing: 8) {
Text(L("onboarding.welcome.title"))
.font(.largeTitle.bold())
.multilineTextAlignment(.center)
Text(L("onboarding.welcome.subtitle"))
.font(.title3)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
Spacer()
VStack(spacing: 12) {
Button(action: onNext) {
Text(L("onboarding.welcome.cta"))
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.accessibilityLabel(L("onboarding.welcome.cta"))
}
.padding(.horizontal, 32)
.padding(.bottom, 48)
}
.padding()
}
}
// MARK: - Step 2: Server URL
struct ServerURLStepView: View {
@Bindable var viewModel: OnboardingViewModel
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text(L("onboarding.server.title"))
.font(.largeTitle.bold())
Text(L("onboarding.server.subtitle"))
.font(.body)
.foregroundStyle(.secondary)
}
VStack(alignment: .leading, spacing: 8) {
HStack {
Image(systemName: "globe")
.foregroundStyle(.secondary)
TextField(L("onboarding.server.placeholder"), text: $viewModel.serverURLInput)
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
}
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
if let error = viewModel.serverURLError {
Label(error, systemImage: "exclamationmark.circle.fill")
.font(.footnote)
.foregroundStyle(.red)
}
if viewModel.isHTTP {
Label(L("onboarding.server.warning.http"), systemImage: "exclamationmark.shield.fill")
.font(.footnote)
.foregroundStyle(.orange)
}
}
Button(action: {
if viewModel.validateServerURL() {
viewModel.advance()
}
}) {
Text(L("onboarding.server.next"))
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.top, 8)
}
.padding(32)
}
}
}
// MARK: - Step 3: API Token
struct APITokenStepView: View {
@Bindable var viewModel: OnboardingViewModel
@State private var showTokenId = false
@State private var showTokenSecret = false
@State private var showHelp = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 24) {
VStack(alignment: .leading, spacing: 8) {
Text(L("onboarding.token.title"))
.font(.largeTitle.bold())
Text(L("onboarding.token.subtitle"))
.font(.body)
.foregroundStyle(.secondary)
}
// Help accordion
DisclosureGroup(L("onboarding.token.help"), isExpanded: $showHelp) {
VStack(alignment: .leading, spacing: 6) {
Text(L("onboarding.token.help.steps"))
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding(.top, 8)
}
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
// Token ID field
VStack(alignment: .leading, spacing: 6) {
Label(L("onboarding.token.id.label"), systemImage: "key.fill")
.font(.subheadline.bold())
HStack {
Group {
if showTokenId {
TextField(L("onboarding.token.id.label"), text: $viewModel.tokenIdInput)
} else {
SecureField(L("onboarding.token.id.label"), text: $viewModel.tokenIdInput)
}
}
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
if UIPasteboard.general.hasStrings {
Button(action: {
viewModel.tokenIdInput = UIPasteboard.general.string ?? ""
}) {
Image(systemName: "clipboard")
.foregroundStyle(.secondary)
}
.accessibilityLabel(L("onboarding.token.paste"))
}
Button(action: { showTokenId.toggle() }) {
Image(systemName: showTokenId ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
}
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
}
// Token Secret field
VStack(alignment: .leading, spacing: 6) {
Label(L("onboarding.token.secret.label"), systemImage: "lock.fill")
.font(.subheadline.bold())
HStack {
Group {
if showTokenSecret {
TextField(L("onboarding.token.secret.label"), text: $viewModel.tokenSecretInput)
} else {
SecureField(L("onboarding.token.secret.label"), text: $viewModel.tokenSecretInput)
}
}
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
if UIPasteboard.general.hasStrings {
Button(action: {
viewModel.tokenSecretInput = UIPasteboard.general.string ?? ""
}) {
Image(systemName: "clipboard")
.foregroundStyle(.secondary)
}
.accessibilityLabel(L("onboarding.token.paste"))
}
Button(action: { showTokenSecret.toggle() }) {
Image(systemName: showTokenSecret ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
}
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
}
Button(action: {
Task { await viewModel.verifyAndSave() }
}) {
Text(L("onboarding.token.verify"))
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(canVerify ? .blue : Color.secondary)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.disabled(!canVerify)
.padding(.top, 8)
}
.padding(32)
}
}
private var canVerify: Bool {
!viewModel.tokenIdInput.isEmpty && !viewModel.tokenSecretInput.isEmpty
}
}
// MARK: - Step 4: Verify
struct VerifyStepView: View {
@Bindable var viewModel: OnboardingViewModel
var body: some View {
VStack(spacing: 32) {
Spacer()
VStack(spacing: 24) {
// Title
VStack(spacing: 8) {
Image(systemName: verifyIcon)
.font(.system(size: 56))
.foregroundStyle(verifyIconColor)
.animation(.spring, value: verifyIcon)
Text(verifyTitle)
.font(.title2.bold())
.multilineTextAlignment(.center)
}
// Phase step rows
VStack(alignment: .leading, spacing: 12) {
VerifyPhaseRow(
label: L("onboarding.verify.phase.server"),
state: serverPhaseState
)
VerifyPhaseRow(
label: L("onboarding.verify.phase.token"),
state: tokenPhaseState
)
}
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
// Error message
if let errorMsg = errorMessage {
Text(errorMsg)
.font(.footnote)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
.padding(.horizontal)
}
}
Spacer()
// Action buttons when failed
if case .failed = viewModel.verifyPhase {
HStack(spacing: 16) {
Button(L("onboarding.verify.goback")) {
viewModel.goBack()
}
.buttonStyle(.bordered)
Button(L("onboarding.verify.retry")) {
Task { await viewModel.verifyAndSave() }
}
.buttonStyle(.borderedProminent)
}
.padding(.bottom, 32)
}
}
.padding()
// Auto-start verification when arriving at this step
.task {
if case .idle = viewModel.verifyPhase {
await viewModel.verifyAndSave()
}
}
}
// MARK: - Derived state
private var verifyIcon: String {
switch viewModel.verifyPhase {
case .idle, .checkingServer, .serverOK, .checkingToken:
return "arrow.trianglehead.2.clockwise"
case .done:
return "checkmark.circle.fill"
case .failed:
return "xmark.circle.fill"
}
}
private var verifyIconColor: Color {
switch viewModel.verifyPhase {
case .done: return .green
case .failed: return .red
default: return .blue
}
}
private var verifyTitle: String {
switch viewModel.verifyPhase {
case .idle: return L("onboarding.verify.ready")
case .checkingServer: return L("onboarding.verify.reaching")
case .serverOK(let n): return String(format: L("onboarding.verify.found"), n)
case .checkingToken: return L("onboarding.verify.checking")
case .done(let n, _): return String(format: L("onboarding.verify.connected"), n)
case .failed(let p, _):
return p == "server" ? L("onboarding.verify.server.failed") : L("onboarding.verify.token.failed")
}
}
private var serverPhaseState: VerifyPhaseRow.RowState {
switch viewModel.verifyPhase {
case .idle: return .pending
case .checkingServer: return .loading
case .serverOK, .checkingToken, .done: return .success
case .failed(let p, _):
return p == "server" ? .failure : .success
}
}
private var tokenPhaseState: VerifyPhaseRow.RowState {
switch viewModel.verifyPhase {
case .idle, .checkingServer, .serverOK: return .pending
case .checkingToken: return .loading
case .done: return .success
case .failed(let p, _):
return p == "token" ? .failure : .pending
}
}
private var errorMessage: String? {
guard case .failed(_, let error) = viewModel.verifyPhase else { return nil }
return error.errorDescription
}
}
// MARK: - Phase Row
struct VerifyPhaseRow: View {
enum RowState { case pending, loading, success, failure }
let label: String
let state: RowState
var body: some View {
HStack(spacing: 12) {
Group {
switch state {
case .pending:
Image(systemName: "circle")
.foregroundStyle(.tertiary)
case .loading:
ProgressView()
.controlSize(.small)
case .success:
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(.green)
case .failure:
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.red)
}
}
.frame(width: 20, height: 20)
Text(label)
.font(.subheadline)
.foregroundStyle(state == .pending ? Color.secondary : Color.primary)
}
}
}
// MARK: - Step 5: Ready
struct ReadyStepView: View {
let onComplete: () -> Void
@State private var animate = false
var body: some View {
VStack(spacing: 32) {
Spacer()
VStack(spacing: 16) {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 80))
.foregroundStyle(.green)
.scaleEffect(animate ? 1.0 : 0.5)
.opacity(animate ? 1.0 : 0.0)
.animation(.spring(response: 0.5, dampingFraction: 0.6), value: animate)
VStack(spacing: 8) {
Text(L("onboarding.ready.title"))
.font(.largeTitle.bold())
Text(L("onboarding.ready.subtitle"))
.font(.body)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
}
}
// Feature preview cards
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 16) {
FeatureCard(icon: "books.vertical.fill", title: L("onboarding.ready.feature.library"), description: L("onboarding.ready.feature.library.desc"))
FeatureCard(icon: "magnifyingglass", title: L("onboarding.ready.feature.search"), description: L("onboarding.ready.feature.search.desc"))
FeatureCard(icon: "pencil", title: L("onboarding.ready.feature.create"), description: L("onboarding.ready.feature.create.desc"))
}
.padding(.horizontal, 32)
}
Spacer()
Button(action: onComplete) {
Text(L("onboarding.ready.cta"))
.font(.headline)
.frame(maxWidth: .infinity)
.padding()
.background(.blue)
.foregroundStyle(.white)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
.padding(.horizontal, 32)
.padding(.bottom, 48)
}
.onAppear { animate = true }
}
}
struct FeatureCard: View {
let icon: String
let title: String
let description: String
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Image(systemName: icon)
.font(.title2)
.foregroundStyle(.blue)
Text(title)
.font(.headline)
Text(description)
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding()
.frame(width: 180, alignment: .leading)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
}
}
#Preview("Welcome") {
WelcomeStepView(onNext: {})
}
#Preview("Onboarding Full") {
OnboardingView()
}
+302
View File
@@ -0,0 +1,302 @@
import SwiftUI
import WebKit
struct PageReaderView: View {
let page: PageDTO
@State private var webPage = WebPage()
@State private var fullPage: PageDTO? = nil
@State private var isLoadingPage = false
@State private var comments: [CommentDTO] = []
@State private var isLoadingComments = false
@State private var showEditor = false
@State private var isFetchingForEdit = false
@State private var newComment = ""
@State private var isPostingComment = false
@AppStorage("showComments") private var showComments = true
@Environment(\.colorScheme) private var colorScheme
/// The resolved page full version once fetched, summary until then.
private var resolvedPage: PageDTO { fullPage ?? page }
private var serverURL: String {
UserDefaults.standard.string(forKey: "serverURL") ?? "https://bookstack.example.com"
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
// Page header
VStack(alignment: .leading, spacing: 4) {
Text(resolvedPage.name)
.font(.largeTitle.bold())
.padding(.horizontal)
.padding(.top)
Text(String(format: L("library.updated"), resolvedPage.updatedAt.bookStackRelative))
.font(.footnote)
.foregroundStyle(.secondary)
.padding(.horizontal)
.padding(.bottom, 8)
}
// Web content
WebView(webPage)
.frame(minHeight: 400)
.frame(maxWidth: .infinity)
Divider()
.padding(.top)
// Comments section (hidden when user disabled in Settings)
if showComments {
DisclosureGroup {
commentsContent
} label: {
Label(String(format: L("reader.comments"), comments.count), systemImage: "bubble.left.and.bubble.right")
.font(.headline)
}
.padding()
}
}
}
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .primaryAction) {
Button {
Task { await openEditor() }
} label: {
if isFetchingForEdit {
ProgressView().scaleEffect(0.7)
} else {
Image(systemName: "pencil")
}
}
.disabled(isFetchingForEdit)
.accessibilityLabel(L("reader.edit"))
}
ToolbarItem(placement: .topBarTrailing) {
Button {
let url = "\(serverURL)/books/\(resolvedPage.bookId)/page/\(resolvedPage.slug)"
let activity = UIActivityViewController(activityItems: [url], applicationActivities: nil)
if let window = UIApplication.shared.connectedScenes
.compactMap({ $0 as? UIWindowScene })
.first?.keyWindow {
window.rootViewController?.present(activity, animated: true)
}
} label: {
Image(systemName: "square.and.arrow.up")
}
.accessibilityLabel(L("reader.share"))
}
}
.sheet(isPresented: $showEditor) {
if let fullPage {
PageEditorView(mode: .edit(page: fullPage))
}
}
.task(id: page.id) {
await loadFullPage()
await loadComments()
}
.onChange(of: colorScheme) {
loadContent()
}
}
// MARK: - Comments UI
@ViewBuilder
private var commentsContent: some View {
VStack(alignment: .leading, spacing: 12) {
if isLoadingComments {
ProgressView()
.frame(maxWidth: .infinity)
.padding()
} else if comments.isEmpty {
Text(L("reader.comments.empty"))
.font(.footnote)
.foregroundStyle(.secondary)
.padding()
} else {
ForEach(comments) { comment in
CommentRow(comment: comment)
Divider()
}
}
// New comment input
HStack(alignment: .bottom, spacing: 8) {
TextField(L("reader.comment.placeholder"), text: $newComment, axis: .vertical)
.lineLimit(1...4)
.padding(10)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 10))
Button {
Task { await postComment() }
} label: {
Image(systemName: "paperplane.fill")
.foregroundStyle(newComment.isEmpty ? Color.secondary : Color.blue)
}
.disabled(newComment.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || isPostingComment)
.accessibilityLabel("Post comment")
}
.padding(.top, 8)
}
.padding(.top, 8)
}
// MARK: - Helpers
private func loadFullPage() async {
isLoadingPage = true
AppLog(.info, "Loading page content for '\(page.name)' (id: \(page.id))", category: "Reader")
do {
fullPage = try await BookStackAPI.shared.fetchPage(id: page.id)
AppLog(.info, "Page content loaded for '\(page.name)'", category: "Reader")
} catch {
AppLog(.warning, "Failed to load full page '\(page.name)': \(error.localizedDescription) — using summary", category: "Reader")
fullPage = page
}
isLoadingPage = false
loadContent()
}
private func openEditor() async {
// Full page is already fetched by loadFullPage; if still loading, wait briefly
if fullPage == nil {
isFetchingForEdit = true
fullPage = (try? await BookStackAPI.shared.fetchPage(id: page.id)) ?? page
isFetchingForEdit = false
}
showEditor = true
}
private func loadContent() {
let html = buildHTML(content: resolvedPage.html ?? "<p><em>\(L("reader.nocontent"))</em></p>")
webPage.load(html: html, baseURL: URL(string: serverURL) ?? URL(string: "https://bookstack.example.com")!)
}
private func loadComments() async {
isLoadingComments = true
comments = (try? await BookStackAPI.shared.fetchComments(pageId: page.id)) ?? []
isLoadingComments = false
}
private func postComment() async {
let text = newComment.trimmingCharacters(in: .whitespacesAndNewlines)
guard !text.isEmpty else { return }
isPostingComment = true
AppLog(.info, "Posting comment on page '\(page.name)' (id: \(page.id))", category: "Reader")
do {
let comment = try await BookStackAPI.shared.postComment(pageId: page.id, text: text)
comments.append(comment)
newComment = ""
AppLog(.info, "Comment posted on '\(page.name)'", category: "Reader")
} catch {
AppLog(.error, "Failed to post comment on '\(page.name)': \(error.localizedDescription)", category: "Reader")
}
isPostingComment = false
}
private func buildHTML(content: String) -> String {
let isDark = colorScheme == .dark
let bg = isDark ? "#1c1c1e" : "#ffffff"
let fg = isDark ? "#f2f2f7" : "#000000"
let codeBg = isDark ? "#2c2c2e" : "#f2f2f7"
let border = isDark ? "#3a3a3c" : "#d1d1d6"
return """
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="color-scheme" content="light dark">
<style>
* { box-sizing: border-box; }
body {
font-family: -apple-system, sans-serif;
font-size: 16px;
line-height: 1.6;
padding: 16px;
max-width: 800px;
margin: 0 auto;
background-color: \(bg);
color: \(fg);
}
img { max-width: 100%; height: auto; border-radius: 6px; }
pre {
overflow-x: auto;
padding: 14px;
background: \(codeBg);
border-radius: 10px;
font-size: 14px;
}
code {
font-family: "SF Mono", "Menlo", monospace;
font-size: 14px;
background: \(codeBg);
padding: 2px 5px;
border-radius: 4px;
}
pre code { background: transparent; padding: 0; }
a { color: #0a84ff; text-decoration: none; }
a:hover { text-decoration: underline; }
blockquote {
border-left: 4px solid #0a84ff;
margin: 16px 0;
padding: 8px 16px;
color: #8e8e93;
}
table { border-collapse: collapse; width: 100%; margin: 16px 0; }
th, td { border: 1px solid \(border); padding: 10px 12px; text-align: left; }
th { background: \(codeBg); font-weight: 600; }
h1, h2, h3, h4, h5, h6 { line-height: 1.3; }
hr { border: none; border-top: 1px solid \(border); margin: 24px 0; }
</style>
</head>
<body>\(content)</body>
</html>
"""
}
}
// MARK: - Comment Row
struct CommentRow: View {
let comment: CommentDTO
var body: some View {
HStack(alignment: .top, spacing: 10) {
Circle()
.fill(.blue.gradient)
.frame(width: 32, height: 32)
.overlay {
Text(comment.createdBy.name.prefix(1).uppercased())
.font(.footnote.bold())
.foregroundStyle(.white)
}
.accessibilityHidden(true)
VStack(alignment: .leading, spacing: 4) {
HStack {
Text(comment.createdBy.name)
.font(.footnote.bold())
Spacer()
Text(comment.createdAt.bookStackRelative)
.font(.caption)
.foregroundStyle(.tertiary)
}
Text(comment.text)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.padding(.vertical, 4)
}
}
#Preview {
NavigationStack {
PageReaderView(page: .mock)
}
}
+254
View File
@@ -0,0 +1,254 @@
import SwiftUI
struct SearchView: View {
@State private var viewModel = SearchViewModel()
// Navigation destination after fetching the full object
enum Destination {
case page(PageDTO)
case book(BookDTO)
case shelf(ShelfDTO)
}
@State private var destination: Destination? = nil
@State private var isLoadingDestination = false
@State private var navigationActive = false
@State private var loadError: BookStackError? = nil
var body: some View {
NavigationStack {
Group {
if viewModel.query.isEmpty {
recentSearchesView
} else if viewModel.isSearching {
LoadingView(message: "Searching…")
} else if viewModel.results.isEmpty {
ContentUnavailableView.search(text: viewModel.query)
} else {
resultsList
}
}
.navigationTitle(L("search.title"))
.searchable(text: $viewModel.query, prompt: L("search.prompt"))
.onChange(of: viewModel.query) { viewModel.onQueryChanged() }
.toolbar {
if !SearchResultDTO.ContentType.allCases.isEmpty {
ToolbarItem(placement: .topBarTrailing) {
filterMenu
}
}
}
.navigationDestination(isPresented: $navigationActive) {
switch destination {
case .page(let page): PageReaderView(page: page)
case .book(let book): BookDetailView(book: book)
case .shelf(let shelf): BooksInShelfView(shelf: shelf)
case nil: EmptyView()
}
}
.overlay {
if isLoadingDestination {
ZStack {
Color.black.opacity(0.15).ignoresSafeArea()
ProgressView(L("search.opening"))
.padding(20)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
}
}
}
.alert(L("search.error.title"), isPresented: Binding(
get: { loadError != nil },
set: { if !$0 { loadError = nil } }
)) {
Button("OK", role: .cancel) {}
} message: {
Text(loadError?.errorDescription ?? "Unknown error")
}
}
}
// MARK: - Results List
private var resultsList: some View {
List {
if let error = viewModel.error {
ErrorBanner(error: error) {
viewModel.onFilterChanged()
}
.listRowBackground(Color.clear)
.listRowSeparator(.hidden)
}
ForEach(viewModel.results) { result in
Button {
Task { await open(result) }
} label: {
SearchResultRow(result: result)
}
.foregroundStyle(.primary)
}
}
.listStyle(.insetGrouped)
.animation(.easeInOut, value: viewModel.results.map(\.id))
}
// MARK: - Open result
private func open(_ result: SearchResultDTO) async {
isLoadingDestination = true
loadError = nil
do {
switch result.type {
case .page:
let page = try await BookStackAPI.shared.fetchPage(id: result.id)
destination = .page(page)
case .book:
let book = try await BookStackAPI.shared.fetchBook(id: result.id)
destination = .book(book)
case .shelf:
let shelfDetail = try await BookStackAPI.shared.fetchShelf(id: result.id)
// Build a ShelfDTO from the ShelfBooksResponse for BooksInShelfView
let shelf = ShelfDTO(
id: shelfDetail.id,
name: shelfDetail.name,
slug: "",
description: "",
createdAt: Date(),
updatedAt: Date(),
cover: nil
)
destination = .shelf(shelf)
case .chapter:
// Navigate to the chapter's parent book
let chapter = try await BookStackAPI.shared.fetchChapter(id: result.id)
let book = try await BookStackAPI.shared.fetchBook(id: chapter.bookId)
destination = .book(book)
}
navigationActive = true
} catch let e as BookStackError {
loadError = e
} catch {
loadError = .unknown(error.localizedDescription)
}
isLoadingDestination = false
}
// MARK: - Recent Searches
private var recentSearchesView: some View {
Group {
if viewModel.recentSearches.isEmpty {
EmptyStateView(
systemImage: "magnifyingglass",
title: "Search BookStack",
message: "Search for pages, books, chapters, and shelves across your entire knowledge base."
)
} else {
List {
Section {
ForEach(viewModel.recentSearches, id: \.self) { recent in
Button {
viewModel.query = recent
viewModel.onQueryChanged()
} label: {
Label(recent, systemImage: "clock")
.foregroundStyle(.primary)
}
}
} header: {
HStack {
Text(L("search.recent"))
Spacer()
Button(L("search.recent.clear")) {
viewModel.clearRecentSearches()
}
.font(.footnote)
}
}
}
.listStyle(.insetGrouped)
}
}
}
// MARK: - Filter Menu
private var filterMenu: some View {
Menu {
Button {
viewModel.selectedTypeFilter = nil
viewModel.onFilterChanged()
} label: {
HStack {
Text("All")
if viewModel.selectedTypeFilter == nil {
Image(systemName: "checkmark")
}
}
}
Divider()
ForEach(SearchResultDTO.ContentType.allCases, id: \.self) { type in
Button {
viewModel.selectedTypeFilter = type
viewModel.onFilterChanged()
} label: {
HStack {
Label(type.displayName, systemImage: type.systemImage)
if viewModel.selectedTypeFilter == type {
Image(systemName: "checkmark")
}
}
}
}
} label: {
Image(systemName: viewModel.selectedTypeFilter == nil
? "line.3.horizontal.decrease.circle"
: "line.3.horizontal.decrease.circle.fill")
}
.accessibilityLabel(L("search.filter"))
}
}
// MARK: - Search Result Row
struct SearchResultRow: View {
let result: SearchResultDTO
var body: some View {
VStack(alignment: .leading, spacing: 4) {
HStack {
Image(systemName: result.type.systemImage)
.font(.footnote)
.foregroundStyle(.blue)
.frame(width: 18)
.accessibilityHidden(true)
Text(result.name)
.font(.body.weight(.medium))
Spacer()
Text(result.type.rawValue.capitalized)
.font(.caption)
.foregroundStyle(.secondary)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(Color(.secondarySystemBackground), in: Capsule())
}
if let preview = result.preview, !preview.isEmpty {
Text(preview)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(2)
.padding(.leading, 26)
}
}
.padding(.vertical, 4)
.accessibilityLabel("\(result.type.rawValue.capitalized): \(result.name)")
.accessibilityHint(result.preview ?? "")
}
}
#Preview {
SearchView()
}
+191
View File
@@ -0,0 +1,191 @@
import SwiftUI
import SafariServices
struct SettingsView: View {
@AppStorage("onboardingComplete") private var onboardingComplete = false
@AppStorage("syncWiFiOnly") private var syncWiFiOnly = true
@AppStorage("showComments") private var showComments = true
@AppStorage("appTheme") private var appTheme = "system"
@State private var serverURL = UserDefaults.standard.string(forKey: "serverURL") ?? ""
@State private var showSignOutAlert = false
@State private var isSyncing = false
@State private var lastSynced = UserDefaults.standard.object(forKey: "lastSynced") as? Date
@State private var showSafari: URL? = nil
@State private var selectedLanguage: LanguageManager.Language = LanguageManager.shared.current
private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
var body: some View {
NavigationStack {
Form {
// Language section
Section {
ForEach(LanguageManager.Language.allCases) { lang in
Button {
selectedLanguage = lang
LanguageManager.shared.set(lang)
} label: {
HStack {
Text(lang.flag)
Text(lang.displayName)
.foregroundStyle(.primary)
Spacer()
if selectedLanguage == lang {
Image(systemName: "checkmark")
.foregroundStyle(.blue)
}
}
}
}
} header: {
Text(L("settings.language.header"))
}
// Appearance section
Section(L("settings.appearance")) {
Picker(L("settings.appearance.theme"), selection: $appTheme) {
Text(L("settings.appearance.theme.system")).tag("system")
Text(L("settings.appearance.theme.light")).tag("light")
Text(L("settings.appearance.theme.dark")).tag("dark")
}
.pickerStyle(.segmented)
}
// Account section
Section(L("settings.account")) {
HStack {
Image(systemName: "person.circle.fill")
.font(.title)
.foregroundStyle(.blue)
VStack(alignment: .leading) {
Text(L("settings.account.connected"))
.font(.headline)
Text(serverURL)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
.padding(.vertical, 4)
Button {
UIPasteboard.general.string = serverURL
} label: {
Label(L("settings.account.copyurl"), systemImage: "doc.on.doc")
}
Button(role: .destructive) {
showSignOutAlert = true
} label: {
Label(L("settings.account.signout"), systemImage: "rectangle.portrait.and.arrow.right")
}
}
// Reader section
Section(L("settings.reader")) {
Toggle(L("settings.reader.showcomments"), isOn: $showComments)
}
// Sync section
Section(L("settings.sync")) {
Toggle(L("settings.sync.wifionly"), isOn: $syncWiFiOnly)
Button {
Task { await syncNow() }
} label: {
HStack {
Label(L("settings.sync.now"), systemImage: "arrow.clockwise")
if isSyncing {
Spacer()
ProgressView()
}
}
}
.disabled(isSyncing)
if let lastSynced {
LabeledContent(L("settings.sync.lastsynced")) {
Text(lastSynced.bookStackFormattedWithTime)
.foregroundStyle(.secondary)
}
}
}
// About section
Section(L("settings.about")) {
LabeledContent(L("settings.about.version"), value: "\(appVersion) (\(buildNumber))")
Button {
showSafari = URL(string: "https://www.bookstackapp.com/docs")
} label: {
Label(L("settings.about.docs"), systemImage: "book.pages")
}
Button {
showSafari = URL(string: "https://github.com/BookStackApp/BookStack/issues")
} label: {
Label(L("settings.about.issue"), systemImage: "exclamationmark.bubble")
}
Text(L("settings.about.credit"))
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.navigationTitle(L("settings.title"))
.alert(L("settings.signout.alert.title"), isPresented: $showSignOutAlert) {
Button(L("settings.signout.alert.confirm"), role: .destructive) { signOut() }
Button(L("settings.signout.alert.cancel"), role: .cancel) {}
} message: {
Text(L("settings.signout.alert.message"))
}
.sheet(item: $showSafari) { url in
SafariView(url: url)
.ignoresSafeArea()
}
}
}
// MARK: - Actions
private func signOut() {
Task {
try? await KeychainService.shared.deleteCredentials()
UserDefaults.standard.removeObject(forKey: "serverURL")
UserDefaults.standard.removeObject(forKey: "lastSynced")
onboardingComplete = false
}
}
private func syncNow() async {
isSyncing = true
// SyncService.shared.syncAll() requires ModelContext from environment
// For now just update last synced date
try? await Task.sleep(for: .seconds(1))
let now = Date()
UserDefaults.standard.set(now, forKey: "lastSynced")
lastSynced = now
isSyncing = false
}
}
// MARK: - Safari View
struct SafariView: UIViewControllerRepresentable {
let url: URL
func makeUIViewController(context: Context) -> SFSafariViewController {
SFSafariViewController(url: url)
}
func updateUIViewController(_ vc: SFSafariViewController, context: Context) {}
}
extension URL: @retroactive Identifiable {
public var id: String { absoluteString }
}
#Preview {
SettingsView()
}
@@ -0,0 +1,36 @@
import SwiftUI
struct EmptyStateView: View {
let systemImage: String
let title: String
let message: String
var actionTitle: String? = nil
var action: (() -> Void)? = nil
var body: some View {
ContentUnavailableView(
label: {
Label(title, systemImage: systemImage)
},
description: {
Text(message)
},
actions: {
if let actionTitle, let action {
Button(actionTitle, action: action)
.buttonStyle(.borderedProminent)
}
}
)
}
}
#Preview {
EmptyStateView(
systemImage: "books.vertical",
title: "No Shelves",
message: "Your library is empty. Create a shelf in BookStack to get started.",
actionTitle: "Refresh",
action: {}
)
}
+45
View File
@@ -0,0 +1,45 @@
import SwiftUI
struct ErrorBanner: View {
let error: BookStackError
var onRetry: (() -> Void)? = nil
var onSettings: (() -> Void)? = nil
var body: some View {
HStack(spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.font(.title3)
Text(error.errorDescription ?? "An unknown error occurred.")
.font(.callout)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
Spacer()
if case .unauthorized = error, let onSettings {
Button("Settings", action: onSettings)
.buttonStyle(.bordered)
.controlSize(.small)
} else if let onRetry {
Button("Retry", action: onRetry)
.buttonStyle(.bordered)
.controlSize(.small)
}
}
.padding()
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 12))
.accessibilityElement(children: .combine)
.accessibilityLabel("Error: \(error.errorDescription ?? "Unknown error")")
}
}
#Preview {
VStack(spacing: 16) {
ErrorBanner(error: .networkUnavailable, onRetry: {})
ErrorBanner(error: .unauthorized)
ErrorBanner(error: .timeout, onRetry: {})
}
.padding()
}
+22
View File
@@ -0,0 +1,22 @@
import SwiftUI
struct LoadingView: View {
var message: String = "Loading..."
var body: some View {
VStack(spacing: 12) {
ProgressView()
.controlSize(.large)
Text(message)
.foregroundStyle(.secondary)
.font(.subheadline)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.accessibilityElement(children: .combine)
.accessibilityLabel(message)
}
}
#Preview {
LoadingView(message: "Loading shelves...")
}
+25
View File
@@ -0,0 +1,25 @@
import SwiftUI
struct OfflineBanner: View {
var body: some View {
HStack(spacing: 8) {
Image(systemName: "wifi.slash")
.font(.footnote.bold())
Text(L("offline.banner"))
.font(.footnote.bold())
}
.frame(maxWidth: .infinity)
.padding(.vertical, 8)
.padding(.horizontal)
.background(Color.orange)
.foregroundStyle(.white)
.accessibilityLabel("Offline. Showing cached content.")
}
}
#Preview {
VStack {
OfflineBanner()
Spacer()
}
}
+39 -3
View File
@@ -6,12 +6,48 @@
//
import SwiftUI
import SwiftData
@main
struct bookstaxApp: App {
var body: some Scene {
WindowGroup {
ContentView()
@AppStorage("onboardingComplete") private var onboardingComplete = false
@AppStorage("appTheme") private var appTheme = "system"
private var preferredColorScheme: ColorScheme? {
switch appTheme {
case "light": return .light
case "dark": return .dark
default: return nil // nil = follow system
}
}
let sharedModelContainer: ModelContainer = {
let schema = Schema([CachedShelf.self, CachedBook.self, CachedPage.self])
let config = ModelConfiguration(schema: schema, isStoredInMemoryOnly: false)
do {
return try ModelContainer(for: schema, configurations: [config])
} catch {
fatalError("Could not create ModelContainer: \(error)")
}
}()
init() {
AppLog(.info, "BookStax launched", category: "App")
}
var body: some Scene {
WindowGroup {
Group {
if onboardingComplete {
MainTabView()
.environment(ConnectivityMonitor.shared)
} else {
OnboardingView()
}
}
.preferredColorScheme(preferredColorScheme)
}
.modelContainer(sharedModelContainer)
}
}
+184
View File
@@ -0,0 +1,184 @@
// MARK: - Onboarding
"onboarding.language.title" = "Sprache wählen";
"onboarding.language.subtitle" = "Du kannst die Sprache später in den Einstellungen ändern.";
"onboarding.welcome.title" = "Willkommen bei BookStax";
"onboarding.welcome.subtitle" = "Deine selbst gehostete Wissensdatenbank,\njetzt in deiner Tasche.";
"onboarding.welcome.cta" = "Los geht's";
"onboarding.server.title" = "Wo ist dein BookStack?";
"onboarding.server.subtitle" = "Gib die Webadresse deiner BookStack-Installation ein. Das ist dieselbe URL, die du auch im Browser verwendest.";
"onboarding.server.placeholder" = "https://wiki.meinefirma.de";
"onboarding.server.next" = "Weiter";
"onboarding.server.error.empty" = "Bitte gib die Adresse deines BookStack-Servers ein.";
"onboarding.server.error.invalid" = "Das sieht nicht nach einer gültigen Webadresse aus. Versuche z.B. https://bookstack.example.com";
"onboarding.server.warning.http" = "Unverschlüsselte Verbindung erkannt. Deine Daten könnten im Netzwerk sichtbar sein.";
"onboarding.token.title" = "Mit API-Token verbinden";
"onboarding.token.subtitle" = "BookStack verwendet API-Tokens für sicheren Zugriff. Du musst einen in deinem BookStack-Profil erstellen.";
"onboarding.token.help" = "Wie bekomme ich einen Token?";
"onboarding.token.help.steps" = "1. Öffne deine BookStack-Instanz im Browser\n2. Klicke auf dein Avatar → Profil bearbeiten\n3. Scrolle zu \"API-Tokens\" → tippe auf \"Token erstellen\"\n4. Gib einen Namen (z.B. \"Mein iPhone\") und ein Ablaufdatum ein\n5. Kopiere Token-ID und Secret — sie werden nicht erneut angezeigt\n\nHinweis: Dein Konto benötigt die Berechtigung \"Zugriff auf System-API\". Kontaktiere deinen Administrator, wenn du den Bereich API-Tokens nicht siehst.";
"onboarding.token.id.label" = "Token-ID";
"onboarding.token.secret.label" = "Token-Secret";
"onboarding.token.paste" = "Aus Zwischenablage einfügen";
"onboarding.token.verify" = "Verbindung testen";
"onboarding.verify.ready" = "Bereit zur Prüfung";
"onboarding.verify.reaching" = "Server wird erreicht…";
"onboarding.verify.found" = "%@ gefunden";
"onboarding.verify.checking" = "Zugangsdaten werden geprüft…";
"onboarding.verify.connected" = "Verbunden mit %@";
"onboarding.verify.server.failed" = "Server nicht erreichbar";
"onboarding.verify.token.failed" = "Authentifizierung fehlgeschlagen";
"onboarding.verify.phase.server" = "Server erreichen";
"onboarding.verify.phase.token" = "Token prüfen";
"onboarding.verify.goback" = "Zurück";
"onboarding.verify.retry" = "Erneut versuchen";
"onboarding.ready.title" = "Alles bereit!";
"onboarding.ready.subtitle" = "BookStax ist mit deiner Wissensdatenbank verbunden.";
"onboarding.ready.cta" = "Zur Bibliothek";
"onboarding.ready.feature.library" = "Bibliothek durchsuchen";
"onboarding.ready.feature.library.desc" = "Regale, Bücher, Kapitel und Seiten navigieren";
"onboarding.ready.feature.search" = "Alles suchen";
"onboarding.ready.feature.search.desc" = "Inhalte sofort finden";
"onboarding.ready.feature.create" = "Erstellen & Bearbeiten";
"onboarding.ready.feature.create.desc" = "Neue Seiten in Markdown schreiben";
// MARK: - Tabs
"tab.library" = "Bibliothek";
"tab.search" = "Suche";
"tab.create" = "Erstellen";
"tab.settings" = "Einstellungen";
// MARK: - Library
"library.title" = "Bibliothek";
"library.loading" = "Bibliothek wird geladen…";
"library.empty.title" = "Keine Regale";
"library.empty.message" = "Deine Bibliothek ist leer. Erstelle ein Regal in BookStack, um zu beginnen.";
"library.refresh" = "Aktualisieren";
"library.shelves" = "Regale";
"library.updated" = "Aktualisiert %@";
// MARK: - Shelf
"shelf.loading" = "Bücher werden geladen…";
"shelf.empty.title" = "Keine Bücher";
"shelf.empty.message" = "Dieses Regal enthält noch keine Bücher.";
// MARK: - Book
"book.loading" = "Inhalte werden geladen…";
"book.empty.title" = "Kein Inhalt";
"book.empty.message" = "Dieses Buch hat noch keine Kapitel oder Seiten.";
"book.addpage" = "Seite hinzufügen";
"book.newpage" = "Neue Seite";
"book.newchapter" = "Neues Kapitel";
"book.pages" = "Seiten";
"book.delete" = "Löschen";
"book.open" = "Öffnen";
"book.sharelink" = "Link teilen";
"book.addcontent" = "Inhalt hinzufügen";
// MARK: - Chapter
"chapter.new.title" = "Neues Kapitel";
"chapter.new.name" = "Kapitelname";
"chapter.new.description" = "Beschreibung (optional)";
"chapter.details" = "Kapiteldetails";
"chapter.cancel" = "Abbrechen";
"chapter.create" = "Erstellen";
// MARK: - Page Reader
"reader.comments" = "Kommentare (%d)";
"reader.comments.empty" = "Noch keine Kommentare. Schreib den ersten!";
"reader.comment.placeholder" = "Kommentar hinzufügen…";
"reader.comment.post" = "Kommentar absenden";
"reader.edit" = "Seite bearbeiten";
"reader.share" = "Seite teilen";
"reader.nocontent" = "Kein Inhalt";
// MARK: - Editor
"editor.new.title" = "Neue Seite";
"editor.edit.title" = "Seite bearbeiten";
"editor.title.placeholder" = "Seitentitel";
"editor.tab.write" = "Schreiben";
"editor.tab.preview" = "Vorschau";
"editor.save" = "Speichern";
"editor.cancel" = "Abbrechen";
"editor.discard.title" = "Änderungen verwerfen?";
"editor.discard.message" = "Deine Änderungen gehen verloren.";
"editor.discard.confirm" = "Verwerfen";
"editor.discard.keepediting" = "Weiter bearbeiten";
// MARK: - Search
"search.title" = "Suche";
"search.prompt" = "Bücher, Seiten, Kapitel suchen…";
"search.recent" = "Letzte Suchen";
"search.recent.clear" = "Löschen";
"search.filter" = "Suchergebnisse filtern";
"search.opening" = "Wird geöffnet…";
"search.error.title" = "Ergebnis konnte nicht geöffnet werden";
// MARK: - New Content
"create.title" = "Erstellen";
"create.section" = "Was möchtest du erstellen?";
"create.page.title" = "Neue Seite";
"create.page.desc" = "Neue Seite in Markdown schreiben";
"create.book.title" = "Neues Buch";
"create.book.desc" = "Seiten in einem Buch organisieren";
"create.shelf.title" = "Neues Regal";
"create.shelf.desc" = "Bücher in einem Regal gruppieren";
"create.book.name" = "Buchname";
"create.book.details" = "Buchdetails";
"create.book.shelf.header" = "Regal (optional)";
"create.book.shelf.footer" = "Weise dieses Buch einem Regal zu, um deine Bibliothek zu organisieren.";
"create.book.shelf.none" = "Keines";
"create.book.shelf.loading" = "Regale werden geladen…";
"create.shelf.name" = "Regalname";
"create.shelf.details" = "Regaldetails";
"create.page.filter.shelf" = "Nach Regal filtern (optional)";
"create.page.book.header" = "Buch";
"create.page.book.footer" = "Die Seite wird in diesem Buch erstellt.";
"create.page.book.select" = "Buch auswählen…";
"create.page.nobooks" = "Keine Bücher verfügbar";
"create.page.nobooks.shelf" = "Keine Bücher in diesem Regal";
"create.page.loading" = "Wird geladen…";
"create.page.next" = "Weiter";
"create.description" = "Beschreibung (optional)";
"create.cancel" = "Abbrechen";
"create.create" = "Erstellen";
"create.loading.books" = "Bücher werden geladen…";
"create.any.shelf" = "Beliebiges Regal";
// MARK: - Settings
"settings.title" = "Einstellungen";
"settings.account" = "Konto";
"settings.account.connected" = "Verbunden";
"settings.account.copyurl" = "Server-URL kopieren";
"settings.account.signout" = "Abmelden";
"settings.signout.alert.title" = "Abmelden";
"settings.signout.alert.message" = "Dadurch werden deine gespeicherten Zugangsdaten entfernt und du musst dich erneut anmelden.";
"settings.signout.alert.confirm" = "Abmelden";
"settings.signout.alert.cancel" = "Abbrechen";
"settings.sync" = "Synchronisierung";
"settings.sync.wifionly" = "Nur über WLAN synchronisieren";
"settings.sync.now" = "Jetzt synchronisieren";
"settings.sync.lastsynced" = "Zuletzt synchronisiert";
"settings.about" = "Über";
"settings.about.version" = "Version";
"settings.about.docs" = "BookStack-Dokumentation";
"settings.about.issue" = "Problem melden";
"settings.about.credit" = "BookStack ist Open-Source-Software von Dan Brown.";
"settings.language" = "Sprache";
"settings.language.header" = "Sprache";
// MARK: - Offline
"offline.banner" = "Du bist offline zwischengespeicherte Inhalte werden angezeigt";
// MARK: - Appearance
"settings.appearance" = "Erscheinungsbild";
"settings.appearance.theme" = "Design";
"settings.appearance.theme.system" = "System";
"settings.appearance.theme.light" = "Hell";
"settings.appearance.theme.dark" = "Dunkel";
// MARK: - Reader Settings
"settings.reader" = "Leser";
"settings.reader.showcomments" = "Kommentare anzeigen";
// MARK: - Common
"common.ok" = "OK";
"common.error" = "Unbekannter Fehler";
+184
View File
@@ -0,0 +1,184 @@
// MARK: - Onboarding
"onboarding.language.title" = "Choose Your Language";
"onboarding.language.subtitle" = "You can change this later in Settings.";
"onboarding.welcome.title" = "Welcome to BookStax";
"onboarding.welcome.subtitle" = "Your self-hosted knowledge base,\nnow in your pocket.";
"onboarding.welcome.cta" = "Get Started";
"onboarding.server.title" = "Where is your BookStack?";
"onboarding.server.subtitle" = "Enter the web address of your BookStack installation. This is the same URL you use in your browser.";
"onboarding.server.placeholder" = "https://wiki.mycompany.com";
"onboarding.server.next" = "Next";
"onboarding.server.error.empty" = "Please enter your BookStack server address.";
"onboarding.server.error.invalid" = "That doesn't look like a valid web address. Try something like https://bookstack.example.com";
"onboarding.server.warning.http" = "Non-encrypted connection detected. Your data may be visible on the network.";
"onboarding.token.title" = "Connect with an API Token";
"onboarding.token.subtitle" = "BookStack uses API tokens for secure access. You'll need to create one in your BookStack profile.";
"onboarding.token.help" = "How do I get a token?";
"onboarding.token.help.steps" = "1. Open your BookStack instance in a browser\n2. Click your avatar → Edit Profile\n3. Scroll to \"API Tokens\" → tap \"Create Token\"\n4. Set a name (e.g. \"My iPhone\") and expiry date\n5. Copy the Token ID and Secret — they won't be shown again\n\nNote: Your account needs the \"Access System API\" permission. Contact your admin if you don't see the API Tokens section.";
"onboarding.token.id.label" = "Token ID";
"onboarding.token.secret.label" = "Token Secret";
"onboarding.token.paste" = "Paste from clipboard";
"onboarding.token.verify" = "Verify Connection";
"onboarding.verify.ready" = "Ready to verify";
"onboarding.verify.reaching" = "Reaching server…";
"onboarding.verify.found" = "Found %@";
"onboarding.verify.checking" = "Checking credentials…";
"onboarding.verify.connected" = "Connected to %@";
"onboarding.verify.server.failed" = "Server unreachable";
"onboarding.verify.token.failed" = "Authentication failed";
"onboarding.verify.phase.server" = "Reaching server";
"onboarding.verify.phase.token" = "Verifying token";
"onboarding.verify.goback" = "Go Back";
"onboarding.verify.retry" = "Try Again";
"onboarding.ready.title" = "You're all set!";
"onboarding.ready.subtitle" = "BookStax is connected to your knowledge base.";
"onboarding.ready.cta" = "Open My Library";
"onboarding.ready.feature.library" = "Browse Library";
"onboarding.ready.feature.library.desc" = "Navigate shelves, books, chapters and pages";
"onboarding.ready.feature.search" = "Search Everything";
"onboarding.ready.feature.search.desc" = "Find any content instantly";
"onboarding.ready.feature.create" = "Create & Edit";
"onboarding.ready.feature.create.desc" = "Write new pages in Markdown";
// MARK: - Tabs
"tab.library" = "Library";
"tab.search" = "Search";
"tab.create" = "Create";
"tab.settings" = "Settings";
// MARK: - Library
"library.title" = "Library";
"library.loading" = "Loading library…";
"library.empty.title" = "No Shelves";
"library.empty.message" = "Your library is empty. Create a shelf in BookStack to get started.";
"library.refresh" = "Refresh";
"library.shelves" = "Shelves";
"library.updated" = "Updated %@";
// MARK: - Shelf
"shelf.loading" = "Loading books…";
"shelf.empty.title" = "No Books";
"shelf.empty.message" = "This shelf has no books yet.";
// MARK: - Book
"book.loading" = "Loading content…";
"book.empty.title" = "No Content";
"book.empty.message" = "This book has no chapters or pages yet.";
"book.addpage" = "Add Page";
"book.newpage" = "New Page";
"book.newchapter" = "New Chapter";
"book.pages" = "Pages";
"book.delete" = "Delete";
"book.open" = "Open";
"book.sharelink" = "Share Link";
"book.addcontent" = "Add content";
// MARK: - Chapter
"chapter.new.title" = "New Chapter";
"chapter.new.name" = "Chapter name";
"chapter.new.description" = "Description (optional)";
"chapter.details" = "Chapter Details";
"chapter.cancel" = "Cancel";
"chapter.create" = "Create";
// MARK: - Page Reader
"reader.comments" = "Comments (%d)";
"reader.comments.empty" = "No comments yet. Be the first!";
"reader.comment.placeholder" = "Add a comment…";
"reader.comment.post" = "Post comment";
"reader.edit" = "Edit page";
"reader.share" = "Share page";
"reader.nocontent" = "No content";
// MARK: - Editor
"editor.new.title" = "New Page";
"editor.edit.title" = "Edit Page";
"editor.title.placeholder" = "Page title";
"editor.tab.write" = "Write";
"editor.tab.preview" = "Preview";
"editor.save" = "Save";
"editor.cancel" = "Cancel";
"editor.discard.title" = "Discard Changes?";
"editor.discard.message" = "Your changes will be lost.";
"editor.discard.confirm" = "Discard";
"editor.discard.keepediting" = "Keep Editing";
// MARK: - Search
"search.title" = "Search";
"search.prompt" = "Search books, pages, chapters…";
"search.recent" = "Recent Searches";
"search.recent.clear" = "Clear";
"search.filter" = "Filter search results";
"search.opening" = "Opening…";
"search.error.title" = "Could not open result";
// MARK: - New Content
"create.title" = "Create";
"create.section" = "What would you like to create?";
"create.page.title" = "New Page";
"create.page.desc" = "Write a new page in Markdown";
"create.book.title" = "New Book";
"create.book.desc" = "Organise pages into a book";
"create.shelf.title" = "New Shelf";
"create.shelf.desc" = "Group books into a shelf";
"create.book.name" = "Book name";
"create.book.details" = "Book Details";
"create.book.shelf.header" = "Shelf (optional)";
"create.book.shelf.footer" = "Assign this book to a shelf to keep your library organised.";
"create.book.shelf.none" = "None";
"create.book.shelf.loading" = "Loading shelves…";
"create.shelf.name" = "Shelf name";
"create.shelf.details" = "Shelf Details";
"create.page.filter.shelf" = "Filter by Shelf (optional)";
"create.page.book.header" = "Book";
"create.page.book.footer" = "The page will be created inside this book.";
"create.page.book.select" = "Select a book…";
"create.page.nobooks" = "No books available";
"create.page.nobooks.shelf" = "No books in this shelf";
"create.page.loading" = "Loading…";
"create.page.next" = "Next";
"create.description" = "Description (optional)";
"create.cancel" = "Cancel";
"create.create" = "Create";
"create.loading.books" = "Loading books…";
"create.any.shelf" = "Any shelf";
// MARK: - Settings
"settings.title" = "Settings";
"settings.account" = "Account";
"settings.account.connected" = "Connected";
"settings.account.copyurl" = "Copy Server URL";
"settings.account.signout" = "Sign Out";
"settings.signout.alert.title" = "Sign Out";
"settings.signout.alert.message" = "This will remove your saved credentials and require you to sign in again.";
"settings.signout.alert.confirm" = "Sign Out";
"settings.signout.alert.cancel" = "Cancel";
"settings.sync" = "Sync";
"settings.sync.wifionly" = "Sync on Wi-Fi only";
"settings.sync.now" = "Sync Now";
"settings.sync.lastsynced" = "Last synced";
"settings.about" = "About";
"settings.about.version" = "Version";
"settings.about.docs" = "BookStack Documentation";
"settings.about.issue" = "Report an Issue";
"settings.about.credit" = "BookStack is open-source software by Dan Brown.";
"settings.language" = "Language";
"settings.language.header" = "Language";
// MARK: - Offline
"offline.banner" = "You're offline — showing cached content";
// MARK: - Appearance
"settings.appearance" = "Appearance";
"settings.appearance.theme" = "Theme";
"settings.appearance.theme.system" = "System";
"settings.appearance.theme.light" = "Light";
"settings.appearance.theme.dark" = "Dark";
// MARK: - Reader Settings
"settings.reader" = "Reader";
"settings.reader.showcomments" = "Show Comments";
// MARK: - Common
"common.ok" = "OK";
"common.error" = "Unknown error";
+184
View File
@@ -0,0 +1,184 @@
// MARK: - Onboarding
"onboarding.language.title" = "Elige tu idioma";
"onboarding.language.subtitle" = "Puedes cambiarlo más tarde en Ajustes.";
"onboarding.welcome.title" = "Bienvenido a BookStax";
"onboarding.welcome.subtitle" = "Tu base de conocimiento autoalojada,\nahora en tu bolsillo.";
"onboarding.welcome.cta" = "Comenzar";
"onboarding.server.title" = "¿Dónde está tu BookStack?";
"onboarding.server.subtitle" = "Introduce la dirección web de tu instalación de BookStack. Es la misma URL que usas en el navegador.";
"onboarding.server.placeholder" = "https://wiki.miempresa.com";
"onboarding.server.next" = "Siguiente";
"onboarding.server.error.empty" = "Por favor, introduce la dirección de tu servidor BookStack.";
"onboarding.server.error.invalid" = "Eso no parece una dirección web válida. Prueba algo como https://bookstack.example.com";
"onboarding.server.warning.http" = "Conexión sin cifrar detectada. Tus datos podrían ser visibles en la red.";
"onboarding.token.title" = "Conectar con un token API";
"onboarding.token.subtitle" = "BookStack usa tokens API para un acceso seguro. Deberás crear uno en tu perfil de BookStack.";
"onboarding.token.help" = "¿Cómo obtengo un token?";
"onboarding.token.help.steps" = "1. Abre tu instancia de BookStack en un navegador\n2. Haz clic en tu avatar → Editar perfil\n3. Desplázate a \"Tokens API\" → toca \"Crear token\"\n4. Ponle un nombre (p.ej. \"Mi iPhone\") y fecha de expiración\n5. Copia el ID y el secreto del token — no se mostrarán de nuevo\n\nNota: Tu cuenta necesita el permiso \"Acceder a la API del sistema\". Contacta a tu administrador si no ves la sección de tokens API.";
"onboarding.token.id.label" = "ID del token";
"onboarding.token.secret.label" = "Secreto del token";
"onboarding.token.paste" = "Pegar desde el portapapeles";
"onboarding.token.verify" = "Verificar conexión";
"onboarding.verify.ready" = "Listo para verificar";
"onboarding.verify.reaching" = "Contactando servidor…";
"onboarding.verify.found" = "%@ encontrado";
"onboarding.verify.checking" = "Verificando credenciales…";
"onboarding.verify.connected" = "Conectado a %@";
"onboarding.verify.server.failed" = "Servidor no disponible";
"onboarding.verify.token.failed" = "Error de autenticación";
"onboarding.verify.phase.server" = "Contactar servidor";
"onboarding.verify.phase.token" = "Verificar token";
"onboarding.verify.goback" = "Volver";
"onboarding.verify.retry" = "Reintentar";
"onboarding.ready.title" = "¡Todo listo!";
"onboarding.ready.subtitle" = "BookStax está conectado a tu base de conocimiento.";
"onboarding.ready.cta" = "Abrir mi biblioteca";
"onboarding.ready.feature.library" = "Explorar biblioteca";
"onboarding.ready.feature.library.desc" = "Navega estantes, libros, capítulos y páginas";
"onboarding.ready.feature.search" = "Buscar todo";
"onboarding.ready.feature.search.desc" = "Encuentra cualquier contenido al instante";
"onboarding.ready.feature.create" = "Crear y editar";
"onboarding.ready.feature.create.desc" = "Escribe nuevas páginas en Markdown";
// MARK: - Tabs
"tab.library" = "Biblioteca";
"tab.search" = "Búsqueda";
"tab.create" = "Crear";
"tab.settings" = "Ajustes";
// MARK: - Library
"library.title" = "Biblioteca";
"library.loading" = "Cargando biblioteca…";
"library.empty.title" = "Sin estantes";
"library.empty.message" = "Tu biblioteca está vacía. Crea un estante en BookStack para empezar.";
"library.refresh" = "Actualizar";
"library.shelves" = "Estantes";
"library.updated" = "Actualizado %@";
// MARK: - Shelf
"shelf.loading" = "Cargando libros…";
"shelf.empty.title" = "Sin libros";
"shelf.empty.message" = "Este estante aún no tiene libros.";
// MARK: - Book
"book.loading" = "Cargando contenido…";
"book.empty.title" = "Sin contenido";
"book.empty.message" = "Este libro aún no tiene capítulos ni páginas.";
"book.addpage" = "Añadir página";
"book.newpage" = "Nueva página";
"book.newchapter" = "Nuevo capítulo";
"book.pages" = "Páginas";
"book.delete" = "Eliminar";
"book.open" = "Abrir";
"book.sharelink" = "Compartir enlace";
"book.addcontent" = "Añadir contenido";
// MARK: - Chapter
"chapter.new.title" = "Nuevo capítulo";
"chapter.new.name" = "Nombre del capítulo";
"chapter.new.description" = "Descripción (opcional)";
"chapter.details" = "Detalles del capítulo";
"chapter.cancel" = "Cancelar";
"chapter.create" = "Crear";
// MARK: - Page Reader
"reader.comments" = "Comentarios (%d)";
"reader.comments.empty" = "Aún no hay comentarios. ¡Sé el primero!";
"reader.comment.placeholder" = "Añadir un comentario…";
"reader.comment.post" = "Publicar comentario";
"reader.edit" = "Editar página";
"reader.share" = "Compartir página";
"reader.nocontent" = "Sin contenido";
// MARK: - Editor
"editor.new.title" = "Nueva página";
"editor.edit.title" = "Editar página";
"editor.title.placeholder" = "Título de la página";
"editor.tab.write" = "Escribir";
"editor.tab.preview" = "Vista previa";
"editor.save" = "Guardar";
"editor.cancel" = "Cancelar";
"editor.discard.title" = "¿Descartar cambios?";
"editor.discard.message" = "Se perderán todos tus cambios.";
"editor.discard.confirm" = "Descartar";
"editor.discard.keepediting" = "Seguir editando";
// MARK: - Search
"search.title" = "Búsqueda";
"search.prompt" = "Buscar libros, páginas, capítulos…";
"search.recent" = "Búsquedas recientes";
"search.recent.clear" = "Borrar";
"search.filter" = "Filtrar resultados";
"search.opening" = "Abriendo…";
"search.error.title" = "No se pudo abrir el resultado";
// MARK: - New Content
"create.title" = "Crear";
"create.section" = "¿Qué deseas crear?";
"create.page.title" = "Nueva página";
"create.page.desc" = "Escribe una nueva página en Markdown";
"create.book.title" = "Nuevo libro";
"create.book.desc" = "Organiza páginas en un libro";
"create.shelf.title" = "Nuevo estante";
"create.shelf.desc" = "Agrupa libros en un estante";
"create.book.name" = "Nombre del libro";
"create.book.details" = "Detalles del libro";
"create.book.shelf.header" = "Estante (opcional)";
"create.book.shelf.footer" = "Asigna este libro a un estante para mantener organizada tu biblioteca.";
"create.book.shelf.none" = "Ninguno";
"create.book.shelf.loading" = "Cargando estantes…";
"create.shelf.name" = "Nombre del estante";
"create.shelf.details" = "Detalles del estante";
"create.page.filter.shelf" = "Filtrar por estante (opcional)";
"create.page.book.header" = "Libro";
"create.page.book.footer" = "La página se creará dentro de este libro.";
"create.page.book.select" = "Seleccionar un libro…";
"create.page.nobooks" = "No hay libros disponibles";
"create.page.nobooks.shelf" = "No hay libros en este estante";
"create.page.loading" = "Cargando…";
"create.page.next" = "Siguiente";
"create.description" = "Descripción (opcional)";
"create.cancel" = "Cancelar";
"create.create" = "Crear";
"create.loading.books" = "Cargando libros…";
"create.any.shelf" = "Cualquier estante";
// MARK: - Settings
"settings.title" = "Ajustes";
"settings.account" = "Cuenta";
"settings.account.connected" = "Conectado";
"settings.account.copyurl" = "Copiar URL del servidor";
"settings.account.signout" = "Cerrar sesión";
"settings.signout.alert.title" = "Cerrar sesión";
"settings.signout.alert.message" = "Esto eliminará tus credenciales guardadas y requerirá que inicies sesión de nuevo.";
"settings.signout.alert.confirm" = "Cerrar sesión";
"settings.signout.alert.cancel" = "Cancelar";
"settings.sync" = "Sincronización";
"settings.sync.wifionly" = "Sincronizar solo por Wi-Fi";
"settings.sync.now" = "Sincronizar ahora";
"settings.sync.lastsynced" = "Última sincronización";
"settings.about" = "Acerca de";
"settings.about.version" = "Versión";
"settings.about.docs" = "Documentación de BookStack";
"settings.about.issue" = "Reportar un problema";
"settings.about.credit" = "BookStack es software de código abierto de Dan Brown.";
"settings.language" = "Idioma";
"settings.language.header" = "Idioma";
// MARK: - Offline
"offline.banner" = "Estás sin conexión — mostrando contenido en caché";
// MARK: - Appearance
"settings.appearance" = "Apariencia";
"settings.appearance.theme" = "Tema";
"settings.appearance.theme.system" = "Sistema";
"settings.appearance.theme.light" = "Claro";
"settings.appearance.theme.dark" = "Oscuro";
// MARK: - Reader Settings
"settings.reader" = "Lector";
"settings.reader.showcomments" = "Mostrar comentarios";
// MARK: - Common
"common.ok" = "Aceptar";
"common.error" = "Error desconocido";