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
75 changes: 75 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
name: Tests

on:
push:
branches:
- main
pull_request:
branches:
- main
types:
- opened
- synchronize
- reopened
- ready_for_review
merge_group:

jobs:
test:
runs-on: macos-latest

steps:
- name: Check out repository
uses: actions/checkout@v4

- name: Select Xcode
uses: maxim-lobanov/setup-xcode@v1
with:
xcode-version: latest-stable

- name: Show Xcode version
run: |
xcodebuild -version
swift --version

- name: Configure project for CI
run: |
cp .env.example .env
{
echo "DEVELOPMENT_TEAM=AAAAAAAAAA"
echo "BUNDLE_IDENTIFIER=com.example.zettel"
echo "CONFIGURATION=Debug"
} >> .env
./configure.sh

- name: Run ZettelKit package tests
run: swift test --package-path Packages/ZettelKit

- name: Run macOS tests
run: xcodebuild test -project Zettel.xcodeproj -scheme ZettelMac -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO

- name: Resolve iOS simulator destination
id: ios-destination
shell: bash
run: |
set -euo pipefail
destination=$(xcrun simctl list devices available --json | ruby -rjson -e '
devices = JSON.parse(STDIN.read).fetch("devices")
candidates = devices.flat_map do |runtime, entries|
next [] unless runtime.include?("iOS")
entries.select { |entry| entry["isAvailable"] && entry["name"].include?("iPhone") }
.map { |entry| [runtime, entry["udid"]] }
end
abort("No available iOS simulator found") if candidates.empty?

best = candidates.max_by do |runtime, _|
version = runtime[/iOS[- ](.+)$/, 1].to_s
version.split(/[-.]/).map(&:to_i)
end

puts "platform=iOS Simulator,id=#{best[1]}"
')
echo "value=$destination" >> "$GITHUB_OUTPUT"

- name: Run iOS tests
run: xcodebuild test -project Zettel.xcodeproj -scheme Zettel -destination '${{ steps.ios-destination.outputs.value }}' CODE_SIGNING_ALLOWED=NO
7 changes: 6 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,8 @@ Thank you for your interest in contributing to Zettel! This guide will help you
- `Stores/` - macOS state management
- `Extensions/` - macOS-specific extensions
- `Packages/ZettelKit/` - Shared Swift package (models, stores, utilities used by both targets)
- `ZettelTests/` - Unit tests
- `ZettelTests/` - iOS logic tests
- `ZettelMacTests/` - macOS logic tests

## Development Workflow

Expand All @@ -73,6 +74,10 @@ Thank you for your interest in contributing to Zettel! This guide will help you
3. **Before Committing**
- Run `./clean.sh` to remove personal configuration
- Ensure `.env` is not committed (it's in `.gitignore`)
- Run the logic test suites:
- `swift test --package-path Packages/ZettelKit`
- `xcodebuild test -project Zettel.xcodeproj -scheme ZettelMac -destination 'platform=macOS' CODE_SIGNING_ALLOWED=NO`
- `xcodebuild test -project Zettel.xcodeproj -scheme Zettel -destination 'platform=iOS Simulator,name=iPhone 16' CODE_SIGNING_ALLOWED=NO`
- Test that the project builds from a clean state

4. **Submitting Changes**
Expand Down
4 changes: 4 additions & 0 deletions Packages/ZettelKit/Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,5 +18,9 @@ let package = Package(
.target(
name: "ZettelKit"
),
.testTarget(
name: "ZettelKitTests",
dependencies: ["ZettelKit"]
),
]
)
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,38 @@
import Foundation

/// Manages the template used for generating default note titles.
public final class DefaultTitleTemplateManager: Sendable {
public final class DefaultTitleTemplateManager: @unchecked Sendable {
public static let shared = DefaultTitleTemplateManager()

/// Storage key for persisting the custom template.
private let storageKey = "defaultTitleTemplate"
private let storageKey: String
private let timeZone: TimeZone
private let userDefaults: UserDefaults

/// Built-in fallback template used when no custom template is set.
public let fallbackTemplate = "{{shortDate}} – {{time}}"

private init() {}
public init(
userDefaults: UserDefaults = .standard,
storageKey: String = "defaultTitleTemplate",
timeZone: TimeZone = .autoupdatingCurrent
) {
self.userDefaults = userDefaults
self.storageKey = storageKey
self.timeZone = timeZone
}

/// Returns the saved custom template if present.
public func savedTemplate() -> String? {
UserDefaults.standard.string(forKey: storageKey)
userDefaults.string(forKey: storageKey)
}

/// Persists a new template.
public func saveTemplate(_ template: String) {
if template.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
UserDefaults.standard.removeObject(forKey: storageKey)
userDefaults.removeObject(forKey: storageKey)
} else {
UserDefaults.standard.set(template, forKey: storageKey)
userDefaults.set(template, forKey: storageKey)
}
}

Expand All @@ -53,6 +63,7 @@ public final class DefaultTitleTemplateManager: Sendable {
public func fallbackTitle(for date: Date) -> String {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = timeZone
formatter.dateFormat = "HH-mm-ss – d MMM yyyy"
return formatter.string(from: date)
}
Expand Down Expand Up @@ -81,6 +92,7 @@ public final class DefaultTitleTemplateManager: Sendable {
private func makeFormatter(_ format: String) -> DateFormatter {
let formatter = DateFormatter()
formatter.locale = Locale(identifier: "en_US_POSIX")
formatter.timeZone = timeZone
formatter.dateFormat = format
return formatter
}
Expand Down
7 changes: 4 additions & 3 deletions Packages/ZettelKit/Sources/ZettelKit/Note.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,19 +49,20 @@ public final class TagCacheManager: @unchecked Sendable {
}

public func getExtractedTags(for note: Note) -> Set<String> {
let noteId = NSString(string: note.id)
let noteId = note.id
let contentHash = (note.title + note.content).hashValue

return cacheQueue.sync {
if let cached = cache.object(forKey: noteId), cached.hash == contentHash {
let cacheKey = NSString(string: noteId)
if let cached = cache.object(forKey: cacheKey), cached.hash == contentHash {
return cached.tags
}

let tags = TagParser.extractTags(from: note)
let cachedTags = CachedTags(hash: contentHash, tags: tags)

cacheQueue.async(flags: .barrier) {
self.cache.setObject(cachedTags, forKey: noteId)
self.cache.setObject(cachedTags, forKey: NSString(string: noteId))
}

return tags
Expand Down
89 changes: 89 additions & 0 deletions Packages/ZettelKit/Sources/ZettelKit/NoteFileRepository.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import Foundation

/// Foundation-only note file persistence used by app stores and tests.
public struct NoteFileRepository: Sendable {
public struct SaveResult: Equatable, Sendable {
public let filename: String
public let fileURL: URL

public init(filename: String, fileURL: URL) {
self.filename = filename
self.fileURL = fileURL
}
}

public init() {}

public func createDirectoryIfNeeded(at directory: URL) throws {
try FileManager.default.createDirectory(at: directory, withIntermediateDirectories: true)
}

public func loadNotes(in directory: URL) throws -> [Note] {
let fileURLs = try FileManager.default.contentsOfDirectory(
at: directory,
includingPropertiesForKeys: [.creationDateKey, .contentModificationDateKey],
options: [.skipsHiddenFiles]
)

return try fileURLs
.filter { $0.pathExtension == "md" }
.map(loadRequiredNote(from:))
.sorted { $0.modifiedAt > $1.modifiedAt }
}

public func loadNote(from url: URL) -> Note? {
try? loadRequiredNote(from: url)
}

@discardableResult
public func save(_ note: Note, in directory: URL, originalFilename: String? = nil) throws -> SaveResult {
try createDirectoryIfNeeded(at: directory)

let targetFilename: String
if let originalFilename, originalFilename == note.filename {
targetFilename = originalFilename
} else {
targetFilename = note.generateUniqueFilename(in: directory)
}

let targetURL = directory.appendingPathComponent(targetFilename)
try note.serializedContent.write(to: targetURL, atomically: true, encoding: .utf8)

if let originalFilename, originalFilename != targetFilename {
let originalURL = directory.appendingPathComponent(originalFilename)
if FileManager.default.fileExists(atPath: originalURL.path) {
try FileManager.default.removeItem(at: originalURL)
}
}

return SaveResult(filename: targetFilename, fileURL: targetURL)
}

public func delete(_ note: Note, from directory: URL) throws {
let fileURL = directory.appendingPathComponent(note.filename)
guard FileManager.default.fileExists(atPath: fileURL.path) else {
return
}

try FileManager.default.removeItem(at: fileURL)
}

private func loadRequiredNote(from url: URL) throws -> Note {
guard url.pathExtension == "md" else {
throw NoteError.fileSystemError("Unsupported file type: \(url.pathExtension)")
}

let content = try String(contentsOf: url, encoding: .utf8)
let title = url.deletingPathExtension().lastPathComponent
let resourceValues = try? url.resourceValues(forKeys: [.creationDateKey, .contentModificationDateKey])
let modifiedAt = resourceValues?.contentModificationDate ?? Date()
let createdAt = resourceValues?.creationDate ?? modifiedAt

return Note.fromSerializedContent(
content,
fallbackTitle: title,
createdAt: createdAt,
modifiedAt: modifiedAt
)
}
}
3 changes: 1 addition & 2 deletions Packages/ZettelKit/Sources/ZettelKit/TagParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,7 @@ import Foundation
public final class TagParser: Sendable {
/// Regex pattern to match standalone hashtags: start of text or whitespace, then # and valid tag characters
private static let hashtagPattern = #"(?<!\S)#[a-zA-Z0-9_]+(?![a-zA-Z0-9_])"#
// nonisolated(unsafe) because NSRegularExpression is inherently thread-safe for matching
nonisolated(unsafe) private static let regex = try! NSRegularExpression(pattern: hashtagPattern, options: [])
private static let regex = try! NSRegularExpression(pattern: hashtagPattern, options: [])

/// Internal accessors for performance (used by TagStore)
public static var hashtagPatternInternal: String { hashtagPattern }
Expand Down
Loading
Loading