diff --git a/CHANGELOG.md b/CHANGELOG.md index 5f1e162ada..ce8497d275 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - **Open in Agent Mode** on a connection in the welcome window. - **Outside MCP Servers** in Settings > Integrations, letting a session call tools on an MCP server you run. - Per-connection allowlist for an outside MCP server, with its token in the Keychain and neither synced. +- Welcome sheet on iPhone and iPad, asking once about iCloud sync and usage data. +- Sample database on iPhone and iPad, opened from the empty connection list or the **More** menu. +- What's New on iPhone and iPad after an update, and under **Settings > About**. +- Tips in the iOS connection list for swiping to favorite, touch and hold, and tag search. +- Acknowledgements and a privacy policy link under **Settings > About** on iPhone and iPad. +- Privacy manifest for the iOS app. ### Changed @@ -22,6 +28,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Every plugin bundle compiled under the same concurrency settings as the app that loads it. - Release C optimization and link-time optimization scoped to the app, not to its Swift package dependencies. - Assistant conversations belong to one connection, and outlive the window that opened them. +- iCloud sync and usage data on iPhone and iPad stay off until you turn them on. +- Group rows in the iOS connection list take swipe actions and show even when no connection is saved. + +### Removed + +- **Refresh from iCloud**, **Sync Now** and the toolbar sync button on iPhone and iPad. +- **Manage Groups**, the **Clear** button on **Recent**, and the **More** menu's tag filter on iPhone and iPad. ### Fixed @@ -82,6 +95,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Rows saved, added or deleted on iPhone and iPad refused by a MySQL, MariaDB, PostgreSQL or Redshift server that starts sessions read-only. - Copying objects into a connection that already has a transaction open committing it. - Replace-copy into a remote libSQL target failing at `BEGIN`. +- iOS edits uploaded to iCloud after iCloud Sync was turned off. +- iOS text left in English for Korean, Vietnamese, Simplified Chinese and Traditional Chinese. +- iOS connections overwritten by the next edit after the saved library failed to load. +- A `.tablepro` file or link closing a half-filled form on iPhone and iPad. +- Links to a table opening only its connection on iPhone and iPad. +- Face ID symbol on the unlock button of Touch ID and Optic ID devices. +- Empty icon tiles for Snowflake, Beancount, SurrealDB and Kafka connections on iPhone and iPad. +- iOS edit mode left on after deleting every connection. +- Two rename fields when renaming a favorite on iPhone and iPad. +- Connection names cut off at large text sizes on iPhone and iPad. - `Cmd+W` closing the whole connection instead of the current tab until something in the window was clicked. ### Security @@ -89,6 +112,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Code inside a plugin bundle, and its resource envelope, were not verified before the bundle was loaded. - The system log carried query text, schema and table names, file paths and driver error messages, which can hold row values. - A chat tool registered at runtime could take the name of a tool TablePro ships. +- An open connection, a sheet and the app switcher preview left usable or visible behind the iOS app lock. +- **Require Face ID** turned off on iPhone and iPad without authenticating. ## [0.75.0] - 2026-09-18 diff --git a/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift b/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift index 24c32d3cb1..195ba1e151 100644 --- a/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift +++ b/Packages/TableProCore/Sources/TableProModels/DatabaseConnection.swift @@ -29,6 +29,11 @@ public struct DatabaseConnection: Identifiable, Hashable, Sendable { public var tagIds: [UUID] public var sortOrder: Int public var isFavorite: Bool + public var isSample: Bool + + public var participatesInSync: Bool { + !isSample + } public var tagId: UUID? { get { tagIds.first } @@ -55,7 +60,8 @@ public struct DatabaseConnection: Identifiable, Hashable, Sendable { groupId: UUID? = nil, tagIds: [UUID] = [], sortOrder: Int = 0, - isFavorite: Bool = false + isFavorite: Bool = false, + isSample: Bool = false ) { self.id = id self.name = name @@ -77,13 +83,14 @@ public struct DatabaseConnection: Identifiable, Hashable, Sendable { self.tagIds = tagIds self.sortOrder = sortOrder self.isFavorite = isFavorite + self.isSample = isSample } private enum CodingKeys: String, CodingKey { case id, name, type, host, port, username, database, color, colorTag case isReadOnly, safeModeLevel, queryTimeoutSeconds, additionalFields case sshEnabled, sshConfiguration, sslEnabled, sslConfiguration - case groupId, tagId, tagIds, sortOrder, isFavorite + case groupId, tagId, tagIds, sortOrder, isFavorite, isSample } } @@ -125,6 +132,7 @@ extension DatabaseConnection: Codable { } sortOrder = try container.decodeIfPresent(Int.self, forKey: .sortOrder) ?? 0 isFavorite = try container.decodeIfPresent(Bool.self, forKey: .isFavorite) ?? false + isSample = try container.decodeIfPresent(Bool.self, forKey: .isSample) ?? false } public func encode(to encoder: Encoder) throws { @@ -151,5 +159,8 @@ extension DatabaseConnection: Codable { } try container.encode(sortOrder, forKey: .sortOrder) try container.encode(isFavorite, forKey: .isFavorite) + if isSample { + try container.encode(isSample, forKey: .isSample) + } } } diff --git a/Packages/TableProCore/Sources/TableProSyncTransport/SyncRecordCache.swift b/Packages/TableProCore/Sources/TableProSyncTransport/SyncRecordCache.swift index 1fe3e14d99..a2d0364661 100644 --- a/Packages/TableProCore/Sources/TableProSyncTransport/SyncRecordCache.swift +++ b/Packages/TableProCore/Sources/TableProSyncTransport/SyncRecordCache.swift @@ -74,12 +74,6 @@ public final class SyncRecordCache { } } - public func removeAll() { - migration.withLock { $0 = true } - legacyDefaults?.removeObject(forKey: legacyStorageKey) - try? FileManager.default.removeItem(at: directory) - } - // MARK: - Migration /// Moves a cache written by an older build out of `UserDefaults` on first use, then clears the diff --git a/Packages/TableProCore/Tests/TableProModelsTests/DatabaseConnectionSampleTests.swift b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseConnectionSampleTests.swift new file mode 100644 index 0000000000..2eb210fe7c --- /dev/null +++ b/Packages/TableProCore/Tests/TableProModelsTests/DatabaseConnectionSampleTests.swift @@ -0,0 +1,42 @@ +import Foundation +import Testing + +@testable import TableProModels + +@Suite("DatabaseConnection sample flag") +struct DatabaseConnectionSampleTests { + private static let storedWithoutFlag = """ + {"id":"6F9619FF-8B86-D011-B42D-00C04FC964FF","name":"Prod","type":"PostgreSQL","host":"db.local",\ + "port":5432,"username":"app","database":"prod"} + """ + + @Test("A connection stored before the flag existed decodes as not a sample") + func legacyRecordIsNotSample() throws { + let connection = try JSONDecoder().decode( + DatabaseConnection.self, + from: Data(Self.storedWithoutFlag.utf8) + ) + + #expect(connection.isSample == false) + #expect(connection.participatesInSync) + } + + @Test("A sample round-trips and stays out of sync") + func sampleRoundTrips() throws { + let sample = DatabaseConnection(name: "Sample", type: .sqlite, database: "Chinook.sqlite", isSample: true) + + let decoded = try JSONDecoder().decode(DatabaseConnection.self, from: JSONEncoder().encode(sample)) + + #expect(decoded.isSample) + #expect(decoded.participatesInSync == false) + } + + @Test("An ordinary connection writes no sample key") + func ordinaryConnectionOmitsKey() throws { + let connection = DatabaseConnection(name: "Prod", type: .mysql) + + let object = try JSONSerialization.jsonObject(with: JSONEncoder().encode(connection)) as? [String: Any] + + #expect(object?["isSample"] == nil) + } +} diff --git a/TablePro/Core/Services/Infrastructure/ThirdPartyLicenseInventory.swift b/TablePro/Core/Services/Infrastructure/ThirdPartyLicenseInventory.swift index 7a988e5355..d4c7bb18b7 100644 --- a/TablePro/Core/Services/Infrastructure/ThirdPartyLicenseInventory.swift +++ b/TablePro/Core/Services/Infrastructure/ThirdPartyLicenseInventory.swift @@ -27,13 +27,13 @@ struct ThirdPartyLicenseInventory { /// Components with a confirmed licence, which is what the acknowledgements list renders. var attributed: [ThirdPartyComponent] { - components.filter { !$0.isUnverified } + components.filter { $0.ships(on: .macos) && !$0.isUnverified } } /// Components whose licence could not be confirmed from a primary source. Surfaced rather /// than dropped, so an unresolved obligation stays visible instead of looking discharged. var unresolved: [ThirdPartyComponent] { - components.filter(\.isUnverified) + components.filter { $0.ships(on: .macos) && $0.isUnverified } } init(rootURL: URL) throws { diff --git a/TablePro/Models/ThirdParty/ThirdPartyComponent.swift b/TablePro/Models/ThirdParty/ThirdPartyComponent.swift index 60112603f4..dc6d0fdcfb 100644 --- a/TablePro/Models/ThirdParty/ThirdPartyComponent.swift +++ b/TablePro/Models/ThirdParty/ThirdPartyComponent.swift @@ -38,6 +38,11 @@ struct ThirdPartyComponent: Codable, Identifiable, Hashable { } } + enum Platform: String, Codable, Hashable { + case macos + case ios + } + let id: String let name: String let version: String @@ -51,15 +56,40 @@ struct ThirdPartyComponent: Codable, Identifiable, Hashable { let source: String let patched: Bool let notes: String? + let platforms: [Platform] var versionSource: VersionSource { VersionSource(rawValue: source) } + func ships(on platform: Platform) -> Bool { + platforms.contains(platform) + } + /// A component whose licence has not been confirmed from a primary source. It is listed /// as unresolved rather than being given a guessed licence, because a wrong SPDX line is /// a licence violation stated in the app's own voice. var isUnverified: Bool { spdx == ThirdPartyComponent.unverifiedSPDX } static let unverifiedSPDX = "UNVERIFIED" + + static let defaultPlatforms: [Platform] = [.macos] +} + +extension ThirdPartyComponent { + init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + id = try container.decode(String.self, forKey: .id) + name = try container.decode(String.self, forKey: .name) + version = try container.decode(String.self, forKey: .version) + spdx = try container.decode(String.self, forKey: .spdx) + copyrights = try container.decode([String].self, forKey: .copyrights) + homepageURL = try container.decode(String.self, forKey: .homepageURL) + licenseTextURL = try container.decode(String.self, forKey: .licenseTextURL) + textFile = try container.decodeIfPresent(String.self, forKey: .textFile) + source = try container.decode(String.self, forKey: .source) + patched = try container.decode(Bool.self, forKey: .patched) + notes = try container.decodeIfPresent(String.self, forKey: .notes) + platforms = try container.decodeIfPresent([Platform].self, forKey: .platforms) ?? Self.defaultPlatforms + } } private extension String { diff --git a/TablePro/Resources/ThirdPartyLicenses/licenses.yml b/TablePro/Resources/ThirdPartyLicenses/licenses.yml index 36ce352c07..e22b3dd34d 100644 --- a/TablePro/Resources/ThirdPartyLicenses/licenses.yml +++ b/TablePro/Resources/ThirdPartyLicenses/licenses.yml @@ -6,6 +6,11 @@ # # spdx: UNVERIFIED means the project publishes no license we could read. Those entries # carry no license text and render as unresolved rather than being given a guess. +# +# platforms lists where a component ships: macos, ios or both. Absent means [macos]. +# The iOS app reads TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json, the +# projection of every ios entry. Run scripts/generate-ios-acknowledgements.py after editing an +# ios entry; ThirdPartyLicenseInventoryTests fails while that file is stale. - id: bigint name: BigInt version: 5.7.0 @@ -17,10 +22,29 @@ textFile: texts/bigint.txt source: spm:bigint patched: false + platforms: [macos, ios] notes: Tag is `v5.7.0`; Package.resolved records the bare "5.7.0". The holder's name carries Hungarian diacritics (Karoly Lorentey, with o-acute and o-double-acute) in the original file; reproduce it from texts/bigint.LICENSE.txt rather than from this YAML, which is ASCII-folded. Transitive via oracle-nio. Zero `$s6BigInt` symbols in the app dylib. +- id: chinook + name: Chinook Database + version: vendored at ac32dbc3d, 2025-10-04 + spdx: MIT + copyrights: + - Copyright (c) 2008-2024 Luis Rocha + homepageURL: https://github.com/lerocha/chinook-database + licenseTextURL: https://github.com/lerocha/chinook-database/blob/ac32dbc3d5b383633c3fd687934f9c719773f00d/LICENSE.md + textFile: texts/chinook.txt + source: manual + patched: false + platforms: [macos, ios] + notes: 'The sample database, shipped as TablePro/Resources/SampleDatabases/Chinook.sqlite in both apps. + Byte-identical (SHA-256 7651ba37...) to ChinookDatabase/DataSources/Chinook_Sqlite.sqlite at upstream + commit ac32dbc3d5b3, the last commit to touch that file. No release asset matches it: v1.4.5, the newest + tag, predates the regenerated invoice dates. GitHub''s detector reports "other" / NOASSERTION because + LICENSE.md opens with a "Chinook Database" title and a rule of dashes; the body is the verbatim MIT + grant and warranty, and it has been unchanged since 2024-01-24 (800ed2d792).' - id: codeeditsourceeditor name: CodeEditSourceEditor version: vendored at 1fa4d3c3f, 2026-03; see Packages/TableProEditor/ORIGIN.md @@ -82,13 +106,16 @@ textFile: texts/duckdb.txt source: sh:DUCKDB_VERSION patched: false + platforms: [macos, ios] notes: 'Built from the v1.5.2 git tag with CMake (build-duckdb.sh:25), linking core_functions, json, parquet, icu and autocomplete statically (scripts/duckdb-macos-extensions.cmake). The build compiles the whole tree including third_party/, which holds 30 vendored projects (brotli, catch, concurrentqueue, fast_float, fastpforlib, fmt, fsst, httplib, hyperloglog, imdb, jaro_winkler, jemalloc, libpg_query, lz4, mbedtls, miniz, parquet, pcg, pdqsort, re2, ska_sort, skiplist, snappy, snowball, tdigest, thrift, utf8proc, vergesort, yyjson, zstd), each under its own licence. Those sub-licences are NOT enumerated - here: UNVERIFIED. scripts/build-duckdb-ios.sh pins the same tag for the iOS xcframework.' + here: UNVERIFIED. scripts/build-duckdb-ios.sh pins the same tag for the iOS xcframework. scripts/duckdb-ios-extensions.cmake + also names the out-of-tree httpfs and quack extensions; the xcframework in Libs/ios carries no symbol + from either today. Both are MIT under this same Stichting DuckDB Foundation notice, byte for byte.' - id: freetds name: FreeTDS version: 1.4.22 @@ -106,6 +133,7 @@ textFile: texts/freetds.txt source: sh:FREETDS_VERSION patched: true + platforms: [macos, ios] notes: 'COPYING_LIB.txt at v1.4.22 is the GNU *Library* General Public License Version 2, June 1991 (LGPL-2.0), not 2.1. Every source file built into libsybdb.a grants "either version 2 of the License, or (at your option) any later version", so LGPL-2.0-or-later is the id. The repo also ships COPYING.txt @@ -139,6 +167,7 @@ textFile: texts/hiredis.txt source: sh:HIREDIS_VERSION patched: false + platforms: [macos, ios] notes: 'COPYING at the tag names only the first two holders plus "All rights reserved."; the remaining five lines are per-file headers on sources compiled into the two archives: ssl.c (Redis Labs, 2019), sds.c (Sanfilippo 2006-2015, Oran Agra, Redis Labs, Inc) and alloc.c (Michael Grunder). All are the @@ -166,6 +195,7 @@ textFile: texts/libssh2.txt source: sh:LIBSSH2_VERSION patched: true + platforms: [macos, ios] notes: Linked by the main app target (SSH tunnels), not by a plugin. The tarball is fetched from the GitHub release for libssh2-1.11.1 and pinned by SHA-256 (build-libssh2.sh:38). The script writes only libssh2_arm64.a, libssh2_x86_64.a and libssh2_universal.a. The flat Libs/libssh2.a that project.yml @@ -210,6 +240,7 @@ textFile: texts/mariadb-connector-c.txt source: sh:MARIADB_VERSION patched: false + platforms: [macos, ios] notes: 'NOT the MariaDB server''s GPL-2.0. The connector ships COPYING.LIB = GNU Lesser General Public License Version 2.1, February 1999, and that is the only licence file in the repo root at v3.4.4. Nuance worth recording: the per-file grant in libmariadb/mariadb_lib.c says "GNU Library General Public @@ -254,6 +285,7 @@ textFile: texts/openssl.txt source: sh:OPENSSL_VERSION patched: false + platforms: [macos, ios] notes: OpenSSL 3.x is Apache-2.0 only; the old OpenSSL/SSLeay dual licence ended at 1.1.1. LICENSE.txt at the tag is the bare Apache-2.0 text with no copyright block, so the two holder lines above are quoted from the Copyright section of README.md at the same tag (https://raw.githubusercontent.com/openssl/openssl/openssl-3.4.3/README.md, @@ -275,6 +307,7 @@ textFile: texts/oracle-nio.txt source: spm:oracle-nio patched: false + platforms: [macos, ios] notes: 'NO state.version in any of the three Package.resolved files, only a revision. All three pin the SAME revision, so there is no version disagreement here. TableProApp/oracle-nio is a fork of lovetodream/oracle-nio (GitHub reports fork: true, parent: lovetodream/oracle-nio); at the pinned revision it is 21 ahead @@ -303,6 +336,7 @@ textFile: texts/postgresql-libpq.txt source: sh:PG_VERSION patched: false + platforms: [macos, ios] notes: Source tarball comes from ftp.postgresql.org (build-libpq.sh:71), SHA-256 pinned at build-libpq.sh:40; the GitHub tag REL_17_4 is the same release and is used here only as a stable URL for the COPYRIGHT file. The COPYRIGHT text is the whole licence; SPDX calls it "PostgreSQL" (a BSD/MIT-style permissive @@ -364,6 +398,7 @@ textFile: texts/swift-asn1.txt source: spm:swift-asn1 patched: false + platforms: [macos, ios] notes: 'VERSION SET, deliberately divergent: the app pins 1.5.1, TableProOracle and TableProMobile pin 1.7.1. LICENSE.txt is byte-identical (11359 bytes) at both tags and is PLAIN Apache-2.0 with no Runtime Library Exception. NOTICE.txt present at both tags and unchanged; its attribution must be reproduced @@ -380,6 +415,7 @@ textFile: texts/swift-async-algorithms.txt source: spm:swift-async-algorithms patched: false + platforms: [macos, ios] notes: '11751 bytes: Apache 2.0 plus the Runtime Library Exception section (verified by reading the file). No NOTICE.txt. Transitive via swift-service-lifecycle.' - id: swift-atomics @@ -393,6 +429,7 @@ textFile: texts/swift-atomics.txt source: spm:swift-atomics patched: false + platforms: [macos, ios] notes: '11751 bytes: Apache 2.0 plus the Runtime Library Exception section (verified by reading the file). No NOTICE.txt. Copyright from a source header.' - id: swift-collections @@ -406,6 +443,7 @@ textFile: texts/swift-collections.txt source: spm:swift-collections patched: false + platforms: [macos, ios] notes: 'LICENSE.txt is 11751 bytes: the Apache 2.0 body PLUS an appended "## Runtime Library Exception to the Apache 2.0 License: ##" section (verified by reading the file, not by trusting GitHub, whose detector reports plain "Apache-2.0" for both variants). The exception waives the attribution otherwise @@ -425,6 +463,7 @@ textFile: texts/swift-crypto.txt source: spm:swift-crypto patched: false + platforms: [macos, ios] notes: 'VERSION SET, deliberately divergent: the app pins 4.2.0, TableProOracle and TableProMobile pin 4.5.1. LICENSE.txt is byte-identical (11358 bytes) at both tags and is PLAIN Apache-2.0 with no Runtime Library Exception. NOTICE.txt present at both tags; its attribution must be reproduced under section @@ -445,6 +484,7 @@ textFile: texts/swift-distributed-tracing.txt source: spm:swift-distributed-tracing patched: false + platforms: [macos, ios] notes: PLAIN Apache-2.0 (11358 bytes, no Runtime Library Exception). NO NOTICE.txt at this tag (checked the root tree). Copyright from a source header. Transitive via oracle-nio. - id: swift-log @@ -458,6 +498,7 @@ textFile: texts/swift-log.txt source: spm:swift-log patched: false + platforms: [macos, ios] notes: 'PLAIN Apache-2.0 (11358 bytes, no Runtime Library Exception). NOTICE.txt present; its attribution must be reproduced under section 4(d). Not in TablePro.app: zero `$s7Logging` mangled-prefix symbols. (A naive `strings` grep for the bare word "Logging" hits 28 times in the app dylib, but those are @@ -473,6 +514,7 @@ textFile: texts/swift-nio-transport-services.txt source: spm:swift-nio-transport-services patched: false + platforms: [macos, ios] notes: PLAIN Apache-2.0 (11358 bytes, no Runtime Library Exception). NO NOTICE.txt at this tag (checked the root tree), unlike its swift-nio siblings, so nothing to carry under 4(d). Copyright line taken from a source header, since the LICENSE names no holder. @@ -487,6 +529,7 @@ textFile: texts/swift-service-context.txt source: spm:swift-service-context patched: false + platforms: [macos, ios] notes: 'PLAIN Apache-2.0 (11358 bytes, no Runtime Library Exception). NOTICE.txt present and must be carried under section 4(d). Reproduce it verbatim and do NOT "correct" it: the upstream NOTICE.txt has a copy-paste bug, pointing at https://github.com/apple/swift-asn1 as the project web site instead @@ -502,6 +545,7 @@ textFile: texts/swift-service-lifecycle.txt source: spm:swift-service-lifecycle patched: false + platforms: [macos, ios] notes: PLAIN Apache-2.0 (11358 bytes, no Runtime Library Exception). NOTICE.txt present and must be carried under section 4(d). Owned by the swift-server org, not apple. Transitive via oracle-nio. - id: swift-syntax @@ -543,6 +587,7 @@ textFile: texts/swift-nio.txt source: spm:swift-nio patched: false + platforms: [macos, ios] notes: 'PLAIN Apache-2.0 (11358 bytes, no Runtime Library Exception). NOTICE.txt is the LONGEST in the tree and section 4(d) requires all of it be carried: Netty (Apache-2.0), NodeJS llhttp (MIT), "cpp_magic.h" from uSHET (MIT), "sha1.c"/"sha1.h" from FreeBSD (BSD-3, "Copyright (C) 1995, 1996, 1997, and 1998 @@ -560,6 +605,7 @@ textFile: texts/swift-nio-ssl.txt source: spm:swift-nio-ssl patched: false + platforms: [macos, ios] notes: PLAIN Apache-2.0 (11358 bytes, no Runtime Library Exception). NOTICE.txt must be reproduced under section 4(d) and carries onward attributions for Netty (Apache-2.0), Tony Stone's process_test_files.rb (Apache-2.0), grpc-swift (Apache-2.0) and BoringSSL (combination ISC and OpenSSL licence). The vendored diff --git a/TablePro/Resources/ThirdPartyLicenses/texts/chinook.txt b/TablePro/Resources/ThirdPartyLicenses/texts/chinook.txt new file mode 100644 index 0000000000..7487a9edc2 --- /dev/null +++ b/TablePro/Resources/ThirdPartyLicenses/texts/chinook.txt @@ -0,0 +1,11 @@ +Chinook Database +-------------------------------------- +Copyright (c) 2008-2024 Luis Rocha + +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated +documentation files (the "Software"), to deal in the Software without restriction, including without limitation +the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and +to permit persons to whom the Software is furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json b/TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json new file mode 100644 index 0000000000..6decc297f6 --- /dev/null +++ b/TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json @@ -0,0 +1,275 @@ +[ + { + "id": "bigint", + "name": "BigInt", + "version": "5.7.0", + "spdx": "MIT", + "copyrights": [ + "Copyright (c) 2016-2017 Karoly Lorentey" + ], + "homepageURL": "https://github.com/attaswift/BigInt", + "textFile": "texts/bigint.txt" + }, + { + "id": "chinook", + "name": "Chinook Database", + "version": "vendored at ac32dbc3d, 2025-10-04", + "spdx": "MIT", + "copyrights": [ + "Copyright (c) 2008-2024 Luis Rocha" + ], + "homepageURL": "https://github.com/lerocha/chinook-database", + "textFile": "texts/chinook.txt" + }, + { + "id": "duckdb", + "name": "DuckDB", + "version": "v1.5.2", + "spdx": "MIT", + "copyrights": [ + "Copyright 2018-2025 Stichting DuckDB Foundation" + ], + "homepageURL": "https://duckdb.org", + "textFile": "texts/duckdb.txt" + }, + { + "id": "freetds", + "name": "FreeTDS", + "version": "1.4.22", + "spdx": "LGPL-2.0-or-later", + "copyrights": [ + "FreeTDS - Library of routines accessing Sybase and Microsoft databases", + "Copyright (C) 1998, 1999, 2000, 2001, 2002, 2003, 2004, 2005 Brian Bruns", + "Copyright (C) 2006-2015 Frediano Ziglio", + "Copyright (C) 2005-2015 Ziglio Frediano", + "Copyright (C) 2012 Frediano Ziglio", + "Copyright (c) 1987, 1993, 1994", + "The Regents of the University of California. All rights reserved." + ], + "homepageURL": "https://www.freetds.org", + "textFile": "texts/freetds.txt" + }, + { + "id": "hiredis", + "name": "hiredis", + "version": "1.2.0", + "spdx": "BSD-3-Clause", + "copyrights": [ + "Copyright (c) 2009-2011, Salvatore Sanfilippo ", + "Copyright (c) 2010-2011, Pieter Noordhuis ", + "All rights reserved.", + "Copyright (c) 2019, Redis Labs", + "Copyright (c) 2006-2015, Salvatore Sanfilippo ", + "Copyright (c) 2015, Oran Agra", + "Copyright (c) 2015, Redis Labs, Inc", + "Copyright (c) 2020, Michael Grunder " + ], + "homepageURL": "https://github.com/redis/hiredis", + "textFile": "texts/hiredis.txt" + }, + { + "id": "libssh2", + "name": "libssh2", + "version": "1.11.1", + "spdx": "BSD-3-Clause", + "copyrights": [ + "Copyright (C) 2004-2007 Sara Golemon ", + "Copyright (C) 2005,2006 Mikhail Gusarov ", + "Copyright (C) 2006-2007 The Written Word, Inc.", + "Copyright (C) 2007 Eli Fant ", + "Copyright (C) 2009-2023 Daniel Stenberg", + "Copyright (C) 2008, 2009 Simon Josefsson", + "Copyright (C) 2000 Markus Friedl", + "Copyright (C) 2015 Microsoft Corp.", + "All rights reserved." + ], + "homepageURL": "https://libssh2.org", + "textFile": "texts/libssh2.txt" + }, + { + "id": "mariadb-connector-c", + "name": "MariaDB Connector/C", + "version": "3.4.4", + "spdx": "LGPL-2.1-or-later", + "copyrights": [ + "Copyright (C) 2000, 2012 MySQL AB & MySQL Finland AB & TCX DataKonsult AB,", + "Monty Program AB", + "2013, 2022 MariaDB Corporation AB", + "This product includes PHP software, freely available from " + ], + "homepageURL": "https://mariadb.com/kb/en/mariadb-connector-c/", + "textFile": "texts/mariadb-connector-c.txt" + }, + { + "id": "openssl", + "name": "OpenSSL", + "version": "3.4.3", + "spdx": "Apache-2.0", + "copyrights": [ + "Copyright (c) 1998-2025 The OpenSSL Project Authors", + "Copyright (c) 1995-1998 Eric A. Young, Tim J. Hudson", + "All rights reserved." + ], + "homepageURL": "https://www.openssl.org", + "textFile": "texts/openssl.txt" + }, + { + "id": "oracle-nio", + "name": "OracleNIO", + "version": "rev:f09d088889e252655ea1833eed821cd2be0de03a", + "spdx": "Apache-2.0", + "copyrights": [ + "Copyright (c) 2023 Timo Zacherl" + ], + "homepageURL": "https://github.com/TableProApp/oracle-nio", + "textFile": "texts/oracle-nio.txt" + }, + { + "id": "postgresql-libpq", + "name": "PostgreSQL (libpq client library)", + "version": "17.4", + "spdx": "PostgreSQL", + "copyrights": [ + "PostgreSQL Database Management System", + "(formerly known as Postgres, then as Postgres95)", + "Portions Copyright (c) 1996-2025, PostgreSQL Global Development Group", + "Portions Copyright (c) 1994, The Regents of the University of California" + ], + "homepageURL": "https://www.postgresql.org", + "textFile": "texts/postgresql-libpq.txt" + }, + { + "id": "swift-asn1", + "name": "swift-asn1", + "version": "1.5.1, 1.7.1", + "spdx": "Apache-2.0", + "copyrights": [ + "Copyright 2022 The SwiftASN1 Project", + "Copyright (c) 2019-2020 Apple Inc. and the SwiftASN1 project authors" + ], + "homepageURL": "https://github.com/apple/swift-asn1", + "textFile": "texts/swift-asn1.txt" + }, + { + "id": "swift-async-algorithms", + "name": "swift-async-algorithms", + "version": "1.1.5", + "spdx": "Apache-2.0 WITH Swift-exception", + "copyrights": [ + "Copyright (c) 2022 Apple Inc. and the Swift project authors" + ], + "homepageURL": "https://github.com/apple/swift-async-algorithms", + "textFile": "texts/swift-async-algorithms.txt" + }, + { + "id": "swift-atomics", + "name": "swift-atomics", + "version": "1.3.1", + "spdx": "Apache-2.0 WITH Swift-exception", + "copyrights": [ + "Copyright (c) 2020 - 2025 Apple Inc. and the Swift project authors" + ], + "homepageURL": "https://github.com/apple/swift-atomics", + "textFile": "texts/swift-atomics.txt" + }, + { + "id": "swift-collections", + "name": "swift-collections", + "version": "1.6.0", + "spdx": "Apache-2.0 WITH Swift-exception", + "copyrights": [ + "Copyright (c) 2021 - 2026 Apple Inc. and the Swift project authors" + ], + "homepageURL": "https://github.com/apple/swift-collections", + "textFile": "texts/swift-collections.txt" + }, + { + "id": "swift-crypto", + "name": "swift-crypto", + "version": "4.2.0, 4.5.1", + "spdx": "Apache-2.0", + "copyrights": [ + "Copyright 2019 The SwiftCrypto Project", + "Copyright (c) 2019-2020 Apple Inc. and the SwiftCrypto project authors" + ], + "homepageURL": "https://github.com/apple/swift-crypto", + "textFile": "texts/swift-crypto.txt" + }, + { + "id": "swift-distributed-tracing", + "name": "swift-distributed-tracing", + "version": "1.4.1", + "spdx": "Apache-2.0", + "copyrights": [ + "Copyright (c) 2020-2023 Apple Inc. and the Swift Distributed Tracing project authors" + ], + "homepageURL": "https://github.com/apple/swift-distributed-tracing", + "textFile": "texts/swift-distributed-tracing.txt" + }, + { + "id": "swift-log", + "name": "swift-log", + "version": "1.15.0", + "spdx": "Apache-2.0", + "copyrights": [ + "Copyright 2018, 2019 The SwiftLog Project" + ], + "homepageURL": "https://github.com/apple/swift-log", + "textFile": "texts/swift-log.txt" + }, + { + "id": "swift-nio-transport-services", + "name": "swift-nio-transport-services", + "version": "1.28.0", + "spdx": "Apache-2.0", + "copyrights": [ + "Copyright (c) 2017-2021 Apple Inc. and the SwiftNIO project authors" + ], + "homepageURL": "https://github.com/apple/swift-nio-transport-services", + "textFile": "texts/swift-nio-transport-services.txt" + }, + { + "id": "swift-service-context", + "name": "swift-service-context", + "version": "1.3.0", + "spdx": "Apache-2.0", + "copyrights": [ + "Copyright 2024 The Swift Service Context Project" + ], + "homepageURL": "https://github.com/apple/swift-service-context", + "textFile": "texts/swift-service-context.txt" + }, + { + "id": "swift-service-lifecycle", + "name": "swift-service-lifecycle", + "version": "2.11.0", + "spdx": "Apache-2.0", + "copyrights": [ + "Copyright 2019-2023 The ServiceLifecycle Project" + ], + "homepageURL": "https://github.com/swift-server/swift-service-lifecycle", + "textFile": "texts/swift-service-lifecycle.txt" + }, + { + "id": "swift-nio", + "name": "SwiftNIO", + "version": "2.101.3", + "spdx": "Apache-2.0", + "copyrights": [ + "Copyright 2017, 2018 The SwiftNIO Project" + ], + "homepageURL": "https://github.com/apple/swift-nio", + "textFile": "texts/swift-nio.txt" + }, + { + "id": "swift-nio-ssl", + "name": "SwiftNIOSSL", + "version": "2.37.2", + "spdx": "Apache-2.0", + "copyrights": [ + "Copyright 2017, 2018 The SwiftNIO Project" + ], + "homepageURL": "https://github.com/apple/swift-nio-ssl", + "textFile": "texts/swift-nio-ssl.txt" + } +] diff --git a/TableProMobile/TableProMobile/AppState.swift b/TableProMobile/TableProMobile/AppState.swift index d5a1bd4179..9b146d0c29 100644 --- a/TableProMobile/TableProMobile/AppState.swift +++ b/TableProMobile/TableProMobile/AppState.swift @@ -29,22 +29,36 @@ final class AppState { return .loading } - var pendingConnectionId: UUID? - var pendingTableName: String? - var pendingImportURL: URL? + private(set) var sampleResetRevision = 0 + let onboarding: OnboardingPreferences let connectionManager: ConnectionManager let backgroundRelease: BackgroundReleaseCoordinator let queryActivities = QueryActivityController() - let syncCoordinator = IOSSyncCoordinator() - let libraryPreferences = ConnectionLibraryPreferences() + let syncCoordinator: IOSSyncCoordinator + + @ObservationIgnored private var automaticPresentationOwner: UUID? + let libraryPreferences: ConnectionLibraryPreferences let sshProvider: IOSSSHProvider let secureStore: KeychainSecureStore - private let storage = ConnectionPersistence() - private let groupStorage = GroupPersistence() - private let tagStorage = TagPersistence() - - init() { + private let sampleInstaller: SampleDatabaseInstaller + private let storage: ConnectionPersistence + private let groupStorage: GroupPersistence + private let tagStorage: TagPersistence + + init( + libraryDirectory: URL = LibraryStorage.defaultDirectory, + defaults: UserDefaults = .standard, + syncCoordinator injectedSyncCoordinator: IOSSyncCoordinator? = nil, + sampleInstaller: SampleDatabaseInstaller = .live + ) { + self.sampleInstaller = sampleInstaller + onboarding = OnboardingPreferences(defaults: defaults) + libraryPreferences = ConnectionLibraryPreferences(defaults: defaults) + syncCoordinator = injectedSyncCoordinator ?? IOSSyncCoordinator() + storage = ConnectionPersistence(directory: libraryDirectory) + groupStorage = GroupPersistence(directory: libraryDirectory) + tagStorage = TagPersistence(directory: libraryDirectory) let driverFactory = IOSDriverFactory() let secureStore = KeychainSecureStore() self.secureStore = secureStore @@ -61,10 +75,11 @@ final class AppState { guard !TestRuntime.isActive else { return } - secureStore.cleanOrphanedCredentials(validConnectionIds: Set(connections.map(\.id))) - Task { - updateWidgetData() - updateSpotlightIndex() + if loadStatus == .ready { + secureStore.cleanOrphanedCredentials(validConnectionIds: Set(connections.map(\.id))) + Task { + publishLibrary() + } } syncCoordinator.onConnectionsChanged = { [weak self] merged in @@ -95,10 +110,34 @@ final class AppState { // MARK: - Load / Retry + var isLibraryWritable: Bool { + loadStatus == .ready + } + func retryLoadIfFailed() { guard loadStatus == .failed else { return } Self.logger.info("Retrying persistence load after previous failure") loadPersistedData() + guard loadStatus == .ready else { return } + publishLibrary() + } + + private func refuseWriteIfNotReady() -> Bool { + guard isLibraryWritable else { + Self.logger.error("Refusing a library write while the stored library is not loaded") + return true + } + return false + } + + private func publishLibrary() { + guard loadStatus == .ready else { return } + updateWidgetData() + updateSpotlightIndex() + } + + private func syncsConnection(_ id: UUID) -> Bool { + connections.first { $0.id == id }?.participatesInSync ?? true } private func loadPersistedData() { @@ -170,21 +209,19 @@ final class AppState { // MARK: - Connections - func addConnection(_ connection: DatabaseConnection) { + @discardableResult + func addConnection(_ connection: DatabaseConnection) -> Bool { apply(ConnectionLibraryEditing.adding(connection, to: connections, validGroupIds: validGroupIds)) } - func updateConnection(_ connection: DatabaseConnection) { + @discardableResult + func updateConnection(_ connection: DatabaseConnection) -> Bool { guard let change = ConnectionLibraryEditing.updating( connection, in: connections, validGroupIds: validGroupIds - ) else { return } - apply(change) - } - - var hasCompletedOnboarding: Bool = UserDefaults.standard.bool(forKey: "com.TablePro.hasCompletedOnboarding") { - didSet { UserDefaults.standard.set(hasCompletedOnboarding, forKey: "com.TablePro.hasCompletedOnboarding") } + ) else { return false } + return apply(change) } func reorderConnections(_ orderedIds: [UUID]) { @@ -207,7 +244,7 @@ final class AppState { func setFavorite(_ ids: Set, isFavorite: Bool) { let previousOrder = libraryPreferences.favoritesOrder - apply(ConnectionLibraryEditing.settingFavorite(ids, to: isFavorite, in: connections)) + guard apply(ConnectionLibraryEditing.settingFavorite(ids, to: isFavorite, in: connections)) else { return } guard isFavorite else { libraryPreferences.setFavoritesOrder(LibraryOrdering.favoritesOrder(previousOrder, removing: ids)) return @@ -221,8 +258,8 @@ final class AppState { libraryPreferences.setFavoritesOrder(orderedIds) } - @discardableResult - func duplicateConnection(_ connection: DatabaseConnection) -> DatabaseConnection { + func duplicateConnection(_ connection: DatabaseConnection) { + guard !refuseWriteIfNotReady() else { return } let result = ConnectionLibraryEditing.duplicating( connection, named: String(format: String(localized: "%@ Copy"), connection.name), @@ -231,14 +268,10 @@ final class AppState { ) ConnectionSecrets(secureStore: secureStore).copy(from: connection.id, to: result.copy.id) apply(result.change) - return result.copy - } - - func removeConnection(_ connection: DatabaseConnection) { - removeConnections([connection.id]) } func removeConnections(_ ids: Set) { + guard !refuseWriteIfNotReady() else { return } let removed = connections.filter { ids.contains($0.id) } guard !removed.isEmpty else { return } let secrets = ConnectionSecrets(secureStore: secureStore) @@ -247,23 +280,25 @@ final class AppState { clearPerConnectionPreferences(for: connection.id) } persist(connections: connections.filter { !ids.contains($0.id) }) - updateWidgetData() - updateSpotlightIndex() - for connection in removed { + publishLibrary() + for connection in removed where connection.participatesInSync { syncCoordinator.markDeleted(connection.id) } syncCoordinator.scheduleSyncAfterChange() } - private func apply(_ change: ConnectionLibraryChange) { - guard !change.changedConnectionIds.isEmpty else { return } + @discardableResult + private func apply(_ change: ConnectionLibraryChange) -> Bool { + guard !refuseWriteIfNotReady() else { return false } + guard !change.changedConnectionIds.isEmpty else { return false } persist(connections: change.connections) - updateWidgetData() - updateSpotlightIndex() - for id in change.changedConnectionIds { + publishLibrary() + let syncedIds = Set(change.connections.filter(\.participatesInSync).map(\.id)) + for id in change.changedConnectionIds where syncedIds.contains(id) { syncCoordinator.markDirty(id) } syncCoordinator.scheduleSyncAfterChange() + return true } private func clearPerConnectionPreferences(for id: UUID) { @@ -278,6 +313,7 @@ final class AppState { @discardableResult func addGroup(_ group: ConnectionGroup) -> Bool { + guard !refuseWriteIfNotReady() else { return false } guard let updated = ConnectionLibraryEditing.addingGroup(group, to: groups) else { return false } persist(groups: updated) syncCoordinator.markDirtyGroup(group.id) @@ -287,6 +323,7 @@ final class AppState { @discardableResult func updateGroup(_ group: ConnectionGroup) -> Bool { + guard !refuseWriteIfNotReady() else { return false } guard let updated = ConnectionLibraryEditing.updatingGroup(group, in: groups) else { return false } persist(groups: updated) syncCoordinator.markDirtyGroup(group.id) @@ -295,6 +332,7 @@ final class AppState { } func reorderGroups(_ orderedIds: [UUID]) { + guard !refuseWriteIfNotReady() else { return } let result = ConnectionLibraryEditing.reorderingGroups(orderedIds, in: groups) guard !result.changed.isEmpty else { return } persist(groups: result.groups) @@ -305,13 +343,14 @@ final class AppState { } func deleteGroup(_ groupId: UUID) { + guard !refuseWriteIfNotReady() else { return } let change = ConnectionLibraryEditing.deletingGroup(groupId, groups: groups, connections: connections) guard !change.removedGroupIds.isEmpty else { return } persist(groups: change.groups) persist(connections: change.connections) - updateWidgetData() + publishLibrary() - for id in change.changedConnectionIds { + for id in change.changedConnectionIds where syncsConnection(id) { syncCoordinator.markDirty(id) } for id in change.removedGroupIds { @@ -323,6 +362,7 @@ final class AppState { // MARK: - Tags func addTag(_ tag: ConnectionTag) { + guard !refuseWriteIfNotReady() else { return } var updated = tags updated.append(tag) persist(tags: updated) @@ -331,6 +371,7 @@ final class AppState { } func updateTag(_ tag: ConnectionTag) { + guard !refuseWriteIfNotReady() else { return } var updated = tags guard let index = updated.firstIndex(where: { $0.id == tag.id }) else { return } updated[index] = tag @@ -340,6 +381,7 @@ final class AppState { } func deleteTag(_ tagId: UUID) { + guard !refuseWriteIfNotReady() else { return } guard let tag = tags.first(where: { $0.id == tagId }), !tag.isPreset else { return } var updatedTags = tags @@ -349,15 +391,96 @@ final class AppState { var updatedConnections = connections for index in updatedConnections.indices where updatedConnections[index].tagIds.contains(tagId) { updatedConnections[index].tagIds.removeAll { $0 == tagId } - syncCoordinator.markDirty(updatedConnections[index].id) + if updatedConnections[index].participatesInSync { + syncCoordinator.markDirty(updatedConnections[index].id) + } } persist(connections: updatedConnections) - updateWidgetData() + publishLibrary() syncCoordinator.markDeletedTag(tagId) syncCoordinator.scheduleSyncAfterChange() } + // MARK: - First Run + + var currentAppVersion: String { + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "" + } + + func claimLaunchPresentation(for sceneId: UUID) -> LaunchPresentation { + guard !TestRuntime.isActive else { return .none } + guard automaticPresentationOwner == nil || automaticPresentationOwner == sceneId else { return .none } + let version = currentAppVersion + let presentation = FirstRunPlan( + hasSeenWelcome: onboarding.hasSeenWelcome, + syncChoice: onboarding.syncChoice, + usageDataChoice: onboarding.usageDataChoice, + lastSeenVersion: onboarding.lastSeenVersion, + currentVersion: version, + hasHighlightsForCurrentVersion: FeatureHighlights.release(version) != nil + ).presentation + onboarding.recordLaunch(version: version) + guard presentation != .none else { return .none } + automaticPresentationOwner = sceneId + return presentation + } + + func releaseLaunchPresentation(for sceneId: UUID) { + guard automaticPresentationOwner == sceneId else { return } + automaticPresentationOwner = nil + } + + func finishFirstRun(pages: [FirstRunPage]) { + if pages.contains(.welcome) { + onboarding.markWelcomeSeen() + } + if pages.contains(.iCloud), onboarding.syncChoice == nil { + setCloudSyncEnabled(false) + } + if pages.contains(.usageData), onboarding.usageDataChoice == nil { + setUsageDataEnabled(false) + } + } + + func setCloudSyncEnabled(_ enabled: Bool) { + onboarding.setSyncChoice(enabled) + syncCoordinator.setEnabled(enabled) + } + + func setUsageDataEnabled(_ enabled: Bool) { + onboarding.setUsageDataChoice(enabled) + } + + // MARK: - Sample Database + + func openSampleDatabase() throws -> UUID { + try sampleInstaller.installIfNeeded() + if let existing = connections.first(where: \.isSample) { + return existing.id + } + let sample = DatabaseConnection( + name: SampleDatabaseInstaller.connectionName, + type: .sqlite, + host: "", + port: 0, + database: SampleDatabaseInstaller.fileName, + color: .green, + isSample: true + ) + guard addConnection(sample) else { throw SampleDatabaseError.libraryUnavailable } + return sample.id + } + + func resetSampleDatabase() async throws { + let sampleIds = connections.filter(\.isSample).map(\.id) + for id in sampleIds { + await connectionManager.disconnect(id) + } + try sampleInstaller.reset() + sampleResetRevision += 1 + } + // MARK: - Spotlight private func updateSpotlightIndex() { @@ -411,14 +534,20 @@ final class AppState { // MARK: - Persistence +nonisolated enum LibraryStorage { + static var defaultDirectory: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + return base.appendingPathComponent("TableProMobile", isDirectory: true) + } +} + private struct ConnectionPersistence { + let directory: URL + private var fileURL: URL? { - guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { - return nil - } - let appDir = dir.appendingPathComponent("TableProMobile", isDirectory: true) - try? FileManager.default.createDirectory(at: appDir, withIntermediateDirectories: true) - return appDir.appendingPathComponent("connections.json") + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appendingPathComponent("connections.json") } func save(_ connections: [DatabaseConnection]) throws { diff --git a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift index ff8cf6e817..c65754040d 100644 --- a/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift +++ b/TableProMobile/TableProMobile/Coordinators/ConnectionCoordinator.swift @@ -28,6 +28,7 @@ final class ConnectionCoordinator { } } var pendingQuery: String? + var pendingTableName: String? var tablesPath = NavigationPath() var showingEditSheet = false @@ -85,8 +86,6 @@ final class ConnectionCoordinator { private var attemptToken = UUID() private var connectTask: Task? - var isConnecting: Bool { connectTask != nil } - /// Returning early without touching `phase` is what left the connecting screen up for good. func connect() async { if let inFlight = connectTask { @@ -338,9 +337,9 @@ final class ConnectionCoordinator { } func navigateToPendingTable() { - guard let tableName = appState.pendingTableName, + guard let tableName = pendingTableName, let table = tables.first(where: { $0.name == tableName }) else { return } - appState.pendingTableName = nil + pendingTableName = nil selectedTab = .tables Task { @MainActor in tablesPath.append(table) diff --git a/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift b/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift new file mode 100644 index 0000000000..17ac0a1c21 --- /dev/null +++ b/TableProMobile/TableProMobile/Coordinators/ScenePresenter.swift @@ -0,0 +1,117 @@ +import Foundation +import Observation +import TableProModels + +enum SceneSheet: Identifiable { + case firstRun([FirstRunPage]) + case whatsNew(version: String) + case addConnection + case editConnection(DatabaseConnection) + case moveConnections([UUID]) + case newGroup(parentId: UUID?) + case editGroup(ConnectionGroup) + case tags + case settings + case importFile(URL) + case export + + var id: String { + switch self { + case .firstRun: "firstRun" + case .whatsNew(let version): "whatsNew-\(version)" + case .addConnection: "addConnection" + case .editConnection(let connection): "editConnection-\(connection.id.uuidString)" + case .moveConnections(let ids): "moveConnections-\(ids.map(\.uuidString).joined(separator: ","))" + case .newGroup(let parentId): "newGroup-\(parentId?.uuidString ?? "root")" + case .editGroup(let group): "editGroup-\(group.id.uuidString)" + case .tags: "tags" + case .settings: "settings" + case .importFile(let url): "importFile-\(url.absoluteString)" + case .export: "export" + } + } + + var isLaunchPresentation: Bool { + switch self { + case .firstRun, .whatsNew: true + default: false + } + } +} + +nonisolated struct PendingTableRequest: Hashable, Sendable { + let connectionId: UUID + let tableName: String +} + +@MainActor @Observable +final class ScenePresenter { + let sceneId = UUID() + + var sheet: SceneSheet? + private(set) var pendingIntent: SceneIntent? + private(set) var pendingTable: PendingTableRequest? + private(set) var holdsConnectionRestore = false + + @ObservationIgnored private var hasBegunLaunch = false + @ObservationIgnored private var presentedLaunchSheet = false + @ObservationIgnored private var presentedFirstRunPages: [FirstRunPage] = [] + + func beginLaunch(with appState: AppState) { + guard !hasBegunLaunch else { return } + hasBegunLaunch = true + switch appState.claimLaunchPresentation(for: sceneId) { + case .none: + return + case .firstRun(let pages): + holdsConnectionRestore = true + presentedFirstRunPages = pages + present(.firstRun(pages)) + case .whatsNew(let version): + present(.whatsNew(version: version)) + } + } + + func sheetDidDismiss(appState: AppState) { + guard presentedLaunchSheet, sheet == nil else { return } + presentedLaunchSheet = false + holdsConnectionRestore = false + appState.finishFirstRun(pages: presentedFirstRunPages) + presentedFirstRunPages = [] + appState.releaseLaunchPresentation(for: sceneId) + } + + func releaseLaunchClaim(appState: AppState) { + appState.releaseLaunchPresentation(for: sceneId) + } + + func present(_ newSheet: SceneSheet) { + if newSheet.isLaunchPresentation { + presentedLaunchSheet = true + } + sheet = newSheet + } + + func receive(_ intent: SceneIntent) { + pendingIntent = intent + } + + func takeDeliverableIntent(isLocked: Bool, isLibraryWritable: Bool) -> SceneIntent? { + guard let pendingIntent, sheet == nil, !isLocked, !holdsConnectionRestore else { return nil } + if case .importConnections = pendingIntent, !isLibraryWritable { + return nil + } + self.pendingIntent = nil + return pendingIntent + } + + func requestTable(_ tableName: String?, in connectionId: UUID) { + pendingTable = tableName.map { PendingTableRequest(connectionId: connectionId, tableName: $0) } + } + + func takeTable(for connectionId: UUID) -> String? { + guard let pendingTable, pendingTable.connectionId == connectionId else { return nil } + self.pendingTable = nil + return pendingTable.tableName + } +} diff --git a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift index 75ec22e466..bc374f296b 100644 --- a/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift +++ b/TableProMobile/TableProMobile/Drivers/MSSQLDriver.swift @@ -56,10 +56,6 @@ nonisolated final class MSSQLDriver: DatabaseDriver, @unchecked Sendable { self.currentSchema = options.schema } - private var escapedSchema: String { - (currentSchema ?? "dbo").replacingOccurrences(of: "'", with: "''") - } - // MARK: - Connection func connect() async throws { diff --git a/TableProMobile/TableProMobile/Helpers/AcknowledgementsInventory.swift b/TableProMobile/TableProMobile/Helpers/AcknowledgementsInventory.swift new file mode 100644 index 0000000000..3f0cf6d5a9 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/AcknowledgementsInventory.swift @@ -0,0 +1,72 @@ +import Foundation +import os + +nonisolated struct AcknowledgementComponent: Decodable, Identifiable, Hashable, Sendable { + let id: String + let name: String + let version: String + let spdx: String + let copyrights: [String] + let homepageURL: String + let textFile: String? + + private static let revisionPrefix = "rev:" + private static let shortRevisionLength = 7 + + var displayVersion: String { + guard version.hasPrefix(Self.revisionPrefix) else { return version } + return String(version.dropFirst(Self.revisionPrefix.count).prefix(Self.shortRevisionLength)) + } + + var homepage: URL? { + URL(string: homepageURL) + } +} + +nonisolated enum AcknowledgementsInventoryError: Error, Equatable { + case manifestMissing + case licenseTextMissing(componentId: String) +} + +nonisolated struct AcknowledgementsInventory: Sendable { + private static let logger = Logger(subsystem: "com.TablePro", category: "Acknowledgements") + + static let manifestName = "Acknowledgements" + static let manifestExtension = "json" + + let components: [AcknowledgementComponent] + private let rootURL: URL + + init(manifestURL: URL) throws { + let data = try Data(contentsOf: manifestURL) + components = try JSONDecoder().decode([AcknowledgementComponent].self, from: data) + rootURL = manifestURL.deletingLastPathComponent() + } + + static func bundled(in bundle: Bundle = .main) throws -> AcknowledgementsInventory { + guard let manifestURL = bundle.url(forResource: manifestName, withExtension: manifestExtension) else { + logger.error("Acknowledgements manifest is missing from the app bundle") + throw AcknowledgementsInventoryError.manifestMissing + } + do { + return try AcknowledgementsInventory(manifestURL: manifestURL) + } catch { + logger.error("Could not read the acknowledgements manifest: \(error.localizedDescription, privacy: .private)") + throw error + } + } + + func licenseText(for component: AcknowledgementComponent) throws -> String { + guard let textFile = component.textFile else { + throw AcknowledgementsInventoryError.licenseTextMissing(componentId: component.id) + } + do { + return try String(contentsOf: rootURL.appendingPathComponent(textFile), encoding: .utf8) + } catch { + Self.logger.error( + "Missing license text \(textFile, privacy: .public) for \(component.id, privacy: .public)" + ) + throw AcknowledgementsInventoryError.licenseTextMissing(componentId: component.id) + } + } +} diff --git a/TableProMobile/TableProMobile/Helpers/ConnectionListState.swift b/TableProMobile/TableProMobile/Helpers/ConnectionListState.swift new file mode 100644 index 0000000000..6e252f7938 --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/ConnectionListState.swift @@ -0,0 +1,46 @@ +import Foundation +import TableProSyncTransport + +nonisolated enum ConnectionListState: Equatable, Sendable { + case loading + case failed + case checkingICloud + case iCloudUnavailable(SyncError) + case empty(syncsWithICloud: Bool) + case content(syncProblem: SyncError?) + + static func resolve( + loadStatus: LoadStatus, + hasLibraryItems: Bool, + isSyncEnabled: Bool, + syncStatus: SyncStatus, + hasCompletedFirstSync: Bool + ) -> ConnectionListState { + switch loadStatus { + case .failed: + return .failed + case .loading: + return .loading + case .ready: + break + } + let syncError = isSyncEnabled ? syncStatus.error : nil + if hasLibraryItems { + return .content(syncProblem: syncError) + } + guard isSyncEnabled, !hasCompletedFirstSync else { + return .empty(syncsWithICloud: isSyncEnabled) + } + if let syncError { + return .iCloudUnavailable(syncError) + } + return syncStatus == .syncing ? .checkingICloud : .empty(syncsWithICloud: true) + } +} + +nonisolated private extension SyncStatus { + var error: SyncError? { + guard case .error(let error) = self else { return nil } + return error + } +} diff --git a/TableProMobile/TableProMobile/Helpers/GroupPersistence.swift b/TableProMobile/TableProMobile/Helpers/GroupPersistence.swift index 249300e164..152787a9ed 100644 --- a/TableProMobile/TableProMobile/Helpers/GroupPersistence.swift +++ b/TableProMobile/TableProMobile/Helpers/GroupPersistence.swift @@ -2,13 +2,11 @@ import Foundation import TableProModels struct GroupPersistence { + let directory: URL + private var fileURL: URL? { - guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { - return nil - } - let appDir = dir.appendingPathComponent("TableProMobile", isDirectory: true) - try? FileManager.default.createDirectory(at: appDir, withIntermediateDirectories: true) - return appDir.appendingPathComponent("groups.json") + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appendingPathComponent("groups.json") } func save(_ groups: [ConnectionGroup]) throws { diff --git a/TableProMobile/TableProMobile/Helpers/SceneIntent.swift b/TableProMobile/TableProMobile/Helpers/SceneIntent.swift new file mode 100644 index 0000000000..73084c0a0f --- /dev/null +++ b/TableProMobile/TableProMobile/Helpers/SceneIntent.swift @@ -0,0 +1,48 @@ +import CoreSpotlight +import Foundation + +nonisolated enum SceneIntent: Hashable, Sendable { + case openConnection(UUID, table: String?) + case importConnections(URL) + + static let viewConnectionActivity = "com.TablePro.viewConnection" + static let viewTableActivity = "com.TablePro.viewTable" + static let connectionFileExtension = "tablepro" + + static func parse(url: URL) -> SceneIntent? { + if url.isFileURL { + guard url.pathExtension.lowercased() == connectionFileExtension else { return nil } + return .importConnections(url) + } + guard url.scheme?.lowercased() == "tablepro", + url.host(percentEncoded: false)?.lowercased() == "connect" else { return nil } + let segments = url.pathComponents.filter { $0 != "/" } + guard let first = segments.first, let connectionId = UUID(uuidString: first) else { return nil } + guard segments.count == 3, segments[1] == "table", !segments[2].isEmpty else { + return .openConnection(connectionId, table: nil) + } + return .openConnection(connectionId, table: segments[2]) + } + + static func parse(activityType: String, userInfo: [AnyHashable: Any]?) -> SceneIntent? { + switch activityType { + case CSSearchableItemActionType: + guard let identifier = userInfo?[CSSearchableItemActivityIdentifier] as? String, + let connectionId = UUID(uuidString: identifier) else { return nil } + return .openConnection(connectionId, table: nil) + case viewConnectionActivity: + guard let connectionId = connectionId(in: userInfo) else { return nil } + return .openConnection(connectionId, table: nil) + case viewTableActivity: + guard let connectionId = connectionId(in: userInfo) else { return nil } + let table = (userInfo?["tableName"] as? String).flatMap { $0.isEmpty ? nil : $0 } + return .openConnection(connectionId, table: table) + default: + return nil + } + } + + private static func connectionId(in userInfo: [AnyHashable: Any]?) -> UUID? { + (userInfo?["connectionId"] as? String).flatMap(UUID.init(uuidString:)) + } +} diff --git a/TableProMobile/TableProMobile/Helpers/StreamingExporter.swift b/TableProMobile/TableProMobile/Helpers/StreamingExporter.swift deleted file mode 100644 index 0ff1721461..0000000000 --- a/TableProMobile/TableProMobile/Helpers/StreamingExporter.swift +++ /dev/null @@ -1,130 +0,0 @@ -import Foundation -import os -import TableProDatabase -import TableProModels - -actor StreamingExporter { - private static let logger = Logger(subsystem: "com.TablePro", category: "StreamingExporter") - - init() {} - - func exportToFile( - driver: DatabaseDriver, - query: String, - format: ExportFormat, - tableName: String, - options: StreamOptions = .default - ) async throws -> URL { - let url = FileManager.default.temporaryDirectory - .appendingPathComponent("TablePro-export-\(UUID().uuidString).\(format.fileExtension)") - FileManager.default.createFile(atPath: url.path, contents: nil) - - let handle = try FileHandle(forWritingTo: url) - defer { try? handle.close() } - - var headerWritten = false - var seenColumns: [String] = [] - var rowIndex = 0 - - if case .json = format { - try handle.write(contentsOf: Data("[\n".utf8)) - } - - do { - for try await element in driver.executeStreaming(query: query, options: options) { - switch element { - case .columns(let cols): - seenColumns = cols.map(\.name) - if !headerWritten, format != .json { - let header = formatHeader(format: format, columns: seenColumns) + "\n" - try handle.write(contentsOf: Data(header.utf8)) - headerWritten = true - } - case .row(let row): - let values = row.legacyValues - let line = formatRow( - format: format, - columns: seenColumns, - values: values, - tableName: tableName, - isFirst: rowIndex == 0 - ) - try handle.write(contentsOf: Data(line.utf8)) - rowIndex += 1 - case .rowsAffected, .statusMessage, .truncated: - continue - } - } - } catch { - try? FileManager.default.removeItem(at: url) - throw error - } - - if case .json = format { - try handle.write(contentsOf: Data("\n]\n".utf8)) - } - - Self.logger.info("Streaming export wrote \(rowIndex) rows to \(url.lastPathComponent, privacy: .public)") - return url - } - - private func formatHeader(format: ExportFormat, columns: [String]) -> String { - switch format { - case .csv: - return columns.map(escapeCsv).joined(separator: ",") - case .json: - return "" - case .sqlInsert: - return "" - } - } - - private func formatRow(format: ExportFormat, columns: [String], values: [String?], tableName: String, isFirst: Bool) -> String { - switch format { - case .csv: - let cells = columns.indices.map { i in - escapeCsv(i < values.count ? (values[i] ?? "NULL") : "NULL") - } - return cells.joined(separator: ",") + "\n" - case .json: - var dict: [String: Any] = [:] - for (i, name) in columns.enumerated() where i < values.count { - if let value = values[i] { - dict[name] = value - } else { - dict[name] = NSNull() - } - } - let data = (try? JSONSerialization.data(withJSONObject: dict, options: [.sortedKeys])) ?? Data() - let json = String(data: data, encoding: .utf8) ?? "{}" - return (isFirst ? " " : ",\n ") + json - case .sqlInsert: - let safeTable = tableName.replacingOccurrences(of: "`", with: "``") - let columnList = columns.map { "`\($0.replacingOccurrences(of: "`", with: "``"))`" }.joined(separator: ", ") - let valueList = columns.indices.map { i -> String in - guard i < values.count, let value = values[i] else { return "NULL" } - let escaped = value.replacingOccurrences(of: "'", with: "''") - return "'\(escaped)'" - }.joined(separator: ", ") - return "INSERT INTO `\(safeTable)` (\(columnList)) VALUES (\(valueList));\n" - } - } - - private func escapeCsv(_ value: String) -> String { - if value.contains(",") || value.contains("\"") || value.contains("\n") { - let escaped = value.replacingOccurrences(of: "\"", with: "\"\"") - return "\"\(escaped)\"" - } - return value - } -} - -extension ExportFormat { - nonisolated var fileExtension: String { - switch self { - case .csv: return "csv" - case .json: return "json" - case .sqlInsert: return "sql" - } - } -} diff --git a/TableProMobile/TableProMobile/Helpers/TagPersistence.swift b/TableProMobile/TableProMobile/Helpers/TagPersistence.swift index a2ce03aa56..990d54037e 100644 --- a/TableProMobile/TableProMobile/Helpers/TagPersistence.swift +++ b/TableProMobile/TableProMobile/Helpers/TagPersistence.swift @@ -2,13 +2,11 @@ import Foundation import TableProModels struct TagPersistence { + let directory: URL + private var fileURL: URL? { - guard let dir = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first else { - return nil - } - let appDir = dir.appendingPathComponent("TableProMobile", isDirectory: true) - try? FileManager.default.createDirectory(at: appDir, withIntermediateDirectories: true) - return appDir.appendingPathComponent("tags.json") + try? FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + return directory.appendingPathComponent("tags.json") } func save(_ tags: [ConnectionTag]) throws { diff --git a/TableProMobile/TableProMobile/Info.plist b/TableProMobile/TableProMobile/Info.plist index 0552a14591..b8c2c1eb73 100644 --- a/TableProMobile/TableProMobile/Info.plist +++ b/TableProMobile/TableProMobile/Info.plist @@ -6,22 +6,10 @@ $(ANALYTICS_HMAC_SECRET) AppIdentifierPrefix $(AppIdentifierPrefix) - NSAppTransportSecurity - - NSAllowsArbitraryLoads - - NSFaceIDUsageDescription TablePro uses Face ID to protect your saved database connections and credentials. NSLocalNetworkUsageDescription TablePro connects to database servers and SSH tunnels running on your local network, including Bonjour (.local) hostnames. - NSBonjourServices - - _ssh._tcp - _mysql._tcp - _postgresql._tcp - _redis._tcp - NSSupportsLiveActivities NSUserActivityTypes @@ -50,7 +38,6 @@ UIBackgroundModes fetch - processing BGTaskSchedulerPermittedIdentifiers diff --git a/TableProMobile/TableProMobile/InfoPlist.xcstrings b/TableProMobile/TableProMobile/InfoPlist.xcstrings index f6f99f601d..820068f237 100644 --- a/TableProMobile/TableProMobile/InfoPlist.xcstrings +++ b/TableProMobile/TableProMobile/InfoPlist.xcstrings @@ -1,6 +1,32 @@ { "sourceLanguage" : "en", "strings" : { + "CFBundleDisplayName" : { + "comment" : "Bundle display name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "TablePro" + } + } + }, + "shouldTranslate" : false + }, + "CFBundleName" : { + "comment" : "Bundle name", + "extractionState" : "extracted_with_value", + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "TableProMobile" + } + } + }, + "shouldTranslate" : false + }, "NSFaceIDUsageDescription" : { "comment" : "Privacy - Face ID Usage Description", "extractionState" : "manual", @@ -72,6 +98,34 @@ } } } + }, + "TablePro Connections" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 연결" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối TablePro" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 連線" + } + } + } } }, "version" : "1.0" diff --git a/TableProMobile/TableProMobile/Localizable.xcstrings b/TableProMobile/TableProMobile/Localizable.xcstrings index a0fa1e02b8..3dc97dee61 100644 --- a/TableProMobile/TableProMobile/Localizable.xcstrings +++ b/TableProMobile/TableProMobile/Localizable.xcstrings @@ -58,6 +58,7 @@ } }, "%1$@ (ORA-%2$ld)." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -85,6 +86,34 @@ } } }, + "%1$@ via %2$@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@(%2$@ 경유)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ qua %2$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@(经由 %2$@)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@(經由 %2$@)" + } + } + } + }, "%@" : { "localizations" : { "ko" : { @@ -147,6 +176,34 @@ } } }, + "%@ Copy" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 복사본" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ bản sao" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 副本" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@ 副本" + } + } + } + }, "%@ connections do not support adding rows from Shortcuts." : { "localizations" : { "ko" : { @@ -341,6 +398,7 @@ } }, "%@, %@, %@" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -375,6 +433,7 @@ } }, "%@, %@, %@, tag %@" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -443,6 +502,7 @@ } }, "%@, port %lld" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -797,6 +857,7 @@ } }, "A fast, lightweight database client for your iPhone and iPad." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -824,7 +885,64 @@ } } }, + "A hashed identifier for this device" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 기기의 해시된 식별자" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mã định danh đã băm của thiết bị này" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此设备经过哈希处理的标识符" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此裝置的雜湊識別碼" + } + } + } + }, + "A running query shows its time and row count in a Live Activity." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실행 중인 쿼리의 경과 시간과 행 수가 실시간 현황에 표시됩니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truy vấn đang chạy hiển thị thời gian và số dòng trong Hoạt động trực tiếp." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在运行的查询会在实时活动中显示用时和行数。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "執行中的查詢會在即時動態中顯示經過時間與列數。" + } + } + } + }, "A sync conflict was detected and needs to be resolved." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -908,6 +1026,62 @@ } } }, + "Accepted" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "수락" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chấp nhận" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "接受" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "接受" + } + } + } + }, + "Acknowledgements" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "감사의 글" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ghi nhận" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "致谢" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "致謝" + } + } + } + }, "Active DB" : { "localizations" : { "ko" : { @@ -1110,6 +1284,34 @@ } } }, + "Add Tag" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "태그 추가" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm nhãn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加标签" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "新增標籤" + } + } + } + }, "Add a connection in TablePro" : { "localizations" : { "ko" : { @@ -1138,7 +1340,36 @@ } } }, + "Add a connection to your database, or explore TablePro with the sample database." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "데이터베이스 연결을 추가하거나 샘플 데이터베이스로 TablePro를 둘러보십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm kết nối tới cơ sở dữ liệu của bạn, hoặc khám phá TablePro với cơ sở dữ liệu mẫu." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加数据库连接,或通过示例数据库体验 TablePro。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "新增資料庫連線,或透過範例資料庫探索 TablePro。" + } + } + } + }, "Add a database connection to get started." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -1306,6 +1537,34 @@ } } }, + "Add to Favorites" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "즐겨찾기에 추가" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thêm vào Yêu thích" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "添加到收藏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "加入我的最愛" + } + } + } + }, "Added %lld rows to %@." : { "localizations" : { "en" : { @@ -1481,6 +1740,7 @@ } }, "All" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -1622,6 +1882,7 @@ } }, "An unknown sync error occurred: %@" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -1873,6 +2134,34 @@ } } }, + "Authenticate to stop locking TablePro." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 잠금을 끄려면 인증하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xác thực để ngừng khóa TablePro." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "进行认证以停止锁定 TablePro。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "進行驗證以停止鎖定 TablePro。" + } + } + } + }, "Authentication Failed" : { "localizations" : { "ko" : { @@ -1985,63 +2274,93 @@ } } }, - "Build" : { + "Browse and Edit Data" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "빌드" + "value" : "데이터 탐색 및 편집" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Bản dựng" + "value" : "Duyệt và sửa dữ liệu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "构建版本" + "value" : "浏览和编辑数据" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "建置版本" + "value" : "瀏覽與編輯資料" } } } }, - "CA Certificate" : { + "Build" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "CA 인증서" + "value" : "빌드" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chứng chỉ CA" + "value" : "Bản dựng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "CA 证书" + "value" : "构建版本" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "CA 憑證" + "value" : "建置版本" + } + } + } + }, + "CA Certificate" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CA 인증서" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chứng chỉ CA" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CA 证书" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "CA 憑證" } } } }, "CA certificate not found: %@" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -2433,6 +2752,62 @@ } } }, + "Checking iCloud" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud 확인 중" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang kiểm tra iCloud" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在检查 iCloud" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在檢查 iCloud" + } + } + } + }, + "Chinook (Sample)" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chinook(샘플)" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chinook (Mẫu)" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chinook(示例)" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chinook(範例)" + } + } + } + }, "Choose File" : { "localizations" : { "ko" : { @@ -2462,6 +2837,7 @@ } }, "Choose a connection from the sidebar." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -2742,6 +3118,7 @@ } }, "Client certificate not found: %@" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -2770,6 +3147,7 @@ } }, "Client key not found: %@" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -2881,6 +3259,34 @@ } } }, + "Computed by the database" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "데이터베이스에서 계산됨" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Do cơ sở dữ liệu tính toán" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "由数据库计算" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "由資料庫計算" + } + } + } + }, "Configuration Error" : { "localizations" : { "ko" : { @@ -2910,6 +3316,7 @@ } }, "Confirm Writes" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -2993,6 +3400,34 @@ } } }, + "Connect Securely" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "안전하게 연결" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối an toàn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "安全连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "安全連線" + } + } + } + }, "Connect Using" : { "localizations" : { "ko" : { @@ -3133,6 +3568,34 @@ } } }, + "Connection Cancelled" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결 취소됨" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đã hủy kết nối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接已取消" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "連線已取消" + } + } + } + }, "Connection Deleted" : { "localizations" : { "ko" : { @@ -3189,7 +3652,36 @@ } } }, + "Connection failed while waiting on the socket." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "소켓을 기다리는 동안 연결에 실패했습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối thất bại khi đang chờ trên socket." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "等待 socket 时连接失败。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "等待 socket 時連線失敗。" + } + } + } + }, "Connection failed: %@" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -3273,315 +3765,456 @@ } } }, - "Connections" : { + "Connection timed out." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "연결" + "value" : "연결 시간이 초과되었습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Kết nối" + "value" : "Kết nối đã hết thời gian chờ." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "连接" + "value" : "连接超时。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "連線" + "value" : "連線逾時。" } } } }, - "Connections in this group will be moved to ungrouped." : { + "Connections" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 그룹의 연결이 그룹 없음으로 이동됩니다." + "value" : "연결" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Các kết nối trong nhóm này sẽ được chuyển sang mục chưa phân nhóm." + "value" : "Kết nối" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此分组中的连接将被移至未分组。" + "value" : "连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此群組中的連線將被移至未分組。" + "value" : "連線" } } } }, - "Continue" : { + "Connections Unavailable" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "계속" + "value" : "연결을 사용할 수 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tiếp tục" + "value" : "Kết nối không khả dụng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "继续" + "value" : "连接不可用" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "繼續" + "value" : "連線無法使用" } } } }, - "Copy Column Name" : { + "Connections from your other devices appear here." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "열 이름 복사" + "value" : "다른 기기의 연결이 여기에 표시됩니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Sao chép tên cột" + "value" : "Kết nối từ các thiết bị khác của bạn sẽ hiển thị ở đây." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "复制列名" + "value" : "你其他设备上的连接会显示在这里。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "複製欄位名稱" + "value" : "來自你其他裝置的連線會顯示在這裡。" } } } }, - "Copy Name" : { + "Connections in this group will be moved to ungrouped." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이름 복사" + "value" : "이 그룹의 연결이 그룹 없음으로 이동됩니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Sao chép tên" + "value" : "Các kết nối trong nhóm này sẽ được chuyển sang mục chưa phân nhóm." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "拷贝名称" + "value" : "此分组中的连接将被移至未分组。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "複製名稱" + "value" : "此群組中的連線將被移至未分組。" } } } }, - "Copy Query" : { + "Connections you add here or in TablePro on your Mac appear on all your devices." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "쿼리 복사" + "value" : "여기 또는 Mac용 TablePro에서 추가한 연결이 모든 기기에 표시됩니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Sao chép truy vấn" + "value" : "Kết nối bạn thêm ở đây hoặc trong TablePro trên máy Mac sẽ xuất hiện trên mọi thiết bị của bạn." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "复制查询" + "value" : "你在此处或 Mac 上的 TablePro 中添加的连接,会显示在你的所有设备上。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "複製查詢" + "value" : "你在這裡或 Mac 版 TablePro 中新增的連線,會顯示在你所有的裝置上。" } } } }, - "Copy Results" : { + "Connections, groups, and tags from TablePro on your Mac appear here over iCloud." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "결과 복사" + "value" : "Mac용 TablePro의 연결, 그룹 및 태그가 iCloud를 통해 여기에 표시됩니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Sao chép kết quả" + "value" : "Kết nối, nhóm và nhãn từ TablePro trên máy Mac sẽ xuất hiện ở đây qua iCloud." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "复制结果" + "value" : "Mac 上 TablePro 中的连接、分组和标签会通过 iCloud 显示在这里。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "複製結果" + "value" : "Mac 版 TablePro 中的連線、群組與標籤會透過 iCloud 顯示在這裡。" } } } }, - "Copy Row" : { + "Continue" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "행 복사" + "value" : "계속" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Sao chép dòng" + "value" : "Tiếp tục" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "复制行" + "value" : "继续" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "複製列" + "value" : "繼續" } } } }, - "Copy Value" : { + "Copy Column Name" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "값 복사" + "value" : "열 이름 복사" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Sao chép giá trị" + "value" : "Sao chép tên cột" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "复制值" + "value" : "复制列名" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "複製值" + "value" : "複製欄位名稱" } } } }, - "Copy to Clipboard" : { + "Copy Name" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "클립보드에 복사" + "value" : "이름 복사" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Sao chép vào bộ nhớ tạm" + "value" : "Sao chép tên" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "拷贝到剪贴板" + "value" : "拷贝名称" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "複製到剪貼簿" + "value" : "複製名稱" } } } }, - "Could Not Load the Referenced Row" : { + "Copy Query" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "참조된 행을 불러올 수 없음" + "value" : "쿼리 복사" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không thể tải dòng tham chiếu" + "value" : "Sao chép truy vấn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无法载入引用行" + "value" : "复制查询" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "無法載入參照列" + "value" : "複製查詢" + } + } + } + }, + "Copy Results" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "결과 복사" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép kết quả" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制结果" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "複製結果" + } + } + } + }, + "Copy Row" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "행 복사" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép dòng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制行" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "複製列" + } + } + } + }, + "Copy Value" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "값 복사" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép giá trị" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "复制值" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "複製值" + } + } + } + }, + "Copy to Clipboard" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "클립보드에 복사" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sao chép vào bộ nhớ tạm" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拷贝到剪贴板" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "複製到剪貼簿" + } + } + } + }, + "Could Not Load the Referenced Row" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "참조된 행을 불러올 수 없음" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể tải dòng tham chiếu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法载入引用行" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法載入參照列" } } } }, "Could not complete Oracle native network encryption with this server. It may require an encryption or checksum algorithm the driver does not support." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -3637,6 +4270,34 @@ } } }, + "Could not install the sample database: %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "샘플 데이터베이스를 설치할 수 없습니다: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể cài đặt cơ sở dữ liệu mẫu: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法安装示例数据库:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法安裝範例資料庫:%@" + } + } + } + }, "Could not read the columns of %@." : { "localizations" : { "ko" : { @@ -3665,6 +4326,34 @@ } } }, + "Could not start a connection." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결을 시작할 수 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể bắt đầu kết nối." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法发起连接。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法啟動連線。" + } + } + } + }, "Create" : { "localizations" : { "ko" : { @@ -3694,6 +4383,7 @@ } }, "Create Group" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -3778,6 +4468,7 @@ } }, "Create a group to organize your connections." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -3833,6 +4524,34 @@ } } }, + "DEFAULT" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "DEFAULT" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "MẶC ĐỊNH" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "默认" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "預設" + } + } + } + }, "Database" : { "localizations" : { "ko" : { @@ -4058,6 +4777,7 @@ } }, "Decryption failed: %@" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -4253,6 +4973,34 @@ } } }, + "Delete %d Connections" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결 %d개 삭제" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa %d kết nối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除 %d 个连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "刪除 %d 個連線" + } + } + } + }, "Delete Connection" : { "localizations" : { "ko" : { @@ -4338,6 +5086,7 @@ } }, "Delete group" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -4505,63 +5254,91 @@ } } }, - "Done" : { + "Don't Share" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "완료" + "value" : "공유 안 함" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Xong" + "value" : "Không chia sẻ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "完成" + "value" : "不共享" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "完成" + "value" : "不要分享" } } } }, - "Drop" : { + "Done" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "삭제" + "value" : "완료" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Xóa bảng" + "value" : "Xong" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "删除" + "value" : "完成" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "刪除" + "value" : "完成" } } } }, - "Drop Table" : { + "Drop" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "삭제" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Xóa bảng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "刪除" + } + } + } + }, + "Drop Table" : { "localizations" : { "ko" : { "stringUnit" : { @@ -4729,6 +5506,34 @@ } } }, + "Empty String" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "빈 문자열" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chuỗi rỗng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "空字符串" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "空字串" + } + } + } + }, "Enabled" : { "localizations" : { "ko" : { @@ -4757,6 +5562,34 @@ } } }, + "Encoding" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인코딩" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bảng mã" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编码" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "編碼" + } + } + } + }, "Enter a name for the new SQLite database." : { "extractionState" : "stale", "localizations" : { @@ -5011,6 +5844,34 @@ } } }, + "Every change you made to the sample database is replaced with the original data." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "샘플 데이터베이스에서 변경한 모든 내용이 원본 데이터로 대체됩니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mọi thay đổi bạn đã thực hiện trên cơ sở dữ liệu mẫu sẽ được thay bằng dữ liệu gốc." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "你对示例数据库所做的所有更改都将被原始数据替换。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "你對範例資料庫所做的每項變更都會被原始資料取代。" + } + } + } + }, "Execute" : { "localizations" : { "ko" : { @@ -5123,6 +5984,34 @@ } } }, + "Explore a music store database without connecting to a server." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서버에 연결하지 않고도 음악 상점 데이터베이스를 둘러볼 수 있습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khám phá cơ sở dữ liệu của một cửa hàng âm nhạc mà không cần kết nối tới máy chủ." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无需连接服务器,即可探索一个音乐商店数据库。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "不必連線到伺服器,就能探索一個音樂商店資料庫。" + } + } + } + }, "Export" : { "localizations" : { "ko" : { @@ -5208,6 +6097,7 @@ } }, "Failed to encode connection data" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -5236,6 +6126,7 @@ } }, "Failed to encode sync data: %@" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -5264,6 +6155,7 @@ } }, "Failed to establish connection" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -5321,6 +6213,7 @@ } }, "Failed to parse connection file: %@" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -5349,6 +6242,7 @@ } }, "Failed to read file: %@" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -5489,6 +6383,7 @@ } }, "Failed to write file: %@" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -5516,6 +6411,62 @@ } } }, + "Favorite" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "즐겨찾기" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yêu thích" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "我的最愛" + } + } + } + }, + "Favorites" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "즐겨찾기" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yêu thích" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "收藏" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "我的最愛" + } + } + } + }, "File" : { "localizations" : { "ko" : { @@ -5544,6 +6495,62 @@ } } }, + "Fill in Password, and Username too if the server uses Redis 6 ACL users." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "암호 필드를 입력하고, 서버에서 Redis 6 ACL 사용자를 사용하는 경우 사용자 이름 필드도 입력하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hãy điền Mật khẩu, và cả Tên đăng nhập nếu máy chủ dùng người dùng ACL của Redis 6." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请填写“密码”;如果服务器使用 Redis 6 ACL 用户,还需填写“用户名”。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "請填寫「密碼」;如果伺服器使用 Redis 6 ACL 使用者,也請填寫「使用者名稱」。" + } + } + } + }, + "Fill in at least one column. This database cannot insert a row made only of defaults." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "열을 하나 이상 입력하십시오. 이 데이터베이스는 기본값만으로 이루어진 행을 삽입할 수 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hãy điền ít nhất một cột. Cơ sở dữ liệu này không thể chèn một dòng chỉ gồm giá trị mặc định." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "请至少填写一列。此数据库无法插入仅由默认值组成的行。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "請至少填寫一個欄位。此資料庫無法插入只含預設值的列。" + } + } + } + }, "Filter" : { "localizations" : { "ko" : { @@ -5713,6 +6720,7 @@ } }, "Get Started" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -5937,6 +6945,7 @@ } }, "Group by Folder" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -5992,92 +7001,206 @@ } } }, - "Help improve TablePro by sharing anonymous usage statistics (no personal data or queries)." : { + "Help decide what to improve next by sending one small report a day." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "개인 데이터나 쿼리는 제외하고 익명의 사용 통계를 공유하여 TablePro 개선에 도움을 주십시오." + "value" : "하루에 한 번 간단한 보고서를 보내 다음에 개선할 부분을 정하는 데 도움을 주십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Giúp cải thiện TablePro bằng cách chia sẻ thống kê sử dụng ẩn danh (không có dữ liệu cá nhân hoặc truy vấn)." + "value" : "Giúp chọn điều cần cải thiện tiếp theo bằng cách gửi một báo cáo nhỏ mỗi ngày." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "通过共享匿名使用统计信息帮助改进TablePro(不包含个人数据或查询)。" + "value" : "每天发送一份简短报告,帮助决定接下来改进什么。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "分享匿名使用統計資料來協助改進 TablePro(不含個人資料或查詢)。" + "value" : "每天傳送一份簡短報告,協助決定接下來要改進哪些地方。" } } } }, - "Hide query in Live Activities" : { - "extractionState" : "manual", + "Help improve TablePro by sharing anonymous usage statistics (no personal data or queries)." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "실시간 현황에서 쿼리 가리기" + "value" : "개인 데이터나 쿼리는 제외하고 익명의 사용 통계를 공유하여 TablePro 개선에 도움을 주십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Ẩn truy vấn trong Live Activity" + "value" : "Giúp cải thiện TablePro bằng cách chia sẻ thống kê sử dụng ẩn danh (không có dữ liệu cá nhân hoặc truy vấn)." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在实时活动中隐藏查询" + "value" : "通过共享匿名使用统计信息帮助改进TablePro(不包含个人数据或查询)。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "在即時動態中隱藏查詢" + "value" : "分享匿名使用統計資料來協助改進 TablePro(不含個人資料或查詢)。" } } } }, - "History" : { + "Hide Query" : { "localizations" : { - "ko" : { + "vi" : { "stringUnit" : { - "state" : "translated", - "value" : "기록" + "value" : "Ẩn truy vấn", + "state" : "translated" } }, - "vi" : { + "ko" : { "stringUnit" : { - "state" : "translated", - "value" : "Lịch sử" + "value" : "쿼리 가리기", + "state" : "translated" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", - "value" : "历史" + "value" : "隐藏查询", + "state" : "translated" } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", - "value" : "歷程記錄" + "value" : "隱藏查詢", + "state" : "translated" } } } }, - "Host" : { + "Hide Query in Live Activities" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실시간 현황에서 쿼리 가리기" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ẩn truy vấn trong Hoạt động trực tiếp" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在实时活动中隐藏查询" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在即時動態中隱藏查詢" + } + } + } + }, + "Hide query in Live Activities" : { + "extractionState" : "manual", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "실시간 현황에서 쿼리 가리기" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ẩn truy vấn trong Live Activity" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在实时活动中隐藏查询" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "在即時動態中隱藏查詢" + } + } + } + }, + "History" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "기록" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lịch sử" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "历史" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "歷程記錄" + } + } + } + }, + "Homepage" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "홈페이지" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trang chủ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主页" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "首頁" + } + } + } + }, + "Host" : { "localizations" : { "ko" : { "stringUnit" : { @@ -6106,6 +7229,7 @@ } }, "If items appear on another device but not here, refresh forces a full re-download from iCloud. This may take a moment on slow networks." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -6302,6 +7426,7 @@ } }, "Import connections from your Mac" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -6329,6 +7454,34 @@ } } }, + "In Memory" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인메모리" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trong bộ nhớ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "内存中" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "記憶體內" + } + } + } + }, "In-Memory Database" : { "localizations" : { "ko" : { @@ -6386,6 +7539,7 @@ } }, "Incorrect passphrase" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -6581,8511 +7735,11113 @@ } } }, - "Jump host key not found: %@" : { + "Interrupted" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "점프 호스트 키를 찾을 수 없습니다: %@" + "value" : "중단됨" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không tìm thấy khóa của jump host: %@" + "value" : "Bị gián đoạn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未找到跳板主机密钥:%@" + "value" : "已中断" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "找不到跳板主機金鑰:%@" + "value" : "已中斷" } } } }, - "Kerberos authentication failed: %@" : { + "Interrupted before the query finished." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Kerberos 인증 실패: %@" + "value" : "쿼리가 완료되기 전에 중단되었습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Xác thực Kerberos thất bại: %@" + "value" : "Bị gián đoạn trước khi truy vấn hoàn tất." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Kerberos 认证失败:%@" + "value" : "查询完成前已中断。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "Kerberos 驗證失敗:%@" + "value" : "查詢完成前已中斷。" } } } }, - "Keychain Warning" : { + "It never contains a hostname, username, password, query, or any data from your databases. You can change this in Settings." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "키체인 경고" + "value" : "호스트 이름, 사용자 이름, 암호, 쿼리 또는 데이터베이스의 데이터는 절대 포함되지 않습니다. 설정에서 변경할 수 있습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Cảnh báo Keychain" + "value" : "Báo cáo không bao giờ chứa tên máy chủ, tên đăng nhập, mật khẩu, truy vấn hay bất kỳ dữ liệu nào từ cơ sở dữ liệu của bạn. Bạn có thể thay đổi điều này trong Cài đặt." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "钥匙串警告" + "value" : "报告绝不包含主机名、用户名、密码、查询或数据库中的任何数据。你可以在设置中更改此选项。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "鑰匙圈警告" + "value" : "報告絕不包含主機名稱、使用者名稱、密碼、查詢,或你資料庫中的任何資料。你可以在「設定」中更改此選項。" } } } }, - "Last Sync" : { + "Its subgroups are deleted too. Their connections move to Ungrouped." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "마지막 동기화" + "value" : "하위 그룹도 함께 삭제됩니다. 그 안의 연결은 그룹 없음으로 이동됩니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đồng bộ lần cuối" + "value" : "Các nhóm con của nhóm này cũng bị xóa. Kết nối trong các nhóm con được chuyển sang Chưa phân nhóm." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "上次同步" + "value" : "其子分组也会被删除,其中的连接将移至“未分组”。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "上次同步" + "value" : "其子群組也會一併刪除。其中的連線將移至「未分組」。" } } } }, - "Load Failed" : { + "Jump host key not found: %@" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "불러오기 실패" + "value" : "점프 호스트 키를 찾을 수 없습니다: %@" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tải thất bại" + "value" : "Không tìm thấy khóa của jump host: %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "加载失败" + "value" : "未找到跳板主机密钥:%@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "載入失敗" + "value" : "找不到跳板主機金鑰:%@" } } } }, - "Load More" : { - "extractionState" : "stale", + "Keep Connections at the Top" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "더 불러오기" + "value" : "연결을 맨 위에 고정" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tải thêm" + "value" : "Giữ kết nối ở đầu danh sách" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "加载更多" + "value" : "将连接置顶" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "載入更多" + "value" : "將連線保留在最上方" } } } }, - "Load full value" : { + "Keep your connections, groups, and tags the same on your iPhone, iPad, and Mac." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "전체 값 불러오기" + "value" : "iPhone, iPad 및 Mac에서 연결, 그룹 및 태그를 동일하게 유지합니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tải đầy đủ" + "value" : "Giữ kết nối, nhóm và nhãn giống nhau trên iPhone, iPad và máy Mac của bạn." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "加载完整值" + "value" : "让你的 iPhone、iPad 和 Mac 上的连接、分组和标签保持一致。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "載入完整值" + "value" : "讓你的連線、群組與標籤在 iPhone、iPad 與 Mac 上保持一致。" } } } }, - "Loading data..." : { + "Kerberos authentication failed: %@" : { "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "데이터 불러오는 중..." + "value" : "Kerberos 인증 실패: %@" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đang tải dữ liệu..." + "value" : "Xác thực Kerberos thất bại: %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在加载数据..." + "value" : "Kerberos 认证失败:%@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "正在載入資料…" + "value" : "Kerberos 驗證失敗:%@" } } } }, - "Loading structure..." : { + "Keychain Warning" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "구조 불러오는 중..." + "value" : "키체인 경고" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đang tải cấu trúc..." + "value" : "Cảnh báo Keychain" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在加载结构..." + "value" : "钥匙串警告" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "正在載入結構…" + "value" : "鑰匙圈警告" } } } }, - "Loading..." : { - "extractionState" : "stale", + "Last Connected" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "불러오는 중..." + "value" : "마지막 연결" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đang tải..." + "value" : "Lần kết nối gần nhất" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在加载..." + "value" : "上次连接时间" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "正在載入…" + "value" : "上次連線時間" } } } }, - "Local Network Access Required" : { + "Last Sync" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "로컬 네트워크 접근 필요" + "value" : "마지막 동기화" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Yêu cầu truy cập mạng nội bộ" + "value" : "Đồng bộ lần cuối" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "需要本地网络访问权限" + "value" : "上次同步" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "需要區域網路存取權限" + "value" : "上次同步" } } } }, - "Local Network access is required. Open Settings > Privacy & Security > Local Network and turn TablePro on." : { + "License" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "로컬 네트워크 접근 권한이 필요합니다. 시스템 설정 > 개인정보 보호 및 보안 > 로컬 네트워크를 열고 TablePro를 켜십시오." + "value" : "라이선스" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Cần truy cập Mạng nội bộ. Mở Cài đặt > Quyền riêng tư & Bảo mật > Mạng nội bộ và bật TablePro." + "value" : "Giấy phép" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "需要本地网络访问权限。请打开 设置 > 隐私与安全性 > 本地网络,并开启 TablePro。" + "value" : "许可证" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "需要區域網路存取權限。請開啟「設定」>「隱私權與安全性」>「區域網路」並開啟 TablePro。" + "value" : "授權" } } } }, - "Local Network access is required. Open Settings > Privacy & Security > Local Network and turn TablePro on. If it is already on, restart the device or update to iOS 18.6 or later." : { + "Live Activities" : { "localizations" : { - "ko" : { + "vi" : { "stringUnit" : { - "state" : "translated", - "value" : "로컬 네트워크 접근 권한이 필요합니다. 설정 > 개인정보 보호 및 보안 > 로컬 네트워크에서 TablePro를 켜십시오. 이미 켜져 있다면 기기를 재시동하거나 iOS 18.6 이상으로 업데이트하십시오." + "value" : "Hoạt động trực tiếp", + "state" : "translated" } }, - "vi" : { + "ko" : { "stringUnit" : { - "state" : "translated", - "value" : "Cần quyền truy cập Mạng cục bộ. Mở Cài đặt > Quyền riêng tư & Bảo mật > Mạng cục bộ và bật TablePro. Nếu đã bật, hãy khởi động lại thiết bị hoặc cập nhật lên iOS 18.6 trở lên." + "value" : "실시간 현황", + "state" : "translated" } }, "zh-Hans" : { "stringUnit" : { - "state" : "translated", - "value" : "需要本地网络访问权限。请打开“设置 > 隐私与安全性 > 本地网络”并开启 TablePro。如果已开启,请重启设备或升级到 iOS 18.6 或更高版本。" + "value" : "实时活动", + "state" : "translated" } }, "zh-Hant" : { "stringUnit" : { - "state" : "translated", - "value" : "需要區域網路存取權。請開啟「設定 > 隱私權與安全性 > 區域網路」並開啟 TablePro。如果已開啟,請重新啟動裝置或更新至 iOS 18.6 或更新版本。" + "value" : "即時動態", + "state" : "translated" } } } }, - "Local Network access may be blocked. Open Settings > Privacy & Security > Local Network and turn TablePro on." : { + "Load Failed" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "로컬 네트워크 접근이 차단되었을 수 있습니다. 시스템 설정 > 개인정보 보호 및 보안 > 로컬 네트워크를 열고 TablePro를 켜십시오." + "value" : "불러오기 실패" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Truy cập Mạng nội bộ có thể bị chặn. Mở Cài đặt > Quyền riêng tư & Bảo mật > Mạng nội bộ và bật TablePro." + "value" : "Tải thất bại" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "本地网络访问可能被阻止。请打开 设置 > 隐私与安全性 > 本地网络,并开启 TablePro。" + "value" : "加载失败" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "區域網路存取可能被封鎖。請開啟「設定」>「隱私權與安全性」>「區域網路」並開啟 TablePro。" + "value" : "載入失敗" } } } }, - "Locks TablePro when reopened after the selected idle time. Cold launches always require authentication." : { + "Load More" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "선택한 시간 동안 사용하지 않은 후 TablePro를 다시 열면 잠깁니다. 앱을 새로 실행할 때는 항상 인증해야 합니다." + "value" : "더 불러오기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Khóa TablePro khi mở lại sau khoảng thời gian không hoạt động đã chọn. Khởi động lạnh luôn yêu cầu xác thực." + "value" : "Tải thêm" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在所选闲置时间后重新打开时锁定 TablePro。冷启动始终需要认证。" + "value" : "加载更多" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "閒置超過所選時間後重新開啟時鎖定 TablePro。冷啟動一律需要驗證。" + "value" : "載入更多" } } } }, - "Logic" : { + "Load full value" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "로직" + "value" : "전체 값 불러오기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Logic" + "value" : "Tải đầy đủ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "逻辑" + "value" : "加载完整值" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "邏輯" + "value" : "載入完整值" } } } }, - "Manage Groups" : { + "Loading data..." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "그룹 관리" + "value" : "데이터 불러오는 중..." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Quản lý nhóm" + "value" : "Đang tải dữ liệu..." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "管理分组" + "value" : "正在加载数据..." } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "管理群組" + "value" : "正在載入資料…" } } } }, - "Manage Tags" : { + "Loading structure..." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "태그 관리" + "value" : "구조 불러오는 중..." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Quản lý nhãn" + "value" : "Đang tải cấu trúc..." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "管理标签" + "value" : "正在加载结构..." } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "管理標籤" + "value" : "正在載入結構…" } } } }, - "Match Case" : { + "Loading..." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "대/소문자 구분" + "value" : "불러오는 중..." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Phân biệt hoa thường" + "value" : "Đang tải..." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "区分大小写" + "value" : "正在加载..." } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "區分大小寫" + "value" : "正在載入…" } } } }, - "Microsoft Entra ID Sign-In Required" : { + "Local Network Access Required" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Microsoft Entra ID 로그인 필요" + "value" : "로컬 네트워크 접근 필요" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Yêu cầu đăng nhập Microsoft Entra ID" + "value" : "Yêu cầu truy cập mạng nội bộ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "需要 Microsoft Entra ID 登录" + "value" : "需要本地网络访问权限" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "需要 Microsoft Entra ID 登入" + "value" : "需要區域網路存取權限" } } } }, - "Mode" : { + "Local Network access is required. Open Settings > Privacy & Security > Local Network and turn TablePro on." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "모드" + "value" : "로컬 네트워크 접근 권한이 필요합니다. 시스템 설정 > 개인정보 보호 및 보안 > 로컬 네트워크를 열고 TablePro를 켜십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chế độ" + "value" : "Cần truy cập Mạng nội bộ. Mở Cài đặt > Quyền riêng tư & Bảo mật > Mạng nội bộ và bật TablePro." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "模式" + "value" : "需要本地网络访问权限。请打开 设置 > 隐私与安全性 > 本地网络,并开启 TablePro。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "模式" + "value" : "需要區域網路存取權限。請開啟「設定」>「隱私權與安全性」>「區域網路」並開啟 TablePro。" } } } }, - "More" : { + "Local Network access is required. Open Settings > Privacy & Security > Local Network and turn TablePro on. If it is already on, restart the device or update to iOS 18.6 or later." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "더 보기" + "value" : "로컬 네트워크 접근 권한이 필요합니다. 설정 > 개인정보 보호 및 보안 > 로컬 네트워크에서 TablePro를 켜십시오. 이미 켜져 있다면 기기를 재시동하거나 iOS 18.6 이상으로 업데이트하십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thêm" + "value" : "Cần quyền truy cập Mạng cục bộ. Mở Cài đặt > Quyền riêng tư & Bảo mật > Mạng cục bộ và bật TablePro. Nếu đã bật, hãy khởi động lại thiết bị hoặc cập nhật lên iOS 18.6 trở lên." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "更多" + "value" : "需要本地网络访问权限。请打开“设置 > 隐私与安全性 > 本地网络”并开启 TablePro。如果已开启,请重启设备或升级到 iOS 18.6 或更高版本。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "更多" + "value" : "需要區域網路存取權。請開啟「設定 > 隱私權與安全性 > 區域網路」並開啟 TablePro。如果已開啟,請重新啟動裝置或更新至 iOS 18.6 或更新版本。" } } } }, - "NULL" : { + "Local Network access may be blocked. Open Settings > Privacy & Security > Local Network and turn TablePro on." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "NULL" + "value" : "로컬 네트워크 접근이 차단되었을 수 있습니다. 시스템 설정 > 개인정보 보호 및 보안 > 로컬 네트워크를 열고 TablePro를 켜십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "NULL" + "value" : "Truy cập Mạng nội bộ có thể bị chặn. Mở Cài đặt > Quyền riêng tư & Bảo mật > Mạng nội bộ và bật TablePro." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "NULL" + "value" : "本地网络访问可能被阻止。请打开 设置 > 隐私与安全性 > 本地网络,并开启 TablePro。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "NULL" + "value" : "區域網路存取可能被封鎖。請開啟「設定」>「隱私權與安全性」>「區域網路」並開啟 TablePro。" } } } }, - "Name" : { + "Locks TablePro when reopened after the selected idle time. Cold launches always require authentication." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이름" + "value" : "선택한 시간 동안 사용하지 않은 후 TablePro를 다시 열면 잠깁니다. 앱을 새로 실행할 때는 항상 인증해야 합니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tên" + "value" : "Khóa TablePro khi mở lại sau khoảng thời gian không hoạt động đã chọn. Khởi động lạnh luôn yêu cầu xác thực." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "名称" + "value" : "在所选闲置时间后重新打开时锁定 TablePro。冷启动始终需要认证。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "名稱" + "value" : "閒置超過所選時間後重新開啟時鎖定 TablePro。冷啟動一律需要驗證。" } } } }, - "Network is unavailable. Changes will sync when connectivity is restored." : { + "Logic" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "네트워크를 사용할 수 없습니다. 연결이 복원되면 변경 사항이 동기화됩니다." + "value" : "로직" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Mạng không khả dụng. Các thay đổi sẽ được đồng bộ khi có kết nối trở lại." + "value" : "Logic" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "网络不可用。恢复连接后将自动同步更改。" + "value" : "逻辑" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "網路無法使用。連線恢復後將自動同步變更。" + "value" : "邏輯" } } } }, - "Never" : { + "Manage Groups" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "안 함" + "value" : "그룹 관리" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không bao giờ" + "value" : "Quản lý nhóm" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "从不" + "value" : "管理分组" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "永不" + "value" : "管理群組" } } } }, - "New Connection" : { + "Manage Tags" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "새 연결" + "value" : "태그 관리" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Kết nối mới" + "value" : "Quản lý nhãn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "新建连接" + "value" : "管理标签" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "新增連線" + "value" : "管理標籤" } } } }, - "New Connections" : { + "Manual" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "새 연결" + "value" : "수동" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Kết nối mới" + "value" : "Thủ công" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "新建连接" + "value" : "手动" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "新增連線" + "value" : "手動" } } } }, - "New Database" : { + "Many Databases, One App" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "새 데이터베이스" + "value" : "하나의 앱, 다양한 데이터베이스" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Cơ sở dữ liệu mới" + "value" : "Nhiều cơ sở dữ liệu, một ứng dụng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "新建数据库" + "value" : "一个应用,多种数据库" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "新增資料庫" + "value" : "多種資料庫,一個 App" } } } }, - "New Group" : { + "Match All Tags" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "새 그룹" + "value" : "모든 태그 일치" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Nhóm mới" + "value" : "Khớp tất cả nhãn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "新建分组" + "value" : "匹配所有标签" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "新增群組" + "value" : "符合所有標籤" } } } }, - "New Tag" : { + "Match Case" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "새 태그" + "value" : "대/소문자 구분" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Nhãn mới" + "value" : "Phân biệt hoa thường" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "新建标签" + "value" : "区分大小写" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "新增標籤" + "value" : "區分大小寫" } } } }, - "No Connections" : { + "Microsoft Entra ID Sign-In Required" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "연결 없음" + "value" : "Microsoft Entra ID 로그인 필요" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chưa có kết nối" + "value" : "Yêu cầu đăng nhập Microsoft Entra ID" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无连接" + "value" : "需要 Microsoft Entra ID 登录" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "沒有連線" + "value" : "需要 Microsoft Entra ID 登入" } } } }, - "No Data" : { + "Mode" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "데이터 없음" + "value" : "모드" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không có dữ liệu" + "value" : "Chế độ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无数据" + "value" : "模式" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "沒有資料" + "value" : "模式" } } } }, - "No Foreign Keys" : { + "More" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "외래 키 없음" + "value" : "더 보기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không có khóa ngoại" + "value" : "Thêm" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无外键" + "value" : "更多" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "沒有外鍵" + "value" : "更多" } } } }, - "No Groups" : { + "More Actions" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "그룹 없음" + "value" : "추가 동작" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chưa có nhóm" + "value" : "Thao tác khác" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无分组" + "value" : "更多操作" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "沒有群組" + "value" : "更多動作" } } } }, - "No History" : { + "Move" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "기록 없음" + "value" : "이동" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chưa có lịch sử" + "value" : "Di chuyển" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无历史记录" + "value" : "移动" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "沒有歷程記錄" + "value" : "移動" } } } }, - "No Indexes" : { + "Move %d Connections" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "인덱스 없음" + "value" : "연결 %d개 이동" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không có chỉ mục" + "value" : "Di chuyển %d kết nối" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无索引" + "value" : "移动 %d 个连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "沒有索引" + "value" : "移動 %d 個連線" } } } }, - "No Matching Connections" : { + "Move Connection" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "일치하는 연결 없음" + "value" : "연결 이동" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không có kết nối phù hợp" + "value" : "Di chuyển kết nối" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无匹配的连接" + "value" : "移动连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "沒有符合的連線" + "value" : "移動連線" } } } }, - "No Microsoft Entra ID access token was supplied." : { + "Move to Group" : { "localizations" : { "ko" : { "stringUnit" : { - "value" : "Microsoft Entra ID 액세스 토큰이 제공되지 않았습니다.", - "state" : "translated" + "state" : "translated", + "value" : "그룹으로 이동" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không có access token Microsoft Entra ID nào được cung cấp." + "value" : "Di chuyển vào nhóm" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未提供 Microsoft Entra ID 访问令牌。" + "value" : "移动到分组" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "未提供 Microsoft Entra ID 存取權杖。" + "value" : "移動至群組" } } } }, - "No Referenced Row" : { + "MySQL, PostgreSQL, SQL Server, Oracle, Redis, SQLite, DuckDB, and more." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "참조된 행 없음" + "value" : "MySQL, PostgreSQL, SQL Server, Oracle, Redis, SQLite, DuckDB 등." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không có dòng tham chiếu" + "value" : "MySQL, PostgreSQL, SQL Server, Oracle, Redis, SQLite, DuckDB và nhiều loại khác." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无引用行" + "value" : "MySQL、PostgreSQL、SQL Server、Oracle、Redis、SQLite、DuckDB 等。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "無參照列" + "value" : "MySQL、PostgreSQL、SQL Server、Oracle、Redis、SQLite、DuckDB 等等。" } } } }, - "No Results" : { + "NULL" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "결과 없음" + "value" : "NULL" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không có kết quả" + "value" : "NULL" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无结果" + "value" : "NULL" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "無結果" + "value" : "NULL" } } } }, - "No Tables" : { + "Name" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "테이블 없음" + "value" : "이름" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không có bảng" + "value" : "Tên" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无表" + "value" : "名称" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "無資料表" + "value" : "名稱" } } } }, - "No Tags" : { + "Network Encryption" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "태그 없음" + "value" : "네트워크 암호화" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chưa có nhãn" + "value" : "Mã hóa mạng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无标签" + "value" : "网络加密" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "無標籤" + "value" : "網路加密" } } } }, - "No active database session." : { + "Network is unavailable. Changes will sync when connectivity is restored." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "활성 데이터베이스 세션이 없습니다." + "value" : "네트워크를 사용할 수 없습니다. 연결이 복원되면 변경 사항이 동기화됩니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không có phiên cơ sở dữ liệu nào đang hoạt động." + "value" : "Mạng không khả dụng. Các thay đổi sẽ được đồng bộ khi có kết nối trở lại." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无活动的数据库会话。" + "value" : "网络不可用。恢复连接后将自动同步更改。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "沒有作用中的資料庫工作階段。" + "value" : "網路無法使用。連線恢復後將自動同步變更。" } } } }, - "No connections match the selected filter." : { + "Never" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "선택한 필터와 일치하는 연결이 없습니다." + "value" : "안 함" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không có kết nối nào khớp với bộ lọc đã chọn." + "value" : "Không bao giờ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "没有连接匹配所选筛选条件。" + "value" : "从不" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "沒有連線符合所選的篩選條件。" + "value" : "永不" } } } }, - "No data was provided to add." : { + "New Connection" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "추가할 데이터가 제공되지 않았습니다." + "value" : "새 연결" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không có dữ liệu nào được cung cấp để thêm." + "value" : "Kết nối mới" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "没有提供要添加的数据。" + "value" : "新建连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "沒有提供要新增的資料。" + "value" : "新增連線" } } } }, - "No primary key values found." : { + "New Connections" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "기본 키 값을 찾을 수 없습니다." + "value" : "새 연결" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không tìm thấy giá trị khóa chính." + "value" : "Kết nối mới" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未找到主键值。" + "value" : "新建连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "找不到主鍵值。" + "value" : "新增連線" } } } }, - "No row found in %@ where %@ = '%@'" : { + "New Database" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%@에서 %@ = '%@'인 행을 찾을 수 없음" + "value" : "새 데이터베이스" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không tìm thấy dòng trong %@ với %@ = '%@'" + "value" : "Cơ sở dữ liệu mới" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在%@中未找到%@ = '%@'的行" + "value" : "新建数据库" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "在 %@ 中找不到 %@ = '%@' 的列" + "value" : "新增資料庫" } } } }, - "None" : { + "New Group" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "없음" + "value" : "새 그룹" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không" + "value" : "Nhóm mới" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无" + "value" : "新建分组" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "無" + "value" : "新增群組" } } } }, - "Normal" : { + "New Subgroup" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "일반" + "value" : "새 하위 그룹" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Bình thường" + "value" : "Nhóm con mới" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正常" + "value" : "新建子分组" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "一般" + "value" : "新增子群組" } } } }, - "Not Connected" : { + "New Tag" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "연결되지 않음" + "value" : "새 태그" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chưa kết nối" + "value" : "Nhãn mới" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未连接" + "value" : "新建标签" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "未連線" + "value" : "新增標籤" } } } }, - "Not connected to SQL Server" : { + "No Connections" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SQL Server에 연결되지 않았습니다" + "value" : "연결 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chưa kết nối tới SQL Server" + "value" : "Chưa có kết nối" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未连接到 SQL Server" + "value" : "无连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "未連線到 SQL Server" + "value" : "沒有連線" } } } }, - "Not connected to database" : { + "No Data" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "데이터베이스에 연결되지 않았습니다" + "value" : "데이터 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chưa kết nối cơ sở dữ liệu" + "value" : "Không có dữ liệu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未连接到数据库" + "value" : "无数据" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "未連線到資料庫" + "value" : "沒有資料" } } } }, - "Not set" : { + "No Foreign Keys" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "설정되지 않음" + "value" : "외래 키 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chưa đặt" + "value" : "Không có khóa ngoại" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未设置" + "value" : "无外键" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "未設定" + "value" : "沒有外鍵" } } } }, - "Nullable" : { + "No Groups" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "NULL 허용" + "value" : "그룹 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Cho phép NULL" + "value" : "Chưa có nhóm" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "可为空" + "value" : "无分组" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "可為空" + "value" : "沒有群組" } } } }, - "OK" : { + "No History" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "확인" + "value" : "기록 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "OK" + "value" : "Chưa có lịch sử" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "好" + "value" : "无历史记录" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "好" + "value" : "沒有歷程記錄" } } } }, - "ON DELETE %@" : { + "No Indexes" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "ON DELETE %@" + "value" : "인덱스 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "ON DELETE %@" + "value" : "Không có chỉ mục" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "ON DELETE %@" + "value" : "无索引" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "ON DELETE %@" + "value" : "沒有索引" } } } }, - "ON UPDATE %@" : { + "No License Information" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "ON UPDATE %@" + "value" : "라이선스 정보 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "ON UPDATE %@" + "value" : "Không có thông tin giấy phép" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "ON UPDATE %@" + "value" : "没有许可证信息" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "ON UPDATE %@" + "value" : "沒有授權資訊" } } } }, - "OR" : { + "No Matching Connections" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "OR" + "value" : "일치하는 연결 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "OR" + "value" : "Không có kết nối phù hợp" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "OR" + "value" : "无匹配的连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "OR" + "value" : "沒有符合的連線" } } } }, - "Off" : { + "No Microsoft Entra ID access token was supplied." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "끔" + "value" : "Microsoft Entra ID 액세스 토큰이 제공되지 않았습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tắt" + "value" : "Không có access token Microsoft Entra ID nào được cung cấp." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "关闭" + "value" : "未提供 Microsoft Entra ID 访问令牌。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "關閉" + "value" : "未提供 Microsoft Entra ID 存取權杖。" } } } }, - "Open Connection" : { + "No Referenced Row" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "연결 열기" + "value" : "참조된 행 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Mở kết nối" + "value" : "Không có dòng tham chiếu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "打开连接" + "value" : "无引用行" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "開啟連線" + "value" : "無參照列" } } } }, - "Open Database File" : { + "No Results" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "데이터베이스 파일 열기" + "value" : "결과 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Mở tệp cơ sở dữ liệu" + "value" : "Không có kết quả" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "打开数据库文件" + "value" : "无结果" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "開啟資料庫檔案" + "value" : "無結果" } } } }, - "Open Settings > Privacy & Security > Local Network and turn TablePro on, then try again." : { + "No Tables" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "시스템 설정 > 개인정보 보호 및 보안 > 로컬 네트워크를 열고 TablePro를 켠 다음 다시 시도하십시오." + "value" : "테이블 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Mở Cài đặt > Quyền riêng tư & Bảo mật > Mạng nội bộ, bật TablePro, sau đó thử lại." + "value" : "Không có bảng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "请打开 设置 > 隐私与安全性 > 本地网络,开启 TablePro,然后重试。" + "value" : "无表" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "請開啟「設定」>「隱私權與安全性」>「區域網路」並開啟 TablePro,然後再試一次。" + "value" : "無資料表" } } } }, - "Opens a database connection in TablePro" : { + "No Tags" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro에서 데이터베이스 연결을 엽니다" + "value" : "태그 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Mở kết nối cơ sở dữ liệu trong TablePro" + "value" : "Chưa có nhãn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "在TablePro中打开数据库连接" + "value" : "无标签" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "在 TablePro 中開啟資料庫連線" + "value" : "無標籤" } } } }, - "Opens table data" : { + "No active database session." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "테이블 데이터를 엽니다" + "value" : "활성 데이터베이스 세션이 없습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Mở dữ liệu bảng" + "value" : "Không có phiên cơ sở dữ liệu nào đang hoạt động." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "打开表数据" + "value" : "无活动的数据库会话。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "開啟資料表資料" + "value" : "沒有作用中的資料庫工作階段。" } } } }, - "Opens this connection" : { + "No connections match the selected filter." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 연결을 엽니다" + "value" : "선택한 필터와 일치하는 연결이 없습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Mở kết nối này" + "value" : "Không có kết nối nào khớp với bộ lọc đã chọn." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "打开此连接" + "value" : "没有连接匹配所选筛选条件。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "開啟此連線" + "value" : "沒有連線符合所選的篩選條件。" } } } }, - "Operator" : { + "No data was provided to add." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "연산자" + "value" : "추가할 데이터가 제공되지 않았습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Toán tử" + "value" : "Không có dữ liệu nào được cung cấp để thêm." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "运算符" + "value" : "没有提供要添加的数据。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "運算子" + "value" : "沒有提供要新增的資料。" } } } }, - "Oracle" : { + "No primary key values found." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle" + "value" : "기본 키 값을 찾을 수 없습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle" + "value" : "Không tìm thấy giá trị khóa chính." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle" + "value" : "未找到主键值。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle" + "value" : "找不到主鍵值。" } } } }, - "Orange" : { + "No row found in %@ where %@ = '%@'" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "주황색" + "value" : "%@에서 %@ = '%@'인 행을 찾을 수 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Cam" + "value" : "Không tìm thấy dòng trong %@ với %@ = '%@'" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "橙色" + "value" : "在%@中未找到%@ = '%@'的行" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "橘色" + "value" : "在 %@ 中找不到 %@ = '%@' 的列" } } } }, - "Order" : { + "None" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "순서" + "value" : "없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thứ tự" + "value" : "Không" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "排序" + "value" : "无" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "排序" + "value" : "無" } } } }, - "Organization" : { + "Normal" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "조직" + "value" : "일반" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tổ chức" + "value" : "Bình thường" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "组织" + "value" : "正常" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "組織" + "value" : "一般" } } } }, - "Page number" : { + "Not Connected" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "페이지 번호" + "value" : "연결되지 않음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Số trang" + "value" : "Chưa kết nối" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "页码" + "value" : "未连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "頁碼" + "value" : "未連線" } } } }, - "Passphrase" : { + "Not Now" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "암호 구문" + "value" : "나중에" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Cụm mật khẩu" + "value" : "Để sau" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "密码短语" + "value" : "以后再说" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "通關密語" + "value" : "稍後再說" } } } }, - "Passphrase (optional)" : { + "Not connected to SQL Server" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "암호 구문(선택 사항)" + "value" : "SQL Server에 연결되지 않았습니다" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Mật khẩu khóa (tùy chọn)" + "value" : "Chưa kết nối tới SQL Server" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "密码短语(可选)" + "value" : "未连接到 SQL Server" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "通關密語(選填)" + "value" : "未連線到 SQL Server" } } } }, - "Passphrases don't match." : { + "Not connected to database" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "암호 구문이 일치하지 않습니다." + "value" : "데이터베이스에 연결되지 않았습니다" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Cụm mật khẩu không khớp." + "value" : "Chưa kết nối cơ sở dữ liệu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "两次输入的密码短语不一致。" + "value" : "未连接到数据库" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "兩次輸入的通關密語不一致。" + "value" : "未連線到資料庫" } } } }, - "Password" : { + "Not set" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "암호" + "value" : "설정되지 않음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Mật khẩu" + "value" : "Chưa đặt" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "密码" + "value" : "未设置" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "密碼" + "value" : "未設定" } } } }, - "Passwords are excluded by default. To include them, set a passphrase. The file is encrypted with it." : { + "Nullable" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "암호는 기본적으로 제외됩니다. 암호를 포함하려면 암호 구문을 설정하십시오. 파일은 설정한 암호 구문으로 암호화됩니다." + "value" : "NULL 허용" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Mặc định mật khẩu không được đưa vào. Để đưa vào, hãy đặt một cụm mật khẩu. Tệp sẽ được mã hóa bằng cụm mật khẩu đó." + "value" : "Cho phép NULL" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "默认不包含密码。若要包含,请设置一个密码短语,文件会用它加密。" + "value" : "可为空" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "預設不包含密碼。若要包含,請設定一組通關密語,檔案會用它加密。" + "value" : "可為空" } } } }, - "Passwords sync through iCloud Keychain, which is end-to-end encrypted. Only affects new saves. Re-save a password to update its sync." : { + "OK" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "암호는 종단 간 암호화된 iCloud 키체인을 통해 동기화됩니다. 새로 저장할 때만 적용됩니다. 암호를 다시 저장하면 해당 암호의 동기화 상태가 업데이트됩니다." + "value" : "확인" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Mật khẩu đồng bộ qua iCloud Keychain, vốn được mã hóa đầu cuối. Chỉ ảnh hưởng đến các lần lưu mới. Hãy lưu lại mật khẩu để cập nhật đồng bộ của nó." + "value" : "OK" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "密码通过端到端加密的 iCloud 钥匙串同步。仅影响新的保存。重新保存密码以更新其同步。" + "value" : "好" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "密碼透過端對端加密的 iCloud 鑰匙圈同步。僅影響新的儲存。重新儲存密碼以更新其同步。" + "value" : "好" } } } }, - "Paste" : { + "ON DELETE %@" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "붙여넣기" + "value" : "ON DELETE %@" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Dán" + "value" : "ON DELETE %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "粘贴" + "value" : "ON DELETE %@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "貼上" + "value" : "ON DELETE %@" } } } }, - "Paste Key" : { + "ON UPDATE %@" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "키 붙여넣기" + "value" : "ON UPDATE %@" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Dán khóa" + "value" : "ON UPDATE %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "粘贴密钥" + "value" : "ON UPDATE %@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "貼上金鑰" + "value" : "ON UPDATE %@" } } } }, - "Paste private key (PEM format)" : { + "OR" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "개인 키 붙여넣기(PEM 형식)" + "value" : "OR" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Dán khóa riêng (định dạng PEM)" + "value" : "OR" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "粘贴私钥(PEM格式)" + "value" : "OR" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "貼上私密金鑰(PEM 格式)" + "value" : "OR" } } } }, - "Paste the whole PEM block, including its BEGIN and END lines." : { + "Off" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "BEGIN 및 END 줄을 포함한 전체 PEM 블록을 붙여넣으십시오." + "value" : "끔" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Dán toàn bộ khối PEM, bao gồm cả dòng BEGIN và END." + "value" : "Tắt" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "粘贴完整的 PEM 块,包括 BEGIN 和 END 行。" + "value" : "关闭" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "貼上完整的 PEM 區塊,包括 BEGIN 和 END 行。" + "value" : "關閉" } } } }, - "Path" : { + "One report a day: hashed device ID, versions, language, database types, first-use dates. Never hostnames, credentials, queries, or data." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "경로" + "value" : "하루 한 번 보고서 전송: 해시된 기기 ID, 버전, 언어, 데이터베이스 유형, 최초 사용 날짜. 호스트 이름, 자격 증명, 쿼리 또는 데이터는 절대 포함되지 않습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đường dẫn" + "value" : "Một báo cáo mỗi ngày: ID thiết bị đã băm, phiên bản, ngôn ngữ, loại cơ sở dữ liệu, ngày sử dụng đầu tiên. Không bao giờ có tên máy chủ, thông tin đăng nhập, truy vấn hay dữ liệu." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "路径" + "value" : "每天一份报告:经过哈希处理的设备 ID、版本、语言、数据库类型、首次使用日期。绝不包含主机名、凭证、查询或数据。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "路徑" + "value" : "每天一份報告:雜湊後的裝置 ID、版本、語言、資料庫類型、首次使用日期。絕不包含主機名稱、憑證、查詢或資料。" } } } }, - "Pink" : { + "Open" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "분홍색" + "value" : "열기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Hồng" + "value" : "Mở" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "粉色" + "value" : "打开" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "粉紅色" + "value" : "開啟" } } } }, - "Port" : { + "Open Connection" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "포트" + "value" : "연결 열기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Cổng" + "value" : "Mở kết nối" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "端口" + "value" : "打开连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "連接埠" + "value" : "開啟連線" } } } }, - "Primary" : { - "extractionState" : "stale", + "Open Database File" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Primary" + "value" : "데이터베이스 파일 열기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chính" + "value" : "Mở tệp cơ sở dữ liệu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "主键" + "value" : "打开数据库文件" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "主鍵" + "value" : "開啟資料庫檔案" } } } }, - "Primary Key" : { + "Open Sample Database" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "기본 키" + "value" : "샘플 데이터베이스 열기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Khóa chính" + "value" : "Mở cơ sở dữ liệu mẫu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "主键" + "value" : "打开示例数据库" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "主鍵" + "value" : "開啟範例資料庫" } } } }, - "Privacy" : { + "Open Settings > Privacy & Security > Local Network and turn TablePro on, then try again." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "개인정보 보호" + "value" : "시스템 설정 > 개인정보 보호 및 보안 > 로컬 네트워크를 열고 TablePro를 켠 다음 다시 시도하십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Quyền riêng tư" + "value" : "Mở Cài đặt > Quyền riêng tư & Bảo mật > Mạng nội bộ, bật TablePro, sau đó thử lại." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "隐私" + "value" : "请打开 设置 > 隐私与安全性 > 本地网络,开启 TablePro,然后重试。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "隱私權" + "value" : "請開啟「設定」>「隱私權與安全性」>「區域網路」並開啟 TablePro,然後再試一次。" } } } }, - "Private Key" : { + "Open a connection or add rows from Shortcuts, Siri, and the Home Screen." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "개인 키" + "value" : "단축어, Siri 및 홈 화면에서 연결을 열거나 행을 추가할 수 있습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Khóa riêng" + "value" : "Mở kết nối hoặc thêm dòng từ Phím tắt, Siri và Màn hình chính." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "私钥" + "value" : "通过“快捷指令”、Siri 和主屏幕打开连接或添加行。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "私密金鑰" + "value" : "透過「捷徑」、Siri 和主畫面開啟連線或新增列。" } } } }, - "Private key" : { + "Open a table, filter its rows, and change values in place." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "개인 키" + "value" : "테이블을 열고, 행을 필터링하고, 그 자리에서 바로 값을 변경할 수 있습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Khóa riêng tư" + "value" : "Mở bảng, lọc các dòng và sửa giá trị ngay tại chỗ." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "私钥" + "value" : "打开表、筛选行,并直接修改值。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "私密金鑰" + "value" : "開啟資料表、篩選其中的列,並直接修改值。" } } } }, - "Provide a JSON object or an array of objects." : { + "Opens a database connection in TablePro" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "JSON 객체 또는 객체 배열을 제공하십시오." + "value" : "TablePro에서 데이터베이스 연결을 엽니다" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Hãy cung cấp một đối tượng JSON hoặc một mảng các đối tượng." + "value" : "Mở kết nối cơ sở dữ liệu trong TablePro" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "请提供一个 JSON 对象或对象数组。" + "value" : "在TablePro中打开数据库连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "請提供一個 JSON 物件或物件陣列。" + "value" : "在 TablePro 中開啟資料庫連線" } } } }, - "Purple" : { + "Opens table data" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "보라색" + "value" : "테이블 데이터를 엽니다" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tím" + "value" : "Mở dữ liệu bảng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "紫色" + "value" : "打开表数据" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "紫色" + "value" : "開啟資料表資料" } } } }, - "Query" : { + "Opens this connection" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "쿼리" + "value" : "이 연결을 엽니다" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Truy vấn" + "value" : "Mở kết nối này" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "查询" + "value" : "打开此连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "查詢" + "value" : "開啟此連線" } } } }, - "Query Error" : { + "Operator" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "쿼리 오류" + "value" : "연산자" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Lỗi truy vấn" + "value" : "Toán tử" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "查询错误" + "value" : "运算符" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "查詢錯誤" + "value" : "運算子" } } } }, - "Query History" : { - "extractionState" : "stale", + "Oracle" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "쿼리 기록" + "value" : "Oracle" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Lịch sử truy vấn" + "value" : "Oracle" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "查询历史" + "value" : "Oracle" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "查詢記錄" + "value" : "Oracle" } } } }, - "Query execution failed" : { + "Orange" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "쿼리 실행 실패" + "value" : "주황색" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thực thi truy vấn thất bại" + "value" : "Cam" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "查询执行失败" + "value" : "橙色" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "查詢執行失敗" + "value" : "橘色" } } } }, - "Query failed: %@" : { + "Order" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "쿼리 실패: %@" + "value" : "순서" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Truy vấn thất bại: %@" + "value" : "Thứ tự" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "查询失败:%@" + "value" : "排序" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "查詢失敗:%@" + "value" : "排序" } } } }, - "Query text and results will be cleared." : { + "Organization" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "쿼리 텍스트와 결과가 지워집니다." + "value" : "조직" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Nội dung truy vấn và kết quả sẽ bị xóa." + "value" : "Tổ chức" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "查询文本和结果将被清除。" + "value" : "组织" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "查詢文字與結果將被清除。" + "value" : "組織" } } } }, - "Query was cancelled" : { + "PING failed: %@" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "쿼리가 취소되었습니다" + "value" : "PING 실패: %@" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Truy vấn đã bị hủy" + "value" : "PING thất bại: %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "查询已取消" + "value" : "PING 失败:%@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "查詢已取消" + "value" : "PING 失敗:%@" } } } }, - "Quick Connect" : { + "Page number" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "빠른 연결" + "value" : "페이지 번호" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Kết nối nhanh" + "value" : "Số trang" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "快速连接" + "value" : "页码" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "快速連線" + "value" : "頁碼" } } } }, - "Quickly connect to your databases." : { + "Parent Group" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "데이터베이스에 빠르게 연결합니다." + "value" : "상위 그룹" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Kết nối nhanh tới các cơ sở dữ liệu của bạn." + "value" : "Nhóm cha" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "快速连接到你的数据库。" + "value" : "父分组" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "快速連線到你的資料庫。" + "value" : "上層群組" } } } }, - "Read-Only" : { + "Passphrase" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "읽기 전용" + "value" : "암호 구문" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chỉ đọc" + "value" : "Cụm mật khẩu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "只读" + "value" : "密码短语" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "唯讀" + "value" : "通關密語" } } } }, - "Reconnecting..." : { + "Passphrase (optional)" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "다시 연결 중..." + "value" : "암호 구문(선택 사항)" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đang kết nối lại..." + "value" : "Mật khẩu khóa (tùy chọn)" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在重新连接..." + "value" : "密码短语(可选)" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "正在重新連線…" + "value" : "通關密語(選填)" } } } }, - "Red" : { + "Passphrases don't match." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "빨간색" + "value" : "암호 구문이 일치하지 않습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đỏ" + "value" : "Cụm mật khẩu không khớp." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "红色" + "value" : "两次输入的密码短语不一致。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "紅色" + "value" : "兩次輸入的通關密語不一致。" } } } }, - "Redis authentication failed: %1$@ %2$@" : { + "Password" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Redis 인증 실패: %1$@ %2$@" + "value" : "암호" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Xác thực Redis thất bại: %1$@ %2$@" + "value" : "Mật khẩu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Redis 认证失败:%1$@ %2$@" + "value" : "密码" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "Redis 驗證失敗:%1$@ %2$@" + "value" : "密碼" } } } }, - "Redis authentication failed: %@" : { + "Passwords are excluded by default. To include them, set a passphrase. The file is encrypted with it." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Redis 인증 실패: %@" + "value" : "암호는 기본적으로 제외됩니다. 암호를 포함하려면 암호 구문을 설정하십시오. 파일은 설정한 암호 구문으로 암호화됩니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Xác thực Redis thất bại: %@" + "value" : "Mặc định mật khẩu không được đưa vào. Để đưa vào, hãy đặt một cụm mật khẩu. Tệp sẽ được mã hóa bằng cụm mật khẩu đó." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Redis 认证失败:%@" + "value" : "默认不包含密码。若要包含,请设置一个密码短语,文件会用它加密。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "Redis 驗證失敗:%@" + "value" : "預設不包含密碼。若要包含,請設定一組通關密語,檔案會用它加密。" } } } }, - "Refresh from iCloud" : { + "Passwords stay in your Keychain. Connect through an SSH tunnel or over SSL." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud에서 새로 고침" + "value" : "암호는 키체인에 보관됩니다. SSH 터널 또는 SSL을 통해 연결할 수 있습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Làm mới từ iCloud" + "value" : "Mật khẩu được giữ trong Chuỗi khóa. Kết nối qua đường hầm SSH hoặc qua SSL." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "从 iCloud 刷新" + "value" : "密码保存在你的钥匙串中。可通过 SSH 隧道或 SSL 连接。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "從 iCloud 重新整理" + "value" : "密碼保存在你的鑰匙圈中。可透過 SSH 隧道或 SSL 連線。" } } } }, - "Refresh from iCloud?" : { + "Passwords stay on this device unless you turn on Sync Passwords in Settings. You can change this at any time in Settings." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud에서 새로 고치시겠습니까?" + "value" : "설정에서 암호 동기화를 켜지 않는 한 암호는 이 기기에만 저장됩니다. 설정에서 언제든지 변경할 수 있습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Làm mới từ iCloud?" + "value" : "Mật khẩu chỉ ở trên thiết bị này trừ khi bạn bật Đồng bộ mật khẩu trong Cài đặt. Bạn có thể thay đổi điều này bất cứ lúc nào trong Cài đặt." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "从 iCloud 刷新?" + "value" : "除非你在设置中开启“同步密码”,否则密码只保留在此设备上。你可以随时在设置中更改此选项。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "從 iCloud 重新整理?" + "value" : "除非你在「設定」中開啟「同步密碼」,否則密碼只會保留在此裝置上。你隨時可以在「設定」中更改此選項。" } } } }, - "Reload" : { + "Passwords sync through iCloud Keychain, which is end-to-end encrypted. Only affects new saves. Re-save a password to update its sync." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "다시 로드" + "value" : "암호는 종단 간 암호화된 iCloud 키체인을 통해 동기화됩니다. 새로 저장할 때만 적용됩니다. 암호를 다시 저장하면 해당 암호의 동기화 상태가 업데이트됩니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tải lại" + "value" : "Mật khẩu đồng bộ qua iCloud Keychain, vốn được mã hóa đầu cuối. Chỉ ảnh hưởng đến các lần lưu mới. Hãy lưu lại mật khẩu để cập nhật đồng bộ của nó." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "重新加载" + "value" : "密码通过端到端加密的 iCloud 钥匙串同步。仅影响新的保存。重新保存密码以更新其同步。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "重新載入" + "value" : "密碼透過端對端加密的 iCloud 鑰匙圈同步。僅影響新的儲存。重新儲存密碼以更新其同步。" } } } }, - "Remove" : { + "Passwords sync through iCloud Keychain, which is end-to-end encrypted. Turning it on affects new saves only, so re-save a password to sync it." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "제거" + "value" : "암호는 종단 간 암호화된 iCloud 키체인을 통해 동기화됩니다. 켜면 새로 저장하는 암호에만 적용되므로, 기존 암호를 동기화하려면 다시 저장하십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Gỡ" + "value" : "Mật khẩu được đồng bộ qua Chuỗi khóa iCloud, vốn được mã hóa đầu cuối. Việc bật chỉ ảnh hưởng đến các lần lưu mới, vì vậy hãy lưu lại mật khẩu để đồng bộ nó." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "移除" + "value" : "密码通过端到端加密的 iCloud 钥匙串同步。开启后仅影响新的保存,因此请重新保存密码以同步它。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "移除" + "value" : "密碼透過端對端加密的 iCloud 鑰匙圈同步。開啟後只會影響新的儲存,因此請重新儲存密碼以同步它。" } } } }, - "Replace" : { + "Paste" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "바꾸기" + "value" : "붙여넣기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thay thế" + "value" : "Dán" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "替换" + "value" : "粘贴" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "取代" + "value" : "貼上" } } } }, - "Require Face ID" : { + "Paste Key" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Face ID 요구" + "value" : "키 붙여넣기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Yêu cầu Face ID" + "value" : "Dán khóa" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "需要 Face ID" + "value" : "粘贴密钥" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "需要 Face ID" + "value" : "貼上金鑰" } } } }, - "Require Optic ID" : { + "Paste private key (PEM format)" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Optic ID 요구" + "value" : "개인 키 붙여넣기(PEM 형식)" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Yêu cầu Optic ID" + "value" : "Dán khóa riêng (định dạng PEM)" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "需要 Optic ID" + "value" : "粘贴私钥(PEM格式)" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "需要 Optic ID" + "value" : "貼上私密金鑰(PEM 格式)" } } } }, - "Require Touch ID" : { + "Paste the whole PEM block, including its BEGIN and END lines." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Touch ID 요구" + "value" : "BEGIN 및 END 줄을 포함한 전체 PEM 블록을 붙여넣으십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Yêu cầu Touch ID" + "value" : "Dán toàn bộ khối PEM, bao gồm cả dòng BEGIN và END." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "需要 Touch ID" + "value" : "粘贴完整的 PEM 块,包括 BEGIN 和 END 行。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "需要 Touch ID" + "value" : "貼上完整的 PEM 區塊,包括 BEGIN 和 END 行。" } } } }, - "Required" : { + "Path" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "필수" + "value" : "경로" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Bắt buộc" + "value" : "Đường dẫn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "必填" + "value" : "路径" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "必填" + "value" : "路徑" } } } }, - "Results Cleared" : { + "Pink" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "결과가 지워짐" + "value" : "분홍색" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đã xoá kết quả" + "value" : "Hồng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "结果已清除" + "value" : "粉色" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "結果已清除" + "value" : "粉紅色" } } } }, - "Results cleared due to memory pressure." : { - "extractionState" : "stale", + "Port" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "메모리 부족으로 결과가 지워졌습니다." + "value" : "포트" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Kết quả đã bị xoá do thiếu bộ nhớ." + "value" : "Cổng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "因内存不足,结果已清除。" + "value" : "端口" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "因記憶體不足,結果已清除。" + "value" : "連接埠" } } } }, - "Results trimmed due to memory pressure." : { + "Primary" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "메모리 부족으로 결과 일부가 삭제되었습니다." + "value" : "Primary" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đã cắt bớt kết quả do áp lực bộ nhớ." + "value" : "Chính" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "由于内存压力,结果已被裁剪。" + "value" : "主键" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "因記憶體不足,結果已縮減。" + "value" : "主鍵" } } } }, - "Retry" : { + "Primary Key" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "재시도" + "value" : "기본 키" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thử lại" + "value" : "Khóa chính" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "重试" + "value" : "主键" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "重試" + "value" : "主鍵" } } } }, - "Role" : { + "Privacy" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "역할" + "value" : "개인정보 보호" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Vai trò" + "value" : "Quyền riêng tư" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "角色" + "value" : "隐私" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "角色" + "value" : "隱私權" } } } }, - "Row %d of %d" : { + "Privacy Policy" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%d/%d행" + "value" : "개인정보 처리방침" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Dòng %d / %d" + "value" : "Chính sách quyền riêng tư" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "第%d行 / 共%d行" + "value" : "隐私政策" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "第 %d 列 / 共 %d 列" + "value" : "隱私權政策" } } } }, - "Row (JSON or CSV)" : { + "Private Key" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "행(JSON 또는 CSV)" + "value" : "개인 키" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Dòng (JSON hoặc CSV)" + "value" : "Khóa riêng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "行(JSON 或 CSV)" + "value" : "私钥" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "列(JSON 或 CSV)" + "value" : "私密金鑰" } } } }, - "Row updated" : { + "Private key" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "행 업데이트됨" + "value" : "개인 키" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đã cập nhật dòng" + "value" : "Khóa riêng tư" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "行已更新" + "value" : "私钥" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "列已更新" + "value" : "私密金鑰" } } } }, - "Rows (JSON or CSV)" : { + "Provide a JSON object or an array of objects." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "여러 행(JSON 또는 CSV)" + "value" : "JSON 객체 또는 객체 배열을 제공하십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Các dòng (JSON hoặc CSV)" + "value" : "Hãy cung cấp một đối tượng JSON hoặc một mảng các đối tượng." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "多行(JSON 或 CSV)" + "value" : "请提供一个 JSON 对象或对象数组。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "多列(JSON 或 CSV)" + "value" : "請提供一個 JSON 物件或物件陣列。" } } } }, - "Rows per Page" : { + "Purple" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "페이지당 행 수" + "value" : "보라색" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Số dòng mỗi trang" + "value" : "Tím" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "每页行数" + "value" : "紫色" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "每頁列數" + "value" : "紫色" } } } }, - "Run" : { + "Queries on the Lock Screen" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "실행" + "value" : "잠금 화면에서 쿼리 확인" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chạy" + "value" : "Truy vấn trên Màn hình khóa" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "运行" + "value" : "锁定屏幕上的查询" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "執行" + "value" : "鎖定畫面上的查詢" } } } }, - "Run a Query" : { + "Query" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "쿼리 실행" + "value" : "쿼리" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chạy truy vấn" + "value" : "Truy vấn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "运行查询" + "value" : "查询" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "執行查詢" + "value" : "查詢" } } } }, - "Running query" : { - "extractionState" : "manual", + "Query Error" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "쿼리 실행 중" + "value" : "쿼리 오류" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đang chạy truy vấn" + "value" : "Lỗi truy vấn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在运行查询" + "value" : "查询错误" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "正在執行查詢" + "value" : "查詢錯誤" } } } }, - "SELECT * FROM ..." : { + "Query History" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SELECT * FROM ..." + "value" : "쿼리 기록" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "SELECT * FROM ..." + "value" : "Lịch sử truy vấn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SELECT * FROM ..." + "value" : "查询历史" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "SELECT * FROM ..." + "value" : "查詢記錄" } } } }, - "SID" : { + "Query execution failed" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SID" + "value" : "쿼리 실행 실패" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "SID" + "value" : "Thực thi truy vấn thất bại" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SID" + "value" : "查询执行失败" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "SID" + "value" : "查詢執行失敗" } } } }, - "SID Not Found" : { + "Query failed: %@" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SID를 찾을 수 없음" + "value" : "쿼리 실패: %@" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không tìm thấy SID" + "value" : "Truy vấn thất bại: %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未找到 SID" + "value" : "查询失败:%@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "找不到 SID" + "value" : "查詢失敗:%@" } } } }, - "SQL" : { + "Query text and results will be cleared." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SQL" + "value" : "쿼리 텍스트와 결과가 지워집니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "SQL" + "value" : "Nội dung truy vấn và kết quả sẽ bị xóa." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SQL" + "value" : "查询文本和结果将被清除。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "SQL" + "value" : "查詢文字與結果將被清除。" } } } }, - "SSH Host" : { + "Query was cancelled" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 호스트" + "value" : "쿼리가 취소되었습니다" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Máy chủ SSH" + "value" : "Truy vấn đã bị hủy" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SSH主机" + "value" : "查询已取消" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 主機" + "value" : "查詢已取消" } } } }, - "SSH Host Key Changed" : { + "Quick Connect" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 호스트 키 변경됨" + "value" : "빠른 연결" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Khóa máy chủ SSH đã thay đổi" + "value" : "Kết nối nhanh" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 主机密钥已更改" + "value" : "快速连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 主機金鑰已變更" + "value" : "快速連線" } } } }, - "SSH Password" : { + "Quickly connect to your databases." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 암호" + "value" : "데이터베이스에 빠르게 연결합니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Mật khẩu SSH" + "value" : "Kết nối nhanh tới các cơ sở dữ liệu của bạn." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SSH密码" + "value" : "快速连接到你的数据库。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 密碼" + "value" : "快速連線到你的資料庫。" } } } }, - "SSH Port" : { + "Read-Only" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 포트" + "value" : "읽기 전용" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Cổng SSH" + "value" : "Chỉ đọc" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SSH端口" + "value" : "只读" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 連接埠" + "value" : "唯讀" } } } }, - "SSH Server" : { + "Recent" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 서버" + "value" : "최근" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Máy chủ SSH" + "value" : "Gần đây" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SSH服务器" + "value" : "最近" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 伺服器" + "value" : "最近" } } } }, - "SSH Tunnel" : { + "Reconnecting..." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 터널" + "value" : "다시 연결 중..." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đường hầm SSH" + "value" : "Đang kết nối lại..." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SSH隧道" + "value" : "正在重新连接..." } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 隧道" + "value" : "正在重新連線…" } } } }, - "SSH Tunnel Failed" : { + "Red" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 터널 실패" + "value" : "빨간색" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đường hầm SSH thất bại" + "value" : "Đỏ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SSH隧道失败" + "value" : "红色" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 隧道失敗" + "value" : "紅色" } } } }, - "SSH Username" : { + "Redis authentication failed: %1$@ %2$@" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 사용자 이름" + "value" : "Redis 인증 실패: %1$@ %2$@" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tên đăng nhập SSH" + "value" : "Xác thực Redis thất bại: %1$@ %2$@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SSH用户名" + "value" : "Redis 认证失败:%1$@ %2$@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 使用者名稱" + "value" : "Redis 驗證失敗:%1$@ %2$@" } } } }, - "SSH private key not found: %@" : { + "Redis authentication failed: %@" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 개인 키를 찾을 수 없습니다: %@" + "value" : "Redis 인증 실패: %@" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không tìm thấy private key SSH: %@" + "value" : "Xác thực Redis thất bại: %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未找到 SSH 私钥:%@" + "value" : "Redis 认证失败:%@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "找不到 SSH 私密金鑰:%@" + "value" : "Redis 驗證失敗:%@" } } } }, - "SSL" : { + "Redis connection failed" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SSL" + "value" : "Redis 연결 실패" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "SSL" + "value" : "Kết nối Redis thất bại" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SSL" + "value" : "Redis 连接失败" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "SSL" + "value" : "Redis 連線失敗" } } } }, - "SSL Mode" : { + "Refresh from iCloud" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SSL 모드" + "value" : "iCloud에서 새로 고침" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chế độ SSL" + "value" : "Làm mới từ iCloud" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SSL 模式" + "value" : "从 iCloud 刷新" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "SSL 模式" + "value" : "從 iCloud 重新整理" } } } }, - "Safe Mode" : { + "Refresh from iCloud?" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "안전 모드" + "value" : "iCloud에서 새로 고치시겠습니까?" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chế độ an toàn" + "value" : "Làm mới từ iCloud?" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "安全模式" + "value" : "从 iCloud 刷新?" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "安全模式" + "value" : "從 iCloud 重新整理?" } } } }, - "Save" : { + "Rejected" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "저장" + "value" : "거부" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Lưu" + "value" : "Từ chối" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "保存" + "value" : "拒绝" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "儲存" + "value" : "拒絕" } } } }, - "Save Changes?" : { + "Reload" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "변경 사항을 저장하시겠습니까?" + "value" : "다시 로드" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Lưu thay đổi?" + "value" : "Tải lại" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "保存更改?" + "value" : "重新加载" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "儲存變更?" + "value" : "重新載入" } } } }, - "Schema" : { + "Remove" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "스키마" + "value" : "제거" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Schema" + "value" : "Gỡ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "模式" + "value" : "移除" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "綱要" + "value" : "移除" } } } }, - "Schemas" : { + "Remove from Favorites" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "스키마" + "value" : "즐겨찾기에서 제거" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Schemas" + "value" : "Gỡ khỏi Yêu thích" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "模式" + "value" : "从收藏中移除" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "綱要" + "value" : "從我的最愛中移除" } } } }, - "Search all columns" : { + "Remove from Recent" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "모든 열 검색" + "value" : "최근 항목에서 제거" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tìm kiếm tất cả cột" + "value" : "Xóa khỏi mục Gần đây" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "搜索所有列" + "value" : "从“最近”中移除" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "搜尋所有欄位" + "value" : "從「最近」中移除" } } } }, - "Search tables" : { + "Replace" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "테이블 검색" + "value" : "바꾸기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tìm bảng" + "value" : "Thay thế" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "搜索表" + "value" : "替换" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "搜尋資料表" + "value" : "取代" } } } }, - "Second value" : { + "Requested" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "두 번째 값" + "value" : "요청" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Giá trị thứ hai" + "value" : "Đề nghị" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "第二个值" + "value" : "请求" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "第二個值" + "value" : "要求" } } } }, - "Section" : { + "Require Face ID" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "섹션" + "value" : "Face ID 요구" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Phần" + "value" : "Yêu cầu Face ID" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "分区" + "value" : "需要 Face ID" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "區段" + "value" : "需要 Face ID" } } } }, - "Security" : { + "Require Optic ID" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "보안" + "value" : "Optic ID 요구" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Bảo mật" + "value" : "Yêu cầu Optic ID" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "安全" + "value" : "需要 Optic ID" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "安全性" + "value" : "需要 Optic ID" } } } }, - "Select Private Key" : { + "Require Touch ID" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "개인 키 선택" + "value" : "Touch ID 요구" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chọn khóa riêng" + "value" : "Yêu cầu Touch ID" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "选择私钥" + "value" : "需要 Touch ID" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "選擇私密金鑰" + "value" : "需要 Touch ID" } } } }, - "Select a Connection" : { + "Required" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "연결 선택" + "value" : "필수" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chọn kết nối" + "value" : "Bắt buộc" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "选择连接" + "value" : "必填" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "選擇連線" + "value" : "必填" } } } }, - "Server" : { + "Reset" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "서버" + "value" : "재설정" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Máy chủ" + "value" : "Đặt lại" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "服务器" + "value" : "重置" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "伺服器" + "value" : "重設" } } } }, - "Service Name" : { + "Reset Database" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "서비스 이름" + "value" : "데이터베이스 재설정" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tên dịch vụ" + "value" : "Đặt lại cơ sở dữ liệu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "服务名称" + "value" : "重置数据库" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "服務名稱" + "value" : "重設資料庫" } } } }, - "Service Name Not Found" : { + "Reset Sample Database?" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "서비스 이름을 찾을 수 없음" + "value" : "샘플 데이터베이스를 재설정하시겠습니까?" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không tìm thấy tên dịch vụ" + "value" : "Đặt lại cơ sở dữ liệu mẫu?" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未找到服务名" + "value" : "要重置示例数据库吗?" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "找不到服務名稱" + "value" : "要重設範例資料庫嗎?" } } } }, - "Set up a new database connection" : { + "Results Cleared" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "새 데이터베이스 연결 설정" + "value" : "결과가 지워짐" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thiết lập kết nối cơ sở dữ liệu mới" + "value" : "Đã xoá kết quả" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "设置新的数据库连接" + "value" : "结果已清除" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "設定新的資料庫連線" + "value" : "結果已清除" } } } }, - "Settings" : { + "Results cleared due to memory pressure." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "설정" + "value" : "메모리 부족으로 결과가 지워졌습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Cài đặt" + "value" : "Kết quả đã bị xoá do thiếu bộ nhớ." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "设置" + "value" : "因内存不足,结果已清除。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "設定" + "value" : "因記憶體不足,結果已清除。" } } } }, - "Share" : { + "Results trimmed due to memory pressure." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "공유" + "value" : "메모리 부족으로 결과 일부가 삭제되었습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chia sẻ" + "value" : "Đã cắt bớt kết quả do áp lực bộ nhớ." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "共享" + "value" : "由于内存压力,结果已被裁剪。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "分享" + "value" : "因記憶體不足,結果已縮減。" } } } }, - "Share Results" : { + "Retry" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "결과 공유" + "value" : "재시도" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chia sẻ kết quả" + "value" : "Thử lại" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "共享结果" + "value" : "重试" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "分享結果" + "value" : "重試" } } } }, - "Share Row" : { + "Role" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "행 공유" + "value" : "역할" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chia sẻ dòng" + "value" : "Vai trò" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "共享行" + "value" : "角色" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "分享列" + "value" : "角色" } } } }, - "Share anonymous usage data" : { + "Row %d of %d" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "익명 사용 데이터 공유" + "value" : "%d/%d행" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chia sẻ dữ liệu sử dụng ẩn danh" + "value" : "Dòng %d / %d" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "共享匿名使用数据" + "value" : "第%d行 / 共%d行" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "分享匿名使用資料" + "value" : "第 %d 列 / 共 %d 列" } } } }, - "Showing the first %d rows. Add LIMIT to fetch more." : { + "Row (JSON or CSV)" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "처음 %d개 행을 표시합니다. 더 가져오려면 LIMIT를 추가하십시오." + "value" : "행(JSON 또는 CSV)" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đang hiển thị %d dòng đầu tiên. Thêm LIMIT để lấy thêm." + "value" : "Dòng (JSON hoặc CSV)" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在显示前 %d 行。添加 LIMIT 以获取更多。" + "value" : "行(JSON 或 CSV)" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "正在顯示前 %d 列。加入 LIMIT 以取得更多。" + "value" : "列(JSON 或 CSV)" } } } }, - "Sign In" : { + "Row updated" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "로그인" + "value" : "행 업데이트됨" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đăng nhập" + "value" : "Đã cập nhật dòng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "登录" + "value" : "行已更新" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "登入" + "value" : "列已更新" } } } }, - "Sign in to Microsoft Entra ID with your browser?" : { + "Rows (JSON or CSV)" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "브라우저에서 Microsoft Entra ID에 로그인하시겠습니까?" + "value" : "여러 행(JSON 또는 CSV)" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đăng nhập Microsoft Entra ID bằng trình duyệt?" + "value" : "Các dòng (JSON hoặc CSV)" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "要用浏览器登录 Microsoft Entra ID 吗?" + "value" : "多行(JSON 或 CSV)" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "要用瀏覽器登入 Microsoft Entra ID 嗎?" + "value" : "多列(JSON 或 CSV)" } } } }, - "Skip" : { + "Rows per Page" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "건너뛰기" + "value" : "페이지당 행 수" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Bỏ qua" + "value" : "Số dòng mỗi trang" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "跳过" + "value" : "每页行数" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "略過" + "value" : "每頁列數" } } } }, - "Some credentials could not be saved to the keychain. You may need to re-enter them later." : { + "Run" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "일부 자격 증명을 키체인에 저장할 수 없습니다. 나중에 다시 입력해야 할 수 있습니다." + "value" : "실행" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không thể lưu một số thông tin xác thực vào keychain. Bạn có thể cần nhập lại sau." + "value" : "Chạy" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "部分凭证无法保存到钥匙串,稍后可能需要重新输入。" + "value" : "运行" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "部分憑證無法儲存至鑰匙圈,稍後可能需要重新輸入。" + "value" : "執行" } } } }, - "Sort" : { + "Run a Query" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "정렬" + "value" : "쿼리 실행" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Sắp xếp" + "value" : "Chạy truy vấn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "排序" + "value" : "运行查询" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "排序" + "value" : "執行查詢" } } } }, - "Sort By" : { + "Run queries and find the ones you ran before in History." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "정렬 기준" + "value" : "쿼리를 실행하고, 이전에 실행한 쿼리를 기록에서 찾을 수 있습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Sắp xếp theo" + "value" : "Chạy truy vấn và tìm lại những truy vấn đã chạy trong Lịch sử." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "排序方式" + "value" : "运行查询,并在“历史”中找到之前运行过的查询。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "排序依據" + "value" : "執行查詢,並在「歷程記錄」中找到你之前執行過的查詢。" } } } }, - "Stats" : { + "Running" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "통계" + "value" : "실행 중" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thống kê" + "value" : "Đang chạy" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "统计" + "value" : "正在运行" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "統計" + "value" : "執行中" } } } }, - "Status" : { + "Running query" : { + "extractionState" : "manual", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "상태" + "value" : "쿼리 실행 중" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Trạng thái" + "value" : "Đang chạy truy vấn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "状态" + "value" : "正在运行查询" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "狀態" + "value" : "正在執行查詢" } } } }, - "Stop" : { + "SELECT * FROM ..." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "중지" + "value" : "SELECT * FROM ..." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Dừng" + "value" : "SELECT * FROM ..." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "停止" + "value" : "SELECT * FROM ..." } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "停止" + "value" : "SELECT * FROM ..." } } } }, - "Stopped at %d rows to stay within memory limits. Add LIMIT to fetch fewer." : { + "SID" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "메모리 한도를 넘지 않도록 %d개 행에서 중지했습니다. 더 적게 가져오려면 LIMIT를 추가하십시오." + "value" : "SID" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đã dừng ở %d dòng để giữ trong giới hạn bộ nhớ. Thêm LIMIT để lấy ít hơn." + "value" : "SID" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已在 %d 行处停止以保持在内存限制内。添加 LIMIT 以获取更少。" + "value" : "SID" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "已在 %d 列處停止以維持在記憶體限制內。加入 LIMIT 以取得更少。" + "value" : "SID" } } } }, - "Stopped. Showing %d rows." : { + "SID Not Found" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "중지됨. %d개 행을 표시합니다." + "value" : "SID를 찾을 수 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đã dừng. Đang hiển thị %d dòng." + "value" : "Không tìm thấy SID" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "已停止。正在显示 %d 行。" + "value" : "未找到 SID" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "已停止。正在顯示 %d 列。" + "value" : "找不到 SID" } } } }, - "Switching..." : { - "extractionState" : "stale", + "SQL" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "전환 중..." + "value" : "SQL" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đang chuyển..." + "value" : "SQL" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在切换..." + "value" : "SQL" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "正在切換…" + "value" : "SQL" } } } }, - "Sync" : { + "SSH Host" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "동기화" + "value" : "SSH 호스트" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đồng bộ" + "value" : "Máy chủ SSH" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "同步" + "value" : "SSH主机" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "同步" + "value" : "SSH 主機" } } } }, - "Sync Now" : { + "SSH Host Key Changed" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "지금 동기화" + "value" : "SSH 호스트 키 변경됨" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đồng bộ ngay" + "value" : "Khóa máy chủ SSH đã thay đổi" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "立即同步" + "value" : "SSH 主机密钥已更改" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "立即同步" + "value" : "SSH 主機金鑰已變更" } } } }, - "Sync Passwords" : { + "SSH Password" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "암호 동기화" + "value" : "SSH 암호" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đồng bộ mật khẩu" + "value" : "Mật khẩu SSH" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "同步密码" + "value" : "SSH密码" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "同步密碼" + "value" : "SSH 密碼" } } } }, - "Sync from iCloud" : { + "SSH Port" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud에서 동기화" + "value" : "SSH 포트" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đồng bộ từ iCloud" + "value" : "Cổng SSH" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "从iCloud同步" + "value" : "SSH端口" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "從 iCloud 同步" + "value" : "SSH 連接埠" } } } }, - "Sync token expired. A full sync will be performed." : { + "SSH Server" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "동기화 토큰이 만료되었습니다. 전체 동기화를 수행합니다." + "value" : "SSH 서버" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Token đồng bộ đã hết hạn. Sẽ thực hiện đồng bộ toàn bộ." + "value" : "Máy chủ SSH" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "同步令牌已过期。将执行一次完整同步。" + "value" : "SSH服务器" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "同步權杖已過期。將執行一次完整同步。" + "value" : "SSH 伺服器" } } } }, - "Sync with iCloud" : { + "SSH Tunnel" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud와 동기화" + "value" : "SSH 터널" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đồng bộ với iCloud" + "value" : "Đường hầm SSH" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "与 iCloud 同步" + "value" : "SSH隧道" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "與 iCloud 同步" + "value" : "SSH 隧道" } } } }, - "Sync zone not found. A full sync will be performed." : { + "SSH Tunnel Failed" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "동기화 영역을 찾을 수 없습니다. 전체 동기화를 수행합니다." + "value" : "SSH 터널 실패" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không tìm thấy vùng đồng bộ. Sẽ thực hiện đồng bộ toàn bộ." + "value" : "Đường hầm SSH thất bại" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未找到同步区域。将执行完整同步。" + "value" : "SSH隧道失败" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "找不到同步區域。將執行完整同步。" + "value" : "SSH 隧道失敗" } } } }, - "Syncing from iCloud..." : { + "SSH Username" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "iCloud에서 동기화 중..." + "value" : "SSH 사용자 이름" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đang đồng bộ từ iCloud..." + "value" : "Tên đăng nhập SSH" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在从iCloud同步..." + "value" : "SSH用户名" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "正在從 iCloud 同步…" + "value" : "SSH 使用者名稱" } } } }, - "Syncing…" : { + "SSH private key not found: %@" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "동기화 중…" + "value" : "SSH 개인 키를 찾을 수 없습니다: %@" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đang đồng bộ…" + "value" : "Không tìm thấy private key SSH: %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在同步…" + "value" : "未找到 SSH 私钥:%@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "正在同步…" + "value" : "找不到 SSH 私密金鑰:%@" } } } }, - "TLS handshake failed: %@" : { + "SSL" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "TLS 핸드셰이크 실패: %@" + "value" : "SSL" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Bắt tay TLS thất bại: %@" + "value" : "SSL" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "TLS 握手失败:%@" + "value" : "SSL" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "TLS 交握失敗:%@" + "value" : "SSL" } } } }, - "Tab" : { - "extractionState" : "stale", + "SSL Mode" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "탭" + "value" : "SSL 모드" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tab" + "value" : "Chế độ SSL" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "标签页" + "value" : "SSL 模式" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "分頁" + "value" : "SSL 模式" } } } }, - "Table" : { + "Safe Mode" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "테이블" + "value" : "안전 모드" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Bảng" + "value" : "Chế độ an toàn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "表" + "value" : "安全模式" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "資料表" + "value" : "安全模式" } } } }, - "Table Structure" : { + "Sample Database" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "테이블 구조" + "value" : "샘플 데이터베이스" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Cấu trúc bảng" + "value" : "Cơ sở dữ liệu mẫu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "表结构" + "value" : "示例数据库" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "資料表結構" + "value" : "範例資料庫" } } } }, - "TablePro" : { + "Sample Database Unavailable" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro" + "value" : "샘플 데이터베이스를 사용할 수 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro" + "value" : "Cơ sở dữ liệu mẫu không khả dụng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro" + "value" : "示例数据库不可用" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro" + "value" : "範例資料庫無法使用" } } } }, - "TablePro could not read that file." : { + "Save" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro가 해당 파일을 읽을 수 없습니다." + "value" : "저장" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro không đọc được tệp đó." + "value" : "Lưu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro 无法读取该文件。" + "value" : "保存" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro 無法讀取該檔案。" + "value" : "儲存" } } } }, - "TablePro doesn't support “%@” connections" : { - "extractionState" : "manual", + "Save Changes?" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro는 “%@” 연결을 지원하지 않습니다" + "value" : "변경 사항을 저장하시겠습니까?" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro không hỗ trợ kết nối “%@”" + "value" : "Lưu thay đổi?" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro 不支持“%@”连接" + "value" : "保存更改?" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro 不支援「%@」連線" + "value" : "儲存變更?" } } } }, - "TablePro has not connected to %@ before.\n\n%@ key fingerprint:\n%@\n\nTrust this server only if the fingerprint matches the one you expect." : { + "Saved credentials for these connections will be permanently removed." : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "TablePro has not connected to %1$@ before.\n\n%2$@ key fingerprint:\n%3$@\n\nTrust this server only if the fingerprint matches the one you expect." - } - }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro는 이전에 %1$@에 연결한 적이 없습니다.\n\n%2$@ 키 지문:\n%3$@\n\n예상한 지문과 일치할 때만 이 서버를 신뢰하십시오." + "value" : "이 연결들에 저장된 자격 증명이 영구적으로 제거됩니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro chưa từng kết nối tới %1$@.\n\nVân tay khóa %2$@:\n%3$@\n\nChỉ tin cậy máy chủ này nếu vân tay khớp với vân tay bạn mong đợi." + "value" : "Thông tin đăng nhập đã lưu của các kết nối này sẽ bị xóa vĩnh viễn." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro 之前未连接过 %1$@。\n\n%2$@ 密钥指纹:\n%3$@\n\n只有当指纹与你预期的一致时才信任此服务器。" + "value" : "这些连接已保存的凭据将被永久移除。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro 之前未連線過 %1$@。\n\n%2$@ 金鑰指紋:\n%3$@\n\n只有當指紋與你預期的一致時才信任此伺服器。" + "value" : "這些連線已儲存的憑證將被永久移除。" } } } }, - "TablePro is Locked" : { + "Schema" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro가 잠겨 있습니다" + "value" : "스키마" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro đã khóa" + "value" : "Schema" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro 已锁定" + "value" : "模式" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro 已鎖定" + "value" : "綱要" } } } }, - "TablePro will re-download every connection, group, and tag from your iCloud account. Local data on this device is not deleted." : { + "Schemas" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro는 iCloud 계정에서 모든 연결, 그룹 및 태그를 다시 다운로드합니다. 이 기기의 로컬 데이터는 삭제되지 않습니다." + "value" : "스키마" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro sẽ tải lại mọi kết nối, nhóm và thẻ từ tài khoản iCloud của bạn. Dữ liệu cục bộ trên thiết bị này không bị xóa." + "value" : "Schemas" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro 将从你的 iCloud 账户重新下载每个连接、分组和标签。此设备上的本地数据不会被删除。" + "value" : "模式" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro 將從你的 iCloud 帳戶重新下載每個連線、群組和標籤。此裝置上的本機資料不會被刪除。" + "value" : "綱要" } } } }, - "Tables" : { + "Search Connections" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "테이블" + "value" : "연결 검색" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Bảng" + "value" : "Tìm kết nối" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "表" + "value" : "搜索连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "資料表" + "value" : "搜尋連線" } } } }, - "Tag" : { + "Search all columns" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "태그" + "value" : "모든 열 검색" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Nhãn" + "value" : "Tìm kiếm tất cả cột" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "标签" + "value" : "搜索所有列" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "標籤" + "value" : "搜尋所有欄位" } } } }, - "Tags" : { + "Search tables" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "태그" + "value" : "테이블 검색" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Nhãn" + "value" : "Tìm bảng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "标签" + "value" : "搜索表" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "標籤" + "value" : "搜尋資料表" } } } }, - "Test Connection" : { + "Second value" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "연결 테스트" + "value" : "두 번째 값" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Kiểm tra kết nối" + "value" : "Giá trị thứ hai" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "测试连接" + "value" : "第二个值" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "測試連線" + "value" : "第二個值" } } } }, - "Testing..." : { + "Section" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "테스트 중..." + "value" : "섹션" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đang kiểm tra..." + "value" : "Phần" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "正在测试..." + "value" : "分区" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "正在測試…" + "value" : "區段" } } } }, - "That connection no longer exists in TablePro." : { + "Security" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "해당 연결이 더 이상 TablePro에 없습니다." + "value" : "보안" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Kết nối đó không còn tồn tại trong TablePro." + "value" : "Bảo mật" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "该连接在 TablePro 中已不存在。" + "value" : "安全" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "該連線在 TablePro 中已不存在。" + "value" : "安全性" } } } }, - "That file holds a certificate, not a private key." : { + "Select Private Key" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 파일에는 개인 키가 아닌 인증서가 들어 있습니다." + "value" : "개인 키 선택" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tệp đó chứa chứng chỉ, không phải khóa riêng tư." + "value" : "Chọn khóa riêng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "该文件包含的是证书,不是私钥。" + "value" : "选择私钥" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "該檔案包含的是憑證,不是私密金鑰。" + "value" : "選擇私密金鑰" } } } }, - "That file holds a private key, not a certificate." : { + "Select a Connection" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 파일에는 인증서가 아닌 개인 키가 들어 있습니다." + "value" : "연결 선택" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tệp đó chứa khóa riêng tư, không phải chứng chỉ." + "value" : "Chọn kết nối" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "该文件包含的是私钥,不是证书。" + "value" : "选择连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "該檔案包含的是私密金鑰,不是憑證。" + "value" : "選擇連線" } } } }, - "That file is not a PEM certificate. Choose a .pem or .crt file, or paste its contents." : { + "Server" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 파일은 PEM 인증서가 아닙니다. .pem 또는 .crt 파일을 선택하거나 인증서 내용을 붙여넣으십시오." + "value" : "서버" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tệp đó không phải chứng chỉ PEM. Hãy chọn tệp .pem hoặc .crt, hoặc dán nội dung của nó." + "value" : "Máy chủ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "该文件不是 PEM 证书。请选择 .pem 或 .crt 文件,或粘贴其内容。" + "value" : "服务器" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "該檔案不是 PEM 憑證。請選擇 .pem 或 .crt 檔案,或貼上其內容。" + "value" : "伺服器" } } } }, - "That private key is protected by a passphrase. Remove the passphrase or import a PKCS#12 file instead." : { + "Service Name" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 개인 키는 암호로 보호되어 있습니다. 암호를 제거하거나 대신 PKCS#12 파일을 가져오십시오." + "value" : "서비스 이름" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Khóa riêng tư đó được bảo vệ bằng passphrase. Hãy gỡ passphrase hoặc nhập tệp PKCS#12 thay thế." + "value" : "Tên dịch vụ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "该私钥受密码短语保护。请移除密码短语,或改为导入 PKCS#12 文件。" + "value" : "服务名称" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "該私密金鑰受密碼短語保護。請移除密碼短語,或改為匯入 PKCS#12 檔案。" + "value" : "服務名稱" } } } }, - "That text has no certificate or key in it. Paste the whole PEM block, including its BEGIN and END lines." : { + "Service Name Not Found" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 텍스트에 인증서 또는 키가 없습니다. BEGIN 및 END 줄을 포함한 전체 PEM 블록을 붙여넣으십시오." + "value" : "서비스 이름을 찾을 수 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Văn bản đó không chứa chứng chỉ hay khóa nào. Hãy dán toàn bộ khối PEM, bao gồm cả dòng BEGIN và END." + "value" : "Không tìm thấy tên dịch vụ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "该文本中没有证书或密钥。请粘贴完整的 PEM 块,包括 BEGIN 和 END 行。" + "value" : "未找到服务名" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "該文字中沒有憑證或金鑰。請貼上完整的 PEM 區塊,包括 BEGIN 和 END 行。" + "value" : "找不到服務名稱" } } } }, - "The CA certificate for this connection is missing. Import it again in the connection's SSL settings." : { + "Set up a new database connection" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 연결의 CA 인증서가 없습니다. 연결의 SSL 설정에서 다시 가져오십시오." + "value" : "새 데이터베이스 연결 설정" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thiếu chứng chỉ CA cho kết nối này. Hãy nhập lại trong cài đặt SSL của kết nối." + "value" : "Thiết lập kết nối cơ sở dữ liệu mới" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此连接的 CA 证书缺失。请在连接的 SSL 设置中重新导入。" + "value" : "设置新的数据库连接" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此連線的 CA 憑證遺失。請在連線的 SSL 設定中重新匯入。" + "value" : "設定新的資料庫連線" } } } }, - "The CSV data has no header row." : { + "Settings" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "CSV 데이터에 헤더 행이 없습니다." + "value" : "설정" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Dữ liệu CSV không có dòng tiêu đề." + "value" : "Cài đặt" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "该 CSV 数据没有标题行。" + "value" : "设置" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "該 CSV 資料沒有標題列。" + "value" : "設定" } } } }, - "The JSON array must contain only objects." : { + "Share" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "JSON 배열에는 객체만 포함해야 합니다." + "value" : "공유" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Mảng JSON chỉ được chứa các đối tượng." + "value" : "Chia sẻ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "JSON 数组只能包含对象。" + "value" : "共享" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "JSON 陣列只能包含物件。" + "value" : "分享" } } } }, - "The Microsoft Entra ID access token was rejected by the driver." : { + "Share Results" : { "localizations" : { "ko" : { "stringUnit" : { - "value" : "Microsoft Entra ID 액세스 토큰이 드라이버에서 거부되었습니다.", - "state" : "translated" + "state" : "translated", + "value" : "결과 공유" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Access token Microsoft Entra ID bị driver từ chối." + "value" : "Chia sẻ kết quả" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "驱动拒绝了该 Microsoft Entra ID 访问令牌。" + "value" : "共享结果" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "驅動程式拒絕了該 Microsoft Entra ID 存取權杖。" + "value" : "分享結果" } } } }, - "The Oracle listener refused the connection (ORA-%ld)." : { + "Share Row" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle 리스너가 연결을 거부했습니다(ORA-%ld)." + "value" : "행 공유" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle listener đã từ chối kết nối (ORA-%ld)." + "value" : "Chia sẻ dòng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle 侦听器拒绝了此连接(ORA-%ld)。" + "value" : "共享行" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle 接聽程式拒絕了此連線(ORA-%ld)。" + "value" : "分享列" } } } }, - "The Oracle listener refused the connection." : { + "Share Usage Data" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle 리스너가 연결을 거부했습니다." + "value" : "사용 데이터 공유" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle listener đã từ chối kết nối." + "value" : "Chia sẻ dữ liệu sử dụng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle 侦听器拒绝了此连接。" + "value" : "共享使用数据" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle 接聽程式拒絕了此連線。" + "value" : "分享使用資料" } } } }, - "The Oracle server closed the connection during the login handshake." : { + "Share Usage Data?" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle 서버가 로그인 핸드셰이크 중 연결을 종료했습니다." + "value" : "사용 데이터를 공유하시겠습니까?" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Máy chủ Oracle đã đóng kết nối trong quá trình bắt tay đăng nhập." + "value" : "Chia sẻ dữ liệu sử dụng?" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle 服务器在登录握手期间关闭了连接。" + "value" : "要共享使用数据吗?" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle 伺服器在登入交握期間關閉了連線。" + "value" : "要分享使用資料嗎?" } } } }, - "The SSH server may be unreachable or running a different protocol." : { + "Share anonymous usage data" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 서버에 연결할 수 없거나 서버에서 다른 프로토콜을 사용 중일 수 있습니다." + "value" : "익명 사용 데이터 공유" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Máy chủ SSH có thể không truy cập được hoặc đang chạy giao thức khác." + "value" : "Chia sẻ dữ liệu sử dụng ẩn danh" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SSH服务器可能无法访问或运行了不同的协议。" + "value" : "共享匿名使用数据" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 伺服器可能無法連線,或正在執行不同的通訊協定。" + "value" : "分享匿名使用資料" } } } }, - "The SSH tunnel connected but could not forward to the database port." : { + "Shortcuts and Widgets" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 터널에 연결했지만 데이터베이스 포트로 전달할 수 없습니다." + "value" : "단축어 및 위젯" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Đường hầm SSH đã kết nối nhưng không thể chuyển tiếp đến cổng cơ sở dữ liệu." + "value" : "Phím tắt và tiện ích" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "SSH隧道已连接但无法转发到数据库端口。" + "value" : "快捷指令和小组件" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "SSH 隧道已連線,但無法轉送至資料庫連接埠。" + "value" : "捷徑與小工具" } } } }, - "The certificate could not be saved to this device's keychain." : { + "Showing the first %d rows. Add LIMIT to fetch more." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "인증서를 이 기기의 키체인에 저장할 수 없습니다." + "value" : "처음 %d개 행을 표시합니다. 더 가져오려면 LIMIT를 추가하십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không thể lưu chứng chỉ vào keychain của thiết bị này." + "value" : "Đang hiển thị %d dòng đầu tiên. Thêm LIMIT để lấy thêm." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无法将证书保存到本设备的钥匙串。" + "value" : "正在显示前 %d 行。添加 LIMIT 以获取更多。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "無法將憑證儲存到本裝置的鑰匙圈。" + "value" : "正在顯示前 %d 列。加入 LIMIT 以取得更多。" } } } }, - "The certificate file could not be read. Check the password and that the file is a PKCS#12 certificate." : { + "Sign In" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "인증서 파일을 읽을 수 없습니다. 암호가 맞는지, 파일이 PKCS#12 인증서인지 확인하십시오." + "value" : "로그인" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không thể đọc tệp chứng chỉ. Hãy kiểm tra mật khẩu và xác nhận tệp là chứng chỉ PKCS#12." + "value" : "Đăng nhập" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无法读取证书文件。请检查密码,并确认该文件是 PKCS#12 证书。" + "value" : "登录" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "無法讀取憑證檔案。請檢查密碼,並確認該檔案是 PKCS#12 憑證。" + "value" : "登入" } } } }, - "The client certificate for this connection is missing. Import it again in the connection's SSL settings." : { + "Sign in to Microsoft Entra ID with your browser?" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 연결의 클라이언트 인증서가 없습니다. 연결의 SSL 설정에서 다시 가져오십시오." + "value" : "브라우저에서 Microsoft Entra ID에 로그인하시겠습니까?" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thiếu chứng chỉ máy khách cho kết nối này. Hãy nhập lại trong cài đặt SSL của kết nối." + "value" : "Đăng nhập Microsoft Entra ID bằng trình duyệt?" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此连接的客户端证书缺失。请在连接的 SSL 设置中重新导入。" + "value" : "要用浏览器登录 Microsoft Entra ID 吗?" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此連線的用戶端憑證遺失。請在連線的 SSL 設定中重新匯入。" + "value" : "要用瀏覽器登入 Microsoft Entra ID 嗎?" } } } }, - "The client key for this connection is missing. Import it again in the connection's SSL settings." : { + "Sign in to iCloud in the Settings app to sync your connections." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 연결의 클라이언트 키가 없습니다. 연결의 SSL 설정에서 다시 가져오십시오." + "value" : "연결을 동기화하려면 설정 앱에서 iCloud에 로그인하십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thiếu khóa máy khách cho kết nối này. Hãy nhập lại trong cài đặt SSL của kết nối." + "value" : "Đăng nhập iCloud trong ứng dụng Cài đặt để đồng bộ kết nối của bạn." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此连接的客户端密钥缺失。请在连接的 SSL 设置中重新导入。" + "value" : "在“设置”App 中登录 iCloud 以同步你的连接。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此連線的用戶端金鑰遺失。請在連線的 SSL 設定中重新匯入。" + "value" : "在「設定」App 中登入 iCloud 以同步你的連線。" } } } }, - "The data could not be read: %@" : { + "Skip" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "데이터를 읽을 수 없습니다: %@" + "value" : "건너뛰기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không thể đọc dữ liệu: %@" + "value" : "Bỏ qua" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "无法读取数据:%@" + "value" : "跳过" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "無法讀取資料:%@" + "value" : "略過" } } } }, - "The data has no values to insert into %@." : { + "Some credentials could not be saved to the keychain. You may need to re-enter them later." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%@에 삽입할 값이 입력 데이터에 없습니다." + "value" : "일부 자격 증명을 키체인에 저장할 수 없습니다. 나중에 다시 입력해야 할 수 있습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Dữ liệu không có giá trị nào để chèn vào %@." + "value" : "Không thể lưu một số thông tin xác thực vào keychain. Bạn có thể cần nhập lại sau." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "该数据没有可插入 %@ 的值。" + "value" : "部分凭证无法保存到钥匙串,稍后可能需要重新输入。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "該資料沒有可插入 %@ 的值。" + "value" : "部分憑證無法儲存至鑰匙圈,稍後可能需要重新輸入。" } } } }, - "The database limited the result. Showing %d rows." : { + "Sort" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "데이터베이스에서 결과를 제한했습니다. %d개 행을 표시합니다." + "value" : "정렬" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Cơ sở dữ liệu đã giới hạn kết quả. Đang hiển thị %d dòng." + "value" : "Sắp xếp" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "数据库限制了结果。正在显示 %d 行。" + "value" : "排序" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "資料庫限制了結果。正在顯示 %d 列。" + "value" : "排序" } } } }, - "The encrypted file is corrupt or incomplete" : { + "Sort By" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "암호화된 파일이 손상되었거나 불완전합니다" + "value" : "정렬 기준" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tệp mã hóa bị hỏng hoặc không đầy đủ" + "value" : "Sắp xếp theo" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "加密文件已损坏或不完整" + "value" : "排序方式" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "加密的檔案已損毀或不完整" + "value" : "排序依據" } } } }, - "The file is not UTF-8 text." : { + "Stats" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "파일이 UTF-8 텍스트가 아닙니다." + "value" : "통계" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tệp không phải văn bản UTF-8." + "value" : "Thống kê" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "该文件不是 UTF-8 文本。" + "value" : "统计" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "該檔案不是 UTF-8 文字。" + "value" : "統計" } } } }, - "The host key for %@ has changed.\n\nThis can mean the server was rebuilt, or that someone is intercepting the connection.\n\nPrevious fingerprint:\n%@\n\nCurrent fingerprint:\n%@" : { + "Status" : { "localizations" : { - "en" : { - "stringUnit" : { - "state" : "new", - "value" : "The host key for %1$@ has changed.\n\nThis can mean the server was rebuilt, or that someone is intercepting the connection.\n\nPrevious fingerprint:\n%2$@\n\nCurrent fingerprint:\n%3$@" - } - }, "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@의 호스트 키가 변경되었습니다.\n\n서버가 다시 구축되었거나 누군가 연결을 가로채고 있을 수 있습니다.\n\n이전 지문:\n%2$@\n\n현재 지문:\n%3$@" + "value" : "상태" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Khóa máy chủ của %1$@ đã thay đổi.\n\nĐiều này có thể nghĩa là máy chủ được dựng lại, hoặc có ai đó đang chặn kết nối.\n\nVân tay trước đây:\n%2$@\n\nVân tay hiện tại:\n%3$@" + "value" : "Trạng thái" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ 的主机密钥已更改。\n\n这可能意味着服务器被重建,也可能有人正在拦截此连接。\n\n之前的指纹:\n%2$@\n\n当前的指纹:\n%3$@" + "value" : "状态" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "%1$@ 的主機金鑰已變更。\n\n這可能表示伺服器被重建,也可能有人正在攔截此連線。\n\n之前的指紋:\n%2$@\n\n目前的指紋:\n%3$@" + "value" : "狀態" } } } }, - "The listener does not know the requested SID" : { + "Stop" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "리스너가 요청한 SID를 인식하지 못합니다" + "value" : "중지" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Listener không biết SID được yêu cầu" + "value" : "Dừng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "侦听器不认识请求的 SID" + "value" : "停止" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "接聽程式不認識請求的 SID" + "value" : "停止" } } } }, - "The listener does not know the requested service name" : { + "Stopped" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "리스너가 요청한 서비스 이름을 인식하지 못합니다" + "value" : "중지됨" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Listener không biết tên dịch vụ được yêu cầu" + "value" : "Đã dừng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "侦听器不认识请求的服务名" + "value" : "已停止" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "接聽程式不認識請求的服務名稱" + "value" : "已停止" } } } }, - "The listener does not know this SID. Databases from 12c onward are usually reached by service name instead." : { + "Stopped at %d rows to stay within memory limits. Add LIMIT to fetch fewer." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "리스너에서 이 SID를 인식하지 못합니다. 12c 이상 데이터베이스는 일반적으로 서비스 이름으로 연결합니다." + "value" : "메모리 한도를 넘지 않도록 %d개 행에서 중지했습니다. 더 적게 가져오려면 LIMIT를 추가하십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Listener không biết SID này. Các cơ sở dữ liệu từ 12c trở đi thường được truy cập bằng tên dịch vụ." + "value" : "Đã dừng ở %d dòng để giữ trong giới hạn bộ nhớ. Thêm LIMIT để lấy ít hơn." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "侦听器不认识此 SID。12c 及以后的数据库通常改用服务名连接。" + "value" : "已在 %d 行处停止以保持在内存限制内。添加 LIMIT 以获取更少。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "接聽程式不認識此 SID。12c 及以後的資料庫通常改用服務名稱連線。" + "value" : "已在 %d 列處停止以維持在記憶體限制內。加入 LIMIT 以取得更少。" } } } }, - "The listener does not know this service name. Check it with your DBA, or switch to SID if this is an older database." : { + "Stopped before the query finished." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "리스너에서 이 서비스 이름을 인식하지 못합니다. DBA에게 확인하거나 이전 버전 데이터베이스인 경우 SID로 전환하십시오." + "value" : "쿼리가 완료되기 전에 중지되었습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Listener không biết tên dịch vụ này. Hãy hỏi DBA của bạn, hoặc chuyển sang SID nếu đây là cơ sở dữ liệu cũ." + "value" : "Đã dừng trước khi truy vấn hoàn tất." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "侦听器不认识此服务名。请与你的 DBA 确认,或在较旧的数据库上改用 SID。" + "value" : "查询完成前已停止。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "接聽程式不認識此服務名稱。請與你的 DBA 確認,或在較舊的資料庫上改用 SID。" + "value" : "查詢完成前已停止。" } } } }, - "The listener has no handler available for the requested service" : { + "Stopped. Showing %d rows." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "리스너에 요청한 서비스를 처리할 수 있는 핸들러가 없습니다" + "value" : "중지됨. %d개 행을 표시합니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Listener không có handler khả dụng cho dịch vụ được yêu cầu" + "value" : "Đã dừng. Đang hiển thị %d dòng." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "侦听器没有可用于所请求服务的处理程序" + "value" : "已停止。正在显示 %d 行。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "接聽程式沒有可用於所請求服務的處理常式" + "value" : "已停止。正在顯示 %d 列。" } } } }, - "The listener is blocking new connections to the requested service" : { + "Swipe right on a connection to add it to Favorites." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "리스너가 요청한 서비스에 대한 새 연결을 차단하고 있습니다" + "value" : "연결을 오른쪽으로 쓸어넘겨 즐겨찾기에 추가하십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Listener đang chặn các kết nối mới tới dịch vụ được yêu cầu" + "value" : "Vuốt sang phải trên một kết nối để thêm vào Yêu thích." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "侦听器正在阻止到所请求服务的新连接" + "value" : "在连接上向右轻扫,即可将其添加到收藏。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "接聽程式正在阻擋到所請求服務的新連線" + "value" : "在連線上向右滑動,即可將其加入我的最愛。" } } } }, - "The operation violates a database constraint." : { + "Switching..." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "작업이 데이터베이스 제약 조건을 위반합니다." + "value" : "전환 중..." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thao tác vi phạm ràng buộc cơ sở dữ liệu." + "value" : "Đang chuyển..." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "操作违反了数据库约束。" + "value" : "正在切换..." } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此操作違反了資料庫限制條件。" + "value" : "正在切換…" } } } }, - "The query did not finish within the configured timeout, so the connection was reset. Run the query again." : { + "Sync" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "쿼리가 설정된 제한 시간 내에 완료되지 않아 연결을 재설정했습니다. 쿼리를 다시 실행하십시오." + "value" : "동기화" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Truy vấn không hoàn tất trong thời gian chờ đã cấu hình, nên kết nối đã bị đặt lại. Hãy chạy lại truy vấn." + "value" : "Đồng bộ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "查询未在配置的超时时间内完成,因此连接已重置。请重新运行该查询。" + "value" : "同步" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "查詢未在設定的逾時時間內完成,因此連線已重設。請重新執行該查詢。" + "value" : "同步" } } } }, - "The query returned no rows." : { + "Sync Now" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "쿼리에서 반환된 행이 없습니다." + "value" : "지금 동기화" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Truy vấn không trả về hàng nào." + "value" : "Đồng bộ ngay" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "查询未返回任何行。" + "value" : "立即同步" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "查詢未傳回任何列。" + "value" : "立即同步" } } } }, - "The server is not responding. Check the host and port." : { + "Sync Passwords" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "서버가 응답하지 않습니다. 호스트와 포트를 확인하십시오." + "value" : "암호 동기화" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Máy chủ không phản hồi. Kiểm tra máy chủ và cổng." + "value" : "Đồng bộ mật khẩu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "服务器无响应。请检查主机和端口。" + "value" : "同步密码" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "伺服器沒有回應。請檢查主機與連接埠。" + "value" : "同步密碼" } } } }, - "The server sent an unexpected message and the connection was reset. Run the query again." : { + "Sync from iCloud" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "서버에서 예상하지 못한 메시지를 보내 연결을 재설정했습니다. 쿼리를 다시 실행하십시오." + "value" : "iCloud에서 동기화" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Máy chủ đã gửi một thông điệp không mong đợi và kết nối đã bị đặt lại. Hãy chạy lại truy vấn." + "value" : "Đồng bộ từ iCloud" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "服务器发送了意外消息,连接已重置。请重新运行该查询。" + "value" : "从iCloud同步" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "伺服器傳送了非預期的訊息,連線已重設。請重新執行該查詢。" + "value" : "從 iCloud 同步" } } } }, - "The server's host key has changed." : { + "Sync token expired. A full sync will be performed." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "서버의 호스트 키가 변경되었습니다." + "value" : "동기화 토큰이 만료되었습니다. 전체 동기화를 수행합니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Khóa máy chủ của server đã thay đổi." + "value" : "Token đồng bộ đã hết hạn. Sẽ thực hiện đồng bộ toàn bộ." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "服务器的主机密钥已更改。" + "value" : "同步令牌已过期。将执行一次完整同步。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "伺服器的主機金鑰已變更。" + "value" : "同步權杖已過期。將執行一次完整同步。" + } + } + } + }, + "Sync with iCloud" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud와 동기화" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đồng bộ với iCloud" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "与 iCloud 同步" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "與 iCloud 同步" + } + } + } + }, + "Sync zone not found. A full sync will be performed." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "동기화 영역을 찾을 수 없습니다. 전체 동기화를 수행합니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không tìm thấy vùng đồng bộ. Sẽ thực hiện đồng bộ toàn bộ." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "未找到同步区域。将执行完整同步。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "找不到同步區域。將執行完整同步。" + } + } + } + }, + "Syncing from iCloud..." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud에서 동기화 중..." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang đồng bộ từ iCloud..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在从iCloud同步..." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在從 iCloud 同步…" + } + } + } + }, + "Syncing…" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "동기화 중…" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang đồng bộ…" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在同步…" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在同步…" + } + } + } + }, + "Syncs with Your Mac" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mac과 동기화" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đồng bộ với máy Mac của bạn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "与你的 Mac 同步" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "與你的 Mac 同步" + } + } + } + }, + "TLS handshake failed: %@" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "TLS 핸드셰이크 실패: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bắt tay TLS thất bại: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TLS 握手失败:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "TLS 交握失敗:%@" + } + } + } + }, + "Tab" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "탭" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tab" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标签页" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "分頁" + } + } + } + }, + "Table" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "테이블" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bảng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "表" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "資料表" + } + } + } + }, + "Table Structure" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "테이블 구조" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cấu trúc bảng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "表结构" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "資料表結構" + } + } + } + }, + "TablePro" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro" + } + } + } + }, + "TablePro Is Locked" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro가 잠겨 있습니다" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro đã khóa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 已锁定" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 已鎖定" + } + } + } + }, + "TablePro cannot receive the output of COPY TO STDOUT, so it was discarded. Run a SELECT instead." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro는 COPY TO STDOUT의 출력을 받을 수 없으므로 출력이 삭제되었습니다. 대신 SELECT를 실행하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro không thể nhận đầu ra của COPY TO STDOUT nên đầu ra đã bị bỏ qua. Hãy chạy SELECT thay thế." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 无法接收 COPY TO STDOUT 的输出,因此输出已被丢弃。请改为运行 SELECT。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 無法接收 COPY TO STDOUT 的輸出,因此已將其捨棄。請改為執行 SELECT。" + } + } + } + }, + "TablePro cannot run a replication COPY." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro는 복제 COPY를 실행할 수 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro không thể chạy lệnh COPY dùng cho replication." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 无法运行用于复制的 COPY。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 無法執行複寫用的 COPY。" + } + } + } + }, + "TablePro cannot send data to COPY FROM STDIN, so no rows were sent. Insert the rows with INSERT instead." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro는 COPY FROM STDIN으로 데이터를 보낼 수 없으므로 행을 보내지 않았습니다. 대신 INSERT로 행을 삽입하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro không thể gửi dữ liệu tới COPY FROM STDIN nên không có dòng nào được gửi. Hãy chèn các dòng bằng INSERT thay thế." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 无法向 COPY FROM STDIN 发送数据,因此没有发送任何行。请改用 INSERT 插入这些行。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 無法傳送資料給 COPY FROM STDIN,因此未傳送任何列。請改用 INSERT 插入這些列。" + } + } + } + }, + "TablePro could not read that file." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro가 해당 파일을 읽을 수 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro không đọc được tệp đó." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 无法读取该文件。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 無法讀取該檔案。" + } + } + } + }, + "TablePro could not read your saved connections. Nothing on this device has been changed." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro가 저장된 연결을 읽을 수 없습니다. 이 기기에서 변경된 내용은 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro không đọc được các kết nối đã lưu. Không có gì trên thiết bị này bị thay đổi." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 无法读取你已保存的连接。此设备上的任何内容都未被更改。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 無法讀取你已儲存的連線。此裝置上的任何內容都沒有變更。" + } + } + } + }, + "TablePro doesn't support “%@” connections" : { + "extractionState" : "manual", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro는 “%@” 연결을 지원하지 않습니다" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro không hỗ trợ kết nối “%@”" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 不支持“%@”连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 不支援「%@」連線" + } + } + } + }, + "TablePro has not connected to %@ before.\n\n%@ key fingerprint:\n%@\n\nTrust this server only if the fingerprint matches the one you expect." : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "TablePro has not connected to %1$@ before.\n\n%2$@ key fingerprint:\n%3$@\n\nTrust this server only if the fingerprint matches the one you expect." + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro는 이전에 %1$@에 연결한 적이 없습니다.\n\n%2$@ 키 지문:\n%3$@\n\n예상한 지문과 일치할 때만 이 서버를 신뢰하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro chưa từng kết nối tới %1$@.\n\nVân tay khóa %2$@:\n%3$@\n\nChỉ tin cậy máy chủ này nếu vân tay khớp với vân tay bạn mong đợi." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 之前未连接过 %1$@。\n\n%2$@ 密钥指纹:\n%3$@\n\n只有当指纹与你预期的一致时才信任此服务器。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 之前未連線過 %1$@。\n\n%2$@ 金鑰指紋:\n%3$@\n\n只有當指紋與你預期的一致時才信任此伺服器。" + } + } + } + }, + "TablePro is Locked" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro가 잠겨 있습니다" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro đã khóa" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 已锁定" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 已鎖定" + } + } + } + }, + "TablePro will re-download every connection, group, and tag from your iCloud account. Local data on this device is not deleted." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro는 iCloud 계정에서 모든 연결, 그룹 및 태그를 다시 다운로드합니다. 이 기기의 로컬 데이터는 삭제되지 않습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro sẽ tải lại mọi kết nối, nhóm và thẻ từ tài khoản iCloud của bạn. Dữ liệu cục bộ trên thiết bị này không bị xóa." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 将从你的 iCloud 账户重新下载每个连接、分组和标签。此设备上的本地数据不会被删除。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "TablePro 將從你的 iCloud 帳戶重新下載每個連線、群組和標籤。此裝置上的本機資料不會被刪除。" + } + } + } + }, + "Tables" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "테이블" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bảng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "表" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "資料表" + } + } + } + }, + "Tag" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "태그" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhãn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标签" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "標籤" + } + } + } + }, + "Tags" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "태그" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhãn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标签" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "標籤" + } + } + } + }, + "Tap Retry to try again." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "다시 시도하려면 ‘재시도’를 탭하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nhấn Thử lại để thử lần nữa." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "轻点“重试”以再试一次。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "點按「重試」以再試一次。" + } + } + } + }, + "Test Connection" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결 테스트" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kiểm tra kết nối" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "测试连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "測試連線" + } + } + } + }, + "Testing..." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "테스트 중..." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đang kiểm tra..." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在测试..." + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "正在測試…" + } + } + } + }, + "That connection no longer exists in TablePro." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "해당 연결이 더 이상 TablePro에 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối đó không còn tồn tại trong TablePro." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "该连接在 TablePro 中已不存在。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "該連線在 TablePro 中已不存在。" + } + } + } + }, + "That file holds a certificate, not a private key." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 파일에는 개인 키가 아닌 인증서가 들어 있습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tệp đó chứa chứng chỉ, không phải khóa riêng tư." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "该文件包含的是证书,不是私钥。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "該檔案包含的是憑證,不是私密金鑰。" + } + } + } + }, + "That file holds a private key, not a certificate." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 파일에는 인증서가 아닌 개인 키가 들어 있습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tệp đó chứa khóa riêng tư, không phải chứng chỉ." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "该文件包含的是私钥,不是证书。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "該檔案包含的是私密金鑰,不是憑證。" + } + } + } + }, + "That file is not a PEM certificate. Choose a .pem or .crt file, or paste its contents." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 파일은 PEM 인증서가 아닙니다. .pem 또는 .crt 파일을 선택하거나 인증서 내용을 붙여넣으십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tệp đó không phải chứng chỉ PEM. Hãy chọn tệp .pem hoặc .crt, hoặc dán nội dung của nó." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "该文件不是 PEM 证书。请选择 .pem 或 .crt 文件,或粘贴其内容。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "該檔案不是 PEM 憑證。請選擇 .pem 或 .crt 檔案,或貼上其內容。" + } + } + } + }, + "That private key is protected by a passphrase. Remove the passphrase or import a PKCS#12 file instead." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 개인 키는 암호로 보호되어 있습니다. 암호를 제거하거나 대신 PKCS#12 파일을 가져오십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khóa riêng tư đó được bảo vệ bằng passphrase. Hãy gỡ passphrase hoặc nhập tệp PKCS#12 thay thế." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "该私钥受密码短语保护。请移除密码短语,或改为导入 PKCS#12 文件。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "該私密金鑰受密碼短語保護。請移除密碼短語,或改為匯入 PKCS#12 檔案。" + } + } + } + }, + "That text has no certificate or key in it. Paste the whole PEM block, including its BEGIN and END lines." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 텍스트에 인증서 또는 키가 없습니다. BEGIN 및 END 줄을 포함한 전체 PEM 블록을 붙여넣으십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Văn bản đó không chứa chứng chỉ hay khóa nào. Hãy dán toàn bộ khối PEM, bao gồm cả dòng BEGIN và END." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "该文本中没有证书或密钥。请粘贴完整的 PEM 块,包括 BEGIN 和 END 行。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "該文字中沒有憑證或金鑰。請貼上完整的 PEM 區塊,包括 BEGIN 和 END 行。" + } + } + } + }, + "The CA certificate for this connection is missing. Import it again in the connection's SSL settings." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 연결의 CA 인증서가 없습니다. 연결의 SSL 설정에서 다시 가져오십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thiếu chứng chỉ CA cho kết nối này. Hãy nhập lại trong cài đặt SSL của kết nối." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此连接的 CA 证书缺失。请在连接的 SSL 设置中重新导入。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此連線的 CA 憑證遺失。請在連線的 SSL 設定中重新匯入。" + } + } + } + }, + "The CSV data has no header row." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "CSV 데이터에 헤더 행이 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dữ liệu CSV không có dòng tiêu đề." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "该 CSV 数据没有标题行。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "該 CSV 資料沒有標題列。" + } + } + } + }, + "The JSON array must contain only objects." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON 배열에는 객체만 포함해야 합니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mảng JSON chỉ được chứa các đối tượng." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON 数组只能包含对象。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON 陣列只能包含物件。" + } + } + } + }, + "The Microsoft Entra ID access token was rejected by the driver." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Microsoft Entra ID 액세스 토큰이 드라이버에서 거부되었습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Access token Microsoft Entra ID bị driver từ chối." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "驱动拒绝了该 Microsoft Entra ID 访问令牌。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "驅動程式拒絕了該 Microsoft Entra ID 存取權杖。" + } + } + } + }, + "The Oracle listener refused the connection (ORA-%ld)." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oracle 리스너가 연결을 거부했습니다(ORA-%ld)." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oracle listener đã từ chối kết nối (ORA-%ld)." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oracle 侦听器拒绝了此连接(ORA-%ld)。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oracle 接聽程式拒絕了此連線(ORA-%ld)。" + } + } + } + }, + "The Oracle listener refused the connection." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oracle 리스너가 연결을 거부했습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oracle listener đã từ chối kết nối." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oracle 侦听器拒绝了此连接。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oracle 接聽程式拒絕了此連線。" + } + } + } + }, + "The Oracle server closed the connection during the login handshake." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oracle 서버가 로그인 핸드셰이크 중 연결을 종료했습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Máy chủ Oracle đã đóng kết nối trong quá trình bắt tay đăng nhập." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oracle 服务器在登录握手期间关闭了连接。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oracle 伺服器在登入交握期間關閉了連線。" + } + } + } + }, + "The Password field is empty. Fill it in, or clear Username to sign in as the default user." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "암호 필드가 비어 있습니다. 암호를 입력하거나, 기본 사용자로 로그인하려면 사용자 이름 필드를 비우십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Trường Mật khẩu đang trống. Hãy điền vào, hoặc xóa trống Tên đăng nhập để đăng nhập bằng người dùng mặc định." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "“密码”字段为空。请填写密码,或清空“用户名”以默认用户身份登录。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "「密碼」欄位是空的。請填寫它,或清空「使用者名稱」以預設使用者身分登入。" + } + } + } + }, + "The SSH server may be unreachable or running a different protocol." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSH 서버에 연결할 수 없거나 서버에서 다른 프로토콜을 사용 중일 수 있습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Máy chủ SSH có thể không truy cập được hoặc đang chạy giao thức khác." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSH服务器可能无法访问或运行了不同的协议。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSH 伺服器可能無法連線,或正在執行不同的通訊協定。" + } + } + } + }, + "The SSH tunnel connected but could not forward to the database port." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSH 터널에 연결했지만 데이터베이스 포트로 전달할 수 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đường hầm SSH đã kết nối nhưng không thể chuyển tiếp đến cổng cơ sở dữ liệu." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSH隧道已连接但无法转发到数据库端口。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "SSH 隧道已連線,但無法轉送至資料庫連接埠。" + } + } + } + }, + "The app and iOS versions, and your language" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앱 및 iOS 버전, 사용 언어" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Phiên bản ứng dụng và iOS, cùng ngôn ngữ của bạn" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "应用和 iOS 的版本,以及你的语言" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "App 與 iOS 版本,以及你的語言" + } + } + } + }, + "The certificate could not be saved to this device's keychain." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인증서를 이 기기의 키체인에 저장할 수 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể lưu chứng chỉ vào keychain của thiết bị này." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法将证书保存到本设备的钥匙串。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法將憑證儲存到本裝置的鑰匙圈。" + } + } + } + }, + "The certificate file could not be read. Check the password and that the file is a PKCS#12 certificate." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "인증서 파일을 읽을 수 없습니다. 암호가 맞는지, 파일이 PKCS#12 인증서인지 확인하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể đọc tệp chứng chỉ. Hãy kiểm tra mật khẩu và xác nhận tệp là chứng chỉ PKCS#12." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法读取证书文件。请检查密码,并确认该文件是 PKCS#12 证书。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法讀取憑證檔案。請檢查密碼,並確認該檔案是 PKCS#12 憑證。" + } + } + } + }, + "The client certificate for this connection is missing. Import it again in the connection's SSL settings." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 연결의 클라이언트 인증서가 없습니다. 연결의 SSL 설정에서 다시 가져오십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thiếu chứng chỉ máy khách cho kết nối này. Hãy nhập lại trong cài đặt SSL của kết nối." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此连接的客户端证书缺失。请在连接的 SSL 设置中重新导入。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此連線的用戶端憑證遺失。請在連線的 SSL 設定中重新匯入。" + } + } + } + }, + "The client key for this connection is missing. Import it again in the connection's SSL settings." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 연결의 클라이언트 키가 없습니다. 연결의 SSL 설정에서 다시 가져오십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thiếu khóa máy khách cho kết nối này. Hãy nhập lại trong cài đặt SSL của kết nối." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此连接的客户端密钥缺失。请在连接的 SSL 设置中重新导入。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此連線的用戶端金鑰遺失。請在連線的 SSL 設定中重新匯入。" + } + } + } + }, + "The connection attempt was cancelled." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "연결 시도가 취소되었습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lần thử kết nối đã bị hủy." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接尝试已取消。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "連線嘗試已取消。" + } + } + } + }, + "The data could not be read: %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "데이터를 읽을 수 없습니다: %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không thể đọc dữ liệu: %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无法读取数据:%@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "無法讀取資料:%@" + } + } + } + }, + "The data has no values to insert into %@." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@에 삽입할 값이 입력 데이터에 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dữ liệu không có giá trị nào để chèn vào %@." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "该数据没有可插入 %@ 的值。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "該資料沒有可插入 %@ 的值。" + } + } + } + }, + "The database limited the result. Showing %d rows." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "데이터베이스에서 결과를 제한했습니다. %d개 행을 표시합니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cơ sở dữ liệu đã giới hạn kết quả. Đang hiển thị %d dòng." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "数据库限制了结果。正在显示 %d 行。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "資料庫限制了結果。正在顯示 %d 列。" + } + } + } + }, + "The encrypted file is corrupt or incomplete" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "암호화된 파일이 손상되었거나 불완전합니다" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tệp mã hóa bị hỏng hoặc không đầy đủ" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加密文件已损坏或不完整" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "加密的檔案已損毀或不完整" + } + } + } + }, + "The file is not UTF-8 text." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "파일이 UTF-8 텍스트가 아닙니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tệp không phải văn bản UTF-8." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "该文件不是 UTF-8 文本。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "該檔案不是 UTF-8 文字。" + } + } + } + }, + "The host key for %@ has changed.\n\nThis can mean the server was rebuilt, or that someone is intercepting the connection.\n\nPrevious fingerprint:\n%@\n\nCurrent fingerprint:\n%@" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "The host key for %1$@ has changed.\n\nThis can mean the server was rebuilt, or that someone is intercepting the connection.\n\nPrevious fingerprint:\n%2$@\n\nCurrent fingerprint:\n%3$@" + } + }, + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@의 호스트 키가 변경되었습니다.\n\n서버가 다시 구축되었거나 누군가 연결을 가로채고 있을 수 있습니다.\n\n이전 지문:\n%2$@\n\n현재 지문:\n%3$@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khóa máy chủ của %1$@ đã thay đổi.\n\nĐiều này có thể nghĩa là máy chủ được dựng lại, hoặc có ai đó đang chặn kết nối.\n\nVân tay trước đây:\n%2$@\n\nVân tay hiện tại:\n%3$@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 的主机密钥已更改。\n\n这可能意味着服务器被重建,也可能有人正在拦截此连接。\n\n之前的指纹:\n%2$@\n\n当前的指纹:\n%3$@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$@ 的主機金鑰已變更。\n\n這可能表示伺服器被重建,也可能有人正在攔截此連線。\n\n之前的指紋:\n%2$@\n\n目前的指紋:\n%3$@" + } + } + } + }, + "The license text is missing from this build." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 빌드에는 라이선스 본문이 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bản dựng này thiếu nội dung giấy phép." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此版本缺少许可证文本。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此建置版本缺少授權文字。" + } + } + } + }, + "The list of open source libraries is missing from this build." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 빌드에는 오픈 소스 라이브러리 목록이 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bản dựng này thiếu danh sách thư viện mã nguồn mở." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此版本缺少开源库清单。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此建置版本缺少開放原始碼程式庫清單。" + } + } + } + }, + "The listener does not know the requested SID" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리스너가 요청한 SID를 인식하지 못합니다" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listener không biết SID được yêu cầu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "侦听器不认识请求的 SID" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "接聽程式不認識請求的 SID" + } + } + } + }, + "The listener does not know the requested service name" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리스너가 요청한 서비스 이름을 인식하지 못합니다" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listener không biết tên dịch vụ được yêu cầu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "侦听器不认识请求的服务名" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "接聽程式不認識請求的服務名稱" + } + } + } + }, + "The listener does not know this SID. Databases from 12c onward are usually reached by service name instead." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리스너에서 이 SID를 인식하지 못합니다. 12c 이상 데이터베이스는 일반적으로 서비스 이름으로 연결합니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listener không biết SID này. Các cơ sở dữ liệu từ 12c trở đi thường được truy cập bằng tên dịch vụ." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "侦听器不认识此 SID。12c 及以后的数据库通常改用服务名连接。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "接聽程式不認識此 SID。12c 及以後的資料庫通常改用服務名稱連線。" + } + } + } + }, + "The listener does not know this service name. Check it with your DBA, or switch to SID if this is an older database." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리스너에서 이 서비스 이름을 인식하지 못합니다. DBA에게 확인하거나 이전 버전 데이터베이스인 경우 SID로 전환하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listener không biết tên dịch vụ này. Hãy hỏi DBA của bạn, hoặc chuyển sang SID nếu đây là cơ sở dữ liệu cũ." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "侦听器不认识此服务名。请与你的 DBA 确认,或在较旧的数据库上改用 SID。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "接聽程式不認識此服務名稱。請與你的 DBA 確認,或在較舊的資料庫上改用 SID。" + } + } + } + }, + "The listener has no handler available for the requested service" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리스너에 요청한 서비스를 처리할 수 있는 핸들러가 없습니다" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listener không có handler khả dụng cho dịch vụ được yêu cầu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "侦听器没有可用于所请求服务的处理程序" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "接聽程式沒有可用於所請求服務的處理常式" + } + } + } + }, + "The listener is blocking new connections to the requested service" : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "리스너가 요청한 서비스에 대한 새 연결을 차단하고 있습니다" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Listener đang chặn các kết nối mới tới dịch vụ được yêu cầu" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "侦听器正在阻止到所请求服务的新连接" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "接聽程式正在阻擋到所請求服務的新連線" + } + } + } + }, + "The operation violates a database constraint." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "작업이 데이터베이스 제약 조건을 위반합니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thao tác vi phạm ràng buộc cơ sở dữ liệu." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "操作违反了数据库约束。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此操作違反了資料庫限制條件。" + } + } + } + }, + "The query did not finish within the configured timeout, so the connection was reset. Run the query again." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "쿼리가 설정된 제한 시간 내에 완료되지 않아 연결을 재설정했습니다. 쿼리를 다시 실행하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truy vấn không hoàn tất trong thời gian chờ đã cấu hình, nên kết nối đã bị đặt lại. Hãy chạy lại truy vấn." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "查询未在配置的超时时间内完成,因此连接已重置。请重新运行该查询。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "查詢未在設定的逾時時間內完成,因此連線已重設。請重新執行該查詢。" + } + } + } + }, + "The query returned no rows." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "쿼리에서 반환된 행이 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Truy vấn không trả về hàng nào." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "查询未返回任何行。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "查詢未傳回任何列。" + } + } + } + }, + "The report contains:" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "보고서에 포함되는 내용:" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Báo cáo gồm:" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "报告包含:" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "報告包含:" + } + } + } + }, + "The sample database is missing from the app." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "앱에 샘플 데이터베이스가 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ứng dụng thiếu cơ sở dữ liệu mẫu." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "应用中缺少示例数据库。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "App 中缺少範例資料庫。" + } + } + } + }, + "The server is not responding. Check the host and port." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서버가 응답하지 않습니다. 호스트와 포트를 확인하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Máy chủ không phản hồi. Kiểm tra máy chủ và cổng." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "服务器无响应。请检查主机和端口。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "伺服器沒有回應。請檢查主機與連接埠。" + } + } + } + }, + "The server sent an unexpected message and the connection was reset. Run the query again." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서버에서 예상하지 못한 메시지를 보내 연결을 재설정했습니다. 쿼리를 다시 실행하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Máy chủ đã gửi một thông điệp không mong đợi và kết nối đã bị đặt lại. Hãy chạy lại truy vấn." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "服务器发送了意外消息,连接已重置。请重新运行该查询。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "伺服器傳送了非預期的訊息,連線已重設。請重新執行該查詢。" + } + } + } + }, + "The server's host key has changed." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서버의 호스트 키가 변경되었습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khóa máy chủ của server đã thay đổi." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "服务器的主机密钥已更改。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "伺服器的主機金鑰已變更。" + } + } + } + }, + "The server's host key was not trusted." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서버의 호스트 키를 신뢰하지 않았습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Khóa máy chủ của server không được tin cậy." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "服务器的主机密钥未被信任。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "伺服器的主機金鑰未被信任。" + } + } + } + }, + "The table \"%@\" and all its data will be permanently deleted." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "\"%@\" 테이블과 모든 데이터가 영구적으로 삭제됩니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bảng \"%@\" và toàn bộ dữ liệu của nó sẽ bị xóa vĩnh viễn." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "表\"%@\"及其所有数据将被永久删除。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "資料表「%@」及其所有資料將被永久刪除。" + } + } + } + }, + "The table or column does not exist." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "테이블 또는 열이 존재하지 않습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bảng hoặc cột không tồn tại." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "表或列不存在。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "資料表或欄位不存在。" + } + } + } + }, + "The text encoding is invalid." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "텍스트 인코딩이 올바르지 않습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bảng mã văn bản không hợp lệ." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "文本编码无效。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "文字編碼無效。" + } + } + } + }, + "This Oracle server is older than release 11.1, which the database driver does not support." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 Oracle 서버는 릴리스 11.1보다 이전 버전이며 데이터베이스 드라이버에서 지원하지 않습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Máy chủ Oracle này cũ hơn bản 11.1, phiên bản mà driver cơ sở dữ liệu không hỗ trợ." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此 Oracle 服务器早于 11.1 版,数据库驱动不支持。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此 Oracle 伺服器早於 11.1 版,資料庫驅動程式不支援。" + } + } + } + }, + "This account uses a password verifier the database driver does not support." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 계정은 데이터베이스 드라이버가 지원하지 않는 암호 검증 형식을 사용합니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tài khoản này dùng password verifier mà driver cơ sở dữ liệu không hỗ trợ." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此账户使用了数据库驱动不支持的口令校验器。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此帳戶使用了資料庫驅動程式不支援的密碼驗證器。" + } + } + } + }, + "This certificate file needs a password. Certificate files exported without one cannot be read on iOS." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 인증서 파일에는 암호가 필요합니다. 암호 없이 내보낸 인증서 파일은 iOS에서 읽을 수 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tệp chứng chỉ này cần mật khẩu. Tệp chứng chỉ xuất ra mà không có mật khẩu thì không đọc được trên iOS." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此证书文件需要密码。导出时未设置密码的证书文件在 iOS 上无法读取。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此憑證檔案需要密碼。匯出時未設定密碼的憑證檔案在 iOS 上無法讀取。" + } + } + } + }, + "This certificate uses a private key type TablePro cannot read." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 인증서에는 TablePro에서 읽을 수 없는 유형의 개인 키가 사용되었습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Chứng chỉ này dùng loại khóa riêng tư mà TablePro không đọc được." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此证书使用了 TablePro 无法读取的私钥类型。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此憑證使用了 TablePro 無法讀取的私密金鑰類型。" + } + } + } + }, + "This column is generated, so it is never written." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 열은 생성 열이므로 값이 기록되지 않습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Đây là cột tự sinh nên không bao giờ được ghi giá trị." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此列是生成列,因此永远不会被写入。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此欄位是產生欄位,因此永遠不會寫入。" + } + } + } + }, + "This connection has a client certificate but no private key. Import both." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 연결에 클라이언트 인증서는 있지만 개인 키가 없습니다. 둘 다 가져오십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối này có chứng chỉ máy khách nhưng không có khóa riêng tư. Hãy nhập cả hai." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此连接有客户端证书但没有私钥。请同时导入两者。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此連線有用戶端憑證但沒有私密金鑰。請同時匯入兩者。" + } + } + } + }, + "This connection has a client key but no certificate. Import both." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 연결에 클라이언트 키는 있지만 인증서가 없습니다. 둘 다 가져오십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối này có khóa máy khách nhưng không có chứng chỉ. Hãy nhập cả hai." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此连接有客户端密钥但没有证书。请同时导入两者。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此連線有用戶端金鑰但沒有憑證。請同時匯入兩者。" + } + } + } + }, + "This connection is in read-only mode. Write queries are not allowed." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 연결은 읽기 전용 모드입니다. 쓰기 쿼리는 허용되지 않습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối này ở chế độ chỉ đọc. Không cho phép truy vấn ghi." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此连接为只读模式,不允许写入查询。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此連線為唯讀模式,不允許寫入查詢。" + } + } + } + }, + "This connection no longer exists. It may have been removed from another device." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 연결은 더 이상 존재하지 않습니다. 다른 기기에서 제거되었을 수 있습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kết nối này không còn tồn tại. Có thể đã bị xóa từ thiết bị khác." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此连接已不存在。它可能已在其他设备上被移除。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此連線已不存在,可能已從其他裝置移除。" + } + } + } + }, + "This connection's CA certificate is not readable on this device (%@). Certificate files do not sync between devices, so add the certificate here or lower the SSL mode to Required." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 기기에서 이 연결의 CA 인증서를 읽을 수 없습니다(%@). 인증서 파일은 기기 간에 동기화되지 않으므로 이 기기에 인증서를 추가하거나 SSL 모드를 필수(Required)로 낮추십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không đọc được chứng chỉ CA của kết nối này trên thiết bị này (%@). Tệp chứng chỉ không đồng bộ giữa các thiết bị, nên hãy thêm chứng chỉ tại đây hoặc hạ chế độ SSL xuống Required." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此连接的 CA 证书在本设备上不可读(%@)。证书文件不会在设备之间同步,请在此处添加证书,或将 SSL 模式降为“必需”。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此連線的 CA 憑證在本裝置上無法讀取(%@)。憑證檔案不會在裝置之間同步,請在此處加入憑證,或將 SSL 模式降為「必要」。" + } + } + } + }, + "This connection's client certificate is not readable on this device (%@). Certificate files do not sync between devices, so add the certificate here before connecting." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 기기에서 이 연결의 클라이언트 인증서를 읽을 수 없습니다(%@). 인증서 파일은 기기 간에 동기화되지 않으므로 연결하기 전에 이 기기에 인증서를 추가하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không đọc được chứng chỉ máy khách của kết nối này trên thiết bị này (%@). Tệp chứng chỉ không đồng bộ giữa các thiết bị, nên hãy thêm chứng chỉ tại đây trước khi kết nối." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此连接的客户端证书在本设备上不可读(%@)。证书文件不会在设备之间同步,请先在此处添加证书再连接。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此連線的用戶端憑證在本裝置上無法讀取(%@)。憑證檔案不會在裝置之間同步,請先在此處加入憑證再連線。" + } + } + } + }, + "This connection's client key is not readable on this device (%@). Key files do not sync between devices, so add the key here before connecting." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 기기에서 이 연결의 클라이언트 키를 읽을 수 없습니다(%@). 키 파일은 기기 간에 동기화되지 않으므로 연결하기 전에 이 기기에 키를 추가하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Không đọc được khóa máy khách của kết nối này trên thiết bị này (%@). Tệp khóa không đồng bộ giữa các thiết bị, nên hãy thêm khóa tại đây trước khi kết nối." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此连接的客户端密钥在本设备上不可读(%@)。密钥文件不会在设备之间同步,请先在此处添加密钥再连接。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此連線的用戶端金鑰在本裝置上無法讀取(%@)。金鑰檔案不會在裝置之間同步,請先在此處加入金鑰再連線。" + } + } + } + }, + "This database has no tables." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 데이터베이스에는 테이블이 없습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Cơ sở dữ liệu này không có bảng." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此数据库没有表。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此資料庫沒有資料表。" + } + } + } + }, + "This file has no private key. Export the certificate together with its key." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "이 파일에 개인 키가 없습니다. 인증서를 개인 키와 함께 내보내십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tệp này không có khóa riêng tư. Hãy xuất chứng chỉ cùng với khóa của nó." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "此文件没有私钥。请将证书与其密钥一起导出。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "此檔案沒有私密金鑰。請將憑證與其金鑰一起匯出。" } } } }, - "The server's host key was not trusted." : { + "This file is encrypted" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "서버의 호스트 키를 신뢰하지 않았습니다." + "value" : "이 파일은 암호화되어 있습니다" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Khóa máy chủ của server không được tin cậy." + "value" : "Tệp này được mã hóa" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "服务器的主机密钥未被信任。" + "value" : "此文件已加密" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "伺服器的主機金鑰未被信任。" + "value" : "此檔案已加密" } } } }, - "The table \"%@\" and all its data will be permanently deleted." : { + "This file is encrypted and requires a passphrase" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "\"%@\" 테이블과 모든 데이터가 영구적으로 삭제됩니다." + "value" : "이 파일은 암호화되어 있어 암호가 필요합니다" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Bảng \"%@\" và toàn bộ dữ liệu của nó sẽ bị xóa vĩnh viễn." + "value" : "Tệp này được mã hóa và yêu cầu cụm mật khẩu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "表\"%@\"及其所有数据将被永久删除。" + "value" : "此文件已加密,需要密码短语" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "資料表「%@」及其所有資料將被永久刪除。" + "value" : "此檔案已加密,需要通關密語" } } } }, - "The table or column does not exist." : { + "This file is not a valid TablePro export" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "테이블 또는 열이 존재하지 않습니다." + "value" : "이 파일은 올바른 TablePro 내보내기 파일이 아닙니다" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Bảng hoặc cột không tồn tại." + "value" : "Tệp này không phải là tệp xuất TablePro hợp lệ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "表或列不存在。" + "value" : "此文件不是有效的 TablePro 导出文件" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "資料表或欄位不存在。" + "value" : "此檔案不是有效的 TablePro 匯出檔" } } } }, - "The text encoding is invalid." : { + "This file requires a newer version of TablePro (format version %d)" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "텍스트 인코딩이 올바르지 않습니다." + "value" : "이 파일을 사용하려면 더 최신 버전의 TablePro가 필요합니다(파일 형식 버전 %d)" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Bảng mã văn bản không hợp lệ." + "value" : "Tệp này yêu cầu phiên bản TablePro mới hơn (phiên bản định dạng %d)" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "文本编码无效。" + "value" : "此文件需要更新版本的TablePro(格式版本%d)" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "文字編碼無效。" + "value" : "此檔案需要較新版本的 TablePro(格式版本 %d)" } } } }, - "This Oracle server is older than release 11.1, which the database driver does not support." : { + "This query will modify data. Are you sure you want to continue?" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 Oracle 서버는 릴리스 11.1보다 이전 버전이며 데이터베이스 드라이버에서 지원하지 않습니다." + "value" : "이 쿼리는 데이터를 변경합니다. 계속하시겠습니까?" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Máy chủ Oracle này cũ hơn bản 11.1, phiên bản mà driver cơ sở dữ liệu không hỗ trợ." + "value" : "Truy vấn này sẽ thay đổi dữ liệu. Bạn có chắc muốn tiếp tục?" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此 Oracle 服务器早于 11.1 版,数据库驱动不支持。" + "value" : "此查询将修改数据,确定要继续吗?" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此 Oracle 伺服器早於 11.1 版,資料庫驅動程式不支援。" + "value" : "此查詢將會修改資料。確定要繼續嗎?" } } } }, - "This account uses a password verifier the database driver does not support." : { + "This server has no password set for the default user. Clear the Password field." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 계정은 데이터베이스 드라이버가 지원하지 않는 암호 검증 형식을 사용합니다." + "value" : "이 서버의 기본 사용자에게는 암호가 설정되어 있지 않습니다. 암호 필드를 비우십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tài khoản này dùng password verifier mà driver cơ sở dữ liệu không hỗ trợ." + "value" : "Máy chủ này không đặt mật khẩu cho người dùng mặc định. Hãy xóa trống trường Mật khẩu." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此账户使用了数据库驱动不支持的口令校验器。" + "value" : "此服务器未为默认用户设置密码。请清空“密码”字段。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此帳戶使用了資料庫驅動程式不支援的密碼驗證器。" + "value" : "此伺服器未為預設使用者設定密碼。請清空「密碼」欄位。" } } } }, - "This certificate file needs a password. Certificate files exported without one cannot be read on iOS." : { + "This server predates Redis 6 and takes no username. Clear the Username field." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 인증서 파일에는 암호가 필요합니다. 암호 없이 내보낸 인증서 파일은 iOS에서 읽을 수 없습니다." + "value" : "이 서버는 Redis 6 이전 버전이므로 사용자 이름을 사용하지 않습니다. 사용자 이름 필드를 비우십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tệp chứng chỉ này cần mật khẩu. Tệp chứng chỉ xuất ra mà không có mật khẩu thì không đọc được trên iOS." + "value" : "Máy chủ này cũ hơn Redis 6 và không nhận tên người dùng. Hãy xóa trống trường Tên người dùng." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此证书文件需要密码。导出时未设置密码的证书文件在 iOS 上无法读取。" + "value" : "此服务器早于 Redis 6,不接受用户名。请清空“用户名”字段。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此憑證檔案需要密碼。匯出時未設定密碼的憑證檔案在 iOS 上無法讀取。" + "value" : "此伺服器早於 Redis 6,不接受使用者名稱。請清空「使用者名稱」欄位。" } } } }, - "This certificate uses a private key type TablePro cannot read." : { + "This server requires authentication." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 인증서에는 TablePro에서 읽을 수 없는 유형의 개인 키가 사용되었습니다." + "value" : "이 서버는 인증이 필요합니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chứng chỉ này dùng loại khóa riêng tư mà TablePro không đọc được." + "value" : "Máy chủ này yêu cầu xác thực." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此证书使用了 TablePro 无法读取的私钥类型。" + "value" : "此服务器需要认证。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此憑證使用了 TablePro 無法讀取的私密金鑰類型。" + "value" : "此伺服器需要驗證。" } } } }, - "This connection has a client certificate but no private key. Import both." : { + "This table has no foreign key relationships." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 연결에 클라이언트 인증서는 있지만 개인 키가 없습니다. 둘 다 가져오십시오." + "value" : "이 테이블에는 외래 키 관계가 없습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Kết nối này có chứng chỉ máy khách nhưng không có khóa riêng tư. Hãy nhập cả hai." + "value" : "Bảng này không có quan hệ khóa ngoại." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此连接有客户端证书但没有私钥。请同时导入两者。" + "value" : "此表没有外键关系。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此連線有用戶端憑證但沒有私密金鑰。請同時匯入兩者。" + "value" : "此資料表沒有外鍵關聯。" } } } }, - "This connection has a client key but no certificate. Import both." : { + "This table has no indexes." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 연결에 클라이언트 키는 있지만 인증서가 없습니다. 둘 다 가져오십시오." + "value" : "이 테이블에는 인덱스가 없습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Kết nối này có khóa máy khách nhưng không có chứng chỉ. Hãy nhập cả hai." + "value" : "Bảng này không có chỉ mục." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此连接有客户端密钥但没有证书。请同时导入两者。" + "value" : "此表没有索引。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此連線有用戶端金鑰但沒有憑證。請同時匯入兩者。" + "value" : "此資料表沒有索引。" } } } }, - "This connection is in read-only mode. Write queries are not allowed." : { + "This table is empty." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 연결은 읽기 전용 모드입니다. 쓰기 쿼리는 허용되지 않습니다." + "value" : "이 테이블은 비어 있습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Kết nối này ở chế độ chỉ đọc. Không cho phép truy vấn ghi." + "value" : "Bảng này trống." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此连接为只读模式,不允许写入查询。" + "value" : "此表为空。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此連線為唯讀模式,不允許寫入查詢。" + "value" : "此資料表是空的。" } } } }, - "This connection no longer exists. It may have been removed from another device." : { + "This table needs a primary key to identify the row." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 연결은 더 이상 존재하지 않습니다. 다른 기기에서 제거되었을 수 있습니다." + "value" : "행을 식별하려면 이 테이블에 기본 키가 있어야 합니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Kết nối này không còn tồn tại. Có thể đã bị xóa từ thiết bị khác." + "value" : "Bảng này cần khóa chính để xác định dòng." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此连接已不存在。它可能已在其他设备上被移除。" + "value" : "此表需要主键来标识行。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此連線已不存在,可能已從其他裝置移除。" + "value" : "此資料表需要主鍵來識別該列。" } } } }, - "This connection's CA certificate is not readable on this device (%@). Certificate files do not sync between devices, so add the certificate here or lower the SSL mode to Required." : { + "This will insert a row into %@. Continue?" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 기기에서 이 연결의 CA 인증서를 읽을 수 없습니다(%@). 인증서 파일은 기기 간에 동기화되지 않으므로 이 기기에 인증서를 추가하거나 SSL 모드를 필수(Required)로 낮추십시오." + "value" : "%@에 행을 삽입합니다. 계속하시겠습니까?" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không đọc được chứng chỉ CA của kết nối này trên thiết bị này (%@). Tệp chứng chỉ không đồng bộ giữa các thiết bị, nên hãy thêm chứng chỉ tại đây hoặc hạ chế độ SSL xuống Required." + "value" : "Thao tác này sẽ chèn một dòng vào %@. Tiếp tục?" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此连接的 CA 证书在本设备上不可读(%@)。证书文件不会在设备之间同步,请在此处添加证书,或将 SSL 模式降为“必需”。" + "value" : "这将向 %@ 插入一行。是否继续?" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此連線的 CA 憑證在本裝置上無法讀取(%@)。憑證檔案不會在裝置之間同步,請在此處加入憑證,或將 SSL 模式降為「必要」。" + "value" : "這將向 %@ 插入一列。是否繼續?" } } } }, - "This connection's client certificate is not readable on this device (%@). Certificate files do not sync between devices, so add the certificate here before connecting." : { + "This will update a row in %@. Continue?" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 기기에서 이 연결의 클라이언트 인증서를 읽을 수 없습니다(%@). 인증서 파일은 기기 간에 동기화되지 않으므로 연결하기 전에 이 기기에 인증서를 추가하십시오." + "value" : "%@의 행을 업데이트합니다. 계속하시겠습니까?" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không đọc được chứng chỉ máy khách của kết nối này trên thiết bị này (%@). Tệp chứng chỉ không đồng bộ giữa các thiết bị, nên hãy thêm chứng chỉ tại đây trước khi kết nối." + "value" : "Thao tác này sẽ cập nhật một dòng trong %@. Tiếp tục?" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此连接的客户端证书在本设备上不可读(%@)。证书文件不会在设备之间同步,请先在此处添加证书再连接。" + "value" : "这将更新 %@ 中的一行。是否继续?" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此連線的用戶端憑證在本裝置上無法讀取(%@)。憑證檔案不會在裝置之間同步,請先在此處加入憑證再連線。" + "value" : "這將更新 %@ 中的一列。是否繼續?" } } } }, - "This connection's client key is not readable on this device (%@). Key files do not sync between devices, so add the key here before connecting." : { + "Timed out completing Kerberos authentication. The Kerberos KDC (domain controller) may be unreachable, the server's SPN may be missing, or this device's clock may be off. Check your network to the domain, or use SQL Server Authentication." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 기기에서 이 연결의 클라이언트 키를 읽을 수 없습니다(%@). 키 파일은 기기 간에 동기화되지 않으므로 연결하기 전에 이 기기에 키를 추가하십시오." + "value" : "Kerberos 인증을 완료하는 동안 시간이 초과되었습니다. Kerberos KDC(도메인 컨트롤러)에 연결할 수 없거나, 서버의 SPN이 없거나, 이 기기의 시계가 맞지 않을 수 있습니다. 도메인에 대한 네트워크 연결을 확인하거나 SQL Server 인증을 사용하십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Không đọc được khóa máy khách của kết nối này trên thiết bị này (%@). Tệp khóa không đồng bộ giữa các thiết bị, nên hãy thêm khóa tại đây trước khi kết nối." + "value" : "Hết thời gian chờ khi hoàn tất xác thực Kerberos. Có thể KDC Kerberos (domain controller) không truy cập được, SPN của máy chủ bị thiếu, hoặc đồng hồ của thiết bị này bị lệch. Hãy kiểm tra mạng tới domain, hoặc dùng SQL Server Authentication." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此连接的客户端密钥在本设备上不可读(%@)。密钥文件不会在设备之间同步,请先在此处添加密钥再连接。" + "value" : "完成 Kerberos 认证超时。可能是 Kerberos KDC(域控制器)无法访问、服务器的 SPN 缺失,或本设备的时钟不准。请检查到域的网络,或改用 SQL Server 身份验证。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此連線的用戶端金鑰在本裝置上無法讀取(%@)。金鑰檔案不會在裝置之間同步,請先在此處加入金鑰再連線。" + "value" : "完成 Kerberos 驗證逾時。可能是 Kerberos KDC(網域控制站)無法連線、伺服器的 SPN 遺失,或本裝置的時鐘不準。請檢查到網域的網路,或改用 SQL Server 驗證。" } } } }, - "This database has no tables." : { + "Timed out connecting to the MySQL server." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 데이터베이스에는 테이블이 없습니다." + "value" : "MySQL 서버에 연결하는 동안 시간이 초과되었습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Cơ sở dữ liệu này không có bảng." + "value" : "Hết thời gian chờ khi kết nối tới máy chủ MySQL." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接 MySQL 服务器超时。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "連線 MySQL 伺服器逾時。" + } + } + } + }, + "Timed out connecting to the server. Check the host, port, and that the server is reachable and accepting connections." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "서버에 연결하는 동안 시간이 초과되었습니다. 호스트와 포트가 올바른지, 서버에 연결할 수 있고 서버가 연결을 수락하는지 확인하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hết thời gian chờ khi kết nối tới máy chủ. Hãy kiểm tra host, cổng, và xem máy chủ có truy cập được và đang nhận kết nối không." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "连接服务器超时。请检查主机、端口,以及服务器是否可达并接受连接。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "連線伺服器逾時。請檢查主機、連接埠,以及伺服器是否可連線並接受連線。" + } + } + } + }, + "Timed out during the Oracle login handshake. The server accepted the network connection but did not finish logging in." : { + "extractionState" : "stale", + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oracle 로그인 핸드셰이크 중 시간이 초과되었습니다. 서버가 네트워크 연결을 수락했지만 로그인을 완료하지 않았습니다." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hết thời gian chờ trong quá trình bắt tay đăng nhập Oracle. Máy chủ đã chấp nhận kết nối mạng nhưng không hoàn tất đăng nhập." + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oracle 登录握手超时。服务器接受了网络连接,但没有完成登录。" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "Oracle 登入交握逾時。伺服器接受了網路連線,但沒有完成登入。" + } + } + } + }, + "Too many rows. Add up to %lld rows at a time." : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "행이 너무 많습니다. 한 번에 최대 %lld개까지 추가하십시오." + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "Quá nhiều dòng. Mỗi lần chỉ thêm tối đa %lld dòng." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此数据库没有表。" + "value" : "行数过多。每次最多添加 %lld 行。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此資料庫沒有資料表。" + "value" : "列數過多。每次最多新增 %lld 列。" } } } }, - "This file has no private key. Export the certificate together with its key." : { + "Touch and hold a connection to rename, duplicate, or move it to a group." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 파일에 개인 키가 없습니다. 인증서를 개인 키와 함께 내보내십시오." + "value" : "연결을 길게 터치하여 이름을 변경하거나, 복제하거나, 그룹으로 이동하십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tệp này không có khóa riêng tư. Hãy xuất chứng chỉ cùng với khóa của nó." + "value" : "Chạm và giữ một kết nối để đổi tên, nhân đôi hoặc di chuyển vào một nhóm." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此文件没有私钥。请将证书与其密钥一起导出。" + "value" : "触碰并按住连接,即可将其重命名、复制或移动到分组。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此檔案沒有私密金鑰。請將憑證與其金鑰一起匯出。" + "value" : "按住連線即可重新命名、複製,或將其移至群組。" } } } }, - "This file is encrypted" : { + "Truncate" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 파일은 암호화되어 있습니다" + "value" : "비우기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tệp này được mã hóa" + "value" : "Xóa dữ liệu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此文件已加密" + "value" : "清空" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此檔案已加密" + "value" : "清空" } } } }, - "This file is encrypted and requires a passphrase" : { + "Truncate Table" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 파일은 암호화되어 있어 암호가 필요합니다" + "value" : "테이블 비우기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tệp này được mã hóa và yêu cầu cụm mật khẩu" + "value" : "Xóa dữ liệu bảng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此文件已加密,需要密码短语" + "value" : "清空表" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此檔案已加密,需要通關密語" + "value" : "清空資料表" } } } }, - "This file is not a valid TablePro export" : { + "Trust" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 파일은 올바른 TablePro 내보내기 파일이 아닙니다" + "value" : "신뢰" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tệp này không phải là tệp xuất TablePro hợp lệ" + "value" : "Tin cậy" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此文件不是有效的 TablePro 导出文件" + "value" : "信任" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此檔案不是有效的 TablePro 匯出檔" + "value" : "信任" } } } }, - "This file requires a newer version of TablePro (format version %d)" : { + "Try Again" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 파일을 사용하려면 더 최신 버전의 TablePro가 필요합니다(파일 형식 버전 %d)" + "value" : "다시 시도" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tệp này yêu cầu phiên bản TablePro mới hơn (phiên bản định dạng %d)" + "value" : "Thử lại" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此文件需要更新版本的TablePro(格式版本%d)" + "value" : "重试" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此檔案需要較新版本的 TablePro(格式版本 %d)" + "value" : "再試一次" } } } }, - "This query will modify data. Are you sure you want to continue?" : { + "Try again or check your connection." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 쿼리는 데이터를 변경합니다. 계속하시겠습니까?" + "value" : "다시 시도하거나 연결을 확인하십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Truy vấn này sẽ thay đổi dữ liệu. Bạn có chắc muốn tiếp tục?" + "value" : "Thử lại hoặc kiểm tra kết nối." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此查询将修改数据,确定要继续吗?" + "value" : "请重试或检查你的连接。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此查詢將會修改資料。確定要繼續嗎?" + "value" : "請再試一次或檢查你的連線。" } } } }, - "This server has no password set for the default user. Clear the Password field." : { + "Turn On iCloud Sync" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 서버의 기본 사용자에게는 암호가 설정되어 있지 않습니다. 암호 필드를 비우십시오." + "value" : "iCloud 동기화 켜기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Máy chủ này không đặt mật khẩu cho người dùng mặc định. Hãy xóa trống trường Mật khẩu." + "value" : "Bật Đồng bộ iCloud" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此服务器未为默认用户设置密码。请清空“密码”字段。" + "value" : "开启 iCloud 同步" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此伺服器未為預設使用者設定密碼。請清空「密碼」欄位。" + "value" : "開啟 iCloud 同步" } } } }, - "This server predates Redis 6 and takes no username. Clear the Username field." : { + "Type" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 서버는 Redis 6 이전 버전이므로 사용자 이름을 사용하지 않습니다. 사용자 이름 필드를 비우십시오." + "value" : "유형" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Máy chủ này cũ hơn Redis 6 và không nhận tên người dùng. Hãy xóa trống trường Tên người dùng." + "value" : "Loại" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此服务器早于 Redis 6,不接受用户名。请清空“用户名”字段。" + "value" : "类型" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此伺服器早於 Redis 6,不接受使用者名稱。請清空「使用者名稱」欄位。" + "value" : "型別" } } } }, - "This table has no foreign key relationships." : { + "Type a tag's name in the search field to show only the connections that carry it." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 테이블에는 외래 키 관계가 없습니다." + "value" : "검색 필드에 태그 이름을 입력하면 해당 태그가 지정된 연결만 표시됩니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Bảng này không có quan hệ khóa ngoại." + "value" : "Nhập tên nhãn vào trường tìm kiếm để chỉ hiện các kết nối có nhãn đó." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此表没有外键关系。" + "value" : "在搜索栏中输入标签名称,即可只显示带有该标签的连接。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此資料表沒有外鍵關聯。" + "value" : "在搜尋欄位中輸入標籤名稱,即可只顯示帶有該標籤的連線。" } } } }, - "This table has no indexes." : { + "UTF-8 via Latin 1" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 테이블에는 인덱스가 없습니다." + "value" : "UTF-8(Latin 1 경유)" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Bảng này không có chỉ mục." + "value" : "UTF-8 qua Latin 1" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此表没有索引。" + "value" : "UTF-8(经由 Latin 1)" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此資料表沒有索引。" + "value" : "UTF-8(透過 Latin 1)" } } } }, - "This table is empty." : { + "Unfavorite" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 테이블은 비어 있습니다." + "value" : "즐겨찾기 해제" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Bảng này trống." + "value" : "Bỏ yêu thích" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此表为空。" + "value" : "取消收藏" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此資料表是空的。" + "value" : "移出我的最愛" } } } }, - "This table needs a primary key to identify the row." : { + "Ungrouped" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "행을 식별하려면 이 테이블에 기본 키가 있어야 합니다." + "value" : "그룹 없음" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Bảng này cần khóa chính để xác định dòng." + "value" : "Chưa phân nhóm" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "此表需要主键来标识行。" + "value" : "未分组" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "此資料表需要主鍵來識別該列。" + "value" : "未分組" } } } }, - "This will insert a row into %@. Continue?" : { + "Unique" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%@에 행을 삽입합니다. 계속하시겠습니까?" + "value" : "고유" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thao tác này sẽ chèn một dòng vào %@. Tiếp tục?" + "value" : "Duy nhất" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "这将向 %@ 插入一行。是否继续?" + "value" : "唯一" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "這將向 %@ 插入一列。是否繼續?" + "value" : "唯一" } } } }, - "This will update a row in %@. Continue?" : { + "Unknown SSH Server" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "%@의 행을 업데이트합니다. 계속하시겠습니까?" + "value" : "알 수 없는 SSH 서버" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thao tác này sẽ cập nhật một dòng trong %@. Tiếp tục?" + "value" : "Máy chủ SSH không xác định" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "这将更新 %@ 中的一行。是否继续?" + "value" : "未知的 SSH 服务器" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "這將更新 %@ 中的一列。是否繼續?" + "value" : "未知的 SSH 伺服器" } } } }, - "Timed out completing Kerberos authentication. The Kerberos KDC (domain controller) may be unreachable, the server's SPN may be missing, or this device's clock may be off. Check your network to the domain, or use SQL Server Authentication." : { + "Unknown error" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Kerberos 인증을 완료하는 동안 시간이 초과되었습니다. Kerberos KDC(도메인 컨트롤러)에 연결할 수 없거나, 서버의 SPN이 없거나, 이 기기의 시계가 맞지 않을 수 있습니다. 도메인에 대한 네트워크 연결을 확인하거나 SQL Server 인증을 사용하십시오." + "value" : "알 수 없는 오류" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Hết thời gian chờ khi hoàn tất xác thực Kerberos. Có thể KDC Kerberos (domain controller) không truy cập được, SPN của máy chủ bị thiếu, hoặc đồng hồ của thiết bị này bị lệch. Hãy kiểm tra mạng tới domain, hoặc dùng SQL Server Authentication." + "value" : "Lỗi không xác định" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "完成 Kerberos 认证超时。可能是 Kerberos KDC(域控制器)无法访问、服务器的 SPN 缺失,或本设备的时钟不准。请检查到域的网络,或改用 SQL Server 身份验证。" + "value" : "未知错误" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "完成 Kerberos 驗證逾時。可能是 Kerberos KDC(網域控制站)無法連線、伺服器的 SPN 遺失,或本裝置的時鐘不準。請檢查到網域的網路,或改用 SQL Server 驗證。" + "value" : "未知錯誤" } } } }, - "Timed out connecting to the server. Check the host, port, and that the server is reachable and accepting connections." : { + "Unlock" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "서버에 연결하는 동안 시간이 초과되었습니다. 호스트와 포트가 올바른지, 서버에 연결할 수 있고 서버가 연결을 수락하는지 확인하십시오." + "value" : "잠금 해제" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Hết thời gian chờ khi kết nối tới máy chủ. Hãy kiểm tra host, cổng, và xem máy chủ có truy cập được và đang nhận kết nối không." + "value" : "Mở khóa" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "连接服务器超时。请检查主机、端口,以及服务器是否可达并接受连接。" + "value" : "解锁" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "連線伺服器逾時。請檢查主機、連接埠,以及伺服器是否可連線並接受連線。" + "value" : "解鎖" } } } }, - "Timed out during the Oracle login handshake. The server accepted the network connection but did not finish logging in." : { + "Unlock TablePro to access your database connections." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle 로그인 핸드셰이크 중 시간이 초과되었습니다. 서버가 네트워크 연결을 수락했지만 로그인을 완료하지 않았습니다." + "value" : "데이터베이스 연결에 접근하려면 TablePro의 잠금을 해제하십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Hết thời gian chờ trong quá trình bắt tay đăng nhập Oracle. Máy chủ đã chấp nhận kết nối mạng nhưng không hoàn tất đăng nhập." + "value" : "Mở khóa TablePro để truy cập các kết nối cơ sở dữ liệu." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle 登录握手超时。服务器接受了网络连接,但没有完成登录。" + "value" : "解锁 TablePro 以访问你的数据库连接。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "Oracle 登入交握逾時。伺服器接受了網路連線,但沒有完成登入。" + "value" : "解鎖 TablePro 以存取你的資料庫連線。" } } } }, - "Too many rows. Add up to %lld rows at a time." : { + "Unsupported encryption version %d" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "행이 너무 많습니다. 한 번에 최대 %lld개까지 추가하십시오." + "value" : "지원되지 않는 암호화 버전 %d" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Quá nhiều dòng. Mỗi lần chỉ thêm tối đa %lld dòng." + "value" : "Phiên bản mã hoá %d không được hỗ trợ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "行数过多。每次最多添加 %lld 行。" + "value" : "不支持的加密版本%d" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "列數過多。每次最多新增 %lld 列。" + "value" : "不支援的加密版本 %d" } } } }, - "Truncate" : { + "Use Default" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "비우기" + "value" : "기본값 사용" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Xóa dữ liệu" + "value" : "Dùng mặc định" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "清空" + "value" : "使用默认值" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "清空" + "value" : "使用預設值" } } } }, - "Truncate Table" : { + "Use Passcode" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "테이블 비우기" + "value" : "암호 사용" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Xóa dữ liệu bảng" + "value" : "Dùng mật mã" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "清空表" + "value" : "使用密码" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "清空資料表" + "value" : "使用密碼" } } } }, - "Trust" : { + "Use SID Instead" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "신뢰" + "value" : "대신 SID 사용" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tin cậy" + "value" : "Dùng SID thay thế" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "信任" + "value" : "改用 SID" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "信任" + "value" : "改用 SID" } } } }, - "Try Again" : { + "Use Service Name Instead" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "다시 시도" + "value" : "대신 서비스 이름 사용" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thử lại" + "value" : "Dùng tên dịch vụ thay thế" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "重试" + "value" : "改用服务名" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "再試一次" + "value" : "改用服務名稱" } } } }, - "Try again or check your connection." : { + "Use iCloud" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "다시 시도하거나 연결을 확인하십시오." + "value" : "iCloud 사용" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Thử lại hoặc kiểm tra kết nối." + "value" : "Dùng iCloud" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "请重试或检查你的连接。" + "value" : "使用 iCloud" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "請再試一次或檢查你的連線。" + "value" : "使用 iCloud" } } } }, - "Type" : { + "Username" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "유형" + "value" : "사용자 이름" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Loại" + "value" : "Tên đăng nhập" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "类型" + "value" : "用户名" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "型別" + "value" : "使用者名稱" } } } }, - "Ungrouped" : { + "Username is for Redis 6 and later ACL users. Leave it empty for password-only servers." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "그룹 없음" + "value" : "사용자 이름은 Redis 6 이상의 ACL 사용자용입니다. 암호만 사용하는 서버에서는 비워 두십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chưa phân nhóm" + "value" : "Tên người dùng dành cho người dùng ACL của Redis 6 trở lên. Để trống với các máy chủ chỉ dùng mật khẩu." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未分组" + "value" : "用户名用于 Redis 6 及更高版本的 ACL 用户。仅使用密码的服务器请留空。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "未分組" + "value" : "使用者名稱用於 Redis 6 及更新版本的 ACL 使用者。僅使用密碼的伺服器請留空。" } } } }, - "Unique" : { - "extractionState" : "stale", + "VALUE" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "고유" + "value" : "값" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Duy nhất" + "value" : "GIÁ TRỊ" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "唯一" + "value" : "值" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "唯一" + "value" : "值" } } } }, - "Unknown SSH Server" : { + "Value" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "알 수 없는 SSH 서버" + "value" : "값" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Máy chủ SSH không xác định" + "value" : "Giá trị" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "未知的 SSH 服务器" + "value" : "值" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "未知的 SSH 伺服器" + "value" : "值" } } } }, - "Unlock" : { + "Value for %@" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "잠금 해제" + "value" : "%@ 값" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Mở khóa" + "value" : "Giá trị cho %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "解锁" + "value" : "%@ 的值" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "解鎖" + "value" : "%@ 的值" } } } }, - "Unlock TablePro to access your database connections." : { + "Verify CA" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "데이터베이스 연결에 접근하려면 TablePro의 잠금을 해제하십시오." + "value" : "CA 확인" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Mở khóa TablePro để truy cập các kết nối cơ sở dữ liệu." + "value" : "Xác minh CA" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "解锁 TablePro 以访问你的数据库连接。" + "value" : "验证 CA" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "解鎖 TablePro 以存取你的資料庫連線。" + "value" : "驗證 CA" } } } }, - "Unsupported encryption version %d" : { + "Verify Identity" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "지원되지 않는 암호화 버전 %d" + "value" : "신원 확인" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Phiên bản mã hoá %d không được hỗ trợ" + "value" : "Xác minh danh tính" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "不支持的加密版本%d" + "value" : "验证身份" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "不支援的加密版本 %d" + "value" : "驗證身分" } } } }, - "Use Passcode" : { + "Version" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "암호 사용" + "value" : "버전" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Dùng mật mã" + "value" : "Phiên bản" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "使用密码" + "value" : "版本" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "使用密碼" + "value" : "版本" } } } }, - "Use SID Instead" : { + "Version %@" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "대신 SID 사용" + "value" : "버전 %@" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Dùng SID thay thế" + "value" : "Phiên bản %@" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "改用 SID" + "value" : "版本 %@" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "改用 SID" + "value" : "版本 %@" } } } }, - "Use Service Name Instead" : { + "View" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "대신 서비스 이름 사용" + "value" : "보기" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Dùng tên dịch vụ thay thế" + "value" : "View" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "改用服务名" + "value" : "视图" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "改用服務名稱" + "value" : "檢視表" } } } }, - "Username" : { + "Views" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "사용자 이름" + "value" : "뷰" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tên đăng nhập" + "value" : "View" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "用户名" + "value" : "视图" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "使用者名稱" + "value" : "檢視表" } } } }, - "Username is for Redis 6 and later ACL users. Leave it empty for password-only servers." : { + "Welcome to TablePro" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "사용자 이름은 Redis 6 이상의 ACL 사용자용입니다. 암호만 사용하는 서버에서는 비워 두십시오." + "value" : "TablePro에 오신 것을 환영합니다" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Tên người dùng dành cho người dùng ACL của Redis 6 trở lên. Để trống với các máy chủ chỉ dùng mật khẩu." + "value" : "Chào mừng đến TablePro" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "用户名用于 Redis 6 及更高版本的 ACL 用户。仅使用密码的服务器请留空。" + "value" : "欢迎使用TablePro" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "使用者名稱用於 Redis 6 及更新版本的 ACL 使用者。僅使用密碼的伺服器請留空。" + "value" : "歡迎使用 TablePro" } } } }, - "Value" : { + "What's New" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "값" + "value" : "새로운 기능" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Giá trị" + "value" : "Có gì mới" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "值" + "value" : "新功能" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "值" + "value" : "新功能" } } } }, - "Verify CA" : { + "What's New in TablePro" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "CA 확인" + "value" : "TablePro의 새로운 기능" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Xác minh CA" + "value" : "Có gì mới trong TablePro" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "验证 CA" + "value" : "TablePro 新功能" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "驗證 CA" + "value" : "TablePro 的新功能" } } } }, - "Verify Identity" : { + "When off, connections, groups, and tags stay on this device only. Existing iCloud data is not deleted." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "신원 확인" + "value" : "끄면 연결, 그룹 및 태그가 이 기기에만 유지됩니다. 기존 iCloud 데이터는 삭제되지 않습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Xác minh danh tính" + "value" : "Khi tắt, các kết nối, nhóm và thẻ chỉ ở trên thiết bị này. Dữ liệu iCloud hiện có sẽ không bị xóa." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "验证身份" + "value" : "关闭后,连接、分组和标签将仅保留在此设备上。现有的 iCloud 数据不会被删除。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "驗證身分" + "value" : "關閉時,連線、群組與標籤只會保留在此裝置上。現有的 iCloud 資料不會被刪除。" } } } }, - "Version" : { + "When off, connections, groups, and tags stay on this device only. Existing iCloud data is not deleted. Passwords stay on this device unless you turn on Sync Passwords." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "버전" + "value" : "이 옵션을 끄면 연결, 그룹 및 태그가 이 기기에만 저장됩니다. 기존 iCloud 데이터는 삭제되지 않습니다. 암호 동기화를 켜지 않는 한 암호도 이 기기에만 저장됩니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Phiên bản" + "value" : "Khi tắt, kết nối, nhóm và thẻ chỉ nằm trên thiết bị này. Dữ liệu iCloud hiện có không bị xóa. Mật khẩu vẫn ở trên thiết bị này trừ khi bạn bật Đồng bộ mật khẩu." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "版本" + "value" : "关闭后,连接、分组和标签只保留在本设备上。已有的 iCloud 数据不会被删除。除非你开启“同步密码”,否则密码只保留在本设备。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "版本" + "value" : "關閉後,連線、群組和標籤只保留在本裝置上。已有的 iCloud 資料不會被刪除。除非你開啟「同步密碼」,否則密碼只保留在本裝置。" } } } }, - "View" : { + "When off, nothing is sent to iCloud and nothing already there is deleted. Changes you make meanwhile sync once you turn it back on." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "보기" + "value" : "끄면 iCloud로 아무것도 전송되지 않으며 이미 iCloud에 있는 데이터도 삭제되지 않습니다. 꺼져 있는 동안 변경한 내용은 다시 켜면 동기화됩니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "View" + "value" : "Khi tắt, không có gì được gửi lên iCloud và những gì đã có ở đó không bị xóa. Các thay đổi bạn thực hiện trong thời gian này sẽ đồng bộ khi bạn bật lại." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "视图" + "value" : "关闭后,不会向 iCloud 发送任何内容,也不会删除 iCloud 中已有的内容。在此期间所做的更改会在你重新开启后同步。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "檢視表" + "value" : "關閉時,不會傳送任何內容到 iCloud,也不會刪除 iCloud 上已有的內容。期間所做的變更會在你重新開啟後同步。" } } } }, - "Views" : { - "extractionState" : "stale", + "When on, the lock screen and Dynamic Island show \"Running query\" instead of the SQL preview." : { + "extractionState" : "manual", "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "뷰" + "value" : "켜면 잠금 화면과 Dynamic Island에 SQL 미리 보기 대신 \"쿼리 실행 중\"이 표시됩니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "View" + "value" : "Khi bật, màn hình khóa và Dynamic Island hiển thị \"Đang chạy truy vấn\" thay cho nội dung SQL." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "视图" + "value" : "开启后,锁定屏幕和灵动岛将显示“正在运行查询”而非 SQL 预览。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "檢視表" + "value" : "開啟時,鎖定畫面與動態島會顯示「正在執行查詢」,而非 SQL 預覽。" } } } }, - "Welcome to TablePro" : { + "When you first connected and first ran a query" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "TablePro에 오신 것을 환영합니다" + "value" : "처음 연결한 시점과 처음 쿼리를 실행한 시점" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Chào mừng đến TablePro" + "value" : "Thời điểm bạn kết nối lần đầu và chạy truy vấn lần đầu" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "欢迎使用TablePro" + "value" : "你首次连接和首次运行查询的时间" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "歡迎使用 TablePro" + "value" : "你首次連線與首次執行查詢的時間" } } } }, - "When off, connections, groups, and tags stay on this device only. Existing iCloud data is not deleted." : { + "Which database types you connect to, and how many connections are open" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "끄면 연결, 그룹 및 태그가 이 기기에만 유지됩니다. 기존 iCloud 데이터는 삭제되지 않습니다." + "value" : "연결하는 데이터베이스 유형 및 열려 있는 연결 수" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Khi tắt, các kết nối, nhóm và thẻ chỉ ở trên thiết bị này. Dữ liệu iCloud hiện có sẽ không bị xóa." + "value" : "Các loại cơ sở dữ liệu bạn kết nối tới và số kết nối đang mở" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "关闭后,连接、分组和标签将仅保留在此设备上。现有的 iCloud 数据不会被删除。" + "value" : "你连接的数据库类型,以及打开的连接数量" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "關閉時,連線、群組與標籤只會保留在此裝置上。現有的 iCloud 資料不會被刪除。" + "value" : "你連線的資料庫類型,以及開啟中的連線數量" } } } }, - "When off, connections, groups, and tags stay on this device only. Existing iCloud data is not deleted. Passwords stay on this device unless you turn on Sync Passwords." : { + "Windows Authentication (Kerberos) isn't supported on iOS yet. Use SQL Server Authentication, or connect from the Mac app." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "이 옵션을 끄면 연결, 그룹 및 태그가 이 기기에만 저장됩니다. 기존 iCloud 데이터는 삭제되지 않습니다. 암호 동기화를 켜지 않는 한 암호도 이 기기에만 저장됩니다." + "value" : "Windows 인증(Kerberos)은 아직 iOS에서 지원되지 않습니다. SQL Server 인증을 사용하거나 Mac 앱에서 연결하십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Khi tắt, kết nối, nhóm và thẻ chỉ nằm trên thiết bị này. Dữ liệu iCloud hiện có không bị xóa. Mật khẩu vẫn ở trên thiết bị này trừ khi bạn bật Đồng bộ mật khẩu." + "value" : "Windows Authentication (Kerberos) chưa được hỗ trợ trên iOS. Hãy dùng SQL Server Authentication, hoặc kết nối từ ứng dụng Mac." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "关闭后,连接、分组和标签只保留在本设备上。已有的 iCloud 数据不会被删除。除非你开启“同步密码”,否则密码只保留在本设备。" + "value" : "iOS 上尚不支持 Windows 身份验证(Kerberos)。请使用 SQL Server 身份验证,或从 Mac 应用连接。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "關閉後,連線、群組和標籤只保留在本裝置上。已有的 iCloud 資料不會被刪除。除非你開啟「同步密碼」,否則密碼只保留在本裝置。" + "value" : "iOS 上尚不支援 Windows 驗證(Kerberos)。請使用 SQL Server 驗證,或從 Mac App 連線。" } } } }, - "When on, the lock screen and Dynamic Island show \"Running query\" instead of the SQL preview." : { - "extractionState" : "manual", + "Write Query Blocked" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "켜면 잠금 화면과 Dynamic Island에 SQL 미리 보기 대신 \"쿼리 실행 중\"이 표시됩니다." + "value" : "쓰기 쿼리 차단됨" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Khi bật, màn hình khóa và Dynamic Island hiển thị \"Đang chạy truy vấn\" thay cho nội dung SQL." + "value" : "Truy vấn ghi bị chặn" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "开启后,锁定屏幕和灵动岛将显示“正在运行查询”而非 SQL 预览。" + "value" : "写入查询被阻止" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "開啟時,鎖定畫面與動態島會顯示「正在執行查詢」,而非 SQL 預覽。" + "value" : "寫入查詢已封鎖" } } } }, - "Windows Authentication (Kerberos) isn't supported on iOS yet. Use SQL Server Authentication, or connect from the Mac app." : { + "Write SQL and tap the play button." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "Windows 인증(Kerberos)은 아직 iOS에서 지원되지 않습니다. SQL Server 인증을 사용하거나 Mac 앱에서 연결하십시오." + "value" : "SQL을 작성하고 재생 버튼을 탭하십시오." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Windows Authentication (Kerberos) chưa được hỗ trợ trên iOS. Hãy dùng SQL Server Authentication, hoặc kết nối từ ứng dụng Mac." + "value" : "Viết SQL và nhấn nút chạy." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "iOS 上尚不支持 Windows 身份验证(Kerberos)。请使用 SQL Server 身份验证,或从 Mac 应用连接。" + "value" : "编写SQL并点击运行按钮。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "iOS 上尚不支援 Windows 驗證(Kerberos)。請使用 SQL Server 驗證,或從 Mac App 連線。" + "value" : "輸入 SQL 並點按播放按鈕。" } } } }, - "Write Query Blocked" : { + "Write and Run SQL" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "쓰기 쿼리 차단됨" + "value" : "SQL 작성 및 실행" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Truy vấn ghi bị chặn" + "value" : "Viết và chạy SQL" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "写入查询被阻止" + "value" : "编写并运行 SQL" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "寫入查詢已封鎖" + "value" : "撰寫並執行 SQL" } } } }, - "Write SQL and tap the play button." : { + "Yellow" : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "SQL을 작성하고 재생 버튼을 탭하십시오." + "value" : "노란색" } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Viết SQL và nhấn nút chạy." + "value" : "Vàng" } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "编写SQL并点击运行按钮。" + "value" : "黄色" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "輸入 SQL 並點按播放按鈕。" + "value" : "黃色" } } } }, - "Yellow" : { + "Your connections could not be loaded, so nothing can be added right now." : { "localizations" : { "ko" : { "stringUnit" : { "state" : "translated", - "value" : "노란색" + "value" : "연결을 불러올 수 없어 지금은 아무것도 추가할 수 없습니다." } }, "vi" : { "stringUnit" : { "state" : "translated", - "value" : "Vàng" + "value" : "Không tải được các kết nối của bạn nên hiện chưa thể thêm gì." } }, "zh-Hans" : { "stringUnit" : { "state" : "translated", - "value" : "黄色" + "value" : "无法加载你的连接,因此目前无法添加任何内容。" } }, "zh-Hant" : { "stringUnit" : { "state" : "translated", - "value" : "黃色" + "value" : "無法載入你的連線,因此目前無法新增任何項目。" } } } @@ -15314,6 +19070,34 @@ } } }, + "iCloud" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud" + } + } + } + }, "iCloud Sync" : { "localizations" : { "ko" : { @@ -15342,7 +19126,36 @@ } } }, + "iCloud Unavailable" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud를 사용할 수 없음" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud không khả dụng" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud 不可用" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud 無法使用" + } + } + } + }, "iCloud account is not available. Sign in to iCloud in System Settings." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -15371,6 +19184,7 @@ } }, "iCloud rejected %d change(s). They stay on this device and will retry: %@" : { + "extractionState" : "stale", "localizations" : { "en" : { "stringUnit" : { @@ -15405,6 +19219,7 @@ } }, "iCloud server error: %@" : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -15433,6 +19248,7 @@ } }, "iCloud storage is full. Free up space in iCloud and try again." : { + "extractionState" : "stale", "localizations" : { "ko" : { "stringUnit" : { @@ -15516,6 +19332,34 @@ } } }, + "in %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "그룹 %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "trong nhóm %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "位于 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "位於 %@" + } + } + } + }, "insert" : { "localizations" : { "ko" : { @@ -15936,6 +19780,34 @@ } } }, + "tags %@" : { + "localizations" : { + "ko" : { + "stringUnit" : { + "state" : "translated", + "value" : "태그 %@" + } + }, + "vi" : { + "stringUnit" : { + "state" : "translated", + "value" : "nhãn %@" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标签 %@" + } + }, + "zh-Hant" : { + "stringUnit" : { + "state" : "translated", + "value" : "標籤 %@" + } + } + } + }, "≤" : { "localizations" : { "ko" : { diff --git a/TableProMobile/TableProMobile/Models/RowWindow.swift b/TableProMobile/TableProMobile/Models/RowWindow.swift index d9301b0d2f..5180c3c4bc 100644 --- a/TableProMobile/TableProMobile/Models/RowWindow.swift +++ b/TableProMobile/TableProMobile/Models/RowWindow.swift @@ -39,10 +39,6 @@ struct RowWindow: Sendable { totalAppended = 0 } - var lastAbsoluteIndex: Int { - firstAbsoluteIndex + rows.count - 1 - } - var isEmpty: Bool { rows.isEmpty } diff --git a/TableProMobile/TableProMobile/Onboarding/ConnectionListTips.swift b/TableProMobile/TableProMobile/Onboarding/ConnectionListTips.swift new file mode 100644 index 0000000000..839bf63523 --- /dev/null +++ b/TableProMobile/TableProMobile/Onboarding/ConnectionListTips.swift @@ -0,0 +1,153 @@ +import Foundation +import os +import SwiftUI +import TipKit + +nonisolated struct FavoriteSwipeTip: Tip { + static let tipId = "favorite-swipe" + + @Parameter static var isListReady: Bool = false + @Parameter static var connectionCount: Int = 0 + @Parameter static var hasFavorites: Bool = false + + var id: String { Self.tipId } + + var title: Text { + Text("Keep Connections at the Top") + } + + var message: Text? { + Text("Swipe right on a connection to add it to Favorites.") + } + + var image: Image? { + Image(systemName: "star") + } + + var rules: [Rule] { + #Rule(Self.$isListReady) { $0 == true } + #Rule(Self.$connectionCount) { $0 >= 3 } + #Rule(Self.$hasFavorites) { $0 == false } + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(2) + } +} + +nonisolated struct ConnectionActionsTip: Tip { + static let tipId = "connection-actions" + static let connectionOpened = Tips.Event(id: "connection-opened") + + var id: String { Self.tipId } + + var title: Text { + Text("More Actions") + } + + var message: Text? { + Text("Touch and hold a connection to rename, duplicate, or move it to a group.") + } + + var image: Image? { + Image(systemName: "hand.tap") + } + + var rules: [Rule] { + #Rule(FavoriteSwipeTip.$isListReady) { $0 == true } + #Rule(Self.connectionOpened) { $0.donations.count >= 3 } + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(2) + } +} + +nonisolated struct TagSearchTip: Tip { + static let tipId = "tag-search" + + @Parameter static var hasTaggedConnections: Bool = false + + var id: String { Self.tipId } + + var title: Text { + Text("Filter by Tag") + } + + var message: Text? { + Text("Type a tag's name in the search field to show only the connections that carry it.") + } + + var image: Image? { + Image(systemName: "tag") + } + + var rules: [Rule] { + #Rule(FavoriteSwipeTip.$isListReady) { $0 == true } + #Rule(Self.$hasTaggedConnections) { $0 == true } + #Rule(ConnectionActionsTip.connectionOpened) { $0.donations.count >= 1 } + } + + var options: [any TipOption] { + Tips.MaxDisplayCount(2) + } +} + +enum ConnectionListTips { + private static let logger = Logger(subsystem: "com.TablePro", category: "ConnectionListTips") + + private(set) static var isConfigured = false + + static func configure(isTestRuntime: Bool = TestRuntime.isActive) { + guard !isTestRuntime, !isConfigured else { return } + do { + try Tips.configure([.displayFrequency(.daily)]) + isConfigured = true + } catch { + logger.error("Could not configure tips: \(error.localizedDescription, privacy: .public)") + } + } + + static func makeGroup() -> TipGroup { + TipGroup(.firstAvailable) { + FavoriteSwipeTip() + ConnectionActionsTip() + TagSearchTip() + } + } + + static func libraryChanged( + isListReady: Bool, + connectionCount: Int, + hasFavorites: Bool, + hasTaggedConnections: Bool + ) { + guard isConfigured else { return } + FavoriteSwipeTip.isListReady = isListReady + FavoriteSwipeTip.connectionCount = connectionCount + FavoriteSwipeTip.hasFavorites = hasFavorites + TagSearchTip.hasTaggedConnections = hasTaggedConnections + } + + static func connectionOpened() { + guard isConfigured else { return } + ConnectionActionsTip.connectionOpened.sendDonation() + } + + static func favoriteSet() { + invalidate(FavoriteSwipeTip()) + } + + static func connectionMenuUsed() { + invalidate(ConnectionActionsTip()) + } + + static func tagFilterUsed() { + invalidate(TagSearchTip()) + } + + private static func invalidate(_ tip: some Tip) { + guard isConfigured else { return } + tip.invalidate(reason: .actionPerformed) + } +} diff --git a/TableProMobile/TableProMobile/Onboarding/FeatureHighlights.swift b/TableProMobile/TableProMobile/Onboarding/FeatureHighlights.swift new file mode 100644 index 0000000000..db545c6143 --- /dev/null +++ b/TableProMobile/TableProMobile/Onboarding/FeatureHighlights.swift @@ -0,0 +1,70 @@ +import Foundation + +nonisolated struct FeatureHighlight: Identifiable, Sendable { + let id: String + let systemImage: String + let title: LocalizedStringResource + let message: LocalizedStringResource +} + +nonisolated enum FeatureHighlights { + static let welcome: [FeatureHighlight] = [ + FeatureHighlight( + id: "databases", + systemImage: "cylinder.split.1x2", + title: "Many Databases, One App", + message: "MySQL, PostgreSQL, SQL Server, Oracle, Redis, SQLite, DuckDB, and more." + ), + FeatureHighlight( + id: "browse", + systemImage: "tablecells", + title: "Browse and Edit Data", + message: "Open a table, filter its rows, and change values in place." + ), + FeatureHighlight( + id: "query", + systemImage: "chevron.left.forwardslash.chevron.right", + title: "Write and Run SQL", + message: "Run queries and find the ones you ran before in History." + ), + FeatureHighlight( + id: "secure", + systemImage: "lock.shield", + title: "Connect Securely", + message: "Passwords stay in your Keychain. Connect through an SSH tunnel or over SSL." + ) + ] + + static func release(_ version: String) -> [FeatureHighlight]? { + releases[version] + } + + private static let releases: [String: [FeatureHighlight]] = [ + "1.0": [ + FeatureHighlight( + id: "sample", + systemImage: "music.note.list", + title: "Sample Database", + message: "Explore a music store database without connecting to a server." + ), + FeatureHighlight( + id: "sync", + systemImage: "icloud", + title: "Syncs with Your Mac", + message: "Connections, groups, and tags from TablePro on your Mac appear here over iCloud." + ), + FeatureHighlight( + id: "live-activity", + systemImage: "lock.iphone", + title: "Queries on the Lock Screen", + message: "A running query shows its time and row count in a Live Activity." + ), + FeatureHighlight( + id: "shortcuts", + systemImage: "apps.iphone", + title: "Shortcuts and Widgets", + message: "Open a connection or add rows from Shortcuts, Siri, and the Home Screen." + ) + ] + ] +} diff --git a/TableProMobile/TableProMobile/Onboarding/FirstRunPlan.swift b/TableProMobile/TableProMobile/Onboarding/FirstRunPlan.swift new file mode 100644 index 0000000000..a04718ab8a --- /dev/null +++ b/TableProMobile/TableProMobile/Onboarding/FirstRunPlan.swift @@ -0,0 +1,50 @@ +import Foundation + +nonisolated enum FirstRunPage: Hashable, Sendable { + case welcome + case iCloud + case usageData +} + +nonisolated enum LaunchPresentation: Hashable, Sendable { + case none + case firstRun([FirstRunPage]) + case whatsNew(version: String) +} + +nonisolated struct FirstRunPlan: Sendable { + let hasSeenWelcome: Bool + let syncChoice: Bool? + let usageDataChoice: Bool? + let lastSeenVersion: String? + let currentVersion: String + let hasHighlightsForCurrentVersion: Bool + + var presentation: LaunchPresentation { + let pages = firstRunPages + if !pages.isEmpty { + return .firstRun(pages) + } + guard isUpgrade, hasHighlightsForCurrentVersion else { return .none } + return .whatsNew(version: currentVersion) + } + + private var firstRunPages: [FirstRunPage] { + var pages: [FirstRunPage] = [] + if !hasSeenWelcome { + pages.append(.welcome) + if syncChoice == nil { + pages.append(.iCloud) + } + } + if usageDataChoice == nil { + pages.append(.usageData) + } + return pages + } + + private var isUpgrade: Bool { + guard let lastSeenVersion else { return false } + return lastSeenVersion != currentVersion + } +} diff --git a/TableProMobile/TableProMobile/Onboarding/FirstRunSheet.swift b/TableProMobile/TableProMobile/Onboarding/FirstRunSheet.swift new file mode 100644 index 0000000000..6592a6b2c1 --- /dev/null +++ b/TableProMobile/TableProMobile/Onboarding/FirstRunSheet.swift @@ -0,0 +1,179 @@ +import CloudKit +import SwiftUI + +struct FirstRunSheet: View { + let pages: [FirstRunPage] + + @Environment(AppState.self) private var appState + @Environment(\.dismiss) private var dismiss + @State private var path: [FirstRunPage] = [] + @State private var isAdvancing = false + + var body: some View { + NavigationStack(path: $path) { + page(pages.first ?? .usageData) + .navigationDestination(for: FirstRunPage.self) { page($0) } + } + } + + @ViewBuilder + private func page(_ page: FirstRunPage) -> some View { + switch page { + case .welcome: + WelcomePage(isAdvancing: isAdvancing) { + advance(from: .welcome) + } + .toolbar(.hidden, for: .navigationBar) + case .iCloud: + ICloudPage( + onUse: { + appState.setCloudSyncEnabled(true) + advance(from: .iCloud) + }, + onNotNow: { + appState.setCloudSyncEnabled(false) + advance(from: .iCloud) + } + ) + .navigationBarTitleDisplayMode(.inline) + case .usageData: + UsageDataPage( + onShare: { + appState.setUsageDataEnabled(true) + advance(from: .usageData) + }, + onDontShare: { + appState.setUsageDataEnabled(false) + advance(from: .usageData) + } + ) + .navigationBarTitleDisplayMode(.inline) + } + } + + private func advance(from current: FirstRunPage) { + guard !isAdvancing else { return } + isAdvancing = true + Task { + defer { isAdvancing = false } + guard let next = await nextPage(after: current) else { + dismiss() + return + } + path.append(next) + } + } + + private func nextPage(after current: FirstRunPage) async -> FirstRunPage? { + guard let index = pages.firstIndex(of: current) else { return nil } + for candidate in pages.dropFirst(index + 1) { + if candidate == .iCloud, await !isICloudAvailable() { + continue + } + return candidate + } + return nil + } + + private func isICloudAvailable() async -> Bool { + await appState.syncCoordinator.accountStatus() == .available + } +} + +private struct WelcomePage: View { + let isAdvancing: Bool + let onContinue: () -> Void + + var body: some View { + OnboardingPageLayout { + OnboardingHeader(title: "Welcome to TablePro", message: nil, image: .appIcon) + } content: { + FeatureHighlightList(highlights: FeatureHighlights.welcome) + } actions: { + Button(action: onContinue) { + Text("Continue") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .disabled(isAdvancing) + .accessibilityIdentifier("first-run-continue") + } + } +} + +private struct ICloudPage: View { + let onUse: () -> Void + let onNotNow: () -> Void + + var body: some View { + OnboardingPageLayout { + OnboardingHeader( + title: "Sync with iCloud", + message: "Keep your connections, groups, and tags the same on your iPhone, iPad, and Mac.", + image: .symbol("icloud") + ) + } content: { + Text("Passwords stay on this device unless you turn on Sync Passwords in Settings. You can change this at any time in Settings.") + .font(.footnote) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } actions: { + Button(action: onUse) { + Text("Use iCloud") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("first-run-use-icloud") + Button("Not Now", action: onNotNow) + .buttonStyle(.borderless) + .accessibilityIdentifier("first-run-icloud-not-now") + } + } +} + +private struct UsageDataPage: View { + let onShare: () -> Void + let onDontShare: () -> Void + + var body: some View { + OnboardingPageLayout { + OnboardingHeader( + title: "Share Usage Data?", + message: "Help decide what to improve next by sending one small report a day.", + image: .symbol("chart.bar.xaxis") + ) + } content: { + UsageDataDisclosure() + } actions: { + Button(action: onShare) { + Text("Share Usage Data") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("first-run-share-usage") + Button("Don't Share", action: onDontShare) + .buttonStyle(.borderless) + .accessibilityIdentifier("first-run-dont-share-usage") + } + } +} + +struct UsageDataDisclosure: View { + var body: some View { + VStack(alignment: .leading, spacing: 12) { + Text("The report contains:") + .font(.subheadline.weight(.semibold)) + VStack(alignment: .leading, spacing: 6) { + Label("A hashed identifier for this device", systemImage: "number") + Label("The app and iOS versions, and your language", systemImage: "info.circle") + Label("Which database types you connect to, and how many connections are open", systemImage: "cylinder") + Label("When you first connected and first ran a query", systemImage: "calendar") + } + .font(.subheadline) + Text("It never contains a hostname, username, password, query, or any data from your databases. You can change this in Settings.") + .font(.footnote) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/TableProMobile/TableProMobile/Onboarding/OnboardingPageLayout.swift b/TableProMobile/TableProMobile/Onboarding/OnboardingPageLayout.swift new file mode 100644 index 0000000000..9c413a3bf1 --- /dev/null +++ b/TableProMobile/TableProMobile/Onboarding/OnboardingPageLayout.swift @@ -0,0 +1,162 @@ +import SwiftUI +import UIKit + +struct OnboardingPageLayout: View { + private static var readableWidth: CGFloat { 560 } + + @ViewBuilder let header: Header + @ViewBuilder let content: Content + @ViewBuilder let actions: Actions + + var body: some View { + if #available(iOS 26.0, *) { + scrollContent.safeAreaBar(edge: .bottom) { actionBar } + } else { + scrollContent.safeAreaInset(edge: .bottom) { + actionBar.background(.bar) + } + } + } + + private var scrollContent: some View { + ScrollView { + VStack(spacing: 32) { + header + content + } + .padding(.horizontal, 24) + .padding(.top, 32) + .padding(.bottom, 24) + .frame(maxWidth: Self.readableWidth) + .frame(maxWidth: .infinity) + } + .scrollBounceBehavior(.basedOnSize) + } + + @ViewBuilder + private var actionBar: some View { + if Actions.self != EmptyView.self { + VStack(spacing: 12) { + actions + } + .controlSize(.large) + .padding(.horizontal, 24) + .padding(.top, 12) + .padding(.bottom, 16) + .frame(maxWidth: Self.readableWidth) + .frame(maxWidth: .infinity) + } + } +} + +struct OnboardingHeader: View { + let title: LocalizedStringResource + let message: LocalizedStringResource? + let image: OnboardingHeaderImage + + var body: some View { + VStack(spacing: 16) { + switch image { + case .appIcon: + AppIconImage() + case .symbol(let name): + Image(systemName: name) + .font(.system(size: 56)) + .foregroundStyle(.tint) + .symbolRenderingMode(.hierarchical) + .accessibilityHidden(true) + } + Text(title) + .font(.largeTitle.bold()) + .multilineTextAlignment(.center) + .accessibilityAddTraits(.isHeader) + if let message { + Text(message) + .font(.body) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + } + .frame(maxWidth: .infinity) + } +} + +enum OnboardingHeaderImage { + case appIcon + case symbol(String) +} + +struct FeatureHighlightList: View { + let highlights: [FeatureHighlight] + + var body: some View { + VStack(alignment: .leading, spacing: 24) { + ForEach(highlights) { highlight in + FeatureHighlightRow(highlight: highlight) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct FeatureHighlightRow: View { + let highlight: FeatureHighlight + + @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @ScaledMetric(relativeTo: .title) private var symbolWidth: CGFloat = 40 + + private var layout: AnyLayout { + dynamicTypeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) + : AnyLayout(HStackLayout(alignment: .top, spacing: 16)) + } + + var body: some View { + layout { + Image(systemName: highlight.systemImage) + .font(.title) + .foregroundStyle(.tint) + .frame(width: symbolWidth) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(highlight.title) + .font(.headline) + Text(highlight.message) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + .accessibilityElement(children: .combine) + } +} + +struct AppIconImage: View { + @ScaledMetric(relativeTo: .largeTitle) private var side: CGFloat = 88 + + var body: some View { + Group { + if let icon = Self.primaryIcon { + Image(uiImage: icon) + .resizable() + } else { + Image(systemName: "cylinder.split.1x2") + .resizable() + .scaledToFit() + .padding(side * 0.2) + .foregroundStyle(.tint) + .background(.fill.tertiary) + } + } + .frame(width: side, height: side) + .clipShape(RoundedRectangle(cornerRadius: side * 0.225, style: .continuous)) + .accessibilityHidden(true) + } + + private static var primaryIcon: UIImage? { + guard let icons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any], + let primary = icons["CFBundlePrimaryIcon"] as? [String: Any], + let files = primary["CFBundleIconFiles"] as? [String], + let name = files.last else { return nil } + return UIImage(named: name) + } +} diff --git a/TableProMobile/TableProMobile/Onboarding/WhatsNewSheet.swift b/TableProMobile/TableProMobile/Onboarding/WhatsNewSheet.swift new file mode 100644 index 0000000000..4c730f5816 --- /dev/null +++ b/TableProMobile/TableProMobile/Onboarding/WhatsNewSheet.swift @@ -0,0 +1,53 @@ +import SwiftUI + +struct WhatsNewSheet: View { + let version: String + + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + WhatsNewContent(version: version) { + Button { + dismiss() + } label: { + Text("Continue") + .frame(maxWidth: .infinity) + } + .buttonStyle(.borderedProminent) + } + .toolbar(.hidden, for: .navigationBar) + } + } +} + +struct WhatsNewContent: View { + let version: String + @ViewBuilder let actions: Actions + + var body: some View { + OnboardingPageLayout { + OnboardingHeader( + title: "What's New in TablePro", + message: LocalizedStringResource("Version \(version)"), + image: .appIcon + ) + } content: { + FeatureHighlightList(highlights: FeatureHighlights.release(version) ?? []) + } actions: { + actions + } + } +} + +struct WhatsNewSettingsPage: View { + let version: String + + var body: some View { + WhatsNewContent(version: version) { + EmptyView() + } + .navigationTitle(Text("What's New")) + .navigationBarTitleDisplayMode(.inline) + } +} diff --git a/TableProMobile/TableProMobile/Platform/AppLockState.swift b/TableProMobile/TableProMobile/Platform/AppLockState.swift index 30e1297456..43eaf05582 100644 --- a/TableProMobile/TableProMobile/Platform/AppLockState.swift +++ b/TableProMobile/TableProMobile/Platform/AppLockState.swift @@ -10,7 +10,7 @@ final class AppLockState { case oneMinute = 60 case fiveMinutes = 300 case fifteenMinutes = 900 - case oneHour = 3600 + case oneHour = 3_600 var id: Int { rawValue } @@ -27,6 +27,7 @@ final class AppLockState { private(set) var isLocked: Bool private var lastBackgroundedAt: Date? + private var unlockTask: Task? private let auth: BiometricAuthService static let lockEnabledKey = "com.TablePro.settings.lockEnabled" @@ -40,6 +41,10 @@ final class AppLockState { self.isLocked = Self.shouldLockOnColdLaunch(auth: auth) } + var biometry: BiometricAuthService.Availability { + auth.availability + } + static var isLockEnabled: Bool { UserDefaults.standard.bool(forKey: lockEnabledKey) } @@ -86,17 +91,19 @@ final class AppLockState { } func unlock() async -> Bool { + guard isLocked else { return true } + if let unlockTask { + return await unlockTask.value + } let reason = String(localized: "Unlock TablePro to access your database connections.") - let success = await auth.authenticate(reason: reason) + let task = Task { await auth.authenticate(reason: reason) } + unlockTask = task + let success = await task.value + unlockTask = nil if success { isLocked = false lastBackgroundedAt = nil } return success } - - func lockNow() { - guard Self.isLockEnabled, auth.availability != .unavailable else { return } - isLocked = true - } } diff --git a/TableProMobile/TableProMobile/Platform/AppPreferences.swift b/TableProMobile/TableProMobile/Platform/AppPreferences.swift index 08ee022382..b7dac65eb5 100644 --- a/TableProMobile/TableProMobile/Platform/AppPreferences.swift +++ b/TableProMobile/TableProMobile/Platform/AppPreferences.swift @@ -3,6 +3,7 @@ import TableProModels nonisolated enum AppPreferences { static let cloudSyncEnabledKey = "com.TablePro.settings.cloudSyncEnabled" + static let usageDataKey = "com.TablePro.settings.shareAnalytics" static let syncPasswordsKey = "com.TablePro.settings.syncPasswords" static let defaultPageSizeKey = "com.TablePro.settings.defaultPageSize" static let defaultSafeModeKey = "com.TablePro.settings.defaultSafeMode" @@ -11,7 +12,11 @@ nonisolated enum AppPreferences { static let pageSizeOptions: [Int] = [50, 100, 200, 500] static var isCloudSyncEnabled: Bool { - UserDefaults.standard.object(forKey: cloudSyncEnabledKey) as? Bool ?? true + UserDefaults.standard.bool(forKey: cloudSyncEnabledKey) + } + + static var isUsageDataEnabled: Bool { + UserDefaults.standard.bool(forKey: usageDataKey) } static var syncsPasswords: Bool { diff --git a/TableProMobile/TableProMobile/Platform/ConnectionLibraryPreferences.swift b/TableProMobile/TableProMobile/Platform/ConnectionLibraryPreferences.swift index 5d4eab5892..17b385380c 100644 --- a/TableProMobile/TableProMobile/Platform/ConnectionLibraryPreferences.swift +++ b/TableProMobile/TableProMobile/Platform/ConnectionLibraryPreferences.swift @@ -57,12 +57,6 @@ final class ConnectionLibraryPreferences { commitRecents(updated) } - func clearRecent() { - var updated = recents - updated.removeAll() - commitRecents(updated) - } - func isGroupExpanded(_ groupId: UUID) -> Bool { !collapsedGroupIds.contains(groupId) } diff --git a/TableProMobile/TableProMobile/Platform/IOSAnalyticsProvider.swift b/TableProMobile/TableProMobile/Platform/IOSAnalyticsProvider.swift index 3cdf315f57..38f54e0f29 100644 --- a/TableProMobile/TableProMobile/Platform/IOSAnalyticsProvider.swift +++ b/TableProMobile/TableProMobile/Platform/IOSAnalyticsProvider.swift @@ -63,7 +63,7 @@ final class IOSAnalyticsProvider: AnalyticsEnvironmentProvider { } var isAnalyticsEnabled: Bool { - defaults.object(forKey: "com.TablePro.settings.shareAnalytics") as? Bool ?? true + defaults.bool(forKey: AppPreferences.usageDataKey) } var hasLicense: Bool { false } diff --git a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift index 789ca5b504..9577744b43 100644 --- a/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift +++ b/TableProMobile/TableProMobile/Platform/IOSDriverFactory.swift @@ -26,6 +26,8 @@ nonisolated final class IOSDriverFactory: DriverFactory { func createDriver(for connection: DatabaseConnection, password: String?) throws -> any DatabaseDriver { switch connection.type { + case .sqlite where connection.isSample: + return SQLiteDriver(path: try SampleDatabaseInstaller.live.installIfNeeded().path) case .sqlite: return SQLiteDriver(path: connection.database) case .duckdb: diff --git a/TableProMobile/TableProMobile/Platform/OnboardingPreferences.swift b/TableProMobile/TableProMobile/Platform/OnboardingPreferences.swift new file mode 100644 index 0000000000..82626d932f --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/OnboardingPreferences.swift @@ -0,0 +1,60 @@ +import Foundation +import Observation + +@MainActor @Observable +final class OnboardingPreferences { + static let hasSeenWelcomeKey = "com.TablePro.hasCompletedOnboarding" + static let lastSeenVersionKey = "com.TablePro.lastSeenAppVersion" + + @ObservationIgnored private let defaults: UserDefaults + + private(set) var syncChoice: Bool? + private(set) var usageDataChoice: Bool? + private(set) var hasSeenWelcome: Bool + private(set) var lastSeenVersion: String? + + init(defaults: UserDefaults = .standard) { + self.defaults = defaults + Self.migrateLegacyState(in: defaults) + syncChoice = defaults.object(forKey: AppPreferences.cloudSyncEnabledKey) as? Bool + usageDataChoice = defaults.object(forKey: AppPreferences.usageDataKey) as? Bool + hasSeenWelcome = defaults.bool(forKey: Self.hasSeenWelcomeKey) + lastSeenVersion = defaults.string(forKey: Self.lastSeenVersionKey) + } + + var isCloudSyncEnabled: Bool { + syncChoice == true + } + + var isUsageDataEnabled: Bool { + usageDataChoice == true + } + + func setSyncChoice(_ enabled: Bool) { + syncChoice = enabled + defaults.set(enabled, forKey: AppPreferences.cloudSyncEnabledKey) + } + + func setUsageDataChoice(_ enabled: Bool) { + usageDataChoice = enabled + defaults.set(enabled, forKey: AppPreferences.usageDataKey) + } + + func markWelcomeSeen() { + guard !hasSeenWelcome else { return } + hasSeenWelcome = true + defaults.set(true, forKey: Self.hasSeenWelcomeKey) + } + + func recordLaunch(version: String) { + guard lastSeenVersion != version else { return } + lastSeenVersion = version + defaults.set(version, forKey: Self.lastSeenVersionKey) + } + + static func migrateLegacyState(in defaults: UserDefaults) { + guard defaults.bool(forKey: hasSeenWelcomeKey), + defaults.object(forKey: AppPreferences.cloudSyncEnabledKey) == nil else { return } + defaults.set(true, forKey: AppPreferences.cloudSyncEnabledKey) + } +} diff --git a/TableProMobile/TableProMobile/Platform/SampleDatabaseInstaller.swift b/TableProMobile/TableProMobile/Platform/SampleDatabaseInstaller.swift new file mode 100644 index 0000000000..6fdc205023 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/SampleDatabaseInstaller.swift @@ -0,0 +1,97 @@ +import Foundation +import os + +nonisolated enum SampleDatabaseError: LocalizedError, Equatable { + case bundleMissing + case copyFailed(message: String) + case libraryUnavailable + + var errorDescription: String? { + switch self { + case .bundleMissing: + return String(localized: "The sample database is missing from the app.") + case .copyFailed(let message): + return String(format: String(localized: "Could not install the sample database: %@"), message) + case .libraryUnavailable: + return String(localized: "Your connections could not be loaded, so nothing can be added right now.") + } + } +} + +nonisolated struct SampleDatabaseInstaller: Sendable { + static let fileName = "Chinook.sqlite" + static let startingTable = "Track" + static let sidecarSuffixes = ["-journal", "-wal", "-shm"] + + static var connectionName: String { + String(localized: "Chinook (Sample)") + } + + static let live = SampleDatabaseInstaller( + bundledURL: Bundle.main.url(forResource: "Chinook", withExtension: "sqlite"), + directory: defaultDirectory + ) + + private static let logger = Logger(subsystem: "com.TablePro", category: "SampleDatabase") + + let bundledURL: URL? + let directory: URL + + var installedURL: URL { + directory.appendingPathComponent(Self.fileName, isDirectory: false) + } + + @discardableResult + func installIfNeeded() throws -> URL { + let installed = installedURL + guard !FileManager.default.fileExists(atPath: installed.path) else { return installed } + try copyBundledFile(to: installed) + Self.logger.info("Installed the sample database") + return installed + } + + @discardableResult + func reset() throws -> URL { + let installed = installedURL + for url in [installed] + Self.sidecarSuffixes.map({ URL(fileURLWithPath: installed.path + $0) }) { + guard FileManager.default.fileExists(atPath: url.path) else { continue } + do { + try FileManager.default.removeItem(at: url) + } catch { + throw SampleDatabaseError.copyFailed(message: error.localizedDescription) + } + } + try copyBundledFile(to: installed) + Self.logger.info("Reset the sample database") + return installed + } + + private func copyBundledFile(to destination: URL) throws { + guard let bundledURL else { + Self.logger.error("Chinook.sqlite is not in the app bundle") + throw SampleDatabaseError.bundleMissing + } + do { + try prepareDirectory() + try FileManager.default.copyItem(at: bundledURL, to: destination) + } catch { + throw SampleDatabaseError.copyFailed(message: error.localizedDescription) + } + } + + private func prepareDirectory() throws { + try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true) + var values = URLResourceValues() + values.isExcludedFromBackup = true + var excluded = directory + try excluded.setResourceValues(values) + } + + private static var defaultDirectory: URL { + let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first + ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) + return base + .appendingPathComponent("TableProMobile", isDirectory: true) + .appendingPathComponent("Samples", isDirectory: true) + } +} diff --git a/TableProMobile/TableProMobile/Platform/SceneLifecycle.swift b/TableProMobile/TableProMobile/Platform/SceneLifecycle.swift new file mode 100644 index 0000000000..7f65197c10 --- /dev/null +++ b/TableProMobile/TableProMobile/Platform/SceneLifecycle.swift @@ -0,0 +1,92 @@ +import Combine +import Observation +import SwiftUI +import UIKit + +final class TableProAppDelegate: NSObject, UIApplicationDelegate { + func application( + _ application: UIApplication, + configurationForConnecting connectingSceneSession: UISceneSession, + options: UIScene.ConnectionOptions + ) -> UISceneConfiguration { + let configuration = UISceneConfiguration(name: nil, sessionRole: connectingSceneSession.role) + configuration.delegateClass = TableProSceneDelegate.self + return configuration + } +} + +enum LockCoverMode: Equatable { + case locked + case obscured +} + +@Observable +final class LockCoverState { + var mode: LockCoverMode = .obscured +} + +final class TableProSceneDelegate: NSObject, UIWindowSceneDelegate, ObservableObject { + var onDisconnect: (() -> Void)? + + private weak var windowScene: UIWindowScene? + private var coverWindow: UIWindow? + private let coverState = LockCoverState() + + func scene( + _ scene: UIScene, + willConnectTo session: UISceneSession, + options connectionOptions: UIScene.ConnectionOptions + ) { + windowScene = scene as? UIWindowScene + } + + func sceneDidDisconnect(_ scene: UIScene) { + onDisconnect?() + onDisconnect = nil + } + + func showCover(_ mode: LockCoverMode?, lockState: AppLockState) { + guard let mode else { + hideCover() + return + } + guard let windowScene else { return } + coverState.mode = mode + let window = coverWindow ?? makeCoverWindow(in: windowScene, lockState: lockState) + coverWindow = window + for other in windowScene.windows where other !== window { + other.endEditing(true) + } + window.isHidden = false + window.makeKey() + } + + private func hideCover() { + guard let coverWindow, !coverWindow.isHidden else { return } + coverWindow.isHidden = true + windowScene?.windows.first { $0 !== coverWindow && !$0.isHidden }?.makeKey() + } + + private func makeCoverWindow(in windowScene: UIWindowScene, lockState: AppLockState) -> UIWindow { + let window = UIWindow(windowScene: windowScene) + window.windowLevel = .alert + 1 + let host = UIHostingController(rootView: LockCoverView(state: coverState).environment(lockState)) + host.view.backgroundColor = .clear + host.view.accessibilityViewIsModal = true + window.rootViewController = host + return window + } +} + +private struct LockCoverView: View { + let state: LockCoverState + + var body: some View { + switch state.mode { + case .locked: + LockScreenView() + case .obscured: + PrivacyCoverView() + } + } +} diff --git a/TableProMobile/TableProMobile/PrivacyInfo.xcprivacy b/TableProMobile/TableProMobile/PrivacyInfo.xcprivacy new file mode 100644 index 0000000000..4caefbbb49 --- /dev/null +++ b/TableProMobile/TableProMobile/PrivacyInfo.xcprivacy @@ -0,0 +1,65 @@ + + + + + NSPrivacyTracking + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeDeviceID + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyCollectedDataType + NSPrivacyCollectedDataTypeProductInteraction + NSPrivacyCollectedDataTypeLinked + + NSPrivacyCollectedDataTypeTracking + + NSPrivacyCollectedDataTypePurposes + + NSPrivacyCollectedDataTypePurposeAnalytics + + + + NSPrivacyAccessedAPITypes + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryUserDefaults + NSPrivacyAccessedAPITypeReasons + + CA92.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryFileTimestamp + NSPrivacyAccessedAPITypeReasons + + C617.1 + 3B52.1 + + + + NSPrivacyAccessedAPIType + NSPrivacyAccessedAPICategoryDiskSpace + NSPrivacyAccessedAPITypeReasons + + E174.1 + + + + + diff --git a/TableProMobile/TableProMobile/Security/CertificateMaterializer.swift b/TableProMobile/TableProMobile/Security/CertificateMaterializer.swift index f0ecba53ce..88aee704b9 100644 --- a/TableProMobile/TableProMobile/Security/CertificateMaterializer.swift +++ b/TableProMobile/TableProMobile/Security/CertificateMaterializer.swift @@ -42,11 +42,6 @@ nonisolated final class CertificateMaterializer: @unchecked Sendable { try? fileManager.removeItem(at: directory) } - func sweep() { - guard let root = try? rootDirectory(), fileManager.fileExists(atPath: root.path) else { return } - try? fileManager.removeItem(at: root) - } - private func write(_ pem: String, role: CertificateRole, for connectionId: UUID) throws -> String { let directory = directory(for: connectionId) try fileManager.createDirectory( diff --git a/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift b/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift index a78a7cbfdd..4d3e217c96 100644 --- a/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift +++ b/TableProMobile/TableProMobile/Services/IOSConnectionImportService.swift @@ -44,6 +44,10 @@ enum IOSConnectionImportService { resolutions: [UUID: ImportResolution], appState: AppState ) -> ImportResult { + guard appState.isLibraryWritable else { + logger.error("Import refused: the stored library is not loaded") + return ImportResult(importedCount: 0, connectionIdMap: [:], newConnectionIdMap: [:]) + } createMissingGroupsAndTags(from: preview.envelope, appState: appState) let tagIdsByName = lookup(appState.tags.map { ($0.name, $0.id) }) @@ -78,7 +82,7 @@ enum IOSConnectionImportService { tagIdsByName: tagIdsByName, groupIdsByName: groupIdsByName ) sortOrder += 1 - appState.addConnection(connection) + guard appState.addConnection(connection) else { continue } connectionIdMap[index] = id newConnectionIdMap[index] = id importedCount += 1 diff --git a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift index 8fb213a285..6b31722ba6 100644 --- a/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift +++ b/TableProMobile/TableProMobile/Sync/IOSSyncCoordinator.swift @@ -17,15 +17,18 @@ final class IOSSyncCoordinator { private static let logger = Logger(subsystem: "com.TablePro", category: "Sync") - var status: SyncStatus = .idle + private(set) var status: SyncStatus var lastSyncDate: Date? @ObservationIgnored private let metadata: SyncMetadataStorage @ObservationIgnored private let recordCache: SyncRecordCache @ObservationIgnored private let makeTransport: () -> any IOSSyncTransport + @ObservationIgnored private let isEnabled: () -> Bool @ObservationIgnored private var transport: (any IOSSyncTransport)? @ObservationIgnored private var debounceTask: Task? + @ObservationIgnored private var runningSync: Task? @ObservationIgnored private var needsResync = false + @ObservationIgnored private var statusGeneration = 0 @ObservationIgnored private var editGenerations: [EditKey: Int] = [:] @ObservationIgnored var onConnectionsChanged: (([DatabaseConnection]) -> Void)? @@ -33,10 +36,6 @@ final class IOSSyncCoordinator { @ObservationIgnored var onTagsChanged: (([ConnectionTag]) -> Void)? @ObservationIgnored var getCurrentState: (() -> LibraryState?)? - /// Where the record cache lives, resolved by the app because the package cannot see it. - /// - /// The path is the one the package used to pick for itself, so a cache written by an earlier build is - /// still found rather than silently abandoned and re-fetched. private static var recordCacheDirectory: URL { let base = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSTemporaryDirectory(), isDirectory: true) @@ -49,11 +48,15 @@ final class IOSSyncCoordinator { directory: IOSSyncCoordinator.recordCacheDirectory, defaults: .standard ), - makeTransport: @escaping () -> any IOSSyncTransport = { CloudKitSyncEngine() } + makeTransport: @escaping () -> any IOSSyncTransport = { CloudKitSyncEngine() }, + isEnabled: @escaping () -> Bool = { AppPreferences.isCloudSyncEnabled } ) { self.metadata = metadata self.recordCache = recordCache self.makeTransport = makeTransport + self.isEnabled = isEnabled + self.status = isEnabled() ? .idle : .disabled(.userDisabled) + self.lastSyncDate = metadata.lastSyncDate } private func currentTransport() -> any IOSSyncTransport { @@ -63,65 +66,123 @@ final class IOSSyncCoordinator { return created } - // MARK: - Sync + var hasCompletedFirstSync: Bool { + lastSyncDate != nil + } - func sync(isRetry: Bool = false) async { - guard isRetry || status != .syncing else { + func accountStatus() async -> CKAccountStatus { + do { + return try await currentTransport().accountStatus() + } catch { + Self.logger.warning("iCloud account status unavailable: \(error.localizedDescription, privacy: .public)") + return .couldNotDetermine + } + } + + // MARK: - Enable / Disable + + func setEnabled(_ enabled: Bool) { + guard enabled else { + debounceTask?.cancel() + debounceTask = nil + needsResync = false + metadata.lastSyncDate = nil + lastSyncDate = nil + decide(.disabled(.userDisabled)) + return + } + decide(.idle) + guard runningSync == nil else { needsResync = true return } + Task { await sync() } + } + + // MARK: - Sync + + func sync() async { + guard isEnabled() else { + if status != .disabled(.userDisabled) { + decide(.disabled(.userDisabled)) + } + return + } + if let runningSync { + await runningSync.value + return + } + let run = Task { await performSync() } + runningSync = run + await run.value + } + + private func performSync() async { + defer { + runningSync = nil + drainResyncIfNeeded() + } guard getCurrentState?() != nil else { return } - status = .syncing - defer { drainResyncIfNeeded() } + let generation = decide(.syncing) + await attempt(generation: generation, isRetry: false) + } + private func attempt(generation: Int, isRetry: Bool) async { do { let transport = currentTransport() guard try await transport.accountStatus() == .available else { - status = .error(.accountUnavailable) + settle(.error(.accountUnavailable), from: generation) return } try await transport.ensureZoneExists() let remoteChanges = try await pull(using: transport) + guard generation == statusGeneration else { return } let connCount = remoteChanges.changedConnections.count let groupCount = remoteChanges.changedGroups.count let tagCount = remoteChanges.changedTags.count Self.logger.info("Pulled \(connCount) connections, \(groupCount) groups, \(tagCount) tags") guard applyRemoteChanges(remoteChanges) else { - status = .idle + settle(.idle, from: generation) return } + guard generation == statusGeneration else { return } try await push(using: transport) + guard generation == statusGeneration else { return } if let newToken = remoteChanges.newToken { metadata.saveToken(newToken) } - metadata.lastSyncDate = Date() lastSyncDate = metadata.lastSyncDate - status = .idle + settle(.idle, from: generation) } catch let error as SyncError where error == .tokenExpired { guard !isRetry else { - status = .error(.tokenExpired) + settle(.error(.tokenExpired), from: generation) return } metadata.saveToken(nil) - await sync(isRetry: true) + await attempt(generation: generation, isRetry: true) } catch { - status = .error(SyncError.from(error)) + settle(.error(SyncError.from(error)), from: generation) } } - // MARK: - Token Reset + @discardableResult + private func decide(_ outcome: SyncStatus) -> Int { + statusGeneration += 1 + status = outcome + return statusGeneration + } - func resetSyncToken() async { - debounceTask?.cancel() - metadata.saveToken(nil) - recordCache.removeAll() - Self.logger.info("Sync token cleared; forcing full pull from iCloud") - await sync() + private func settle(_ outcome: SyncStatus, from generation: Int) { + guard generation == statusGeneration else { + Self.logger.info("Discarding a sync outcome the status moved on from") + return + } + status = outcome } // MARK: - Dirty / Tombstone Tracking @@ -131,7 +192,7 @@ final class IOSSyncCoordinator { } func markDeleted(_ connectionId: UUID) { - metadata.addTombstone(connectionId.uuidString, type: .connection) + addTombstone(connectionId.uuidString, type: .connection) } func markDirtyGroup(_ groupId: UUID) { @@ -139,7 +200,7 @@ final class IOSSyncCoordinator { } func markDeletedGroup(_ groupId: UUID) { - metadata.addTombstone(groupId.uuidString, type: .group) + addTombstone(groupId.uuidString, type: .group) } func markDirtyTag(_ tagId: UUID) { @@ -147,7 +208,7 @@ final class IOSSyncCoordinator { } func markDeletedTag(_ tagId: UUID) { - metadata.addTombstone(tagId.uuidString, type: .tag) + addTombstone(tagId.uuidString, type: .tag) } private func markDirty(_ id: String, type: SyncRecordType) { @@ -155,6 +216,10 @@ final class IOSSyncCoordinator { metadata.markDirty(id, type: type) } + private func addTombstone(_ id: String, type: SyncRecordType) { + metadata.addTombstone(id, type: type) + } + private func drainResyncIfNeeded() { guard needsResync, status == .idle else { needsResync = false @@ -166,9 +231,17 @@ final class IOSSyncCoordinator { func scheduleSyncAfterChange() { debounceTask?.cancel() + guard isEnabled() else { + debounceTask = nil + return + } debounceTask = Task { try? await Task.sleep(nanoseconds: 2_000_000_000) guard !Task.isCancelled else { return } + guard runningSync == nil else { + needsResync = true + return + } await sync() } } @@ -193,7 +266,8 @@ final class IOSSyncCoordinator { var allDeletions: [CKRecord.ID] = [] let dirtyConnIDs = metadata.dirtyIds(for: .connection) - for connection in state.connections where dirtyConnIDs.contains(connection.id.uuidString) { + for connection in state.connections + where connection.participatesInSync && dirtyConnIDs.contains(connection.id.uuidString) { let recordID = SyncRecordMapper.recordID(type: .connection, id: connection.id.uuidString, in: zoneID) if let existing = recordCache.record(for: recordID) { SyncRecordMapper.updateRecord(existing, with: connection) diff --git a/TableProMobile/TableProMobile/TableProMobileApp.swift b/TableProMobile/TableProMobile/TableProMobileApp.swift index 4ae943c4eb..9dc3a4f55d 100644 --- a/TableProMobile/TableProMobile/TableProMobileApp.swift +++ b/TableProMobile/TableProMobile/TableProMobileApp.swift @@ -1,5 +1,4 @@ import BackgroundTasks -import CoreSpotlight import os import SwiftUI import TableProAnalytics @@ -11,6 +10,7 @@ struct TableProMobileApp: App { static let backgroundSyncIdentifier = "com.TablePro.sync" private static let backgroundLogger = Logger(subsystem: "com.TablePro", category: "BackgroundSync") + @UIApplicationDelegateAdaptor(TableProAppDelegate.self) private var appDelegate @State private var appState = AppState() @State private var lockState = AppLockState() @State private var syncTask: Task? @@ -18,54 +18,19 @@ struct TableProMobileApp: App { @State private var heartbeatTask: Task? @Environment(\.scenePhase) private var scenePhase + init() { + ConnectionListTips.configure() + } + var body: some Scene { WindowGroup { - ZStack { - SceneRootView(connectionManager: appState.connectionManager) - .environment(appState) - .blur(radius: lockState.isLocked ? 20 : 0) - .allowsHitTesting(!lockState.isLocked) - - if lockState.isLocked { - LockScreenView() - .environment(lockState) - .transition(.opacity) - } - } - .animation(.default, value: lockState.isLocked) - .hostKeyPrompt() - .entraSignInPrompt() - .onOpenURL { url in - if url.isFileURL, url.pathExtension.lowercased() == "tablepro" { - appState.pendingImportURL = url - return - } - guard url.scheme == "tablepro", - url.host(percentEncoded: false) == "connect", - let uuidString = url.pathComponents.dropFirst().first, - let uuid = UUID(uuidString: uuidString) else { return } - appState.pendingConnectionId = uuid - } - .onContinueUserActivity(CSSearchableItemActionType) { activity in - guard let identifier = activity.userInfo?[CSSearchableItemActivityIdentifier] as? String, - let uuid = UUID(uuidString: identifier) else { return } - appState.pendingConnectionId = uuid - } - .onContinueUserActivity("com.TablePro.viewConnection") { activity in - guard let connectionId = activity.userInfo?["connectionId"] as? String, - let uuid = UUID(uuidString: connectionId) else { return } - appState.pendingConnectionId = uuid - } - .onContinueUserActivity("com.TablePro.viewTable") { activity in - guard let connectionId = activity.userInfo?["connectionId"] as? String, - let uuid = UUID(uuidString: connectionId) else { return } - appState.pendingConnectionId = uuid - appState.pendingTableName = activity.userInfo?["tableName"] as? String - } + SceneRootView(connectionManager: appState.connectionManager) + .environment(appState) + .environment(lockState) + .hostKeyPrompt() + .entraSignInPrompt() } .onChange(of: scenePhase) { _, phase in - // Skip lifecycle side-effects under tests so unit tests do not - // boot CloudKit sync, analytics, or biometric checks. guard !TestRuntime.isActive else { return } lockState.handleScenePhase(phase) switch phase { @@ -74,27 +39,19 @@ struct TableProMobileApp: App { appState.backgroundRelease.cancelPreparation() MemoryPressureMonitor.shared.start() appState.retryLoadIfFailed() - if AppPreferences.isCloudSyncEnabled && appState.loadStatus == .ready { + if appState.onboarding.isCloudSyncEnabled && appState.loadStatus == .ready { syncTask?.cancel() syncTask = Task { await appState.syncCoordinator.sync() } } - if heartbeatTask == nil { - let provider = IOSAnalyticsProvider.shared - provider.attach(appState: appState) - let service = AnalyticsHeartbeatService(provider: provider) - heartbeatService = service - heartbeatTask = service.startPeriodicHeartbeat() - } + startHeartbeatIfConsented() case .inactive: appState.backgroundRelease.prepareForSuspension() case .background: syncTask?.cancel() syncTask = nil - heartbeatTask?.cancel() - heartbeatTask = nil - heartbeatService = nil + stopHeartbeat() Task { let released = await appState.backgroundRelease.releaseForSuspension() for connectionId in released { @@ -106,11 +63,34 @@ struct TableProMobileApp: App { break } } + .onChange(of: appState.onboarding.usageDataChoice) { _, choice in + guard !TestRuntime.isActive else { return } + guard choice == true else { + stopHeartbeat() + return + } + startHeartbeatIfConsented() + } .backgroundTask(.appRefresh(Self.backgroundSyncIdentifier)) { await runBackgroundSync() } } + private func startHeartbeatIfConsented() { + guard heartbeatTask == nil, appState.onboarding.isUsageDataEnabled else { return } + let provider = IOSAnalyticsProvider.shared + provider.attach(appState: appState) + let service = AnalyticsHeartbeatService(provider: provider) + heartbeatService = service + heartbeatTask = service.startPeriodicHeartbeat() + } + + private func stopHeartbeat() { + heartbeatTask?.cancel() + heartbeatTask = nil + heartbeatService = nil + } + private func scheduleBackgroundSync() { guard AppPreferences.isCloudSyncEnabled else { return } let request = BGAppRefreshTaskRequest(identifier: Self.backgroundSyncIdentifier) diff --git a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift index fbbecbf6bf..5eb249c732 100644 --- a/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift +++ b/TableProMobile/TableProMobile/ViewModels/RowDetailViewModel.swift @@ -82,11 +82,6 @@ final class RowDetailViewModel { var supportsLazyLoading: Bool { loadFullValueProvider != nil } - var currentRowCells: [Cell] { - guard currentIndex >= 0, currentIndex < rows.count else { return [] } - return rows[currentIndex].cells - } - var currentRow: [String?] { row(at: currentIndex) } diff --git a/TableProMobile/TableProMobile/Views/AcknowledgementsView.swift b/TableProMobile/TableProMobile/Views/AcknowledgementsView.swift new file mode 100644 index 0000000000..0f308248b4 --- /dev/null +++ b/TableProMobile/TableProMobile/Views/AcknowledgementsView.swift @@ -0,0 +1,121 @@ +import SwiftUI + +struct AcknowledgementsView: View { + @State private var inventory: Loadable = .loading + + init() {} + + var body: some View { + content + .navigationTitle("Acknowledgements") + .task { loadInventoryIfNeeded() } + } + + @ViewBuilder + private var content: some View { + switch inventory { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .loaded(let loaded) where !loaded.components.isEmpty: + componentList(loaded) + case .loaded, .failed: + ContentUnavailableView( + "No License Information", + systemImage: "doc.text.magnifyingglass", + description: Text("The list of open source libraries is missing from this build.") + ) + } + } + + private func componentList(_ inventory: AcknowledgementsInventory) -> some View { + List(inventory.components) { component in + NavigationLink { + AcknowledgementDetailView(component: component, inventory: inventory) + } label: { + AcknowledgementRow(component: component) + } + } + } + + private func loadInventoryIfNeeded() { + guard case .loading = inventory else { return } + do { + inventory = .loaded(try AcknowledgementsInventory.bundled()) + } catch { + inventory = .failed(error) + } + } +} + +private struct AcknowledgementRow: View { + let component: AcknowledgementComponent + + var body: some View { + LabeledContent { + Text(verbatim: component.displayVersion) + .lineLimit(1) + } label: { + Text(verbatim: component.name) + Text(verbatim: component.spdx) + } + } +} + +private struct AcknowledgementDetailView: View { + let component: AcknowledgementComponent + let inventory: AcknowledgementsInventory + + @State private var licenseText: Loadable = .loading + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + LabeledContent("Version") { + Text(verbatim: component.displayVersion) + } + LabeledContent("License") { + Text(verbatim: component.spdx) + } + if let homepage = component.homepage { + Link("Homepage", destination: homepage) + } + if !component.copyrights.isEmpty { + Text(component.copyrights.joined(separator: "\n")) + .font(.footnote.monospaced()) + } + licenseBlock + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding() + } + .textSelection(.enabled) + .navigationTitle(component.name) + .navigationBarTitleDisplayMode(.inline) + .task { loadLicenseTextIfNeeded() } + } + + @ViewBuilder + private var licenseBlock: some View { + switch licenseText { + case .loading: + ProgressView() + case .loaded(let text): + Text(text) + .font(.caption.monospaced()) + case .failed: + Text("The license text is missing from this build.") + .font(.callout) + .foregroundStyle(.secondary) + } + } + + private func loadLicenseTextIfNeeded() { + guard case .loading = licenseText else { return } + do { + licenseText = .loaded(try inventory.licenseText(for: component)) + } catch { + licenseText = .failed(error) + } + } +} diff --git a/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift b/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift index b3cbda6805..fb35fae66c 100644 --- a/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift +++ b/TableProMobile/TableProMobile/Views/Components/DatabaseIconView.swift @@ -1,27 +1,40 @@ import SwiftUI import TableProModels +import UIKit struct DatabaseIconView: View { let type: DatabaseType let size: CGFloat var tint: Color? + private static let fallbackSymbol = "cylinder.split.1x2" + var body: some View { - let name = type.iconName - if name.hasSuffix("-icon") { - Image(name) + if let assetName { + Image(assetName) .renderingMode(.template) .resizable() .scaledToFit() .frame(width: size, height: size) .foregroundStyle(color) } else { - Image(systemName: name) + Image(systemName: symbolName) .font(.system(size: size)) .foregroundStyle(color) } } + private var assetName: String? { + let name = type.iconName + guard name.hasSuffix("-icon"), UIImage(named: name) != nil else { return nil } + return name + } + + private var symbolName: String { + let name = type.iconName + return name.hasSuffix("-icon") ? Self.fallbackSymbol : name + } + var color: Color { tint ?? Self.color(for: type) } diff --git a/TableProMobile/TableProMobile/Views/Components/ErrorView.swift b/TableProMobile/TableProMobile/Views/Components/ErrorView.swift index 844a3f9570..79e8fadb2a 100644 --- a/TableProMobile/TableProMobile/Views/Components/ErrorView.swift +++ b/TableProMobile/TableProMobile/Views/Components/ErrorView.swift @@ -37,17 +37,3 @@ struct ErrorView: View { } } } - -struct ErrorToast: View { - let message: String - - var body: some View { - Label(message, systemImage: "exclamationmark.triangle") - .font(.subheadline) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(.regularMaterial, in: Capsule()) - .padding(.bottom) - .transition(.move(edge: .bottom).combined(with: .opacity)) - } -} diff --git a/TableProMobile/TableProMobile/Views/ConnectedView.swift b/TableProMobile/TableProMobile/Views/ConnectedView.swift index 215fdcc099..b778038bd1 100644 --- a/TableProMobile/TableProMobile/Views/ConnectedView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectedView.swift @@ -5,6 +5,7 @@ import TableProModels struct ConnectedView: View { @Environment(AppState.self) private var appState @Environment(ConnectionCoordinatorStore.self) private var coordinatorStore + @Environment(ScenePresenter.self) private var presenter @Environment(\.scenePhase) private var scenePhase @Environment(\.dismiss) private var dismiss let connection: DatabaseConnection @@ -50,7 +51,13 @@ struct ConnectedView: View { .task(id: coordinatorStore.revision) { let resolved = coordinatorStore.coordinator(for: connection, appState: appState) coordinator = resolved - if case .connected = resolved.phase { return } + if let table = presenter.takeTable(for: connection.id) { + resolved.pendingTableName = table + } + if case .connected = resolved.phase { + resolved.navigateToPendingTable() + return + } await resolved.connect() guard !Task.isCancelled else { return } if case .connected = resolved.phase { @@ -60,6 +67,11 @@ struct ConnectedView: View { hapticError.toggle() } } + .onChange(of: presenter.pendingTable) { _, _ in + guard let coordinator, let table = presenter.takeTable(for: connection.id) else { return } + coordinator.pendingTableName = table + coordinator.navigateToPendingTable() + } .onChange(of: scenePhase) { _, phase in if phase == .active { Task { await coordinator?.reconnectIfNeeded() } @@ -97,8 +109,10 @@ struct ConnectedView: View { private var connectingView: some View { VStack(spacing: 16) { ProgressView { - Text(String(format: String(localized: "Connecting to %@..."), - connection.name.isEmpty ? connection.host : connection.name)) + Text(String( + format: String(localized: "Connecting to %@..."), + connection.name.isEmpty ? connection.host : connection.name + )) } Button(String(localized: "Cancel"), role: .cancel) { coordinator?.cancelConnect() @@ -195,7 +209,7 @@ struct ConnectedView: View { } message: { Text(coordinator.failureAlertMessage ?? "") } - .userActivity("com.TablePro.viewConnection") { activity in + .userActivity(SceneIntent.viewConnectionActivity, isActive: !connection.isSample) { activity in activity.title = connection.name.isEmpty ? connection.host : connection.name activity.isEligibleForHandoff = true activity.userInfo = ["connectionId": connection.id.uuidString] @@ -218,7 +232,7 @@ struct ConnectedView: View { @ToolbarContentBuilder private func connectionToolbar(_ coordinator: ConnectionCoordinator) -> some ToolbarContent { - if coordinator.selectedTab == .info { + if coordinator.selectedTab == .info, !connection.isSample { ToolbarItem(placement: .topBarTrailing) { Button { coordinator.showingEditSheet = true diff --git a/TableProMobile/TableProMobile/Views/ConnectionListRow.swift b/TableProMobile/TableProMobile/Views/ConnectionListRow.swift index 68c13ac449..ba68b25b34 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListRow.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListRow.swift @@ -4,6 +4,7 @@ import TableProModels struct ConnectionListRow: View { @Environment(\.editMode) private var editMode + @Environment(\.dynamicTypeSize) private var dynamicTypeSize let model: ConnectionListRowModel let isRenaming: Bool @@ -44,8 +45,18 @@ struct ConnectionListRow: View { } } + private var lineLimit: Int? { + dynamicTypeSize.isAccessibilitySize ? nil : 1 + } + + private var contentLayout: AnyLayout { + dynamicTypeSize.isAccessibilitySize + ? AnyLayout(VStackLayout(alignment: .leading, spacing: 8)) + : AnyLayout(HStackLayout(spacing: 12)) + } + private var content: some View { - HStack(spacing: 12) { + contentLayout { ConnectionTile(type: model.type, color: model.color) VStack(alignment: .leading, spacing: 3) { @@ -53,13 +64,12 @@ struct ConnectionListRow: View { Text(model.detail) .font(.subheadline) .foregroundStyle(.secondary) - .lineLimit(1) + .lineLimit(lineLimit) if model.groupLabel != nil || !model.tags.isEmpty { - ConnectionRowLabels(model: model) + ConnectionRowLabels(model: model, lineLimit: lineLimit) } } - - Spacer(minLength: 8) + .frame(maxWidth: .infinity, alignment: .leading) if model.showsFavoriteMark { Image(systemName: "star.fill") @@ -94,7 +104,7 @@ struct ConnectionListRow: View { } else { Text(model.title) .font(.body) - .lineLimit(1) + .lineLimit(lineLimit) } } } @@ -103,10 +113,13 @@ struct ConnectionTile: View { let type: DatabaseType let color: ConnectionColor + @ScaledMetric(relativeTo: .body) private var side: CGFloat = 32 + @ScaledMetric(relativeTo: .body) private var iconSize: CGFloat = 18 + var body: some View { let hasColor = color != .none - DatabaseIconView(type: type, size: 18, tint: hasColor ? .white : nil) - .frame(width: 32, height: 32) + DatabaseIconView(type: type, size: iconSize, tint: hasColor ? .white : nil) + .frame(width: side, height: side) .background( hasColor ? ConnectionColorPicker.swiftUIColor(for: color) @@ -119,6 +132,7 @@ struct ConnectionTile: View { private struct ConnectionRowLabels: View { let model: ConnectionListRowModel + let lineLimit: Int? var body: some View { HStack(spacing: 10) { @@ -134,7 +148,7 @@ private struct ConnectionRowLabels: View { } .font(.caption) .foregroundStyle(.secondary) - .lineLimit(1) + .lineLimit(lineLimit) } } diff --git a/TableProMobile/TableProMobile/Views/ConnectionListStatusViews.swift b/TableProMobile/TableProMobile/Views/ConnectionListStatusViews.swift new file mode 100644 index 0000000000..2551817f6e --- /dev/null +++ b/TableProMobile/TableProMobile/Views/ConnectionListStatusViews.swift @@ -0,0 +1,131 @@ +import SwiftUI +import TableProSyncTransport + +struct ConnectionListEmptyActions { + let addConnection: () -> Void + let openSample: () -> Void + let turnOnICloud: (() -> Void)? + let importConnections: () -> Void + let retrySync: () -> Void + let retryLoad: () -> Void +} + +struct ConnectionListStatusView: View { + let state: ConnectionListState + let actions: ConnectionListEmptyActions + + var body: some View { + switch state { + case .loading: + ProgressView() + .frame(maxWidth: .infinity, maxHeight: .infinity) + case .failed: + failedView + case .checkingICloud: + checkingView + case .iCloudUnavailable(let error): + unavailableView(error) + case .empty(let syncsWithICloud): + emptyView(syncsWithICloud: syncsWithICloud) + case .content: + EmptyView() + } + } + + private var failedView: some View { + ContentUnavailableView { + Label("Connections Unavailable", systemImage: "exclamationmark.triangle") + } description: { + Text("TablePro could not read your saved connections. Nothing on this device has been changed.") + } actions: { + Button("Try Again", action: actions.retryLoad) + .buttonStyle(.borderedProminent) + } + } + + private var checkingView: some View { + ContentUnavailableView { + Label { + Text("Checking iCloud") + } icon: { + ProgressView() + .controlSize(.large) + } + } description: { + Text("Connections from your other devices appear here.") + } + } + + private func unavailableView(_ error: SyncError) -> some View { + ContentUnavailableView { + Label("iCloud Unavailable", systemImage: "exclamationmark.icloud") + } description: { + Text(ConnectionListSyncMessage.text(for: error)) + } actions: { + Button("Try Again", action: actions.retrySync) + .buttonStyle(.borderedProminent) + Button("Add Connection", action: actions.addConnection) + .buttonStyle(.bordered) + } + } + + private func emptyView(syncsWithICloud: Bool) -> some View { + ContentUnavailableView { + Label("No Connections", systemImage: "server.rack") + } description: { + if syncsWithICloud { + Text("Connections you add here or in TablePro on your Mac appear on all your devices.") + } else { + Text("Add a connection to your database, or explore TablePro with the sample database.") + } + } actions: { + Button("Add Connection", action: actions.addConnection) + .buttonStyle(.borderedProminent) + .accessibilityIdentifier("empty-add-connection") + Button("Open Sample Database", action: actions.openSample) + .buttonStyle(.bordered) + .accessibilityIdentifier("empty-open-sample") + if let turnOnICloud = actions.turnOnICloud { + Button("Turn On iCloud Sync", action: turnOnICloud) + .buttonStyle(.bordered) + .accessibilityIdentifier("empty-turn-on-icloud") + } else { + Button("Import Connections", action: actions.importConnections) + .buttonStyle(.bordered) + .accessibilityIdentifier("empty-import-connections") + } + } + } +} + +struct ConnectionListSyncProblemRow: View { + let error: SyncError + let retry: () -> Void + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 12) { + Label { + Text(ConnectionListSyncMessage.text(for: error)) + .font(.subheadline) + } icon: { + Image(systemName: "exclamationmark.icloud") + .foregroundStyle(.orange) + } + .frame(maxWidth: .infinity, alignment: .leading) + Button("Try Again", action: retry) + .buttonStyle(.borderless) + .font(.subheadline) + } + } +} + +enum ConnectionListSyncMessage { + static func text(for error: SyncError) -> String { + switch error { + case .accountUnavailable: + return String(localized: "Sign in to iCloud in the Settings app to sync your connections.") + default: + return error.localizedDescription + } + } +} diff --git a/TableProMobile/TableProMobile/Views/ConnectionListView.swift b/TableProMobile/TableProMobile/Views/ConnectionListView.swift index 820d2cd2ae..86345898be 100644 --- a/TableProMobile/TableProMobile/Views/ConnectionListView.swift +++ b/TableProMobile/TableProMobile/Views/ConnectionListView.swift @@ -1,8 +1,10 @@ +import CloudKit import SwiftUI import TableProConnectionLibrary import TableProImport import TableProModels import TableProSyncTransport +import TipKit import UniformTypeIdentifiers nonisolated struct ConnectionTagToken: Identifiable, Hashable, Sendable { @@ -10,51 +12,28 @@ nonisolated struct ConnectionTagToken: Identifiable, Hashable, Sendable { let name: String } -private enum ConnectionListSheet: Identifiable { - case addConnection - case editConnection(DatabaseConnection) - case moveConnections([UUID]) - case newGroup(parentId: UUID?) - case editGroup(ConnectionGroup) - case groups - case tags - case settings - case importFile(URL) - case export - - var id: String { - switch self { - case .addConnection: "addConnection" - case .editConnection(let connection): "editConnection-\(connection.id.uuidString)" - case .moveConnections(let ids): "moveConnections-\(ids.map(\.uuidString).joined(separator: ","))" - case .newGroup(let parentId): "newGroup-\(parentId?.uuidString ?? "root")" - case .editGroup(let group): "editGroup-\(group.id.uuidString)" - case .groups: "groups" - case .tags: "tags" - case .settings: "settings" - case .importFile(let url): "importFile-\(url.absoluteString)" - case .export: "export" - } - } -} - struct ConnectionListView: View { @Environment(AppState.self) private var appState + @Environment(AppLockState.self) private var lockState @Environment(ConnectionCoordinatorStore.self) private var coordinatorStore + @Environment(ScenePresenter.self) private var presenter @SceneStorage("lastConnectionId") private var selectedConnectionIdString: String? - @AppStorage(AppPreferences.cloudSyncEnabledKey) private var cloudSyncEnabled = true @State private var searchText = "" @State private var searchTokens: [ConnectionTagToken] = [] @State private var matchesAllTags = false @State private var editMode: EditMode = .inactive @State private var selection: Set = [] - @State private var renamingConnectionId: UUID? - @State private var activeSheet: ConnectionListSheet? + @State private var renamingRow: LibraryRowID? @State private var connectionsPendingDeletion: Set = [] @State private var groupPendingDeletion: ConnectionGroup? + @State private var isConfirmingSampleReset = false @State private var showingFileImporter = false + @State private var importAfterCoverDismissal: URL? @State private var importResultCount: Int? + @State private var actionErrorMessage: String? + @State private var iCloudAccountAvailable = false + @State private var tips = ConnectionListTips.makeGroup() private var selectedConnectionUUID: UUID? { selectedConnectionIdString.flatMap { UUID(uuidString: $0) } @@ -63,21 +42,35 @@ struct ConnectionListView: View { private var openConnection: Binding { Binding( get: { - guard let id = selectedConnectionUUID else { return nil } + guard !presenter.holdsConnectionRestore, let id = selectedConnectionUUID else { return nil } return appState.connections.first { $0.id == id } }, set: { selectedConnectionIdString = $0?.id.uuidString } ) } - private var isSyncing: Bool { - appState.syncCoordinator.status == .syncing + private var isSyncEnabled: Bool { + appState.onboarding.isCloudSyncEnabled } private var isEditing: Bool { editMode == .active } + private var hasLibraryItems: Bool { + !appState.connections.isEmpty || !appState.groups.isEmpty + } + + private var listState: ConnectionListState { + ConnectionListState.resolve( + loadStatus: appState.loadStatus, + hasLibraryItems: hasLibraryItems, + isSyncEnabled: isSyncEnabled, + syncStatus: appState.syncCoordinator.status, + hasCompletedFirstSync: appState.syncCoordinator.hasCompletedFirstSync + ) + } + private var query: LibraryQuery { LibraryQuery( text: searchText, @@ -115,32 +108,60 @@ struct ConnectionListView: View { } } + private var tipInputs: [Int] { + [ + appState.onboarding.hasSeenWelcome && presenter.sheet == nil ? 1 : 0, + appState.connections.count, + appState.connections.contains(where: \.isFavorite) ? 1 : 0, + appState.connections.contains { !$0.tagIds.isEmpty } ? 1 : 0 + ] + } + var body: some View { + @Bindable var presenter = presenter NavigationStack { content .navigationTitle("Connections") .toolbar { toolbarContent } - .onChange(of: appState.pendingConnectionId) { _, newId in - navigateToPendingConnection(newId) - } .onChange(of: editMode) { _, mode in guard mode == .inactive else { return } selection = [] } + .onChange(of: hasLibraryItems) { _, hasItems in + guard !hasItems else { return } + editMode = .inactive + selection = [] + } .onChange(of: appState.tags) { _, tags in let known = Set(tags.map(\.id)) searchTokens.removeAll { !known.contains($0.id) } } - .onAppear { - navigateToPendingConnection(appState.pendingConnectionId) - presentPendingImport() + .onChange(of: searchTokens) { _, tokens in + guard !tokens.isEmpty else { return } + ConnectionListTips.tagFilterUsed() + } + .task(id: tipInputs) { + ConnectionListTips.libraryChanged( + isListReady: tipInputs[0] == 1, + connectionCount: tipInputs[1], + hasFavorites: tipInputs[2] == 1, + hasTaggedConnections: tipInputs[3] == 1 + ) + } + .task(id: isSyncEnabled) { + guard !isSyncEnabled else { return } + iCloudAccountAvailable = await appState.syncCoordinator.accountStatus() == .available + } + .task { + presenter.beginLaunch(with: appState) + deliverPendingIntent() } } - .fullScreenCover(item: openConnection) { connection in + .fullScreenCover(item: openConnection, onDismiss: presentImportAfterCoverDismissal) { connection in ConnectedView(connection: connection) .id(connection.id) } - .sheet(item: $activeSheet) { sheet in + .sheet(item: $presenter.sheet, onDismiss: sheetDidDismiss) { sheet in sheetContent(sheet) } .fileImporter( @@ -149,46 +170,75 @@ struct ConnectionListView: View { allowsMultipleSelection: false ) { result in guard case .success(let urls) = result, let url = urls.first else { return } - activeSheet = .importFile(url) + presenter.present(.importFile(url)) } - .onChange(of: appState.pendingImportURL) { _, _ in - presentPendingImport() + .onChange(of: presenter.pendingIntent) { _, _ in + deliverPendingIntent() + } + .onChange(of: presenter.holdsConnectionRestore) { _, _ in + deliverPendingIntent() + } + .onChange(of: lockState.isLocked) { _, _ in + deliverPendingIntent() + } + .onChange(of: appState.loadStatus) { _, _ in + deliverPendingIntent() } .alert(importResultMessage, isPresented: importResultPresented) { Button(String(localized: "OK")) { importResultCount = nil } } + .alert( + String(localized: "Sample Database Unavailable"), + isPresented: actionErrorPresented + ) { + Button(String(localized: "OK")) { actionErrorMessage = nil } + } message: { + Text(actionErrorMessage ?? "") + } } // MARK: - Content @ViewBuilder private var content: some View { - if appState.connections.isEmpty && !isSyncing { - ContentUnavailableView { - Label("No Connections", systemImage: "server.rack") - } description: { - Text("Add a database connection to get started.") - } actions: { - Button("Add Connection") { - activeSheet = .addConnection - } - .buttonStyle(.borderedProminent) - } - } else if appState.connections.isEmpty { - ProgressView("Syncing from iCloud...") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - libraryList + switch listState { + case .content(let syncProblem): + libraryList(syncProblem: syncProblem) + default: + ConnectionListStatusView(state: listState, actions: emptyActions) } } - private var libraryList: some View { + private var emptyActions: ConnectionListEmptyActions { + ConnectionListEmptyActions( + addConnection: { presenter.present(.addConnection) }, + openSample: openSampleDatabase, + turnOnICloud: !isSyncEnabled && iCloudAccountAvailable ? { appState.setCloudSyncEnabled(true) } : nil, + importConnections: { showingFileImporter = true }, + retrySync: { Task { await appState.syncCoordinator.sync() } }, + retryLoad: { appState.retryLoadIfFailed() } + ) + } + + private func libraryList(syncProblem: SyncError?) -> some View { let outline = outline let connectionsById = Dictionary(appState.connections.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let groupsById = Dictionary(appState.groups.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) let canReorder = appState.libraryPreferences.sortMode == .manual && !outline.isQueryActive return List(selection: $selection) { + if let syncProblem { + Section { + ConnectionListSyncProblemRow(error: syncProblem) { + Task { await appState.syncCoordinator.sync() } + } + } + } + if !outline.isQueryActive, !isEditing, let tip = tips.currentTip { + Section { + TipView(tip) + } + } ForEach(outline.sections, id: \.kind) { section in Section { switch section.kind { @@ -233,10 +283,9 @@ struct ConnectionListView: View { ) { token in Label(token.name, systemImage: "tag") } - .refreshable { - guard cloudSyncEnabled else { return } + .modifier(SyncRefreshModifier(isEnabled: isSyncEnabled) { await appState.syncCoordinator.sync() - } + }) .confirmationDialog(deletionTitle, isPresented: deletionPresented, titleVisibility: .visible) { Button(String(localized: "Delete"), role: .destructive) { confirmConnectionDeletion() @@ -266,6 +315,17 @@ struct ConnectionListView: View { Text("Connections in this group will be moved to ungrouped.") } } + .confirmationDialog( + String(localized: "Reset Sample Database?"), + isPresented: $isConfirmingSampleReset, + titleVisibility: .visible + ) { + Button(String(localized: "Reset"), role: .destructive) { + resetSampleDatabase() + } + } message: { + Text("Every change you made to the sample database is replaced with the original data.") + } } @ViewBuilder @@ -274,15 +334,7 @@ struct ConnectionListView: View { case .favorites: Text("Favorites") case .recent: - HStack { - Text("Recent") - Spacer() - Button("Clear") { - appState.libraryPreferences.clearRecent() - } - .font(.subheadline) - .textCase(nil) - } + Text("Recent") default: Text("Connections") .frame(maxWidth: .infinity, alignment: .leading) @@ -298,6 +350,7 @@ struct ConnectionListView: View { @ViewBuilder private func connectionRow(_ connection: DatabaseConnection?, section: LibrarySectionKind) -> some View { if let connection { + let rowId = LibraryRowID.connection(connection.id, section: section) ConnectionListRow( model: ConnectionListRowModel( connection: connection, @@ -305,22 +358,24 @@ struct ConnectionListView: View { tags: appState.tags, groups: appState.groups ), - isRenaming: renamingConnectionId == connection.id, - onOpen: { selectedConnectionIdString = connection.id.uuidString }, - onCommitRename: { commitRename(connection.id, name: $0) }, - onCancelRename: { renamingConnectionId = nil } + isRenaming: renamingRow == rowId, + onOpen: { open(connection.id) }, + onCommitRename: { commitRename(rowId, connectionId: connection.id, name: $0) }, + onCancelRename: { renamingRow = nil } ) - .tag(LibraryRowID.connection(connection.id, section: section)) + .tag(rowId) .draggable(connection.id.uuidString) .swipeActions(edge: .leading) { favoriteButton(for: connection) .tint(.yellow) - Button { - activeSheet = .editConnection(connection) - } label: { - Label("Edit", systemImage: "pencil") + if !connection.isSample { + Button { + presenter.present(.editConnection(connection)) + } label: { + Label("Edit", systemImage: "slider.horizontal.3") + } + .tint(.blue) } - .tint(.blue) } .swipeActions(edge: .trailing, allowsFullSwipe: false) { trailingSwipeAction(for: connection, section: section) @@ -329,7 +384,8 @@ struct ConnectionListView: View { connectionMenu(for: connection, section: section) } .renameAction { - renamingConnectionId = connection.id + ConnectionListTips.connectionMenuUsed() + renamingRow = rowId } } } @@ -342,15 +398,31 @@ struct ConnectionListView: View { .dropDestination(for: String.self) { items, _ in moveDropped(items, toGroup: group.id) } + .swipeActions(edge: .leading) { + Button { + presenter.present(.editGroup(group)) + } label: { + Label("Edit", systemImage: "pencil") + } + .tint(.blue) + } + .swipeActions(edge: .trailing, allowsFullSwipe: false) { + Button { + groupPendingDeletion = group + } label: { + Label("Delete", systemImage: "trash") + } + .tint(.red) + } .contextMenu { Button { - activeSheet = .editGroup(group) + presenter.present(.editGroup(group)) } label: { Label("Edit Group", systemImage: "pencil") } if LibraryGroupGraph(groups: appState.groups).canCreateSubgroup(under: group.id) { Button { - activeSheet = .newGroup(parentId: group.id) + presenter.present(.newGroup(parentId: group.id)) } label: { Label("New Subgroup", systemImage: "folder.badge.plus") } @@ -362,6 +434,9 @@ struct ConnectionListView: View { Label("Delete Group", systemImage: "trash") } } + .accessibilityAction(named: Text("Delete Group")) { + groupPendingDeletion = group + } } } @@ -369,6 +444,9 @@ struct ConnectionListView: View { private func favoriteButton(for connection: DatabaseConnection) -> some View { Button { appState.setFavorite([connection.id], isFavorite: !connection.isFavorite) + if !connection.isFavorite { + ConnectionListTips.favoriteSet() + } } label: { if connection.isFavorite { Label("Remove from Favorites", systemImage: "star.slash") @@ -401,25 +479,32 @@ struct ConnectionListView: View { @ViewBuilder private func connectionMenu(for connection: DatabaseConnection, section: LibrarySectionKind) -> some View { Button { - selectedConnectionIdString = connection.id.uuidString + open(connection.id) } label: { Label("Open", systemImage: "arrow.right.circle") } - Button { - activeSheet = .editConnection(connection) - } label: { - Label("Edit", systemImage: "pencil") + if !connection.isSample { + Button { + ConnectionListTips.connectionMenuUsed() + presenter.present(.editConnection(connection)) + } label: { + Label("Edit", systemImage: "slider.horizontal.3") + } } RenameButton() - Button { - appState.duplicateConnection(connection) - } label: { - Label("Duplicate", systemImage: "doc.on.doc") + if !connection.isSample { + Button { + ConnectionListTips.connectionMenuUsed() + appState.duplicateConnection(connection) + } label: { + Label("Duplicate", systemImage: "doc.on.doc") + } } Divider() favoriteButton(for: connection) Button { - activeSheet = .moveConnections([connection.id]) + ConnectionListTips.connectionMenuUsed() + presenter.present(.moveConnections([connection.id])) } label: { Label("Move to Group", systemImage: "folder") } @@ -430,6 +515,13 @@ struct ConnectionListView: View { Label("Remove from Recent", systemImage: "clock.badge.xmark") } } + if connection.isSample { + Button { + isConfirmingSampleReset = true + } label: { + Label("Reset Database", systemImage: "arrow.counterclockwise") + } + } Divider() Button(role: .destructive) { connectionsPendingDeletion = [connection.id] @@ -442,9 +534,16 @@ struct ConnectionListView: View { @ToolbarContentBuilder private var toolbarContent: some ToolbarContent { + ToolbarItem(placement: .topBarLeading) { + Button { + presenter.present(.settings) + } label: { + Label("Settings", systemImage: "gear") + } + } ToolbarItemGroup(placement: .topBarTrailing) { moreMenu - if !appState.connections.isEmpty { + if hasLibraryItems { Button(isEditing ? String(localized: "Done") : String(localized: "Edit")) { withAnimation { editMode = isEditing ? .inactive : .active @@ -452,43 +551,18 @@ struct ConnectionListView: View { } } Button { - activeSheet = .addConnection + presenter.present(.addConnection) } label: { - Image(systemName: "plus") + Label("Add Connection", systemImage: "plus") } .keyboardShortcut("n", modifiers: .command) - .accessibilityLabel(Text("Add Connection")) - } - ToolbarItemGroup(placement: .topBarLeading) { - Button { - Task { - await appState.syncCoordinator.sync() - } - } label: { - if isSyncing { - ProgressView() - .controlSize(.small) - } else { - Image(systemName: cloudSyncEnabled - ? "arrow.triangle.2.circlepath.icloud" - : "icloud.slash") - } - } - .disabled(isSyncing || !cloudSyncEnabled) - .accessibilityLabel(Text("Sync with iCloud")) - - Button { - activeSheet = .settings - } label: { - Image(systemName: "gear") - } - .accessibilityLabel(Text("Settings")) + .disabled(!appState.isLibraryWritable) } if isEditing { ToolbarItemGroup(placement: .bottomBar) { let ids = selectedConnectionIds Button("Move") { - activeSheet = .moveConnections(ids) + presenter.present(.moveConnections(ids)) } .disabled(ids.isEmpty) Spacer() @@ -529,71 +603,65 @@ struct ConnectionListView: View { } .pickerStyle(.menu) - if !appState.tags.isEmpty { - Section("Filter by Tag") { - ForEach(appState.tags) { tag in - Toggle(isOn: tokenBinding(for: tag)) { - Text(verbatim: tag.name) - } - } - if searchTokens.count > 1 { - Toggle("Match All Tags", isOn: $matchesAllTags) - } - } + if searchTokens.count > 1 { + Toggle("Match All Tags", isOn: $matchesAllTags) } Section { Button { - activeSheet = .newGroup(parentId: nil) + presenter.present(.newGroup(parentId: nil)) } label: { Label("New Group", systemImage: "folder.badge.plus") } Button { - activeSheet = .groups - } label: { - Label("Manage Groups", systemImage: "folder") - } - Button { - activeSheet = .tags + presenter.present(.tags) } label: { Label("Manage Tags", systemImage: "tag") } } + .disabled(!appState.isLibraryWritable) Section { + Button(action: openSampleDatabase) { + Label("Open Sample Database", systemImage: "music.note.list") + } Button { showingFileImporter = true } label: { Label("Import Connections", systemImage: "square.and.arrow.down") } + .disabled(!appState.isLibraryWritable) Button { - activeSheet = .export + presenter.present(.export) } label: { Label("Export Connections", systemImage: "square.and.arrow.up") } - .disabled(appState.connections.isEmpty) + .disabled(!appState.connections.contains(where: \.participatesInSync)) } } label: { - Image(systemName: "ellipsis.circle") + Label("More", systemImage: "ellipsis.circle") } - .accessibilityLabel(Text("More")) } // MARK: - Sheets @ViewBuilder - private func sheetContent(_ sheet: ConnectionListSheet) -> some View { + private func sheetContent(_ sheet: SceneSheet) -> some View { switch sheet { + case .firstRun(let pages): + FirstRunSheet(pages: pages) + case .whatsNew(let version): + WhatsNewSheet(version: version) case .addConnection: ConnectionFormView { connection in appState.addConnection(connection) - activeSheet = nil + presenter.sheet = nil } case .editConnection(let connection): ConnectionFormView(editing: connection) { updated in appState.updateConnection(updated) coordinatorStore.invalidate(updated.id) - activeSheet = nil + presenter.sheet = nil } case .moveConnections(let ids): MoveToGroupSheet(connectionIds: ids) @@ -605,17 +673,15 @@ struct ConnectionListView: View { GroupFormSheet(editing: group) { updated in appState.updateGroup(updated) } - case .groups: - GroupManagementView() case .tags: TagManagementView() case .settings: NavigationStack { SettingsView() .toolbar { - ToolbarItem(placement: .confirmationAction) { + ToolbarItem(placement: .topBarTrailing) { CloseButton { - activeSheet = nil + presenter.sheet = nil } } } @@ -626,7 +692,7 @@ struct ConnectionListView: View { } .environment(appState) case .export: - MobileConnectionExportSheet(connections: appState.connections) + MobileConnectionExportSheet(connections: appState.connections.filter(\.participatesInSync)) .environment(appState) } } @@ -640,17 +706,6 @@ struct ConnectionListView: View { ) } - private func tokenBinding(for tag: ConnectionTag) -> Binding { - Binding( - get: { searchTokens.contains { $0.id == tag.id } }, - set: { isOn in - searchTokens.removeAll { $0.id == tag.id } - guard isOn else { return } - searchTokens.append(ConnectionTagToken(id: tag.id, name: tag.name)) - } - ) - } - private func groupExpansion(_ groupId: UUID, outline: LibraryOutline) -> Binding { Binding( get: { @@ -688,6 +743,13 @@ struct ConnectionListView: View { ) } + private var actionErrorPresented: Binding { + Binding( + get: { actionErrorMessage != nil }, + set: { if !$0 { actionErrorMessage = nil } } + ) + } + private var importResultMessage: String { let count = importResultCount ?? 0 return count == 1 @@ -697,6 +759,31 @@ struct ConnectionListView: View { // MARK: - Actions + private func open(_ connectionId: UUID) { + ConnectionListTips.connectionOpened() + selectedConnectionIdString = connectionId.uuidString + } + + private func openSampleDatabase() { + do { + let sampleId = try appState.openSampleDatabase() + presenter.requestTable(SampleDatabaseInstaller.startingTable, in: sampleId) + open(sampleId) + } catch { + actionErrorMessage = error.localizedDescription + } + } + + private func resetSampleDatabase() { + Task { + do { + try await appState.resetSampleDatabase() + } catch { + actionErrorMessage = error.localizedDescription + } + } + } + private func reorderFavorites(_ ids: [UUID]) -> (IndexSet, Int) -> Void { { source, destination in var ordered = ids @@ -714,10 +801,10 @@ struct ConnectionListView: View { return true } - private func commitRename(_ id: UUID, name: String) { - guard renamingConnectionId == id else { return } - renamingConnectionId = nil - appState.renameConnection(id, to: name) + private func commitRename(_ rowId: LibraryRowID, connectionId: UUID, name: String) { + guard renamingRow == rowId else { return } + renamingRow = nil + appState.renameConnection(connectionId, to: name) } private func confirmConnectionDeletion() { @@ -733,16 +820,47 @@ struct ConnectionListView: View { connectionsPendingDeletion = [] } - private func presentPendingImport() { - guard let url = appState.pendingImportURL else { return } - appState.pendingImportURL = nil - activeSheet = .importFile(url) + private func sheetDidDismiss() { + presenter.sheetDidDismiss(appState: appState) + deliverPendingIntent() + } + + private func deliverPendingIntent() { + guard let intent = presenter.takeDeliverableIntent( + isLocked: lockState.isLocked, + isLibraryWritable: appState.isLibraryWritable + ) else { return } + switch intent { + case .openConnection(let connectionId, let table): + guard appState.connections.contains(where: { $0.id == connectionId }) else { return } + presenter.requestTable(table, in: connectionId) + open(connectionId) + case .importConnections(let url): + guard selectedConnectionUUID != nil else { + presenter.present(.importFile(url)) + return + } + importAfterCoverDismissal = url + selectedConnectionIdString = nil + } + } + + private func presentImportAfterCoverDismissal() { + guard let url = importAfterCoverDismissal else { return } + importAfterCoverDismissal = nil + presenter.present(.importFile(url)) } +} - private func navigateToPendingConnection(_ id: UUID?) { - guard let id, - appState.connections.contains(where: { $0.id == id }) else { return } - selectedConnectionIdString = id.uuidString - appState.pendingConnectionId = nil +private struct SyncRefreshModifier: ViewModifier { + let isEnabled: Bool + let refresh: @Sendable () async -> Void + + func body(content: Content) -> some View { + if isEnabled { + content.refreshable(action: refresh) + } else { + content + } } } diff --git a/TableProMobile/TableProMobile/Views/DataBrowserView.swift b/TableProMobile/TableProMobile/Views/DataBrowserView.swift index 7bd997e7bd..26e6351ed6 100644 --- a/TableProMobile/TableProMobile/Views/DataBrowserView.swift +++ b/TableProMobile/TableProMobile/Views/DataBrowserView.swift @@ -76,7 +76,7 @@ struct DataBrowserView: View { var body: some View { @Bindable var viewModel = viewModel return searchableContent - .userActivity("com.TablePro.viewTable") { activity in + .userActivity(SceneIntent.viewTableActivity, isActive: !connection.isSample) { activity in activity.title = table.name activity.isEligibleForHandoff = true activity.userInfo = [ diff --git a/TableProMobile/TableProMobile/Views/GroupManagementView.swift b/TableProMobile/TableProMobile/Views/GroupManagementView.swift deleted file mode 100644 index 5ab1abb586..0000000000 --- a/TableProMobile/TableProMobile/Views/GroupManagementView.swift +++ /dev/null @@ -1,161 +0,0 @@ -import SwiftUI -import TableProConnectionLibrary -import TableProModels - -struct GroupManagementView: View { - private struct GroupRow: Identifiable { - let group: ConnectionGroup - let depth: Int - var id: UUID { group.id } - } - - private enum GroupSheet: Identifiable { - case add(parentId: UUID?) - case edit(ConnectionGroup) - - var id: String { - switch self { - case .add(let parentId): "add-\(parentId?.uuidString ?? "root")" - case .edit(let group): "edit-\(group.id.uuidString)" - } - } - } - - @Environment(AppState.self) private var appState - @Environment(\.dismiss) private var dismiss - @State private var activeSheet: GroupSheet? - @State private var groupToDelete: ConnectionGroup? - - private var showDeleteConfirmation: Binding { - Binding( - get: { groupToDelete != nil }, - set: { if !$0 { groupToDelete = nil } } - ) - } - - var body: some View { - let graph = LibraryGroupGraph(groups: appState.groups) - let groupsById = Dictionary(appState.groups.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first }) - let rows = graph.flattened().compactMap { entry in - groupsById[entry.id].map { GroupRow(group: $0, depth: entry.depth) } - } - let counts = Dictionary(grouping: appState.connections.compactMap(\.groupId), by: { $0 }).mapValues(\.count) - - NavigationStack { - List { - ForEach(rows) { row in - groupRow(row, count: counts[row.id] ?? 0, canNest: graph.canCreateSubgroup(under: row.id)) - } - .onMove { source, destination in - reorder(rows: rows, graph: graph, source: source, destination: destination) - } - } - .overlay { - if appState.groups.isEmpty { - ContentUnavailableView { - Label("No Groups", systemImage: "folder") - } description: { - Text("Create a group to organize your connections.") - } actions: { - Button("Create Group") { activeSheet = .add(parentId: nil) } - .buttonStyle(.borderedProminent) - } - } - } - .confirmationDialog( - String(localized: "Delete Group"), - isPresented: showDeleteConfirmation, - titleVisibility: .visible - ) { - Button(String(localized: "Delete"), role: .destructive) { - if let group = groupToDelete { - appState.deleteGroup(group.id) - } - } - } message: { - if let group = groupToDelete, !graph.descendantIds(of: group.id).isEmpty { - Text("Its subgroups are deleted too. Their connections move to Ungrouped.") - } else { - Text("Connections in this group will be moved to ungrouped.") - } - } - .navigationTitle("Groups") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarLeading) { - EditButton() - } - ToolbarItemGroup(placement: .topBarTrailing) { - Button { - activeSheet = .add(parentId: nil) - } label: { - Image(systemName: "plus") - } - .accessibilityLabel(Text("Add Group")) - CloseButton { dismiss() } - } - } - .sheet(item: $activeSheet) { sheet in - switch sheet { - case .add(let parentId): - GroupFormSheet(parentId: parentId) { group in - appState.addGroup(group) - } - case .edit(let group): - GroupFormSheet(editing: group) { updated in - appState.updateGroup(updated) - } - } - } - } - } - - private func groupRow(_ row: GroupRow, count: Int, canNest: Bool) -> some View { - Button { - activeSheet = .edit(row.group) - } label: { - ConnectionGroupRowLabel(group: row.group, connectionCount: count) - .padding(.leading, CGFloat(row.depth) * 20) - .foregroundStyle(.primary) - } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button { - groupToDelete = row.group - } label: { - Label("Delete", systemImage: "trash") - } - .tint(.red) - } - .contextMenu { - Button { - activeSheet = .edit(row.group) - } label: { - Label("Edit Group", systemImage: "pencil") - } - if canNest { - Button { - activeSheet = .add(parentId: row.id) - } label: { - Label("New Subgroup", systemImage: "folder.badge.plus") - } - } - Divider() - Button(role: .destructive) { - groupToDelete = row.group - } label: { - Label("Delete Group", systemImage: "trash") - } - } - .accessibilityAction(named: Text("Delete group")) { - groupToDelete = row.group - } - } - - private func reorder(rows: [GroupRow], graph: LibraryGroupGraph, source: IndexSet, destination: Int) { - guard let movedIndex = source.first, rows.indices.contains(movedIndex) else { return } - let parentId = graph.parentId(of: rows[movedIndex].id) - var ordered = rows.map(\.id) - ordered.move(fromOffsets: source, toOffset: destination) - appState.reorderGroups(ordered.filter { graph.parentId(of: $0) == parentId }) - } -} diff --git a/TableProMobile/TableProMobile/Views/LockScreenView.swift b/TableProMobile/TableProMobile/Views/LockScreenView.swift index 0beb7210d6..3fe77e27ff 100644 --- a/TableProMobile/TableProMobile/Views/LockScreenView.swift +++ b/TableProMobile/TableProMobile/Views/LockScreenView.swift @@ -16,10 +16,12 @@ struct LockScreenView: View { .font(.system(size: 56)) .foregroundStyle(.tint) .symbolRenderingMode(.hierarchical) + .accessibilityHidden(true) VStack(spacing: 6) { - Text("TablePro is Locked") + Text("TablePro Is Locked") .font(.title2.weight(.semibold)) + .accessibilityAddTraits(.isHeader) Text("Authenticate to access your database connections.") .font(.subheadline) .foregroundStyle(.secondary) @@ -30,11 +32,8 @@ struct LockScreenView: View { Button { Task { await unlock() } } label: { - Label( - didFail ? String(localized: "Try Again") : String(localized: "Unlock"), - systemImage: "faceid" - ) - .frame(minWidth: 220) + Label(buttonTitle, systemImage: biometrySymbol) + .frame(minWidth: 220) } .buttonStyle(.borderedProminent) .controlSize(.large) @@ -44,13 +43,37 @@ struct LockScreenView: View { .task { await unlock() } } + private var buttonTitle: String { + didFail ? String(localized: "Try Again") : String(localized: "Unlock") + } + + private var biometrySymbol: String { + switch lockState.biometry { + case .faceID: "faceid" + case .touchID: "touchid" + case .opticID: "opticid" + case .unavailable: "lock.open" + } + } + private func unlock() async { guard !isAuthenticating, lockState.isLocked else { return } isAuthenticating = true defer { isAuthenticating = false } - let success = await lockState.unlock() - if !success { - didFail = true - } + didFail = !(await lockState.unlock()) + } +} + +struct PrivacyCoverView: View { + var body: some View { + Rectangle() + .fill(.regularMaterial) + .ignoresSafeArea() + .overlay { + Image(systemName: "lock.fill") + .font(.system(size: 44)) + .foregroundStyle(.secondary) + .accessibilityHidden(true) + } } } diff --git a/TableProMobile/TableProMobile/Views/OnboardingView.swift b/TableProMobile/TableProMobile/Views/OnboardingView.swift deleted file mode 100644 index ab8a9b91e2..0000000000 --- a/TableProMobile/TableProMobile/Views/OnboardingView.swift +++ /dev/null @@ -1,196 +0,0 @@ -import SwiftUI - -struct OnboardingView: View { - @Environment(AppState.self) private var appState - @State private var currentPage = 0 - @State private var showAddConnection = false - @State private var didAddConnection = false - @State private var isSyncing = false - @State private var syncTask: Task? - - var body: some View { - TabView(selection: $currentPage) { - welcomePage.tag(0) - getStartedPage.tag(1) - } - .tabViewStyle(.page(indexDisplayMode: .always)) - .indexViewStyle(.page(backgroundDisplayMode: .always)) - .sheet(isPresented: $showAddConnection, onDismiss: { - if didAddConnection { - completeOnboarding() - } - }) { - ConnectionFormView { connection in - appState.addConnection(connection) - didAddConnection = true - showAddConnection = false - } - } - .overlay { - if isSyncing { - ZStack { - Rectangle() - .fill(.ultraThinMaterial) - .ignoresSafeArea() - VStack(spacing: 12) { - ProgressView() - .controlSize(.large) - Text("Syncing from iCloud...") - .font(.subheadline) - .foregroundStyle(.secondary) - } - } - } - } - .allowsHitTesting(!isSyncing) - } - - // MARK: - Pages - - private var welcomePage: some View { - VStack(spacing: 0) { - Spacer() - - appIconImage - .padding(.bottom, 24) - - Text("Welcome to TablePro") - .font(.largeTitle.bold()) - .padding(.bottom, 12) - - Text("A fast, lightweight database client for your iPhone and iPad.") - .font(.body) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 40) - - Spacer() - - Button { - withAnimation { currentPage = 1 } - } label: { - Text("Continue") - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .padding(.horizontal, 24) - .padding(.bottom, 60) - } - } - - private var getStartedPage: some View { - VStack(spacing: 0) { - Spacer() - - Text("Get Started") - .font(.title.bold()) - .padding(.bottom, 32) - - VStack(spacing: 12) { - Button(action: syncFromiCloud) { - actionCard( - icon: "icloud.and.arrow.down", - color: .blue, - title: String(localized: "Sync from iCloud"), - description: String(localized: "Import connections from your Mac") - ) - } - - Button(action: addNewConnection) { - actionCard( - icon: "plus.circle.fill", - color: .green, - title: String(localized: "Add Connection"), - description: String(localized: "Set up a new database connection") - ) - } - } - .padding(.horizontal, 24) - - Spacer() - - Button("Skip", action: completeOnboarding) - .font(.subheadline) - .foregroundStyle(.secondary) - .padding(.bottom, 60) - } - } - - // MARK: - Components - - private var appIconImage: some View { - Group { - if let uiImage = Self.loadAppIcon() { - Image(uiImage: uiImage) - .resizable() - } else { - Image(systemName: "cylinder.split.1x2") - .font(.system(size: 60)) - .foregroundStyle(.blue) - } - } - .frame(width: 100, height: 100) - .clipShape(RoundedRectangle(cornerRadius: 22)) - } - - private func actionCard(icon: String, color: Color, title: String, description: String) -> some View { - HStack(spacing: 16) { - Image(systemName: icon) - .font(.title2) - .foregroundStyle(color) - .frame(width: 44, height: 44) - .background(color.opacity(0.12)) - .clipShape(RoundedRectangle(cornerRadius: 10)) - - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.headline) - .foregroundStyle(.primary) - Text(description) - .font(.subheadline) - .foregroundStyle(.secondary) - } - - Spacer() - - Image(systemName: "chevron.right") - .font(.subheadline) - .foregroundStyle(.tertiary) - } - .padding() - .background(.fill.quaternary) - .clipShape(RoundedRectangle(cornerRadius: 12)) - } - - // MARK: - Actions - - private func syncFromiCloud() { - isSyncing = true - syncTask = Task { - await appState.syncCoordinator.sync() - guard !Task.isCancelled else { return } - isSyncing = false - completeOnboarding() - } - } - - private func addNewConnection() { - didAddConnection = false - showAddConnection = true - } - - private func completeOnboarding() { - appState.hasCompletedOnboarding = true - } - - // MARK: - Helpers - - private static func loadAppIcon() -> UIImage? { - guard let icons = Bundle.main.object(forInfoDictionaryKey: "CFBundleIcons") as? [String: Any], - let primary = icons["CFBundlePrimaryIcon"] as? [String: Any], - let files = primary["CFBundleIconFiles"] as? [String], - let name = files.last else { return nil } - return UIImage(named: name) - } -} diff --git a/TableProMobile/TableProMobile/Views/SceneRootView.swift b/TableProMobile/TableProMobile/Views/SceneRootView.swift index 8f36b577d4..b09b528e3d 100644 --- a/TableProMobile/TableProMobile/Views/SceneRootView.swift +++ b/TableProMobile/TableProMobile/Views/SceneRootView.swift @@ -1,11 +1,15 @@ +import CoreSpotlight import SwiftUI import TableProDatabase +import TableProModels -/// One per scene, because a coordinator carries the screen's own tab, navigation path and connect -/// attempt. Two iPad windows on the same connection are two screens, not one. struct SceneRootView: View { @Environment(AppState.self) private var appState + @Environment(AppLockState.self) private var lockState + @Environment(\.scenePhase) private var scenePhase + @EnvironmentObject private var sceneDelegate: TableProSceneDelegate @State private var coordinatorStore: ConnectionCoordinatorStore + @State private var presenter = ScenePresenter() init(connectionManager: ConnectionManager) { _coordinatorStore = State( @@ -14,16 +18,57 @@ struct SceneRootView: View { } var body: some View { - Group { - if appState.hasCompletedOnboarding { - ConnectionListView() - } else { - OnboardingView() + ConnectionListView() + .environment(coordinatorStore) + .environment(presenter) + .onChange(of: appState.connections) { previous, current in + coordinatorStore.reconcile(from: previous, to: current) } + .onChange(of: appState.sampleResetRevision) { _, _ in + for sample in appState.connections where sample.isSample { + coordinatorStore.invalidate(sample.id, droppingSession: false) + } + } + .onOpenURL { url in + guard let intent = SceneIntent.parse(url: url) else { return } + presenter.receive(intent) + } + .onContinueUserActivity(CSSearchableItemActionType, perform: receive) + .onContinueUserActivity(SceneIntent.viewConnectionActivity, perform: receive) + .onContinueUserActivity(SceneIntent.viewTableActivity, perform: receive) + .onAppear { + sceneDelegate.onDisconnect = { [presenter, appState] in + presenter.releaseLaunchClaim(appState: appState) + } + updateLockCover() + } + .onChange(of: lockState.isLocked) { _, _ in + updateLockCover() + } + .onChange(of: scenePhase) { _, _ in + updateLockCover() + } + } + + private func receive(_ activity: NSUserActivity) { + guard let intent = SceneIntent.parse(activityType: activity.activityType, userInfo: activity.userInfo) else { + return + } + presenter.receive(intent) + } + + private func updateLockCover() { + guard !TestRuntime.isActive else { return } + sceneDelegate.showCover(lockCoverMode, lockState: lockState) + } + + private var lockCoverMode: LockCoverMode? { + if lockState.isLocked { + return .locked } - .environment(coordinatorStore) - .onChange(of: appState.connections) { previous, current in - coordinatorStore.reconcile(from: previous, to: current) + guard scenePhase != .active, AppLockState.isLockEnabled, lockState.biometry != .unavailable else { + return nil } + return .obscured } } diff --git a/TableProMobile/TableProMobile/Views/SettingsView.swift b/TableProMobile/TableProMobile/Views/SettingsView.swift index bb795f5ed7..14c77b10ea 100644 --- a/TableProMobile/TableProMobile/Views/SettingsView.swift +++ b/TableProMobile/TableProMobile/Views/SettingsView.swift @@ -3,52 +3,27 @@ import TableProModels import TableProSyncTransport struct SettingsView: View { + private static let privacyPolicyURL = URL(string: "https://tablepro.app/privacy") + @Environment(AppState.self) private var appState - @AppStorage("com.TablePro.settings.shareAnalytics") private var shareAnalytics = true @AppStorage(AppLockState.lockEnabledKey) private var lockEnabled = false @AppStorage(AppLockState.lockTimeoutKey) private var lockTimeoutSeconds = AppLockState.AutoLockTimeout.fiveMinutes.rawValue - @AppStorage(AppPreferences.cloudSyncEnabledKey) private var cloudSyncEnabled = true @AppStorage(AppPreferences.syncPasswordsKey) private var syncPasswords = false @AppStorage(AppPreferences.defaultPageSizeKey) private var defaultPageSize = 100 @AppStorage(AppPreferences.defaultSafeModeKey) private var defaultSafeModeRaw = SafeModeLevel.off.rawValue @AppStorage(AppPreferences.hideQueryPreviewInActivityKey) private var hideQueryPreviewInActivity = false - @State private var showRefreshConfirmation = false - private let auth = BiometricAuthService() var body: some View { Form { biometricSection - syncSection - if cloudSyncEnabled { - refreshFromICloudSection - } + iCloudSection defaultsSection - - Section { - Toggle(String(localized: "Share anonymous usage data"), isOn: $shareAnalytics) - - Text("Help improve TablePro by sharing anonymous usage statistics (no personal data or queries).") - .font(.caption) - .foregroundStyle(.secondary) - - Toggle(String(localized: "Hide query in Live Activities"), isOn: $hideQueryPreviewInActivity) - } header: { - Text("Privacy") - } footer: { - Text("When on, the lock screen and Dynamic Island show \"Running query\" instead of the SQL preview.") - } - - Section("About") { - LabeledContent(String(localized: "Version")) { - Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "-") - } - LabeledContent(String(localized: "Build")) { - Text(Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "-") - } - } + usageDataSection + liveActivitySection + aboutSection } .navigationTitle(String(localized: "Settings")) } @@ -58,7 +33,7 @@ struct SettingsView: View { let availability = auth.availability if availability != .unavailable { Section { - Toggle(toggleLabel(for: availability), isOn: $lockEnabled) + Toggle(toggleLabel(for: availability), isOn: lockBinding) if lockEnabled { Picker(String(localized: "Auto-Lock"), selection: $lockTimeoutSeconds) { @@ -70,70 +45,30 @@ struct SettingsView: View { } header: { Text("Security") } footer: { - Text("Locks TablePro when reopened after the selected idle time. Cold launches always require authentication.") + if lockEnabled { + Text("Locks TablePro when reopened after the selected idle time. Cold launches always require authentication.") + } } } } - private var syncSection: some View { + private var iCloudSection: some View { Section { - Toggle(String(localized: "iCloud Sync"), isOn: $cloudSyncEnabled) - if cloudSyncEnabled { + Toggle(String(localized: "iCloud Sync"), isOn: cloudSyncBinding) + if appState.onboarding.isCloudSyncEnabled { LabeledContent(String(localized: "Last Sync")) { syncStatusLabel } - Button { - Task { await runSync() } - } label: { - HStack { - Text(String(localized: "Sync Now")) - Spacer() - if isSyncing { - ProgressView().controlSize(.small) - } - } - } - .disabled(isSyncing) - Toggle(String(localized: "Sync Passwords"), isOn: $syncPasswords) - - Text("Passwords sync through iCloud Keychain, which is end-to-end encrypted. Only affects new saves. Re-save a password to update its sync.") - .font(.caption) - .foregroundStyle(.secondary) } } header: { - Text("Sync") + Text("iCloud") } footer: { - Text("When off, connections, groups, and tags stay on this device only. Existing iCloud data is not deleted. Passwords stay on this device unless you turn on Sync Passwords.") - } - } - - private var refreshFromICloudSection: some View { - Section { - Button { - showRefreshConfirmation = true - } label: { - Label { - Text(String(localized: "Refresh from iCloud")) - } icon: { - Image(systemName: "arrow.clockwise.icloud") - } - } - .disabled(isSyncing) - .confirmationDialog( - String(localized: "Refresh from iCloud?"), - isPresented: $showRefreshConfirmation, - titleVisibility: .visible - ) { - Button(String(localized: "Refresh from iCloud")) { - Task { await runRefresh() } - } - Button(String(localized: "Cancel"), role: .cancel) {} - } message: { - Text("TablePro will re-download every connection, group, and tag from your iCloud account. Local data on this device is not deleted.") + if appState.onboarding.isCloudSyncEnabled { + Text("Passwords sync through iCloud Keychain, which is end-to-end encrypted. Turning it on affects new saves only, so re-save a password to sync it.") + } else { + Text("When off, nothing is sent to iCloud and nothing already there is deleted. Changes you make meanwhile sync once you turn it back on.") } - } footer: { - Text("If items appear on another device but not here, refresh forces a full re-download from iCloud. This may take a moment on slow networks.") } } @@ -147,11 +82,11 @@ struct SettingsView: View { .foregroundStyle(.secondary) } case .error(let error): - Text(error.localizedDescription) + Text(ConnectionListSyncMessage.text(for: error)) .foregroundStyle(.red) .multilineTextAlignment(.trailing) - .lineLimit(2) - case .idle: + .lineLimit(3) + case .idle, .disabled: if let date = appState.syncCoordinator.lastSyncDate { Text(date, style: .relative) .foregroundStyle(.secondary) @@ -159,24 +94,9 @@ struct SettingsView: View { Text(String(localized: "Never")) .foregroundStyle(.secondary) } - case .disabled: - Text(String(localized: "Off")) - .foregroundStyle(.secondary) } } - private var isSyncing: Bool { - appState.syncCoordinator.status == .syncing - } - - private func runSync() async { - await appState.syncCoordinator.sync() - } - - private func runRefresh() async { - await appState.syncCoordinator.resetSyncToken() - } - private var defaultsSection: some View { Section { Picker(String(localized: "Rows per Page"), selection: $defaultPageSize) { @@ -197,6 +117,82 @@ struct SettingsView: View { } } + private var usageDataSection: some View { + Section { + Toggle(String(localized: "Share Usage Data"), isOn: usageDataBinding) + } header: { + Text("Privacy") + } footer: { + Text("One report a day: hashed device ID, versions, language, database types, first-use dates. Never hostnames, credentials, queries, or data.") + } + } + + private var liveActivitySection: some View { + Section { + Toggle(String(localized: "Hide Query"), isOn: $hideQueryPreviewInActivity) + } header: { + Text("Live Activities") + } footer: { + Text("When on, the lock screen and Dynamic Island show \"Running query\" instead of the SQL preview.") + } + } + + private var aboutSection: some View { + Section("About") { + LabeledContent(String(localized: "Version"), value: versionText) + if FeatureHighlights.release(appState.currentAppVersion) != nil { + NavigationLink(String(localized: "What's New")) { + WhatsNewSettingsPage(version: appState.currentAppVersion) + } + } + if let privacyPolicyURL = Self.privacyPolicyURL { + Link(String(localized: "Privacy Policy"), destination: privacyPolicyURL) + } + NavigationLink(String(localized: "Acknowledgements")) { + AcknowledgementsView() + } + } + } + + private var versionText: String { + let build = Bundle.main.object(forInfoDictionaryKey: "CFBundleVersion") as? String ?? "" + guard !build.isEmpty else { return appState.currentAppVersion } + return String(format: String(localized: "%1$@ (%2$@)"), appState.currentAppVersion, build) + } + + private var lockBinding: Binding { + Binding( + get: { lockEnabled }, + set: { enabled in + guard !enabled else { + lockEnabled = true + return + } + Task { await turnOffLock() } + } + ) + } + + private func turnOffLock() async { + let reason = String(localized: "Authenticate to stop locking TablePro.") + guard await auth.authenticate(reason: reason) else { return } + lockEnabled = false + } + + private var cloudSyncBinding: Binding { + Binding( + get: { appState.onboarding.isCloudSyncEnabled }, + set: { appState.setCloudSyncEnabled($0) } + ) + } + + private var usageDataBinding: Binding { + Binding( + get: { appState.onboarding.isUsageDataEnabled }, + set: { appState.setUsageDataEnabled($0) } + ) + } + private func toggleLabel(for availability: BiometricAuthService.Availability) -> String { switch availability { case .faceID: String(localized: "Require Face ID") diff --git a/TableProMobile/TableProMobileTests/Helpers/AcknowledgementsInventoryTests.swift b/TableProMobile/TableProMobileTests/Helpers/AcknowledgementsInventoryTests.swift new file mode 100644 index 0000000000..16e448fe94 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Helpers/AcknowledgementsInventoryTests.swift @@ -0,0 +1,63 @@ +import Foundation +import Testing + +@testable import TableProMobile + +@Suite("Acknowledgements inventory") +struct AcknowledgementsInventoryTests { + @Test("the bundled acknowledgements decode and name the libraries the app links") + func bundledInventoryDecodes() throws { + let inventory = try AcknowledgementsInventory.bundled() + let ids = Set(inventory.components.map(\.id)) + + #expect(!inventory.components.isEmpty) + for required in ["openssl", "libssh2", "chinook"] { + #expect(ids.contains(required), "\(required) is missing from the bundled acknowledgements") + } + } + + @Test("every license text the acknowledgements name is in the app bundle") + func everyLicenseTextResolves() throws { + let inventory = try AcknowledgementsInventory.bundled() + + for component in inventory.components where component.textFile != nil { + let text = try? inventory.licenseText(for: component) + #expect(text?.isEmpty == false, "\(component.id) names a license text the app bundle does not carry") + } + } + + @Test("a license text missing from disk is reported rather than read as empty") + func missingLicenseTextThrows() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("AcknowledgementsInventoryTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let manifest = """ + [{"id": "absent", "name": "Absent", "version": "1.0", "spdx": "MIT", "copyrights": [], + "homepageURL": "https://example.com", "textFile": "texts/absent.txt"}] + """ + let manifestURL = root.appendingPathComponent("Acknowledgements.json") + try manifest.write(to: manifestURL, atomically: true, encoding: .utf8) + + let inventory = try AcknowledgementsInventory(manifestURL: manifestURL) + let component = try #require(inventory.components.first) + + #expect(throws: AcknowledgementsInventoryError.licenseTextMissing(componentId: "absent")) { + try inventory.licenseText(for: component) + } + } + + @Test("a pinned revision shows as a short hash, a release version as written") + func displayVersion() throws { + let data = Data(""" + [{"id": "pinned", "name": "Pinned", "version": "rev:f09d088889e252655ea1833eed821cd2be0de03a", + "spdx": "MIT", "copyrights": [], "homepageURL": "https://example.com", "textFile": null}, + {"id": "release", "name": "Release", "version": "3.4.4", "spdx": "MIT", "copyrights": [], + "homepageURL": "https://example.com", "textFile": null}] + """.utf8) + let components = try JSONDecoder().decode([AcknowledgementComponent].self, from: data) + + #expect(components.map(\.displayVersion) == ["f09d088", "3.4.4"]) + } +} diff --git a/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift b/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift new file mode 100644 index 0000000000..f5baf2279e --- /dev/null +++ b/TableProMobile/TableProMobileTests/Onboarding/AppStateLibraryTests.swift @@ -0,0 +1,138 @@ +import CloudKit +import Foundation +@testable import TableProMobile +import TableProModels +import TableProSync +import TableProSyncTransport +import Testing + +@MainActor +@Suite("App state library writes") +struct AppStateLibraryTests { + private let root: URL + private let libraryDirectory: URL + private let defaults: UserDefaults + private let metadata: SyncMetadataStorage + private let bundledSample: URL + + init() throws { + root = FileManager.default.temporaryDirectory + .appendingPathComponent("app-state-\(UUID().uuidString)", isDirectory: true) + libraryDirectory = root.appendingPathComponent("Library", isDirectory: true) + try FileManager.default.createDirectory(at: libraryDirectory, withIntermediateDirectories: true) + defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.AppState.\(UUID().uuidString)")) + metadata = SyncMetadataStorage(userDefaults: defaults) + bundledSample = root.appendingPathComponent("Chinook.sqlite") + try Data("sample".utf8).write(to: bundledSample) + } + + private func makeState(syncEnabled: Bool) -> AppState { + let coordinator = IOSSyncCoordinator( + metadata: metadata, + recordCache: SyncRecordCache(directory: root.appendingPathComponent("Cache"), defaults: nil), + makeTransport: { UnreachableTransport() }, + isEnabled: { syncEnabled } + ) + return AppState( + libraryDirectory: libraryDirectory, + defaults: defaults, + syncCoordinator: coordinator, + sampleInstaller: SampleDatabaseInstaller( + bundledURL: bundledSample, + directory: root.appendingPathComponent("Samples", isDirectory: true) + ) + ) + } + + @Test("A library that failed to load refuses every write and leaves the file alone") + func failedLoadRefusesWrites() throws { + let file = libraryDirectory.appendingPathComponent("connections.json") + let unreadable = Data("{ not json".utf8) + try unreadable.write(to: file) + let state = makeState(syncEnabled: false) + + #expect(state.loadStatus == .failed) + #expect(state.isLibraryWritable == false) + #expect(state.addConnection(DatabaseConnection(name: "New", type: .mysql)) == false) + #expect(state.addGroup(ConnectionGroup(name: "Team")) == false) + #expect(throws: SampleDatabaseError.libraryUnavailable) { + try state.openSampleDatabase() + } + #expect(try Data(contentsOf: file) == unreadable) + } + + @Test("Opening the sample twice keeps one sample connection, and it is never marked for sync") + func sampleIsLocal() throws { + let state = makeState(syncEnabled: true) + + let first = try state.openSampleDatabase() + let second = try state.openSampleDatabase() + + #expect(first == second) + #expect(state.connections.filter(\.isSample).count == 1) + #expect(state.connections.first?.database == SampleDatabaseInstaller.fileName) + #expect(!metadata.dirtyIds(for: .connection).contains(first.uuidString)) + + state.removeConnections([first]) + #expect(metadata.tombstones(for: .connection).isEmpty) + } + + @Test("An ordinary connection is marked for sync while sync is on") + func ordinaryConnectionIsMarked() { + let state = makeState(syncEnabled: true) + let connection = DatabaseConnection(name: "Prod", type: .postgresql) + + #expect(state.addConnection(connection)) + #expect(metadata.dirtyIds(for: .connection).contains(connection.id.uuidString)) + } + + @Test("A change made while sync is off waits for sync instead of being dropped") + func changeWaitsWhileOff() { + let state = makeState(syncEnabled: false) + let connection = DatabaseConnection(name: "Prod", type: .postgresql) + + #expect(state.addConnection(connection)) + #expect(metadata.dirtyIds(for: .connection).contains(connection.id.uuidString)) + } + + @Test("Closing the first run without answering records every question as declined") + func dismissedFirstRunDeclines() { + let state = makeState(syncEnabled: false) + + state.finishFirstRun(pages: [.welcome, .iCloud, .usageData]) + + #expect(state.onboarding.hasSeenWelcome) + #expect(state.onboarding.syncChoice == false) + #expect(state.onboarding.usageDataChoice == false) + } + + @Test("An answer given during the first run is kept when the sheet closes") + func answeredChoiceKept() { + let state = makeState(syncEnabled: false) + state.setUsageDataEnabled(true) + + state.finishFirstRun(pages: [.welcome, .usageData]) + + #expect(state.onboarding.usageDataChoice == true) + } +} + +private struct UnreachableTransport: IOSSyncTransport { + var currentZoneID: CKRecordZone.ID { + get async { CKRecordZone.ID(zoneName: "Unused", ownerName: CKCurrentUserDefaultName) } + } + + func accountStatus() async throws -> CKAccountStatus { + .noAccount + } + + func ensureZoneExists() async throws {} + + func pull(since token: CKServerChangeToken?) async throws -> PullResult { + PullResult(changedRecords: [], deletedRecordIDs: [], newToken: nil) + } + + func push(records: [CKRecord], deletions: [CKRecord.ID]) async throws -> PushOutcome { + PushOutcome(savedRecords: [:], deletedRecordIDs: []) + } +} diff --git a/TableProMobile/TableProMobileTests/Onboarding/ConnectionListStateTests.swift b/TableProMobile/TableProMobileTests/Onboarding/ConnectionListStateTests.swift new file mode 100644 index 0000000000..855eb016e9 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Onboarding/ConnectionListStateTests.swift @@ -0,0 +1,60 @@ +import Foundation +@testable import TableProMobile +import TableProSyncTransport +import Testing + +@MainActor +@Suite("Connection list state") +struct ConnectionListStateTests { + private func state( + loadStatus: LoadStatus = .ready, + hasItems: Bool = false, + syncEnabled: Bool = false, + status: SyncStatus = .idle, + firstSyncDone: Bool = false + ) -> ConnectionListState { + ConnectionListState.resolve( + loadStatus: loadStatus, + hasLibraryItems: hasItems, + isSyncEnabled: syncEnabled, + syncStatus: status, + hasCompletedFirstSync: firstSyncDone + ) + } + + @Test("A library that failed to load is never shown as empty") + func failedLoad() { + #expect(state(loadStatus: .failed) == .failed) + #expect(state(loadStatus: .failed, hasItems: true) == .failed) + } + + @Test("An empty library with sync off offers the local actions") + func emptyLocal() { + #expect(state() == .empty(syncsWithICloud: false)) + } + + @Test("The first iCloud pull shows progress instead of an empty list") + func firstPull() { + #expect(state(syncEnabled: true, status: .syncing) == .checkingICloud) + } + + @Test("A later sync over an empty library does not look like a first pull") + func laterSync() { + #expect(state(syncEnabled: true, status: .syncing, firstSyncDone: true) == .empty(syncsWithICloud: true)) + } + + @Test("iCloud trouble before anything arrived is said, not hidden") + func unavailable() { + #expect(state(syncEnabled: true, status: .error(.accountUnavailable)) == .iCloudUnavailable(.accountUnavailable)) + } + + @Test("A sync problem over a loaded library keeps the rows and reports it") + func problemWithContent() { + #expect(state(hasItems: true, syncEnabled: true, status: .error(.networkUnavailable)) == .content(syncProblem: .networkUnavailable)) + } + + @Test("A stale sync error is ignored once sync is off") + func errorIgnoredWhenOff() { + #expect(state(hasItems: true, status: .error(.networkUnavailable)) == .content(syncProblem: nil)) + } +} diff --git a/TableProMobile/TableProMobileTests/Onboarding/FirstRunPlanTests.swift b/TableProMobile/TableProMobileTests/Onboarding/FirstRunPlanTests.swift new file mode 100644 index 0000000000..76d3d12afa --- /dev/null +++ b/TableProMobile/TableProMobileTests/Onboarding/FirstRunPlanTests.swift @@ -0,0 +1,91 @@ +import Foundation +@testable import TableProMobile +import Testing + +@Suite("First run plan") +struct FirstRunPlanTests { + private func plan( + hasSeenWelcome: Bool = false, + syncChoice: Bool? = nil, + usageDataChoice: Bool? = nil, + lastSeenVersion: String? = nil, + currentVersion: String = "1.1", + hasHighlights: Bool = true + ) -> LaunchPresentation { + FirstRunPlan( + hasSeenWelcome: hasSeenWelcome, + syncChoice: syncChoice, + usageDataChoice: usageDataChoice, + lastSeenVersion: lastSeenVersion, + currentVersion: currentVersion, + hasHighlightsForCurrentVersion: hasHighlights + ).presentation + } + + @Test("A fresh install asks every question, welcome first") + func freshInstall() { + #expect(plan() == .firstRun([.welcome, .iCloud, .usageData])) + } + + @Test("A fresh install never shows What's New") + func freshInstallSkipsWhatsNew() { + #expect(plan(lastSeenVersion: nil) == .firstRun([.welcome, .iCloud, .usageData])) + } + + @Test("A TestFlight user who finished the old onboarding is asked about usage data only") + func legacyUserAskedOnce() { + #expect(plan(hasSeenWelcome: true, syncChoice: true, usageDataChoice: nil) == .firstRun([.usageData])) + } + + @Test("The iCloud page belongs to the welcome and is never shown on its own later") + func iCloudOnlyWithWelcome() { + #expect(plan(hasSeenWelcome: true, syncChoice: nil, usageDataChoice: false) == .none) + } + + @Test("A sync choice already made keeps the iCloud page out of the welcome") + func answeredSyncSkipsPage() { + #expect(plan(syncChoice: false, usageDataChoice: true) == .firstRun([.welcome])) + } + + @Test("An upgrade with highlights shows What's New once everything is answered") + func upgradeShowsWhatsNew() { + let presentation = plan( + hasSeenWelcome: true, + syncChoice: false, + usageDataChoice: false, + lastSeenVersion: "1.0", + currentVersion: "1.1" + ) + #expect(presentation == .whatsNew(version: "1.1")) + } + + @Test("An upgrade without highlights shows nothing") + func upgradeWithoutHighlights() { + let presentation = plan( + hasSeenWelcome: true, + syncChoice: true, + usageDataChoice: true, + lastSeenVersion: "1.0", + hasHighlights: false + ) + #expect(presentation == .none) + } + + @Test("A relaunch of the same version shows nothing") + func sameVersion() { + let presentation = plan( + hasSeenWelcome: true, + syncChoice: true, + usageDataChoice: false, + lastSeenVersion: "1.1", + currentVersion: "1.1" + ) + #expect(presentation == .none) + } + + @Test("A pending question wins over What's New") + func questionBeatsWhatsNew() { + let presentation = plan(hasSeenWelcome: true, syncChoice: true, lastSeenVersion: "1.0") + #expect(presentation == .firstRun([.usageData])) + } +} diff --git a/TableProMobile/TableProMobileTests/Onboarding/OnboardingPreferencesTests.swift b/TableProMobile/TableProMobileTests/Onboarding/OnboardingPreferencesTests.swift new file mode 100644 index 0000000000..54a079fa29 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Onboarding/OnboardingPreferencesTests.swift @@ -0,0 +1,61 @@ +import Foundation +@testable import TableProMobile +import Testing + +@MainActor +@Suite("Onboarding preferences") +struct OnboardingPreferencesTests { + private let defaults: UserDefaults + + init() throws { + defaults = try #require(UserDefaults(suiteName: "com.TablePro.tests.Onboarding.\(UUID().uuidString)")) + } + + @Test("A fresh install has made no choices and syncs nothing") + func freshInstall() { + let preferences = OnboardingPreferences(defaults: defaults) + + #expect(preferences.syncChoice == nil) + #expect(preferences.usageDataChoice == nil) + #expect(preferences.isCloudSyncEnabled == false) + #expect(preferences.isUsageDataEnabled == false) + #expect(preferences.hasSeenWelcome == false) + } + + @Test("A user who finished the old onboarding keeps syncing and is not assumed to consent") + func legacyUserMigrates() { + defaults.set(true, forKey: OnboardingPreferences.hasSeenWelcomeKey) + + let preferences = OnboardingPreferences(defaults: defaults) + + #expect(preferences.hasSeenWelcome) + #expect(preferences.syncChoice == true) + #expect(preferences.usageDataChoice == nil) + } + + @Test("A legacy user who had turned sync off stays off") + func legacyUserKeepsExplicitChoice() { + defaults.set(true, forKey: OnboardingPreferences.hasSeenWelcomeKey) + defaults.set(false, forKey: AppPreferences.cloudSyncEnabledKey) + + let preferences = OnboardingPreferences(defaults: defaults) + + #expect(preferences.syncChoice == false) + } + + @Test("Choices and the last seen version survive a relaunch") + func choicesPersist() { + let preferences = OnboardingPreferences(defaults: defaults) + preferences.setSyncChoice(true) + preferences.setUsageDataChoice(false) + preferences.markWelcomeSeen() + preferences.recordLaunch(version: "1.0") + + let reloaded = OnboardingPreferences(defaults: defaults) + + #expect(reloaded.syncChoice == true) + #expect(reloaded.usageDataChoice == false) + #expect(reloaded.hasSeenWelcome) + #expect(reloaded.lastSeenVersion == "1.0") + } +} diff --git a/TableProMobile/TableProMobileTests/Onboarding/SampleDatabaseInstallerTests.swift b/TableProMobile/TableProMobileTests/Onboarding/SampleDatabaseInstallerTests.swift new file mode 100644 index 0000000000..6d02e3fcac --- /dev/null +++ b/TableProMobile/TableProMobileTests/Onboarding/SampleDatabaseInstallerTests.swift @@ -0,0 +1,71 @@ +import Foundation +@testable import TableProMobile +import Testing + +@Suite("Sample database installer") +struct SampleDatabaseInstallerTests { + private let directory: URL + private let bundled: URL + + init() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("sample-installer-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + bundled = root.appendingPathComponent("Bundled.sqlite") + try Data("original".utf8).write(to: bundled) + directory = root.appendingPathComponent("Samples", isDirectory: true) + } + + @Test("The app bundle carries the sample database") + func bundleCarriesSample() throws { + let url = try #require(SampleDatabaseInstaller.live.bundledURL) + #expect(FileManager.default.fileExists(atPath: url.path)) + } + + @Test("Install copies once and keeps the user's changes after that") + func installKeepsChanges() throws { + let installer = SampleDatabaseInstaller(bundledURL: bundled, directory: directory) + + let installed = try installer.installIfNeeded() + try Data("edited".utf8).write(to: installed) + try installer.installIfNeeded() + + #expect(try String(contentsOf: installed, encoding: .utf8) == "edited") + } + + @Test("Reset restores the original and removes the journal files") + func resetRemovesSidecars() throws { + let installer = SampleDatabaseInstaller(bundledURL: bundled, directory: directory) + let installed = try installer.installIfNeeded() + try Data("edited".utf8).write(to: installed) + for suffix in SampleDatabaseInstaller.sidecarSuffixes { + try Data("stale".utf8).write(to: URL(fileURLWithPath: installed.path + suffix)) + } + + try installer.reset() + + #expect(try String(contentsOf: installed, encoding: .utf8) == "original") + for suffix in SampleDatabaseInstaller.sidecarSuffixes { + #expect(!FileManager.default.fileExists(atPath: installed.path + suffix)) + } + } + + @Test("A missing bundle is an error, never an empty database") + func missingBundle() { + let installer = SampleDatabaseInstaller(bundledURL: nil, directory: directory) + + #expect(throws: SampleDatabaseError.bundleMissing) { + try installer.installIfNeeded() + } + #expect(!FileManager.default.fileExists(atPath: installer.installedURL.path)) + } + + @Test("The samples folder is left out of device backups") + func excludedFromBackup() throws { + let installer = SampleDatabaseInstaller(bundledURL: bundled, directory: directory) + try installer.installIfNeeded() + + let values = try directory.resourceValues(forKeys: [.isExcludedFromBackupKey]) + #expect(values.isExcludedFromBackup == true) + } +} diff --git a/TableProMobile/TableProMobileTests/Onboarding/SceneIntentTests.swift b/TableProMobile/TableProMobileTests/Onboarding/SceneIntentTests.swift new file mode 100644 index 0000000000..6be1f71dc7 --- /dev/null +++ b/TableProMobile/TableProMobileTests/Onboarding/SceneIntentTests.swift @@ -0,0 +1,61 @@ +import CoreSpotlight +import Foundation +@testable import TableProMobile +import Testing + +@Suite("Scene intent parsing") +struct SceneIntentTests { + private let connectionId = UUID() + + @Test("A connect link opens the connection") + func connectLink() throws { + let url = try #require(URL(string: "tablepro://connect/\(connectionId.uuidString)")) + #expect(SceneIntent.parse(url: url) == .openConnection(connectionId, table: nil)) + } + + @Test("A connect link with a table opens that table, as it does on the Mac") + func tableLink() throws { + let url = try #require(URL(string: "tablepro://connect/\(connectionId.uuidString)/table/users")) + #expect(SceneIntent.parse(url: url) == .openConnection(connectionId, table: "users")) + } + + @Test("A link naming a database still opens the connection") + func databaseLinkOpensConnection() throws { + let url = try #require(URL(string: "tablepro://connect/\(connectionId.uuidString)/database/app/table/users")) + #expect(SceneIntent.parse(url: url) == .openConnection(connectionId, table: nil)) + } + + @Test("A malformed link or another host is ignored") + func malformedLinks() throws { + #expect(SceneIntent.parse(url: try #require(URL(string: "tablepro://connect/not-a-uuid"))) == nil) + #expect(SceneIntent.parse(url: try #require(URL(string: "tablepro://integrations/pair"))) == nil) + #expect(SceneIntent.parse(url: try #require(URL(string: "https://tablepro.app"))) == nil) + } + + @Test("A .tablepro file imports, any other file is ignored") + func files() { + let share = URL(fileURLWithPath: "/tmp/Team.TablePro") + #expect(SceneIntent.parse(url: share) == .importConnections(share)) + #expect(SceneIntent.parse(url: URL(fileURLWithPath: "/tmp/notes.txt")) == nil) + } + + @Test("Spotlight, Handoff and table activities resolve to the connection") + func activities() { + let id = connectionId.uuidString + #expect( + SceneIntent.parse(activityType: CSSearchableItemActionType, userInfo: [CSSearchableItemActivityIdentifier: id]) + == .openConnection(connectionId, table: nil) + ) + #expect( + SceneIntent.parse(activityType: SceneIntent.viewConnectionActivity, userInfo: ["connectionId": id]) + == .openConnection(connectionId, table: nil) + ) + #expect( + SceneIntent.parse( + activityType: SceneIntent.viewTableActivity, + userInfo: ["connectionId": id, "tableName": "orders"] + ) == .openConnection(connectionId, table: "orders") + ) + #expect(SceneIntent.parse(activityType: "com.example.other", userInfo: ["connectionId": id]) == nil) + } +} diff --git a/TableProMobile/TableProMobileTests/Onboarding/ScenePresenterTests.swift b/TableProMobile/TableProMobileTests/Onboarding/ScenePresenterTests.swift new file mode 100644 index 0000000000..817cba4a8e --- /dev/null +++ b/TableProMobile/TableProMobileTests/Onboarding/ScenePresenterTests.swift @@ -0,0 +1,61 @@ +import Foundation +@testable import TableProMobile +import Testing + +@MainActor +@Suite("Scene presenter") +struct ScenePresenterTests { + private let connectionId = UUID() + + @Test("An incoming link waits while any sheet is open, then arrives") + func waitsForSheet() { + let presenter = ScenePresenter() + presenter.present(.addConnection) + presenter.receive(.openConnection(connectionId, table: nil)) + + #expect(presenter.takeDeliverableIntent(isLocked: false, isLibraryWritable: true) == nil) + + presenter.sheet = nil + #expect(presenter.takeDeliverableIntent(isLocked: false, isLibraryWritable: true) == .openConnection(connectionId, table: nil)) + #expect(presenter.takeDeliverableIntent(isLocked: false, isLibraryWritable: true) == nil) + } + + @Test("Nothing is delivered while the app is locked") + func waitsForUnlock() { + let presenter = ScenePresenter() + presenter.receive(.openConnection(connectionId, table: nil)) + + #expect(presenter.takeDeliverableIntent(isLocked: true, isLibraryWritable: true) == nil) + #expect(presenter.takeDeliverableIntent(isLocked: false, isLibraryWritable: true) != nil) + } + + @Test("An import waits until the stored library can be written") + func importWaitsForLibrary() { + let presenter = ScenePresenter() + let file = URL(fileURLWithPath: "/tmp/Team.tablepro") + presenter.receive(.importConnections(file)) + + #expect(presenter.takeDeliverableIntent(isLocked: false, isLibraryWritable: false) == nil) + #expect(presenter.takeDeliverableIntent(isLocked: false, isLibraryWritable: true) == .importConnections(file)) + } + + @Test("The latest link wins") + func latestWins() { + let presenter = ScenePresenter() + let other = UUID() + presenter.receive(.openConnection(connectionId, table: nil)) + presenter.receive(.openConnection(other, table: "users")) + + #expect(presenter.takeDeliverableIntent(isLocked: false, isLibraryWritable: true) == .openConnection(other, table: "users")) + } + + @Test("A requested table is taken once, and only by its own connection") + func tableRequest() { + let presenter = ScenePresenter() + presenter.requestTable("Track", in: connectionId) + + #expect(presenter.takeTable(for: UUID()) == nil) + #expect(presenter.takeTable(for: connectionId) == "Track") + #expect(presenter.takeTable(for: connectionId) == nil) + } +} diff --git a/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift b/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift index ad5dadb0f8..cc8cd8a363 100644 --- a/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift +++ b/TableProMobile/TableProMobileTests/Sync/IOSSyncCoordinatorTests.swift @@ -13,6 +13,7 @@ private final class LibraryStateBox { var tags: [ConnectionTag] = [] var duringPull: () -> Void = {} var duringPush: () -> Void = {} + var syncEnabled = true func runDuringPull() { duringPull() @@ -28,6 +29,7 @@ private actor FakeSyncTransport: IOSSyncTransport { private let remoteRecords: [CKRecord] private let box: LibraryStateBox private(set) var pushedRecords: [CKRecord] = [] + private(set) var pullCount = 0 init(remoteRecords: [CKRecord], box: LibraryStateBox) { self.remoteRecords = remoteRecords @@ -41,6 +43,7 @@ private actor FakeSyncTransport: IOSSyncTransport { func ensureZoneExists() async throws {} func pull(since token: CKServerChangeToken?) async throws -> PullResult { + pullCount += 1 await box.runDuringPull() return PullResult(changedRecords: remoteRecords, deletedRecordIDs: [], newToken: nil) } @@ -73,7 +76,8 @@ struct IOSSyncCoordinatorTests { let coordinator = IOSSyncCoordinator( metadata: metadata, recordCache: SyncRecordCache(directory: cacheDirectory, defaults: nil), - makeTransport: { transport } + makeTransport: { transport }, + isEnabled: { box.syncEnabled } ) coordinator.getCurrentState = { (box.connections, box.groups, box.tags) } coordinator.onConnectionsChanged = { box.connections = $0 } @@ -154,4 +158,106 @@ struct IOSSyncCoordinatorTests { let pushed = await transport.pushedRecords.compactMap(SyncRecordMapper.toConnection) #expect(pushed.first?.isFavorite == true) } + + @Test("Sync is off: changes wait, and nothing is pulled or pushed") + func disabledSyncOnlyQueues() async { + let box = LibraryStateBox() + let local = DatabaseConnection(name: "Local", type: .mysql) + box.connections = [local] + box.syncEnabled = false + let transport = FakeSyncTransport(remoteRecords: [], box: box) + let coordinator = makeCoordinator(box: box, transport: transport) + + coordinator.markDirty(local.id) + coordinator.markDeleted(UUID()) + await coordinator.sync() + + #expect(await transport.pullCount == 0) + #expect(await transport.pushedRecords.isEmpty) + #expect(metadata.dirtyIds(for: .connection).contains(local.id.uuidString)) + #expect(metadata.tombstones(for: .connection).count == 1) + #expect(coordinator.status == .disabled(.userDisabled)) + } + + @Test("A sample connection is never pushed, even when marked") + func sampleIsNeverPushed() async { + let box = LibraryStateBox() + let sample = DatabaseConnection(name: "Sample", type: .sqlite, database: "Chinook.sqlite", isSample: true) + let local = DatabaseConnection(name: "Prod", type: .postgresql) + box.connections = [sample, local] + let transport = FakeSyncTransport(remoteRecords: [], box: box) + let coordinator = makeCoordinator(box: box, transport: transport) + coordinator.markDirty(sample.id) + coordinator.markDirty(local.id) + + await coordinator.sync() + + let pushed = await transport.pushedRecords.compactMap(SyncRecordMapper.toConnection).map(\.id) + #expect(pushed == [local.id]) + } + + @Test("A second sync waits for the one already running instead of returning early") + func concurrentSyncJoins() async { + let box = LibraryStateBox() + let transport = FakeSyncTransport(remoteRecords: [], box: box) + let coordinator = makeCoordinator(box: box, transport: transport) + + async let first: Void = coordinator.sync() + async let second: Void = coordinator.sync() + _ = await (first, second) + + #expect(await transport.pullCount == 1) + #expect(coordinator.status == .idle) + #expect(coordinator.lastSyncDate != nil) + } + + @Test("Turning sync on sends what changed while it was off, except the sample") + func enablingSendsQueuedChanges() async throws { + let box = LibraryStateBox() + let sample = DatabaseConnection(name: "Sample", type: .sqlite, database: "Chinook.sqlite", isSample: true) + let local = DatabaseConnection(name: "Prod", type: .postgresql) + box.connections = [sample, local] + box.syncEnabled = false + let transport = FakeSyncTransport(remoteRecords: [], box: box) + let coordinator = makeCoordinator(box: box, transport: transport) + coordinator.markDirty(sample.id) + coordinator.markDirty(local.id) + + box.syncEnabled = true + coordinator.setEnabled(true) + await coordinator.sync() + + let pushed = await transport.pushedRecords.compactMap(SyncRecordMapper.toConnection).map(\.id) + #expect(pushed == [local.id]) + } + + @Test("Turning sync off forgets the last sync, so turning it on again shows the first pull") + func disablingResetsFirstSync() async { + let box = LibraryStateBox() + let transport = FakeSyncTransport(remoteRecords: [], box: box) + let coordinator = makeCoordinator(box: box, transport: transport) + await coordinator.sync() + #expect(coordinator.hasCompletedFirstSync) + + box.syncEnabled = false + coordinator.setEnabled(false) + + #expect(coordinator.hasCompletedFirstSync == false) + } + + @Test("Turning sync off wins over a sync that was already running") + func disablingWinsOverRunningSync() async { + let box = LibraryStateBox() + let transport = FakeSyncTransport(remoteRecords: [], box: box) + let coordinator = makeCoordinator(box: box, transport: transport) + box.duringPull = { + box.syncEnabled = false + coordinator.setEnabled(false) + } + + await coordinator.sync() + + #expect(coordinator.status == .disabled(.userDisabled)) + #expect(coordinator.lastSyncDate == nil) + } } diff --git a/TableProMobile/TableProWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/TableProMobile/TableProWidget/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index 2305880107..0000000000 --- a/TableProMobile/TableProWidget/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,35 +0,0 @@ -{ - "images" : [ - { - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "dark" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - }, - { - "appearances" : [ - { - "appearance" : "luminosity", - "value" : "tinted" - } - ], - "idiom" : "universal", - "platform" : "ios", - "size" : "1024x1024" - } - ], - "info" : { - "author" : "xcode", - "version" : 1 - } -} diff --git a/TableProMobile/TableProWidget/TableProWidget.entitlements b/TableProMobile/TableProWidget/TableProWidget.entitlements deleted file mode 100644 index 930e4ce9bc..0000000000 --- a/TableProMobile/TableProWidget/TableProWidget.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.application-groups - - group.com.TablePro.TableProMobile - - - diff --git a/TableProMobile/project.yml b/TableProMobile/project.yml index b13702fc0a..196bdb2db8 100644 --- a/TableProMobile/project.yml +++ b/TableProMobile/project.yml @@ -44,6 +44,9 @@ targets: - TableProWidget/Shared/QueryActivityAttributes.swift - TableProWidget/Shared/SharedConnectionStore.swift - TableProWidget/Shared/WidgetConnectionItem.swift + # The sample database the macOS app ships, opened from the empty connection list. + - path: ../TablePro/Resources/SampleDatabases/Chinook.sqlite + buildPhase: resources # FreeTDS bridging the iOS build compiles directly; there is no plugin # bundle on iOS, so the driver links into the app. - ../Plugins/MSSQLDriverPlugin/FreeTDSConnection.swift @@ -74,6 +77,11 @@ targets: - ../Plugins/PostgreSQLDriverPlugin/LibPQCopyState.swift # The read-write BEGIN both platforms open a write transaction with. - ../Plugins/PostgreSQLDriverPlugin/PostgreSQLTransactionStatement.swift + # License texts behind the Acknowledgements screen, copied whole so each keeps the + # texts/ path Acknowledgements.json names it by. + - path: ../TablePro/Resources/ThirdPartyLicenses/texts + type: folder + buildPhase: resources configFiles: Debug: ../Configs/Version-iOS.xcconfig Release: ../Configs/Version-iOS.xcconfig diff --git a/TableProTests/Core/ThirdPartyLicenseInventoryTests.swift b/TableProTests/Core/ThirdPartyLicenseInventoryTests.swift index ce3b09c9af..a7b22a22c2 100644 --- a/TableProTests/Core/ThirdPartyLicenseInventoryTests.swift +++ b/TableProTests/Core/ThirdPartyLicenseInventoryTests.swift @@ -35,8 +35,8 @@ struct ThirdPartyLicenseInventoryTests { } /// Every `NAME_VERSION="value"` literal the build scripts pin, which is what the shipped - /// static libraries are actually built from. - private static func shellVersionPins() throws -> [String: String] { + /// static libraries are actually built from, plus the default of a `"${1:-value}"` pin. + private static func shellVersionPins() throws -> [String: Set] { let scripts = repositoryRoot.appendingPathComponent("scripts") /// `scripts/lib` as well as the build scripts. A pin shared by several builds lives in the /// library rather than in any one script, and naming a single file here meant the test @@ -44,25 +44,36 @@ struct ThirdPartyLicenseInventoryTests { let buildScripts = try FileManager.default.contentsOfDirectory(atPath: scripts.path) .filter { $0.hasPrefix("build-") && $0.hasSuffix(".sh") } .map { scripts.appendingPathComponent($0) } - let libraryDirectory = scripts.appendingPathComponent("lib") - let libraryScripts = ((try? FileManager.default.contentsOfDirectory(atPath: libraryDirectory.path)) ?? []) - .filter { $0.hasSuffix(".sh") } - .map { libraryDirectory.appendingPathComponent($0) } + let libraryScripts = shellScripts(in: scripts.appendingPathComponent("lib")) + let iosScripts = shellScripts(in: scripts.appendingPathComponent("ios")) - let pattern = try NSRegularExpression(pattern: #"^([A-Z0-9_]+_VERSION)="(v?[0-9][^"$]*)""#, options: [.anchorsMatchLines]) - var pins: [String: String] = [:] - for path in buildScripts + libraryScripts { + let pattern = try NSRegularExpression( + pattern: #"^([A-Z0-9_]+_VERSION)="(?:\$\{[^:}]+:-)*(v?[0-9][^"$}]*)\}*""#, + options: [.anchorsMatchLines] + ) + var pins: [String: Set] = [:] + for path in buildScripts + libraryScripts + iosScripts { guard let source = try? String(contentsOf: path, encoding: .utf8) else { continue } let range = NSRange(source.startIndex ..< source.endIndex, in: source) for match in pattern.matches(in: source, range: range) { guard let key = Range(match.range(at: 1), in: source), let value = Range(match.range(at: 2), in: source) else { continue } - pins[String(source[key])] = String(source[value]) + pins[String(source[key]), default: []].insert(String(source[value])) } } return pins } + private static func shellScripts(in directory: URL) -> [URL] { + ((try? FileManager.default.contentsOfDirectory(atPath: directory.path)) ?? []) + .filter { $0.hasSuffix(".sh") } + .map { directory.appendingPathComponent($0) } + } + + private static var iosAcknowledgementsURL: URL { + repositoryRoot.appendingPathComponent("TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json") + } + private static func swiftPackagePins() throws -> [String: Set] { let files = [ "TablePro.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved", @@ -149,10 +160,13 @@ struct ThirdPartyLicenseInventoryTests { let pinned = pins[variable] #expect(pinned != nil, "\(component.id) points at \(variable), which no build script defines") guard let pinned else { continue } + let pinnedVersions = Set(pinned.map(\.normalizedVersion)) + let recorded = Set(component.version.components(separatedBy: ", ").map(\.normalizedVersion)) #expect( - pinned.normalizedVersion == component.version.normalizedVersion, + recorded == pinnedVersions, """ - \(component.id) is recorded as \(component.version) but \(variable) pins \(pinned). + \(component.id) is recorded as \(component.version) but \(variable) pins \ + \(pinned.sorted().joined(separator: ", ")) across the macOS and iOS build scripts. Re-read the upstream license at the new version before updating this entry: a bump can change the license, as OpenSSL did at 3.0 and Redis at 7.4. """ @@ -225,6 +239,93 @@ struct ThirdPartyLicenseInventoryTests { ) } } + + @Test("The iOS acknowledgements are the projection of the inventory's iOS entries") + func iosAcknowledgementsMatchInventory() throws { + let inventory = try loadInventory() + let expected = inventory.components + .filter { $0.ships(on: .ios) } + .map(IOSAcknowledgement.init(component:)) + .sorted { ($0.name.lowercased(), $0.id) < ($1.name.lowercased(), $1.id) } + + let data = try Data(contentsOf: Self.iosAcknowledgementsURL) + let committed = try JSONDecoder().decode([IOSAcknowledgement].self, from: data) + + #expect( + committed == expected, + """ + TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json is out of date with licenses.yml. + Run scripts/generate-ios-acknowledgements.py and commit the result. + """ + ) + } + + @Test("A component with no platforms ships on macOS only") + func platformsDefaultToMacOS() throws { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("ThirdPartyLicenseInventoryTests-\(UUID().uuidString)", isDirectory: true) + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + defer { try? FileManager.default.removeItem(at: root) } + + let yaml = """ + - id: mac-only + name: Mac Only + version: 1.0.0 + spdx: MIT + copyrights: [] + homepageURL: https://example.com/mac + licenseTextURL: https://example.com/mac/LICENSE + textFile: texts/mac.txt + source: manual + patched: false + - id: ios-only + name: iOS Only + version: 1.0.0 + spdx: MIT + copyrights: [] + homepageURL: https://example.com/ios + licenseTextURL: https://example.com/ios/LICENSE + textFile: texts/ios.txt + source: manual + patched: false + platforms: [ios] + """ + try yaml.write( + to: root.appendingPathComponent(ThirdPartyLicenseInventory.inventoryFileName), + atomically: true, + encoding: .utf8 + ) + + let inventory = try ThirdPartyLicenseInventory(rootURL: root) + let macOnly = try #require(inventory.components.first { $0.id == "mac-only" }) + + #expect(macOnly.platforms == [.macos]) + #expect(inventory.attributed.map(\.id) == ["mac-only"]) + } +} + +private struct IOSAcknowledgement: Codable, Equatable { + let id: String + let name: String + let version: String + let spdx: String + let copyrights: [String] + let homepageURL: String + let textFile: String? +} + +private extension IOSAcknowledgement { + init(component: ThirdPartyComponent) { + self.init( + id: component.id, + name: component.name, + version: component.version, + spdx: component.spdx, + copyrights: component.copyrights, + homepageURL: component.homepageURL, + textFile: component.textFile + ) + } } private extension String { diff --git a/docs/features/icloud-sync.mdx b/docs/features/icloud-sync.mdx index d031239cc1..cc26bd41b0 100644 --- a/docs/features/icloud-sync.mdx +++ b/docs/features/icloud-sync.mdx @@ -17,7 +17,7 @@ Each synced category has its own toggle under **Sync Categories**. | Data | Synced | Notes | |------|--------|-------| | **Connections** | Yes | Host, port, username, database type, SSH/SSL config, and whether the connection is a favorite | -| **Passwords** | Opt-in | Nested under Connections, and carried by Apple's iCloud Keychain rather than TablePro's own records. Turning it on affects new saves only, so re-save a password to sync it. On iPhone and iPad the same switch is **Settings > Sync > Sync Passwords** | +| **Passwords** | Opt-in | Nested under Connections, and carried by Apple's iCloud Keychain rather than TablePro's own records. Turning it on affects new saves only, so re-save a password to sync it. On iPhone and iPad the same switch is **Settings > iCloud > Sync Passwords** | | **Groups & Tags** | Yes | Nested group hierarchy and sort order included | | **SSH Profiles** | Yes | Named [SSH profiles](/connections/ssh-profiles) | | **Credential Profiles** | Not yet | Named [credential profiles](/connections/credential-profiles) stay on the Mac that created them until their CloudKit schema ships | diff --git a/docs/images/ios-connection-list-dark.png b/docs/images/ios-connection-list-dark.png index 61d965bdb9..9fd8403261 100644 Binary files a/docs/images/ios-connection-list-dark.png and b/docs/images/ios-connection-list-dark.png differ diff --git a/docs/images/ios-connection-list.png b/docs/images/ios-connection-list.png index 70a657a161..a3a411a767 100644 Binary files a/docs/images/ios-connection-list.png and b/docs/images/ios-connection-list.png differ diff --git a/docs/images/ios-first-run-dark.png b/docs/images/ios-first-run-dark.png new file mode 100644 index 0000000000..9da0c628d6 Binary files /dev/null and b/docs/images/ios-first-run-dark.png differ diff --git a/docs/images/ios-first-run.png b/docs/images/ios-first-run.png new file mode 100644 index 0000000000..e59aa3e457 Binary files /dev/null and b/docs/images/ios-first-run.png differ diff --git a/docs/ios/index.mdx b/docs/ios/index.mdx index 7b2662bb1c..913b6a9d5c 100644 --- a/docs/ios/index.mdx +++ b/docs/ios/index.mdx @@ -5,9 +5,25 @@ description: Browse tables, run queries, and edit rows on ten database engines f You need iOS 18 or later. Builds ship on their own schedule, so a feature in a Mac release note may not be on your phone yet. Your version and build are under **Settings > About**. Connections saved on a Mac come over iCloud; the rest of your Mac setup stays on the Mac. - - TablePro connection list on iPhone - TablePro connection list on iPhone + + Connections grouped under Favorites and a Production folder, next to Settings with Security, iCloud and New Connections sections + Connections grouped under Favorites and a Production folder, next to Settings with Security, iCloud and New Connections sections + + +## First launch + +A welcome sheet lists what the app does. **Continue** leads to at most two questions, each asked once: + +| Question | Shown when | If you swipe the sheet away | +| --- | --- | --- | +| **Sync with iCloud** | The device is signed in to iCloud | Sync stays off | +| **Share Usage Data?** | Always | Nothing is sent | + +Both answers can change later, under **Settings > iCloud** and **Settings > Privacy**. After an update, a **What's New** sheet lists what changed, once per version. + + + Welcome to TablePro sheet with four feature rows, beside the Share Usage Data page listing what the daily report contains + Welcome to TablePro sheet with four feature rows, beside the Share Usage Data page listing what the daily report contains ## Supported databases @@ -34,7 +50,7 @@ MongoDB, ClickHouse, Cassandra, BigQuery, Spanner, Snowflake and the rest have n ## The connection list -Favorites come first, then **Recent** with the five connections opened last, then your groups, nested the way the Mac nests them. The **More** menu holds **Sort By** (**Manual**, **Name**, **Database Type**, **Last Connected**), **Filter by Tag**, group and tag management, and import and export. Typing a tag's name in the search field offers it as a filter. +Favorites come first, then **Recent** with the five connections opened last, then your groups, nested the way the Mac nests them. The **More** menu holds **Sort By** (**Manual**, **Name**, **Database Type**, **Last Connected**), **New Group**, **Manage Tags**, **Open Sample Database**, and import and export. Type a tag's name in the search field to filter by it; with two tags or more chosen, **Match All Tags** appears in the **More** menu. | To | Do this | | --- | --- | @@ -44,12 +60,18 @@ Favorites come first, then **Recent** with the five connections opened last, the | Reorder | Tap **Edit** and drag the handles, with **Sort By** on **Manual** | | Change several at once | Tap **Edit**, select the rows, and tap **Move**, **Favorite** or **Delete** | | Delete | Swipe left, or touch and hold and choose **Delete** | +| Edit or delete a group | Swipe right on the group to edit it, swipe left to delete it | +| Sync now | Pull down on the list, with iCloud Sync on | **Duplicate** copies the saved password, SSH secrets, client certificates and file access along with the settings. Recent and the sort order stay on the device. +## The sample database + +**Open Sample Database** sits on the empty list and in the **More** menu. It opens Chinook, a SQLite database of a music store, at its **Track** table, with no server to set up. The sample stays on this device: it never syncs, exports or hands off to another device. To throw away your edits, touch and hold **Chinook (Sample)** and choose **Reset Database**. + ## What syncs over -The same CloudKit container as the Mac, with iCloud Sync on by default here and off by default there. Three record types cross: connections (host, port, SSH tunnel and SSL settings, color, safe mode level, favorite), groups with their nesting, and tags. SSH profiles, table favorites, saved queries, and app settings are skipped, and query history on the phone is only what you ran on the phone. +The same CloudKit container as the Mac. iCloud Sync is off on both until you turn it on: here during first launch or under **Settings > iCloud**. While it is off nothing leaves the device, and edits made in the meantime go up when you turn it back on. Three record types cross: connections (host, port, SSH tunnel and SSL settings, color, safe mode level, favorite), groups with their nesting, and tags. SSH profiles, table favorites, saved queries, and app settings are skipped, and query history on the phone is only what you ran on the phone. Three things a connection depends on are per device and never sync: @@ -59,7 +81,7 @@ Three things a connection depends on are per device and never sync: | An SSH private key | It arrived as a path on the Mac, so the tunnel cannot find it | Pick the key on this device, or paste it into **Private Key** | | SQLite and DuckDB file paths | The path points at the Mac's disk | Pick the file again here | -Passwords are a separate opt-in, off by default, at **Settings > Sync > Sync Passwords**. They ride iCloud Keychain and only new saves are affected, so re-save a password on the Mac to push it across. **Sync Now** sits in the same section, and a background refresh runs about every 30 minutes. +Passwords are a separate opt-in, off by default, at **Settings > iCloud > Sync Passwords**. They ride iCloud Keychain and only new saves are affected, so re-save a password on the Mac to push it across. Pull down on the connection list to sync now; a background refresh runs about every 30 minutes. Picking a SQLite file copies it into the app, and edits go to that copy, so the original never changes and the two drift apart. DuckDB writes back to the file you picked. @@ -85,7 +107,7 @@ A save, insert, delete, truncate or drop runs inside a read-write transaction. M The editor highlights SQL, runs a statement, and stops one mid-flight. **Stop** ends the result stream at once; on MySQL and Redis the statement keeps running on the server until it finishes. Results copy or export as JSON, CSV, or SQL `INSERT`. -A running query appears in a Live Activity on the lock screen and Dynamic Island, with the elapsed time and the row count so far. **Settings > Privacy** replaces the SQL text there with "Running query". The card clears when the query ends. Quitting the app mid-query leaves it behind, marked interrupted, until the next launch clears it. +A running query appears in a Live Activity on the lock screen and Dynamic Island, with the elapsed time and the row count so far. **Settings > Live Activities > Hide Query** replaces the SQL text there with "Running query". The card clears when the query ends. Quitting the app mid-query leaves it behind, marked interrupted, until the next launch clears it. ## What is missing @@ -95,12 +117,18 @@ A running query appears in a Live Activity on the lock screen and Dynamic Island ## Security -Turn on Face ID, Touch ID, or Optic ID under **Settings > Security**. A cold launch always asks; after that **Auto-Lock** relocks immediately or after 1, 5, 15, or 60 minutes idle, starting at 5. +Turn on Face ID, Touch ID, or Optic ID under **Settings > Security**. A cold launch always asks; after that **Auto-Lock** relocks immediately or after 1, 5, 15, or 60 minutes idle, starting at 5. The lock covers open sheets and connections, and the app switcher shows a locked card instead of your data. Turning the lock off asks you to authenticate first. Each connection carries its own [safe mode](/features/safe-mode) level, synced from the Mac and settable here. iOS has three: **Off**, **Confirm Writes**, and **Read-Only**, which refuses writes outright. **Settings > New Connections** sets the level a new connection starts at. SSH tunnels authenticate with a password or a private key, and an unknown host key prompts with its fingerprint first. +## Privacy + +**Share Usage Data** under **Settings > Privacy** sends one small report a day: a hashed device identifier, the app and iOS versions, your language, the database types you use, and when you first connected and first ran a query. It is off unless you chose **Share Usage Data** on first launch, and it never carries a hostname, username, password, query, or row. + +**Settings > About** holds the version, **What's New**, the privacy policy, and **Acknowledgements** for the open source libraries the app ships. + ## Shortcuts and widgets Three App Intents put the app in Shortcuts, the Share Sheet, and Siri: **Open Connection**, **Add Row to Table**, **Add Rows to Table**. See [iOS Shortcuts](/external-api/ios-shortcuts) for pickers, data formats, and limits. diff --git a/docs/security/privacy.mdx b/docs/security/privacy.mdx index 7737d63dec..f0bc2fdaba 100644 --- a/docs/security/privacy.mdx +++ b/docs/security/privacy.mdx @@ -44,6 +44,8 @@ An `X-Signature` header carries an HMAC-SHA256 of the body. It proves the payloa **Share anonymous usage data** in **Settings > General** is on by default. With it off, the send returns before the payload is built, and nothing queues for later. +On iPhone and iPad the heartbeat is off until you choose **Share Usage Data**, and its `platform` is `ios`. See [iPhone and iPad](/ios#privacy). + ## AI providers Nothing reaches a model until you add a provider, and a fresh install has none: with none configured, every AI path returns before it builds a request. **Enable AI Features** in **Settings > AI** is on out of the box, but it gates an empty tab. diff --git a/scripts/generate-ios-acknowledgements.py b/scripts/generate-ios-acknowledgements.py new file mode 100755 index 0000000000..0a465dd272 --- /dev/null +++ b/scripts/generate-ios-acknowledgements.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Write the iOS app's Acknowledgements.json from the third-party license inventory. + +The iOS app does not link Yams, so it reads a JSON projection of licenses.yml: every component +whose platforms include ios, sorted by name, with only the fields its Acknowledgements screen shows. +ThirdPartyLicenseInventoryTests fails when the committed file differs from this output. + +Usage: + scripts/generate-ios-acknowledgements.py +""" + +import json +import sys +from pathlib import Path + +import yaml + +REPOSITORY_ROOT = Path(__file__).resolve().parent.parent +INVENTORY_PATH = REPOSITORY_ROOT / "TablePro/Resources/ThirdPartyLicenses/licenses.yml" +OUTPUT_PATH = REPOSITORY_ROOT / "TableProMobile/TableProMobile/Acknowledgements/Acknowledgements.json" + +PROJECTED_FIELDS = ("id", "name", "version", "spdx", "copyrights", "homepageURL", "textFile") +KNOWN_PLATFORMS = {"macos", "ios"} +DEFAULT_PLATFORMS = ["macos"] +TARGET_PLATFORM = "ios" + + +def load_inventory(path: Path) -> list[dict]: + with path.open(encoding="utf-8") as handle: + return yaml.load(handle, Loader=yaml.BaseLoader) + + +def platforms_of(component: dict) -> list[str]: + platforms = component.get("platforms", DEFAULT_PLATFORMS) + unknown = sorted(set(platforms) - KNOWN_PLATFORMS) + if unknown: + raise ValueError(f"{component['id']} names unknown platforms: {', '.join(unknown)}") + return platforms + + +def project(component: dict) -> dict: + return {field: component.get(field) for field in PROJECTED_FIELDS} + + +def sort_key(component: dict) -> tuple[str, str]: + return (component["name"].lower(), component["id"]) + + +def main() -> int: + try: + components = load_inventory(INVENTORY_PATH) + shipped = [project(c) for c in components if TARGET_PLATFORM in platforms_of(c)] + except (OSError, yaml.YAMLError, ValueError) as error: + print(f"error: {error}", file=sys.stderr) + return 1 + + shipped.sort(key=sort_key) + OUTPUT_PATH.parent.mkdir(parents=True, exist_ok=True) + OUTPUT_PATH.write_text(json.dumps(shipped, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f"Wrote {len(shipped)} components to {OUTPUT_PATH.relative_to(REPOSITORY_ROOT)}") + return 0 + + +if __name__ == "__main__": + sys.exit(main())