From a5b48e0464bd937b464343b891429ef3837d4429 Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 15 Mar 2026 22:30:00 +0200 Subject: [PATCH 1/3] Add SFTP web publishing for iOS Introduce SFTP web publishing support for iOS: add SFTPUploader (uses Shout) to upload/remove notes, including key-based auth via temporary key files and descriptive error wrapping. Add SFTPViewController for settings/UI (host, port, path, web URL, username/password, import/delete private/public keys, passphrase, test connection and toggle). Persist public key data in UserDefaultsManagement and integrate SFTP options into SettingsViewController and NotesTableView (Create/Update/Delete web actions use SFTPUploader when custom server is enabled). Add localization keys/placeholders and update project.pbxproj to include new sources and related entitlements/project settings. --- FSNotes iOS/Helpers/SFTPUploader.swift | 194 ++++++++ FSNotes iOS/Localizable.xcstrings | 87 ++++ .../Preferences/SFTPViewController.swift | 444 ++++++++++++++++++ .../Preferences/SettingsViewController.swift | 9 +- FSNotes iOS/View/NotesTableView.swift | 46 +- FSNotes.xcodeproj/project.pbxproj | 30 +- FSNotesCore/UserDefaultsManagement.swift | 10 + 7 files changed, 807 insertions(+), 13 deletions(-) create mode 100644 FSNotes iOS/Helpers/SFTPUploader.swift create mode 100644 FSNotes iOS/Preferences/SFTPViewController.swift diff --git a/FSNotes iOS/Helpers/SFTPUploader.swift b/FSNotes iOS/Helpers/SFTPUploader.swift new file mode 100644 index 000000000..84ef22185 --- /dev/null +++ b/FSNotes iOS/Helpers/SFTPUploader.swift @@ -0,0 +1,194 @@ +// +// SFTPUploader.swift +// FSNotes iOS +// +// SFTP web publishing for iOS, mirroring the macOS ViewController+Web.swift logic. +// Private keys are stored as raw Data in UserDefaultsManagement.sftpAccessData and +// written to a temporary file for libssh2, then immediately deleted after use. +// + +import Foundation +import Shout + +/// Wraps any error so its description is always visible via `localizedDescription`, +/// even for types (like Shout's SSHError) that don't conform to LocalizedError. +private struct DescriptiveError: LocalizedError { + let errorDescription: String? + init(_ error: Error) { + if let le = error as? LocalizedError, let msg = le.errorDescription, !msg.isEmpty { + errorDescription = msg + } else { + errorDescription = String(describing: error) + } + } +} + +enum SFTPUploaderError: LocalizedError { + case missingCredentials + case missingPath + case missingWebURL + case buildPageFailed + + var errorDescription: String? { + switch self { + case .missingCredentials: return NSLocalizedString("Please set a password or import a private key in SFTP settings.", comment: "") + case .missingPath: return NSLocalizedString("Remote path is not configured in SFTP settings.", comment: "") + case .missingWebURL: return NSLocalizedString("Web URL is not configured in SFTP settings.", comment: "") + case .buildPageFailed: return NSLocalizedString("Failed to render the note as HTML.", comment: "") + } + } +} + +struct SFTPUploader { + + // MARK: - Public API + + /// Uploads a note to the configured SFTP server and returns the public web URL. + static func upload(note: Note, completion: @escaping (Result) -> Void) { + DispatchQueue.global().async { + do { + guard let sftpPath = UserDefaultsManagement.sftpPath, !sftpPath.isEmpty else { + throw SFTPUploaderError.missingPath + } + guard let webBase = UserDefaultsManagement.sftpWeb, !webBase.isEmpty else { + throw SFTPUploaderError.missingWebURL + } + + let dst = URL(fileURLWithPath: NSTemporaryDirectory()).appendingPathComponent("SFTPUpload") + try? FileManager.default.removeItem(at: dst) + + guard let localURL = MPreviewView.buildPage(for: note, at: dst, web: true) else { + throw SFTPUploaderError.buildPageFailed + } + + let latinName = note.getLatinName() + let remoteDir = sftpPath.hasSuffix("/") ? "\(sftpPath)\(latinName)/" : "\(sftpPath)/\(latinName)/" + let resultURL = URL(string: webBase.hasSuffix("/") ? webBase + latinName + "/" : webBase + "/" + latinName + "/")! + let images = note.content.getImagesAndFiles() + + guard let ssh = try makeSSH() else { + throw SFTPUploaderError.missingCredentials + } + + try ssh.execute("mkdir -p \(remoteDir)") + + let sftp = try ssh.openSftp() + + // Upload index.html + let remoteIndex = remoteDir + "index.html" + _ = try? ssh.execute("rm -f \(remoteIndex)") + try sftp.upload(localURL: localURL, remotePath: remoteIndex) + + // Upload zip archive if present + let zipURL = localURL + .deletingLastPathComponent() + .appendingPathComponent(latinName) + .appendingPathExtension("zip") + if FileManager.default.fileExists(atPath: zipURL.path) { + try? sftp.upload(localURL: zipURL, remotePath: remoteDir + latinName + ".zip") + } + + // Upload images + var imageDirCreated = false + for image in images { + if image.path.hasPrefix("http://") || image.path.hasPrefix("https://") { + continue + } + if !imageDirCreated { + try ssh.execute("mkdir -p \(remoteDir)i/") + imageDirCreated = true + } + try? sftp.upload(localURL: image.url, remotePath: remoteDir + "i/" + image.url.lastPathComponent) + } + + note.uploadPath = remoteDir + Storage.shared().saveUploadPaths() + + DispatchQueue.main.async { + completion(.success(resultURL)) + } + } catch { + DispatchQueue.main.async { + completion(.failure(DescriptiveError(error))) + } + } + } + } + + /// Removes a previously uploaded note from the SFTP server. + static func remove(note: Note, completion: @escaping (Error?) -> Void) { + guard let remotePath = note.uploadPath else { + completion(nil) + return + } + + DispatchQueue.global().async { + do { + guard let ssh = try makeSSH() else { + throw SFTPUploaderError.missingCredentials + } + + try ssh.execute("rm -rf \(remotePath)") + + note.uploadPath = nil + Storage.shared().saveUploadPaths() + + DispatchQueue.main.async { + completion(nil) + } + } catch { + DispatchQueue.main.async { + completion(DescriptiveError(error)) + } + } + } + } + + // MARK: - SSH session factory + + /// Creates and authenticates an SSH session using the configured credentials. + /// Returns nil (without throwing) when credentials are simply absent/incomplete, + /// throws when a connection or auth error occurs. + static func makeSSH() throws -> SSH? { + let host = UserDefaultsManagement.sftpHost + let port = UserDefaultsManagement.sftpPort + let username = UserDefaultsManagement.sftpUsername + let password = UserDefaultsManagement.sftpPassword + let passphrase = UserDefaultsManagement.sftpPassphrase + + guard !host.isEmpty else { return nil } + + let ssh = try SSH(host: host, port: port > 0 ? port : 22) + + if !password.isEmpty { + try ssh.authenticate(username: username, password: password) + return ssh + } + + // Key-based auth: write key data to temp files, authenticate, then delete + if let keyData = UserDefaultsManagement.sftpAccessData { + let rand = Int.random(in: 1000...9999) + let tmpKey = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("fsnotes_sftp_key_\(rand)") + try keyData.write(to: tmpKey) + defer { try? FileManager.default.removeItem(at: tmpKey) } + + var tmpPubKeyPath: String? = nil + var tmpPubKey: URL? = nil + if let pubKeyData = UserDefaultsManagement.sftpPublicKeyData { + let tmpPub = URL(fileURLWithPath: NSTemporaryDirectory()) + .appendingPathComponent("fsnotes_sftp_key_\(rand).pub") + try pubKeyData.write(to: tmpPub) + tmpPubKey = tmpPub + tmpPubKeyPath = tmpPub.path + } + defer { if let u = tmpPubKey { try? FileManager.default.removeItem(at: u) } } + + let passValue = passphrase.isEmpty ? nil : passphrase + try ssh.authenticate(username: username, privateKey: tmpKey.path, publicKey: tmpPubKeyPath, passphrase: passValue) + return ssh + } + + return nil + } +} diff --git a/FSNotes iOS/Localizable.xcstrings b/FSNotes iOS/Localizable.xcstrings index 3163c17cd..98cf8e12d 100644 --- a/FSNotes iOS/Localizable.xcstrings +++ b/FSNotes iOS/Localizable.xcstrings @@ -76,6 +76,12 @@ } } } + }, + "(optional)" : { + + }, + "(or use private key)" : { + }, "+" : { "comment" : "Settings", @@ -230,6 +236,9 @@ } } }, + "Actions" : { + "comment" : "SFTP settings" + }, "Add External Folder" : { "comment" : "Settings", "localizations" : { @@ -688,6 +697,9 @@ } } }, + "Authentication" : { + "comment" : "SFTP settings" + }, "Auto Rename By Title" : { "comment" : "Settings", "localizations" : { @@ -1530,6 +1542,12 @@ } } } + }, + "Connection Failed" : { + + }, + "Connection Successful" : { + }, "Container" : { "comment" : "Settings", @@ -1683,6 +1701,9 @@ } } } + }, + "Could not authenticate. Check credentials." : { + }, "Create Folder" : { "comment" : "Main view popover table", @@ -3288,6 +3309,9 @@ } } } + }, + "Failed to render the note as HTML." : { + }, "Family" : { "comment" : "Settings", @@ -4747,6 +4771,9 @@ } } } + }, + "Host" : { + }, "iCloud Drive" : { "comment" : "Settings", @@ -5442,6 +5469,9 @@ } } } + }, + "loaded" : { + }, "Loading..." : { "localizations" : { @@ -5825,6 +5855,9 @@ } } } + }, + "Missing Host" : { + }, "Modification Date" : { "localizations" : { @@ -6208,6 +6241,9 @@ } } } + }, + "not set" : { + }, "Notes" : { "comment" : "Notes in sidebar\nSidebar items\nSidebar label", @@ -7048,6 +7084,9 @@ } } } + }, + "Please enter a host address." : { + }, "Please enter valid password" : { "localizations" : { @@ -7124,6 +7163,9 @@ } } } + }, + "Please set a password or import a private key in SFTP settings." : { + }, "Please try again" : { "localizations" : { @@ -7200,6 +7242,9 @@ } } } + }, + "Port" : { + }, "Private Key" : { "localizations" : { @@ -7352,6 +7397,9 @@ } } } + }, + "Public Key" : { + }, "Public Key (optional)" : { "localizations" : { @@ -7504,6 +7552,12 @@ } } } + }, + "Remote Path" : { + + }, + "Remote path is not configured in SFTP settings." : { + }, "Remove Encryption" : { "localizations" : { @@ -8805,6 +8859,9 @@ } } }, + "Server" : { + "comment" : "SFTP settings" + }, "Settings" : { "comment" : "Sidebar settings", "localizations" : { @@ -8881,6 +8938,9 @@ } } } + }, + "SFTP Error" : { + }, "Share" : { "localizations" : { @@ -9346,6 +9406,9 @@ } } } + }, + "Successfully connected to the server." : { + }, "Support" : { "comment" : "Settings", @@ -9500,6 +9563,12 @@ } } } + }, + "Test Connection" : { + + }, + "Testing…" : { + }, "Thanks" : { "comment" : "Settings", @@ -10343,6 +10412,9 @@ } } } + }, + "Use Custom SFTP Server" : { + }, "Use First Line as Title" : { "localizations" : { @@ -10572,6 +10644,9 @@ } } } + }, + "Username" : { + }, "Verify Password" : { "localizations" : { @@ -10956,6 +11031,12 @@ } } }, + "Web" : { + "comment" : "Settings" + }, + "Web Publishing (SFTP)" : { + "comment" : "Settings" + }, "Web sharing error" : { "localizations" : { "cs" : { @@ -11031,6 +11112,12 @@ } } } + }, + "Web URL" : { + + }, + "Web URL is not configured in SFTP settings." : { + }, "Website" : { "comment" : "Settings", diff --git a/FSNotes iOS/Preferences/SFTPViewController.swift b/FSNotes iOS/Preferences/SFTPViewController.swift new file mode 100644 index 000000000..cc76b20ca --- /dev/null +++ b/FSNotes iOS/Preferences/SFTPViewController.swift @@ -0,0 +1,444 @@ +// +// SFTPViewController.swift +// FSNotes iOS +// +// Created for FSNotes iOS SFTP web publishing support. +// + +import UIKit +import Shout + +class SFTPViewController: UIViewController, UITableViewDelegate, UITableViewDataSource { + + private let tableView = UITableView(frame: .zero, style: .insetGrouped) + + enum SFTPSection: Int, CaseIterable { + case server + case authentication + case actions + + var title: String { + switch self { + case .server: return NSLocalizedString("Server", comment: "SFTP settings") + case .authentication: return NSLocalizedString("Authentication", comment: "SFTP settings") + case .actions: return NSLocalizedString("Actions", comment: "SFTP settings") + } + } + } + + // MARK: - Row tags for text fields + private let tagHost = 100 + private let tagPort = 101 + private let tagRemotePath = 102 + private let tagWebURL = 103 + private let tagUsername = 104 + private let tagPassword = 105 + private let tagPassphrase = 106 + + private var privateKeyData: Data? { + get { return UserDefaultsManagement.sftpAccessData } + set { UserDefaultsManagement.sftpAccessData = newValue } + } + + private var publicKeyData: Data? { + get { return UserDefaultsManagement.sftpPublicKeyData } + set { UserDefaultsManagement.sftpPublicKeyData = newValue } + } + + private lazy var documentPickerPrivateKey: UIDocumentPickerViewController = { + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.data], asCopy: true) + picker.delegate = self + picker.allowsMultipleSelection = false + picker.modalPresentationStyle = .formSheet + return picker + }() + + private lazy var documentPickerPublicKey: UIDocumentPickerViewController = { + let picker = UIDocumentPickerViewController(forOpeningContentTypes: [.data], asCopy: true) + picker.delegate = self + picker.allowsMultipleSelection = false + picker.modalPresentationStyle = .formSheet + return picker + }() + + // MARK: - Lifecycle + + override func viewDidLoad() { + super.viewDidLoad() + title = NSLocalizedString("Web Publishing (SFTP)", comment: "Settings") + navigationItem.largeTitleDisplayMode = .never + + view.addSubview(tableView) + tableView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + tableView.topAnchor.constraint(equalTo: view.topAnchor), + tableView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + tableView.trailingAnchor.constraint(equalTo: view.trailingAnchor), + tableView.bottomAnchor.constraint(equalTo: view.bottomAnchor) + ]) + + tableView.delegate = self + tableView.dataSource = self + tableView.keyboardDismissMode = .interactive + + setupKeyboardObservers() + } + + // MARK: - UITableViewDataSource + + func numberOfSections(in tableView: UITableView) -> Int { + return SFTPSection.allCases.count + } + + func tableView(_ tableView: UITableView, numberOfRowsInSection section: Int) -> Int { + switch SFTPSection(rawValue: section)! { + case .server: return 4 // host, port, remote path, web URL + case .authentication: return 5 // username, password, private key, public key, passphrase + case .actions: return 2 // enable custom server toggle + test button + } + } + + func tableView(_ tableView: UITableView, titleForHeaderInSection section: Int) -> String? { + return SFTPSection(rawValue: section)?.title + } + + func tableView(_ tableView: UITableView, heightForHeaderInSection section: Int) -> CGFloat { + return 50 + } + + func tableView(_ tableView: UITableView, heightForRowAt indexPath: IndexPath) -> CGFloat { + return 50 + } + + func tableView(_ tableView: UITableView, cellForRowAt indexPath: IndexPath) -> UITableViewCell { + switch SFTPSection(rawValue: indexPath.section)! { + case .server: + return makeTextFieldCell(for: indexPath) + case .authentication: + return makeAuthCell(for: indexPath) + case .actions: + return makeActionCell(for: indexPath) + } + } + + // MARK: - UITableViewDelegate + + func tableView(_ tableView: UITableView, didSelectRowAt indexPath: IndexPath) { + tableView.deselectRow(at: indexPath, animated: true) + + if indexPath.section == SFTPSection.authentication.rawValue && indexPath.row == 2 { + present(documentPickerPrivateKey, animated: true) + } + + if indexPath.section == SFTPSection.authentication.rawValue && indexPath.row == 3 { + present(documentPickerPublicKey, animated: true) + } + + if indexPath.section == SFTPSection.actions.rawValue && indexPath.row == 1 { + testConnection() + } + } + + // MARK: - Cell builders + + private func makeTextFieldCell(for indexPath: IndexPath) -> UITableViewCell { + var labelText = "" + var placeholder = "" + var currentValue = "" + var tag = 0 + var keyboardType: UIKeyboardType = .default + let isSecure = false + + switch indexPath.row { + case 0: + labelText = NSLocalizedString("Host", comment: "") + placeholder = "example.com" + currentValue = UserDefaultsManagement.sftpHost + tag = tagHost + keyboardType = .URL + case 1: + labelText = NSLocalizedString("Port", comment: "") + placeholder = "22" + currentValue = UserDefaultsManagement.sftpPort > 0 ? "\(UserDefaultsManagement.sftpPort)" : "" + tag = tagPort + keyboardType = .numberPad + case 2: + labelText = NSLocalizedString("Remote Path", comment: "") + placeholder = "/var/www/notes/" + currentValue = UserDefaultsManagement.sftpPath ?? "" + tag = tagRemotePath + case 3: + labelText = NSLocalizedString("Web URL", comment: "") + placeholder = "https://example.com/notes/" + currentValue = UserDefaultsManagement.sftpWeb ?? "" + tag = tagWebURL + keyboardType = .URL + default: + break + } + + return makeLabelTextFieldCell(label: labelText, placeholder: placeholder, + value: currentValue, tag: tag, + keyboardType: keyboardType, isSecure: isSecure) + } + + private func makeAuthCell(for indexPath: IndexPath) -> UITableViewCell { + switch indexPath.row { + case 0: + return makeLabelTextFieldCell( + label: NSLocalizedString("Username", comment: ""), + placeholder: "admin", + value: UserDefaultsManagement.sftpUsername, + tag: tagUsername + ) + case 1: + return makeLabelTextFieldCell( + label: NSLocalizedString("Password", comment: ""), + placeholder: NSLocalizedString("(or use private key)", comment: ""), + value: UserDefaultsManagement.sftpPassword, + tag: tagPassword, + isSecure: true + ) + case 2: + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.textLabel?.text = NSLocalizedString("Private Key", comment: "") + cell.accessoryType = .disclosureIndicator + + if privateKeyData != nil { + cell.detailTextLabel?.text = "✓ " + NSLocalizedString("loaded", comment: "") + let deleteButton = UIButton(type: .system) + deleteButton.setImage(UIImage(systemName: "trash"), for: .normal) + deleteButton.frame = CGRect(x: 0, y: 0, width: 35, height: 35) + deleteButton.addTarget(self, action: #selector(deletePrivateKey), for: .touchUpInside) + cell.accessoryView = deleteButton + } else { + cell.detailTextLabel?.text = NSLocalizedString("not set", comment: "") + } + return cell + + case 3: + let cell = UITableViewCell(style: .value1, reuseIdentifier: nil) + cell.textLabel?.text = NSLocalizedString("Public Key", comment: "") + cell.accessoryType = .disclosureIndicator + + if publicKeyData != nil { + cell.detailTextLabel?.text = "✓ " + NSLocalizedString("loaded", comment: "") + let deleteButton = UIButton(type: .system) + deleteButton.setImage(UIImage(systemName: "trash"), for: .normal) + deleteButton.frame = CGRect(x: 0, y: 0, width: 35, height: 35) + deleteButton.addTarget(self, action: #selector(deletePublicKey), for: .touchUpInside) + cell.accessoryView = deleteButton + } else { + cell.detailTextLabel?.text = NSLocalizedString("not set", comment: "") + } + return cell + + case 4: + return makeLabelTextFieldCell( + label: NSLocalizedString("Passphrase", comment: ""), + placeholder: NSLocalizedString("(optional)", comment: ""), + value: UserDefaultsManagement.sftpPassphrase, + tag: tagPassphrase, + isSecure: true + ) + + default: + return UITableViewCell() + } + } + + private func makeActionCell(for indexPath: IndexPath) -> UITableViewCell { + switch indexPath.row { + case 0: + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.textLabel?.text = NSLocalizedString("Use Custom SFTP Server", comment: "") + let toggle = UISwitch() + toggle.isOn = UserDefaultsManagement.customWebServer + toggle.addTarget(self, action: #selector(customServerToggleChanged(_:)), for: .valueChanged) + cell.accessoryView = toggle + cell.selectionStyle = .none + return cell + + case 1: + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.textLabel?.text = NSLocalizedString("Test Connection", comment: "") + cell.textLabel?.textColor = .systemBlue + cell.textLabel?.textAlignment = .center + return cell + + default: + return UITableViewCell() + } + } + + // MARK: - Text field helpers + + /// Builds a cell with a fixed-width label on the left and an editable text field on the right. + private func makeLabelTextFieldCell(label: String, + placeholder: String, + value: String, + tag: Int, + keyboardType: UIKeyboardType = .default, + isSecure: Bool = false) -> UITableViewCell { + let cell = UITableViewCell(style: .default, reuseIdentifier: nil) + cell.selectionStyle = .none + + let titleLabel = UILabel() + titleLabel.translatesAutoresizingMaskIntoConstraints = false + titleLabel.text = label + titleLabel.font = UIFont.systemFont(ofSize: 17) + titleLabel.setContentHuggingPriority(.required, for: .horizontal) + titleLabel.setContentCompressionResistancePriority(.required, for: .horizontal) + + let textField = UITextField() + textField.translatesAutoresizingMaskIntoConstraints = false + textField.placeholder = placeholder + textField.text = value + textField.tag = tag + textField.textAlignment = .right + textField.textColor = UIColor.secondaryLabel + textField.keyboardType = keyboardType + textField.isSecureTextEntry = isSecure + textField.autocorrectionType = .no + textField.autocapitalizationType = .none + textField.addTarget(self, action: #selector(textFieldDidChange(_:)), for: .editingChanged) + + let cv = cell.contentView + cv.addSubview(titleLabel) + cv.addSubview(textField) + + NSLayoutConstraint.activate([ + titleLabel.leadingAnchor.constraint(equalTo: cv.leadingAnchor, constant: 16), + titleLabel.centerYAnchor.constraint(equalTo: cv.centerYAnchor), + + textField.leadingAnchor.constraint(equalTo: titleLabel.trailingAnchor, constant: 12), + textField.trailingAnchor.constraint(equalTo: cv.trailingAnchor, constant: -16), + textField.topAnchor.constraint(equalTo: cv.topAnchor), + textField.bottomAnchor.constraint(equalTo: cv.bottomAnchor), + ]) + + return cell + } + + // MARK: - Actions + + @objc private func textFieldDidChange(_ sender: UITextField) { + let text = sender.text ?? "" + switch sender.tag { + case tagHost: UserDefaultsManagement.sftpHost = text + case tagPort: UserDefaultsManagement.sftpPort = Int32(text) ?? 22 + case tagRemotePath: UserDefaultsManagement.sftpPath = text + case tagWebURL: UserDefaultsManagement.sftpWeb = text + case tagUsername: UserDefaultsManagement.sftpUsername = text + case tagPassword: UserDefaultsManagement.sftpPassword = text + case tagPassphrase: UserDefaultsManagement.sftpPassphrase = text + default: break + } + } + + @objc private func customServerToggleChanged(_ sender: UISwitch) { + UserDefaultsManagement.customWebServer = sender.isOn + } + + @objc private func deletePrivateKey() { + privateKeyData = nil + tableView.reloadSections(IndexSet(integer: SFTPSection.authentication.rawValue), with: .automatic) + } + + @objc private func deletePublicKey() { + publicKeyData = nil + tableView.reloadSections(IndexSet(integer: SFTPSection.authentication.rawValue), with: .automatic) + } + + private func testConnection() { + let host = UserDefaultsManagement.sftpHost + guard !host.isEmpty else { + showAlert(title: NSLocalizedString("Missing Host", comment: ""), + message: NSLocalizedString("Please enter a host address.", comment: "")) + return + } + + let hud = UIAlertController(title: NSLocalizedString("Testing…", comment: ""), message: nil, preferredStyle: .alert) + present(hud, animated: true) + + DispatchQueue.global().async { + do { + guard let ssh = try SFTPUploader.makeSSH() else { + DispatchQueue.main.async { + hud.dismiss(animated: true) { + self.showAlert(title: NSLocalizedString("Connection Failed", comment: ""), + message: NSLocalizedString("Could not authenticate. Check credentials.", comment: "")) + } + } + return + } + _ = try ssh.capture("echo ok") + + DispatchQueue.main.async { + hud.dismiss(animated: true) { + self.showAlert(title: NSLocalizedString("Connection Successful", comment: ""), + message: NSLocalizedString("Successfully connected to the server.", comment: "")) + } + } + } catch { + let message = (error as? LocalizedError)?.errorDescription ?? String(describing: error) + DispatchQueue.main.async { + hud.dismiss(animated: true) { + self.showAlert(title: NSLocalizedString("Connection Failed", comment: ""), + message: message) + } + } + } + } + } + + private func showAlert(title: String, message: String) { + let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + present(alert, animated: true) + } + + // MARK: - Keyboard + + private func setupKeyboardObservers() { + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillShow(_:)), name: UIResponder.keyboardWillShowNotification, object: nil) + NotificationCenter.default.addObserver(self, selector: #selector(keyboardWillHide(_:)), name: UIResponder.keyboardWillHideNotification, object: nil) + } + + @objc private func keyboardWillShow(_ notification: Notification) { + guard let frame = notification.userInfo?[UIResponder.keyboardFrameEndUserInfoKey] as? CGRect else { return } + UIView.animate(withDuration: 0.3) { + self.tableView.contentInset.bottom = frame.height - self.view.safeAreaInsets.bottom + self.tableView.verticalScrollIndicatorInsets.bottom = frame.height - self.view.safeAreaInsets.bottom + } + } + + @objc private func keyboardWillHide(_ notification: Notification) { + UIView.animate(withDuration: 0.3) { + self.tableView.contentInset.bottom = 0 + self.tableView.verticalScrollIndicatorInsets.bottom = 0 + } + } + + deinit { + NotificationCenter.default.removeObserver(self) + } +} + +// MARK: - UIDocumentPickerDelegate + +extension SFTPViewController: UIDocumentPickerDelegate, UINavigationControllerDelegate { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first, let data = try? Data(contentsOf: url) else { return } + if controller === documentPickerPublicKey { + publicKeyData = data + } else { + privateKeyData = data + } + tableView.reloadSections(IndexSet(integer: SFTPSection.authentication.rawValue), with: .automatic) + } + + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + controller.dismiss(animated: true) + } +} diff --git a/FSNotes iOS/Preferences/SettingsViewController.swift b/FSNotes iOS/Preferences/SettingsViewController.swift index 1c83fb9de..040d879ee 100644 --- a/FSNotes iOS/Preferences/SettingsViewController.swift +++ b/FSNotes iOS/Preferences/SettingsViewController.swift @@ -25,6 +25,7 @@ class SettingsViewController: UITableViewController, UIDocumentPickerDelegate { NSLocalizedString("Editor", comment: "Settings"), NSLocalizedString("Security", comment: "Settings"), NSLocalizedString("Git", comment: "Settings"), + NSLocalizedString("Web", comment: "Settings"), NSLocalizedString("Icon", comment: "Settings"), NSLocalizedString("Advanced", comment: "Settings"), ], [ @@ -46,6 +47,7 @@ class SettingsViewController: UITableViewController, UIDocumentPickerDelegate { "paragraphsign", "lock.fill", "arrow.triangle.pull", + "server.rack", "square.grid.3x3.middleleft.filled", "atom" ], [ @@ -67,6 +69,7 @@ class SettingsViewController: UITableViewController, UIDocumentPickerDelegate { ["#ff453a", "#ff9f0a"], ["#bf5af2", "#40c8e0"], ["#8e8e93", "#48484a"], + ["#0a84ff", "#5ac8fa"], ["#5e5ce6", "#8e8e93"], ["#dc1c13", "#f07470"] ], @@ -84,7 +87,7 @@ class SettingsViewController: UITableViewController, UIDocumentPickerDelegate { ] ] - var rowsInSection = [6, 4, 4] + var rowsInSection = [7, 4, 4] override func viewWillAppear(_ animated: Bool) { navigationController?.navigationBar.prefersLargeTitles = true @@ -207,8 +210,10 @@ class SettingsViewController: UITableViewController, UIDocumentPickerDelegate { guard let project = Storage.shared().getDefault() else { return } lvc = AppDelegate.getGitVC(for: project) case 4: - lvc = AppIconViewController() + lvc = SFTPViewController() case 5: + lvc = AppIconViewController() + case 6: lvc = ProViewController() default: return diff --git a/FSNotes iOS/View/NotesTableView.swift b/FSNotes iOS/View/NotesTableView.swift index 5c7dd93b4..e45113e86 100644 --- a/FSNotes iOS/View/NotesTableView.swift +++ b/FSNotes iOS/View/NotesTableView.swift @@ -393,15 +393,17 @@ class NotesTableView: UITableView, let shareImage = UIImage(systemName: "square.and.arrow.up") actions.append(UIAction(title: shareTitle, image: shareImage, identifier: UIAction.Identifier("share"), handler: handler)) + let isPublished = note.apiId != nil || (UserDefaultsManagement.customWebServer && note.uploadPath != nil) + var shareWebTitle = NSLocalizedString("Create Web Page", comment: "") - if note.apiId != nil { + if isPublished { shareWebTitle = NSLocalizedString("Update Web Page", comment: "") } let shareWebImage = UIImage(systemName: "newspaper") actions.append(UIAction(title: shareWebTitle, image: shareWebImage, identifier: UIAction.Identifier("shareWeb"), handler: handler)) - if note.apiId != nil { + if isPublished { let deleteWebTitle = NSLocalizedString("Delete Web Page", comment: "") let deleteWebImage = UIImage(systemName: "newspaper.fill") actions.append(UIAction(title: deleteWebTitle, image: deleteWebImage, identifier: UIAction.Identifier("deleteWeb"), handler: handler)) @@ -1050,6 +1052,21 @@ class NotesTableView: UITableView, } public func shareWebAction(note: Note) { + if UserDefaultsManagement.customWebServer { + SFTPUploader.upload(note: note) { result in + self.reloadRowForce(note: note) + UIApplication.getEVC().configureNavMenu() + + switch result { + case .success(let url): + UIApplication.shared.open(url) + case .failure(let error): + self.showSFTPError(error) + } + } + return + } + UIApplication.getVC().createAPI(note: note, completion: { url in DispatchQueue.main.async { self.reloadRowForce(note: note) @@ -1064,6 +1081,18 @@ class NotesTableView: UITableView, } public func deleteWebAction(note: Note) { + if UserDefaultsManagement.customWebServer { + SFTPUploader.remove(note: note) { error in + self.reloadRowForce(note: note) + UIApplication.getEVC().configureNavMenu() + + if let error = error { + self.showSFTPError(error) + } + } + return + } + UIApplication.getVC().deleteAPI(note: note, completion: { DispatchQueue.main.async { self.reloadRowForce(note: note) @@ -1073,6 +1102,19 @@ class NotesTableView: UITableView, }) } + private func showSFTPError(_ error: Error) { + DispatchQueue.main.async { + guard let vc = UIApplication.getVC().presentedViewController ?? UIApplication.getVC() as UIViewController? else { return } + let alert = UIAlertController( + title: NSLocalizedString("SFTP Error", comment: ""), + message: error.localizedDescription, + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: "OK", style: .default)) + vc.present(alert, animated: true) + } + } + public func moveRowUp(note: Note) { guard let vc = viewDelegate, vc.isNoteInsertionAllowed(), diff --git a/FSNotes.xcodeproj/project.pbxproj b/FSNotes.xcodeproj/project.pbxproj index d27941b71..8d64ffc23 100644 --- a/FSNotes.xcodeproj/project.pbxproj +++ b/FSNotes.xcodeproj/project.pbxproj @@ -220,6 +220,8 @@ 48BEA1E16CCED6900AD756F7 /* Pods_FSNotes_iOS_Share_Extension.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 99068B82274CF88F23C4761D /* Pods_FSNotes_iOS_Share_Extension.framework */; }; 8F7136EE23490CBF004DFA6E /* Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F7136ED23490CBF004DFA6E /* Markdown.swift */; }; 8F7136F023490CBF004DFA6E /* Markdown.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F7136ED23490CBF004DFA6E /* Markdown.swift */; }; + A0781CA02F6737B100918C07 /* SFTPViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0781C9F2F6737B100918C07 /* SFTPViewController.swift */; }; + A0781CA22F6737D100918C07 /* SFTPUploader.swift in Sources */ = {isa = PBXBuildFile; fileRef = A0781CA12F6737D100918C07 /* SFTPUploader.swift */; }; BE957A4A1B908EC91BECB3D3 /* Pods_FSNotes__iCloud_.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6066605B9BAF43A4BF3B60C1 /* Pods_FSNotes__iCloud_.framework */; }; CE3427A778205E1713A014B9 /* Pods_FSNotes_iOS.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 85B72F46887638CA9CC70D39 /* Pods_FSNotes_iOS.framework */; }; D4DB932C9F51CAE71393A28B /* Pods_FSNotes.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = F616385FF783029192B97DF6 /* Pods_FSNotes.framework */; }; @@ -1008,6 +1010,10 @@ 9381D32FA909CAB6102C4A5C /* Pods-FSNotes (Notarized).debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FSNotes (Notarized).debug.xcconfig"; path = "Target Support Files/Pods-FSNotes (Notarized)/Pods-FSNotes (Notarized).debug.xcconfig"; sourceTree = ""; }; 99068B82274CF88F23C4761D /* Pods_FSNotes_iOS_Share_Extension.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FSNotes_iOS_Share_Extension.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9EA62EEDB6BE9BF8727E66E0 /* Pods-FSNotes.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FSNotes.release.xcconfig"; path = "Target Support Files/Pods-FSNotes/Pods-FSNotes.release.xcconfig"; sourceTree = ""; }; + A0781C9F2F6737B100918C07 /* SFTPViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPViewController.swift; sourceTree = ""; }; + A0781CA12F6737D100918C07 /* SFTPUploader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SFTPUploader.swift; sourceTree = ""; }; + A0781CA32F67416700918C07 /* FSNotes iOSDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "FSNotes iOSDebug.entitlements"; sourceTree = ""; }; + A0781CA42F67418100918C07 /* FSNotes iOS Share ExtensionDebug.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = "FSNotes iOS Share ExtensionDebug.entitlements"; sourceTree = ""; }; A0C1E679E6D0B408CAC6372D /* Pods-FSNotesCore macOS.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FSNotesCore macOS.release.xcconfig"; path = "Target Support Files/Pods-FSNotesCore macOS/Pods-FSNotesCore macOS.release.xcconfig"; sourceTree = ""; }; B3A8E91DD978BBA557F778F9 /* Pods_FSNotesCore_macOS.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_FSNotesCore_macOS.framework; sourceTree = BUILT_PRODUCTS_DIR; }; C1E2B9BF60C418804784CC3B /* Pods-FSNotes (Notarized).release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-FSNotes (Notarized).release.xcconfig"; path = "Target Support Files/Pods-FSNotes (Notarized)/Pods-FSNotes (Notarized).release.xcconfig"; sourceTree = ""; }; @@ -1338,6 +1344,7 @@ D70716DB2307E82900B44B0D /* SingleImageTouchDownGestureRecognizer.swift */, D73673A720D10CF2000BA61D /* CloudDriveManager.swift */, D7958A3822ED512D00EDBDDC /* SandboxBookmark.swift */, + A0781CA12F6737D100918C07 /* SFTPUploader.swift */, ); path = Helpers; sourceTree = ""; @@ -1723,6 +1730,7 @@ D7679387201F21F5000F7BBF /* FSNotes iOS */ = { isa = PBXGroup; children = ( + A0781CA32F67416700918C07 /* FSNotes iOSDebug.entitlements */, 11C23F022EDC486E0064C5B5 /* Icons */, D7F6CFEE2056ABDB008C584A /* Preferences */, D7ED3FFC20CD0ADE001438EE /* Extensions */, @@ -1754,6 +1762,7 @@ D7793C661F211C6000CA39B7 = { isa = PBXGroup; children = ( + A0781CA42F67418100918C07 /* FSNotes iOS Share ExtensionDebug.entitlements */, D7D61FCC1F32EEA1004357C2 /* Resources */, 6F13BB2320FEDE230005E120 /* FSNotesCore */, D7793C711F211C6000CA39B7 /* FSNotes */, @@ -1995,6 +2004,7 @@ D709C9E129AFD9E0006EF9A8 /* GitTableViewCell.swift */, D7C33F6D29E09A690006C473 /* AppIconViewController.swift */, D7CF7EAA29E2093C00FEC0C5 /* SecurityViewController.swift */, + A0781C9F2F6737B100918C07 /* SFTPViewController.swift */, ); path = Preferences; sourceTree = ""; @@ -2793,12 +2803,14 @@ D7679389201F21F5000F7BBF /* AppDelegate.swift in Sources */, D730BD46223510A900E69C93 /* KeychainConfiguration.swift in Sources */, 11D6C0E02EE22B77006017F0 /* Csharp.swift in Sources */, + A0781CA02F6737B100918C07 /* SFTPViewController.swift in Sources */, D7BDFE70201F788D00897A58 /* NoteCellView.swift in Sources */, D73794C123366F5200E75A28 /* ImageScrollView.swift in Sources */, D73B3135298FBF4400F46144 /* GitViewController.swift in Sources */, D730BD5C223BFEB200E69C93 /* RuntimeError.swift in Sources */, D7B2B6EA245EEA620084B78D /* LanguageType.swift in Sources */, D70E9E142901AEEB00A3C634 /* StatusIterator.swift in Sources */, + A0781CA22F6737D100918C07 /* SFTPUploader.swift in Sources */, 111013152EC8F1B600B6CF1B /* ImagePreviewViewController.swift in Sources */, 11D702AC2E5B8E0C004DBAEC /* HtmlExtractor.swift in Sources */, 11D6C0D92EE229E9006017F0 /* Go.swift in Sources */, @@ -3314,12 +3326,12 @@ isa = XCBuildConfiguration; baseConfigurationReference = 484D580095FCD450AE46554D /* Pods-FSNotes iOS Share Extension.debug.xcconfig */; buildSettings = { - CODE_SIGN_ENTITLEMENTS = "FSNotes iOS Share/FSNotes iOS Share.entitlements"; + CODE_SIGN_ENTITLEMENTS = "FSNotes iOS Share ExtensionDebug.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 332; - DEVELOPMENT_TEAM = 866P6MTE92; + DEVELOPMENT_TEAM = 49S2J747RZ; INFOPLIST_FILE = "FSNotes iOS Share/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 18.0; LD_RUNPATH_SEARCH_PATHS = ( @@ -3328,7 +3340,7 @@ "@executable_path/../../Frameworks", ); MARKETING_VERSION = 7.1.2; - PRODUCT_BUNDLE_IDENTIFIER = "co.fluder.mobile.FSNotes-iOS.FSNotes-iOS-Share"; + PRODUCT_BUNDLE_IDENTIFIER = "com.iliesa.needs.share"; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = iphoneos; SKIP_INSTALL = YES; @@ -3353,7 +3365,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 332; - DEVELOPMENT_TEAM = 866P6MTE92; + DEVELOPMENT_TEAM = 49S2J747RZ; ENABLE_NS_ASSERTIONS = NO; INFOPLIST_FILE = "FSNotes iOS Share/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 18.0; @@ -3387,13 +3399,13 @@ ASSETCATALOG_COMPILER_ALTERNATE_APPICON_NAMES = ""; ASSETCATALOG_COMPILER_APPICON_NAME = modern; ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; - CODE_SIGN_ENTITLEMENTS = "FSNotes iOS/FSNotes iOS.entitlements"; + CODE_SIGN_ENTITLEMENTS = "FSNotes iOS/FSNotes iOSDebug.entitlements"; CODE_SIGN_IDENTITY = "iPhone Developer"; "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 332; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; - DEVELOPMENT_TEAM = 866P6MTE92; + DEVELOPMENT_TEAM = 49S2J747RZ; INFOPLIST_FILE = "FSNotes iOS/Info.plist"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; IPHONEOS_DEPLOYMENT_TARGET = 18.0; @@ -3402,7 +3414,7 @@ "@executable_path/Frameworks", ); MARKETING_VERSION = 7.1.2; - PRODUCT_BUNDLE_IDENTIFIER = "co.fluder.mobile.FSNotes-iOS"; + PRODUCT_BUNDLE_IDENTIFIER = com.iliesa.needs; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE = ""; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -3433,7 +3445,7 @@ "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 332; - DEVELOPMENT_TEAM = 866P6MTE92; + DEVELOPMENT_TEAM = 49S2J747RZ; ENABLE_NS_ASSERTIONS = NO; INFOPLIST_FILE = "FSNotes iOS/Info.plist"; INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.productivity"; @@ -3669,7 +3681,7 @@ CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 706; - DEVELOPMENT_TEAM = 866P6MTE92; + DEVELOPMENT_TEAM = 49S2J747RZ; EMBED_ASSET_PACKS_IN_PRODUCT_BUNDLE = YES; ENABLE_HARDENED_RUNTIME = YES; ENABLE_NS_ASSERTIONS = NO; diff --git a/FSNotesCore/UserDefaultsManagement.swift b/FSNotesCore/UserDefaultsManagement.swift index c50c88e11..168e4e09a 100644 --- a/FSNotesCore/UserDefaultsManagement.swift +++ b/FSNotesCore/UserDefaultsManagement.swift @@ -136,6 +136,7 @@ public class UserDefaultsManagement { static let SftpUsername = "sftpUsername" static let SftpPassword = "sftpPassword" static let SftpKeysAccessData = "sftpKeysAccessData" + static let SftpPublicKeyData = "sftpPublicKeyData" static let SftpUploadBookmarksData = "sftpUploadBookmarksData" static let SharedContainerKey = "sharedContainer" static let ShowDockIcon = "showDockIcon" @@ -1556,6 +1557,15 @@ public class UserDefaultsManagement { } } + static var sftpPublicKeyData: Data? { + get { + return shared?.data(forKey: Constants.SftpPublicKeyData) + } + set { + shared?.set(newValue, forKey: Constants.SftpPublicKeyData) + } + } + static var sftpUploadBookmarksData: Data? { get { return shared?.data(forKey: Constants.SftpUploadBookmarksData) From 26f368b7b0f37f9d8dc60ef4d13111932d824059 Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Sun, 15 Mar 2026 23:03:37 +0200 Subject: [PATCH 2/3] feat(sftp-ios): show loading indicator on sftp actions --- FSNotes iOS/View/NotesTableView.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/FSNotes iOS/View/NotesTableView.swift b/FSNotes iOS/View/NotesTableView.swift index e45113e86..3095c5aff 100644 --- a/FSNotes iOS/View/NotesTableView.swift +++ b/FSNotes iOS/View/NotesTableView.swift @@ -1053,7 +1053,9 @@ class NotesTableView: UITableView, public func shareWebAction(note: Note) { if UserDefaultsManagement.customWebServer { + showLoader() SFTPUploader.upload(note: note) { result in + self.hideLoader() self.reloadRowForce(note: note) UIApplication.getEVC().configureNavMenu() @@ -1082,7 +1084,9 @@ class NotesTableView: UITableView, public func deleteWebAction(note: Note) { if UserDefaultsManagement.customWebServer { + showLoader() SFTPUploader.remove(note: note) { error in + self.hideLoader() self.reloadRowForce(note: note) UIApplication.getEVC().configureNavMenu() From 016c0d1453fabe7ae25725a1c1311436fec1a71a Mon Sep 17 00:00:00 2001 From: Alex Ilies Date: Mon, 16 Mar 2026 12:08:29 +0200 Subject: [PATCH 3/3] feat(ios-sftp): add locazation strings for fr | nl | pt | ru | uk --- FSNotes iOS/fr.lproj/Localizable.strings | 44 +++++++++++++++++++++ FSNotes iOS/nl-NL.lproj/Localizable.strings | 44 +++++++++++++++++++++ FSNotes iOS/pt-PT.lproj/Localizable.strings | 44 +++++++++++++++++++++ FSNotes iOS/ru.lproj/Localizable.strings | 43 ++++++++++++++++++++ FSNotes iOS/uk.lproj/Localizable.strings | 44 +++++++++++++++++++++ 5 files changed, 219 insertions(+) create mode 100644 FSNotes iOS/fr.lproj/Localizable.strings create mode 100644 FSNotes iOS/nl-NL.lproj/Localizable.strings create mode 100644 FSNotes iOS/pt-PT.lproj/Localizable.strings create mode 100644 FSNotes iOS/uk.lproj/Localizable.strings diff --git a/FSNotes iOS/fr.lproj/Localizable.strings b/FSNotes iOS/fr.lproj/Localizable.strings new file mode 100644 index 000000000..c4fb89ca8 --- /dev/null +++ b/FSNotes iOS/fr.lproj/Localizable.strings @@ -0,0 +1,44 @@ +/* SFTP Web Publishing */ +"Web Publishing (SFTP)" = "Publication web (SFTP)"; + +/* SFTP settings section headers */ +"Server" = "Serveur"; +"Authentication" = "Authentification"; +"Actions" = "Actions"; + +/* SFTP settings fields */ +"Host" = "Hôte"; +"Port" = "Port"; +"Remote Path" = "Chemin distant"; +"Web URL" = "URL web"; +"Username" = "Nom d'utilisateur"; +"(or use private key)" = "(ou utiliser une clé privée)"; +"Public Key" = "Clé publique"; +"loaded" = "chargée"; +"not set" = "non définie"; +"(optional)" = "(facultatif)"; + +/* SFTP settings actions */ +"Use Custom SFTP Server" = "Utiliser un serveur SFTP"; +"Test Connection" = "Tester la connexion"; + +/* SFTP connection test alerts */ +"Missing Host" = "Hôte manquant"; +"Please enter a host address." = "Veuillez saisir une adresse d'hôte."; +"Testing…" = "Test en cours…"; +"Could not authenticate. Check credentials." = "Authentification impossible. Vérifiez vos identifiants."; +"Successfully connected to the server." = "Connexion au serveur réussie."; +"Connection Successful" = "Connexion réussie"; +"Connection Failed" = "Échec de la connexion"; + +/* SFTP context menu */ +"Create Web Page" = "Créer une page web"; +"Update Web Page" = "Mettre à jour la page web"; +"Delete Web Page" = "Supprimer la page web"; +"SFTP Error" = "Erreur SFTP"; + +/* SFTP error messages */ +"Please set a password or import a private key in SFTP settings." = "Définissez un mot de passe ou importez une clé privée dans les paramètres SFTP."; +"Remote path is not configured in SFTP settings." = "Le chemin distant n'est pas configuré dans les paramètres SFTP."; +"Web URL is not configured in SFTP settings." = "L'URL web n'est pas configurée dans les paramètres SFTP."; +"Failed to render the note as HTML." = "Impossible de convertir la note en HTML."; diff --git a/FSNotes iOS/nl-NL.lproj/Localizable.strings b/FSNotes iOS/nl-NL.lproj/Localizable.strings new file mode 100644 index 000000000..03d71f107 --- /dev/null +++ b/FSNotes iOS/nl-NL.lproj/Localizable.strings @@ -0,0 +1,44 @@ +/* SFTP Web Publishing */ +"Web Publishing (SFTP)" = "Webpublicatie (SFTP)"; + +/* SFTP settings section headers */ +"Server" = "Server"; +"Authentication" = "Authenticatie"; +"Actions" = "Acties"; + +/* SFTP settings fields */ +"Host" = "Host"; +"Port" = "Poort"; +"Remote Path" = "Extern pad"; +"Web URL" = "Web-URL"; +"Username" = "Gebruikersnaam"; +"(or use private key)" = "(of gebruik een privésleutel)"; +"Public Key" = "Publieke sleutel"; +"loaded" = "geladen"; +"not set" = "niet ingesteld"; +"(optional)" = "(optioneel)"; + +/* SFTP settings actions */ +"Use Custom SFTP Server" = "Gebruik aangepaste SFTP-server"; +"Test Connection" = "Verbinding testen"; + +/* SFTP connection test alerts */ +"Missing Host" = "Host ontbreekt"; +"Please enter a host address." = "Voer een hostadres in."; +"Testing…" = "Testen…"; +"Could not authenticate. Check credentials." = "Verificatie mislukt. Controleer uw gegevens."; +"Successfully connected to the server." = "Verbinding met de server geslaagd."; +"Connection Successful" = "Verbinding geslaagd"; +"Connection Failed" = "Verbinding mislukt"; + +/* SFTP context menu */ +"Create Web Page" = "Webpagina aanmaken"; +"Update Web Page" = "Webpagina bijwerken"; +"Delete Web Page" = "Webpagina verwijderen"; +"SFTP Error" = "SFTP-fout"; + +/* SFTP error messages */ +"Please set a password or import a private key in SFTP settings." = "Stel een wachtwoord in of importeer een privésleutel in de SFTP-instellingen."; +"Remote path is not configured in SFTP settings." = "Het externe pad is niet geconfigureerd in de SFTP-instellingen."; +"Web URL is not configured in SFTP settings." = "De web-URL is niet geconfigureerd in de SFTP-instellingen."; +"Failed to render the note as HTML." = "De notitie kon niet als HTML worden weergegeven."; diff --git a/FSNotes iOS/pt-PT.lproj/Localizable.strings b/FSNotes iOS/pt-PT.lproj/Localizable.strings new file mode 100644 index 000000000..2c1569692 --- /dev/null +++ b/FSNotes iOS/pt-PT.lproj/Localizable.strings @@ -0,0 +1,44 @@ +/* SFTP Web Publishing */ +"Web Publishing (SFTP)" = "Publicação web (SFTP)"; + +/* SFTP settings section headers */ +"Server" = "Servidor"; +"Authentication" = "Autenticação"; +"Actions" = "Ações"; + +/* SFTP settings fields */ +"Host" = "Anfitrião"; +"Port" = "Porta"; +"Remote Path" = "Caminho remoto"; +"Web URL" = "URL web"; +"Username" = "Nome de utilizador"; +"(or use private key)" = "(ou usar chave privada)"; +"Public Key" = "Chave pública"; +"loaded" = "carregada"; +"not set" = "não definida"; +"(optional)" = "(opcional)"; + +/* SFTP settings actions */ +"Use Custom SFTP Server" = "Usar servidor SFTP personalizado"; +"Test Connection" = "Testar ligação"; + +/* SFTP connection test alerts */ +"Missing Host" = "Anfitrião em falta"; +"Please enter a host address." = "Introduza um endereço de anfitrião."; +"Testing…" = "A testar…"; +"Could not authenticate. Check credentials." = "Não foi possível autenticar. Verifique as credenciais."; +"Successfully connected to the server." = "Ligação ao servidor bem-sucedida."; +"Connection Successful" = "Ligação bem-sucedida"; +"Connection Failed" = "Falha na ligação"; + +/* SFTP context menu */ +"Create Web Page" = "Criar página web"; +"Update Web Page" = "Atualizar página web"; +"Delete Web Page" = "Eliminar página web"; +"SFTP Error" = "Erro SFTP"; + +/* SFTP error messages */ +"Please set a password or import a private key in SFTP settings." = "Defina uma palavra-passe ou importe uma chave privada nas definições SFTP."; +"Remote path is not configured in SFTP settings." = "O caminho remoto não está configurado nas definições SFTP."; +"Web URL is not configured in SFTP settings." = "O URL web não está configurado nas definições SFTP."; +"Failed to render the note as HTML." = "Não foi possível converter a nota para HTML."; diff --git a/FSNotes iOS/ru.lproj/Localizable.strings b/FSNotes iOS/ru.lproj/Localizable.strings index 3be30680f..b156ae303 100644 --- a/FSNotes iOS/ru.lproj/Localizable.strings +++ b/FSNotes iOS/ru.lproj/Localizable.strings @@ -426,3 +426,46 @@ /* No comment provided by engineer. */ "✅ - " = "✅ - "; + +/* SFTP Web Publishing */ +"Web Publishing (SFTP)" = "Веб-публикация (SFTP)"; + +/* SFTP settings section headers */ +"Server" = "Сервер"; +"Authentication" = "Аутентификация"; +"Actions" = "Действия"; + +/* SFTP settings fields */ +"Host" = "Хост"; +"Port" = "Порт"; +"Remote Path" = "Удалённый путь"; +"Web URL" = "Веб-адрес"; +"Username" = "Имя пользователя"; +"(or use private key)" = "(или использовать ключ)"; +"Public Key" = "Публичный ключ"; +"loaded" = "загружен"; +"not set" = "не задан"; +"(optional)" = "(необязательно)"; + +/* SFTP settings actions */ +"Use Custom SFTP Server" = "Использовать SFTP-сервер"; +"Test Connection" = "Проверить соединение"; + +/* SFTP connection test alerts */ +"Missing Host" = "Хост не указан"; +"Please enter a host address." = "Введите адрес хоста."; +"Testing…" = "Проверка…"; +"Could not authenticate. Check credentials." = "Не удалось аутентифицироваться. Проверьте данные."; +"Successfully connected to the server." = "Успешное подключение к серверу."; +"Connection Successful" = "Соединение установлено"; +"Connection Failed" = "Ошибка соединения"; + +/* SFTP context menu */ +"Update Web Page" = "Обновить веб-страницу"; +"SFTP Error" = "Ошибка SFTP"; + +/* SFTP error messages */ +"Please set a password or import a private key in SFTP settings." = "Укажите пароль или импортируйте приватный ключ в настройках SFTP."; +"Remote path is not configured in SFTP settings." = "Удалённый путь не настроен в настройках SFTP."; +"Web URL is not configured in SFTP settings." = "Веб-адрес не настроен в настройках SFTP."; +"Failed to render the note as HTML." = "Не удалось преобразовать заметку в HTML."; diff --git a/FSNotes iOS/uk.lproj/Localizable.strings b/FSNotes iOS/uk.lproj/Localizable.strings new file mode 100644 index 000000000..d80da32eb --- /dev/null +++ b/FSNotes iOS/uk.lproj/Localizable.strings @@ -0,0 +1,44 @@ +/* SFTP Web Publishing */ +"Web Publishing (SFTP)" = "Веб-публікація (SFTP)"; + +/* SFTP settings section headers */ +"Server" = "Сервер"; +"Authentication" = "Автентифікація"; +"Actions" = "Дії"; + +/* SFTP settings fields */ +"Host" = "Хост"; +"Port" = "Порт"; +"Remote Path" = "Віддалений шлях"; +"Web URL" = "Веб-адреса"; +"Username" = "Ім'я користувача"; +"(or use private key)" = "(або використати ключ)"; +"Public Key" = "Публічний ключ"; +"loaded" = "завантажено"; +"not set" = "не задано"; +"(optional)" = "(необов'язково)"; + +/* SFTP settings actions */ +"Use Custom SFTP Server" = "Використовувати SFTP-сервер"; +"Test Connection" = "Перевірити з'єднання"; + +/* SFTP connection test alerts */ +"Missing Host" = "Хост не вказано"; +"Please enter a host address." = "Введіть адресу хоста."; +"Testing…" = "Перевірка…"; +"Could not authenticate. Check credentials." = "Не вдалося автентифікуватись. Перевірте дані."; +"Successfully connected to the server." = "Успішне підключення до сервера."; +"Connection Successful" = "З'єднання встановлено"; +"Connection Failed" = "Помилка з'єднання"; + +/* SFTP context menu */ +"Create Web Page" = "Створити веб-сторінку"; +"Update Web Page" = "Оновити веб-сторінку"; +"Delete Web Page" = "Видалити веб-сторінку"; +"SFTP Error" = "Помилка SFTP"; + +/* SFTP error messages */ +"Please set a password or import a private key in SFTP settings." = "Вкажіть пароль або імпортуйте приватний ключ у налаштуваннях SFTP."; +"Remote path is not configured in SFTP settings." = "Віддалений шлях не налаштовано в налаштуваннях SFTP."; +"Web URL is not configured in SFTP settings." = "Веб-адреса не налаштована в налаштуваннях SFTP."; +"Failed to render the note as HTML." = "Не вдалося перетворити нотатку на HTML.";