# Contributing to EdgeMark **Requirements:** macOS 15.7+, Xcode 16.2+, [Homebrew](https://brew.sh) ```bash brew install swiftformat ``` Code style is enforced by [SwiftFormat](https://github.com/nicklockwood/SwiftFormat) via CI — rules are in `.swiftformat` at the project root. --- # Architecture ## Data Flow ```mermaid graph TD EM["EdgeDetector
(mouse monitor)"] -->|"edge hit"| SP["SidePanelController
(NSWindow)"] HK["ShortcutManager
(Carbon hotkey)"] -->|"toggle"| SP SP -->|"host"| SUI["SwiftUI Views"] SUI -->|"observe"| NS["NoteStore (@Observable)"] NS -->|"read / write"| FS["FileStorage"] FS -->|".md files"| Disk[("~/Documents/EdgeMark/")] FS -->|"metadata"| SC["SidecarStore
(.edgemark/meta.json)"] SUI -->|"observe"| AS["AppSettings (@Observable)"] AS -->|"persist"| UD["UserDefaults"] SUI -->|"observe"| US["UpdateState (@Observable)"] US -->|"check · download · install"| UC["UpdateChecker / Installer"] UC -->|"GitHub API"| GH["GitHub Releases"] SUI -->|"embed"| ED["MarkdownEditorView
(NativeTextViewWrapper)"] ED -->|"@Binding text"| NS ED -->|"onPasteImage / drag"| FS NS -.->|"log"| Log["OSLog"] ED -.->|"log"| Log SP -.->|"log"| Log Log -.->|"Console.app"| CA["Diagnostic Logs"] ``` ## Source Tree ``` EdgeMark/ ├── App/ # Entry point + global state │ ├── EdgeMarkApp.swift # @main, menu bar utility (LSUIElement) │ ├── AppDelegate.swift # Lifecycle, sidecar migration, shortcut setup, switchRoot + menu-bar storage submenu │ └── ContentView.swift # Navigation shell (folders → notes → editor) │ ├── Core/ # Business logic — no SwiftUI imports │ ├── Editor/ │ │ ├── MarkdownEditorView.swift # SwiftUI wrapper around NativeTextViewWrapper │ │ │ # (swift-markdown-engine). Heading strip, │ │ │ # debounced save, image conversion layer, │ │ │ # slash command integration. │ │ ├── EditorConfigFactory.swift # Shared MarkdownEditorConfiguration (insets, │ │ │ # highlight/strikethrough extensions, task-checkbox │ │ │ # symbols, image/syntax/latex services) for both editors │ │ ├── ReadOnlyMarkdownView.swift # Non-editable preview (trash) │ │ ├── SlashCommandHandler.swift # /h1, /todo, /code, /quote — NSTextView insertion │ │ ├── SlashCommandPopup.swift # Floating autocomplete panel │ │ └── ImageDropHandler.swift # Transparent NSView overlay for image drag-and-drop │ ├── Settings/ │ │ ├── AppSettings.swift # @Observable — sort, panel style/tint, editor font/checkbox, spell-check, peek, tags, + appearance/updates/launch │ │ ├── ShortcutSettings.swift # Global + 6 local keyboard shortcuts + conflict detection (posts .shortcutSettingsChanged) │ │ ├── PanelSettings.swift # Edge activation, dismissal, panel width/animation, swipe gestures (.panelPinStateChanged) │ │ └── StorageSettings.swift # Storage roots (#55) + active/ask-on-launch/session-override (.storageRootChanged) │ ├── Shortcuts/ │ │ ├── ShortcutManager.swift # Carbon RegisterEventHotKey global shortcut │ │ └── KeyCodeTranslator.swift # Virtual key code → display string mapping │ ├── Storage/ │ │ ├── NoteStore.swift # @Observable — note CRUD, trash, folders, tag filter, multi-selection + batch ops, move-conflict queue │ │ ├── FileStorage.swift # Plain .md file I/O (no YAML); asset dir management │ │ ├── SidecarStore.swift # In-memory .edgemark/meta.json store + persistence │ │ ├── SidecarMigration.swift # One-time migration: strips YAML, restores timestamps │ │ ├── Note.swift # Note model (id, title, body, timestamps, tags, savedAt) │ │ ├── Folder.swift # Folder model │ │ ├── TagColor.swift # Finder-style 7-color tag palette │ │ └── TrashedFolder.swift # Trashed folder with expiry metadata │ ├── Updates/ │ │ ├── UpdateChecker.swift # GitHub Releases API, version comparison │ │ ├── UpdateDownloader.swift # URLSession delegate with progress tracking │ │ ├── UpdateInstaller.swift # DMG mount → verify → copy → replace → restart │ │ ├── UpdateModels.swift # GitHubRelease, UpdateProgress, UpdateError │ │ ├── UpdateState.swift # @Observable — update UI state machine │ │ └── ChecksumVerifier.swift # SHA256 verification via CryptoKit │ └── Window/ │ ├── SidePanelController.swift # NSWindowController — show/hide/animate │ ├── EdgeDetector.swift # Global mouse monitor → edge activation │ ├── SettingsWindowController.swift # Settings window lifecycle │ └── UpdateWindowController.swift # Update window lifecycle │ ├── UI/ # SwiftUI views │ ├── EditorScreen.swift # Editor chrome (header, editor, footer) │ ├── Navigation/ │ │ ├── HomeFolderView.swift # Folder list with create/rename/trash; hosts the ask-on-launch storage picker (in-card mode) │ │ ├── NoteListView.swift # Note cards with search, sort, context menus │ │ └── TrashView.swift # Trash browser with restore/delete/empty │ ├── Components/ │ │ ├── ContentFooterBar.swift # Bottom toolbar (word count, copy format picker) │ │ ├── DateFormatting.swift # Shared date → display string helpers │ │ ├── EmptyStateView.swift # Icon + title + subtitle placeholder │ │ ├── FontPickerButton.swift # NSFontPanel button with live changeFont(_:) preview │ │ ├── HeaderIconButton.swift # Standard icon button with hover UX │ │ ├── InlineRenameEditor.swift# Inline text field with "Name taken" overlay │ │ ├── MoveConflictAlerts.swift# View extension: note + folder move conflict dialogs │ │ ├── NSContextMenuModifier.swift # NSMenu context menus with SF Symbol icons │ │ ├── NoteCardView.swift # Note list row (title, preview, date) │ │ ├── NoteListMenus.swift # Note/folder context menu builders (incl. Tags submenu) │ │ ├── PageLayout.swift # Navigation page chrome (header + content + footer) │ │ ├── PinButton.swift # Toggle for PanelSettings.isPanelPinned │ │ ├── ShortcutRecorderView.swift # Key capture field for shortcut settings │ │ ├── SwipeDetectorView.swift # NSView wrapper for two-finger swipe gestures │ │ ├── TagDotsView.swift # Inline colored dots for note rows │ │ ├── TagFilterBar.swift # Search-context tag filter strip │ │ └── VisualEffectView.swift # NSVisualEffectView wrapper with optional tint sublayer │ └── Settings/ │ ├── SettingsView.swift # Tab container (General, Behavior, Tags, Keyboard, About) │ ├── GeneralSettingsTab.swift # Appearance (incl. panel style/tint), editor font, language, multi-root storage list │ ├── BehaviorSettingsTab.swift# Panel position, edge activation, auto-hide │ ├── TagsSettingsTab.swift # Rename color tag labels │ ├── KeyboardSettingsTab.swift# Global + 6 customizable local shortcut recorders │ ├── AboutSettingsTab.swift # Version info, links, copyright │ └── UpdateView.swift # Download progress, verify, install UI │ ├── Shared/Utils/ │ ├── L10n.swift # JSON-based i18n runtime │ ├── Log.swift # OSLog — 6 categories │ └── Debouncer.swift # Generic debounce utility │ └── Resources/ └── Locales/ # i18n strings ├── en.json # English ├── zh-Hans.json # Simplified Chinese ├── hi.json # Hindi ├── es.json # Spanish └── de.json # German ``` ## Key Patterns | Pattern | Detail | |---------|--------| | **@Observable** | `NoteStore`, `AppSettings`, and `UpdateState` use the `@Observable` macro — views read properties directly, no `@Published` needed | | **MainActor by default** | `SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor`. All types are `@MainActor` unless explicitly opted out | | **AppKit + SwiftUI hybrid** | `NSHostingView` embeds SwiftUI inside a borderless `NSWindow`. Panel lifecycle managed by `SidePanelController` (AppKit), UI rendered by SwiftUI | | **Native editor (swift-markdown-engine)** | `MarkdownEditorView` wraps `NativeTextViewWrapper` (NSViewRepresentable from swift-markdown-engine). Text flows via `@Binding`. Heading stripping, image display-layer conversion (`![](path)` ↔ `![[path]]`), and save debouncing are handled in `MarkdownEditorView`. Both editors build their `MarkdownEditorConfiguration` via `EditorConfigFactory.makeEdgeMarkConfig` (shared insets, highlight/strikethrough extensions, task-checkbox symbols from `AppSettings.taskCheckboxPreset`, and image/syntax/latex services) — so previews match the editor. Changing the checkbox preset rebuilds the view via `.id()` because the engine's `updateNSView` doesn't sync `taskCheckbox` live. | | **Sidecar metadata** | Notes are plain `.md` files with no headers. Metadata (UUID, timestamps, tags, trash state) lives in `.edgemark/meta.json` keyed by UUID. `SidecarMigration` strips YAML on first launch and restores original file timestamps. `savedAt` (last EdgeMark write) is the external-change sentinel; `modifiedAt` only advances on real content edits. | | **Image asset co-location** | Images are stored in a hidden dot-prefix directory next to the note (`.NoteTitle/IMG-uuid.png`). Paths in `.md` files are standard `![](path)` — relative, readable in any external editor. The editor display layer converts them to `![[path]]` for rendering via `EmbeddedImageProvider`. `FileStorage` handles create/rename/move/trash/delete of asset dirs alongside their note. | | **Carbon hotkeys** | Global shortcut uses `RegisterEventHotKey` (Carbon API) since `NSEvent.addGlobalMonitorForEvents` can't intercept key events | | **Multiple storage locations** | `StorageSettings` owns a list of `StorageRoot`s + an `activeRootID` (persistent default) + an in-memory `sessionRootOverride` (menu-bar temporary switch, reverts on restart). `resolvedStorageDirectory` resolves session-override → active root → legacy → default. All storage (`FileStorage.rootURL`, `SidecarStore`, `.trash/`) reads the active root live, so flipping it re-points the whole layer — but in-memory `NoteStore`/`SidecarStore` must be reloaded (`AppDelegate.switchRoot(to:temporary:dismissPicker:)` is the single path: save dirty → set override/activeID → `SidecarStore.load` → `noteStore.loadFromDisk`, wrapped in `withAnimation` for a row crossfade). Per-root isolation: each root has its own sidecar, trash, and external-edit scope. | | **Local shortcut monitor** | `SidePanelController` installs an `NSEvent.addLocalMonitorForEvents` that checks all six configurable local shortcuts at event time. Settings changes take effect immediately without re-registration. | | **JSON i18n** | `L10n` loads locale JSON at runtime. Access: `l10n["key"]` or `l10n.t("key", arg1, arg2)` for interpolation | | **OSLog diagnostics** | 6 categorized loggers (app, storage, window, shortcuts, navigation, updates). View in Console.app with `subsystem:io.github.ender-wang.EdgeMark` | | **Move conflict queue** | Name-conflict pre-flight uses filesystem-aware helpers (`noteFilenameWouldCollide`, `folderWouldCollide`) that check both in-memory state and the destination on disk. Conflicts are queued, not singletons — `MoveConflictAlerts` reads the queue head and surfaces batch buttons (Keep Both All / Replace All / Skip / Cancel) when more than one is pending. Resolver branches handle orphan files / directories at the destination. | | **DMG auto-update** | `UpdateChecker` queries GitHub Releases API. `UpdateInstaller`: mount DMG → verify bundle ID → copy → replace → restart | --- # Localization EdgeMark uses a custom JSON-based i18n system. Currently supported: | Language | File | Status | |----------|------|--------| | English | `EdgeMark/Resources/Locales/en.json` | ✅ | | Simplified Chinese | `EdgeMark/Resources/Locales/zh-Hans.json` | ✅ | | Hindi | `EdgeMark/Resources/Locales/hi.json` | ✅ | | Spanish | `EdgeMark/Resources/Locales/es.json` | ✅ | | German | `EdgeMark/Resources/Locales/de.json` | ✅ | ## Contributing a Translation 1. Copy `EdgeMark/Resources/Locales/en.json` 2. Rename to your [BCP-47 language code](https://en.wikipedia.org/wiki/IETF_language_tag) (e.g. `ja.json`, `ko.json`, `fr.json`, `de.json`, `pt-BR.json`) 3. Translate the values — keep the JSON keys unchanged 4. (Optional but appreciated) Add a translated `README-.md` (e.g. `README-ja.md`) modeled on `README.md`, and add your language to the switcher row at the top of every `README*.md` (`English · 简体中文 · हिन्दी · Español · Deutsch · …`), bolding the current language in each file. 5. Submit a PR No code, project, or build-phase changes are needed. The Xcode project uses Xcode 16 file-system synchronized groups, so any `.json` you drop into the folder is auto-bundled. The language picker enumerates locale files at runtime, and `L10n` matches the system language by prefix — `pt-BR.json` will be selected for any `pt-*` user, and so on. Native-script display names (e.g. "English", "简体中文", "हिन्दी") come from `Locale.localizedString(forIdentifier:)`, so no language-label keys need to be maintained. ### What reviewers check on translation PRs - All keys from `en.json` are present (no missing strings → no English fallback in the UI). - No leftover English values where the language has a native term. - Placeholders (`{0}`, `{1}`, …) preserved in the same order. - No structural changes to keys, only values. --- # Submitting a Pull Request - Target the `main` branch. - Run `swiftformat EdgeMark/` before pushing — CI fails on lint errors. - **Do not modify** `MARKETING_VERSION` or `CURRENT_PROJECT_VERSION` in `EdgeMark.xcodeproj/project.pbxproj`. Releases are cut by the maintainer from `main`/`develop`; PRs that bump these values will fail the `check-version` CI step.