From 5ce73ca2aadf9d7b72b2d7f87bfb3e21d8314157 Mon Sep 17 00:00:00 2001 From: Levi Date: Tue, 24 Mar 2026 14:51:50 +0900 Subject: [PATCH] Add Daily Note feature with Obsidian template support MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Today tab in sidebar footer opens today's daily note - Daily note filename format configurable in Settings (Obsidian-style tokens: YYYY, MM, DD, ddd, dddd) - Sidebar shows full note at top + headings (H1–H6) as navigation items - Clicking a heading shows only that section's content - If today's note doesn't exist, shows a "Create Daily Note" prompt - Template file path configurable in Settings; supports Obsidian tokens: {{title}}, {{date}}, {{date:FORMAT}}, {{time}} - Edit/preview toggle (⌘E) and double-click to edit - Live file watcher reloads content when edited externally (e.g. in Obsidian) - Keyboard shortcut ⌥⌘D to open Today tab Co-Authored-By: Claude Sonnet 4.6 --- Flowbar/Flowbar.xcodeproj/project.pbxproj | 10 +- Flowbar/Sources/App/AppState.swift | 94 ++++++++++ Flowbar/Sources/App/SettingsState.swift | 22 +++ Flowbar/Sources/Services/MarkdownParser.swift | 39 +++++ .../Views/Components/SidebarFooter.swift | 4 + .../Sources/Views/DailyNoteContentView.swift | 147 ++++++++++++++++ .../Views/LiveMarkdownEditorView.swift | 147 ++++++++++++++++ Flowbar/Sources/Views/MainView.swift | 6 + Flowbar/Sources/Views/SettingsView.swift | 49 ++++++ Flowbar/Sources/Views/SidebarView.swift | 164 +++++++++++++----- 10 files changed, 637 insertions(+), 45 deletions(-) create mode 100644 Flowbar/Sources/Views/DailyNoteContentView.swift create mode 100644 Flowbar/Sources/Views/LiveMarkdownEditorView.swift diff --git a/Flowbar/Flowbar.xcodeproj/project.pbxproj b/Flowbar/Flowbar.xcodeproj/project.pbxproj index 1fcd988..653dc33 100644 --- a/Flowbar/Flowbar.xcodeproj/project.pbxproj +++ b/Flowbar/Flowbar.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 3CF166A148A08429BEDD69D0 /* ShortcutRecorderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E182CA139B433EF47FE91ECA /* ShortcutRecorderView.swift */; }; 42A5459E77ED6745E1DBCD43 /* NoteFile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 635F72DDE09908762049B42B /* NoteFile.swift */; }; 42DD96B155C593078565CF7D /* FileOperationsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 17BB2C6C1AB7D078E4FD1381 /* FileOperationsTests.swift */; }; + 431C24232ACE659EE5F206F8 /* DailyNoteContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB635E96DA18E8639C69A666 /* DailyNoteContentView.swift */; }; 4F139AF375C4FA41F292C0E7 /* MarkdownParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = ED1B419B41DBE32FBEC53E7E /* MarkdownParser.swift */; }; 519DC7D5F956BF9728A11B79 /* SidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9429D1FCA615F8FF6BA06083 /* SidebarView.swift */; }; 521CE08AE3FC9244CE92C597 /* TimerTodosView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAF1CAFB6CFD0E5A2A75702D /* TimerTodosView.swift */; }; @@ -50,6 +51,7 @@ D086C43C3929EF6052A89C94 /* SidebarState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 270E141D1C4D7BA89319620F /* SidebarState.swift */; }; D8D82F66CEBB7C7A976E244E /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DAC8FE70A4CC25EFC1101BE /* AppDelegate.swift */; }; E037448B11CE5D13C85FF32F /* FileWatcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7F0362096FD8D2B22AA6247 /* FileWatcher.swift */; }; + F2A48F8469F7191EF0536B94 /* LiveMarkdownEditorView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 852C10DC9CB48EDE94DC2521 /* LiveMarkdownEditorView.swift */; }; F6A2FCC540B165487531BA6C /* MainView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA28AD0B52FFBD4002D7108F /* MainView.swift */; }; F87CF5317C3FC2FF9D17983D /* EditorState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D9EFAC3B9D3F56345305475 /* EditorState.swift */; }; /* End PBXBuildFile section */ @@ -101,6 +103,7 @@ 7A266BA9229D8B4FF01FD473 /* FlowbarUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = FlowbarUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 7D9EFAC3B9D3F56345305475 /* EditorState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EditorState.swift; sourceTree = ""; }; 8510A28B4BDA11F845C2A2F0 /* SidebarFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFooter.swift; sourceTree = ""; }; + 852C10DC9CB48EDE94DC2521 /* LiveMarkdownEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveMarkdownEditorView.swift; sourceTree = ""; }; 8FFA9B476EBA20F346ECA6F7 /* MarkdownBlockParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownBlockParserTests.swift; sourceTree = ""; }; 90E92AC056D0D1D801EA2F5A /* AppStateNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateNavigationTests.swift; sourceTree = ""; }; 926C247304FAFA07BD755FC0 /* SearchOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOverlayView.swift; sourceTree = ""; }; @@ -108,6 +111,7 @@ 968FA0A43E76B9C67650651E /* TimerServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerServiceTests.swift; sourceTree = ""; }; A7C86951E8C0ECA5E746AD0B /* SearchStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchStateTests.swift; sourceTree = ""; }; AA28AD0B52FFBD4002D7108F /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = ""; }; + AB635E96DA18E8639C69A666 /* DailyNoteContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyNoteContentView.swift; sourceTree = ""; }; AEF20A10743ADB27EC3D5761 /* AppStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTests.swift; sourceTree = ""; }; B0BF69A8C9E2C43EC6598948 /* TitleBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleBarView.swift; sourceTree = ""; }; B7F0362096FD8D2B22AA6247 /* FileWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileWatcher.swift; sourceTree = ""; }; @@ -174,6 +178,8 @@ 8EA68FA9A29B3138B118A6AC /* Views */ = { isa = PBXGroup; children = ( + AB635E96DA18E8639C69A666 /* DailyNoteContentView.swift */, + 852C10DC9CB48EDE94DC2521 /* LiveMarkdownEditorView.swift */, AA28AD0B52FFBD4002D7108F /* MainView.swift */, 0E24C83A4A1DC47A34546EA3 /* MarkdownEditorView.swift */, 54CD749B6DD3D3CE8D8305A1 /* MarkdownPreviewView.swift */, @@ -371,7 +377,6 @@ }; }; buildConfigurationList = C1DA36052B4A1E46CA65FA2F /* Build configuration list for PBXProject "Flowbar" */; - compatibilityVersion = "Xcode 14.0"; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( @@ -381,6 +386,7 @@ mainGroup = 786E57523B2E348AB25D334B; minimizedProjectReferenceProxies = 1; preferredProjectObjectVersion = 77; + productRefGroup = C44FBD2640CD80E693BD7B0B /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( @@ -417,12 +423,14 @@ D8D82F66CEBB7C7A976E244E /* AppDelegate.swift in Sources */, A0595C51DB1C1D18D3C64375 /* AppState.swift in Sources */, 5CB99267DAC1962A81157A85 /* Colors.swift in Sources */, + 431C24232ACE659EE5F206F8 /* DailyNoteContentView.swift in Sources */, 8995DCB6F7D39DB04F49F5FC /* DatabaseService.swift in Sources */, F87CF5317C3FC2FF9D17983D /* EditorState.swift in Sources */, E037448B11CE5D13C85FF32F /* FileWatcher.swift in Sources */, A1C37D455CD1DEEE92C0F49F /* FloatingPanel.swift in Sources */, BE8CACC0DB336F9249758FD2 /* FlowbarApp.swift in Sources */, 264D0FD6D1EDDD539A1440FF /* GlobalShortcut.swift in Sources */, + F2A48F8469F7191EF0536B94 /* LiveMarkdownEditorView.swift in Sources */, F6A2FCC540B165487531BA6C /* MainView.swift in Sources */, 09487C36AEA44DEB3EEF97D0 /* MarkdownEditorView.swift in Sources */, 4F139AF375C4FA41F292C0E7 /* MarkdownParser.swift in Sources */, diff --git a/Flowbar/Sources/App/AppState.swift b/Flowbar/Sources/App/AppState.swift index 997c1dc..a8e2f1f 100644 --- a/Flowbar/Sources/App/AppState.swift +++ b/Flowbar/Sources/App/AppState.swift @@ -7,6 +7,7 @@ enum AppTheme: String, CaseIterable { enum ActivePanel: Equatable { case file(NoteFile) + case dailyNote case settings case timer case empty @@ -24,6 +25,19 @@ final class AppState { let editor: EditorState let search: SearchState + // MARK: - Daily Note + + var dailyNoteContent: String = "" + var dailyNoteHeadings: [(level: Int, text: String)] = [] + var dailyNoteSelectedHeading: String? + @ObservationIgnored private var dailyNoteWatcher: FileWatcher? + + /// The filtered content to display — full note or just the selected heading's section. + var dailyNoteDisplayContent: String { + guard let heading = dailyNoteSelectedHeading else { return dailyNoteContent } + return MarkdownParser.sectionContent(for: heading, in: dailyNoteContent) + } + init(defaults: UserDefaults = .standard) { self.settings = SettingsState(defaults: defaults) self.sidebar = SidebarState(defaults: defaults) @@ -202,6 +216,86 @@ final class AppState { } } + // MARK: - Daily Note + + var dailyNoteExists: Bool = false + + var dailyNoteURL: URL? { + guard !settings.folderPath.isEmpty else { return nil } + let filename = settings.dailyNoteFilename() + return URL(fileURLWithPath: settings.folderPath).appendingPathComponent("\(filename).md") + } + + func showDailyNote() { + guard !settings.folderPath.isEmpty else { return } + dailyNoteSelectedHeading = nil + sidebar.activePanel = .dailyNote + guard let fileURL = dailyNoteURL else { return } + dailyNoteExists = FileManager.default.fileExists(atPath: fileURL.path) + guard dailyNoteExists else { return } + loadDailyNoteContent(from: fileURL) + watchDailyNote(at: fileURL) + } + + func createDailyNote() { + guard let fileURL = dailyNoteURL else { return } + let content = resolveTemplate(for: fileURL) + try? content.write(to: fileURL, atomically: true, encoding: .utf8) + dailyNoteExists = true + loadDailyNoteContent(from: fileURL) + watchDailyNote(at: fileURL) + } + + private func resolveTemplate(for fileURL: URL) -> String { + let templatePath = settings.dailyNoteTemplatePath + guard !templatePath.isEmpty, + let raw = try? String(contentsOfFile: templatePath, encoding: .utf8) else { + return "" + } + let title = fileURL.deletingPathExtension().lastPathComponent + let now = Date() + // Substitute Obsidian template tokens + var result = raw + .replacingOccurrences(of: "{{title}}", with: title) + .replacingOccurrences(of: "{{date}}", with: settings.dailyNoteFilename(for: now)) + .replacingOccurrences(of: "{{time}}", with: formatTime(now)) + // {{date:FORMAT}} — replace each occurrence + if let regex = try? NSRegularExpression(pattern: #"\{\{date:(.+?)\}\}"#) { + let nsResult = result as NSString + let matches = regex.matches(in: result, range: NSRange(location: 0, length: nsResult.length)) + for match in matches.reversed() { + let fmtRange = match.range(at: 1) + let fmt = nsResult.substring(with: fmtRange) + let replacement = settings.dailyNoteFilename(for: now, format: fmt) + result = (result as NSString).replacingCharacters(in: match.range, with: replacement) + } + } + return result + } + + private func formatTime(_ date: Date) -> String { + let fmt = DateFormatter() + fmt.dateFormat = "HH:mm" + return fmt.string(from: date) + } + + private func watchDailyNote(at fileURL: URL) { + dailyNoteWatcher = FileWatcher(url: fileURL) { [weak self] in + Task { @MainActor in + self?.loadDailyNoteContent(from: fileURL) + } + } + } + + private func loadDailyNoteContent(from url: URL) { + dailyNoteContent = (try? String(contentsOf: url, encoding: .utf8)) ?? "" + dailyNoteHeadings = MarkdownParser.extractHeadings(from: dailyNoteContent) + } + + func selectDailyNoteHeading(_ heading: String?) { + dailyNoteSelectedHeading = heading + } + // MARK: - External app integration func openInObsidian() { diff --git a/Flowbar/Sources/App/SettingsState.swift b/Flowbar/Sources/App/SettingsState.swift index c5fd2b4..ca3912e 100644 --- a/Flowbar/Sources/App/SettingsState.swift +++ b/Flowbar/Sources/App/SettingsState.swift @@ -44,6 +44,13 @@ final class SettingsState { } } + var dailyNoteFormat: String { + didSet { defaults.set(dailyNoteFormat, forKey: "dailyNoteFormat") } + } + var dailyNoteTemplatePath: String { + didSet { defaults.set(dailyNoteTemplatePath, forKey: "dailyNoteTemplatePath") } + } + var globalShortcut: GlobalShortcut { didSet { guard globalShortcut != oldValue else { return } @@ -70,6 +77,8 @@ final class SettingsState { init(defaults: UserDefaults = .standard) { self.defaults = defaults + self.dailyNoteFormat = defaults.string(forKey: "dailyNoteFormat") ?? "YYYY-MM-DD" + self.dailyNoteTemplatePath = defaults.string(forKey: "dailyNoteTemplatePath") ?? "" self.folderPath = defaults.string(forKey: "folderPath") ?? "" self.theme = AppTheme(rawValue: defaults.string(forKey: "theme") ?? "") ?? .dark self.typography = TypographySize(rawValue: defaults.string(forKey: "typography") ?? "") ?? .default @@ -84,6 +93,19 @@ final class SettingsState { self.launchAtLogin = SMAppService.mainApp.status == .enabled } + /// Resolve a date string from an Obsidian-style format (e.g. "YYYY-MM-DD" → "2026-03-24"). + /// Uses `dailyNoteFormat` by default; pass a custom `format` for `{{date:FORMAT}}` tokens. + func dailyNoteFilename(for date: Date = Date(), format: String? = nil) -> String { + let fmt = DateFormatter() + let swift = (format ?? dailyNoteFormat) + .replacingOccurrences(of: "YYYY", with: "yyyy") + .replacingOccurrences(of: "dddd", with: "EEEE") + .replacingOccurrences(of: "ddd", with: "EEE") + .replacingOccurrences(of: "DD", with: "dd") + fmt.dateFormat = swift + return fmt.string(from: date) + } + /// Toggle between light and dark theme. func toggleTheme() { theme = theme == .dark ? .light : .dark diff --git a/Flowbar/Sources/Services/MarkdownParser.swift b/Flowbar/Sources/Services/MarkdownParser.swift index a9bdaf4..80771c9 100644 --- a/Flowbar/Sources/Services/MarkdownParser.swift +++ b/Flowbar/Sources/Services/MarkdownParser.swift @@ -99,6 +99,45 @@ enum MarkdownParser { && Set(stripped).count == 1 } + // MARK: - Heading extraction + + /// Extract all headings from markdown content as (level, text) pairs. + static func extractHeadings(from content: String) -> [(level: Int, text: String)] { + content.components(separatedBy: "\n").compactMap { line in + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard let (level, text) = parseHeading(trimmed) else { return nil } + return (level, text) + } + } + + /// Extract the content under a specific heading (until the next heading of same or higher level). + static func sectionContent(for headingText: String, in content: String) -> String { + let lines = content.components(separatedBy: "\n") + var collecting = false + var headingLevel = 0 + var result: [String] = [] + + for line in lines { + let trimmed = line.trimmingCharacters(in: .whitespaces) + if let (level, text) = parseHeading(trimmed) { + if collecting { + // Stop at same or higher level heading + if level <= headingLevel { break } + } + if text == headingText && !collecting { + collecting = true + headingLevel = level + result.append(line) + continue + } + } + if collecting { + result.append(line) + } + } + return result.joined(separator: "\n") + } + // MARK: - Line-level todo toggle /// Toggles a single line between `- [ ]` and `- [x]`. Returns the toggled line, or nil if not a todo. diff --git a/Flowbar/Sources/Views/Components/SidebarFooter.swift b/Flowbar/Sources/Views/Components/SidebarFooter.swift index ccb24dd..3c2c7d0 100644 --- a/Flowbar/Sources/Views/Components/SidebarFooter.swift +++ b/Flowbar/Sources/Views/Components/SidebarFooter.swift @@ -5,9 +5,13 @@ struct SidebarFooter: View { private var isSettings: Bool { appState.sidebar.activePanel == .settings } private var isTimer: Bool { appState.sidebar.activePanel == .timer } + private var isDailyNote: Bool { appState.sidebar.activePanel == .dailyNote } var body: some View { HStack(spacing: 4) { + footerButton(icon: "calendar", label: "Today", isActive: isDailyNote) { + isDailyNote ? appState.returnToFiles() : appState.showDailyNote() + } footerButton(icon: "gearshape", label: "Settings", isActive: isSettings) { isSettings ? appState.returnToFiles() : appState.showSettings() } diff --git a/Flowbar/Sources/Views/DailyNoteContentView.swift b/Flowbar/Sources/Views/DailyNoteContentView.swift new file mode 100644 index 0000000..0807089 --- /dev/null +++ b/Flowbar/Sources/Views/DailyNoteContentView.swift @@ -0,0 +1,147 @@ +import SwiftUI + +/// Daily note content view with edit/preview toggle (same pattern as NoteContentView). +struct DailyNoteContentView: View { + @Environment(AppState.self) var appState + @State private var isEditing = false + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + header + Divider().opacity(0.2) + + if !appState.dailyNoteExists { + createNotePrompt + } else if isEditing { + LiveMarkdownEditorView( + text: Binding( + get: { appState.dailyNoteDisplayContent }, + set: { saveSection($0) } + ), + baseSize: appState.settings.typography.bodySize, + onTextChange: {} + ) + } else { + MarkdownPreviewView( + content: appState.dailyNoteDisplayContent, + bodySize: appState.settings.typography.bodySize, + accentColor: appState.settings.accent, + onToggleTodo: { toggleTodo(at: $0) }, + onDoubleClick: { isEditing = true } + ) + } + } + .onChange(of: appState.dailyNoteSelectedHeading) { isEditing = false } + } + + private var createNotePrompt: some View { + VStack(spacing: 16) { + Image(systemName: "calendar.badge.plus") + .font(.system(size: 36)) + .foregroundStyle(.secondary) + Text("No note for today") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.secondary) + Button(action: { appState.createDailyNote() }) { + Text("Create Daily Note") + .font(.system(size: 13, weight: .medium)) + .padding(.horizontal, 16) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(appState.settings.accent.opacity(0.8)) + ) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + .accessibilityIdentifier("create-daily-note") + + if appState.settings.dailyNoteTemplatePath.isEmpty { + Text("Tip: add a template path in Settings") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + + private var header: some View { + HStack(spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(appState.settings.dailyNoteFilename()) + .font(.system(size: appState.settings.typography.titleSize, weight: .bold)) + if let heading = appState.dailyNoteSelectedHeading { + Text(heading) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + } + Spacer() + + Button(action: { isEditing.toggle() }) { + Image(systemName: isEditing ? "eye" : "pencil") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + } + .buttonStyle(.plain) + .help(isEditing ? "Preview (⌘E)" : "Edit (⌘E)") + .accessibilityIdentifier("daily-note-edit-preview") + .keyboardShortcut("e", modifiers: .command) + + if let url = appState.dailyNoteURL { + Button(action: { openInObsidian(url) }) { + ObsidianIcon().frame(width: 18, height: 18) + } + .buttonStyle(.plain) + .help("Open in Obsidian") + .accessibilityIdentifier("daily-note-open-obsidian") + } + } + .padding(.leading, 20) + .padding(.trailing, 20) + .padding(.top, FloatingPanel.contentTopPadding) + .padding(.bottom, 10) + } + + private func toggleTodo(at lineIndex: Int) { + guard let url = appState.dailyNoteURL else { return } + var lines = appState.dailyNoteContent.components(separatedBy: "\n") + if appState.dailyNoteSelectedHeading != nil { + let displayLines = appState.dailyNoteDisplayContent.components(separatedBy: "\n") + guard lineIndex < displayLines.count else { return } + let targetLine = displayLines[lineIndex] + guard let fullIndex = lines.firstIndex(where: { $0 == targetLine }), + let toggled = MarkdownParser.toggleTodoLine(lines[fullIndex]) else { return } + lines[fullIndex] = toggled + } else { + guard lineIndex < lines.count, + let toggled = MarkdownParser.toggleTodoLine(lines[lineIndex]) else { return } + lines[lineIndex] = toggled + } + appState.dailyNoteContent = lines.joined(separator: "\n") + try? appState.dailyNoteContent.write(to: url, atomically: true, encoding: .utf8) + } + + private func saveSection(_ newContent: String) { + guard let url = appState.dailyNoteURL else { return } + if let heading = appState.dailyNoteSelectedHeading { + let old = MarkdownParser.sectionContent(for: heading, in: appState.dailyNoteContent) + appState.dailyNoteContent = appState.dailyNoteContent.replacingOccurrences(of: old, with: newContent) + } else { + appState.dailyNoteContent = newContent + } + appState.dailyNoteHeadings = MarkdownParser.extractHeadings(from: appState.dailyNoteContent) + try? appState.dailyNoteContent.write(to: url, atomically: true, encoding: .utf8) + } + + private func openInObsidian(_ fileURL: URL) { + let vaultPath = URL(fileURLWithPath: appState.settings.folderPath).deletingLastPathComponent() + let vaultName = vaultPath.lastPathComponent + let folderName = URL(fileURLWithPath: appState.settings.folderPath).lastPathComponent + let fileName = fileURL.lastPathComponent + let encoded = fileName.addingPercentEncoding(withAllowedCharacters: .urlPathAllowed) ?? fileName + if let url = URL(string: "obsidian://open?vault=\(vaultName)&file=\(folderName)/\(encoded)") { + NSWorkspace.shared.open(url) + } + } +} diff --git a/Flowbar/Sources/Views/LiveMarkdownEditorView.swift b/Flowbar/Sources/Views/LiveMarkdownEditorView.swift new file mode 100644 index 0000000..f99c62b --- /dev/null +++ b/Flowbar/Sources/Views/LiveMarkdownEditorView.swift @@ -0,0 +1,147 @@ +import SwiftUI +import AppKit + +/// Editable view with live markdown formatting — headings render large/bold, +/// done todos show strikethrough. Always editable, no mode toggle. +struct LiveMarkdownEditorView: NSViewRepresentable { + @Binding var text: String + var baseSize: CGFloat + var onTextChange: () -> Void + + func makeCoordinator() -> Coordinator { Coordinator(self) } + + func makeNSView(context: Context) -> NSScrollView { + let scrollView = NSTextView.scrollableTextView() + let textView = scrollView.documentView as! NSTextView + configure(textView, coordinator: context.coordinator) + setText(text, in: textView) + scrollView.drawsBackground = false + scrollView.hasHorizontalScroller = false + return scrollView + } + + func updateNSView(_ scrollView: NSScrollView, context: Context) { + let textView = scrollView.documentView as! NSTextView + guard !context.coordinator.isUpdating, textView.string != text else { return } + context.coordinator.isUpdating = true + setText(text, in: textView) + context.coordinator.isUpdating = false + } + + private func configure(_ textView: NSTextView, coordinator: Coordinator) { + textView.delegate = coordinator + textView.isRichText = true + textView.allowsUndo = true + textView.isAutomaticQuoteSubstitutionEnabled = false + textView.isAutomaticDashSubstitutionEnabled = false + textView.isAutomaticTextReplacementEnabled = false + textView.drawsBackground = false + textView.textContainerInset = NSSize(width: 16, height: 16) + textView.isVerticallyResizable = true + textView.isHorizontallyResizable = false + textView.textContainer?.widthTracksTextView = true + textView.typingAttributes = Self.defaultAttrs(size: baseSize) + coordinator.textView = textView + } + + private func setText(_ string: String, in textView: NSTextView) { + guard let storage = textView.textStorage else { return } + storage.beginEditing() + storage.setAttributedString(NSAttributedString(string: string, attributes: Self.defaultAttrs(size: baseSize))) + Self.applyFormatting(to: storage, baseSize: baseSize) + storage.endEditing() + } + + // MARK: - Formatting + + static func defaultAttrs(size: CGFloat) -> [NSAttributedString.Key: Any] { + [.font: NSFont.systemFont(ofSize: size), .foregroundColor: NSColor.labelColor] + } + + static func applyFormatting(to storage: NSTextStorage, baseSize: CGFloat) { + let full = NSRange(location: 0, length: storage.length) + storage.setAttributes(defaultAttrs(size: baseSize), range: full) + var offset = 0 + for line in storage.string.components(separatedBy: "\n") { + let lineRange = NSRange(location: offset, length: (line as NSString).length) + applyLineStyle(line, in: lineRange, to: storage, baseSize: baseSize) + offset += (line as NSString).length + 1 + } + } + + private static func applyLineStyle(_ line: String, in range: NSRange, to storage: NSTextStorage, baseSize: CGFloat) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + let level = trimmed.prefix(while: { $0 == "#" }).count + if level >= 1, level <= 6, trimmed.dropFirst(level).hasPrefix(" ") { + let sizes: [CGFloat] = [baseSize * 2.0, baseSize * 1.6, baseSize * 1.3, baseSize * 1.15, baseSize * 1.05, baseSize] + let weight: NSFont.Weight = level <= 2 ? .bold : .semibold + storage.addAttribute(.font, value: NSFont.systemFont(ofSize: sizes[level - 1], weight: weight), range: range) + } else if trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("- [X] ") { + storage.addAttribute(.foregroundColor, value: NSColor.secondaryLabelColor, range: range) + storage.addAttribute(.strikethroughStyle, value: NSUnderlineStyle.single.rawValue, range: range) + } + } + + // MARK: - Coordinator + + @MainActor final class Coordinator: NSObject, NSTextViewDelegate { + var parent: LiveMarkdownEditorView + weak var textView: NSTextView? + var isUpdating = false + + init(_ parent: LiveMarkdownEditorView) { self.parent = parent } + + func textDidChange(_ notification: Notification) { + guard !isUpdating, let tv = notification.object as? NSTextView, + let storage = tv.textStorage else { return } + isUpdating = true + let selection = tv.selectedRange() + storage.beginEditing() + LiveMarkdownEditorView.applyFormatting(to: storage, baseSize: parent.baseSize) + storage.endEditing() + tv.setSelectedRange(selection) + tv.typingAttributes = LiveMarkdownEditorView.defaultAttrs(size: parent.baseSize) + parent.text = tv.string + parent.onTextChange() + isUpdating = false + } + + func textView(_ textView: NSTextView, doCommandBy selector: Selector) -> Bool { + if selector == #selector(NSResponder.insertNewline(_:)) { + return handleNewline(textView) + } + return false + } + + private func handleNewline(_ tv: NSTextView) -> Bool { + let nsText = tv.string as NSString + let cursor = tv.selectedRange().location + let lineRange = nsText.lineRange(for: NSRange(location: cursor, length: 0)) + let line = nsText.substring(with: lineRange).replacingOccurrences(of: "\n", with: "") + let indent = String(line.prefix(while: { $0 == " " || $0 == "\t" })) + let trimmed = line.trimmingCharacters(in: .whitespaces) + + let emptyPrefixes = ["-", "- [ ]", "- [x]", "- [X]", "*"] + if emptyPrefixes.contains(trimmed) { + tv.setSelectedRange(lineRange) + tv.insertText("\n", replacementRange: tv.selectedRange()) + return true + } + + var prefix = "" + if trimmed.hasPrefix("- [ ] ") || trimmed.hasPrefix("- [x] ") || trimmed.hasPrefix("- [X] ") { + prefix = indent + "- [ ] " + } else if trimmed.hasPrefix("- ") { + prefix = indent + "- " + } else if trimmed.hasPrefix("* ") { + prefix = indent + "* " + } + + if !prefix.isEmpty { + tv.insertText("\n" + prefix, replacementRange: tv.selectedRange()) + return true + } + return false + } + } +} diff --git a/Flowbar/Sources/Views/MainView.swift b/Flowbar/Sources/Views/MainView.swift index 3938b4c..1620581 100644 --- a/Flowbar/Sources/Views/MainView.swift +++ b/Flowbar/Sources/Views/MainView.swift @@ -25,6 +25,8 @@ struct MainView: View { SettingsView() case .timer: TimerContainerView() + case .dailyNote: + DailyNoteContentView() case .file: NoteContentView() case .empty: @@ -105,6 +107,10 @@ struct MainView: View { } .keyboardShortcut("l", modifiers: [.option, .command]) + // Open daily note + Button("") { appState.showDailyNote() } + .keyboardShortcut("d", modifiers: [.option, .command]) + // Toggle search Button("") { appState.search.toggle(files: appState.sidebar.noteFiles) } .keyboardShortcut("f", modifiers: .command) diff --git a/Flowbar/Sources/Views/SettingsView.swift b/Flowbar/Sources/Views/SettingsView.swift index a55a7ac..1018ac6 100644 --- a/Flowbar/Sources/Views/SettingsView.swift +++ b/Flowbar/Sources/Views/SettingsView.swift @@ -30,6 +30,44 @@ struct SettingsView: View { } } + settingsSection("Daily Note") { + HStack { + Text("Filename Format") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + Spacer() + TextField("YYYY-MM-DD", text: Binding( + get: { appState.settings.dailyNoteFormat }, + set: { appState.settings.dailyNoteFormat = $0 } + )) + .textFieldStyle(.roundedBorder) + .frame(width: 160) + .font(.system(size: 12, design: .monospaced)) + } + Text("Obsidian format: YYYY, MM, DD (e.g. YYYY-MM-DD → \(appState.settings.dailyNoteFilename()))") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + + HStack { + Text("Template File") + .font(.system(size: 13)) + .foregroundStyle(.secondary) + Spacer() + TextField("Path to template .md file", text: Binding( + get: { appState.settings.dailyNoteTemplatePath }, + set: { appState.settings.dailyNoteTemplatePath = $0 } + )) + .textFieldStyle(.roundedBorder) + .font(.system(size: 11)) + Button("Browse...") { browseTemplate() } + .buttonStyle(.bordered) + .tint(appState.settings.accent) + } + Text("Supports: {{title}}, {{date}}, {{date:FORMAT}}, {{time}}") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + settingsSection("Appearance") { settingsPickerRow("Theme", selection: $settings.theme, options: AppTheme.allCases) { $0.rawValue.capitalized } settingsPickerRow("Text Size", selection: $settings.typography, options: TypographySize.allCases) { $0.rawValue.capitalized } @@ -198,6 +236,17 @@ struct SettingsView: View { } } + private func browseTemplate() { + let panel = NSOpenPanel() + panel.canChooseDirectories = false + panel.canChooseFiles = true + panel.allowedContentTypes = [.init(filenameExtension: "md")!] + panel.message = "Select your daily note template file" + if panel.runModal() == .OK, let url = panel.url { + appState.settings.dailyNoteTemplatePath = url.path + } + } + private func browseFolder() { let panel = NSOpenPanel() panel.canChooseDirectories = true diff --git a/Flowbar/Sources/Views/SidebarView.swift b/Flowbar/Sources/Views/SidebarView.swift index 8d62838..9a6480e 100644 --- a/Flowbar/Sources/Views/SidebarView.swift +++ b/Flowbar/Sources/Views/SidebarView.swift @@ -3,52 +3,14 @@ import SwiftUI struct SidebarView: View { @Environment(AppState.self) var appState + private var isDailyNote: Bool { appState.sidebar.activePanel == .dailyNote } + var body: some View { VStack(spacing: 0) { - ScrollView { - LazyVStack(alignment: .leading, spacing: 2) { - ForEach(appState.sidebar.noteFiles) { file in - SidebarFileRow( - file: file, - isSelected: appState.sidebar.selectedFile?.id == file.id, - isRenaming: appState.sidebar.renamingFileID == file.id - ) - .onTapGesture { - guard appState.sidebar.renamingFileID != file.id else { return } - appState.selectFile(file) - } - .accessibilityElement(children: .contain) - .accessibilityIdentifier("sidebar-row-\(file.id)") - .contextMenu { - Button("New File") { appState.createNewFile() } - Divider() - Button("Reveal in Finder") { appState.revealInFinder(file) } - Button("Open in Obsidian") { appState.openInObsidian(file) } - Divider() - Button("Rename") { appState.startRename(file) } - Divider() - Button("Move to Trash", role: .destructive) { appState.trashFile(file) } - } - } - } - .padding(.vertical, 4) - .padding(.horizontal, 8) - } - .contextMenu { - Button("New File") { appState.createNewFile() } - } - .overlay { - if appState.sidebar.noteFiles.isEmpty { - VStack(spacing: 8) { - Text("No files yet") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - Text("Right-click to create one") - .font(.system(size: 11)) - .foregroundStyle(.tertiary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } + if isDailyNote { + dailyNoteSidebar + } else { + fileListSidebar } Spacer() @@ -57,6 +19,120 @@ struct SidebarView: View { .frame(maxHeight: .infinity) .background(FlowbarColors.sidebarBg) } + + // MARK: - Daily Note Sidebar + + private var dailyNoteSidebar: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + // "All" row — shows full daily note + DailyNoteHeadingRow( + text: appState.settings.dailyNoteFilename(), + icon: "doc.text", + isSelected: appState.dailyNoteSelectedHeading == nil, + indent: 0 + ) + .onTapGesture { appState.selectDailyNoteHeading(nil) } + .accessibilityIdentifier("daily-note-all") + + // Heading rows + ForEach(Array(appState.dailyNoteHeadings.enumerated()), id: \.offset) { _, heading in + DailyNoteHeadingRow( + text: heading.text, + icon: nil, + isSelected: appState.dailyNoteSelectedHeading == heading.text, + indent: heading.level - 1 + ) + .onTapGesture { appState.selectDailyNoteHeading(heading.text) } + .accessibilityIdentifier("daily-note-heading-\(heading.text)") + } + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + } + } + + // MARK: - File List Sidebar + + private var fileListSidebar: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 2) { + ForEach(appState.sidebar.noteFiles) { file in + SidebarFileRow( + file: file, + isSelected: appState.sidebar.selectedFile?.id == file.id, + isRenaming: appState.sidebar.renamingFileID == file.id + ) + .onTapGesture { + guard appState.sidebar.renamingFileID != file.id else { return } + appState.selectFile(file) + } + .accessibilityElement(children: .contain) + .accessibilityIdentifier("sidebar-row-\(file.id)") + .contextMenu { + Button("New File") { appState.createNewFile() } + Divider() + Button("Reveal in Finder") { appState.revealInFinder(file) } + Button("Open in Obsidian") { appState.openInObsidian(file) } + Divider() + Button("Rename") { appState.startRename(file) } + Divider() + Button("Move to Trash", role: .destructive) { appState.trashFile(file) } + } + } + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + } + .contextMenu { + Button("New File") { appState.createNewFile() } + } + .overlay { + if appState.sidebar.noteFiles.isEmpty { + VStack(spacing: 8) { + Text("No files yet") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + Text("Right-click to create one") + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } + } +} + +/// A sidebar row for daily note headings. +struct DailyNoteHeadingRow: View { + @Environment(AppState.self) var appState + let text: String + let icon: String? + let isSelected: Bool + let indent: Int + + var body: some View { + HStack(spacing: 6) { + if let icon { + Image(systemName: icon) + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + Text(text) + .font(.system(size: appState.settings.typography.sidebarSize)) + .foregroundStyle(isSelected ? .primary : .secondary) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading, 12 + CGFloat(indent) * 12) + .padding(.trailing, 12) + .padding(.vertical, 8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(isSelected ? appState.settings.accent.opacity(0.4) : Color.clear) + ) + .contentShape(Rectangle()) + } } /// A single file row — plain text normally, inline NSTextField when renaming.