accounts and email

This commit is contained in:
2026-06-04 08:50:34 -04:00
parent 73d505ac7e
commit ddc7d5cbf5
46 changed files with 2685 additions and 309 deletions

View File

@@ -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 {

View File

@@ -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 {

View File

@@ -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,

View File

@@ -116,7 +116,7 @@ public struct StatusPopoverView: View {
label: "Note",
color: .green
) {
controller.createNoteFromClipboard()
controller.createNote()
}
ActionButton(

View File

@@ -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()
}
}

View File

@@ -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.
""")