accounts and email
This commit is contained in:
@@ -1,11 +1,17 @@
|
||||
import Foundation
|
||||
import Security
|
||||
|
||||
public enum NewNoteAction: String, Codable, CaseIterable {
|
||||
case clipboard
|
||||
case editor
|
||||
}
|
||||
|
||||
public struct SyncConfig: Codable, Equatable {
|
||||
public var serverURL: String
|
||||
public var apiToken: String?
|
||||
public var syncFolder: String?
|
||||
public var defaultUploadFolder: String?
|
||||
public var newNoteAction: NewNoteAction?
|
||||
public var autoSync: Bool
|
||||
public var syncInterval: Int // seconds
|
||||
public var deviceId: String
|
||||
@@ -30,6 +36,7 @@ public struct SyncConfig: Codable, Equatable {
|
||||
apiToken: String? = nil,
|
||||
syncFolder: String? = nil,
|
||||
defaultUploadFolder: String? = nil,
|
||||
newNoteAction: NewNoteAction? = nil,
|
||||
autoSync: Bool = true,
|
||||
syncInterval: Int = 30,
|
||||
deviceId: String? = nil,
|
||||
@@ -39,6 +46,7 @@ public struct SyncConfig: Codable, Equatable {
|
||||
self.apiToken = apiToken
|
||||
self.syncFolder = syncFolder
|
||||
self.defaultUploadFolder = defaultUploadFolder
|
||||
self.newNoteAction = newNoteAction
|
||||
self.autoSync = autoSync
|
||||
self.syncInterval = syncInterval
|
||||
self.deviceId = deviceId ?? SyncConfig.generateDeviceId()
|
||||
@@ -57,6 +65,10 @@ public struct SyncConfig: Codable, Equatable {
|
||||
public var serverBaseURL: URL? {
|
||||
URL(string: serverURL)
|
||||
}
|
||||
|
||||
public var effectiveNewNoteAction: NewNoteAction {
|
||||
newNoteAction ?? .clipboard
|
||||
}
|
||||
}
|
||||
|
||||
public actor ConfigStore {
|
||||
|
||||
@@ -276,6 +276,25 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadDataResolvingDuplicates(_ data: Data, filename: String) async throws -> UploadResult {
|
||||
var duplicateAction: UploadDuplicateAction?
|
||||
|
||||
while true {
|
||||
do {
|
||||
return try await uploadManager.uploadData(
|
||||
data,
|
||||
filename: filename,
|
||||
duplicateAction: duplicateAction
|
||||
)
|
||||
} catch let error as APIError where error.isUploadConflict {
|
||||
guard let action = promptForUploadDuplicate(error, filename: filename) else {
|
||||
throw UploadCancelledError()
|
||||
}
|
||||
duplicateAction = action
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public func refreshRecentUploads() async {
|
||||
guard !isLoadingRecentUploads else { return }
|
||||
|
||||
@@ -290,6 +309,15 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
public func createNote() {
|
||||
switch config.effectiveNewNoteAction {
|
||||
case .clipboard:
|
||||
createNoteFromClipboard()
|
||||
case .editor:
|
||||
createNoteInEditor()
|
||||
}
|
||||
}
|
||||
|
||||
public func createNoteFromClipboard() {
|
||||
guard let text = NSPasteboard.general.string(forType: .string) else {
|
||||
syncStatus = "Clipboard is empty"
|
||||
@@ -305,6 +333,97 @@ public class MenuBarController: NSObject, ObservableObject, NSWindowDelegate {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func createNoteInEditor() {
|
||||
let draftURL = FileManager.default.temporaryDirectory
|
||||
.appendingPathComponent("cairnquire-\(UUID().uuidString)")
|
||||
.appendingPathExtension("md")
|
||||
let completionURL = draftURL.appendingPathExtension("done")
|
||||
|
||||
do {
|
||||
try Data().write(to: draftURL, options: .atomic)
|
||||
try launchTerminalEditor(draftURL: draftURL, completionURL: completionURL)
|
||||
syncStatus = "Waiting for $EDITOR to exit"
|
||||
} catch {
|
||||
try? FileManager.default.removeItem(at: draftURL)
|
||||
syncStatus = "Failed to open $EDITOR: \(error.localizedDescription)"
|
||||
return
|
||||
}
|
||||
|
||||
Task { [weak self] in
|
||||
await self?.uploadEditorDraft(draftURL: draftURL, completionURL: completionURL)
|
||||
}
|
||||
}
|
||||
|
||||
private func uploadEditorDraft(draftURL: URL, completionURL: URL) async {
|
||||
defer {
|
||||
try? FileManager.default.removeItem(at: draftURL)
|
||||
try? FileManager.default.removeItem(at: completionURL)
|
||||
}
|
||||
|
||||
while !FileManager.default.fileExists(atPath: completionURL.path) {
|
||||
try? await Task.sleep(for: .milliseconds(500))
|
||||
}
|
||||
|
||||
do {
|
||||
let data = try Data(contentsOf: draftURL)
|
||||
guard !data.isEmpty else {
|
||||
syncStatus = "Editor note was empty; nothing uploaded"
|
||||
return
|
||||
}
|
||||
|
||||
let filename = Self.editorNoteFilename()
|
||||
let result = try await uploadDataResolvingDuplicates(data, filename: filename)
|
||||
syncStatus = "Uploaded \(result.filename)"
|
||||
clipboardNotice = ClipboardNotice(filename: result.filename)
|
||||
await refreshRecentUploads()
|
||||
} catch {
|
||||
syncStatus = "Failed to upload editor note: \(error.localizedDescription)"
|
||||
}
|
||||
}
|
||||
|
||||
private func launchTerminalEditor(draftURL: URL, completionURL: URL) throws {
|
||||
let draftPath = Self.shellQuote(draftURL.path)
|
||||
let completionPath = Self.shellQuote(completionURL.path)
|
||||
let shellCommand = "trap '/usr/bin/touch \(completionPath)' EXIT; editor=\"${EDITOR:-${VISUAL:-vi}}\"; eval \"$editor \(draftPath)\""
|
||||
let script = "tell application \"Terminal\" to do script \"\(Self.appleScriptEscape(shellCommand))\""
|
||||
|
||||
let process = Process()
|
||||
let errorPipe = Pipe()
|
||||
process.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||
process.arguments = ["-e", "tell application \"Terminal\" to activate", "-e", script]
|
||||
process.standardError = errorPipe
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
|
||||
guard process.terminationStatus == 0 else {
|
||||
let data = errorPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let message = String(data: data, encoding: .utf8)?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
throw NSError(
|
||||
domain: "CairnquireSync",
|
||||
code: Int(process.terminationStatus),
|
||||
userInfo: [NSLocalizedDescriptionKey: message ?? "Terminal could not be opened"]
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func shellQuote(_ value: String) -> String {
|
||||
"'" + value.replacingOccurrences(of: "'", with: "'\\''") + "'"
|
||||
}
|
||||
|
||||
private static func appleScriptEscape(_ value: String) -> String {
|
||||
value
|
||||
.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
}
|
||||
|
||||
private static func editorNoteFilename() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.locale = Locale(identifier: "en_US_POSIX")
|
||||
formatter.dateFormat = "yyyy-MM-dd HH-mm-ss"
|
||||
return "Note \(formatter.string(from: Date())).md"
|
||||
}
|
||||
}
|
||||
|
||||
extension MenuBarController: FolderWatcherDelegate {
|
||||
|
||||
@@ -8,6 +8,7 @@ public struct SettingsView: View {
|
||||
@State private var apiToken: String = ""
|
||||
@State private var syncFolder: String = ""
|
||||
@State private var defaultUploadFolder: String = ""
|
||||
@State private var newNoteAction: NewNoteAction = .clipboard
|
||||
@State private var autoSync: Bool = true
|
||||
@State private var syncInterval: Double = 30
|
||||
@State private var selectedIcon: String = "tray.circle.fill"
|
||||
@@ -20,28 +21,34 @@ public struct SettingsView: View {
|
||||
}
|
||||
|
||||
public var body: some View {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
serverSection
|
||||
Divider().padding(.vertical, 16)
|
||||
syncSection
|
||||
Divider().padding(.vertical, 16)
|
||||
uploadsSection
|
||||
Divider().padding(.vertical, 16)
|
||||
appearanceSection
|
||||
|
||||
Spacer(minLength: 32)
|
||||
|
||||
saveButton
|
||||
VStack(spacing: 0) {
|
||||
ScrollView {
|
||||
VStack(spacing: 0) {
|
||||
serverSection
|
||||
Divider().padding(.vertical, 12)
|
||||
syncSection
|
||||
Divider().padding(.vertical, 12)
|
||||
uploadsSection
|
||||
Divider().padding(.vertical, 12)
|
||||
appearanceSection
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
.padding(24)
|
||||
.scrollIndicators(.hidden)
|
||||
|
||||
Divider()
|
||||
|
||||
saveButton
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.vertical, 12)
|
||||
}
|
||||
.frame(width: 520, height: 600)
|
||||
.frame(width: 520, height: 640)
|
||||
.onAppear {
|
||||
serverURL = config.serverURL
|
||||
apiToken = config.apiToken ?? ""
|
||||
syncFolder = config.syncFolder ?? ""
|
||||
defaultUploadFolder = config.defaultUploadFolder ?? ""
|
||||
newNoteAction = config.effectiveNewNoteAction
|
||||
autoSync = config.autoSync
|
||||
syncInterval = Double(config.syncInterval)
|
||||
selectedIcon = config.iconName
|
||||
@@ -69,7 +76,7 @@ public struct SettingsView: View {
|
||||
HStack {
|
||||
Spacer()
|
||||
.frame(width: labelWidth)
|
||||
Text("Create an API token from the web app at /account")
|
||||
Text("Create an API token from the web app at /account/tokens/new")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.lineLimit(nil)
|
||||
@@ -124,11 +131,20 @@ public struct SettingsView: View {
|
||||
.textFieldStyle(.roundedBorder)
|
||||
.frame(width: 280)
|
||||
}
|
||||
|
||||
formRow(label: "New Note") {
|
||||
Picker("", selection: $newNoteAction) {
|
||||
Text("From Clipboard").tag(NewNoteAction.clipboard)
|
||||
Text("Open Terminal $EDITOR").tag(NewNoteAction.editor)
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
.frame(width: 200)
|
||||
}
|
||||
|
||||
HStack {
|
||||
Spacer()
|
||||
.frame(width: labelWidth)
|
||||
Text("Notes and uploads will be saved to this folder")
|
||||
Text("Notes and uploads will be saved to this folder. Editor notes upload after the editor exits.")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
.frame(width: 280, alignment: .leading)
|
||||
@@ -166,6 +182,7 @@ public struct SettingsView: View {
|
||||
apiToken: apiToken.isEmpty ? nil : apiToken,
|
||||
syncFolder: syncFolder.isEmpty ? nil : syncFolder,
|
||||
defaultUploadFolder: defaultUploadFolder.isEmpty ? nil : defaultUploadFolder,
|
||||
newNoteAction: newNoteAction,
|
||||
autoSync: autoSync,
|
||||
syncInterval: Int(syncInterval),
|
||||
deviceId: config.deviceId,
|
||||
|
||||
@@ -116,7 +116,7 @@ public struct StatusPopoverView: View {
|
||||
label: "Note",
|
||||
color: .green
|
||||
) {
|
||||
controller.createNoteFromClipboard()
|
||||
controller.createNote()
|
||||
}
|
||||
|
||||
ActionButton(
|
||||
|
||||
@@ -189,7 +189,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
contextMenu.addItem(NSMenuItem(title: "Sync Now", action: #selector(syncNow), keyEquivalent: "s"))
|
||||
contextMenu.addItem(NSMenuItem(title: "Upload File...", action: #selector(uploadFile), keyEquivalent: "u"))
|
||||
contextMenu.addItem(NSMenuItem(title: "Create Note from Clipboard", action: #selector(createNote), keyEquivalent: "n"))
|
||||
contextMenu.addItem(NSMenuItem(title: "New Note", action: #selector(createNote), keyEquivalent: "n"))
|
||||
contextMenu.addItem(NSMenuItem(title: "Drop Zone", action: #selector(showDropZone), keyEquivalent: "d"))
|
||||
contextMenu.addItem(NSMenuItem(title: "View Uploads", action: #selector(showUploads), keyEquivalent: "v"))
|
||||
|
||||
@@ -253,7 +253,7 @@ class AppDelegate: NSObject, NSApplicationDelegate {
|
||||
|
||||
@objc private func createNote() {
|
||||
Task { @MainActor in
|
||||
controller.createNoteFromClipboard()
|
||||
controller.createNote()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -224,6 +224,12 @@ struct CLI {
|
||||
config.syncFolder = value
|
||||
case "--upload-folder":
|
||||
config.defaultUploadFolder = value
|
||||
case "--new-note":
|
||||
guard let action = NewNoteAction(rawValue: value) else {
|
||||
print("Invalid new note action: \(value). Use clipboard or editor.")
|
||||
throw CLIError.invalidArguments
|
||||
}
|
||||
config.newNoteAction = action
|
||||
default:
|
||||
print("Unknown config option: \(arg)")
|
||||
throw CLIError.invalidArguments
|
||||
@@ -237,6 +243,7 @@ struct CLI {
|
||||
print(" Server: \(config.serverURL)")
|
||||
print(" Sync Folder: \(config.syncFolder ?? "(none)")")
|
||||
print(" Upload Folder: \(config.defaultUploadFolder ?? "(none)")")
|
||||
print(" New Note: \(config.effectiveNewNoteAction.rawValue)")
|
||||
print(" Token: \(config.apiToken != nil ? "(set)" : "(not set)")")
|
||||
}
|
||||
|
||||
@@ -382,6 +389,7 @@ struct CLI {
|
||||
--token <token> Set the API token
|
||||
--folder <path> Set the local folder used by sync
|
||||
--upload-folder <path> Set the server folder for readable uploads
|
||||
--new-note <action> Set New Note behavior: clipboard or editor
|
||||
|
||||
The menubar app and CLI use the same saved configuration.
|
||||
""")
|
||||
|
||||
Reference in New Issue
Block a user