Multi-Server implementiert

This commit is contained in:
2026-03-22 11:04:52 +01:00
parent 6b3b2db013
commit c4a4833bec
12 changed files with 603 additions and 49 deletions
+137
View File
@@ -0,0 +1,137 @@
import Foundation
import Observation
// MARK: - ServerProfile
struct ServerProfile: Codable, Identifiable, Hashable {
let id: UUID
var name: String
var serverURL: String
}
// MARK: - ServerProfileStore
@Observable
final class ServerProfileStore {
static let shared = ServerProfileStore()
private(set) var profiles: [ServerProfile] = []
private(set) var activeProfileId: UUID?
var activeProfile: ServerProfile? {
profiles.first { $0.id == activeProfileId }
}
private let profilesKey = "serverProfiles"
private let activeIdKey = "activeProfileId"
private init() {
load()
migrate()
}
// MARK: - Add
/// Adds a new profile and persists its credentials synchronously.
func addProfile(_ profile: ServerProfile, tokenId: String, tokenSecret: String) {
KeychainService.saveCredentialsSync(tokenId: tokenId, tokenSecret: tokenSecret, profileId: profile.id)
profiles.append(profile)
save()
activate(profile)
}
// MARK: - Activate
func activate(_ profile: ServerProfile) {
guard let creds = KeychainService.loadCredentialsSync(profileId: profile.id) else { return }
activeProfileId = profile.id
UserDefaults.standard.set(profile.id.uuidString, forKey: activeIdKey)
// Keep legacy "serverURL" key in sync for BookStackAPI
UserDefaults.standard.set(profile.serverURL, forKey: "serverURL")
Task {
await BookStackAPI.shared.configure(
serverURL: profile.serverURL,
tokenId: creds.tokenId,
tokenSecret: creds.tokenSecret
)
}
}
// MARK: - Remove
func remove(_ profile: ServerProfile) {
profiles.removeAll { $0.id == profile.id }
KeychainService.deleteCredentialsSync(profileId: profile.id)
save()
if activeProfileId == profile.id {
activeProfileId = nil
UserDefaults.standard.removeObject(forKey: activeIdKey)
UserDefaults.standard.removeObject(forKey: "serverURL")
}
}
// MARK: - Update
/// Updates the name/URL of an existing profile. Optionally rotates credentials.
func updateProfile(_ profile: ServerProfile, newName: String, newURL: String,
newTokenId: String? = nil, newTokenSecret: String? = nil) {
guard let idx = profiles.firstIndex(where: { $0.id == profile.id }) else { return }
profiles[idx].name = newName
profiles[idx].serverURL = newURL
if let id = newTokenId, let secret = newTokenSecret {
KeychainService.saveCredentialsSync(tokenId: id, tokenSecret: secret, profileId: profile.id)
}
save()
// If this is the active profile, re-configure the API client
if activeProfileId == profile.id {
UserDefaults.standard.set(newURL, forKey: "serverURL")
let creds = (newTokenId != nil && newTokenSecret != nil)
? (tokenId: newTokenId!, tokenSecret: newTokenSecret!)
: KeychainService.loadCredentialsSync(profileId: profile.id) ?? (tokenId: "", tokenSecret: "")
Task {
await BookStackAPI.shared.configure(
serverURL: newURL,
tokenId: creds.tokenId,
tokenSecret: creds.tokenSecret
)
}
}
}
// MARK: - Persistence
private func save() {
guard let data = try? JSONEncoder().encode(profiles) else { return }
UserDefaults.standard.set(data, forKey: profilesKey)
}
private func load() {
if let data = UserDefaults.standard.data(forKey: profilesKey),
let decoded = try? JSONDecoder().decode([ServerProfile].self, from: data) {
profiles = decoded
}
if let idString = UserDefaults.standard.string(forKey: activeIdKey),
let uuid = UUID(uuidString: idString) {
activeProfileId = uuid
}
}
// MARK: - Migration from legacy single-server config
private func migrate() {
guard profiles.isEmpty,
let url = UserDefaults.standard.string(forKey: "serverURL"),
!url.isEmpty,
let tokenId = KeychainService.loadSync(key: "tokenId"),
let tokenSecret = KeychainService.loadSync(key: "tokenSecret") else { return }
let profile = ServerProfile(id: UUID(), name: "BookStack", serverURL: url)
KeychainService.saveCredentialsSync(tokenId: tokenId, tokenSecret: tokenSecret, profileId: profile.id)
profiles.append(profile)
save()
activeProfileId = profile.id
UserDefaults.standard.set(profile.id.uuidString, forKey: activeIdKey)
// Leave legacy "serverURL" in place so BookStackAPI continues working after migration.
AppLog(.info, "Migrated legacy server config to profile \(profile.id)", category: "ServerProfile")
}
}
+62 -1
View File
@@ -26,7 +26,68 @@ actor KeychainService {
try delete(key: tokenSecretKey)
}
// MARK: - Synchronous static helper (for use at app init before async context)
// MARK: - Per-profile credential methods
func saveCredentials(tokenId: String, tokenSecret: String, profileId: UUID) throws {
try save(value: tokenId, key: "tokenId-\(profileId.uuidString)")
try save(value: tokenSecret, key: "tokenSecret-\(profileId.uuidString)")
}
func loadCredentials(profileId: UUID) throws -> (tokenId: String, tokenSecret: String)? {
guard let id = try load(key: "tokenId-\(profileId.uuidString)"),
let secret = try load(key: "tokenSecret-\(profileId.uuidString)") else { return nil }
return (id, secret)
}
func deleteCredentials(profileId: UUID) throws {
try delete(key: "tokenId-\(profileId.uuidString)")
try delete(key: "tokenSecret-\(profileId.uuidString)")
}
// MARK: - Synchronous static helpers (for use at app init / non-async contexts)
static func loadCredentialsSync(profileId: UUID) -> (tokenId: String, tokenSecret: String)? {
guard let tokenId = loadSync(key: "tokenId-\(profileId.uuidString)"),
let tokenSecret = loadSync(key: "tokenSecret-\(profileId.uuidString)") else { return nil }
return (tokenId, tokenSecret)
}
@discardableResult
static func saveSync(value: String, key: String) -> Bool {
let service = "com.bookstax.credentials"
guard let data = value.data(using: .utf8) else { return false }
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
]
return SecItemAdd(addQuery as CFDictionary, nil) == errSecSuccess
}
static func saveCredentialsSync(tokenId: String, tokenSecret: String, profileId: UUID) {
saveSync(value: tokenId, key: "tokenId-\(profileId.uuidString)")
saveSync(value: tokenSecret, key: "tokenSecret-\(profileId.uuidString)")
}
static func deleteCredentialsSync(profileId: UUID) {
let service = "com.bookstax.credentials"
for suffix in ["tokenId-\(profileId.uuidString)", "tokenSecret-\(profileId.uuidString)"] {
let query: [CFString: Any] = [
kSecClass: kSecClassGenericPassword,
kSecAttrService: service,
kSecAttrAccount: suffix
]
SecItemDelete(query as CFDictionary)
}
}
static func loadSync(key: String) -> String? {
let service = "com.bookstax.credentials"
+11
View File
@@ -77,4 +77,15 @@ final class SyncService {
async let booksTask: Void = syncBooks(context: context)
_ = try await (shelvesTask, booksTask)
}
// MARK: - Clear all cached content
func clearAllCache(context: ModelContext) throws {
try context.delete(model: CachedShelf.self)
try context.delete(model: CachedBook.self)
try context.delete(model: CachedPage.self)
try context.save()
UserDefaults.standard.removeObject(forKey: "lastSynced")
AppLog(.info, "Cleared all cached content", category: "SyncService")
}
}
+23 -14
View File
@@ -16,6 +16,7 @@ final class OnboardingViewModel {
var navPath: NavigationPath = NavigationPath()
// Input
var serverNameInput: String = ""
var serverURLInput: String = ""
var tokenIdInput: String = ""
var tokenSecretInput: String = ""
@@ -36,6 +37,10 @@ final class OnboardingViewModel {
// Completion
var isComplete: Bool = false
/// Set to true after successfully adding a server in the Settings "Add Server" flow.
var isAddComplete: Bool = false
/// When true, skips the ready step navigation (used in Add Server sheet).
var isAddServerMode: Bool = false
// MARK: - Navigation
@@ -117,6 +122,11 @@ final class OnboardingViewModel {
let appName = info.appName ?? "BookStack"
verifyPhase = .serverOK(appName: appName)
// Auto-populate server name from API if the user left it blank
if serverNameInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
serverNameInput = appName
}
// Configure the shared API client with validated credentials
await BookStackAPI.shared.configure(serverURL: url, tokenId: tokenId, tokenSecret: tokenSecret)
verifyPhase = .checkingToken
@@ -124,26 +134,25 @@ final class OnboardingViewModel {
// Attempt to fetch user info (non-fatal some installs restrict /api/users)
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
}
// Create and persist a ServerProfile via the shared store
let profile = ServerProfile(
id: UUID(),
name: serverNameInput.trimmingCharacters(in: .whitespacesAndNewlines),
serverURL: url
)
ServerProfileStore.shared.addProfile(profile, tokenId: tokenId, tokenSecret: tokenSecret)
AppLog(.info, "Onboarding complete — connected to \(appName)\(userName.map { " as \($0)" } ?? "")", category: "Onboarding")
verifyPhase = .done(appName: appName, userName: userName)
// Navigate to the ready step
if isAddServerMode {
// In the "Add Server" sheet: signal completion so the sheet dismisses
isAddComplete = true
} else {
// Normal onboarding: navigate to the ready step
navPath.append(Step.ready)
}
}
// MARK: - Complete
@@ -195,6 +195,18 @@ struct ConnectStepView: View {
.foregroundStyle(.secondary)
}
// Server Name field
VStack(alignment: .leading, spacing: 6) {
Label(L("onboarding.server.name.label"), systemImage: "tag")
.font(.subheadline.bold())
TextField(L("onboarding.server.name.placeholder"), text: $viewModel.serverNameInput)
.autocorrectionDisabled()
.textInputAutocapitalization(.words)
.padding()
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 12))
}
// Server URL field
VStack(alignment: .leading, spacing: 8) {
HStack {
@@ -204,6 +216,7 @@ struct ConnectStepView: View {
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.URL)
.onChange(of: viewModel.serverURLInput) {
if case .idle = viewModel.verifyPhase { } else {
viewModel.resetVerification()
@@ -253,6 +266,7 @@ struct ConnectStepView: View {
}
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.username)
.onChange(of: viewModel.tokenIdInput) {
if case .idle = viewModel.verifyPhase { } else {
viewModel.resetVerification()
@@ -293,6 +307,7 @@ struct ConnectStepView: View {
}
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.password)
.onChange(of: viewModel.tokenSecretInput) {
if case .idle = viewModel.verifyPhase { } else {
viewModel.resetVerification()
@@ -350,6 +365,9 @@ struct ConnectStepView: View {
.onDisappear {
verifyTask?.cancel()
}
.onChange(of: viewModel.serverURLInput) { _, _ in
// Clear name hint when URL changes so it re-auto-fills on next verify
}
}
private var canConnect: Bool {
@@ -0,0 +1,28 @@
import SwiftUI
/// Sheet that lets the user connect an additional BookStack server.
/// Reuses ConnectStepView skips the language/welcome steps.
struct AddServerView: View {
@Environment(\.dismiss) private var dismiss
@State private var viewModel: OnboardingViewModel = {
let vm = OnboardingViewModel()
vm.isAddServerMode = true
return vm
}()
var body: some View {
NavigationStack {
ConnectStepView(viewModel: viewModel)
.navigationTitle(L("settings.servers.add"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L("create.cancel")) { dismiss() }
}
}
}
.onChange(of: viewModel.isAddComplete) { _, done in
if done { dismiss() }
}
}
}
@@ -0,0 +1,149 @@
import SwiftUI
struct EditServerView: View {
@Environment(\.dismiss) private var dismiss
@Environment(ServerProfileStore.self) private var profileStore
let profile: ServerProfile
@State private var name: String
@State private var serverURL: String
@State private var tokenId: String = ""
@State private var tokenSecret: String = ""
@State private var showTokenId = false
@State private var showTokenSecret = false
@State private var changeCredentials = false
@State private var isSaving = false
@State private var saveError: String? = nil
init(profile: ServerProfile) {
self.profile = profile
_name = State(initialValue: profile.name)
_serverURL = State(initialValue: profile.serverURL)
}
var body: some View {
NavigationStack {
Form {
// Name & URL
Section(L("onboarding.server.name.label")) {
TextField(L("onboarding.server.name.placeholder"), text: $name)
.autocorrectionDisabled()
.textInputAutocapitalization(.words)
}
Section(L("onboarding.server.title")) {
TextField(L("onboarding.server.placeholder"), text: $serverURL)
.keyboardType(.URL)
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.URL)
}
// Optional credential rotation
Section {
Toggle(L("settings.servers.edit.changecreds"), isOn: $changeCredentials.animation())
} footer: {
Text(L("settings.servers.edit.changecreds.footer"))
.font(.footnote)
}
if changeCredentials {
Section(L("onboarding.token.id.label")) {
HStack {
Group {
if showTokenId {
TextField(L("onboarding.token.id.label"), text: $tokenId)
} else {
SecureField(L("onboarding.token.id.label"), text: $tokenId)
}
}
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.username)
Button { showTokenId.toggle() } label: {
Image(systemName: showTokenId ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
Section(L("onboarding.token.secret.label")) {
HStack {
Group {
if showTokenSecret {
TextField(L("onboarding.token.secret.label"), text: $tokenSecret)
} else {
SecureField(L("onboarding.token.secret.label"), text: $tokenSecret)
}
}
.autocorrectionDisabled()
.textInputAutocapitalization(.never)
.textContentType(.password)
Button { showTokenSecret.toggle() } label: {
Image(systemName: showTokenSecret ? "eye.slash" : "eye")
.foregroundStyle(.secondary)
}
.buttonStyle(.plain)
}
}
}
if let error = saveError {
Section {
Label(error, systemImage: "exclamationmark.circle.fill")
.foregroundStyle(.red)
.font(.footnote)
}
}
}
.navigationTitle(L("settings.servers.edit.title"))
.navigationBarTitleDisplayMode(.inline)
.toolbar {
ToolbarItem(placement: .cancellationAction) {
Button(L("create.cancel")) { dismiss() }
}
ToolbarItem(placement: .confirmationAction) {
Button(L("common.done")) {
save()
}
.disabled(!canSave || isSaving)
.overlay { if isSaving { ProgressView().scaleEffect(0.7) } }
}
}
}
}
// MARK: - Helpers
private var canSave: Bool {
!name.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
!serverURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty &&
(!changeCredentials || (!tokenId.isEmpty && !tokenSecret.isEmpty))
}
private func save() {
isSaving = true
saveError = nil
var cleanURL = serverURL.trimmingCharacters(in: .whitespacesAndNewlines)
if !cleanURL.hasPrefix("http://") && !cleanURL.hasPrefix("https://") {
cleanURL = "https://" + cleanURL
}
while cleanURL.hasSuffix("/") { cleanURL = String(cleanURL.dropLast()) }
profileStore.updateProfile(
profile,
newName: name.trimmingCharacters(in: .whitespacesAndNewlines),
newURL: cleanURL,
newTokenId: changeCredentials ? tokenId : nil,
newTokenSecret: changeCredentials ? tokenSecret : nil
)
isSaving = false
dismiss()
}
}
+107 -29
View File
@@ -1,5 +1,6 @@
import SwiftUI
import SafariServices
import SwiftData
struct SettingsView: View {
@AppStorage("onboardingComplete") private var onboardingComplete = false
@@ -8,11 +9,11 @@ struct SettingsView: View {
@AppStorage("appTheme") private var appTheme = "system"
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
@AppStorage("loggingEnabled") private var loggingEnabled = false
@Environment(ServerProfileStore.self) private var profileStore
private var selectedTheme: AccentTheme {
AccentTheme(rawValue: accentThemeRaw) ?? .ocean
}
@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
@@ -20,6 +21,11 @@ struct SettingsView: View {
@State private var selectedLanguage: LanguageManager.Language = LanguageManager.shared.current
@State private var showLogViewer = false
@State private var shareItems: [Any]? = nil
@Environment(\.modelContext) private var modelContext
@State private var showAddServer = false
@State private var profileToSwitch: ServerProfile? = nil
@State private var profileToDelete: ServerProfile? = nil
@State private var profileToEdit: ServerProfile? = nil
private let appVersion = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0"
private let buildNumber = Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? "1"
@@ -93,33 +99,56 @@ struct SettingsView: View {
.padding(.vertical, 4)
}
// 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)
// Servers section
Section(L("settings.servers")) {
ForEach(profileStore.profiles) { profile in
Button {
if profile.id != profileStore.activeProfileId {
profileToSwitch = profile
}
} label: {
HStack(spacing: 12) {
Image(systemName: profile.id == profileStore.activeProfileId
? "checkmark.circle.fill" : "circle")
.foregroundStyle(profile.id == profileStore.activeProfileId
? Color.accentColor : .secondary)
VStack(alignment: .leading, spacing: 2) {
Text(profile.name)
.font(.body)
.foregroundStyle(.primary)
Text(profile.serverURL)
.font(.footnote)
.foregroundStyle(.secondary)
.lineLimit(1)
}
Spacer()
if profile.id == profileStore.activeProfileId {
Text(L("settings.servers.active"))
.font(.caption)
.foregroundStyle(.secondary)
}
}
}
.swipeActions(edge: .trailing, allowsFullSwipe: false) {
Button(role: .destructive) {
profileToDelete = profile
} label: {
Label(L("settings.servers.delete.confirm"), systemImage: "trash")
}
.tint(.red)
Button {
profileToEdit = profile
} label: {
Label(L("settings.servers.edit"), systemImage: "pencil")
}
.tint(.blue)
}
}
.padding(.vertical, 4)
Button {
UIPasteboard.general.string = serverURL
showAddServer = true
} 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")
Label(L("settings.servers.add"), systemImage: "plus.circle")
}
}
@@ -207,11 +236,58 @@ struct SettingsView: View {
.onAppear {
loggingEnabled = LogManager.shared.isEnabled
}
.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) {}
// Switch server confirmation
.alert(L("settings.servers.switch.title"), isPresented: Binding(
get: { profileToSwitch != nil },
set: { if !$0 { profileToSwitch = nil } }
)) {
Button(L("settings.servers.switch.confirm")) {
if let p = profileToSwitch { profileStore.activate(p) }
profileToSwitch = nil
}
Button(L("settings.signout.alert.cancel"), role: .cancel) { profileToSwitch = nil }
} message: {
Text(L("settings.signout.alert.message"))
if let p = profileToSwitch {
Text(String(format: L("settings.servers.switch.message"), p.name))
}
}
// Delete inactive server confirmation
.alert(L("settings.servers.delete.title"), isPresented: Binding(
get: { profileToDelete != nil && profileToDelete?.id != profileStore.activeProfileId },
set: { if !$0 { profileToDelete = nil } }
)) {
Button(L("settings.servers.delete.confirm"), role: .destructive) {
if let p = profileToDelete { removeProfile(p) }
profileToDelete = nil
}
Button(L("settings.signout.alert.cancel"), role: .cancel) { profileToDelete = nil }
} message: {
if let p = profileToDelete {
Text(String(format: L("settings.servers.delete.message"), p.name))
}
}
// Delete ACTIVE server stronger warning
.alert(L("settings.servers.delete.active.title"), isPresented: Binding(
get: { profileToDelete != nil && profileToDelete?.id == profileStore.activeProfileId },
set: { if !$0 { profileToDelete = nil } }
)) {
Button(L("settings.servers.delete.confirm"), role: .destructive) {
if let p = profileToDelete { removeProfile(p) }
profileToDelete = nil
}
Button(L("settings.signout.alert.cancel"), role: .cancel) { profileToDelete = nil }
} message: {
if let p = profileToDelete {
Text(String(format: L("settings.servers.delete.active.message"), p.name))
}
}
// Add server sheet
.sheet(isPresented: $showAddServer) {
AddServerView()
}
// Edit server sheet
.sheet(item: $profileToEdit) { profile in
EditServerView(profile: profile)
}
.sheet(item: $showSafari) { url in
SafariView(url: url)
@@ -233,11 +309,13 @@ struct SettingsView: View {
// MARK: - Actions
private func signOut() {
Task {
try? await KeychainService.shared.deleteCredentials()
UserDefaults.standard.removeObject(forKey: "serverURL")
UserDefaults.standard.removeObject(forKey: "lastSynced")
private func removeProfile(_ profile: ServerProfile) {
profileStore.remove(profile)
// Always clear the cache it may contain content from this server
try? SyncService.shared.clearAllCache(context: modelContext)
lastSynced = nil
// If no profiles remain, return to onboarding
if profileStore.profiles.isEmpty {
onboardingComplete = false
}
}
+7 -1
View File
@@ -14,6 +14,9 @@ struct bookstaxApp: App {
@AppStorage("appTheme") private var appTheme = "system"
@AppStorage("accentTheme") private var accentThemeRaw = AccentTheme.ocean.rawValue
// ServerProfileStore is initialised here so migration runs at launch
private var profileStore = ServerProfileStore.shared
private var preferredColorScheme: ColorScheme? {
switch appTheme {
case "light": return .light
@@ -43,13 +46,16 @@ struct bookstaxApp: App {
var body: some Scene {
WindowGroup {
Group {
if onboardingComplete {
if onboardingComplete && profileStore.activeProfile != nil {
MainTabView()
.environment(ConnectivityMonitor.shared)
// Re-creates the entire tab hierarchy when the active server changes
.id(profileStore.activeProfileId)
} else {
OnboardingView()
}
}
.environment(profileStore)
.environment(\.accentTheme, accentTheme)
.tint(accentTheme.accentColor)
.preferredColorScheme(preferredColorScheme)
+19
View File
@@ -214,6 +214,25 @@
"search.filter.tag" = "Tag";
"search.filter.tag.clear" = "Tag-Filter entfernen";
// MARK: - Servers
"settings.servers" = "Server";
"settings.servers.add" = "Server hinzufügen…";
"settings.servers.active" = "Aktiv";
"settings.servers.switch.title" = "Server wechseln";
"settings.servers.switch.message" = "Zu \"%@\" wechseln? Die App wird neu geladen.";
"settings.servers.switch.confirm" = "Wechseln";
"settings.servers.delete.title" = "Server entfernen";
"settings.servers.delete.message" = "\"%@\" entfernen? Der Cache wird geleert. Dies kann nicht rückgängig gemacht werden.";
"settings.servers.delete.confirm" = "Entfernen";
"settings.servers.delete.active.title" = "Aktiven Server entfernen?";
"settings.servers.delete.active.message" = "\"%@\" ist dein aktueller Server. Durch das Entfernen werden alle zwischengespeicherten Inhalte gelöscht und du wirst von diesem Server abgemeldet.";
"settings.servers.edit" = "Bearbeiten";
"settings.servers.edit.title" = "Server bearbeiten";
"settings.servers.edit.changecreds" = "API-Token aktualisieren";
"settings.servers.edit.changecreds.footer" = "Aktivieren, um Token-ID und Secret für diesen Server zu ersetzen.";
"onboarding.server.name.label" = "Servername";
"onboarding.server.name.placeholder" = "z.B. Firmen-Wiki";
// MARK: - Common
"common.ok" = "OK";
"common.error" = "Unbekannter Fehler";
+19
View File
@@ -214,6 +214,25 @@
"search.filter.tag" = "Tag";
"search.filter.tag.clear" = "Clear Tag Filter";
// MARK: - Servers
"settings.servers" = "Servers";
"settings.servers.add" = "Add Server…";
"settings.servers.active" = "Active";
"settings.servers.switch.title" = "Switch Server";
"settings.servers.switch.message" = "Switch to \"%@\"? The app will reload.";
"settings.servers.switch.confirm" = "Switch";
"settings.servers.delete.title" = "Remove Server";
"settings.servers.delete.message" = "Remove \"%@\"? Its cached content will be cleared. This cannot be undone.";
"settings.servers.delete.confirm" = "Remove";
"settings.servers.delete.active.title" = "Remove Active Server?";
"settings.servers.delete.active.message" = "\"%@\" is your current server. Removing it will clear all cached content and sign you out of this server.";
"settings.servers.edit" = "Edit";
"settings.servers.edit.title" = "Edit Server";
"settings.servers.edit.changecreds" = "Update API Token";
"settings.servers.edit.changecreds.footer" = "Enable to replace the stored Token ID and Secret for this server.";
"onboarding.server.name.label" = "Server Name";
"onboarding.server.name.placeholder" = "e.g. Work Wiki";
// MARK: - Common
"common.ok" = "OK";
"common.error" = "Unknown error";
+19
View File
@@ -214,6 +214,25 @@
"search.filter.tag" = "Etiqueta";
"search.filter.tag.clear" = "Eliminar filtro de etiqueta";
// MARK: - Servers
"settings.servers" = "Servidores";
"settings.servers.add" = "Añadir servidor…";
"settings.servers.active" = "Activo";
"settings.servers.switch.title" = "Cambiar servidor";
"settings.servers.switch.message" = "¿Cambiar a \"%@\"? La app se recargará.";
"settings.servers.switch.confirm" = "Cambiar";
"settings.servers.delete.title" = "Eliminar servidor";
"settings.servers.delete.message" = "¿Eliminar \"%@\"? Se borrará el contenido en caché. Esta acción no se puede deshacer.";
"settings.servers.delete.confirm" = "Eliminar";
"settings.servers.delete.active.title" = "¿Eliminar el servidor activo?";
"settings.servers.delete.active.message" = "\"%@\" es tu servidor actual. Al eliminarlo se borrará todo el contenido en caché y se cerrará sesión en este servidor.";
"settings.servers.edit" = "Editar";
"settings.servers.edit.title" = "Editar servidor";
"settings.servers.edit.changecreds" = "Actualizar token API";
"settings.servers.edit.changecreds.footer" = "Activa para reemplazar el Token ID y Secret almacenados para este servidor.";
"onboarding.server.name.label" = "Nombre del servidor";
"onboarding.server.name.placeholder" = "p.ej. Wiki de trabajo";
// MARK: - Common
"common.ok" = "Aceptar";
"common.error" = "Error desconocido";