diff --git a/CHANGELOG.md b/CHANGELOG.md index f316da2..725f310 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,28 @@ All notable changes to VaultSync are documented here. --- +## [1.4.0] — 2026-05-30 + +### Added + +- **Turn off conflict notifications** ([#10](https://github.com/psimaker/vaultsync/issues/10)) — A new Settings → Notifications toggle mutes the sync-conflict banner without touching anything else. It gates only the banner, so Cloud Relay wake-ups and background sync keep working, and turning it off no longer requires disabling iOS notifications for the whole app. Defaults on; existing installs keep their current behaviour. +- **Support VaultSync** — An optional "Support VaultSync" section in Settings offers two one-time contributions (Small and Big). They unlock nothing — VaultSync stays fully functional without them — and you can contribute as often as you like. Localized for English, German, Spanish, and Simplified Chinese. +- **Spanish localization** — VaultSync is now fully localized in Spanish (`es`), joining English, German, and Simplified Chinese across the app and the home-screen widget. + +### Changed + +- **Cloud Relay price shown correctly per region** — The subscribe button and the subscription details previously mixed the localized App Store price with a hard-coded "$0.99/month", so non-US storefronts saw two different currencies. Both now show a single price taken straight from StoreKit (for example "0,99 € / month" or "A$1.99 / month"), so the displayed price is always correct for the user's storefront. +- **Clearer Support and Cloud Relay wording** — The Settings → Support footer now frames a contribution around keeping VaultSync independent, open-source (MPL-2.0), and ad-free — it still unlocks nothing. The Cloud Relay description explains the silent-push wake-up more plainly and honestly: changes on your server wake the app the moment they happen so incoming sync feels instant, and the relay only sends a wake-up signal — it never sees your notes. The German, Spanish, and Simplified Chinese translations were polished for terminology and punctuation consistency in the same pass. + +### Fixed + +- **Conflict notifications no longer spam the screen** ([#10](https://github.com/psimaker/vaultsync/issues/10)) — The conflict banner was posted with a fresh identifier on every background sync that reached idle, so iOS never coalesced them: the same "28 conflicts" message re-lit the screen over and over and drained battery. VaultSync now uses a stable notification that replaces itself in place, only alerts (with sound) when the conflict count actually grows, refreshes quietly when it shrinks, and clears when the last conflict is resolved. +- **Disabling notifications no longer reports Cloud Relay as broken** ([#10](https://github.com/psimaker/vaultsync/issues/10)) — Silent push wake-ups do not need alert permission, but the app used to flag APNs/relay as "failed" purely because notifications were turned off, cascading a misleading "relay broken" message across diagnostics. Relay health is now judged from the things that actually matter (subscription, APNs token, provisioning, and a recent silent-push trigger), and the alert-permission state is shown as a separate, informational row. +- **More reliable background sync** — Two background wake-ups that fired at the same time could tear down each other's sync mid-transfer; a single-flight guard now prevents that. The silent-push time budget is measured from the start of the run so long setups can't overrun iOS's window and get future wake-ups throttled. And a folder stuck in an error state no longer spins out the full background deadline or raises a misleading "Background Sync Timed Out". +- **Localized conflict notification text** — The conflict banner body was English-only on German and Simplified Chinese devices; it is now translated. + +--- + ## [1.3.2] — 2026-05-23 ### Fixed diff --git a/README.md b/README.md index 4d62621..46936bb 100644 --- a/README.md +++ b/README.md @@ -99,9 +99,9 @@ VaultSync is also not a magic always-on Syncthing daemon for iOS. Apple’s back --- -## What’s New — v1.3.2 +## What’s New — v1.4.0 -> **Skip on iPhone now actually skips returning conflicts** — Tapping "Always skip on this iPhone" in the conflict resolver previously added only the original file's path to the ignore list, so a fresh sync-conflict copy with a new timestamp would arrive from the desktop and the conflict reappeared. Skip now also covers future conflict copies of the same file, removes any existing copies on disk, and groups the pair as a single row in Sync Filters. +> **Quiet conflict notifications — and a switch to turn them off** ([#10](https://github.com/psimaker/vaultsync/issues/10)) — VaultSync no longer re-posts the same "N conflicts" banner on every background sync. It now replaces a single notification in place, only alerts when the count actually grows, and adds a Settings toggle so you can mute conflict banners without disabling iOS notifications — which no longer makes Cloud Relay look "broken". Background sync is more reliable too, the Cloud Relay price now displays correctly in every region, and you can optionally support development with a one-time contribution. This release also adds full **Spanish** localization, alongside English, German, and Simplified Chinese. See [CHANGELOG.md](CHANGELOG.md) for full details. diff --git a/docs/setup.md b/docs/setup.md index e37aa98..97848e1 100644 --- a/docs/setup.md +++ b/docs/setup.md @@ -44,6 +44,13 @@ cd ../ios xcodegen generate ``` +> **Code signing (device builds only):** No development team is committed to the +> repo. To build on a physical device, copy `ios/Signing.local.xcconfig.example` +> to `ios/Signing.local.xcconfig`, set your `DEVELOPMENT_TEAM`, and re-run +> `xcodegen generate`. That file is gitignored, so your team never lands in the +> repo and persists across regenerations. Simulator builds need no team — build +> with `CODE_SIGNING_ALLOWED=NO`. + ### 6. Open and build in Xcode ```bash diff --git a/ios/.gitignore b/ios/.gitignore index 76f3c43..adbf0bb 100644 --- a/ios/.gitignore +++ b/ios/.gitignore @@ -10,3 +10,6 @@ build/ *.xcuserstate *.xcuserdata xcuserdata/ + +# Local signing override (your DEVELOPMENT_TEAM) — see Signing.local.xcconfig.example +Signing.local.xcconfig diff --git a/ios/Signing.local.xcconfig.example b/ios/Signing.local.xcconfig.example new file mode 100644 index 0000000..a490600 --- /dev/null +++ b/ios/Signing.local.xcconfig.example @@ -0,0 +1,10 @@ +// Local, per-developer signing — NOT committed (see ios/.gitignore). +// +// 1. Copy this file to "Signing.local.xcconfig" in the same folder. +// 2. Set your Apple Developer Team ID below. +// 3. Run `xcodegen generate`. Your team now persists across regenerations. +// +// Simulator-only builds don't need this; leave it unset and build with +// CODE_SIGNING_ALLOWED=NO. + +DEVELOPMENT_TEAM = ABCDE12345 diff --git a/ios/Signing.xcconfig b/ios/Signing.xcconfig new file mode 100644 index 0000000..45edca7 --- /dev/null +++ b/ios/Signing.xcconfig @@ -0,0 +1,10 @@ +// Base signing configuration for VaultSync (committed — contains no team). +// +// Automatic signing is the default. Your Apple Developer Team ID lives in the +// gitignored Signing.local.xcconfig (copy it from Signing.local.xcconfig.example). +// The optional include below is silently skipped when that file is absent, so a +// fresh clone or CI can `xcodegen generate` and build for the Simulator without +// forcing any developer team onto contributors. +CODE_SIGN_STYLE = Automatic + +#include? "Signing.local.xcconfig" diff --git a/ios/VaultSync.storekit b/ios/VaultSync.storekit new file mode 100644 index 0000000..0c9edb7 --- /dev/null +++ b/ios/VaultSync.storekit @@ -0,0 +1,74 @@ +{ + "identifier" : "VAULTSYNC_STOREKIT", + "nonRenewingSubscriptions" : [], + "products" : [ + { + "displayPrice" : "2.99", + "familyShareable" : false, + "internalID" : "1001", + "localizations" : [ + { + "description" : "A one-time contribution to support VaultSync development. Unlocks nothing.", + "displayName" : "Small Contribution", + "locale" : "en_US" + } + ], + "productID" : "eu.vaultsync.app.contribution.small", + "referenceName" : "Small Contribution", + "type" : "Consumable" + }, + { + "displayPrice" : "9.99", + "familyShareable" : false, + "internalID" : "1002", + "localizations" : [ + { + "description" : "A larger one-time contribution to support VaultSync development. Unlocks nothing.", + "displayName" : "Big Contribution", + "locale" : "en_US" + } + ], + "productID" : "eu.vaultsync.app.contribution.big", + "referenceName" : "Big Contribution", + "type" : "Consumable" + } + ], + "settings" : { + "_locale" : "en_US", + "_storefront" : "USA" + }, + "subscriptionGroups" : [ + { + "id" : "2001", + "localizations" : [], + "name" : "Cloud Relay", + "subscriptions" : [ + { + "adHocOffers" : [], + "codeOffers" : [], + "displayPrice" : "0.99", + "familyShareable" : false, + "groupNumber" : 1, + "internalID" : "2002", + "introductoryOffer" : null, + "localizations" : [ + { + "description" : "Silent push wake-ups for faster server-to-iPhone sync.", + "displayName" : "Cloud Relay", + "locale" : "en_US" + } + ], + "productID" : "eu.vaultsync.app.relay.monthly", + "recurringSubscriptionPeriod" : "P1M", + "referenceName" : "Cloud Relay", + "subscriptionGroupID" : "2001", + "type" : "RecurringSubscription" + } + ] + } + ], + "version" : { + "major" : 4, + "minor" : 0 + } +} diff --git a/ios/VaultSync/App/AppDelegate.swift b/ios/VaultSync/App/AppDelegate.swift index 99efdb8..d309562 100644 --- a/ios/VaultSync/App/AppDelegate.swift +++ b/ios/VaultSync/App/AppDelegate.swift @@ -1,5 +1,4 @@ import UIKit -import UserNotifications import os private let logger = Logger(subsystem: "eu.vaultsync.app", category: "appdelegate") @@ -13,7 +12,11 @@ class AppDelegate: NSObject, UIApplicationDelegate { application.registerForRemoteNotifications() logger.info("Registered for remote notifications") logger.debug("Custom URL routing is handled by VaultSyncApp.onOpenURL; AppDelegate remains dedicated to push and background delivery") - Task { await refreshNotificationAuthorizationState() } + // NOTE: We deliberately do NOT flag APNs/relay as failed based on alert + // authorization. Silent (content-available) pushes — the relay's wake + // mechanism — are delivered regardless of UNAuthorizationStatus. The + // live alert-permission state is surfaced as informational in Relay + // Diagnostics instead (see SubscriptionManager.alertBannerStatus). return true } @@ -60,7 +63,7 @@ class AppDelegate: NSObject, UIApplicationDelegate { switch result { case .synced: completionHandler(.newData) - case .alreadyIdle, .noFoldersConfigured: + case .alreadyIdle, .noFoldersConfigured, .settledWithFolderError: completionHandler(.noData) case .noBookmarkAccess, .bridgeStartFailed, .notIdleBeforeDeadline, .failed: completionHandler(.failed) @@ -81,12 +84,4 @@ class AppDelegate: NSObject, UIApplicationDelegate { return base } - - private func refreshNotificationAuthorizationState() async { - let settings = await UNUserNotificationCenter.current().notificationSettings() - guard settings.authorizationStatus == .denied else { return } - APNsRegistrationStore.markFailed( - reason: L10n.tr("Notifications are disabled for VaultSync. Enable them in iOS Settings > Notifications > VaultSync, then retry APNs registration.") - ) - } } diff --git a/ios/VaultSync/App/VaultSyncApp.swift b/ios/VaultSync/App/VaultSyncApp.swift index cdc9f90..dde020f 100644 --- a/ios/VaultSync/App/VaultSyncApp.swift +++ b/ios/VaultSync/App/VaultSyncApp.swift @@ -16,6 +16,13 @@ struct VaultSyncApp: App { private static let foregroundRescanThreshold: TimeInterval = 5 init() { + // Conflict banners default ON. Registered defaults are per-process and + // not persisted, so the background handler still relies on its own + // `?? true` fallback — this only keeps foreground `bool(forKey:)` reads + // consistent before the user ever touches the toggle. + UserDefaults.standard.register( + defaults: [BackgroundSyncService.conflictNotificationsEnabledKey: true] + ) BackgroundSyncService.registerTasks() logger.info("VaultSync starting") Task.detached(priority: .utility) { @@ -50,6 +57,7 @@ struct VaultSyncApp: App { .onChange(of: scenePhase) { _, newPhase in switch newPhase { case .active: + BackgroundSyncService.setSceneActive(true) BackgroundSyncService.endBackgroundAssertion() BackgroundSyncService.cancelContinuedProcessing() if !SyncBridgeService.isRunning() { @@ -75,6 +83,7 @@ struct VaultSyncApp: App { lastBackgroundedAt = nil case .background: lastBackgroundedAt = Date() + BackgroundSyncService.setSceneActive(false) // Release the foreground lifecycle lock so silent-push and // BGAppRefresh handlers can manage Syncthing when the process diff --git a/ios/VaultSync/Models/RelayProvisionStatus.swift b/ios/VaultSync/Models/RelayProvisionStatus.swift index a84dcf5..f0884ba 100644 --- a/ios/VaultSync/Models/RelayProvisionStatus.swift +++ b/ios/VaultSync/Models/RelayProvisionStatus.swift @@ -66,8 +66,6 @@ enum APNsRegistrationStore { static let tokenDidChangeNotification = Notification.Name("APNsDeviceTokenDidChange") struct Snapshot: Equatable, Sendable { - let status: APNsRegistrationStatus - let failureReason: String? let updatedAt: Date? let lastSuccessAt: Date? let lastFailureAt: Date? @@ -89,16 +87,7 @@ enum APNsRegistrationStore { static func snapshot() -> Snapshot { let defaults = UserDefaults.standard - let status = current() - let failureReason: String? - if case .failed(let reason) = status { - failureReason = reason - } else { - failureReason = nil - } return Snapshot( - status: status, - failureReason: failureReason, updatedAt: defaults.object(forKey: updatedAtKey) as? Date, lastSuccessAt: defaults.object(forKey: lastSuccessAtKey) as? Date, lastFailureAt: defaults.object(forKey: lastFailureAtKey) as? Date diff --git a/ios/VaultSync/Resources/Theme.swift b/ios/VaultSync/Resources/Theme.swift new file mode 100644 index 0000000..ef5aecc --- /dev/null +++ b/ios/VaultSync/Resources/Theme.swift @@ -0,0 +1,10 @@ +import SwiftUI + +/// Shared brand palette. Single source of truth so the app and the widget +/// render the same accent colors instead of redefining the RGB per file. +extension Color { + /// Brand teal — active / in-progress sync accent. + static let vaultTeal = Color(red: 0 / 255, green: 137 / 255, blue: 123 / 255) + /// Brand slate — muted/inactive accent. + static let vaultSlate = Color(red: 38 / 255, green: 50 / 255, blue: 56 / 255) +} diff --git a/ios/VaultSync/Services/BackgroundSyncService.swift b/ios/VaultSync/Services/BackgroundSyncService.swift index ec2b222..d45019e 100644 --- a/ios/VaultSync/Services/BackgroundSyncService.swift +++ b/ios/VaultSync/Services/BackgroundSyncService.swift @@ -28,6 +28,29 @@ enum BackgroundSyncService { /// Shared lock coordinating Syncthing bridge start/stop across foreground and background. static let lifecycleLock = OSAllocatedUnfairLock(initialState: SyncLifecycleState()) + /// Single-flight guard: only one background sync may drive the shared + /// Syncthing instance at a time. Distinct from `lifecycleLock`, which only + /// tracks whether the foreground owns start/stop — this prevents two + /// concurrent background wake-ups from tearing down each other's sync. + private static let syncInFlightLock = OSAllocatedUnfairLock(initialState: false) + + /// Whether the scene is currently foreground-active. Drives conflict-banner + /// behavior: while active, the in-app UI surfaces conflicts so no banner is + /// posted and the foreground poll keeps the suppression baseline in sync; + /// while inactive (including the ~30s post-background grace window, when the + /// poll is still running) the baseline is frozen and silent-push handlers + /// may post banners. This is NOT `lifecycleLock.foregroundActive`, which + /// tracks bridge ownership and is released early on `.background`. + private static let sceneActiveLock = OSAllocatedUnfairLock(initialState: false) + + static func setSceneActive(_ active: Bool) { + sceneActiveLock.withLock { $0 = active } + } + + static func isSceneActive() -> Bool { + sceneActiveLock.withLock { $0 } + } + /// Guards the UIApplication background-task assertion used for the /// scene-phase grace period after the app leaves the foreground. private static let backgroundAssertionLock = OSAllocatedUnfairLock( @@ -41,6 +64,21 @@ enum BackgroundSyncService { private static let lastSyncOutcomeStorageKey = "background-sync-last-outcome-v1" static let lastSyncOutcomeDidChangeNotification = Notification.Name("BackgroundSyncLastOutcomeDidChange") + /// Stable identifier for the conflict banner. Re-posting with the same id + /// replaces the existing notification in place instead of stacking a fresh + /// one each silent push (issue #10). + private static let conflictNotificationIdentifier = "sync-conflict" + /// Persisted distinct/visible conflict count last surfaced to the user. + /// Used to suppress re-posting an unchanged count. Lives in + /// `UserDefaults.standard`, which the in-process silent-push / BGTask + /// handlers share with the foreground UI. + private static let lastNotifiedConflictCountKey = "conflict-notification-last-count-v1" + /// In-app toggle gating the conflict banner (Settings → Notifications). + /// Defaults ON; an absent key must read as ON so existing installs are not + /// silently muted after upgrade. Gates only the banner — never relay + /// silent-push wake-ups, which do not depend on alert authorization. + static let conflictNotificationsEnabledKey = "conflict-notifications-enabled-v1" + struct SyncOutcome: Codable, Equatable, Sendable { let timestamp: Date let triggerReason: String @@ -215,6 +253,94 @@ enum BackgroundSyncService { } } + /// Whether iOS will actually present a conflict alert banner. Affects ONLY + /// the banner — silent (content-available) pushes used by Cloud Relay are + /// delivered regardless, so this is surfaced as informational and never as a + /// relay/APNs failure. + enum AlertBannerStatus: Sendable { + case allowed // authorized AND banners enabled + case denied // denied, or banners explicitly turned off + case unknown // not determined / provisional / not supported + } + + /// Resolve the real banner capability from UNNotificationSettings. Authorized + /// is not enough on its own — the user can keep authorization but switch + /// "Banners" off in iOS Settings (`alertSetting == .disabled`). + static func alertBannerStatus() async -> AlertBannerStatus { + let settings = await UNUserNotificationCenter.current().notificationSettings() + switch settings.authorizationStatus { + case .denied: + return .denied + case .authorized: + switch settings.alertSetting { + case .enabled: + return .allowed + case .disabled: + return .denied + default: + return .unknown + } + case .notDetermined, .provisional, .ephemeral: + // Not requested by VaultSync (full alert auth is requested at + // onboarding); provisional/ephemeral deliver quietly, not as banners. + return .unknown + @unknown default: + return .unknown + } + } + + /// What to do with the conflict banner given the current conflict count and + /// the count last surfaced to the user. Pure and deterministic so the + /// suppression logic can be unit-tested without the bridge. + enum ConflictNotificationAction: Equatable, Sendable { + /// Count rose since last notification — post audibly (genuinely new). + case alert + /// Count fell but conflicts remain — refresh the banner quietly. + case updateQuiet + /// Count is unchanged — leave the existing banner untouched (no spam). + case suppress + /// No conflicts remain — remove the banner. + case clear + } + + static func conflictNotificationAction( + currentCount current: Int, + lastNotifiedCount last: Int + ) -> ConflictNotificationAction { + if current <= 0 { return .clear } + if current > last { return .alert } + if current < last { return .updateQuiet } + return .suppress + } + + /// Re-baseline conflict-notification suppression to what the user can + /// currently see in the app, and clear the banner when no conflicts remain. + /// Called from the foreground poll so (a) a banner the user has already seen + /// is not re-alerted in the background, and (b) a brand-new conflict that + /// appears after a full resolve still alerts instead of being treated as a + /// "decrease" against a stale high-water mark. + @MainActor + static func reconcileConflictNotificationBaseline(currentCount: Int) { + // Only re-baseline while the scene is genuinely active. During the ~30s + // post-background grace window the foreground poll keeps running; if it + // silently bumped the baseline there, a conflict that arrived in that + // window would be read as "unchanged" by the next silent push and never + // alert. Freezing the baseline keeps such a conflict a genuine rise. + guard isSceneActive() else { return } + + let normalized = max(0, currentCount) + // Skip the write/IPC entirely when nothing changed — the 2s poll would + // otherwise hit usernotificationsd every tick in the zero-conflict case. + guard normalized != UserDefaults.standard.integer(forKey: lastNotifiedConflictCountKey) else { return } + + UserDefaults.standard.set(normalized, forKey: lastNotifiedConflictCountKey) + if normalized == 0 { + UNUserNotificationCenter.current().removeDeliveredNotifications( + withIdentifiers: [conflictNotificationIdentifier] + ) + } + } + // MARK: - Shared Sync Logic /// Result of a background sync operation. @@ -226,19 +352,25 @@ enum BackgroundSyncService { case bridgeStartFailed case notIdleBeforeDeadline case failed + /// The run stopped early because a folder is in a terminal error state + /// (nothing more could sync). NOT a timeout — the folder error surfaces + /// via the in-app Sync Issues panel, so this must not raise a misleading + /// "Timed Out" issue, but it is also not a clean success: the widget + /// should still reflect the error rather than a green idle state. + case settledWithFolderError var isSuccessful: Bool { switch self { case .synced, .alreadyIdle: return true - case .noBookmarkAccess, .noFoldersConfigured, .bridgeStartFailed, .notIdleBeforeDeadline, .failed: + case .noBookmarkAccess, .noFoldersConfigured, .bridgeStartFailed, .notIdleBeforeDeadline, .failed, .settledWithFolderError: return false } } var shouldSurfaceIssue: Bool { switch self { - case .synced, .alreadyIdle: + case .synced, .alreadyIdle, .settledWithFolderError: return false case .noBookmarkAccess, .noFoldersConfigured, .bridgeStartFailed, .notIdleBeforeDeadline, .failed: return true @@ -257,7 +389,7 @@ enum BackgroundSyncService { return L10n.tr("Background Sync Timed Out") case .failed: return L10n.tr("Background Sync Failed") - case .synced, .alreadyIdle: + case .synced, .alreadyIdle, .settledWithFolderError: return L10n.tr("Background Sync Completed") } } @@ -278,6 +410,8 @@ enum BackgroundSyncService { return L10n.tr("Background sync completed and reached idle.") case .alreadyIdle: return L10n.tr("Background sync ran, but folders were already idle.") + case .settledWithFolderError: + return L10n.tr("Background sync settled with at least one folder in an error state.") } } @@ -293,7 +427,7 @@ enum BackgroundSyncService { return L10n.tr("Open VaultSync to allow a longer foreground sync session.") case .failed: return L10n.tr("Retry from the app and review relay/background diagnostics in Settings.") - case .synced, .alreadyIdle: + case .synced, .alreadyIdle, .settledWithFolderError: return L10n.tr("No action needed.") } } @@ -315,6 +449,27 @@ enum BackgroundSyncService { ) async -> SyncResult { logger.info("Background sync starting (reason=\(reason))") trace("Starting background sync (reason=\(reason), maxDuration=\(Int(maxDuration))s).") + + // Single-flight: a second concurrent background wake-up must not drive + // the shared Syncthing instance while another sync is mid-flight — one + // task's expiration/cleanup could stop the bridge under the other and + // silently abort a transfer. The loser just nudges a rescan so its + // changes are still picked up, then returns without competing. + let didAcquireSyncSlot = syncInFlightLock.withLock { inFlight -> Bool in + if inFlight { return false } + inFlight = true + return true + } + guard didAcquireSyncSlot else { + logger.info("Background sync already in flight — coalescing (reason=\(reason))") + trace("Concurrent background sync suppressed (reason=\(reason)); nudging rescan.") + if SyncBridgeService.isRunning() { + _ = requestFolderRescans() + } + return .alreadyIdle + } + defer { syncInFlightLock.withLock { $0 = false } } + let syncStartedAt = Date() var telemetryEventCursor = latestBridgeEventID() let syncStartEventCursor = telemetryEventCursor @@ -465,6 +620,12 @@ enum BackgroundSyncService { if allFoldersIdle() && !(progressTracker?.requiresMeaningfulProgress == true) { trace("Folders already idle after setup checks.") + // Surface conflicts on the fast idle path too — a small change that + // conflicts and settles before the deadline loop would otherwise + // skip notification. Must run BEFORE cleanup stops the bridge, since + // the conflict scan reads through it. notifyConflictsIfAny is + // internally gated (foreground/toggle/suppression), so it's cheap. + await notifyConflictsIfAny() if ownsLifecycle { cleanupBackgroundManaged(managedURLs) } @@ -477,26 +638,35 @@ enum BackgroundSyncService { ) } - let deadline = Date(timeIntervalSinceNow: maxDuration) + // Budget the wait from sync START, not from "now": the silent-push setup + // above (folder availability, wake-evidence window, optional forced + // restart) already consumed part of iOS's ~30s content-available budget. + // An absolute deadline keeps total wall-clock under budget so overruns + // don't get future wake-ups throttled. + let deadline = syncStartedAt.addingTimeInterval(maxDuration) trace("Waiting for idle state until deadline.") while SyncBridgeService.isRunning() && Date() < deadline { if Task.isCancelled { break } try? await Task.sleep(for: .milliseconds(500)) - let idle = allFoldersIdle() + // Stop as soon as no folder can make further progress — either all + // idle, or a folder is stuck in a terminal error state. Without the + // error case a single stuck folder spins out the whole deadline. + let settled = allFoldersSettledOrErrored() if var tracker = progressTracker { let snapshot = tracker.poll() progressTrackerTraceIfNeeded(snapshot) - if idle && !snapshot.requiresMeaningfulProgress { + if settled && !snapshot.requiresMeaningfulProgress { progressTracker = tracker break } progressTracker = tracker - } else if idle { + } else if settled { break } } let idle = allFoldersIdle() + let settledWithError = !idle && allFoldersSettledOrErrored() let progressSnapshot = progressTracker?.poll() if let progressSnapshot { progressTrackerTraceIfNeeded(progressSnapshot) @@ -522,6 +692,14 @@ enum BackgroundSyncService { } else if idle { result = .synced detail = nil + } else if settledWithError { + // Stopped because a folder is in a terminal error state, not because + // we ran out of time. The folder error surfaces via the in-app Sync + // Issues panel; don't raise a misleading "Background Sync Timed Out", + // but don't report a clean success either (the widget should still + // reflect the error, not a green idle state). + result = .settledWithFolderError + detail = L10n.tr("Background sync settled with at least one folder in an error state.") } else { result = .notIdleBeforeDeadline detail = L10n.fmt("Sync did not reach idle before %ds deadline.", Int(maxDuration)) @@ -596,7 +774,7 @@ enum BackgroundSyncService { let pct = averageFolderCompletion() progress.completedUnitCount = Int64(pct) - task.updateTitle("Syncing Vault", subtitle: "\(Int(pct))% complete") + task.updateTitle(L10n.tr("Syncing Vault"), subtitle: L10n.fmt("%d%% complete", Int(pct))) } task.setTaskCompleted(success: false) @@ -675,36 +853,72 @@ enum BackgroundSyncService { NotificationCenter.default.post(name: lastSyncOutcomeDidChangeNotification, object: nil) } - private static func allFoldersIdle() -> Bool { + /// Settlement classification for one folder, derived purely from its status + /// fields so it can be unit-tested without the bridge. + enum FolderSettlement: Equatable, Sendable { + case idle // nothing left to do + case errored // terminal error — cannot progress without intervention + case active // scanning, syncing, or outstanding work to pull + } + + static func folderSettlement( + state: String, + needFiles: Int, + needBytes: Int64, + inProgressBytes: Int64 + ) -> FolderSettlement { + if state == "error" { return .errored } + // A folder is truly idle only when Syncthing is neither scanning nor + // syncing AND has no outstanding work. The state field alone is not + // enough: Syncthing briefly reports `idle` between scan and sync phases + // while needBytes/needFiles still await a pull. Treating that window as + // "done" caused background-sync handlers to shut Syncthing down before + // any file was actually pulled. + let hasPendingWork = needFiles > 0 || needBytes > 0 || inProgressBytes > 0 + if state == "idle" && !hasPendingWork { return .idle } + return .active + } + + /// Per-folder settlement snapshot, or nil when the folder list is empty or a + /// status fails to decode (can't confirm state — caller treats as not-idle). + private static func folderSettlements() -> [FolderSettlement]? { let json = SyncBridgeService.getFoldersJSON() guard let data = json.data(using: .utf8), let folders = try? JSONDecoder().decode([FolderStub].self, from: data), !folders.isEmpty else { - // Empty folder list means Syncthing hasn't populated yet — not idle - return false + return nil } + var settlements: [FolderSettlement] = [] + settlements.reserveCapacity(folders.count) for folder in folders { let statusJSON = SyncBridgeService.getFolderStatusJSON(folderID: folder.id) guard let statusData = statusJSON.data(using: .utf8), let status = try? JSONDecoder().decode(StatusStub.self, from: statusData) else { - // Decode failure means we can't confirm state — treat as not idle - return false - } - // A folder is truly idle only when Syncthing is neither scanning - // nor syncing AND has no outstanding work. The state field alone - // is not enough: Syncthing briefly reports `idle` between scan - // and sync phases while needBytes/needFiles still await a pull. - // Treating that window as "done" caused background-sync handlers - // to shut Syncthing down before any file was actually pulled. - let hasPendingWork = status.needFiles > 0 - || status.needBytes > 0 - || status.inProgressBytes > 0 - if status.state != "idle" || hasPendingWork { - return false + return nil } + settlements.append(folderSettlement( + state: status.state, + needFiles: status.needFiles, + needBytes: status.needBytes, + inProgressBytes: status.inProgressBytes + )) } - return true + return settlements + } + + private static func allFoldersIdle() -> Bool { + guard let settlements = folderSettlements() else { return false } + return settlements.allSatisfy { $0 == .idle } + } + + /// True when no folder can make further progress on its own — every folder + /// is either idle or in a terminal error state. Used to stop waiting out the + /// full deadline (and raising a misleading "timed out") when a folder is + /// stuck in error and nothing more can happen. + private static func allFoldersSettledOrErrored() -> Bool { + guard let settlements = folderSettlements() else { return false } + return settlements.allSatisfy { $0 != .active } } private static func averageFolderCompletion() -> Double { @@ -748,42 +962,99 @@ enum BackgroundSyncService { } private static func notifyConflictsIfAny() async { + // If the app is foreground-active, the in-app conflict UI already + // surfaces these — don't also post a banner, and don't race the + // foreground poll's baseline reconcile over the shared count key. (A + // silent push can arrive while the app is open.) + guard !isSceneActive() else { return } + + // In-app toggle (default ON). Read the raw object so an absent key — + // every install from before this feature shipped — reads as ON, not + // false (UserDefaults.bool returns false for a missing key). Gating + // here, before the per-folder conflict scan, also skips that disk I/O + // when the user has turned banners off. + let bannersEnabled = (UserDefaults.standard.object(forKey: conflictNotificationsEnabledKey) as? Bool) ?? true + guard bannersEnabled else { return } + + guard let count = currentConflictCount() else { + // Unreadable conflict snapshot (transient bridge/decode failure) — + // leave the banner and persisted baseline untouched rather than + // mistaking it for "no conflicts". + return + } + let lastCount = UserDefaults.standard.integer(forKey: lastNotifiedConflictCountKey) + let action = conflictNotificationAction(currentCount: count, lastNotifiedCount: lastCount) + + switch action { + case .suppress: + // Same count as last time — not new information. Leave the existing + // banner alone so the screen does not light up every silent push. + return + + case .clear: + UNUserNotificationCenter.current().removeDeliveredNotifications( + withIdentifiers: [conflictNotificationIdentifier] + ) + UserDefaults.standard.set(0, forKey: lastNotifiedConflictCountKey) + return + + case .alert, .updateQuiet: + let content = UNMutableNotificationContent() + content.title = L10n.tr("Sync Conflicts") + content.body = count == 1 + ? L10n.tr("1 file has a sync conflict. Open VaultSync to resolve it.") + : L10n.fmt("%d files have sync conflicts. Open VaultSync to resolve them.", count) + if action == .alert { + content.sound = .default + content.interruptionLevel = .active + } else { + // Count dropped (some resolved) — refresh the number without a + // sound or screen-wake. + content.interruptionLevel = .passive + } + + let request = UNNotificationRequest( + identifier: conflictNotificationIdentifier, + content: content, + trigger: nil + ) + + do { + try await UNUserNotificationCenter.current().add(request) + UserDefaults.standard.set(count, forKey: lastNotifiedConflictCountKey) + logger.info("Conflict notification \(action == .alert ? "posted" : "updated quietly") (\(count) conflicts)") + } catch { + logger.error("Failed to send notification: \(error)") + } + } + } + + /// Total conflict copies across all folders, as reported by the bridge's + /// on-disk scan. Matches the count shown in the in-app conflict list. + /// + /// Returns nil when the snapshot is UNREADABLE — the folder list or any + /// folder's conflict list failed to decode. A transient bridge/decode + /// failure must NOT be mistaken for "no conflicts": that would clear the + /// banner and reset the baseline, and the next successful read would then + /// re-alert the still-present conflicts as if they were new. + private static func currentConflictCount() -> Int? { let json = SyncBridgeService.getFoldersJSON() guard let data = json.data(using: .utf8), let folders = try? JSONDecoder().decode([FolderStub].self, from: data) else { - return + return nil } var count = 0 for folder in folders { let cJSON = SyncBridgeService.getConflictFilesJSON(folderID: folder.id) - if let cData = cJSON.data(using: .utf8), - let conflicts = try? JSONDecoder().decode([ConflictStub].self, from: cData) { - count += conflicts.count + guard let cData = cJSON.data(using: .utf8), + let conflicts = try? JSONDecoder().decode([ConflictStub].self, from: cData) else { + // Suppress rather than undercount this folder's conflicts to 0. + return nil } + count += conflicts.count } - - guard count > 0 else { return } - - let content = UNMutableNotificationContent() - content.title = L10n.tr("Sync Conflicts") - content.body = count == 1 - ? "1 file has a sync conflict. Open VaultSync to resolve it." - : "\(count) files have sync conflicts. Open VaultSync to resolve them." - content.sound = .default - - let request = UNNotificationRequest( - identifier: "sync-conflict-\(UUID().uuidString)", - content: content, - trigger: nil - ) - - do { - try await UNUserNotificationCenter.current().add(request) - logger.info("Conflict notification sent (\(count) conflicts)") - } catch { - logger.error("Failed to send notification: \(error)") - } + return count } private static func restoreBookmarkAccess() -> [URL] { diff --git a/ios/VaultSync/Services/SubscriptionManager.swift b/ios/VaultSync/Services/SubscriptionManager.swift index cb55c92..3213a56 100644 --- a/ios/VaultSync/Services/SubscriptionManager.swift +++ b/ios/VaultSync/Services/SubscriptionManager.swift @@ -25,6 +25,68 @@ final class SubscriptionManager { private(set) var relayHealthCheckInFlight = false private(set) var lastRelayTriggerReceivedAt: Date? private(set) var lastRelayError: RelayService.RecordedRelayError? + /// Whether iOS will actually present an alert banner (authorized + banners + /// enabled), denied, or unknown. Informational only — silent pushes (Cloud + /// Relay wake-ups) do not depend on it, so this must NOT feed any relay/APNs + /// "failure" state. + private(set) var alertBannerStatus: BackgroundSyncService.AlertBannerStatus = .unknown + + /// Strong signal: a recent silent-push trigger proves Cloud Relay is + /// actually delivering wake-ups to THIS device (the only leg that proves + /// end-to-end delivery to this device's token). Deliberately independent of + /// alert-banner authorization, so muting conflict banners never reads as + /// "relay broken". + var relayDeliveryConfirmed: Bool { + guard isRelaySubscribed, hasAPNsToken, + relayProvisionStatuses.values.contains(.provisioned), + let last = lastRelayTriggerReceivedAt else { + return false + } + return Date().timeIntervalSince(last) < Self.relayTriggerFreshnessWindow + } + + /// Weaker signal: subscribed, provisioned, and the relay endpoint is + /// reachable — but no recent trigger has proven delivery to this device yet. + /// Use for a "looks reachable" indicator, NOT a definitive "delivering" one. + var relayDeliveryLikelyWorking: Bool { + if relayDeliveryConfirmed { return true } + guard isRelaySubscribed, hasAPNsToken, + relayProvisionStatuses.values.contains(.provisioned) else { + return false + } + return relayHealthResult?.isHealthy ?? false + } + + private static let relayTriggerFreshnessWindow: TimeInterval = 48 * 60 * 60 + + /// Localized "price / period" for the relay subscription, derived entirely + /// from StoreKit so it is correct in every storefront — e.g. "0,99 € / month" + /// in Germany, "A$1.99 / month" in Australia. Falls back to the bare + /// localized price if the subscription period is unavailable. Never hard-code + /// a currency or amount in the UI. + var relayPriceText: String? { + guard let product = availableProduct else { return nil } + guard let period = product.subscription?.subscriptionPeriod else { + return product.displayPrice + } + let unit: String + switch period.unit { + case .day: + unit = period.value == 1 ? L10n.tr("day") : L10n.tr("days") + case .week: + unit = period.value == 1 ? L10n.tr("week") : L10n.tr("weeks") + case .month: + unit = period.value == 1 ? L10n.tr("month") : L10n.tr("months") + case .year: + unit = period.value == 1 ? L10n.tr("year") : L10n.tr("years") + @unknown default: + return product.displayPrice + } + if period.value == 1 { + return L10n.fmt("%@ / %@", product.displayPrice, unit) + } + return L10n.fmt("%1$@ / %2$d %3$@", product.displayPrice, period.value, unit) + } @ObservationIgnored nonisolated(unsafe) private var loadTask: Task? @ObservationIgnored nonisolated(unsafe) private var unfinishedTask: Task? @@ -191,6 +253,7 @@ final class SubscriptionManager { } refreshAPNsRegistrationStatus() refreshStoredRelayDiagnostics() + alertBannerStatus = await BackgroundSyncService.alertBannerStatus() await checkSubscriptionStatus() await runRelayHealthCheck() // Opportunistically re-provision if the last successful provision is diff --git a/ios/VaultSync/Services/SyncBridgeService.swift b/ios/VaultSync/Services/SyncBridgeService.swift index 63eb611..fb9310b 100644 --- a/ios/VaultSync/Services/SyncBridgeService.swift +++ b/ios/VaultSync/Services/SyncBridgeService.swift @@ -96,18 +96,6 @@ struct SyncBridgeService { BridgeGetEventsSince(lastID) } - /// Get current Syncthing configuration as JSON. - static func getConfigJSON() -> String { - BridgeGetConfigJSON() - } - - /// Toggle local and global discovery. - /// - Returns: nil on success, error message on failure. - static func setDiscoveryEnabled(local: Bool, global: Bool) -> String? { - let result = BridgeSetDiscoveryEnabled(local, global) - return result.isEmpty ? nil : result - } - // MARK: - Phase 4: Folder management /// Add a new folder with SendReceive type. diff --git a/ios/VaultSync/Services/SyncthingManager.swift b/ios/VaultSync/Services/SyncthingManager.swift index fcacdf1..9223d86 100644 --- a/ios/VaultSync/Services/SyncthingManager.swift +++ b/ios/VaultSync/Services/SyncthingManager.swift @@ -439,7 +439,7 @@ final class SyncthingManager { severity = .critical case .noFoldersConfigured, .notIdleBeforeDeadline: severity = .warning - case .synced, .alreadyIdle: + case .synced, .alreadyIdle, .settledWithFolderError: return nil } @@ -826,6 +826,7 @@ final class SyncthingManager { } } conflictFiles = newConflicts + BackgroundSyncService.reconcileConflictNotificationBaseline(currentCount: unresolvedConflictCount) writeWidgetSnapshotIfNeeded() } @@ -939,6 +940,7 @@ final class SyncthingManager { } } conflictFiles = allConflicts + BackgroundSyncService.reconcileConflictNotificationBaseline(currentCount: unresolvedConflictCount) } private func refreshPendingFolders() { @@ -1598,12 +1600,6 @@ final class SyncthingManager { return nil } - /// True iff every pattern in the preset is currently in the folder's `.stignore`. - func isPresetActive(_ preset: IgnorePreset, folderID: String) -> Bool { - let current = Set(ignorePatterns(folderID: folderID)) - return preset.patterns.allSatisfy { current.contains($0) } - } - /// Atomically add or remove a preset's patterns from `.stignore`. Aborts /// without writing if the current `.stignore` cannot be parsed, so an /// unreadable bridge response can never wipe existing rules. diff --git a/ios/VaultSync/Services/TipJarManager.swift b/ios/VaultSync/Services/TipJarManager.swift new file mode 100644 index 0000000..a5aea2d --- /dev/null +++ b/ios/VaultSync/Services/TipJarManager.swift @@ -0,0 +1,102 @@ +import Foundation +import Observation +import StoreKit +import os + +private let logger = Logger(subsystem: "eu.vaultsync.app", category: "tipjar") + +/// One-time, repeatable "contribution" purchases (StoreKit consumables) that +/// unlock nothing — they only let users support development. Fully independent +/// of the Cloud Relay subscription: VaultSync stays completely functional +/// whether or not a contribution is ever made, and a user may contribute as +/// often as they like. +@MainActor +@Observable +final class TipJarManager { + + static let smallProductID = "eu.vaultsync.app.contribution.small" + static let bigProductID = "eu.vaultsync.app.contribution.big" + + /// Loaded contribution products, ordered cheapest → most expensive so the + /// UI lists "Small" before "Big" regardless of fetch order. + private(set) var products: [Product] = [] + private(set) var isLoading = true + /// The productID currently being purchased, or nil. Drives per-row spinners + /// and disables the buttons while a purchase is in flight. + private(set) var purchasingProductID: String? + /// Set after a successful contribution so the UI can say thank you. The view + /// calls `acknowledgeThankYou()` once it has shown its message. + private(set) var didContribute = false + private(set) var errorMessage: String? + /// Set when a purchase is deferred (e.g. an Ask to Buy awaiting approval). + /// Neutral status, not an error — shown so the tap is not silently dropped. + private(set) var pendingMessage: String? + + @ObservationIgnored nonisolated(unsafe) private var loadTask: Task? + + init() { + loadTask = Task { [weak self] in + await self?.loadProducts() + } + } + + deinit { + loadTask?.cancel() + } + + func loadProducts() async { + isLoading = true + defer { isLoading = false } + do { + let fetched = try await Product.products(for: [Self.smallProductID, Self.bigProductID]) + products = fetched.sorted { $0.price < $1.price } + if fetched.isEmpty { + logger.warning("No contribution products returned by StoreKit") + } + } catch { + logger.error("Failed to load contribution products: \(error)") + } + } + + func purchase(_ product: Product) async { + purchasingProductID = product.id + errorMessage = nil + pendingMessage = nil + defer { purchasingProductID = nil } + + do { + let result = try await product.purchase() + switch result { + case .success(let verification): + guard case .verified(let transaction) = verification else { + logger.warning("Unverified contribution transaction for \(product.id)") + return + } + // Consumable: there is nothing to unlock, so finishing the + // transaction IS the fulfillment. (If a contribution arrives + // later via Transaction.updates — e.g. an approved Ask to Buy — + // SubscriptionManager's updates loop finishes it as a safety net.) + await transaction.finish() + didContribute = true + logger.info("Contribution completed: \(product.id)") + case .userCancelled: + logger.info("Contribution cancelled by user") + case .pending: + logger.info("Contribution pending (e.g. Ask to Buy)") + pendingMessage = L10n.tr("Your contribution is pending approval.") + @unknown default: + break + } + } catch { + logger.error("Contribution purchase failed: \(error)") + errorMessage = SyncUserError.from( + error: error, + fallbackTitle: L10n.tr("Contribution Failed") + ).userVisibleDescription + } + } + + func acknowledgeThankYou() { + didContribute = false + } +} diff --git a/ios/VaultSync/Views/ConflictDiffView.swift b/ios/VaultSync/Views/ConflictDiffView.swift index 22f1ff6..bde71a1 100644 --- a/ios/VaultSync/Views/ConflictDiffView.swift +++ b/ios/VaultSync/Views/ConflictDiffView.swift @@ -38,7 +38,7 @@ struct ConflictDiffView: View { var body: some View { Group { if isLoading { - ProgressView("Loading files...") + ProgressView("Loading files…") } else if let loadError { ContentUnavailableView( "Cannot Load Files", @@ -53,7 +53,7 @@ struct ConflictDiffView: View { .font(.headline) HStack(spacing: 8) { Label(conflict.deviceShortID, systemImage: "laptopcomputer") - Label(conflict.conflictDate, systemImage: "clock") + Label(conflict.formattedConflictDate, systemImage: "clock") } .font(.caption) .foregroundStyle(.secondary) @@ -67,26 +67,7 @@ struct ConflictDiffView: View { .padding(.horizontal) .padding(.bottom, 4) - if showLineDiff { - VStack(alignment: .leading, spacing: 4) { - Text("Differences") - .font(.subheadline.bold()) - .padding(.horizontal) - LineDiffView(original: originalContent, conflict: conflictContent) - } - } else { - fileSection( - title: "This Device", - icon: "iphone", - content: originalContent - ) - - fileSection( - title: "Other Device (\(conflict.deviceShortID))", - icon: "laptopcomputer", - content: conflictContent - ) - } + comparisonContent } .padding(.vertical) } @@ -206,6 +187,50 @@ struct ConflictDiffView: View { } } + /// The body of the comparison — line-by-line diff (with a colour/sign legend) + /// or the two side-by-side file panes. Extracted from `body` to keep each + /// view expression small enough for the Swift type-checker. + @ViewBuilder + private var comparisonContent: some View { + if showLineDiff { + VStack(alignment: .leading, spacing: 4) { + Text("Differences") + .font(.subheadline.bold()) + .padding(.horizontal) + diffLegend + LineDiffView(original: originalContent, conflict: conflictContent) + } + } else { + fileSection( + title: L10n.tr("This Device"), + icon: "iphone", + content: originalContent + ) + + fileSection( + title: L10n.fmt("Other Device (%@)", conflict.deviceShortID), + icon: "laptopcomputer", + content: conflictContent + ) + } + } + + /// Legend so the +/green and -/red mapping is explicit (colour is never the + /// only signal — the +/- symbols carry the same meaning for colourblind and + /// VoiceOver users). + private var diffLegend: some View { + HStack(spacing: 12) { + Label(L10n.tr("Other Device"), systemImage: "plus") + .foregroundStyle(Color(uiColor: .systemGreen)) + Label(L10n.tr("This Device"), systemImage: "minus") + .foregroundStyle(Color(uiColor: .systemRed)) + } + .font(.caption2) + .padding(.horizontal) + .accessibilityElement(children: .ignore) + .accessibilityLabel(L10n.tr("Added lines come from the other device; removed lines are your version on this device.")) + } + private func skipThisFile() { // Wrap the call in a Task so the button handler returns immediately // and SwiftUI can dispatch any UI updates (alert presentation, view diff --git a/ios/VaultSync/Views/ConflictListView.swift b/ios/VaultSync/Views/ConflictListView.swift index 3a41ce2..8af3409 100644 --- a/ios/VaultSync/Views/ConflictListView.swift +++ b/ios/VaultSync/Views/ConflictListView.swift @@ -2,9 +2,15 @@ import SwiftUI struct ConflictListView: View { let folderID: String - let conflicts: [SyncthingManager.ConflictInfo] let syncthingManager: SyncthingManager + /// Read live from the manager so a conflict resolved in the detail view + /// disappears immediately. The view previously held a by-value snapshot + /// captured at push time, which left resolved files as tappable dead rows. + private var conflicts: [SyncthingManager.ConflictInfo] { + syncthingManager.conflictFiles[folderID] ?? [] + } + var body: some View { List { Section { @@ -19,26 +25,31 @@ struct ConflictListView: View { } Section { - ForEach(conflicts) { conflict in - NavigationLink { - ConflictDiffView( - folderID: folderID, - conflict: conflict, - syncthingManager: syncthingManager - ) - } label: { - VStack(alignment: .leading, spacing: 4) { - Text(conflict.originalPath) - .font(.body) - HStack(spacing: 8) { - Label(formattedDate(conflict.conflictDate), systemImage: "clock") - Label(conflict.deviceShortID, systemImage: "laptopcomputer") + if conflicts.isEmpty { + Label("All conflicts resolved", systemImage: "checkmark.circle") + .foregroundStyle(.secondary) + } else { + ForEach(conflicts) { conflict in + NavigationLink { + ConflictDiffView( + folderID: folderID, + conflict: conflict, + syncthingManager: syncthingManager + ) + } label: { + VStack(alignment: .leading, spacing: 4) { + Text(conflict.originalPath) + .font(.body) + HStack(spacing: 8) { + Label(conflict.formattedConflictDate, systemImage: "clock") + Label(conflict.deviceShortID, systemImage: "laptopcomputer") + } + .font(.caption) + .foregroundStyle(.secondary) } - .font(.caption) - .foregroundStyle(.secondary) + .padding(.vertical, 2) + .accessibilityElement(children: .combine) } - .padding(.vertical, 2) - .accessibilityElement(children: .combine) } } } header: { @@ -49,6 +60,9 @@ struct ConflictListView: View { .navigationBarTitleDisplayMode(.inline) } +} + +extension SyncthingManager.ConflictInfo { private static let conflictDateParser: DateFormatter = { let f = DateFormatter() f.dateFormat = "yyyyMMdd-HHmmss" @@ -58,12 +72,18 @@ struct ConflictListView: View { private static let conflictDateDisplay: DateFormatter = { let f = DateFormatter() - f.dateFormat = "yyyy-MM-dd HH:mm" + // Localized styles (not a fixed pattern) so the displayed date follows + // the user's locale and 12/24-hour preference. + f.locale = .autoupdatingCurrent + f.dateStyle = .medium + f.timeStyle = .short return f }() - private func formattedDate(_ dateStr: String) -> String { - guard let date = Self.conflictDateParser.date(from: dateStr) else { return dateStr } + /// Parses the Syncthing conflict-filename timestamp (e.g. "20260530-143000") + /// into a locale-aware display string, shared by the list and the diff view. + var formattedConflictDate: String { + guard let date = Self.conflictDateParser.date(from: conflictDate) else { return conflictDate } return Self.conflictDateDisplay.string(from: date) } } diff --git a/ios/VaultSync/Views/ContentView.swift b/ios/VaultSync/Views/ContentView.swift index dd78b79..83de1bc 100644 --- a/ios/VaultSync/Views/ContentView.swift +++ b/ios/VaultSync/Views/ContentView.swift @@ -15,8 +15,18 @@ struct ContentView: View { @State private var isRescanning = false @State private var pendingFilterSheetFolder: SyncthingManager.FolderInfo? - private let slate = Color(red: 38 / 255, green: 50 / 255, blue: 56 / 255) - private let teal = Color(red: 0 / 255, green: 137 / 255, blue: 123 / 255) + private let slate = Color.vaultSlate + private let teal = Color.vaultTeal + + /// Cached formatter for the dashboard "Last sync" line. Produces a fully + /// localized relative phrase ("2 hours ago" / "vor 2 Stunden" / "2 小时前"). + /// Output is static (not live-ticking), which is fine for a last-sync label — + /// the dashboard re-renders on state changes anyway. + private static let lastSyncFormatter: RelativeDateTimeFormatter = { + let f = RelativeDateTimeFormatter() + f.unitsStyle = .full + return f + }() var body: some View { NavigationStack { @@ -159,7 +169,7 @@ struct ContentView: View { .foregroundStyle(.secondary) } if let lastSync = syncthingManager.lastSyncTime { - Text("\(L10n.tr("Last sync:")) \(lastSync, style: .relative) \(L10n.tr("ago"))") + Text(L10n.fmt("Last sync: %@", Self.lastSyncFormatter.localizedString(for: lastSync, relativeTo: Date()))) .font(.caption) .foregroundStyle(.secondary) } @@ -551,11 +561,6 @@ struct ContentView: View { .accessibilityElement(children: .combine) } - private func isFolderSyncing(_ status: SyncthingManager.FolderStatusInfo?) -> Bool { - guard let state = status?.state else { return false } - return state == "syncing" || state == "scanning" - } - private func stateIcon(_ state: String) -> String { switch state { case "idle": "checkmark.circle.fill" @@ -568,7 +573,7 @@ struct ContentView: View { private func stateColor(_ state: String) -> Color { switch state { case "idle": .green - case "scanning", "syncing": .blue + case "scanning", "syncing": teal case "error": .red default: .gray } @@ -622,7 +627,6 @@ struct ContentView: View { NavigationLink { ConflictListView( folderID: folder.id, - conflicts: conflicts, syncthingManager: syncthingManager ) } label: { @@ -641,7 +645,6 @@ struct ContentView: View { NavigationLink { IgnorePatternsView( folderID: folder.id, - folderLabel: folder.label.isEmpty ? folder.id : folder.label, syncthingManager: syncthingManager ) } label: { @@ -723,7 +726,6 @@ struct ContentView: View { .sheet(item: $pendingFilterSheetFolder) { folder in SyncFilterRecommendationSheet( folderID: folder.id, - folderLabel: folder.label.isEmpty ? folder.id : folder.label, syncthingManager: syncthingManager ) } @@ -771,7 +773,7 @@ struct ContentView: View { Image(systemName: device.connected ? "checkmark.circle.fill" : "xmark.circle.fill") .foregroundStyle(device.connected ? .green : .secondary) .accessibilityHidden(true) - Text(device.connected ? L10n.tr("Connected") : L10n.tr("Offline")) + Text(device.connected ? L10n.tr("Connected") : L10n.tr("Disconnected")) .font(.caption2.weight(.semibold)) .foregroundStyle(.secondary) } diff --git a/ios/VaultSync/Views/IgnorePatternsView.swift b/ios/VaultSync/Views/IgnorePatternsView.swift index 20353ea..8ebe2a3 100644 --- a/ios/VaultSync/Views/IgnorePatternsView.swift +++ b/ios/VaultSync/Views/IgnorePatternsView.swift @@ -2,7 +2,6 @@ import SwiftUI struct IgnorePatternsView: View { let folderID: String - let folderLabel: String let syncthingManager: SyncthingManager @State private var ignoredPatterns: Set = [] diff --git a/ios/VaultSync/Views/LineDiffView.swift b/ios/VaultSync/Views/LineDiffView.swift index 378fa37..8045609 100644 --- a/ios/VaultSync/Views/LineDiffView.swift +++ b/ios/VaultSync/Views/LineDiffView.swift @@ -22,7 +22,7 @@ struct LineDiffView: View { var body: some View { Group { if isComputing { - ProgressView("Computing diff...") + ProgressView("Computing diff…") .padding() } else { ScrollView(.horizontal, showsIndicators: false) { @@ -46,6 +46,11 @@ struct LineDiffView: View { } } .padding() + // Size the column to its widest line so every row's + // highlight fills the same width (inside a horizontal + // ScrollView, maxWidth: .infinity alone clamps to each + // line's own width, leaving ragged backgrounds). + .fixedSize(horizontal: true, vertical: false) } } } diff --git a/ios/VaultSync/Views/OnboardingView.swift b/ios/VaultSync/Views/OnboardingView.swift index 7c1d512..5a39754 100644 --- a/ios/VaultSync/Views/OnboardingView.swift +++ b/ios/VaultSync/Views/OnboardingView.swift @@ -25,8 +25,8 @@ struct OnboardingView: View { var id: Int { number } } - private let slate = Color(red: 38 / 255, green: 50 / 255, blue: 56 / 255) - private let teal = Color(red: 0 / 255, green: 137 / 255, blue: 123 / 255) + private let slate = Color.vaultSlate + private let teal = Color.vaultTeal private var overviewSteps: [OverviewStep] { [ diff --git a/ios/VaultSync/Views/RelayDiagnosticsView.swift b/ios/VaultSync/Views/RelayDiagnosticsView.swift index 8366cad..2d6aed1 100644 --- a/ios/VaultSync/Views/RelayDiagnosticsView.swift +++ b/ios/VaultSync/Views/RelayDiagnosticsView.swift @@ -28,6 +28,15 @@ struct RelayDiagnosticsView: View { private var relayHealthSection: some View { Section("Relay Backend") { + if subscriptionManager.relayDeliveryConfirmed { + Label(L10n.tr("Cloud Relay is delivering wake-ups"), systemImage: "checkmark.seal.fill") + .foregroundStyle(.green) + .font(.subheadline) + } else if subscriptionManager.relayDeliveryLikelyWorking { + Label(L10n.tr("Cloud Relay looks reachable"), systemImage: "checkmark.circle") + .foregroundStyle(.green) + .font(.subheadline) + } HStack { Label("Health Endpoint", systemImage: "server.rack") Spacer() @@ -117,6 +126,20 @@ struct RelayDiagnosticsView: View { } .accessibilityElement(children: .combine) + HStack { + Label(L10n.tr("Alert Banners"), systemImage: "app.badge") + Spacer() + Text(alertBannerText) + .foregroundStyle(alertBannerColor) + } + .accessibilityElement(children: .combine) + + if subscriptionManager.alertBannerStatus == .denied { + Text(L10n.tr("Alert banners are off at the iOS level. Cloud Relay wake-ups still work — they use silent push, which does not need notification permission.")) + .font(.caption) + .foregroundStyle(.secondary) + } + if let updatedAt = subscriptionManager.apnsRegistrationSnapshot.updatedAt { LabeledContent("Last Update") { Text(updatedAt, style: .relative) @@ -155,8 +178,6 @@ struct RelayDiagnosticsView: View { Button("Open iOS Notification Settings") { openSystemSettings() } - .buttonStyle(.bordered) - .controlSize(.small) } } @@ -291,11 +312,11 @@ struct RelayDiagnosticsView: View { } if !subscriptionManager.hasAPNsToken { - hints.append(L10n.tr("APNs token is missing. Enable notifications for VaultSync and retry APNs registration.")) + hints.append(L10n.tr("APNs token is missing. Retry APNs registration; if it keeps failing, check your internet connection. (Silent push does not require notification banners.)")) } if case .failed = subscriptionManager.apnsRegistrationStatus { - hints.append(L10n.tr("APNs registration failed. Open iOS Settings > Notifications > VaultSync, allow notifications, then retry.")) + hints.append(L10n.tr("APNs registration failed. Check your internet connection and retry registration. Silent push does not require notification banners to be enabled.")) } if let health = subscriptionManager.relayHealthResult, !health.isHealthy { @@ -317,6 +338,24 @@ struct RelayDiagnosticsView: View { return hints } + private var alertBannerText: String { + switch subscriptionManager.alertBannerStatus { + case .allowed: return L10n.tr("Allowed") + case .denied: return L10n.tr("Denied") + case .unknown: return L10n.tr("Unknown") + } + } + + private var alertBannerColor: Color { + switch subscriptionManager.alertBannerStatus { + case .allowed: return .green + case .denied: return .secondary + // "Not determined" is not an error — keep it neutral rather than a + // warning yellow that implies something is wrong. + case .unknown: return .secondary + } + } + private var apnsStatusColor: Color { switch subscriptionManager.apnsRegistrationStatus { case .registered: diff --git a/ios/VaultSync/Views/SettingsView.swift b/ios/VaultSync/Views/SettingsView.swift index c3ac686..2a4cafb 100644 --- a/ios/VaultSync/Views/SettingsView.swift +++ b/ios/VaultSync/Views/SettingsView.swift @@ -10,22 +10,38 @@ struct SettingsView: View { @State private var alertMessage: String? @State private var showAlert = false @State private var showSetupStatus = false + @State private var tipJar = TipJarManager() + @State private var showThankYou = false + @State private var deviceIDCopied = false + @State private var isRestoring = false + @AppStorage(BackgroundSyncService.conflictNotificationsEnabledKey) private var conflictNotificationsEnabled = true @Environment(\.dismiss) private var dismiss var body: some View { NavigationStack { List { cloudRelaySection + supportSection + notificationsSection aboutSection Section("This Device") { if syncthingManager.deviceID.isEmpty { - LabeledContent("Device ID", value: "Not available") + LabeledContent("Device ID", value: L10n.tr("Not available")) } else { Button { UIPasteboard.general.string = syncthingManager.deviceID + UINotificationFeedbackGenerator().notificationOccurred(.success) + deviceIDCopied = true + Task { + try? await Task.sleep(for: .seconds(1.5)) + deviceIDCopied = false + } } label: { - Label("Copy Device ID", systemImage: "doc.on.doc") + Label( + deviceIDCopied ? L10n.tr("Copied") : L10n.tr("Copy Device ID"), + systemImage: deviceIDCopied ? "checkmark.circle" : "doc.on.doc" + ) } } @@ -91,6 +107,17 @@ struct SettingsView: View { ) } } + .onChange(of: tipJar.didContribute) { _, contributed in + if contributed { + showThankYou = true + tipJar.acknowledgeThankYou() + } + } + .alert(L10n.tr("Thank you!"), isPresented: $showThankYou) { + Button("OK") { } + } message: { + Text(L10n.tr("Your contribution means a lot and directly supports VaultSync development. Thank you!")) + } } } @@ -177,8 +204,13 @@ struct SettingsView: View { HStack { Text("Subscribe") Spacer() - Text(product.displayPrice + "/mo") - .foregroundStyle(.secondary) + if subscriptionManager.purchaseInProgress { + ProgressView() + .controlSize(.small) + } else { + Text(subscriptionManager.relayPriceText ?? product.displayPrice) + .foregroundStyle(.secondary) + } } } .disabled(subscriptionManager.purchaseInProgress) @@ -187,11 +219,23 @@ struct SettingsView: View { .foregroundStyle(.secondary) } - Button("Restore Purchases") { + Button { Task { + isRestoring = true await subscriptionManager.restorePurchases() + isRestoring = false + } + } label: { + HStack { + Text("Restore Purchases") + if isRestoring { + Spacer() + ProgressView() + .controlSize(.small) + } } } + .disabled(isRestoring) } NavigationLink { @@ -215,10 +259,16 @@ struct SettingsView: View { } } - // Subscription details (required by App Store Review) + // Subscription details (required by App Store Review). Price comes + // from StoreKit so it is correct per storefront — never hard-coded. VStack(alignment: .leading, spacing: 2) { - Text("Cloud Relay — $0.99/month") - .font(.caption) + if let priceText = subscriptionManager.relayPriceText { + Text(L10n.fmt("Cloud Relay — %@", priceText)) + .font(.caption) + } else { + Text(L10n.tr("Cloud Relay subscription")) + .font(.caption) + } Text("Auto-renews monthly. Cancel anytime in Settings → Subscriptions.") .font(.caption) .foregroundStyle(.secondary) @@ -227,8 +277,96 @@ struct SettingsView: View { } header: { Text("Cloud Relay") } footer: { - Text("Cloud Relay enables instant sync when files change on your server, instead of waiting for the next background refresh.") + Text("When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes.") + } + } + + // MARK: - Notifications Section + + private var notificationsSection: some View { + Section { + Toggle(isOn: $conflictNotificationsEnabled) { + Label(L10n.tr("Conflict Notifications"), systemImage: "exclamationmark.triangle") + } + } header: { + Text(L10n.tr("Notifications")) + } footer: { + Text(L10n.tr("Show a banner when sync conflicts are detected. Turning this off does not affect Cloud Relay or background sync — your vault keeps syncing.")) + } + } + + // MARK: - Support Section + + private var supportSection: some View { + Section { + if tipJar.products.isEmpty { + if tipJar.isLoading { + HStack { + Text(L10n.tr("Loading…")) + .foregroundStyle(.secondary) + Spacer() + ProgressView() + .controlSize(.small) + } + } else { + Text(L10n.tr("Contributions are currently unavailable.")) + .foregroundStyle(.secondary) + } + } else { + ForEach(tipJar.products, id: \.id) { product in + Button { + Task { await tipJar.purchase(product) } + } label: { + HStack { + Label(contributionTitle(for: product), systemImage: contributionSymbol(for: product)) + Spacer() + if tipJar.purchasingProductID == product.id { + ProgressView() + .controlSize(.small) + } else { + Text(product.displayPrice) + .foregroundStyle(.secondary) + } + } + } + .disabled(tipJar.purchasingProductID != nil) + } + } + + if let error = tipJar.errorMessage, !error.isEmpty { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + + if let pending = tipJar.pendingMessage, !pending.isEmpty { + Text(pending) + .font(.caption) + .foregroundStyle(.secondary) + } + } header: { + Text(L10n.tr("Support VaultSync")) + } footer: { + Text(L10n.tr("VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward. It unlocks nothing — VaultSync stays fully functional without it — and you can give as often as you like.")) + } + } + + private func contributionTitle(for product: Product) -> String { + if !product.displayName.isEmpty { + return product.displayName } + switch product.id { + case TipJarManager.smallProductID: + return L10n.tr("Small Contribution") + case TipJarManager.bigProductID: + return L10n.tr("Big Contribution") + default: + return L10n.tr("Contribution") + } + } + + private func contributionSymbol(for product: Product) -> String { + product.id == TipJarManager.bigProductID ? "heart.fill" : "heart" } private var aboutSection: some View { diff --git a/ios/VaultSync/Views/SyncFilterRecommendationSheet.swift b/ios/VaultSync/Views/SyncFilterRecommendationSheet.swift index f6cb52a..ce2cbf0 100644 --- a/ios/VaultSync/Views/SyncFilterRecommendationSheet.swift +++ b/ios/VaultSync/Views/SyncFilterRecommendationSheet.swift @@ -2,7 +2,6 @@ import SwiftUI struct SyncFilterRecommendationSheet: View { let folderID: String - let folderLabel: String let syncthingManager: SyncthingManager @Environment(\.dismiss) private var dismiss diff --git a/ios/VaultSync/Views/SyncIssuesView.swift b/ios/VaultSync/Views/SyncIssuesView.swift index a3df1e3..2e64f06 100644 --- a/ios/VaultSync/Views/SyncIssuesView.swift +++ b/ios/VaultSync/Views/SyncIssuesView.swift @@ -96,7 +96,6 @@ struct SyncIssuesView: View { NavigationLink("Resolve Conflicts") { ConflictListView( folderID: destination.folderID, - conflicts: destination.conflicts, syncthingManager: syncthingManager ) } @@ -149,7 +148,11 @@ struct SyncIssuesView: View { case .pendingShares: anchor = "no-pending-shares-appear" case .conflicts: - anchor = "background-sync-not-working" + // No conflict-resolution section exists in the troubleshooting doc, + // and "Background Sync Not Working" is unrelated. The inline + // "Resolve Conflicts" action is the correct fix path, so don't + // surface a misdirecting link here. + return nil case .staleSync, .backgroundSync: anchor = "background-sync-not-working" } diff --git a/ios/VaultSync/de.lproj/Localizable.strings b/ios/VaultSync/de.lproj/Localizable.strings index e58321a..0f02634 100644 --- a/ios/VaultSync/de.lproj/Localizable.strings +++ b/ios/VaultSync/de.lproj/Localizable.strings @@ -6,7 +6,6 @@ "Active" = "Aktiv"; "Add" = "Hinzufügen"; "Add Device" = "Gerät hinzufügen"; -"Add a device from the main screen using its Syncthing Device ID." = "Füge auf dem Hauptbildschirm ein Gerät mit seiner Syncthing-Geräte-ID hinzu."; "Add a device using its Syncthing Device ID. Find it in the Syncthing web UI under Actions > Show ID." = "Füge ein Gerät mit seiner Syncthing-Geräte-ID hinzu. Du findest sie in der Syncthing-Weboberfläche unter Actions > Show ID."; "Add or Reconnect Device" = "Gerät hinzufügen oder neu verbinden"; "Added line. %@" = "Hinzugefügte Zeile. %@"; @@ -23,7 +22,6 @@ "Background Sync Found No Vaults" = "Hintergrundsynchronisation fand keine Vaults"; "Background Sync Timed Out" = "Zeitüberschreitung bei Hintergrundsynchronisation"; "Background sync: %@" = "Hintergrundsync: %@"; -"Before you start" = "Bevor du beginnst"; "Camera Access Required" = "Kamerazugriff erforderlich"; "Can't find the Obsidian folder?" = "Den Obsidian-Ordner nicht gefunden?"; "Cancel" = "Abbrechen"; @@ -35,18 +33,13 @@ "Check your subscription status in Settings and retry. If this persists, restart VaultSync." = "Prüfe deinen Abostatus in den Einstellungen und versuche es erneut. Wenn das Problem bleibt, starte VaultSync neu."; "Cloud Relay" = "Cloud Relay"; "Cloud Relay active" = "Cloud Relay aktiv"; -"Cloud Relay configured (optional)" = "Cloud Relay konfiguriert (optional)"; -"Cloud Relay enables instant sync when files change on your server, instead of waiting for the next background refresh." = "Cloud Relay aktiviert sofortige Synchronisation bei Dateiänderungen auf deinem Server, statt bis zur nächsten Hintergrundaktualisierung zu warten."; +"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes." = "Ändern sich Dateien auf deinem Server, weckt ein stiller Push VaultSync im selben Moment — Synchronisation wirkt sofort, ohne die App zu öffnen. Das Relay sendet nur ein Wecksignal und sieht deine Notizen nie."; "Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "Cloud Relay ist derzeit nicht abonniert. Push-ausgelöste Aufweckvorgänge sind deaktiviert, bis das Abo aktiv ist."; "Cloud Relay is not subscribed. Start a subscription first." = "Cloud Relay ist nicht abonniert. Starte zuerst ein Abo."; -"Cloud Relay is off." = "Cloud Relay ist aus."; "Cloud Relay provisioning could not contact the relay backend." = "Cloud Relay-Provisioning konnte das Relay-Backend nicht erreichen."; "Cloud Relay provisioning did not complete." = "Cloud Relay-Provisioning wurde nicht abgeschlossen."; "Cloud Relay provisioning is temporarily rate limited." = "Cloud Relay-Provisioning ist vorübergehend rate-limitiert."; -"Cloud Relay — $0.99/month" = "Cloud Relay — 0,99 €/Monat"; -"Complete" = "Abgeschlossen"; "Completion" = "Fortschritt"; -"Computing diff..." = "Diff wird berechnet..."; "Configuration Error" = "Konfigurationsfehler"; "Conflict Resolution Failed" = "Konfliktauflösung fehlgeschlagen"; "Conflict Resolved" = "Konflikt gelöst"; @@ -55,11 +48,8 @@ "Conflicts mean multiple versions exist and need a manual decision." = "Konflikte bedeuten, dass mehrere Versionen existieren und manuell entschieden werden muss."; "Connect Obsidian to accept shares" = "Obsidian verbinden, um Freigaben anzunehmen"; "Connect Obsidian Folder" = "Obsidian-Ordner verbinden"; -"Connect the Obsidian folder from the main screen." = "Verbinde den Obsidian-Ordner über den Hauptbildschirm."; "Connect to Obsidian first" = "Zuerst mit Obsidian verbinden"; "Connected" = "Verbunden"; -"Connected. %@" = "Verbunden. %@"; -"Connected. Waiting for vault folders to appear." = "Verbunden. Warte darauf, dass Vault-Ordner erscheinen."; "Connection to peer was closed." = "Die Verbindung zum Peer wurde geschlossen."; "Copy Device ID" = "Geräte-ID kopieren"; "Could Not Accept Share" = "Freigabe konnte nicht angenommen werden"; @@ -70,19 +60,15 @@ "Could not read files.\n\n%@\n%@" = "Dateien konnten nicht gelesen werden.\n\n%@\n%@"; "Could not read original file.\n\n%@" = "Originaldatei konnte nicht gelesen werden.\n\n%@"; "Create a vault in Obsidian first. VaultSync will detect it automatically." = "Erstelle zuerst einen Vault in Obsidian. VaultSync erkennt ihn automatisch."; -"Desktop device paired" = "Desktop-Gerät gekoppelt"; "Device" = "Gerät"; "Device ID" = "Geräte-ID"; -"Device ID is available and Syncthing is running." = "Die Geräte-ID ist verfügbar und Syncthing läuft."; "%d of %d devices connected" = "%d von %d Geräten verbunden"; "Device name" = "Gerätename"; "Devices" = "Geräte"; "Differences" = "Unterschiede"; "Discards the version from the other device." = "Verwirft die Version vom anderen Gerät."; "Disconnected" = "Getrennt"; -"Discovery" = "Erkennung"; "Done" = "Fertig"; -"Download Syncthing" = "Syncthing herunterladen"; "Empty line" = "Leere Zeile"; "Enable notifications for VaultSync in iOS Settings -> Notifications -> VaultSync, then restart the app." = "Aktiviere Mitteilungen für VaultSync unter iOS Einstellungen -> Mitteilungen -> VaultSync und starte die App dann neu."; "Error" = "Fehler"; @@ -92,8 +78,6 @@ "File Read Failed" = "Datei konnte nicht gelesen werden"; "File synced in %@" = "Datei in %@ synchronisiert"; "Files are being synchronized with peers." = "Dateien werden mit Peers synchronisiert."; -"Finish the required steps so sync stays reliable." = "Schließe die erforderlichen Schritte ab, damit die Synchronisation zuverlässig bleibt."; -"First share detected" = "Erste Freigabe erkannt"; "Folder Not Configured" = "Ordner nicht konfiguriert"; "Folder Path Missing" = "Ordnerpfad fehlt"; "Folder Permission Error" = "Ordnerberechtigungsfehler"; @@ -103,21 +87,15 @@ "Folder is currently in an error state." = "Der Ordner befindet sich derzeit in einem Fehlerzustand."; "Folder reached idle state after syncing." = "Der Ordner hat nach der Synchronisation den Leerlaufzustand erreicht."; "Folder reported an error." = "Der Ordner hat einen Fehler gemeldet."; -"From desktop Syncthing, share one vault to this iPhone Device ID." = "Teile in Syncthing auf dem Desktop einen Vault mit dieser iPhone-Geräte-ID."; -"Global Discovery" = "Globale Erkennung"; "Global Files" = "Globale Dateien"; "Health Endpoint" = "Health-Endpunkt"; "Healthy" = "Gesund"; -"How pairing works" = "So funktioniert das Koppeln"; "How to fix: %@" = "So behebst du es: %@"; "Idle" = "Leerlauf"; -"If syncing has not started, reshare a vault from desktop Syncthing." = "Wenn die Synchronisation nicht gestartet hat, teile einen Vault erneut aus Syncthing auf dem Desktop."; "Ignore for Now" = "Vorerst ignorieren"; "Ignored shares (%d)" = "Ignorierte Freigaben (%d)"; "In progress" = "Läuft"; -"In the picker, choose \"On My iPhone\" -> \"Obsidian\", then tap Open." = "Wähle im Picker „Auf meinem iPhone“ -> „Obsidian“ und tippe dann auf „Öffnen“."; "Install Obsidian from the App Store and open it once. The folder appears after Obsidian creates it." = "Installiere Obsidian aus dem App Store und öffne es einmal. Der Ordner erscheint, nachdem Obsidian ihn erstellt hat."; -"Instant sync via Cloud Relay is active." = "Sofortsynchronisation über Cloud Relay ist aktiv."; "Invalid Input" = "Ungültige Eingabe"; "Invalid folder name: '%@'" = "Ungültiger Ordnername: '%@'"; "Keep Both" = "Beide behalten"; @@ -128,7 +106,6 @@ "Keep both versions" = "Beide Versionen behalten"; "Keep other device version" = "Version des anderen Geräts behalten"; "Keep the app open for a moment and retry. If this persists, restart VaultSync." = "Lass die App einen Moment geöffnet und versuche es erneut. Wenn das Problem bleibt, starte VaultSync neu."; -"Keep the app open for a moment. If this persists, restart VaultSync." = "Lass die App einen Moment geöffnet. Wenn das Problem bleibt, starte VaultSync neu."; "Keep this device version" = "Version dieses Geräts behalten"; "Keeps your local file and renames the other device's file." = "Behält deine lokale Datei und benennt die Datei des anderen Geräts um."; "Last Check" = "Letzte Prüfung"; @@ -139,15 +116,11 @@ "Last Update" = "Letzte Aktualisierung"; "Last successful sync was about %d %@ ago." = "Die letzte erfolgreiche Synchronisation war vor etwa %d %@."; "Last successful sync was more than %d %@ ago." = "Die letzte erfolgreiche Synchronisation ist mehr als %d %@ her."; -"Last sync:" = "Letzte Synchronisation:"; +"Last sync: %@" = "Letzte Synchronisation: %@"; "Latency" = "Latenz"; "Learn how to fix" = "So behebst du es"; -"Let's Get Started" = "Los geht's"; -"Loading files..." = "Dateien werden geladen..."; "Loading…" = "Wird geladen…"; -"Local Discovery" = "Lokale Erkennung"; "Local Files" = "Lokale Dateien"; -"Local discovery finds devices on your WiFi network. Global discovery uses Syncthing's servers to find devices anywhere." = "Die lokale Erkennung findet Geräte in deinem WLAN. Die globale Erkennung nutzt die Server von Syncthing, um Geräte überall zu finden."; "Log" = "Protokoll"; "Manage Subscription" = "Abo verwalten"; "Name" = "Name"; @@ -159,10 +132,8 @@ "No Syncthing peers available yet." = "Noch keine Syncthing-Peers verfügbar."; "No action needed." = "Keine Aktion erforderlich."; "No active pending shares" = "Keine aktiven ausstehenden Freigaben"; -"No desktop or laptop Syncthing device configured yet." = "Noch kein Syncthing-Gerät für Desktop oder Laptop konfiguriert."; "No devices configured" = "Keine Geräte konfiguriert"; "No devices connected" = "Keine Geräte verbunden"; -"No folder share from your desktop has been detected yet." = "Es wurde noch keine Ordnerfreigabe von deinem Desktop erkannt."; "No folders syncing yet" = "Noch keine Ordner in Synchronisation"; "No home server devices available for relay provisioning." = "Keine Home-Server-Geräte für Relay-Provisioning verfügbar."; "No immediate relay problems detected." = "Keine unmittelbaren Relay-Probleme erkannt."; @@ -176,16 +147,12 @@ "Not attempted" = "Nicht versucht"; "Not checked" = "Nicht geprüft"; "Not shared" = "Nicht geteilt"; -"Notifications are disabled for VaultSync. Enable them in iOS Settings > Notifications > VaultSync, then retry APNs registration." = "Mitteilungen sind für VaultSync deaktiviert. Aktiviere sie in iOS Einstellungen > Mitteilungen > VaultSync und versuche die APNs-Registrierung erneut."; "OK" = "OK"; "Obsidian Folder Connection Failed" = "Verbindung zum Obsidian-Ordner fehlgeschlagen"; "Obsidian Vaults" = "Obsidian-Vaults"; "Obsidian access expired" = "Obsidian-Zugriff abgelaufen"; -"Obsidian connected" = "Obsidian verbunden"; "Obsidian directory not accessible." = "Auf das Obsidian-Verzeichnis kann nicht zugegriffen werden."; "Obsidian folder not connected" = "Obsidian-Ordner nicht verbunden"; -"Offline" = "Offline"; -"Open Pending Shares in VaultSync and accept one to start syncing." = "Öffne ausstehende Freigaben in VaultSync und nimm eine an, um die Synchronisation zu starten."; "Open Relay Diagnostics" = "Relay-Diagnose öffnen"; "Open Settings" = "Einstellungen öffnen"; "Open VaultSync" = "VaultSync öffnen"; @@ -249,7 +216,6 @@ "Rename Failed" = "Umbenennen fehlgeschlagen"; "Renews" = "Verlängert sich"; "Requesting camera access…" = "Kamerazugriff wird angefordert…"; -"Required" = "Erforderlich"; "Rescan Failed" = "Erneuter Scan fehlgeschlagen"; "Rescan Failed Vaults" = "Fehlgeschlagene Vaults erneut scannen"; "Rescan Vault" = "Vault erneut scannen"; @@ -258,30 +224,22 @@ "Resolve Conflicts" = "Konflikte lösen"; "Restore Purchases" = "Käufe wiederherstellen"; "Restore Share" = "Freigabe wiederherstellen"; -"%d/%d required" = "%d/%d erforderlich"; "A conflict happens when a file is edited on two devices at the same time. Syncthing saves both versions to prevent data loss." = "Ein Konflikt entsteht, wenn eine Datei gleichzeitig auf zwei Geräten bearbeitet wird. Syncthing speichert beide Versionen, um Datenverlust zu vermeiden."; "APNs Registration" = "APNs-Registrierung"; "APNs Token" = "APNs-Token"; -"APNs registration failed. Open iOS Settings > Notifications > VaultSync, allow notifications, then retry." = "Die APNs-Registrierung ist fehlgeschlagen. Öffne iOS Einstellungen > Mitteilungen > VaultSync, erlaube Mitteilungen und versuche es dann erneut."; -"APNs token is missing. Enable notifications for VaultSync and retry APNs registration." = "Der APNs-Token fehlt. Aktiviere Mitteilungen für VaultSync und versuche die APNs-Registrierung erneut."; "Context: %@ · %@" = "Kontext: %@ · %@"; -"Discovery Update Failed" = "Aktualisierung der Erkennung fehlgeschlagen"; "In the picker, choose \"On My iPhone\" → \"Obsidian\", then tap Open." = "Wähle im Picker „Auf meinem iPhone“ → „Obsidian“ und tippe dann auf „Öffnen“."; "Missing" = "Fehlt"; "Present" = "Vorhanden"; "Purchase Failed" = "Kauf fehlgeschlagen"; "Rescan All Vaults" = "Alle Vaults erneut scannen"; -"Some devices are not provisioned. Retry provisioning after APNs and subscription checks are green." = "Einige Geräte sind nicht provisioniert. Versuche das Provisioning erneut, sobald APNs und Abo-Prüfungen grün sind."; "Sync Conflicts" = "Sync-Konflikte"; -"This timestamp is updated when VaultSync receives a silent push from Cloud Relay." = "Dieser Zeitstempel wird aktualisiert, wenn VaultSync einen stillen Push von Cloud Relay erhält."; "XXXXXXX-XXXXXXX-..." = "XXXXXXX-XXXXXXX-..."; -"e.g. My Laptop" = "z. B. Mein Laptop"; "Retry APNs Registration" = "APNs-Registrierung erneut versuchen"; "Retry Accept" = "Annahme erneut versuchen"; "Retry Provisioning" = "Provisioning erneut versuchen"; "Retry provisioning from Settings. If this persists, verify subscription status." = "Versuche das Provisioning über die Einstellungen erneut. Wenn das Problem bleibt, prüfe den Abostatus."; "Retry the action. If it keeps failing, restart the app and check Settings diagnostics." = "Versuche die Aktion erneut. Wenn sie weiter fehlschlägt, starte die App neu und prüfe die Diagnose in den Einstellungen."; -"Review or complete the initial setup checklist." = "Prüfe oder vervollständige die anfängliche Einrichtungs-Checkliste."; "Review the affected device/folder setup in the app and retry." = "Prüfe die betroffene Geräte-/Ordnerkonfiguration in der App und versuche es erneut."; "Review the value and try again." = "Prüfe den Wert und versuche es erneut."; "Run Foreground Rescan" = "Erneuten Scan im Vordergrund ausführen"; @@ -290,10 +248,6 @@ "Scanning completed in %@" = "Scan in %@ abgeschlossen"; "Scanning started in %@" = "Scan in %@ gestartet"; "Settings" = "Einstellungen"; -"Settings Error" = "Einstellungsfehler"; -"Setup Checklist" = "Einrichtungs-Checkliste"; -"Setup Guide" = "Einrichtungsanleitung"; -"Setup checklist progress" = "Fortschritt der Einrichtungs-Checkliste"; "Share a folder from your desktop Syncthing — it will be accepted automatically." = "Teile einen Ordner aus Syncthing auf deinem Desktop — er wird automatisch angenommen."; "Share requests are shown below, but Accept and Retry are disabled until your Obsidian folder is connected." = "Freigabeanfragen werden unten angezeigt, aber Annehmen und Erneut versuchen sind deaktiviert, bis dein Obsidian-Ordner verbunden ist."; "Shared" = "Geteilt"; @@ -301,7 +255,6 @@ "Shared by an unknown device" = "Geteilt von einem unbekannten Gerät"; "Shared by: %@" = "Geteilt von: %@"; "Show Line-by-Line Diff" = "Zeilenweisen Diff anzeigen"; -"Shows whether Syncthing is currently active." = "Zeigt an, ob Syncthing derzeit aktiv ist."; "Shows whether this Syncthing device is currently reachable." = "Zeigt an, ob dieses Syncthing-Gerät derzeit erreichbar ist."; "Silent Push" = "Stiller Push"; "Some devices are not provisioned. Retry provisioning after APNs and subscription checks are green." = "Einige Geräte sind nicht provisioniert. Versuche das Provisioning erneut, nachdem APNs und Abo-Prüfungen grün sind."; @@ -322,15 +275,13 @@ "Sync completed in %@" = "Synchronisation in %@ abgeschlossen"; "Sync error in %@" = "Synchronisationsfehler in %@"; "Sync started in %@" = "Synchronisation in %@ gestartet"; -"Sync your Obsidian vaults privately with Syncthing — no cloud required." = "Synchronisiere deine Obsidian-Vaults privat mit Syncthing — ganz ohne Cloud."; "Synchronizing your Obsidian vault..." = "Dein Obsidian-Vault wird synchronisiert..."; -"Syncing" = "Synchronisiert"; +"Syncing" = "Wird synchronisiert"; "Syncing Vault" = "Vault wird synchronisiert"; "Syncing…" = "Synchronisiert…"; "Reconnecting…" = "Verbinde…"; "Restoring connection to 1 device" = "Verbindung zu 1 Gerät wird wiederhergestellt"; "Restoring connection to %d devices" = "Verbindung zu %d Geräten wird wiederhergestellt"; -"Syncthing engine started" = "Syncthing-Engine gestartet"; "Syncthing is scanning local changes." = "Syncthing scannt lokale Änderungen."; "Syncthing no longer has this folder configured." = "Syncthing hat diesen Ordner nicht mehr konfiguriert."; "Terms of Use" = "Nutzungsbedingungen"; @@ -362,7 +313,6 @@ "Unreachable" = "Nicht erreichbar"; "Vault" = "Vault"; "VaultSync" = "VaultSync"; -"VaultSync Setup" = "VaultSync-Einrichtung"; "VaultSync can no longer read your Obsidian directory. Reconnect the folder to restore sync access." = "VaultSync kann dein Obsidian-Verzeichnis nicht mehr lesen. Verbinde den Ordner erneut, um den Synchronisationszugriff wiederherzustellen."; "VaultSync can no longer resolve the saved Obsidian folder permission. Reconnect the Obsidian directory to continue syncing." = "VaultSync kann die gespeicherte Berechtigung für den Obsidian-Ordner nicht mehr auflösen. Verbinde das Obsidian-Verzeichnis erneut, um weiter zu synchronisieren."; "VaultSync cannot access the saved Obsidian folder anymore. Reconnect the Obsidian directory to continue syncing." = "VaultSync kann nicht mehr auf den gespeicherten Obsidian-Ordner zugreifen. Verbinde das Obsidian-Verzeichnis erneut, um weiter zu synchronisieren."; @@ -373,46 +323,20 @@ "VaultSync could not reach the Cloud Relay service." = "VaultSync konnte den Cloud Relay-Dienst nicht erreichen."; "VaultSync could not restore bookmark access for the Obsidian folder during a background run." = "VaultSync konnte den Bookmark-Zugriff auf den Obsidian-Ordner während eines Hintergrundlaufs nicht wiederherstellen."; "VaultSync could not verify this request." = "VaultSync konnte diese Anfrage nicht verifizieren."; -"VaultSync does not have access to your Obsidian directory." = "VaultSync hat keinen Zugriff auf dein Obsidian-Verzeichnis."; "VaultSync does not have the required permission for this action." = "VaultSync hat nicht die erforderliche Berechtigung für diese Aktion."; "VaultSync found a configuration problem." = "VaultSync hat ein Konfigurationsproblem erkannt."; -"VaultSync is still starting Syncthing." = "VaultSync startet Syncthing noch."; "VaultSync needs camera access to scan Syncthing Device ID QR codes. Please enable it in Settings." = "VaultSync benötigt Kamerazugriff, um Syncthing-Geräte-QR-Codes zu scannen. Bitte aktiviere ihn in den Einstellungen."; "VaultSync needs one-time access to your Obsidian folder before it can accept shares." = "VaultSync benötigt einmaligen Zugriff auf deinen Obsidian-Ordner, bevor Freigaben angenommen werden können."; "VaultSync reported an unexpected error." = "VaultSync hat einen unerwarteten Fehler gemeldet."; "Wait a moment and retry." = "Warte einen Moment und versuche es erneut."; -"Welcome to VaultSync" = "Willkommen bei VaultSync"; "What is a conflict?" = "Was ist ein Konflikt?"; "day" = "Tag"; "days" = "Tage"; -"device configured" = "Gerät konfiguriert"; -"devices configured" = "Geräte konfiguriert"; "e.g. My Laptop" = "z. B. Mein Laptop"; "hour" = "Stunde"; "hours" = "Stunden"; -"pending share found" = "ausstehende Freigabe gefunden"; -"pending shares found" = "ausstehende Freigaben gefunden"; -"shared folder active" = "geteilter Ordner aktiv"; -"shared folders active" = "geteilte Ordner aktiv"; -"vault detected" = "Vault erkannt"; -"vaults detected" = "Vaults erkannt"; -"Both devices exchange Device IDs" = "Beide Geräte tauschen Geräte-IDs aus"; -"Both devices should be on the same WiFi, or have global discovery enabled in Syncthing." = "Beide Geräte sollten im selben WLAN sein oder die globale Erkennung in Syncthing aktiviert haben."; -"End-to-end encrypted" = "Ende-zu-Ende-verschlüsselt"; -"Fast Markdown sync" = "Schneller Markdown-Sync"; -"Install Obsidian on this iPhone and on your desktop." = "Installiere Obsidian auf diesem iPhone und auf deinem Desktop."; -"Install and run Syncthing on the computer you sync with." = "Installiere und starte Syncthing auf dem Computer, mit dem du synchronisierst."; "No Activity Yet" = "Noch keine Aktivität"; -"No cloud required" = "Keine Cloud erforderlich"; -"Obsidian on both devices" = "Obsidian auf beiden Geräten"; -"Running" = "Läuft"; -"Same network or global discovery" = "Gleiches Netzwerk oder globale Erkennung"; "Sync engine running" = "Sync-Engine läuft"; -"Sync engine stopped" = "Sync-Engine gestoppt"; -"Syncthing on your desktop" = "Syncthing auf deinem Desktop"; -"You can enable it later in Settings for instant push-based sync." = "Du kannst es später in den Einstellungen für sofortige push-basierte Synchronisation aktivieren."; -"Your desktop shares a vault folder" = "Dein Desktop teilt einen Vault-Ordner"; -"VaultSync keeps everything in sync" = "VaultSync hält alles synchron"; "onboarding.welcome.title" = "Deine Obsidian-Notizen. Privat synchronisiert."; "onboarding.welcome.subtitle" = "Halte deinen Vault zwischen deinen eigenen Geräten synchron — ohne fremden Cloud-Speicher."; "onboarding.welcome.benefit.private" = "Privat zwischen deinen Geräten"; @@ -429,7 +353,7 @@ "onboarding.overview.step3.description" = "Teile deinen Obsidian Vault in Syncthing von deinem Computer mit diesem iPhone."; "onboarding.overview.step4.title" = "Sync-Status verfolgen"; "onboarding.overview.step4.description" = "VaultSync zeigt aktive Vaults, Probleme und Sync-Fortschritt auf dem Startbildschirm."; -"onboarding.overview.cloudRelay" = "Cloud Relay kann später in den Einstellungen für schnellere Updates im Hintergrund aktiviert werden."; +"onboarding.overview.cloudRelay" = "Aktiviere Cloud Relay später in den Einstellungen, damit Änderungen im selben Moment ankommen — ganz ohne die App zu öffnen."; "onboarding.cta.openVaultSync" = "VaultSync öffnen"; "onboarding.accessibility.page" = "Seite %d von 2"; "onboarding.accessibility.continueHint" = "Öffnet eine kurze Setup-Übersicht."; @@ -478,7 +402,6 @@ "Always skip on this iPhone" = "Auf diesem iPhone immer überspringen"; "More actions" = "Weitere Aktionen"; "Skipping enabled" = "Überspringen aktiviert"; -"'%@' will no longer sync to this iPhone. You can undo this in Sync Filters." = "„%@“ wird nicht mehr auf dieses iPhone synchronisiert. Du kannst das in den Sync-Filtern rückgängig machen."; "'%@' and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters." = "„%@“ und seine Konflikt-Kopien werden nicht mehr auf dieses iPhone synchronisiert. Du kannst das in den Sync-Filtern rückgängig machen."; "1 existing conflict copy was removed." = "1 vorhandene Konflikt-Kopie wurde entfernt."; "%d existing conflict copies were removed." = "%d vorhandene Konflikt-Kopien wurden entfernt."; @@ -501,7 +424,6 @@ "Cache of Obsidian Copilot, regenerated automatically." = "Cache von Obsidian Copilot, wird automatisch neu erzeugt."; "Obsidian app cache" = "Obsidian-App-Cache"; "Auto-regenerated Obsidian internal cache." = "Automatisch regenerierter interner Obsidian-Cache."; -"Node modules" = "Node-Module"; // Sync Issue titles & messages "1 Vault Has Sync Errors" = "1 Vault hat Synchronisationsfehler"; @@ -513,3 +435,98 @@ "%d Pending Shares Need Attention" = "%d ausstehende Freigaben benötigen Aufmerksamkeit"; "1 Conflict Needs Resolution" = "1 Konflikt muss gelöst werden"; "%d Conflicts Need Resolution" = "%d Konflikte müssen gelöst werden"; + +/* Issue #10 — conflict notification body */ +"1 file has a sync conflict. Open VaultSync to resolve it." = "1 Datei hat einen Sync-Konflikt. Öffne VaultSync, um ihn zu lösen."; +"%d files have sync conflicts. Open VaultSync to resolve them." = "%d Dateien haben Sync-Konflikte. Öffne VaultSync, um sie zu lösen."; + +/* Issue #10 — conflict-notifications toggle (Settings) */ +"Notifications" = "Benachrichtigungen"; +"Conflict Notifications" = "Konflikt-Benachrichtigungen"; +"Show a banner when sync conflicts are detected. Turning this off does not affect Cloud Relay or background sync — your vault keeps syncing." = "Zeigt ein Banner an, wenn Sync-Konflikte erkannt werden. Das Ausschalten betrifft weder Cloud Relay noch die Hintergrundsynchronisation — dein Vault synchronisiert weiter."; + +/* Issue #10 — relay/alert decoupling (Relay Diagnostics) */ +"Alert Banners" = "Hinweis-Banner"; +"Allowed" = "Erlaubt"; +"Denied" = "Verweigert"; +"Cloud Relay is delivering wake-ups" = "Cloud Relay liefert Weckrufe"; +"Alert banners are off at the iOS level. Cloud Relay wake-ups still work — they use silent push, which does not need notification permission." = "Hinweis-Banner sind auf iOS-Ebene aus. Cloud-Relay-Weckrufe funktionieren weiterhin — sie nutzen Silent Push, der keine Benachrichtigungsberechtigung benötigt."; +"APNs token is missing. Retry APNs registration; if it keeps failing, check your internet connection. (Silent push does not require notification banners.)" = "APNs-Token fehlt. Wiederhole die APNs-Registrierung; falls es weiter fehlschlägt, prüfe deine Internetverbindung. (Silent Push benötigt keine Hinweis-Banner.)"; +"APNs registration failed. Check your internet connection and retry registration. Silent push does not require notification banners to be enabled." = "APNs-Registrierung fehlgeschlagen. Prüfe deine Internetverbindung und wiederhole die Registrierung. Silent Push benötigt keine aktivierten Hinweis-Banner."; + +/* Issue #10 — background-sync reliability (error-settled outcome) */ +"Background sync settled with at least one folder in an error state." = "Hintergrundsynchronisation beendet — mindestens ein Ordner ist im Fehlerzustand."; + +/* Issue #10 — relay reachable (vs delivering) */ +"Cloud Relay looks reachable" = "Cloud Relay scheint erreichbar"; + +/* Subscription period units */ +"month" = "Monat"; +"months" = "Monate"; +"week" = "Woche"; +"weeks" = "Wochen"; +"year" = "Jahr"; +"years" = "Jahre"; +"%@ / %@" = "%@ / %@"; +"%1$@ / %2$d %3$@" = "%1$@ / %2$d %3$@"; +/* Contributions / tip jar */ +"Support VaultSync" = "VaultSync unterstützen"; +"Small Contribution" = "Kleine Unterstützung"; +"Big Contribution" = "Große Unterstützung"; +"Contribution" = "Unterstützung"; +"Contribution Failed" = "Unterstützung fehlgeschlagen"; +"Contributions are currently unavailable." = "Unterstützungen sind derzeit nicht verfügbar."; +"Thank you!" = "Danke!"; +"Your contribution means a lot and directly supports VaultSync development. Thank you!" = "Deine Unterstützung bedeutet viel und fließt direkt in die Entwicklung von VaultSync. Danke!"; +"VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward. It unlocks nothing — VaultSync stays fully functional without it — and you can give as often as you like." = "VaultSync ist eine unabhängige Open-Source-App (MPL-2.0). Eine einmalige Unterstützung hält sie unabhängig, werbefrei und in Entwicklung. Sie schaltet nichts frei — VaultSync bleibt auch ohne sie voll funktionsfähig — und du kannst so oft geben, wie du möchtest."; +/* Cloud Relay price (localized via StoreKit) */ +"Cloud Relay — %@" = "Cloud Relay — %@"; +"Cloud Relay subscription" = "Cloud-Relay-Abo"; + +/* Issue #10 pre-merge polish — added missing localization keys */ +"Not available" = "Nicht verfügbar"; +"Copied" = "Kopiert"; +"Rescanning…" = "Wird neu gescannt…"; +"Cannot Load Files" = "Dateien können nicht geladen werden"; +"Remove this device?" = "Dieses Gerät entfernen?"; +"Double-tap to share this vault with this device." = "Doppeltippen, um diesen Vault mit diesem Gerät zu teilen."; +"Double-tap to stop sharing this vault with this device." = "Doppeltippen, um das Teilen dieses Vaults mit diesem Gerät zu beenden."; +"All conflicts resolved" = "Alle Konflikte gelöst"; +"Loading files…" = "Dateien werden geladen…"; +"Computing diff…" = "Unterschiede werden berechnet…"; +"Other Device" = "Anderes Gerät"; +"Other Device (%@)" = "Anderes Gerät (%@)"; +"Added lines come from the other device; removed lines are your version on this device." = "Hinzugefügte Zeilen stammen vom anderen Gerät; entfernte Zeilen sind deine Version auf diesem Gerät."; +"(empty or unreadable)" = "(leer oder nicht lesbar)"; +"a new name" = "einen neuen Namen"; +"Both versions were kept.\n\nYour local version remains as '%@'.\nThe other device's version was renamed to '%@'." = "Beide Versionen wurden behalten.\n\nDeine lokale Version bleibt als '%@'.\nDie Version des anderen Geräts wurde in '%@' umbenannt."; +"%d conflicts" = "%d Konflikte"; +"Your contribution is pending approval." = "Dein Beitrag wartet auf Freigabe."; +"Could not accept share '%@'.\n\n%@" = "Freigabe '%@' konnte nicht angenommen werden.\n\n%@"; +"iOS did not provide a push token required for instant sync." = "iOS hat kein Push-Token bereitgestellt, das für Sofort-Sync erforderlich ist."; +"%@ (Trigger: %@)" = "%@ (Auslöser: %@)"; +"1 additional file synced in %@" = "1 weitere Datei in %@ synchronisiert"; +"%d additional files synced in %@" = "%d weitere Dateien in %@ synchronisiert"; +"%@ connected" = "%@ verbunden"; +"%@ disconnected" = "%@ getrennt"; +"%d%% complete" = "%d %% abgeschlossen"; +"Background sync completed and reached idle." = "Hintergrundsynchronisation abgeschlossen und Leerlauf erreicht."; +"Background sync did not reach an idle folder state before the iOS deadline." = "Hintergrundsynchronisation hat vor dem iOS-Zeitlimit keinen Leerlauf-Zustand der Ordner erreicht."; +"Background sync ended with an unexpected failure." = "Hintergrundsynchronisation endete mit einem unerwarteten Fehler."; +"Background sync ran, but folders were already idle." = "Hintergrundsynchronisation lief, aber die Ordner waren bereits im Leerlauf."; +"Forced silent-push restart failed." = "Erzwungener Neustart per Silent Push fehlgeschlagen."; +"No Syncthing folders were available to sync in the background." = "Es waren keine Syncthing-Ordner für die Hintergrundsynchronisation verfügbar."; +"No folders were available after forced silent-push restart." = "Nach dem erzwungenen Silent-Push-Neustart waren keine Ordner verfügbar."; +"No folders were available for background sync." = "Für die Hintergrundsynchronisation waren keine Ordner verfügbar."; +"No security-scoped bookmark access was available." = "Es war kein Security-Scoped-Bookmark-Zugriff verfügbar."; +"Accept or create a shared vault before relying on background sync." = "Akzeptiere oder erstelle einen geteilten Vault, bevor du dich auf Hintergrund-Sync verlässt."; +"Retry from the app and review relay/background diagnostics in Settings." = "Versuche es erneut in der App und prüfe die Relay-/Hintergrund-Diagnose in den Einstellungen."; +"Silent push restarted Syncthing, but no real sync progress was observed before the app returned to idle." = "Silent Push hat Syncthing neu gestartet, aber vor der Rückkehr in den Leerlauf wurde kein echter Sync-Fortschritt beobachtet."; +"Sync did not reach idle before %ds deadline." = "Sync hat den Leerlauf nicht vor dem %ds-Zeitlimit erreicht."; +"Relay %@ failed with HTTP %d." = "Relay %@ ist mit HTTP %d fehlgeschlagen."; +"Relay %@ is rate limited (HTTP 429)." = "Relay %@ ist rate-limitiert (HTTP 429)."; +"Relay %@ network error: %@" = "Relay %@ Netzwerkfehler: %@"; +"Relay %@ returned a non-HTTP response." = "Relay %@ hat eine Nicht-HTTP-Antwort zurückgegeben."; +"Relay %@ unauthorized (HTTP %d)." = "Relay %@ nicht autorisiert (HTTP %d)."; +"Unauthorized request." = "Nicht autorisierte Anfrage."; +"relay network: %@" = "Relay-Netzwerk: %@"; diff --git a/ios/VaultSync/en.lproj/Localizable.strings b/ios/VaultSync/en.lproj/Localizable.strings index 45a7d9f..9de2923 100644 --- a/ios/VaultSync/en.lproj/Localizable.strings +++ b/ios/VaultSync/en.lproj/Localizable.strings @@ -6,7 +6,6 @@ "Active" = "Active"; "Add" = "Add"; "Add Device" = "Add Device"; -"Add a device from the main screen using its Syncthing Device ID." = "Add a device from the main screen using its Syncthing Device ID."; "Add a device using its Syncthing Device ID. Find it in the Syncthing web UI under Actions > Show ID." = "Add a device using its Syncthing Device ID. Find it in the Syncthing web UI under Actions > Show ID."; "Add or Reconnect Device" = "Add or Reconnect Device"; "Added line. %@" = "Added line. %@"; @@ -23,7 +22,6 @@ "Background Sync Found No Vaults" = "Background Sync Found No Vaults"; "Background Sync Timed Out" = "Background Sync Timed Out"; "Background sync: %@" = "Background sync: %@"; -"Before you start" = "Before you start"; "Camera Access Required" = "Camera Access Required"; "Can't find the Obsidian folder?" = "Can't find the Obsidian folder?"; "Cancel" = "Cancel"; @@ -35,18 +33,13 @@ "Check your subscription status in Settings and retry. If this persists, restart VaultSync." = "Check your subscription status in Settings and retry. If this persists, restart VaultSync."; "Cloud Relay" = "Cloud Relay"; "Cloud Relay active" = "Cloud Relay active"; -"Cloud Relay configured (optional)" = "Cloud Relay configured (optional)"; -"Cloud Relay enables instant sync when files change on your server, instead of waiting for the next background refresh." = "Cloud Relay enables instant sync when files change on your server, instead of waiting for the next background refresh."; +"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes." = "When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes."; "Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active."; "Cloud Relay is not subscribed. Start a subscription first." = "Cloud Relay is not subscribed. Start a subscription first."; -"Cloud Relay is off." = "Cloud Relay is off."; "Cloud Relay provisioning could not contact the relay backend." = "Cloud Relay provisioning could not contact the relay backend."; "Cloud Relay provisioning did not complete." = "Cloud Relay provisioning did not complete."; "Cloud Relay provisioning is temporarily rate limited." = "Cloud Relay provisioning is temporarily rate limited."; -"Cloud Relay — $0.99/month" = "Cloud Relay — $0.99/month"; -"Complete" = "Complete"; "Completion" = "Completion"; -"Computing diff..." = "Computing diff..."; "Configuration Error" = "Configuration Error"; "Conflict Resolution Failed" = "Conflict Resolution Failed"; "Conflict Resolved" = "Conflict Resolved"; @@ -55,11 +48,8 @@ "Conflicts mean multiple versions exist and need a manual decision." = "Conflicts mean multiple versions exist and need a manual decision."; "Connect Obsidian to accept shares" = "Connect Obsidian to accept shares"; "Connect Obsidian Folder" = "Connect Obsidian Folder"; -"Connect the Obsidian folder from the main screen." = "Connect the Obsidian folder from the main screen."; "Connect to Obsidian first" = "Connect to Obsidian first"; "Connected" = "Connected"; -"Connected. %@" = "Connected. %@"; -"Connected. Waiting for vault folders to appear." = "Connected. Waiting for vault folders to appear."; "Connection to peer was closed." = "Connection to peer was closed."; "Copy Device ID" = "Copy Device ID"; "Could Not Accept Share" = "Could Not Accept Share"; @@ -70,19 +60,15 @@ "Could not read files.\n\n%@\n%@" = "Could not read files.\n\n%@\n%@"; "Could not read original file.\n\n%@" = "Could not read original file.\n\n%@"; "Create a vault in Obsidian first. VaultSync will detect it automatically." = "Create a vault in Obsidian first. VaultSync will detect it automatically."; -"Desktop device paired" = "Desktop device paired"; "Device" = "Device"; "Device ID" = "Device ID"; -"Device ID is available and Syncthing is running." = "Device ID is available and Syncthing is running."; "%d of %d devices connected" = "%d of %d devices connected"; "Device name" = "Device name"; "Devices" = "Devices"; "Differences" = "Differences"; "Discards the version from the other device." = "Discards the version from the other device."; "Disconnected" = "Disconnected"; -"Discovery" = "Discovery"; "Done" = "Done"; -"Download Syncthing" = "Download Syncthing"; "Empty line" = "Empty line"; "Enable notifications for VaultSync in iOS Settings -> Notifications -> VaultSync, then restart the app." = "Enable notifications for VaultSync in iOS Settings -> Notifications -> VaultSync, then restart the app."; "Error" = "Error"; @@ -92,8 +78,6 @@ "File Read Failed" = "File Read Failed"; "File synced in %@" = "File synced in %@"; "Files are being synchronized with peers." = "Files are being synchronized with peers."; -"Finish the required steps so sync stays reliable." = "Finish the required steps so sync stays reliable."; -"First share detected" = "First share detected"; "Folder Not Configured" = "Folder Not Configured"; "Folder Path Missing" = "Folder Path Missing"; "Folder Permission Error" = "Folder Permission Error"; @@ -103,21 +87,15 @@ "Folder is currently in an error state." = "Folder is currently in an error state."; "Folder reached idle state after syncing." = "Folder reached idle state after syncing."; "Folder reported an error." = "Folder reported an error."; -"From desktop Syncthing, share one vault to this iPhone Device ID." = "From desktop Syncthing, share one vault to this iPhone Device ID."; -"Global Discovery" = "Global Discovery"; "Global Files" = "Global Files"; "Health Endpoint" = "Health Endpoint"; "Healthy" = "Healthy"; -"How pairing works" = "How pairing works"; "How to fix: %@" = "How to fix: %@"; "Idle" = "Idle"; -"If syncing has not started, reshare a vault from desktop Syncthing." = "If syncing has not started, reshare a vault from desktop Syncthing."; "Ignore for Now" = "Ignore for Now"; "Ignored shares (%d)" = "Ignored shares (%d)"; "In progress" = "In progress"; -"In the picker, choose \"On My iPhone\" -> \"Obsidian\", then tap Open." = "In the picker, choose \"On My iPhone\" -> \"Obsidian\", then tap Open."; "Install Obsidian from the App Store and open it once. The folder appears after Obsidian creates it." = "Install Obsidian from the App Store and open it once. The folder appears after Obsidian creates it."; -"Instant sync via Cloud Relay is active." = "Instant sync via Cloud Relay is active."; "Invalid Input" = "Invalid Input"; "Invalid folder name: '%@'" = "Invalid folder name: '%@'"; "Keep Both" = "Keep Both"; @@ -128,7 +106,6 @@ "Keep both versions" = "Keep both versions"; "Keep other device version" = "Keep other device version"; "Keep the app open for a moment and retry. If this persists, restart VaultSync." = "Keep the app open for a moment and retry. If this persists, restart VaultSync."; -"Keep the app open for a moment. If this persists, restart VaultSync." = "Keep the app open for a moment. If this persists, restart VaultSync."; "Keep this device version" = "Keep this device version"; "Keeps your local file and renames the other device's file." = "Keeps your local file and renames the other device's file."; "Last Check" = "Last Check"; @@ -139,15 +116,11 @@ "Last Update" = "Last Update"; "Last successful sync was about %d %@ ago." = "Last successful sync was about %d %@ ago."; "Last successful sync was more than %d %@ ago." = "Last successful sync was more than %d %@ ago."; -"Last sync:" = "Last sync:"; +"Last sync: %@" = "Last sync: %@"; "Latency" = "Latency"; "Learn how to fix" = "Learn how to fix"; -"Let's Get Started" = "Let's Get Started"; -"Loading files..." = "Loading files..."; "Loading…" = "Loading…"; -"Local Discovery" = "Local Discovery"; "Local Files" = "Local Files"; -"Local discovery finds devices on your WiFi network. Global discovery uses Syncthing's servers to find devices anywhere." = "Local discovery finds devices on your WiFi network. Global discovery uses Syncthing's servers to find devices anywhere."; "Log" = "Log"; "Manage Subscription" = "Manage Subscription"; "Name" = "Name"; @@ -159,10 +132,8 @@ "No Syncthing peers available yet." = "No Syncthing peers available yet."; "No action needed." = "No action needed."; "No active pending shares" = "No active pending shares"; -"No desktop or laptop Syncthing device configured yet." = "No desktop or laptop Syncthing device configured yet."; "No devices configured" = "No devices configured"; "No devices connected" = "No devices connected"; -"No folder share from your desktop has been detected yet." = "No folder share from your desktop has been detected yet."; "No folders syncing yet" = "No folders syncing yet"; "No home server devices available for relay provisioning." = "No home server devices available for relay provisioning."; "No immediate relay problems detected." = "No immediate relay problems detected."; @@ -176,16 +147,12 @@ "Not attempted" = "Not attempted"; "Not checked" = "Not checked"; "Not shared" = "Not shared"; -"Notifications are disabled for VaultSync. Enable them in iOS Settings > Notifications > VaultSync, then retry APNs registration." = "Notifications are disabled for VaultSync. Enable them in iOS Settings > Notifications > VaultSync, then retry APNs registration."; "OK" = "OK"; "Obsidian Folder Connection Failed" = "Obsidian Folder Connection Failed"; "Obsidian Vaults" = "Obsidian Vaults"; "Obsidian access expired" = "Obsidian access expired"; -"Obsidian connected" = "Obsidian connected"; "Obsidian directory not accessible." = "Obsidian directory not accessible."; "Obsidian folder not connected" = "Obsidian folder not connected"; -"Offline" = "Offline"; -"Open Pending Shares in VaultSync and accept one to start syncing." = "Open Pending Shares in VaultSync and accept one to start syncing."; "Open Relay Diagnostics" = "Open Relay Diagnostics"; "Open Settings" = "Open Settings"; "Open VaultSync" = "Open VaultSync"; @@ -212,7 +179,6 @@ "Push Registration" = "Push Registration"; "Push Registration Failed" = "Push Registration Failed"; "Push registration is unavailable in Simulator. Test APNs on a physical iPhone in Settings > Notifications for VaultSync." = "Push registration is unavailable in Simulator. Test APNs on a physical iPhone in Settings > Notifications for VaultSync."; -"QR Code" = "QR Code"; "Ready" = "Ready"; "Recent scan, sync, connection, and error events will appear here." = "Recent scan, sync, connection, and error events will appear here."; "Reconnect Obsidian Directory" = "Reconnect Obsidian Directory"; @@ -250,7 +216,6 @@ "Rename Failed" = "Rename Failed"; "Renews" = "Renews"; "Requesting camera access…" = "Requesting camera access…"; -"Required" = "Required"; "Rescan Failed" = "Rescan Failed"; "Rescan Failed Vaults" = "Rescan Failed Vaults"; "Rescan Vault" = "Rescan Vault"; @@ -259,30 +224,22 @@ "Resolve Conflicts" = "Resolve Conflicts"; "Restore Purchases" = "Restore Purchases"; "Restore Share" = "Restore Share"; -"%d/%d required" = "%d/%d required"; "A conflict happens when a file is edited on two devices at the same time. Syncthing saves both versions to prevent data loss." = "A conflict happens when a file is edited on two devices at the same time. Syncthing saves both versions to prevent data loss."; "APNs Registration" = "APNs Registration"; "APNs Token" = "APNs Token"; -"APNs registration failed. Open iOS Settings > Notifications > VaultSync, allow notifications, then retry." = "APNs registration failed. Open iOS Settings > Notifications > VaultSync, allow notifications, then retry."; -"APNs token is missing. Enable notifications for VaultSync and retry APNs registration." = "APNs token is missing. Enable notifications for VaultSync and retry APNs registration."; "Context: %@ · %@" = "Context: %@ · %@"; -"Discovery Update Failed" = "Discovery Update Failed"; "In the picker, choose \"On My iPhone\" → \"Obsidian\", then tap Open." = "In the picker, choose \"On My iPhone\" → \"Obsidian\", then tap Open."; "Missing" = "Missing"; "Present" = "Present"; "Purchase Failed" = "Purchase Failed"; "Rescan All Vaults" = "Rescan All Vaults"; -"Some devices are not provisioned. Retry provisioning after APNs and subscription checks are green." = "Some devices are not provisioned. Retry provisioning after APNs and subscription checks are green."; "Sync Conflicts" = "Sync Conflicts"; -"This timestamp is updated when VaultSync receives a silent push from Cloud Relay." = "This timestamp is updated when VaultSync receives a silent push from Cloud Relay."; "XXXXXXX-XXXXXXX-..." = "XXXXXXX-XXXXXXX-..."; -"e.g. My Laptop" = "e.g. My Laptop"; "Retry APNs Registration" = "Retry APNs Registration"; "Retry Accept" = "Retry Accept"; "Retry Provisioning" = "Retry Provisioning"; "Retry provisioning from Settings. If this persists, verify subscription status." = "Retry provisioning from Settings. If this persists, verify subscription status."; "Retry the action. If it keeps failing, restart the app and check Settings diagnostics." = "Retry the action. If it keeps failing, restart the app and check Settings diagnostics."; -"Review or complete the initial setup checklist." = "Review or complete the initial setup checklist."; "Review the affected device/folder setup in the app and retry." = "Review the affected device/folder setup in the app and retry."; "Review the value and try again." = "Review the value and try again."; "Run Foreground Rescan" = "Run Foreground Rescan"; @@ -291,10 +248,6 @@ "Scanning completed in %@" = "Scanning completed in %@"; "Scanning started in %@" = "Scanning started in %@"; "Settings" = "Settings"; -"Settings Error" = "Settings Error"; -"Setup Checklist" = "Setup Checklist"; -"Setup Guide" = "Setup Guide"; -"Setup checklist progress" = "Setup checklist progress"; "Share a folder from your desktop Syncthing — it will be accepted automatically." = "Share a folder from your desktop Syncthing — it will be accepted automatically."; "Share requests are shown below, but Accept and Retry are disabled until your Obsidian folder is connected." = "Share requests are shown below, but Accept and Retry are disabled until your Obsidian folder is connected."; "Shared" = "Shared"; @@ -302,7 +255,6 @@ "Shared by an unknown device" = "Shared by an unknown device"; "Shared by: %@" = "Shared by: %@"; "Show Line-by-Line Diff" = "Show Line-by-Line Diff"; -"Shows whether Syncthing is currently active." = "Shows whether Syncthing is currently active."; "Shows whether this Syncthing device is currently reachable." = "Shows whether this Syncthing device is currently reachable."; "Silent Push" = "Silent Push"; "Some devices are not provisioned. Retry provisioning after APNs and subscription checks are green." = "Some devices are not provisioned. Retry provisioning after APNs and subscription checks are green."; @@ -323,7 +275,6 @@ "Sync completed in %@" = "Sync completed in %@"; "Sync error in %@" = "Sync error in %@"; "Sync started in %@" = "Sync started in %@"; -"Sync your Obsidian vaults privately with Syncthing — no cloud required." = "Sync your Obsidian vaults privately with Syncthing — no cloud required."; "Synchronizing your Obsidian vault..." = "Synchronizing your Obsidian vault..."; "Syncing" = "Syncing"; "Syncing Vault" = "Syncing Vault"; @@ -331,7 +282,6 @@ "Reconnecting…" = "Reconnecting…"; "Restoring connection to 1 device" = "Restoring connection to 1 device"; "Restoring connection to %d devices" = "Restoring connection to %d devices"; -"Syncthing engine started" = "Syncthing engine started"; "Syncthing is scanning local changes." = "Syncthing is scanning local changes."; "Syncthing no longer has this folder configured." = "Syncthing no longer has this folder configured."; "Terms of Use" = "Terms of Use"; @@ -363,7 +313,6 @@ "Unreachable" = "Unreachable"; "Vault" = "Vault"; "VaultSync" = "VaultSync"; -"VaultSync Setup" = "VaultSync Setup"; "VaultSync can no longer read your Obsidian directory. Reconnect the folder to restore sync access." = "VaultSync can no longer read your Obsidian directory. Reconnect the folder to restore sync access."; "VaultSync can no longer resolve the saved Obsidian folder permission. Reconnect the Obsidian directory to continue syncing." = "VaultSync can no longer resolve the saved Obsidian folder permission. Reconnect the Obsidian directory to continue syncing."; "VaultSync cannot access the saved Obsidian folder anymore. Reconnect the Obsidian directory to continue syncing." = "VaultSync cannot access the saved Obsidian folder anymore. Reconnect the Obsidian directory to continue syncing."; @@ -374,46 +323,20 @@ "VaultSync could not reach the Cloud Relay service." = "VaultSync could not reach the Cloud Relay service."; "VaultSync could not restore bookmark access for the Obsidian folder during a background run." = "VaultSync could not restore bookmark access for the Obsidian folder during a background run."; "VaultSync could not verify this request." = "VaultSync could not verify this request."; -"VaultSync does not have access to your Obsidian directory." = "VaultSync does not have access to your Obsidian directory."; "VaultSync does not have the required permission for this action." = "VaultSync does not have the required permission for this action."; "VaultSync found a configuration problem." = "VaultSync found a configuration problem."; -"VaultSync is still starting Syncthing." = "VaultSync is still starting Syncthing."; "VaultSync needs camera access to scan Syncthing Device ID QR codes. Please enable it in Settings." = "VaultSync needs camera access to scan Syncthing Device ID QR codes. Please enable it in Settings."; "VaultSync needs one-time access to your Obsidian folder before it can accept shares." = "VaultSync needs one-time access to your Obsidian folder before it can accept shares."; "VaultSync reported an unexpected error." = "VaultSync reported an unexpected error."; "Wait a moment and retry." = "Wait a moment and retry."; -"Welcome to VaultSync" = "Welcome to VaultSync"; "What is a conflict?" = "What is a conflict?"; "day" = "day"; "days" = "days"; -"device configured" = "device configured"; -"devices configured" = "devices configured"; "e.g. My Laptop" = "e.g. My Laptop"; "hour" = "hour"; "hours" = "hours"; -"pending share found" = "pending share found"; -"pending shares found" = "pending shares found"; -"shared folder active" = "shared folder active"; -"shared folders active" = "shared folders active"; -"vault detected" = "vault detected"; -"vaults detected" = "vaults detected"; -"Both devices exchange Device IDs" = "Both devices exchange Device IDs"; -"Both devices should be on the same WiFi, or have global discovery enabled in Syncthing." = "Both devices should be on the same WiFi, or have global discovery enabled in Syncthing."; -"End-to-end encrypted" = "End-to-end encrypted"; -"Fast Markdown sync" = "Fast Markdown sync"; -"Install Obsidian on this iPhone and on your desktop." = "Install Obsidian on this iPhone and on your desktop."; -"Install and run Syncthing on the computer you sync with." = "Install and run Syncthing on the computer you sync with."; "No Activity Yet" = "No Activity Yet"; -"No cloud required" = "No cloud required"; -"Obsidian on both devices" = "Obsidian on both devices"; -"Running" = "Running"; -"Same network or global discovery" = "Same network or global discovery"; "Sync engine running" = "Sync engine running"; -"Sync engine stopped" = "Sync engine stopped"; -"Syncthing on your desktop" = "Syncthing on your desktop"; -"You can enable it later in Settings for instant push-based sync." = "You can enable it later in Settings for instant push-based sync."; -"Your desktop shares a vault folder" = "Your desktop shares a vault folder"; -"VaultSync keeps everything in sync" = "VaultSync keeps everything in sync"; "onboarding.welcome.title" = "Your Obsidian notes. Privately synced."; "onboarding.welcome.subtitle" = "Keep your vault in sync between your own devices — without relying on third-party cloud storage."; "onboarding.welcome.benefit.private" = "Private between your devices"; @@ -430,7 +353,7 @@ "onboarding.overview.step3.description" = "Share your Obsidian vault from Syncthing on your computer to this iPhone."; "onboarding.overview.step4.title" = "Watch sync status"; "onboarding.overview.step4.description" = "VaultSync shows active vaults, issues, and sync progress on the home screen."; -"onboarding.overview.cloudRelay" = "Cloud Relay can be enabled later in Settings for faster background updates."; +"onboarding.overview.cloudRelay" = "Turn on Cloud Relay later in Settings to get changes pushed the moment they happen — without opening the app."; "onboarding.cta.openVaultSync" = "Open VaultSync"; "onboarding.accessibility.page" = "Page %d of 2"; "onboarding.accessibility.continueHint" = "Opens a short setup overview."; @@ -479,7 +402,6 @@ "Always skip on this iPhone" = "Always skip on this iPhone"; "More actions" = "More actions"; "Skipping enabled" = "Skipping enabled"; -"'%@' will no longer sync to this iPhone. You can undo this in Sync Filters." = "'%@' will no longer sync to this iPhone. You can undo this in Sync Filters."; "'%@' and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters." = "'%@' and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters."; "1 existing conflict copy was removed." = "1 existing conflict copy was removed."; "%d existing conflict copies were removed." = "%d existing conflict copies were removed."; @@ -502,7 +424,6 @@ "Cache of Obsidian Copilot, regenerated automatically." = "Cache of Obsidian Copilot, regenerated automatically."; "Obsidian app cache" = "Obsidian app cache"; "Auto-regenerated Obsidian internal cache." = "Auto-regenerated Obsidian internal cache."; -"Node modules" = "Node modules"; // Sync Issue titles & messages "1 Vault Has Sync Errors" = "1 Vault Has Sync Errors"; @@ -514,3 +435,98 @@ "%d Pending Shares Need Attention" = "%d Pending Shares Need Attention"; "1 Conflict Needs Resolution" = "1 Conflict Needs Resolution"; "%d Conflicts Need Resolution" = "%d Conflicts Need Resolution"; + +/* Issue #10 — conflict notification body */ +"1 file has a sync conflict. Open VaultSync to resolve it." = "1 file has a sync conflict. Open VaultSync to resolve it."; +"%d files have sync conflicts. Open VaultSync to resolve them." = "%d files have sync conflicts. Open VaultSync to resolve them."; + +/* Issue #10 — conflict-notifications toggle (Settings) */ +"Notifications" = "Notifications"; +"Conflict Notifications" = "Conflict Notifications"; +"Show a banner when sync conflicts are detected. Turning this off does not affect Cloud Relay or background sync — your vault keeps syncing." = "Show a banner when sync conflicts are detected. Turning this off does not affect Cloud Relay or background sync — your vault keeps syncing."; + +/* Issue #10 — relay/alert decoupling (Relay Diagnostics) */ +"Alert Banners" = "Alert Banners"; +"Allowed" = "Allowed"; +"Denied" = "Denied"; +"Cloud Relay is delivering wake-ups" = "Cloud Relay is delivering wake-ups"; +"Alert banners are off at the iOS level. Cloud Relay wake-ups still work — they use silent push, which does not need notification permission." = "Alert banners are off at the iOS level. Cloud Relay wake-ups still work — they use silent push, which does not need notification permission."; +"APNs token is missing. Retry APNs registration; if it keeps failing, check your internet connection. (Silent push does not require notification banners.)" = "APNs token is missing. Retry APNs registration; if it keeps failing, check your internet connection. (Silent push does not require notification banners.)"; +"APNs registration failed. Check your internet connection and retry registration. Silent push does not require notification banners to be enabled." = "APNs registration failed. Check your internet connection and retry registration. Silent push does not require notification banners to be enabled."; + +/* Issue #10 — background-sync reliability (error-settled outcome) */ +"Background sync settled with at least one folder in an error state." = "Background sync settled with at least one folder in an error state."; + +/* Issue #10 — relay reachable (vs delivering) */ +"Cloud Relay looks reachable" = "Cloud Relay looks reachable"; + +/* Subscription period units */ +"month" = "month"; +"months" = "months"; +"week" = "week"; +"weeks" = "weeks"; +"year" = "year"; +"years" = "years"; +"%@ / %@" = "%@ / %@"; +"%1$@ / %2$d %3$@" = "%1$@ / %2$d %3$@"; +/* Contributions / tip jar */ +"Support VaultSync" = "Support VaultSync"; +"Small Contribution" = "Small Contribution"; +"Big Contribution" = "Big Contribution"; +"Contribution" = "Contribution"; +"Contribution Failed" = "Contribution Failed"; +"Contributions are currently unavailable." = "Contributions are currently unavailable."; +"Thank you!" = "Thank you!"; +"Your contribution means a lot and directly supports VaultSync development. Thank you!" = "Your contribution means a lot and directly supports VaultSync development. Thank you!"; +"VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward. It unlocks nothing — VaultSync stays fully functional without it — and you can give as often as you like." = "VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward. It unlocks nothing — VaultSync stays fully functional without it — and you can give as often as you like."; +/* Cloud Relay price (localized via StoreKit) */ +"Cloud Relay — %@" = "Cloud Relay — %@"; +"Cloud Relay subscription" = "Cloud Relay subscription"; + +/* Issue #10 pre-merge polish — added missing localization keys */ +"Not available" = "Not available"; +"Copied" = "Copied"; +"Rescanning…" = "Rescanning…"; +"Cannot Load Files" = "Cannot Load Files"; +"Remove this device?" = "Remove this device?"; +"Double-tap to share this vault with this device." = "Double-tap to share this vault with this device."; +"Double-tap to stop sharing this vault with this device." = "Double-tap to stop sharing this vault with this device."; +"All conflicts resolved" = "All conflicts resolved"; +"Loading files…" = "Loading files…"; +"Computing diff…" = "Computing diff…"; +"Other Device" = "Other Device"; +"Other Device (%@)" = "Other Device (%@)"; +"Added lines come from the other device; removed lines are your version on this device." = "Added lines come from the other device; removed lines are your version on this device."; +"(empty or unreadable)" = "(empty or unreadable)"; +"a new name" = "a new name"; +"Both versions were kept.\n\nYour local version remains as '%@'.\nThe other device's version was renamed to '%@'." = "Both versions were kept.\n\nYour local version remains as '%@'.\nThe other device's version was renamed to '%@'."; +"%d conflicts" = "%d conflicts"; +"Your contribution is pending approval." = "Your contribution is pending approval."; +"Could not accept share '%@'.\n\n%@" = "Could not accept share '%@'.\n\n%@"; +"iOS did not provide a push token required for instant sync." = "iOS did not provide a push token required for instant sync."; +"%@ (Trigger: %@)" = "%@ (Trigger: %@)"; +"1 additional file synced in %@" = "1 additional file synced in %@"; +"%d additional files synced in %@" = "%d additional files synced in %@"; +"%@ connected" = "%@ connected"; +"%@ disconnected" = "%@ disconnected"; +"%d%% complete" = "%d%% complete"; +"Background sync completed and reached idle." = "Background sync completed and reached idle."; +"Background sync did not reach an idle folder state before the iOS deadline." = "Background sync did not reach an idle folder state before the iOS deadline."; +"Background sync ended with an unexpected failure." = "Background sync ended with an unexpected failure."; +"Background sync ran, but folders were already idle." = "Background sync ran, but folders were already idle."; +"Forced silent-push restart failed." = "Forced silent-push restart failed."; +"No Syncthing folders were available to sync in the background." = "No Syncthing folders were available to sync in the background."; +"No folders were available after forced silent-push restart." = "No folders were available after forced silent-push restart."; +"No folders were available for background sync." = "No folders were available for background sync."; +"No security-scoped bookmark access was available." = "No security-scoped bookmark access was available."; +"Accept or create a shared vault before relying on background sync." = "Accept or create a shared vault before relying on background sync."; +"Retry from the app and review relay/background diagnostics in Settings." = "Retry from the app and review relay/background diagnostics in Settings."; +"Silent push restarted Syncthing, but no real sync progress was observed before the app returned to idle." = "Silent push restarted Syncthing, but no real sync progress was observed before the app returned to idle."; +"Sync did not reach idle before %ds deadline." = "Sync did not reach idle before %ds deadline."; +"Relay %@ failed with HTTP %d." = "Relay %@ failed with HTTP %d."; +"Relay %@ is rate limited (HTTP 429)." = "Relay %@ is rate limited (HTTP 429)."; +"Relay %@ network error: %@" = "Relay %@ network error: %@"; +"Relay %@ returned a non-HTTP response." = "Relay %@ returned a non-HTTP response."; +"Relay %@ unauthorized (HTTP %d)." = "Relay %@ unauthorized (HTTP %d)."; +"Unauthorized request." = "Unauthorized request."; +"relay network: %@" = "relay network: %@"; diff --git a/ios/VaultSync/es.lproj/InfoPlist.strings b/ios/VaultSync/es.lproj/InfoPlist.strings new file mode 100644 index 0000000..f2e7d41 --- /dev/null +++ b/ios/VaultSync/es.lproj/InfoPlist.strings @@ -0,0 +1,2 @@ +"CFBundleDisplayName" = "VaultSync"; +"NSCameraUsageDescription" = "VaultSync usa la cámara para escanear códigos QR de dispositivos de Syncthing y facilitar la configuración."; diff --git a/ios/VaultSync/es.lproj/Localizable.strings b/ios/VaultSync/es.lproj/Localizable.strings new file mode 100644 index 0000000..073e4f3 --- /dev/null +++ b/ios/VaultSync/es.lproj/Localizable.strings @@ -0,0 +1,532 @@ +"About" = "Acerca de"; +"Accept First Pending Share" = "Aceptar primera compartición pendiente"; +"Accept Share" = "Aceptar compartición"; +"Accept a share to activate syncing for that vault." = "Acepta una compartición para activar la sincronización de ese Vault."; +"Actions" = "Acciones"; +"Active" = "Activo"; +"Add" = "Añadir"; +"Add Device" = "Añadir dispositivo"; +"Add a device using its Syncthing Device ID. Find it in the Syncthing web UI under Actions > Show ID." = "Añade un dispositivo con su ID de dispositivo de Syncthing. Lo encontrarás en la interfaz web de Syncthing en Actions > Show ID."; +"Add or Reconnect Device" = "Añadir o volver a conectar dispositivo"; +"Added line. %@" = "Línea añadida. %@"; +"All Synced" = "Todo sincronizado"; +"App Refresh" = "Actualización de la app"; +"Applying share" = "Aplicando compartición"; +"Applying…" = "Aplicando…"; +"Authentication Error" = "Error de autenticación"; +"Auto-renews monthly. Cancel anytime in Settings → Subscriptions." = "Se renueva automáticamente cada mes. Cancela cuando quieras en Ajustes → Suscripciones."; +"Background Sync Completed" = "Sincronización en segundo plano completada"; +"Background Sync Could Not Access Obsidian" = "La sincronización en segundo plano no pudo acceder a Obsidian"; +"Background Sync Could Not Start" = "La sincronización en segundo plano no pudo iniciarse"; +"Background Sync Failed" = "La sincronización en segundo plano falló"; +"Background Sync Found No Vaults" = "La sincronización en segundo plano no encontró Vaults"; +"Background Sync Timed Out" = "La sincronización en segundo plano agotó el tiempo de espera"; +"Background sync: %@" = "Sincronización en segundo plano: %@"; +"Camera Access Required" = "Se requiere acceso a la cámara"; +"Can't find the Obsidian folder?" = "¿No encuentras la carpeta de Obsidian?"; +"Cancel" = "Cancelar"; +"Check Relay Status" = "Comprobar estado del Relay"; +"Check folder sharing, connectivity, and permissions, then retry." = "Comprueba la compartición de la carpeta, la conectividad y los permisos, y vuelve a intentarlo."; +"Check internet connectivity and retry provisioning." = "Comprueba la conexión a internet y vuelve a intentar el aprovisionamiento."; +"Check your connection and retry." = "Comprueba tu conexión y vuelve a intentarlo."; +"Check your internet connection and try the relay health check again in Settings." = "Comprueba tu conexión a internet y vuelve a ejecutar la comprobación de estado del Relay en Ajustes."; +"Check your subscription status in Settings and retry. If this persists, restart VaultSync." = "Comprueba el estado de tu suscripción en Ajustes y vuelve a intentarlo. Si continúa, reinicia VaultSync."; +"Cloud Relay" = "Cloud Relay"; +"Cloud Relay active" = "Cloud Relay activo"; +"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes." = "Cuando cambian archivos en tu servidor, un push silencioso despierta VaultSync en ese mismo momento, así la sincronización se siente inmediata sin abrir la app. El relay solo envía una señal de aviso: nunca ve tus notas."; +"Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "Cloud Relay no está suscrito actualmente. Las activaciones por push están desactivadas hasta que la suscripción esté activa."; +"Cloud Relay is not subscribed. Start a subscription first." = "Cloud Relay no está suscrito. Inicia primero una suscripción."; +"Cloud Relay provisioning could not contact the relay backend." = "El aprovisionamiento de Cloud Relay no pudo contactar con el backend del relay."; +"Cloud Relay provisioning did not complete." = "El aprovisionamiento de Cloud Relay no se completó."; +"Cloud Relay provisioning is temporarily rate limited." = "El aprovisionamiento de Cloud Relay está temporalmente limitado por frecuencia."; +"Completion" = "Progreso"; +"Configuration Error" = "Error de configuración"; +"Conflict Resolution Failed" = "No se pudo resolver el conflicto"; +"Conflict Resolved" = "Conflicto resuelto"; +"Conflicted Files" = "Archivos en conflicto"; +"Conflicts" = "Conflictos"; +"Conflicts mean multiple versions exist and need a manual decision." = "Los conflictos significan que existen varias versiones y hace falta decidir manualmente."; +"Connect Obsidian to accept shares" = "Conecta Obsidian para aceptar comparticiones"; +"Connect Obsidian Folder" = "Conectar carpeta de Obsidian"; +"Connect to Obsidian first" = "Conecta primero con Obsidian"; +"Connected" = "Conectado"; +"Connection to peer was closed." = "Se cerró la conexión con el par."; +"Copy Device ID" = "Copiar ID del dispositivo"; +"Could Not Accept Share" = "No se pudo aceptar la compartición"; +"Could Not Add Device" = "No se pudo añadir el dispositivo"; +"Could Not Start Sync" = "No se pudo iniciar la sincronización"; +"Could not access the selected folder." = "No se pudo acceder a la carpeta seleccionada."; +"Could not read conflict file.\n\n%@" = "No se pudo leer el archivo de conflicto.\n\n%@"; +"Could not read files.\n\n%@\n%@" = "No se pudieron leer los archivos.\n\n%@\n%@"; +"Could not read original file.\n\n%@" = "No se pudo leer el archivo original.\n\n%@"; +"Create a vault in Obsidian first. VaultSync will detect it automatically." = "Crea primero un Vault en Obsidian. VaultSync lo detectará automáticamente."; +"Device" = "Dispositivo"; +"Device ID" = "ID del dispositivo"; +"%d of %d devices connected" = "%d de %d dispositivos conectados"; +"Device name" = "Nombre del dispositivo"; +"Devices" = "Dispositivos"; +"Differences" = "Diferencias"; +"Discards the version from the other device." = "Descarta la versión del otro dispositivo."; +"Disconnected" = "Desconectado"; +"Done" = "Listo"; +"Empty line" = "Línea vacía"; +"Enable notifications for VaultSync in iOS Settings -> Notifications -> VaultSync, then restart the app." = "Activa las notificaciones de VaultSync en Ajustes de iOS -> Notificaciones -> VaultSync y luego reinicia la app."; +"Error" = "Error"; +"Failed" = "Fallido"; +"Failed to save access permission: %@" = "No se pudo guardar el permiso de acceso: %@"; +"Failed to sync file in %@" = "No se pudo sincronizar el archivo en %@"; +"File Read Failed" = "No se pudo leer el archivo"; +"File synced in %@" = "Archivo sincronizado en %@"; +"Files are being synchronized with peers." = "Los archivos se están sincronizando con los pares."; +"Folder Not Configured" = "Carpeta no configurada"; +"Folder Path Missing" = "Falta la ruta de la carpeta"; +"Folder Permission Error" = "Error de permisos de la carpeta"; +"Folder Sync Error" = "Error de sincronización de la carpeta"; +"Folder entered an error state." = "La carpeta entró en un estado de error."; +"Folder error in %@" = "Error de carpeta en %@"; +"Folder is currently in an error state." = "La carpeta se encuentra actualmente en un estado de error."; +"Folder reached idle state after syncing." = "La carpeta alcanzó el estado inactivo tras la sincronización."; +"Folder reported an error." = "La carpeta informó de un error."; +"Global Files" = "Archivos globales"; +"Health Endpoint" = "Endpoint de estado"; +"Healthy" = "En buen estado"; +"How to fix: %@" = "Cómo solucionarlo: %@"; +"Idle" = "Inactivo"; +"Ignore for Now" = "Ignorar por ahora"; +"Ignored shares (%d)" = "Comparticiones ignoradas (%d)"; +"In progress" = "En curso"; +"Install Obsidian from the App Store and open it once. The folder appears after Obsidian creates it." = "Instala Obsidian desde la App Store y ábrelo una vez. La carpeta aparece después de que Obsidian la cree."; +"Invalid Input" = "Entrada no válida"; +"Invalid folder name: '%@'" = "Nombre de carpeta no válido: '%@'"; +"Keep Both" = "Conservar ambas"; +"Keep Other" = "Conservar la otra"; +"Keep Other Device's Version" = "Conservar la versión del otro dispositivo"; +"Keep This" = "Conservar esta"; +"Keep This Device's Version" = "Conservar la versión de este dispositivo"; +"Keep both versions" = "Conservar ambas versiones"; +"Keep other device version" = "Conservar la versión del otro dispositivo"; +"Keep the app open for a moment and retry. If this persists, restart VaultSync." = "Mantén la app abierta un momento y vuelve a intentarlo. Si continúa, reinicia VaultSync."; +"Keep this device version" = "Conservar la versión de este dispositivo"; +"Keeps your local file and renames the other device's file." = "Conserva tu archivo local y renombra el archivo del otro dispositivo."; +"Last Check" = "Última comprobación"; +"Last Failure" = "Último fallo"; +"Last Relay Error" = "Último error del Relay"; +"Last Success" = "Último éxito"; +"Last Trigger Received" = "Último activador recibido"; +"Last Update" = "Última actualización"; +"Last successful sync was about %d %@ ago." = "La última sincronización correcta fue hace unos %d %@."; +"Last successful sync was more than %d %@ ago." = "La última sincronización correcta fue hace más de %d %@."; +"Last sync: %@" = "Última sincronización: %@"; +"Latency" = "Latencia"; +"Learn how to fix" = "Aprende a solucionarlo"; +"Loading…" = "Cargando…"; +"Local Files" = "Archivos locales"; +"Log" = "Registro"; +"Manage Subscription" = "Gestionar suscripción"; +"Name" = "Nombre"; +"Name (optional)" = "Nombre (opcional)"; +"Needs Attention" = "Requiere atención"; +"Network Error" = "Error de red"; +"Never" = "Nunca"; +"No Cloud Relay product is currently available." = "Actualmente no hay ningún producto de Cloud Relay disponible."; +"No Syncthing peers available yet." = "Aún no hay pares de Syncthing disponibles."; +"No action needed." = "No se requiere ninguna acción."; +"No active pending shares" = "No hay comparticiones pendientes activas"; +"No devices configured" = "No hay dispositivos configurados"; +"No devices connected" = "No hay dispositivos conectados"; +"No folders syncing yet" = "Aún no se sincroniza ninguna carpeta"; +"No home server devices available for relay provisioning." = "No hay dispositivos de servidor doméstico disponibles para el aprovisionamiento del relay."; +"No immediate relay problems detected." = "No se detectaron problemas inmediatos del relay."; +"No relay errors recorded." = "No se registraron errores del relay."; +"No relay trigger has been received yet. Verify your homeserver `vaultsync-notify` container is running and can reach relay.vaultsync.eu." = "Aún no se ha recibido ningún activador del relay. Verifica que el contenedor `vaultsync-notify` de tu servidor doméstico esté en ejecución y pueda acceder a relay.vaultsync.eu."; +"No successful sync has been recorded for your vaults yet." = "Aún no se ha registrado ninguna sincronización correcta para tus Vaults."; +"No vaults found" = "No se encontraron Vaults"; +"Not Configured" = "No configurado"; +"Not Shared" = "No compartido"; +"Not Subscribed" = "No suscrito"; +"Not attempted" = "No intentado"; +"Not checked" = "No comprobado"; +"Not shared" = "No compartido"; +"OK" = "OK"; +"Obsidian Folder Connection Failed" = "Error al conectar la carpeta de Obsidian"; +"Obsidian Vaults" = "Vaults de Obsidian"; +"Obsidian access expired" = "El acceso a Obsidian caducó"; +"Obsidian directory not accessible." = "No se puede acceder al directorio de Obsidian."; +"Obsidian folder not connected" = "Carpeta de Obsidian no conectada"; +"Open Relay Diagnostics" = "Abrir diagnóstico del Relay"; +"Open Settings" = "Abrir Ajustes"; +"Open VaultSync" = "Abrir VaultSync"; +"Open VaultSync once to restart Syncthing, then retry." = "Abre VaultSync una vez para reiniciar Syncthing y vuelve a intentarlo."; +"Open VaultSync to allow a longer foreground sync session." = "Abre VaultSync para permitir una sesión de sincronización más larga en primer plano."; +"Open conflicts and choose which version to keep." = "Abre los conflictos y elige qué versión conservar."; +"Open full relay troubleshooting" = "Abrir la resolución de problemas completa del relay"; +"Open iOS Notification Settings" = "Abrir los ajustes de notificaciones de iOS"; +"Open iOS Settings -> VaultSync and check that all permissions are enabled, then retry." = "Abre Ajustes de iOS -> VaultSync y comprueba que todos los permisos estén activados, luego vuelve a intentarlo."; +"Open the folder picker again and select your Obsidian directory." = "Vuelve a abrir el selector de carpetas y elige tu directorio de Obsidian."; +"Opens discovery, relay, and notification settings." = "Abre los ajustes de detección, relay y notificaciones."; +"Opens the form to add a Syncthing device." = "Abre el formulario para añadir un dispositivo de Syncthing."; +"Optional" = "Opcional"; +"Overwrites your local file with the version from the other device." = "Sobrescribe tu archivo local con la versión del otro dispositivo."; +"Path" = "Ruta"; +"Peer connection is active." = "La conexión con el par está activa."; +"Pending Shares" = "Comparticiones pendientes"; +"Pending shares are waiting to be accepted before sync can start." = "Las comparticiones pendientes esperan a ser aceptadas antes de que pueda comenzar la sincronización."; +"Per-Device Provisioning" = "Aprovisionamiento por dispositivo"; +"Permission Required" = "Permiso requerido"; +"Please select a folder. In the picker choose \"On My iPhone\" -> \"Obsidian\"." = "Selecciona una carpeta. En el selector elige \"En mi iPhone\" -> \"Obsidian\"."; +"Privacy Policy" = "Política de privacidad"; +"Provisioned" = "Aprovisionado"; +"Push Registration" = "Registro de push"; +"Push Registration Failed" = "Error en el registro de push"; +"Push registration is unavailable in Simulator. Test APNs on a physical iPhone in Settings > Notifications for VaultSync." = "El registro de push no está disponible en el Simulador. Prueba APNs en un iPhone físico en Ajustes > Notificaciones para VaultSync."; +"Ready" = "Listo"; +"Recent scan, sync, connection, and error events will appear here." = "Aquí aparecerán los eventos recientes de escaneo, sincronización, conexión y error."; +"Reconnect Obsidian Directory" = "Volver a conectar el directorio de Obsidian"; +"Reconnect Obsidian Folder" = "Volver a conectar la carpeta de Obsidian"; +"Reconnect Obsidian access or adjust folder permissions on the host device." = "Vuelve a conectar el acceso a Obsidian o ajusta los permisos de la carpeta en el dispositivo anfitrión."; +"Reconnect devices or add missing peers to restore continuous sync." = "Vuelve a conectar dispositivos o añade los pares que falten para restaurar la sincronización continua."; +"Reconnect your Obsidian folder access in VaultSync, then run a foreground rescan." = "Vuelve a conectar el acceso a tu carpeta de Obsidian en VaultSync y luego ejecuta un reescaneo en primer plano."; +"Recreate or reselect the folder, then trigger a rescan." = "Vuelve a crear o a seleccionar la carpeta y luego inicia un reescaneo."; +"Registered" = "Registrado"; +"Relay Backend" = "Backend del Relay"; +"Relay Diagnostics" = "Diagnóstico del Relay"; +"Relay Error" = "Error del Relay"; +"Relay Provisioning Failed" = "Error en el aprovisionamiento del Relay"; +"Relay Rate Limited" = "Relay limitado por frecuencia"; +"Relay Unreachable" = "Relay inaccesible"; +"Relay health check failed: %@" = "La comprobación de estado del Relay falló: %@"; +"Relay health check returned a non-HTTP response." = "La comprobación de estado del Relay devolvió una respuesta que no es HTTP."; +"Relay health check timed out after %ds." = "La comprobación de estado del Relay agotó el tiempo de espera tras %ds."; +"Relay health endpoint is not healthy. Check internet access, VPN/firewall rules, or relay availability." = "El endpoint de estado del Relay no está en buen estado. Comprueba el acceso a internet, las reglas de VPN/firewall o la disponibilidad del relay."; +"Relay health endpoint returned HTTP %d." = "El endpoint de estado del Relay devolvió HTTP %d."; +"Relay network error: %@" = "Error de red del Relay: %@"; +"Relay provision failed with HTTP %d." = "El aprovisionamiento del Relay falló con HTTP %d."; +"Relay provision is rate limited (HTTP 429)." = "El aprovisionamiento del Relay está limitado por frecuencia (HTTP 429)."; +"Relay provision returned a non-HTTP response." = "El aprovisionamiento del Relay devolvió una respuesta que no es HTTP."; +"Relay provisioning failed." = "El aprovisionamiento del Relay falló."; +"Relay provisioning is currently rate limited (429)." = "El aprovisionamiento del Relay está actualmente limitado por frecuencia (429)."; +"Relay request was rate limited." = "La solicitud al Relay fue limitada por frecuencia."; +"Relay server error (HTTP %d)." = "Error del servidor del Relay (HTTP %d)."; +"Relay server returned HTTP %d." = "El servidor del Relay devolvió HTTP %d."; +"Remove" = "Eliminar"; +"Remove Device" = "Eliminar dispositivo"; +"Remove Failed" = "No se pudo eliminar"; +"Remove and re-share the folder from your desktop device." = "Elimina y vuelve a compartir la carpeta desde tu equipo de escritorio."; +"Removed line. %@" = "Línea eliminada. %@"; +"Rename Failed" = "No se pudo renombrar"; +"Renews" = "Se renueva"; +"Requesting camera access…" = "Solicitando acceso a la cámara…"; +"Rescan Failed" = "El reescaneo falló"; +"Rescan Failed Vaults" = "Reescanear Vaults con fallos"; +"Rescan Vault" = "Reescanear Vault"; +"Rescan failed vaults, then verify folder access and permissions." = "Reescanea los Vaults con fallos y luego verifica el acceso y los permisos de la carpeta."; +"Resolve Conflict" = "Resolver conflicto"; +"Resolve Conflicts" = "Resolver conflictos"; +"Restore Purchases" = "Restaurar compras"; +"Restore Share" = "Restaurar compartición"; +"A conflict happens when a file is edited on two devices at the same time. Syncthing saves both versions to prevent data loss." = "Un conflicto ocurre cuando un archivo se edita en dos dispositivos a la vez. Syncthing guarda ambas versiones para evitar la pérdida de datos."; +"APNs Registration" = "Registro de APNs"; +"APNs Token" = "Token de APNs"; +"Context: %@ · %@" = "Contexto: %@ · %@"; +"In the picker, choose \"On My iPhone\" → \"Obsidian\", then tap Open." = "En el selector, elige \"En mi iPhone\" → \"Obsidian\" y luego toca Abrir."; +"Missing" = "Falta"; +"Present" = "Presente"; +"Purchase Failed" = "La compra falló"; +"Rescan All Vaults" = "Reescanear todos los Vaults"; +"Sync Conflicts" = "Conflictos de sincronización"; +"XXXXXXX-XXXXXXX-..." = "XXXXXXX-XXXXXXX-..."; +"Retry APNs Registration" = "Reintentar registro de APNs"; +"Retry Accept" = "Reintentar aceptar"; +"Retry Provisioning" = "Reintentar aprovisionamiento"; +"Retry provisioning from Settings. If this persists, verify subscription status." = "Reintenta el aprovisionamiento desde Ajustes. Si continúa, verifica el estado de la suscripción."; +"Retry the action. If it keeps failing, restart the app and check Settings diagnostics." = "Reintenta la acción. Si sigue fallando, reinicia la app y revisa el diagnóstico en Ajustes."; +"Review the affected device/folder setup in the app and retry." = "Revisa la configuración del dispositivo/carpeta afectado en la app y vuelve a intentarlo."; +"Review the value and try again." = "Revisa el valor y vuelve a intentarlo."; +"Run Foreground Rescan" = "Ejecutar reescaneo en primer plano"; +"Scan QR Code" = "Escanear código QR"; +"Scanning" = "Escaneando"; +"Scanning completed in %@" = "Escaneo completado en %@"; +"Scanning started in %@" = "Escaneo iniciado en %@"; +"Settings" = "Ajustes"; +"Share a folder from your desktop Syncthing — it will be accepted automatically." = "Comparte una carpeta desde Syncthing en tu escritorio — se aceptará automáticamente."; +"Share requests are shown below, but Accept and Retry are disabled until your Obsidian folder is connected." = "Las solicitudes de compartición se muestran abajo, pero Aceptar y Reintentar están desactivados hasta que tu carpeta de Obsidian esté conectada."; +"Shared" = "Compartido"; +"Shared With" = "Compartido con"; +"Shared by an unknown device" = "Compartido por un dispositivo desconocido"; +"Shared by: %@" = "Compartido por: %@"; +"Show Line-by-Line Diff" = "Mostrar diferencias línea por línea"; +"Shows whether this Syncthing device is currently reachable." = "Indica si este dispositivo de Syncthing es accesible actualmente."; +"Silent Push" = "Push silencioso"; +"Some devices are not provisioned. Retry provisioning after APNs and subscription checks are green." = "Algunos dispositivos no están aprovisionados. Reintenta el aprovisionamiento cuando las comprobaciones de APNs y de la suscripción estén en verde."; +"Some entered data is invalid or incomplete." = "Algunos datos introducidos no son válidos o están incompletos."; +"Some shared peers are offline or unreachable right now." = "Algunos pares compartidos están desconectados o inaccesibles en este momento."; +"Starting…" = "Iniciando…"; +"State" = "Estado"; +"Status" = "Estado"; +"Subscribe" = "Suscribirse"; +"Subscription unavailable" = "Suscripción no disponible"; +"Sync Activity" = "Actividad de sincronización"; +"Sync Activity Looks Stale" = "La actividad de sincronización parece desactualizada"; +"Sync Engine Not Running" = "El motor de sincronización no está en ejecución"; +"Sync Error" = "Error de sincronización"; +"Sync Issue" = "Problema de sincronización"; +"Sync Issues" = "Problemas de sincronización"; +"Sync Status" = "Estado de sincronización"; +"Sync completed in %@" = "Sincronización completada en %@"; +"Sync error in %@" = "Error de sincronización en %@"; +"Sync started in %@" = "Sincronización iniciada en %@"; +"Synchronizing your Obsidian vault..." = "Sincronizando tu Vault de Obsidian..."; +"Syncing" = "Sincronizando"; +"Syncing Vault" = "Sincronizando Vault"; +"Syncing…" = "Sincronizando…"; +"Reconnecting…" = "Reconectando…"; +"Restoring connection to 1 device" = "Restaurando la conexión con 1 dispositivo"; +"Restoring connection to %d devices" = "Restaurando la conexión con %d dispositivos"; +"Syncthing is scanning local changes." = "Syncthing está escaneando los cambios locales."; +"Syncthing no longer has this folder configured." = "Syncthing ya no tiene esta carpeta configurada."; +"Terms of Use" = "Términos de uso"; +"The device will be disconnected and removed from all shared folders." = "El dispositivo se desconectará y se eliminará de todas las carpetas compartidas."; +"The embedded Syncthing bridge did not start for a background sync." = "El puente integrado de Syncthing no se inició para una sincronización en segundo plano."; +"The file '%@' was kept as your local version. The other device's version was discarded." = "El archivo '%@' se conservó como tu versión local. La versión del otro dispositivo se descartó."; +"The file '%@' was overwritten with the version from the other device." = "El archivo '%@' se sobrescribió con la versión del otro dispositivo."; +"The folder path no longer exists%@." = "La ruta de la carpeta ya no existe%@."; +"The folder scan finished successfully." = "El escaneo de la carpeta finalizó correctamente."; +"This Device" = "Este dispositivo"; +"This folder does not look like your Obsidian directory yet. In the picker choose \"On My iPhone\" -> \"Obsidian\"." = "Esta carpeta todavía no parece tu directorio de Obsidian. En el selector elige \"En mi iPhone\" -> \"Obsidian\"."; +"This timestamp is updated when VaultSync receives a silent push from Cloud Relay." = "Esta marca de tiempo se actualiza cuando VaultSync recibe un push silencioso de Cloud Relay."; +"This will permanently discard the version from the other device." = "Esto descartará permanentemente la versión del otro dispositivo."; +"This will permanently discard your local version." = "Esto descartará permanentemente tu versión local."; +"Timed out" = "Tiempo de espera agotado"; +"Timeline updates were rate-limited to keep activity readable." = "Las actualizaciones de la cronología se limitaron por frecuencia para mantener la actividad legible."; +"Trigger Delivery" = "Entrega de activadores"; +"Trigger a vault rescan to refresh sync state." = "Inicia un reescaneo del Vault para actualizar el estado de sincronización."; +"Troubleshooting" = "Resolución de problemas"; +"Unchanged line. %@" = "Línea sin cambios. %@"; +"Unhealthy" = "En mal estado"; +"Unknown" = "Desconocido"; +"Unknown APNs registration error" = "Error de registro de APNs desconocido"; +"Unknown Device" = "Dispositivo desconocido"; +"Unknown Folder" = "Carpeta desconocida"; +"Unnamed" = "Sin nombre"; +"Unnamed Device" = "Dispositivo sin nombre"; +"Unnamed device" = "Dispositivo sin nombre"; +"Unreachable" = "Inaccesible"; +"Vault" = "Vault"; +"VaultSync" = "VaultSync"; +"VaultSync can no longer read your Obsidian directory. Reconnect the folder to restore sync access." = "VaultSync ya no puede leer tu directorio de Obsidian. Vuelve a conectar la carpeta para restaurar el acceso de sincronización."; +"VaultSync can no longer resolve the saved Obsidian folder permission. Reconnect the Obsidian directory to continue syncing." = "VaultSync ya no puede resolver el permiso guardado de la carpeta de Obsidian. Vuelve a conectar el directorio de Obsidian para seguir sincronizando."; +"VaultSync cannot access the saved Obsidian folder anymore. Reconnect the Obsidian directory to continue syncing." = "VaultSync ya no puede acceder a la carpeta de Obsidian guardada. Vuelve a conectar el directorio de Obsidian para seguir sincronizando."; +"VaultSync cannot access this folder%@." = "VaultSync no puede acceder a esta carpeta%@."; +"VaultSync cannot read this folder. Reopen the picker and select \"On My iPhone\" -> \"Obsidian\"." = "VaultSync no puede leer esta carpeta. Vuelve a abrir el selector y elige \"En mi iPhone\" -> \"Obsidian\"."; +"VaultSync cannot talk to Syncthing right now." = "VaultSync no puede comunicarse con Syncthing en este momento."; +"VaultSync could not complete the request due to a network issue." = "VaultSync no pudo completar la solicitud debido a un problema de red."; +"VaultSync could not reach the Cloud Relay service." = "VaultSync no pudo acceder al servicio de Cloud Relay."; +"VaultSync could not restore bookmark access for the Obsidian folder during a background run." = "VaultSync no pudo restaurar el acceso mediante marcador a la carpeta de Obsidian durante una ejecución en segundo plano."; +"VaultSync could not verify this request." = "VaultSync no pudo verificar esta solicitud."; +"VaultSync does not have the required permission for this action." = "VaultSync no tiene el permiso necesario para esta acción."; +"VaultSync found a configuration problem." = "VaultSync detectó un problema de configuración."; +"VaultSync needs camera access to scan Syncthing Device ID QR codes. Please enable it in Settings." = "VaultSync necesita acceso a la cámara para escanear los códigos QR de ID de dispositivo de Syncthing. Actívalo en Ajustes."; +"VaultSync needs one-time access to your Obsidian folder before it can accept shares." = "VaultSync necesita acceso por única vez a tu carpeta de Obsidian antes de poder aceptar comparticiones."; +"VaultSync reported an unexpected error." = "VaultSync informó de un error inesperado."; +"Wait a moment and retry." = "Espera un momento y vuelve a intentarlo."; +"What is a conflict?" = "¿Qué es un conflicto?"; +"day" = "día"; +"days" = "días"; +"e.g. My Laptop" = "p. ej. Mi portátil"; +"hour" = "hora"; +"hours" = "horas"; +"No Activity Yet" = "Aún no hay actividad"; +"Sync engine running" = "Motor de sincronización en ejecución"; +"onboarding.welcome.title" = "Tus notas de Obsidian. Sincronizadas en privado."; +"onboarding.welcome.subtitle" = "Mantén tu Vault sincronizado entre tus propios dispositivos, sin depender del almacenamiento en la nube de terceros."; +"onboarding.welcome.benefit.private" = "Privado entre tus dispositivos"; +"onboarding.welcome.benefit.obsidian" = "Diseñado para Vaults de Obsidian"; +"onboarding.welcome.benefit.noCloud" = "No requiere cuenta en la nube"; +"onboarding.cta.continue" = "Continuar"; +"onboarding.overview.title" = "Qué ocurre a continuación"; +"onboarding.overview.subtitle" = "Completarás la configuración en la pantalla de inicio de VaultSync. Esta lista solo muestra el orden."; +"onboarding.overview.step1.title" = "Conecta tu carpeta de Obsidian"; +"onboarding.overview.step1.description" = "Permite que VaultSync acceda a la carpeta de Obsidian en este iPhone."; +"onboarding.overview.step2.title" = "Añade tu ordenador o servidor"; +"onboarding.overview.step2.description" = "Escanea un código QR o introduce el ID de dispositivo de Syncthing."; +"onboarding.overview.step3.title" = "Comparte tu Vault"; +"onboarding.overview.step3.description" = "Comparte tu Vault de Obsidian desde Syncthing en tu ordenador con este iPhone."; +"onboarding.overview.step4.title" = "Sigue el estado de sincronización"; +"onboarding.overview.step4.description" = "VaultSync muestra los Vaults activos, los problemas y el progreso de sincronización en la pantalla de inicio."; +"onboarding.overview.cloudRelay" = "Activa Cloud Relay más tarde en Ajustes para recibir los cambios en el momento en que ocurren, sin abrir la app."; +"onboarding.cta.openVaultSync" = "Abrir VaultSync"; +"onboarding.accessibility.page" = "Página %d de 2"; +"onboarding.accessibility.continueHint" = "Abre una breve descripción general de la configuración."; +"onboarding.accessibility.openVaultSyncHint" = "Cierra la introducción y abre la pantalla de inicio de VaultSync."; +"Setup Status" = "Estado de la configuración"; +"Check setup progress and troubleshooting tips." = "Consulta el progreso de la configuración y los consejos para resolver problemas."; +"Check the essentials for syncing. You can complete setup actions from the VaultSync home screen." = "Comprueba lo esencial para sincronizar. Puedes completar las acciones de configuración desde la pantalla de inicio de VaultSync."; +"Setup status progress" = "Progreso del estado de la configuración"; +"%d of %d essentials ready" = "%d de %d elementos esenciales listos"; +"Obsidian folder connected" = "Carpeta de Obsidian conectada"; +"VaultSync can access your local Obsidian folder." = "VaultSync puede acceder a tu carpeta local de Obsidian."; +"VaultSync cannot access your local Obsidian folder." = "VaultSync no puede acceder a tu carpeta local de Obsidian."; +"Connect your Obsidian folder from the VaultSync home screen." = "Conecta tu carpeta de Obsidian desde la pantalla de inicio de VaultSync."; +"Computer or server added" = "Ordenador o servidor añadido"; +"Your iPhone is paired with at least one Syncthing device." = "Tu iPhone está emparejado con al menos un dispositivo de Syncthing."; +"Your iPhone is not paired with a Syncthing device yet." = "Tu iPhone aún no está emparejado con ningún dispositivo de Syncthing."; +"Add your computer or server from the Devices section on the home screen." = "Añade tu ordenador o servidor desde la sección Dispositivos en la pantalla de inicio."; +"Vault syncing" = "Vault sincronizándose"; +"At least one Obsidian vault is active in VaultSync." = "Al menos un Vault de Obsidian está activo en VaultSync."; +"A vault offer is waiting to be accepted." = "Hay una oferta de Vault esperando a ser aceptada."; +"A vault offer is waiting. Accept it from Pending Shares on the home screen." = "Hay una oferta de Vault esperando. Acéptala en Comparticiones pendientes en la pantalla de inicio."; +"A vault offer was seen earlier, but no vault is syncing right now." = "Antes se detectó una oferta de Vault, pero ahora mismo no se está sincronizando ningún Vault."; +"If syncing has not started, share your Obsidian vault again from Syncthing on your computer." = "Si la sincronización no ha comenzado, vuelve a compartir tu Vault de Obsidian desde Syncthing en tu ordenador."; +"No Obsidian vault is active in VaultSync yet." = "Aún no hay ningún Vault de Obsidian activo en VaultSync."; +"Share your Obsidian vault from Syncthing on your computer." = "Comparte tu Vault de Obsidian desde Syncthing en tu ordenador."; +"VaultSync’s sync engine is running." = "El motor de sincronización de VaultSync está en ejecución."; +"VaultSync’s sync engine is still starting or unavailable." = "El motor de sincronización de VaultSync todavía se está iniciando o no está disponible."; +"If this stays unavailable, restart VaultSync and check the home screen for issues." = "Si sigue sin estar disponible, reinicia VaultSync y revisa la pantalla de inicio en busca de problemas."; +"Cloud Relay ready" = "Cloud Relay listo"; +"Cloud Relay is available for faster background updates." = "Cloud Relay está disponible para actualizaciones en segundo plano más rápidas."; +"Cloud Relay is not enabled." = "Cloud Relay no está activado."; +"Enable Cloud Relay later in Settings if you want faster background updates." = "Activa Cloud Relay más tarde en Ajustes si quieres actualizaciones en segundo plano más rápidas."; + +/* Sync Filters */ +"Sync Filters" = "Filtros de sincronización"; +"Recommended" = "Recomendado"; +"Found in this vault" = "Encontrado en este Vault"; +"Other presets" = "Otras preconfiguraciones"; +"Custom patterns" = "Patrones personalizados"; +"How filters work" = "Cómo funcionan los filtros"; +"Add pattern (e.g. *.tmp)" = "Añadir patrón (p. ej. *.tmp)"; +"Sync Filter Error" = "Error de filtro de sincronización"; +"Skip these on this iPhone? You can change this anytime in Sync Filters." = "¿Omitir estos en este iPhone? Puedes cambiarlo cuando quieras en Filtros de sincronización."; +"Skip" = "Omitir"; +"Choose what gets synced to this iPhone" = "Elige qué se sincroniza en este iPhone"; +"Always skip on this iPhone" = "Omitir siempre en este iPhone"; +"More actions" = "Más acciones"; +"Skipping enabled" = "Omisión activada"; +"'%@' and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters." = "'%@' y sus copias de conflicto dejarán de sincronizarse en este iPhone. Puedes deshacerlo en Filtros de sincronización."; +"1 existing conflict copy was removed." = "Se eliminó 1 copia de conflicto existente."; +"%d existing conflict copies were removed." = "Se eliminaron %d copias de conflicto existentes."; +"+ conflict copies" = "+ copias de conflicto"; +"Could not add filter" = "No se pudo añadir el filtro"; +"Could not save filters" = "No se pudieron guardar los filtros"; +"Could not read current sync filters. Please try again." = "No se pudieron leer los filtros de sincronización actuales. Inténtalo de nuevo."; +"%@ — %d files" = "%@ — %d archivos"; + +/* IgnorePreset labels and descriptions */ +"Workspace state" = "Estado del espacio de trabajo"; +"Prevents sync conflicts on which notes were open." = "Evita conflictos de sincronización sobre qué notas estaban abiertas."; +"Trash" = "Papelera"; +"Files already deleted on other devices." = "Archivos ya eliminados en otros dispositivos."; +"Git repository" = "Repositorio de Git"; +"Version history — rarely useful on iPhone." = "Historial de versiones — rara vez útil en el iPhone."; +"macOS metadata" = "Metadatos de macOS"; +"Finder metadata files like .DS_Store." = "Archivos de metadatos de Finder como .DS_Store."; +"Copilot index" = "Índice de Copilot"; +"Cache of Obsidian Copilot, regenerated automatically." = "Caché de Obsidian Copilot, se regenera automáticamente."; +"Obsidian app cache" = "Caché de la app Obsidian"; +"Auto-regenerated Obsidian internal cache." = "Caché interna de Obsidian regenerada automáticamente."; + +// Sync Issue titles & messages +"1 Vault Has Sync Errors" = "1 Vault tiene errores de sincronización"; +"%d Vaults Have Sync Errors" = "%d Vaults tienen errores de sincronización"; +"At least one folder is currently in an error state." = "Al menos una carpeta se encuentra actualmente en un estado de error."; +"1 Required Device Is Disconnected" = "1 dispositivo necesario está desconectado"; +"%d Required Devices Are Disconnected" = "%d dispositivos necesarios están desconectados"; +"1 Pending Share Needs Attention" = "1 compartición pendiente requiere atención"; +"%d Pending Shares Need Attention" = "%d comparticiones pendientes requieren atención"; +"1 Conflict Needs Resolution" = "1 conflicto necesita resolverse"; +"%d Conflicts Need Resolution" = "%d conflictos necesitan resolverse"; + +/* Issue #10 — conflict notification body */ +"1 file has a sync conflict. Open VaultSync to resolve it." = "1 archivo tiene un conflicto de sincronización. Abre VaultSync para resolverlo."; +"%d files have sync conflicts. Open VaultSync to resolve them." = "%d archivos tienen conflictos de sincronización. Abre VaultSync para resolverlos."; + +/* Issue #10 — conflict-notifications toggle (Settings) */ +"Notifications" = "Notificaciones"; +"Conflict Notifications" = "Notificaciones de conflictos"; +"Show a banner when sync conflicts are detected. Turning this off does not affect Cloud Relay or background sync — your vault keeps syncing." = "Muestra un aviso cuando se detectan conflictos de sincronización. Desactivarlo no afecta a Cloud Relay ni a la sincronización en segundo plano: tu Vault sigue sincronizándose."; + +/* Issue #10 — relay/alert decoupling (Relay Diagnostics) */ +"Alert Banners" = "Avisos"; +"Allowed" = "Permitido"; +"Denied" = "Denegado"; +"Cloud Relay is delivering wake-ups" = "Cloud Relay está entregando activaciones"; +"Alert banners are off at the iOS level. Cloud Relay wake-ups still work — they use silent push, which does not need notification permission." = "Los avisos están desactivados a nivel de iOS. Las activaciones de Cloud Relay siguen funcionando: usan push silencioso, que no necesita permiso de notificaciones."; +"APNs token is missing. Retry APNs registration; if it keeps failing, check your internet connection. (Silent push does not require notification banners.)" = "Falta el token de APNs. Reintenta el registro de APNs; si sigue fallando, comprueba tu conexión a internet. (El push silencioso no requiere avisos de notificación.)"; +"APNs registration failed. Check your internet connection and retry registration. Silent push does not require notification banners to be enabled." = "El registro de APNs falló. Comprueba tu conexión a internet y reintenta el registro. El push silencioso no requiere que los avisos de notificación estén activados."; + +/* Issue #10 — background-sync reliability (error-settled outcome) */ +"Background sync settled with at least one folder in an error state." = "La sincronización en segundo plano terminó con al menos una carpeta en un estado de error."; + +/* Issue #10 — relay reachable (vs delivering) */ +"Cloud Relay looks reachable" = "Cloud Relay parece accesible"; + +/* Subscription period units */ +"month" = "mes"; +"months" = "meses"; +"week" = "semana"; +"weeks" = "semanas"; +"year" = "año"; +"years" = "años"; +"%@ / %@" = "%@ / %@"; +"%1$@ / %2$d %3$@" = "%1$@ / %2$d %3$@"; +/* Contributions / tip jar */ +"Support VaultSync" = "Apoyar VaultSync"; +"Small Contribution" = "Contribución pequeña"; +"Big Contribution" = "Contribución grande"; +"Contribution" = "Contribución"; +"Contribution Failed" = "La contribución falló"; +"Contributions are currently unavailable." = "Las contribuciones no están disponibles actualmente."; +"Thank you!" = "¡Gracias!"; +"Your contribution means a lot and directly supports VaultSync development. Thank you!" = "Tu contribución significa mucho y apoya directamente el desarrollo de VaultSync. ¡Gracias!"; +"VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward. It unlocks nothing — VaultSync stays fully functional without it — and you can give as often as you like." = "VaultSync es una app independiente y de código abierto (MPL-2.0). Una contribución única la mantiene independiente, sin anuncios y en marcha. No desbloquea nada — VaultSync sigue siendo totalmente funcional sin ella — y puedes contribuir tantas veces como quieras."; +/* Cloud Relay price (localized via StoreKit) */ +"Cloud Relay — %@" = "Cloud Relay — %@"; +"Cloud Relay subscription" = "Suscripción a Cloud Relay"; + +/* Issue #10 pre-merge polish — added missing localization keys */ +"Not available" = "No disponible"; +"Copied" = "Copiado"; +"Rescanning…" = "Reescaneando…"; +"Cannot Load Files" = "No se pueden cargar los archivos"; +"Remove this device?" = "¿Eliminar este dispositivo?"; +"Double-tap to share this vault with this device." = "Toca dos veces para compartir este Vault con este dispositivo."; +"Double-tap to stop sharing this vault with this device." = "Toca dos veces para dejar de compartir este Vault con este dispositivo."; +"All conflicts resolved" = "Todos los conflictos resueltos"; +"Loading files…" = "Cargando archivos…"; +"Computing diff…" = "Calculando diferencias…"; +"Other Device" = "Otro dispositivo"; +"Other Device (%@)" = "Otro dispositivo (%@)"; +"Added lines come from the other device; removed lines are your version on this device." = "Las líneas añadidas provienen del otro dispositivo; las líneas eliminadas son tu versión en este dispositivo."; +"(empty or unreadable)" = "(vacío o ilegible)"; +"a new name" = "un nombre nuevo"; +"Both versions were kept.\n\nYour local version remains as '%@'.\nThe other device's version was renamed to '%@'." = "Se conservaron ambas versiones.\n\nTu versión local permanece como '%@'.\nLa versión del otro dispositivo se renombró a '%@'."; +"%d conflicts" = "%d conflictos"; +"Your contribution is pending approval." = "Tu contribución está pendiente de aprobación."; +"Could not accept share '%@'.\n\n%@" = "No se pudo aceptar la compartición '%@'.\n\n%@"; +"iOS did not provide a push token required for instant sync." = "iOS no proporcionó un token de push necesario para la sincronización instantánea."; +"%@ (Trigger: %@)" = "%@ (Activador: %@)"; +"1 additional file synced in %@" = "1 archivo adicional sincronizado en %@"; +"%d additional files synced in %@" = "%d archivos adicionales sincronizados en %@"; +"%@ connected" = "%@ conectado"; +"%@ disconnected" = "%@ desconectado"; +"%d%% complete" = "%d%% completado"; +"Background sync completed and reached idle." = "La sincronización en segundo plano se completó y alcanzó el estado inactivo."; +"Background sync did not reach an idle folder state before the iOS deadline." = "La sincronización en segundo plano no alcanzó un estado de carpeta inactivo antes del plazo de iOS."; +"Background sync ended with an unexpected failure." = "La sincronización en segundo plano terminó con un fallo inesperado."; +"Background sync ran, but folders were already idle." = "La sincronización en segundo plano se ejecutó, pero las carpetas ya estaban inactivas."; +"Forced silent-push restart failed." = "El reinicio forzado por push silencioso falló."; +"No Syncthing folders were available to sync in the background." = "No había carpetas de Syncthing disponibles para sincronizar en segundo plano."; +"No folders were available after forced silent-push restart." = "No había carpetas disponibles tras el reinicio forzado por push silencioso."; +"No folders were available for background sync." = "No había carpetas disponibles para la sincronización en segundo plano."; +"No security-scoped bookmark access was available." = "No había acceso disponible mediante marcador de ámbito de seguridad."; +"Accept or create a shared vault before relying on background sync." = "Acepta o crea un Vault compartido antes de depender de la sincronización en segundo plano."; +"Retry from the app and review relay/background diagnostics in Settings." = "Reintenta desde la app y revisa el diagnóstico de relay/segundo plano en Ajustes."; +"Silent push restarted Syncthing, but no real sync progress was observed before the app returned to idle." = "El push silencioso reinició Syncthing, pero no se observó ningún progreso real de sincronización antes de que la app volviera al estado inactivo."; +"Sync did not reach idle before %ds deadline." = "La sincronización no alcanzó el estado inactivo antes del plazo de %ds."; +"Relay %@ failed with HTTP %d." = "El Relay %@ falló con HTTP %d."; +"Relay %@ is rate limited (HTTP 429)." = "El Relay %@ está limitado por frecuencia (HTTP 429)."; +"Relay %@ network error: %@" = "Error de red del Relay %@: %@"; +"Relay %@ returned a non-HTTP response." = "El Relay %@ devolvió una respuesta que no es HTTP."; +"Relay %@ unauthorized (HTTP %d)." = "El Relay %@ no está autorizado (HTTP %d)."; +"Unauthorized request." = "Solicitud no autorizada."; +"relay network: %@" = "red del relay: %@"; diff --git a/ios/VaultSync/zh-Hans.lproj/Localizable.strings b/ios/VaultSync/zh-Hans.lproj/Localizable.strings index ef591ff..9455adf 100644 --- a/ios/VaultSync/zh-Hans.lproj/Localizable.strings +++ b/ios/VaultSync/zh-Hans.lproj/Localizable.strings @@ -6,7 +6,6 @@ "Active" = "已激活"; "Add" = "添加"; "Add Device" = "添加设备"; -"Add a device from the main screen using its Syncthing Device ID." = "在主界面使用 Syncthing 设备 ID 添加设备。"; "Add a device using its Syncthing Device ID. Find it in the Syncthing web UI under Actions > Show ID." = "使用 Syncthing 设备 ID 添加设备。你可以在 Syncthing 网页界面的 Actions > Show ID 中找到它。"; "Add or Reconnect Device" = "添加或重新连接设备"; "Added line. %@" = "已添加行。%@"; @@ -23,7 +22,6 @@ "Background Sync Found No Vaults" = "后台同步未找到 Vault"; "Background Sync Timed Out" = "后台同步超时"; "Background sync: %@" = "后台同步:%@"; -"Before you start" = "开始之前"; "Camera Access Required" = "需要相机权限"; "Can't find the Obsidian folder?" = "找不到 Obsidian 文件夹?"; "Cancel" = "取消"; @@ -35,18 +33,13 @@ "Check your subscription status in Settings and retry. If this persists, restart VaultSync." = "在设置中检查订阅状态后重试。如果问题持续存在,请重启 VaultSync。"; "Cloud Relay" = "Cloud Relay"; "Cloud Relay active" = "Cloud Relay 已启用"; -"Cloud Relay configured (optional)" = "Cloud Relay 已配置(可选)"; -"Cloud Relay enables instant sync when files change on your server, instead of waiting for the next background refresh." = "Cloud Relay 会在服务器文件发生变化时立即同步,而不是等待下一次后台刷新。"; +"When files change on your server, a silent push wakes VaultSync the moment it happens, so sync feels instant without opening the app. The relay only sends a wake-up signal — it never sees your notes." = "服务器上的文件一发生变化,静默推送就会在第一时间唤醒 VaultSync,无需打开应用,同步仿佛即时完成。Relay 只发送唤醒信号,绝不会看到你的笔记。"; "Cloud Relay is not currently subscribed. Push-triggered wake-ups are disabled until the subscription is active." = "当前未订阅 Cloud Relay。在订阅激活前,基于推送的唤醒功能将被禁用。"; "Cloud Relay is not subscribed. Start a subscription first." = "Cloud Relay 尚未订阅。请先开始订阅。"; -"Cloud Relay is off." = "Cloud Relay 已关闭。"; "Cloud Relay provisioning could not contact the relay backend." = "Cloud Relay 配置无法连接到 relay 后端。"; "Cloud Relay provisioning did not complete." = "Cloud Relay 配置未完成。"; "Cloud Relay provisioning is temporarily rate limited." = "Cloud Relay 配置当前被临时限流。"; -"Cloud Relay — $0.99/month" = "Cloud Relay — ¥ 8.00/月"; -"Complete" = "已完成"; "Completion" = "完成度"; -"Computing diff..." = "正在计算 diff..."; "Configuration Error" = "配置错误"; "Conflict Resolution Failed" = "冲突解决失败"; "Conflict Resolved" = "冲突已解决"; @@ -55,11 +48,8 @@ "Conflicts mean multiple versions exist and need a manual decision." = "冲突表示存在多个版本,需要手动决定保留哪一个。"; "Connect Obsidian to accept shares" = "连接 Obsidian 以接受共享"; "Connect Obsidian Folder" = "连接 Obsidian 文件夹"; -"Connect the Obsidian folder from the main screen." = "从主界面连接 Obsidian 文件夹。"; "Connect to Obsidian first" = "请先连接 Obsidian"; "Connected" = "已连接"; -"Connected. %@" = "已连接。%@"; -"Connected. Waiting for vault folders to appear." = "已连接。正在等待 Vault 文件夹出现。"; "Connection to peer was closed." = "与对端的连接已关闭。"; "Copy Device ID" = "复制设备 ID"; "Could Not Accept Share" = "无法接受共享"; @@ -70,19 +60,15 @@ "Could not read files.\n\n%@\n%@" = "无法读取文件。\n\n%@\n%@"; "Could not read original file.\n\n%@" = "无法读取原始文件。\n\n%@"; "Create a vault in Obsidian first. VaultSync will detect it automatically." = "请先在 Obsidian 中创建一个 Vault。VaultSync 会自动检测到它。"; -"Desktop device paired" = "桌面设备已配对"; "Device" = "设备"; "Device ID" = "设备 ID"; -"Device ID is available and Syncthing is running." = "设备 ID 可用,且 Syncthing 正在运行。"; "%d of %d devices connected" = "已连接 %d/%d 台设备"; "Device name" = "设备名称"; "Devices" = "设备"; "Differences" = "差异"; "Discards the version from the other device." = "丢弃另一台设备的版本。"; "Disconnected" = "已断开"; -"Discovery" = "发现"; "Done" = "完成"; -"Download Syncthing" = "下载 Syncthing"; "Empty line" = "空行"; "Enable notifications for VaultSync in iOS Settings -> Notifications -> VaultSync, then restart the app." = "请在 iOS 设置 -> 通知 -> VaultSync 中启用通知,然后重新启动应用。"; "Error" = "错误"; @@ -92,8 +78,6 @@ "File Read Failed" = "读取文件失败"; "File synced in %@" = "已同步 %@ 中的文件"; "Files are being synchronized with peers." = "文件正在与对端同步。"; -"Finish the required steps so sync stays reliable." = "完成必需步骤,以保持同步稳定可靠。"; -"First share detected" = "已检测到第一个共享"; "Folder Not Configured" = "文件夹未配置"; "Folder Path Missing" = "文件夹路径缺失"; "Folder Permission Error" = "文件夹权限错误"; @@ -103,21 +87,15 @@ "Folder is currently in an error state." = "该文件夹当前处于错误状态。"; "Folder reached idle state after syncing." = "同步后文件夹已进入空闲状态。"; "Folder reported an error." = "文件夹报告了一个错误。"; -"From desktop Syncthing, share one vault to this iPhone Device ID." = "在桌面 Syncthing 中将一个 Vault 共享给这个 iPhone 设备 ID。"; -"Global Discovery" = "全局发现"; "Global Files" = "全局文件"; "Health Endpoint" = "健康检查端点"; "Healthy" = "正常"; -"How pairing works" = "配对方式"; "How to fix: %@" = "修复方法:%@"; "Idle" = "空闲"; -"If syncing has not started, reshare a vault from desktop Syncthing." = "如果同步尚未开始,请从桌面 Syncthing 重新共享一个 Vault。"; "Ignore for Now" = "暂时忽略"; "Ignored shares (%d)" = "已忽略的共享(%d)"; "In progress" = "进行中"; -"In the picker, choose \"On My iPhone\" -> \"Obsidian\", then tap Open." = "在选择器中,选择“在我的 iPhone 上” -> “Obsidian”,然后点按“打开”。"; "Install Obsidian from the App Store and open it once. The folder appears after Obsidian creates it." = "从 App Store 安装 Obsidian 并打开一次。文件夹会在 Obsidian 创建后出现。"; -"Instant sync via Cloud Relay is active." = "通过 Cloud Relay 的即时同步已启用。"; "Invalid Input" = "输入无效"; "Invalid folder name: '%@'" = "无效的文件夹名称:'%@'"; "Keep Both" = "两者都保留"; @@ -128,7 +106,6 @@ "Keep both versions" = "保留两个版本"; "Keep other device version" = "保留另一台设备版本"; "Keep the app open for a moment and retry. If this persists, restart VaultSync." = "请保持应用打开片刻后重试。如果问题持续存在,请重启 VaultSync。"; -"Keep the app open for a moment. If this persists, restart VaultSync." = "请保持应用打开片刻。如果问题持续存在,请重启 VaultSync。"; "Keep this device version" = "保留此设备版本"; "Keeps your local file and renames the other device's file." = "保留你的本地文件,并重命名另一台设备的文件。"; "Last Check" = "上次检查"; @@ -139,15 +116,11 @@ "Last Update" = "上次更新"; "Last successful sync was about %d %@ ago." = "上次成功同步约在 %d %@ 前。"; "Last successful sync was more than %d %@ ago." = "上次成功同步已超过 %d %@。"; -"Last sync:" = "上次同步:"; +"Last sync: %@" = "上次同步:%@"; "Latency" = "延迟"; "Learn how to fix" = "查看修复方法"; -"Let's Get Started" = "开始使用"; -"Loading files..." = "正在加载文件..."; "Loading…" = "正在加载…"; -"Local Discovery" = "本地发现"; "Local Files" = "本地文件"; -"Local discovery finds devices on your WiFi network. Global discovery uses Syncthing's servers to find devices anywhere." = "本地发现会在你的 WiFi 网络中查找设备。全局发现会通过 Syncthing 的服务器查找任意位置的设备。"; "Log" = "日志"; "Manage Subscription" = "管理订阅"; "Name" = "名称"; @@ -159,10 +132,8 @@ "No Syncthing peers available yet." = "尚无可用的 Syncthing 对端。"; "No action needed." = "无需操作。"; "No active pending shares" = "没有活动的待处理共享"; -"No desktop or laptop Syncthing device configured yet." = "尚未配置桌面或笔记本 Syncthing 设备。"; "No devices configured" = "未配置任何设备"; "No devices connected" = "没有已连接的设备"; -"No folder share from your desktop has been detected yet." = "尚未检测到来自桌面的文件夹共享。"; "No folders syncing yet" = "尚无正在同步的文件夹"; "No home server devices available for relay provisioning." = "没有可用于 Relay 配置的家庭服务器设备。"; "No immediate relay problems detected." = "未检测到即时 Relay 问题。"; @@ -176,16 +147,12 @@ "Not attempted" = "未尝试"; "Not checked" = "未检查"; "Not shared" = "未共享"; -"Notifications are disabled for VaultSync. Enable them in iOS Settings > Notifications > VaultSync, then retry APNs registration." = "VaultSync 的通知已被禁用。请在 iOS 设置 > 通知 > VaultSync 中启用通知,然后重试 APNs 注册。"; "OK" = "确定"; "Obsidian Folder Connection Failed" = "连接 Obsidian 文件夹失败"; "Obsidian Vaults" = "Obsidian Vault"; "Obsidian access expired" = "Obsidian 访问已过期"; -"Obsidian connected" = "Obsidian 已连接"; "Obsidian directory not accessible." = "无法访问 Obsidian 目录。"; "Obsidian folder not connected" = "Obsidian 文件夹未连接"; -"Offline" = "离线"; -"Open Pending Shares in VaultSync and accept one to start syncing." = "在 VaultSync 中打开待处理共享并接受一个以开始同步。"; "Open Relay Diagnostics" = "打开 Relay 诊断"; "Open Settings" = "打开设置"; "Open VaultSync" = "打开 VaultSync"; @@ -249,7 +216,6 @@ "Rename Failed" = "重命名失败"; "Renews" = "续订"; "Requesting camera access…" = "正在请求相机权限…"; -"Required" = "必需"; "Rescan Failed" = "重扫失败"; "Rescan Failed Vaults" = "重扫失败的 Vault"; "Rescan Vault" = "重扫 Vault"; @@ -258,42 +224,30 @@ "Resolve Conflicts" = "解决冲突"; "Restore Purchases" = "恢复购买"; "Restore Share" = "恢复共享"; -"%d/%d required" = "%d/%d 为必需"; "A conflict happens when a file is edited on two devices at the same time. Syncthing saves both versions to prevent data loss." = "当同一个文件同时在两台设备上被编辑时,就会发生冲突。Syncthing 会保存两个版本以防止数据丢失。"; "APNs Registration" = "APNs 注册"; "APNs Token" = "APNs 令牌"; -"APNs registration failed. Open iOS Settings > Notifications > VaultSync, allow notifications, then retry." = "APNs 注册失败。请打开 iOS 设置 > 通知 > VaultSync,允许通知后再重试。"; -"APNs token is missing. Enable notifications for VaultSync and retry APNs registration." = "缺少 APNs 令牌。请为 VaultSync 启用通知并重试 APNs 注册。"; "Context: %@ · %@" = "上下文:%@ · %@"; -"Discovery Update Failed" = "发现设置更新失败"; "In the picker, choose \"On My iPhone\" → \"Obsidian\", then tap Open." = "在选择器中选择“在我的 iPhone 上” → “Obsidian”,然后点按“打开”。"; "Missing" = "缺失"; "Present" = "已提供"; "Purchase Failed" = "购买失败"; "Rescan All Vaults" = "重扫所有 Vault"; -"Some devices are not provisioned. Retry provisioning after APNs and subscription checks are green." = "部分设备尚未完成配置。请在 APNs 和订阅检查都正常后重试配置。"; "Sync Conflicts" = "同步冲突"; -"This timestamp is updated when VaultSync receives a silent push from Cloud Relay." = "当 VaultSync 收到来自 Cloud Relay 的静默推送时,这个时间戳会更新。"; "XXXXXXX-XXXXXXX-..." = "XXXXXXX-XXXXXXX-..."; -"e.g. My Laptop" = "例如:我的笔记本"; "Retry APNs Registration" = "重试 APNs 注册"; "Retry Accept" = "重试接受"; "Retry Provisioning" = "重试配置"; "Retry provisioning from Settings. If this persists, verify subscription status." = "请在设置中重试配置。如果问题持续存在,请检查订阅状态。"; "Retry the action. If it keeps failing, restart the app and check Settings diagnostics." = "请重试该操作。如果仍然失败,请重启应用并检查设置中的诊断信息。"; -"Review or complete the initial setup checklist." = "查看或完成初始设置清单。"; "Review the affected device/folder setup in the app and retry." = "检查应用中相关设备/文件夹设置后再重试。"; "Review the value and try again." = "请检查该值后重试。"; "Run Foreground Rescan" = "执行前台重扫"; "Scan QR Code" = "扫描 QR Code"; "Scanning" = "扫描中"; -"Scanning completed in %@" = "%@ 扫描完成"; -"Scanning started in %@" = "%@ 开始扫描"; +"Scanning completed in %@" = "%@ 中的扫描已完成"; +"Scanning started in %@" = "%@ 中开始扫描"; "Settings" = "设置"; -"Settings Error" = "设置错误"; -"Setup Checklist" = "设置清单"; -"Setup Guide" = "设置指南"; -"Setup checklist progress" = "设置清单进度"; "Share a folder from your desktop Syncthing — it will be accepted automatically." = "从桌面 Syncthing 共享一个文件夹——它会被自动接受。"; "Share requests are shown below, but Accept and Retry are disabled until your Obsidian folder is connected." = "共享请求显示在下方,但在连接 Obsidian 文件夹之前,“接受”和“重试”按钮会被禁用。"; "Shared" = "已共享"; @@ -301,7 +255,6 @@ "Shared by an unknown device" = "由未知设备共享"; "Shared by: %@" = "共享者:%@"; "Show Line-by-Line Diff" = "显示逐行差异"; -"Shows whether Syncthing is currently active." = "显示 Syncthing 当前是否处于活动状态。"; "Shows whether this Syncthing device is currently reachable." = "显示该 Syncthing 设备当前是否可达。"; "Silent Push" = "静默推送"; "Some devices are not provisioned. Retry provisioning after APNs and subscription checks are green." = "某些设备尚未配置。在 APNs 和订阅检查都正常后,请重试配置。"; @@ -322,7 +275,6 @@ "Sync completed in %@" = "%@ 中的同步已完成"; "Sync error in %@" = "%@ 中发生同步错误"; "Sync started in %@" = "%@ 中开始同步"; -"Sync your Obsidian vaults privately with Syncthing — no cloud required." = "使用 Syncthing 私密同步你的 Obsidian Vault,无需云服务。"; "Synchronizing your Obsidian vault..." = "正在同步你的 Obsidian Vault..."; "Syncing" = "同步中"; "Syncing Vault" = "正在同步 Vault"; @@ -330,7 +282,6 @@ "Reconnecting…" = "正在重新连接…"; "Restoring connection to 1 device" = "正在恢复到 1 个设备的连接"; "Restoring connection to %d devices" = "正在恢复到 %d 个设备的连接"; -"Syncthing engine started" = "Syncthing 引擎已启动"; "Syncthing is scanning local changes." = "Syncthing 正在扫描本地更改。"; "Syncthing no longer has this folder configured." = "Syncthing 已不再配置该文件夹。"; "Terms of Use" = "使用条款"; @@ -362,7 +313,6 @@ "Unreachable" = "无法访问"; "Vault" = "Vault"; "VaultSync" = "VaultSync"; -"VaultSync Setup" = "VaultSync 设置"; "VaultSync can no longer read your Obsidian directory. Reconnect the folder to restore sync access." = "VaultSync 已无法读取你的 Obsidian 目录。请重新连接该文件夹以恢复同步访问。"; "VaultSync can no longer resolve the saved Obsidian folder permission. Reconnect the Obsidian directory to continue syncing." = "VaultSync 已无法解析已保存的 Obsidian 文件夹权限。请重新连接 Obsidian 目录以继续同步。"; "VaultSync cannot access the saved Obsidian folder anymore. Reconnect the Obsidian directory to continue syncing." = "VaultSync 已无法访问已保存的 Obsidian 文件夹。请重新连接 Obsidian 目录以继续同步。"; @@ -373,46 +323,20 @@ "VaultSync could not reach the Cloud Relay service." = "VaultSync 无法连接到 Cloud Relay 服务。"; "VaultSync could not restore bookmark access for the Obsidian folder during a background run." = "VaultSync 无法在后台运行期间恢复对 Obsidian 文件夹的书签访问权限。"; "VaultSync could not verify this request." = "VaultSync 无法验证该请求。"; -"VaultSync does not have access to your Obsidian directory." = "VaultSync 无法访问你的 Obsidian 目录。"; "VaultSync does not have the required permission for this action." = "VaultSync 没有所需的权限来执行此操作。"; "VaultSync found a configuration problem." = "VaultSync 检测到配置问题。"; -"VaultSync is still starting Syncthing." = "VaultSync 仍在启动 Syncthing。"; "VaultSync needs camera access to scan Syncthing Device ID QR codes. Please enable it in Settings." = "VaultSync 需要相机权限来扫描 Syncthing 设备 ID 的 QR Code。请在设置中启用。"; "VaultSync needs one-time access to your Obsidian folder before it can accept shares." = "VaultSync 需要一次性访问你的 Obsidian 文件夹后,才能接受共享。"; "VaultSync reported an unexpected error." = "VaultSync 报告了一个意外错误。"; "Wait a moment and retry." = "请稍等片刻后重试。"; -"Welcome to VaultSync" = "欢迎使用 VaultSync"; "What is a conflict?" = "什么是冲突?"; "day" = "天"; "days" = "天"; -"device configured" = "已配置设备"; -"devices configured" = "已配置设备"; "e.g. My Laptop" = "例如:我的笔记本"; "hour" = "小时"; "hours" = "小时"; -"pending share found" = "已找到待处理共享"; -"pending shares found" = "已找到待处理共享"; -"shared folder active" = "共享文件夹已启用"; -"shared folders active" = "共享文件夹已启用"; -"vault detected" = "已检测到 Vault"; -"vaults detected" = "已检测到 Vault"; -"Both devices exchange Device IDs" = "两台设备交换设备 ID"; -"Both devices should be on the same WiFi, or have global discovery enabled in Syncthing." = "两台设备应在同一 WiFi 下,或在 Syncthing 中启用全局发现。"; -"End-to-end encrypted" = "端到端加密"; -"Fast Markdown sync" = "快速 Markdown 同步"; -"Install Obsidian on this iPhone and on your desktop." = "请在这台 iPhone 和桌面设备上安装 Obsidian。"; -"Install and run Syncthing on the computer you sync with." = "请在与你同步的电脑上安装并运行 Syncthing。"; "No Activity Yet" = "还没有活动"; -"No cloud required" = "无需云服务"; -"Obsidian on both devices" = "两台设备上都安装 Obsidian"; -"Running" = "运行中"; -"Same network or global discovery" = "同一网络或全局发现"; "Sync engine running" = "同步引擎正在运行"; -"Sync engine stopped" = "同步引擎已停止"; -"Syncthing on your desktop" = "桌面上的 Syncthing"; -"You can enable it later in Settings for instant push-based sync." = "你可以稍后在设置中启用它,以获得即时的推送同步。"; -"Your desktop shares a vault folder" = "你的桌面设备共享一个 Vault 文件夹"; -"VaultSync keeps everything in sync" = "VaultSync 让一切保持同步"; "onboarding.welcome.title" = "你的 Obsidian 笔记,私密同步。"; "onboarding.welcome.subtitle" = "在你自己的设备之间同步 Vault,无需依赖第三方云存储。"; "onboarding.welcome.benefit.private" = "只在你的设备之间同步"; @@ -429,7 +353,7 @@ "onboarding.overview.step3.description" = "在电脑上的 Syncthing 中将你的 Obsidian Vault 共享到这台 iPhone。"; "onboarding.overview.step4.title" = "查看同步状态"; "onboarding.overview.step4.description" = "VaultSync 会在主屏幕显示活动 Vault、问题和同步进度。"; -"onboarding.overview.cloudRelay" = "Cloud Relay 可稍后在“设置”中启用,以获得更快的后台更新。"; +"onboarding.overview.cloudRelay" = "稍后可在“设置”中启用 Cloud Relay,让改动在发生的那一刻就送达,无需打开应用。"; "onboarding.cta.openVaultSync" = "打开 VaultSync"; "onboarding.accessibility.page" = "第 %d 页,共 2 页"; "onboarding.accessibility.continueHint" = "打开简短的设置概览。"; @@ -470,7 +394,7 @@ "Other presets" = "其他预设"; "Custom patterns" = "自定义规则"; "How filters work" = "过滤器工作原理"; -"Add pattern (e.g. *.tmp)" = "添加规则(例如 *.tmp)"; +"Add pattern (e.g. *.tmp)" = "添加规则(例如 *.tmp)"; "Sync Filter Error" = "同步过滤器错误"; "Skip these on this iPhone? You can change this anytime in Sync Filters." = "在此 iPhone 上跳过这些?你可以随时在同步过滤器中修改。"; "Skip" = "跳过"; @@ -478,7 +402,6 @@ "Always skip on this iPhone" = "在此 iPhone 上始终跳过"; "More actions" = "更多操作"; "Skipping enabled" = "跳过已启用"; -"'%@' will no longer sync to this iPhone. You can undo this in Sync Filters." = "「%@」将不再同步到此 iPhone。你可以在同步过滤器中撤销。"; "'%@' and its conflict copies will no longer sync to this iPhone. You can undo this in Sync Filters." = "「%@」及其冲突副本将不再同步到此 iPhone。你可以在同步过滤器中撤销。"; "1 existing conflict copy was removed." = "已移除 1 个现有冲突副本。"; "%d existing conflict copies were removed." = "已移除 %d 个现有冲突副本。"; @@ -501,7 +424,6 @@ "Cache of Obsidian Copilot, regenerated automatically." = "Obsidian Copilot 的缓存,会自动重建。"; "Obsidian app cache" = "Obsidian 应用缓存"; "Auto-regenerated Obsidian internal cache." = "自动重建的 Obsidian 内部缓存。"; -"Node modules" = "Node 模块"; // Sync Issue titles & messages "1 Vault Has Sync Errors" = "1 个 Vault 出现同步错误"; @@ -513,3 +435,98 @@ "%d Pending Shares Need Attention" = "%d 个待处理共享需要处理"; "1 Conflict Needs Resolution" = "1 个冲突待解决"; "%d Conflicts Need Resolution" = "%d 个冲突待解决"; + +/* Issue #10 — conflict notification body */ +"1 file has a sync conflict. Open VaultSync to resolve it." = "1 个文件存在同步冲突。打开 VaultSync 解决。"; +"%d files have sync conflicts. Open VaultSync to resolve them." = "%d 个文件存在同步冲突。打开 VaultSync 解决。"; + +/* Issue #10 — conflict-notifications toggle (Settings) */ +"Notifications" = "通知"; +"Conflict Notifications" = "冲突通知"; +"Show a banner when sync conflicts are detected. Turning this off does not affect Cloud Relay or background sync — your vault keeps syncing." = "检测到同步冲突时显示提醒横幅。关闭此项不会影响 Cloud Relay 或后台同步——你的 Vault 会继续同步。"; + +/* Issue #10 — relay/alert decoupling (Relay Diagnostics) */ +"Alert Banners" = "提醒横幅"; +"Allowed" = "已允许"; +"Denied" = "已拒绝"; +"Cloud Relay is delivering wake-ups" = "Cloud Relay 正在投递唤醒信号"; +"Alert banners are off at the iOS level. Cloud Relay wake-ups still work — they use silent push, which does not need notification permission." = "提醒横幅已在 iOS 层面关闭。Cloud Relay 唤醒仍然有效——它使用静默推送,无需通知权限。"; +"APNs token is missing. Retry APNs registration; if it keeps failing, check your internet connection. (Silent push does not require notification banners.)" = "缺少 APNs 令牌。请重试 APNs 注册;若持续失败,请检查网络连接。(静默推送无需提醒横幅。)"; +"APNs registration failed. Check your internet connection and retry registration. Silent push does not require notification banners to be enabled." = "APNs 注册失败。请检查网络连接并重试注册。静默推送无需启用提醒横幅。"; + +/* Issue #10 — background-sync reliability (error-settled outcome) */ +"Background sync settled with at least one folder in an error state." = "后台同步已结束——至少一个文件夹处于错误状态。"; + +/* Issue #10 — relay reachable (vs delivering) */ +"Cloud Relay looks reachable" = "Cloud Relay 似乎可达"; + +/* Subscription period units */ +"month" = "月"; +"months" = "个月"; +"week" = "周"; +"weeks" = "周"; +"year" = "年"; +"years" = "年"; +"%@ / %@" = "%@ / %@"; +"%1$@ / %2$d %3$@" = "%1$@ / %2$d %3$@"; +/* Contributions / tip jar */ +"Support VaultSync" = "支持 VaultSync"; +"Small Contribution" = "小额支持"; +"Big Contribution" = "大额支持"; +"Contribution" = "支持"; +"Contribution Failed" = "支持失败"; +"Contributions are currently unavailable." = "目前无法进行支持。"; +"Thank you!" = "谢谢!"; +"Your contribution means a lot and directly supports VaultSync development. Thank you!" = "你的支持意义重大,将直接用于 VaultSync 的开发。谢谢!"; +"VaultSync is an independent, open-source app (MPL-2.0). A one-time contribution keeps it independent, ad-free, and moving forward. It unlocks nothing — VaultSync stays fully functional without it — and you can give as often as you like." = "VaultSync 是一款独立的开源应用(MPL-2.0)。一次性的支持就能让它保持独立、无广告并持续更新。它不会解锁任何功能——没有它 VaultSync 也完全可用——你可以随时多次支持。"; +/* Cloud Relay price (localized via StoreKit) */ +"Cloud Relay — %@" = "Cloud Relay — %@"; +"Cloud Relay subscription" = "Cloud Relay 订阅"; + +/* Issue #10 pre-merge polish — added missing localization keys */ +"Not available" = "不可用"; +"Copied" = "已复制"; +"Rescanning…" = "正在重新扫描…"; +"Cannot Load Files" = "无法加载文件"; +"Remove this device?" = "移除此设备?"; +"Double-tap to share this vault with this device." = "双击以与此设备共享此 Vault。"; +"Double-tap to stop sharing this vault with this device." = "双击以停止与此设备共享此 Vault。"; +"All conflicts resolved" = "所有冲突已解决"; +"Loading files…" = "正在加载文件…"; +"Computing diff…" = "正在计算差异…"; +"Other Device" = "其他设备"; +"Other Device (%@)" = "其他设备(%@)"; +"Added lines come from the other device; removed lines are your version on this device." = "添加的行来自其他设备;删除的行是此设备上你的版本。"; +"(empty or unreadable)" = "(为空或无法读取)"; +"a new name" = "一个新名称"; +"Both versions were kept.\n\nYour local version remains as '%@'.\nThe other device's version was renamed to '%@'." = "已保留两个版本。\n\n你的本地版本保留为“%@”。\n其他设备的版本已重命名为“%@”。"; +"%d conflicts" = "%d 个冲突"; +"Your contribution is pending approval." = "你的支持正在等待批准。"; +"Could not accept share '%@'.\n\n%@" = "无法接受共享“%@”。\n\n%@"; +"iOS did not provide a push token required for instant sync." = "iOS 未提供即时同步所需的推送令牌。"; +"%@ (Trigger: %@)" = "%@(触发:%@)"; +"1 additional file synced in %@" = "在 %@ 内同步了 1 个额外文件"; +"%d additional files synced in %@" = "在 %2$@ 内同步了 %1$d 个额外文件"; +"%@ connected" = "%@ 已连接"; +"%@ disconnected" = "%@ 已断开连接"; +"%d%% complete" = "已完成 %d%%"; +"Background sync completed and reached idle." = "后台同步已完成并进入空闲状态。"; +"Background sync did not reach an idle folder state before the iOS deadline." = "后台同步在 iOS 截止时间前未达到空闲文件夹状态。"; +"Background sync ended with an unexpected failure." = "后台同步因意外错误而结束。"; +"Background sync ran, but folders were already idle." = "后台同步已运行,但文件夹已处于空闲状态。"; +"Forced silent-push restart failed." = "强制静默推送重启失败。"; +"No Syncthing folders were available to sync in the background." = "没有可用于后台同步的 Syncthing 文件夹。"; +"No folders were available after forced silent-push restart." = "强制静默推送重启后没有可用的文件夹。"; +"No folders were available for background sync." = "没有可用于后台同步的文件夹。"; +"No security-scoped bookmark access was available." = "没有可用的安全范围书签访问权限。"; +"Accept or create a shared vault before relying on background sync." = "在依赖后台同步前,请接受或创建一个共享 Vault。"; +"Retry from the app and review relay/background diagnostics in Settings." = "请在应用中重试,并在设置中查看 Relay/后台诊断。"; +"Silent push restarted Syncthing, but no real sync progress was observed before the app returned to idle." = "静默推送已重启 Syncthing,但在应用返回空闲前未观察到实际同步进展。"; +"Sync did not reach idle before %ds deadline." = "同步未在 %d 秒截止时间前进入空闲状态。"; +"Relay %@ failed with HTTP %d." = "Relay %@ 失败,HTTP %d。"; +"Relay %@ is rate limited (HTTP 429)." = "Relay %@ 被限流(HTTP 429)。"; +"Relay %@ network error: %@" = "Relay %@ 网络错误:%@"; +"Relay %@ returned a non-HTTP response." = "Relay %@ 返回了非 HTTP 响应。"; +"Relay %@ unauthorized (HTTP %d)." = "Relay %@ 未授权(HTTP %d)。"; +"Unauthorized request." = "未授权的请求。"; +"relay network: %@" = "Relay 网络:%@"; diff --git a/ios/VaultSyncTests/BackgroundSyncServiceTests.swift b/ios/VaultSyncTests/BackgroundSyncServiceTests.swift new file mode 100644 index 0000000..90d4bf4 --- /dev/null +++ b/ios/VaultSyncTests/BackgroundSyncServiceTests.swift @@ -0,0 +1,68 @@ +import Foundation +import Testing +@testable import VaultSync + +@Suite("Conflict notification suppression") +struct ConflictNotificationActionTests { + + @Test("First conflicts (none surfaced yet) alert") + func firstConflictsAlert() { + #expect(BackgroundSyncService.conflictNotificationAction(currentCount: 28, lastNotifiedCount: 0) == .alert) + #expect(BackgroundSyncService.conflictNotificationAction(currentCount: 1, lastNotifiedCount: 0) == .alert) + } + + @Test("Unchanged count is suppressed — the core of issue #10") + func unchangedCountSuppressed() { + #expect(BackgroundSyncService.conflictNotificationAction(currentCount: 28, lastNotifiedCount: 28) == .suppress) + #expect(BackgroundSyncService.conflictNotificationAction(currentCount: 1, lastNotifiedCount: 1) == .suppress) + } + + @Test("A rising count alerts (genuinely new conflicts)") + func risingCountAlerts() { + #expect(BackgroundSyncService.conflictNotificationAction(currentCount: 29, lastNotifiedCount: 28) == .alert) + } + + @Test("A falling-but-nonzero count refreshes quietly") + func fallingCountUpdatesQuietly() { + #expect(BackgroundSyncService.conflictNotificationAction(currentCount: 25, lastNotifiedCount: 28) == .updateQuiet) + } + + @Test("No conflicts left clears the banner") + func zeroClears() { + #expect(BackgroundSyncService.conflictNotificationAction(currentCount: 0, lastNotifiedCount: 28) == .clear) + #expect(BackgroundSyncService.conflictNotificationAction(currentCount: 0, lastNotifiedCount: 0) == .clear) + } + + @Test("Negative/garbage current count is treated as cleared, never as a post") + func negativeCurrentClears() { + #expect(BackgroundSyncService.conflictNotificationAction(currentCount: -3, lastNotifiedCount: 5) == .clear) + } +} + +@Suite("Folder settlement classification") +struct FolderSettlementTests { + + @Test("Idle with no pending work is idle") + func idleNoWork() { + #expect(BackgroundSyncService.folderSettlement(state: "idle", needFiles: 0, needBytes: 0, inProgressBytes: 0) == .idle) + } + + @Test("Idle but with pending work is still active (the scan→sync gap)") + func idleWithPendingIsActive() { + #expect(BackgroundSyncService.folderSettlement(state: "idle", needFiles: 3, needBytes: 0, inProgressBytes: 0) == .active) + #expect(BackgroundSyncService.folderSettlement(state: "idle", needFiles: 0, needBytes: 4096, inProgressBytes: 0) == .active) + #expect(BackgroundSyncService.folderSettlement(state: "idle", needFiles: 0, needBytes: 0, inProgressBytes: 512) == .active) + } + + @Test("Scanning and syncing are active") + func scanningSyncingActive() { + #expect(BackgroundSyncService.folderSettlement(state: "scanning", needFiles: 0, needBytes: 0, inProgressBytes: 0) == .active) + #expect(BackgroundSyncService.folderSettlement(state: "syncing", needFiles: 0, needBytes: 0, inProgressBytes: 0) == .active) + } + + @Test("Error is terminal even with outstanding work — lets the deadline loop break early") + func errorIsTerminal() { + #expect(BackgroundSyncService.folderSettlement(state: "error", needFiles: 0, needBytes: 0, inProgressBytes: 0) == .errored) + #expect(BackgroundSyncService.folderSettlement(state: "error", needFiles: 12, needBytes: 9000, inProgressBytes: 1) == .errored) + } +} diff --git a/ios/VaultSyncWidget/VaultSyncWidget.swift b/ios/VaultSyncWidget/VaultSyncWidget.swift index 1430305..68f9e62 100644 --- a/ios/VaultSyncWidget/VaultSyncWidget.swift +++ b/ios/VaultSyncWidget/VaultSyncWidget.swift @@ -64,7 +64,7 @@ private struct VaultSyncWidgetSnapshot: Codable, Equatable { var statusColor: Color { switch status { case "syncing": - return .blue + return .vaultTeal case "error": return .orange default: diff --git a/ios/VaultSyncWidget/de.lproj/Localizable.strings b/ios/VaultSyncWidget/de.lproj/Localizable.strings index e1b77bf..3765649 100644 --- a/ios/VaultSyncWidget/de.lproj/Localizable.strings +++ b/ios/VaultSyncWidget/de.lproj/Localizable.strings @@ -2,7 +2,7 @@ "Idle" = "Leerlauf"; "Needs Attention" = "Benötigt Aufmerksamkeit"; "Open VaultSync" = "VaultSync öffnen"; -"Syncing" = "Synchronisiert"; +"Syncing" = "Wird synchronisiert"; // Metric labels "widget_last_sync" = "Letzter Sync"; diff --git a/ios/VaultSyncWidget/es.lproj/InfoPlist.strings b/ios/VaultSyncWidget/es.lproj/InfoPlist.strings new file mode 100644 index 0000000..4c2b2ac --- /dev/null +++ b/ios/VaultSyncWidget/es.lproj/InfoPlist.strings @@ -0,0 +1 @@ +"CFBundleDisplayName" = "VaultSync"; diff --git a/ios/VaultSyncWidget/es.lproj/Localizable.strings b/ios/VaultSyncWidget/es.lproj/Localizable.strings new file mode 100644 index 0000000..4822a9b --- /dev/null +++ b/ios/VaultSyncWidget/es.lproj/Localizable.strings @@ -0,0 +1,25 @@ +// Status labels reused from the main app +"Idle" = "Inactivo"; +"Needs Attention" = "Requiere atención"; +"Open VaultSync" = "Abrir VaultSync"; +"Syncing" = "Sincronizando"; + +// Metric labels +"widget_last_sync" = "Última sinc."; +"widget_files_synced" = "Archivos"; +"widget_files_synced_format" = "%d archivos"; +"widget_vaults" = "Vaults"; + +// Fallback labels +"widget_no_sync_yet" = "Aún sin sincronizar"; +"widget_waiting" = "Esperando"; +"widget_duration_unavailable" = "Duración no disponible"; +"widget_last_run_seconds" = "Última ejecución: %ds"; +"widget_files_tap_to_sync" = "%d archivos • Toca para sincronizar"; + +// Actions +"widget_sync_now" = "Sincronizar ahora"; + +// Widget gallery +"VaultSync" = "VaultSync"; +"widget_gallery_description" = "Consulta el estado de sincronización y abre VaultSync."; diff --git a/ios/project.yml b/ios/project.yml index a74438d..c307b95 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -13,7 +13,7 @@ options: text = File.read(path) text.sub!( /knownRegions = \(\n\s*Base,\n\s*en,\n\s*\);/, - "knownRegions = (\n\t\t\t\tBase,\n\t\t\t\tde,\n\t\t\t\ten,\n\t\t\t\t\"zh-Hans\",\n\t\t\t);" + "knownRegions = (\n\t\t\t\tBase,\n\t\t\t\tde,\n\t\t\t\ten,\n\t\t\t\tes,\n\t\t\t\t\"zh-Hans\",\n\t\t\t);" ) File.write(path, text) ' @@ -25,6 +25,14 @@ settings: # User script sandboxing must be disabled so Xcode can execute them. ENABLE_USER_SCRIPT_SANDBOXING: false +# Signing comes from Signing.xcconfig (committed, no team), which optionally +# includes a gitignored Signing.local.xcconfig for your own DEVELOPMENT_TEAM. +# This survives `xcodegen generate` without committing a team or forcing one on +# contributors — copy Signing.local.xcconfig.example to Signing.local.xcconfig. +configFiles: + Debug: Signing.xcconfig + Release: Signing.xcconfig + schemes: VaultSync: build: @@ -32,6 +40,7 @@ schemes: VaultSync: all run: config: Debug + storeKitConfiguration: VaultSync.storekit test: config: Debug targets: @@ -50,8 +59,8 @@ targets: info: path: VaultSync/Info.plist properties: - CFBundleShortVersionString: "1.3.2" - CFBundleVersion: "24" + CFBundleShortVersionString: "1.4.0" + CFBundleVersion: "25" UILaunchScreen: {} UIApplicationSceneManifest: UIApplicationSupportsMultipleScenes: false @@ -97,11 +106,14 @@ targets: - path: VaultSyncWidget excludes: - "**/.gitkeep" + # Shared brand palette (Color.vaultTeal/.vaultSlate) so the widget + # matches the app accent without redefining the RGB. + - path: VaultSync/Resources/Theme.swift info: path: VaultSyncWidget/Info.plist properties: - CFBundleShortVersionString: "1.3.2" - CFBundleVersion: "24" + CFBundleShortVersionString: "1.4.0" + CFBundleVersion: "25" CFBundleDisplayName: VaultSync Widget NSExtension: NSExtensionPointIdentifier: com.apple.widgetkit-extension