Skip to content
Merged
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
174 changes: 144 additions & 30 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,18 +1,121 @@
# apple-notes-sync

A macOS CLI tool that exports Apple Notes to a Git repository as Markdown files, with optional Google Drive sync via rclone.
[![Go](https://img.shields.io/badge/Go-1.26+-00ADD8?logo=go&logoColor=white)](https://go.dev)
[![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](LICENSE)
[![Platform: macOS](https://img.shields.io/badge/platform-macOS-brightgreen)](#prerequisites)
[![CI](https://github.com/PyAgni/apple-notes-syncer/actions/workflows/ci.yml/badge.svg)](https://github.com/PyAgni/apple-notes-syncer/actions)

**Seamless, automatic Git backup of your Apple Notes — with optional Google Drive sync.**
One command (or hourly launchd) turns your Notes app into a version-controlled Markdown repo.

## Table of Contents

- [Quick Start](#quick-start)
- [Features](#features)
- [Why apple-notes-sync?](#why-apple-notes-sync)
- [In Action](#in-action)
- [Prerequisites](#prerequisites)
- [Installation](#installation)
- [Configuration](#configuration)
- [Running Manually](#running-manually)
- [Scheduling with launchd](#scheduling-with-launchd)
- [Google Drive Setup](#google-drive-setup)
- [How Renames and Deletions Are Handled](#how-renames-and-deletions-are-handled)
- [Limitations](#limitations)
- [Troubleshooting](#troubleshooting)
- [Alternatives](#alternatives)
- [Contributing](#contributing)
- [License](#license)

## Quick Start

```bash
# 1. Install
go install github.com/PyAgni/apple-notes-syncer/cmd/apple-notes-sync@latest

# 2. Create a Git repo for your notes
mkdir ~/Notes && cd ~/Notes
git init
git remote add origin git@github.com:yourusername/my-notes.git
git commit --allow-empty -m "init"
git push -u origin main

# 3. Configure
cp configs/config.example.yaml ~/.apple-notes-sync.yaml
# Edit repo_path in the config file

# 4. Run
apple-notes-sync --repo-path ~/Notes

# 5. (Optional) Schedule hourly syncs
make launchd
```

Done. Your notes now live in Git with full history.

## Features

- Extracts all notes from the macOS Notes app via AppleScript
- Converts HTML note bodies to clean Markdown
- Mirrors the Notes folder hierarchy into the repository
- Adds YAML front matter (title, dates, account) to each note
- Adds a metadata table (ID, dates, account) to each note
- Commits with timestamped messages and pushes to your remote
- Optionally syncs to Google Drive via rclone
- Cleans up notes that were deleted from Apple Notes
- Configurable via CLI flags, environment variables, or YAML config file

## Why apple-notes-sync?

Apple Notes is great on your Mac and iPhone, but terrible for backups, version history, or migrating to Obsidian/Logseq.

Other exporters are one-shot scripts. This tool gives you **continuous sync**:

- **Automatic Git commits + push** — full version history of every edit
- **Orphan cleanup** — deleted notes disappear from the repo automatically
- **Hourly launchd scheduling** — set it and forget it
- **rclone integration** — Google Drive as a bonus backup layer
- **Folder mirroring** — your Notes folder structure is preserved exactly

> **Note**: This is a one-way export. Edits made to the Markdown files do not flow back to Apple Notes.

## In Action

<!-- TODO: Add demo GIF or screenshot -->
<!-- ![Demo](https://github.com/PyAgni/apple-notes-syncer/raw/main/assets/demo.gif) -->

Example output for a single note:

```markdown
# My Project Ideas

Content of the note converted to clean Markdown...

- Bullet points preserved
- [Links](https://example.com) converted properly
- **Bold** and *italic* formatting kept

---

| ID | Created | Modified | Account | Shared |
|----|---------|----------|---------|--------|
| x-coredata://abc123 | 2026-03-18 16:00:00 | 2026-03-20 09:30:00 | iCloud | No |
```

Repository structure mirrors your Notes folders:

```
~/Notes/
├── Work/
│ ├── Meeting Notes.md
│ └── Project Ideas.md
├── Personal/
│ ├── Travel Plans.md
│ └── Reading List.md
├── Recipes/
│ └── Pasta Carbonara.md
└── ...
```

## Prerequisites

- **macOS** (required — uses AppleScript to access Notes)
Expand All @@ -33,20 +136,7 @@ make install
### Using `go install`

```bash
go install github.com/agni/apple-notes-sync/cmd/apple-notes-sync@latest
```

## One-time repo setup

Create and initialize a Git repository for your notes:

```bash
mkdir ~/Notes
cd ~/Notes
git init
git remote add origin git@github.com:yourusername/my-notes.git
git commit --allow-empty -m "init"
git push -u origin main
go install github.com/PyAgni/apple-notes-syncer/cmd/apple-notes-sync@latest
```

## Configuration
Expand All @@ -58,6 +148,18 @@ Configuration is loaded from (in order of precedence):
3. YAML config file (`~/.apple-notes-sync.yaml`)
4. Defaults

Minimal config to get started:

```yaml
repo_path: ~/Notes
```

See [`configs/config.example.yaml`](configs/config.example.yaml) for all options. Copy it:

```bash
cp configs/config.example.yaml ~/.apple-notes-sync.yaml
```

### CLI flags

```
Expand All @@ -82,15 +184,6 @@ export ANS_RCLONE_REMOTE_NAME=gdrive
export ANS_RCLONE_REMOTE_PATH=AppleNotes
```

### YAML config file

See [`configs/config.example.yaml`](configs/config.example.yaml) for a complete reference. Copy it to get started:

```bash
cp configs/config.example.yaml ~/.apple-notes-sync.yaml
# Edit with your settings
```

<details>
<summary>Full config reference</summary>

Expand Down Expand Up @@ -119,7 +212,7 @@ cp configs/config.example.yaml ~/.apple-notes-sync.yaml
| `attachments.max_size_mb` | int | `50` | Max attachment size in MB |
| `attachments.dir` | string | `"_attachments"` | Attachment subdirectory |
| `dry_run` | bool | `false` | Preview mode |
| `front_matter` | bool | `true` | Add YAML front matter |
| `front_matter` | bool | `true` | Add metadata table to notes |
| `clean_orphans` | bool | `true` | Remove deleted notes |
| `timeout` | duration | `120s` | AppleScript timeout |
| `commit_template` | string | see below | Commit message Go template |
Expand All @@ -130,7 +223,7 @@ Template fields: `.Timestamp`, `.Written`, `.Total`, `.Skipped`

</details>

## Running manually
## Running Manually

```bash
# Basic run
Expand Down Expand Up @@ -169,7 +262,7 @@ Create the log directory:
mkdir -p ~/Library/Logs/apple-notes-sync
```

## Google Drive setup
## Google Drive Setup

1. Install rclone: `brew install rclone`

Expand Down Expand Up @@ -198,14 +291,34 @@ rclone:
rclone sync ~/Notes gdrive:AppleNotes --dry-run
```

## How renames and deletions are handled
## How Renames and Deletions Are Handled

- **Renamed notes**: A renamed note appears as a new file and the old filename is removed (if `clean_orphans: true`). This shows as a delete + add in git, which GitHub renders as a rename if content is similar.
- **Deleted notes**: When a note is deleted from Apple Notes, the corresponding `.md` file is removed on the next sync (if `clean_orphans: true`).
- **Moved notes**: Moving a note to a different folder creates the file in the new directory and removes it from the old one.

## Limitations

- **macOS only** — relies on AppleScript to access the Notes app
- **One-way export** — edits to Markdown files do not sync back to Apple Notes
- **AppleScript permissions required** — on first run, macOS will prompt to allow automation access
- **Attachments >50 MB skipped** by default (configurable via `attachments.max_size_mb`)
- **Large note libraries** may take a few minutes on the first sync (AppleScript extraction is the bottleneck)

## Troubleshooting

| Problem | Solution |
|---------|----------|
| AppleScript timeout | Increase `timeout: 300s` in your config |
| Permission denied on Notes | System Settings > Privacy & Security > Automation > allow Terminal (or your terminal app) |
| rclone OAuth expired | Run `rclone config` again to re-authenticate |
| Unicode errors in dates | Already handled — the parser normalizes Unicode whitespace from macOS locales |
| "repo_path is required" | Set `repo_path` in your config file or pass `--repo-path` |

## Contributing

Contributions are welcome! Please open an issue or submit a pull request.

1. Fork the repository
2. Create a feature branch: `git checkout -b my-feature`
3. Make your changes
Expand All @@ -217,10 +330,11 @@ rclone sync ~/Notes gdrive:AppleNotes --dry-run
```bash
make build # Build the binary
make test # Run tests with race detector
make check-coverage # Run tests and check coverage 80%
make check-coverage # Run tests and check coverage >= 80%
make lint # Run go vet + staticcheck
make fmt # Format code
make tidy # Tidy go modules
make help # Show all available targets
```

## License
Expand Down
18 changes: 9 additions & 9 deletions cmd/apple-notes-sync/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,15 @@ import (
"github.com/spf13/cobra"
"go.uber.org/zap"

"github.com/agni/apple-notes-sync/internal/applescript"
"github.com/agni/apple-notes-sync/internal/config"
"github.com/agni/apple-notes-sync/internal/converter"
"github.com/agni/apple-notes-sync/internal/filesystem"
"github.com/agni/apple-notes-sync/internal/gitops"
"github.com/agni/apple-notes-sync/internal/logging"
"github.com/agni/apple-notes-sync/internal/rclone"
"github.com/agni/apple-notes-sync/internal/shell"
"github.com/agni/apple-notes-sync/internal/syncer"
"github.com/PyAgni/apple-notes-syncer/internal/applescript"
"github.com/PyAgni/apple-notes-syncer/internal/config"
"github.com/PyAgni/apple-notes-syncer/internal/converter"
"github.com/PyAgni/apple-notes-syncer/internal/filesystem"
"github.com/PyAgni/apple-notes-syncer/internal/gitops"
"github.com/PyAgni/apple-notes-syncer/internal/logging"
"github.com/PyAgni/apple-notes-syncer/internal/rclone"
"github.com/PyAgni/apple-notes-syncer/internal/shell"
"github.com/PyAgni/apple-notes-syncer/internal/syncer"
)

// Build-time variables set via -ldflags.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
module github.com/agni/apple-notes-sync
module github.com/PyAgni/apple-notes-syncer

go 1.26.1

Expand Down
4 changes: 2 additions & 2 deletions internal/applescript/extractor.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@ import (

"go.uber.org/zap"

"github.com/agni/apple-notes-sync/internal/model"
"github.com/agni/apple-notes-sync/internal/shell"
"github.com/PyAgni/apple-notes-syncer/internal/model"
"github.com/PyAgni/apple-notes-syncer/internal/shell"
)

//go:embed scripts
Expand Down
4 changes: 2 additions & 2 deletions internal/applescript/extractor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/zap"

"github.com/agni/apple-notes-sync/internal/model"
"github.com/agni/apple-notes-sync/internal/shell"
"github.com/PyAgni/apple-notes-syncer/internal/model"
"github.com/PyAgni/apple-notes-syncer/internal/shell"
)

// MockCommandExecutor is a testify mock for shell.CommandExecutor.
Expand Down
2 changes: 1 addition & 1 deletion internal/applescript/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import (
"strings"
"time"

"github.com/agni/apple-notes-sync/internal/model"
"github.com/PyAgni/apple-notes-syncer/internal/model"
)

const (
Expand Down
2 changes: 1 addition & 1 deletion internal/filesystem/writer.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ import (

"go.uber.org/zap"

"github.com/agni/apple-notes-sync/internal/model"
"github.com/PyAgni/apple-notes-syncer/internal/model"
)

// NoteWriter manages writing notes to the filesystem.
Expand Down
2 changes: 1 addition & 1 deletion internal/filesystem/writer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/zap"

"github.com/agni/apple-notes-sync/internal/model"
"github.com/PyAgni/apple-notes-syncer/internal/model"
)

func newTestWriter(t *testing.T, subdir string, frontMatter bool) (*FSNoteWriter, string) {
Expand Down
2 changes: 1 addition & 1 deletion internal/gitops/git.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (

"go.uber.org/zap"

"github.com/agni/apple-notes-sync/internal/shell"
"github.com/PyAgni/apple-notes-syncer/internal/shell"
)

// GitClient performs git operations on the notes repository.
Expand Down
2 changes: 1 addition & 1 deletion internal/gitops/git_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/zap"

"github.com/agni/apple-notes-sync/internal/shell"
"github.com/PyAgni/apple-notes-syncer/internal/shell"
)

// MockCommandExecutor is a testify mock for shell.CommandExecutor.
Expand Down
2 changes: 1 addition & 1 deletion internal/rclone/sync.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (

"go.uber.org/zap"

"github.com/agni/apple-notes-sync/internal/shell"
"github.com/PyAgni/apple-notes-syncer/internal/shell"
)

// Syncer syncs a local directory to a cloud remote via rclone.
Expand Down
2 changes: 1 addition & 1 deletion internal/rclone/sync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import (
"github.com/stretchr/testify/require"
"go.uber.org/zap"

"github.com/agni/apple-notes-sync/internal/shell"
"github.com/PyAgni/apple-notes-syncer/internal/shell"
)

// MockCommandExecutor is a testify mock for shell.CommandExecutor.
Expand Down
14 changes: 7 additions & 7 deletions internal/syncer/syncer.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,13 @@ import (

"go.uber.org/zap"

"github.com/agni/apple-notes-sync/internal/applescript"
"github.com/agni/apple-notes-sync/internal/config"
"github.com/agni/apple-notes-sync/internal/converter"
"github.com/agni/apple-notes-sync/internal/filesystem"
"github.com/agni/apple-notes-sync/internal/gitops"
"github.com/agni/apple-notes-sync/internal/model"
"github.com/agni/apple-notes-sync/internal/rclone"
"github.com/PyAgni/apple-notes-syncer/internal/applescript"
"github.com/PyAgni/apple-notes-syncer/internal/config"
"github.com/PyAgni/apple-notes-syncer/internal/converter"
"github.com/PyAgni/apple-notes-syncer/internal/filesystem"
"github.com/PyAgni/apple-notes-syncer/internal/gitops"
"github.com/PyAgni/apple-notes-syncer/internal/model"
"github.com/PyAgni/apple-notes-syncer/internal/rclone"
)

// Syncer orchestrates the full Apple Notes sync pipeline.
Expand Down
Loading
Loading