Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion Flowbar/Flowbar.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 */
Expand Down Expand Up @@ -101,13 +103,15 @@
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 = "<group>"; };
8510A28B4BDA11F845C2A2F0 /* SidebarFooter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarFooter.swift; sourceTree = "<group>"; };
852C10DC9CB48EDE94DC2521 /* LiveMarkdownEditorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveMarkdownEditorView.swift; sourceTree = "<group>"; };
8FFA9B476EBA20F346ECA6F7 /* MarkdownBlockParserTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkdownBlockParserTests.swift; sourceTree = "<group>"; };
90E92AC056D0D1D801EA2F5A /* AppStateNavigationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateNavigationTests.swift; sourceTree = "<group>"; };
926C247304FAFA07BD755FC0 /* SearchOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchOverlayView.swift; sourceTree = "<group>"; };
9429D1FCA615F8FF6BA06083 /* SidebarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SidebarView.swift; sourceTree = "<group>"; };
968FA0A43E76B9C67650651E /* TimerServiceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerServiceTests.swift; sourceTree = "<group>"; };
A7C86951E8C0ECA5E746AD0B /* SearchStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchStateTests.swift; sourceTree = "<group>"; };
AA28AD0B52FFBD4002D7108F /* MainView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MainView.swift; sourceTree = "<group>"; };
AB635E96DA18E8639C69A666 /* DailyNoteContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DailyNoteContentView.swift; sourceTree = "<group>"; };
AEF20A10743ADB27EC3D5761 /* AppStateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppStateTests.swift; sourceTree = "<group>"; };
B0BF69A8C9E2C43EC6598948 /* TitleBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TitleBarView.swift; sourceTree = "<group>"; };
B7F0362096FD8D2B22AA6247 /* FileWatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileWatcher.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -174,6 +178,8 @@
8EA68FA9A29B3138B118A6AC /* Views */ = {
isa = PBXGroup;
children = (
AB635E96DA18E8639C69A666 /* DailyNoteContentView.swift */,
852C10DC9CB48EDE94DC2521 /* LiveMarkdownEditorView.swift */,
AA28AD0B52FFBD4002D7108F /* MainView.swift */,
0E24C83A4A1DC47A34546EA3 /* MarkdownEditorView.swift */,
54CD749B6DD3D3CE8D8305A1 /* MarkdownPreviewView.swift */,
Expand Down Expand Up @@ -371,7 +377,6 @@
};
};
buildConfigurationList = C1DA36052B4A1E46CA65FA2F /* Build configuration list for PBXProject "Flowbar" */;
compatibilityVersion = "Xcode 14.0";
developmentRegion = en;
hasScannedForEncodings = 0;
knownRegions = (
Expand All @@ -381,6 +386,7 @@
mainGroup = 786E57523B2E348AB25D334B;
minimizedProjectReferenceProxies = 1;
preferredProjectObjectVersion = 77;
productRefGroup = C44FBD2640CD80E693BD7B0B /* Products */;
projectDirPath = "";
projectRoot = "";
targets = (
Expand Down Expand Up @@ -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 */,
Expand Down
94 changes: 94 additions & 0 deletions Flowbar/Sources/App/AppState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ enum AppTheme: String, CaseIterable {

enum ActivePanel: Equatable {
case file(NoteFile)
case dailyNote
case settings
case timer
case empty
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Comment on lines +243 to +246
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

Using try? to write the daily note file can lead to an inconsistent UI state. If the write operation fails, the error is ignored, but the code proceeds to set dailyNoteExists = true and attempts to load the (non-existent or outdated) file. This can cause unexpected behavior. It's safer to handle the error from content.write explicitly, for example with a do-catch block, and only update the app state on a successful write.

        do {
            try content.write(to: fileURL, atomically: true, encoding: .utf8)
            dailyNoteExists = true
            loadDailyNoteContent(from: fileURL)
            watchDailyNote(at: fileURL)
        } catch {
            print("Failed to create daily note: \(error.localizedDescription)")
            dailyNoteExists = false
        }

}

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() {
Expand Down
22 changes: 22 additions & 0 deletions Flowbar/Sources/App/SettingsState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 }
Expand All @@ -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
Expand All @@ -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
Expand Down
39 changes: 39 additions & 0 deletions Flowbar/Sources/Services/MarkdownParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 4 additions & 0 deletions Flowbar/Sources/Views/Components/SidebarFooter.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
Loading