Automated migration from Apple Notes to Microsoft OneNote — no copy-paste, no screen macros.
Apple Notes (AppleScript) → HTML export → Graph API cleanup → Microsoft OneNote
- Overview
- How It Works
- Requirements
- Setup
- Command-Line Options
- Configuration Reference
- Code Architecture
- Structure Mapping
- Limitations & Known Issues
- Scheduling Automated Runs
- Troubleshooting
Apple Notes has no built-in export for OneNote, and there is no direct migration tool. The common workarounds (copy-paste, screen macros, intermediary services) are slow, fragile, and don't scale.
notes2onenote takes a fully automated engineering approach:
- Reads your Apple Notes directly through the macOS AppleScript bridge
- Exports each note as a clean HTML file with folder structure preserved
- Cleans the HTML to be compatible with Microsoft Graph API
- Authenticates with Microsoft via a standard OAuth2 device-code flow
- Creates notebooks, sections, and pages in OneNote via the Graph API
After the initial browser sign-in (which happens once), the entire pipeline runs headlessly with a single command.
┌─────────────────────────────────────────────────────────────────────┐
│ STEP 1: EXPORT │
│ │
│ Apple Notes (NoteStore.sqlite) │
│ └── macnotesapp (Python → AppleScript bridge) │
│ └── HTML files per note, folders preserved │
│ /tmp/notes2onenote/html/{folder}/{note}.html │
└──────────────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────────────────▼──────────────────────────────────┐
│ STEP 2: CLEAN │
│ │
│ Apple HTML → Graph API-compatible HTML │
│ • Strips unsupported tags (script, style, SVG, Apple-proprietary) │
│ • Rewrites file:/// image references → base64 data URIs │
│ • Removes inline styles and Apple-specific attributes │
│ • Normalises charset declarations │
│ /tmp/notes2onenote/clean/{folder}/{note}.html │
└──────────────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────────────────▼──────────────────────────────────┐
│ STEP 3: AUTHENTICATE │
│ │
│ OAuth2 device-code flow via MSAL │
│ • First run: open a URL in your browser, enter a short code │
│ • Token cached to token_cache.json for all future runs │
│ • No password or secret stored in config │
└──────────────────────────────────┬──────────────────────────────────┘
│
┌──────────────────────────────────▼──────────────────────────────────┐
│ STEP 4: IMPORT │
│ │
│ Microsoft Graph API (https://graph.microsoft.com/v1.0/me/onenote) │
│ • Get or create target notebook │
│ • Get or create one section per Apple Notes folder │
│ • POST each note as an HTML page into its section │
│ • Automatic retry on 429 rate-limit responses │
└─────────────────────────────────────────────────────────────────────┘
Why HTML directly, not Markdown?
The OneNote Graph API natively accepts HTML input (multipart/form-data with a Presentation part). Converting to Markdown and back would degrade formatting. Sending HTML is the cleanest one-step path.
Why device-code auth, not a client secret? Client secrets stored in config files are a security anti-pattern. The device-code flow lets you authenticate interactively once. MSAL caches the resulting token locally — no secret ever touches disk.
Why macnotesapp instead of direct SQLite parsing?
Apple Notes' NoteStore.sqlite stores note content as gzip-compressed Protocol Buffers blobs. The format is undocumented and changes between macOS versions. macnotesapp wraps the stable AppleScript interface that Apple maintains, which is far more reliable across OS updates.
Why extract images as separate multipart parts?
The Graph API enforces a strict size limit on the HTML body of a page POST. Notes with several large images exceed this limit when images are inlined as base64 data: URIs. The correct approach — and what the importer does — is to extract each image from the HTML, replace its src with a name:imageN reference, and send the image binary as a separate named part in the same multipart/form-data request. The cleaner still converts local file:/// image paths to base64 as an intermediate form; the importer re-encodes them to binary before sending.
- macOS (Ventura 13+ recommended)
- Python 3.11–3.13 (Python 3.14+ is not yet supported by
macnotesapp) - Notes.app with your notes present locally (iCloud sync must be enabled if using iCloud)
- Microsoft account (personal, M365, or work/school) with access to OneNote
- An Azure App Registration (free — see Step 3)
lxmlis listed inrequirements.txtas an optional but recommended faster HTML parser used by BeautifulSoup4. It installs automatically withpip install -r requirements.txt. If it fails to compile on your system, BeautifulSoup4 will fall back to its built-in parser without any functional difference.
Option A — Git clone (recommended)
git clone https://github.com/YOUR_USERNAME/notes2onenote.git
cd notes2onenoteOption B — Zip download
If you received the project as a zip file, unzip it and navigate into the folder:
cd ~/Downloads/notes2onenoteEither way, confirm the files are present before continuing:
ls
# Expected: migrate.py exporter.py cleaner.py importer.py
# config.yaml.example requirements.txt README.md scripts/
⚠️ Security note: Two files created during setup —config.yamlandtoken_cache.json— contain sensitive credentials. They are already listed in.gitignoreand must never be committed or shared. Treattoken_cache.jsonlike a password file: it grants access to your Microsoft account without a login prompt.
Important: Use Python 3.13 explicitly. Python 3.14+ is not yet compatible with
macnotesapp.
python3.13 -m venv venv
source venv/bin/activate
pip install -r requirements.txtIf python3.13 is not found, install it via Homebrew: brew install python@3.13
This is needed so the tool can authenticate with Microsoft on your behalf. It's free and takes about 5 minutes.
-
Go to https://portal.azure.com and sign in with your Microsoft account.
-
Search for "App registrations" and click New registration.
-
Fill in the form:
- Name:
notes2onenote(or anything you like) - Supported account types: Accounts in any organizational directory and personal Microsoft accounts (Multitenant)
⚠️ This must be Multitenant. Single tenant will fail with authentication errors. - Redirect URI: leave blank for now
- Name:
-
Click Register.
-
On the app overview page, copy the Application (client) ID. You'll paste this into
config.yaml. -
In the left sidebar, click API permissions → Add a permission → Microsoft Graph → Delegated permissions.
-
Search for and add:
Notes.CreateNotes.ReadWriteUser.Read
-
Click Add permissions, then Grant admin consent (if prompted — required for some org accounts).
Personal Microsoft accounts (outlook.com, hotmail.com, live.com): admin consent is not required. You'll simply be asked to approve the permissions when you sign in.
-
In the left sidebar, click Authentication. If you see "Welcome to the new experience", click "To switch to the old experience, please click here".
-
Click Add a platform → Mobile and desktop applications.
- Check the box for
https://login.microsoftonline.com/common/oauth2/nativeclient - Click Configure
- Check the box for
-
Back on the Authentication page, scroll down to Advanced settings and set Allow public client flows to Yes. Click Save.
⚠️ Steps 10–11 are required for the device-code flow to work. Skipping them causesAADSTS70002errors.
cp config.yaml.example config.yamlOpen config.yaml in your editor and fill in at minimum:
client_id: "PASTE_YOUR_CLIENT_ID_HERE" # from Step 3
# Personal Microsoft accounts (live.com, outlook.com, hotmail.com):
tenant_id: "consumers"
# Work/school Microsoft 365 accounts:
# tenant_id: "your-org-tenant-id"Which tenant_id do I use?
consumers— for personal accounts (live.com, outlook.com, hotmail.com, xbox.com)- Your organisation's tenant ID — for work/school M365 accounts (find it in Azure portal → Azure Active Directory → Overview → Tenant ID)
- Do not use
common— it causesAADSTS50059errors with this app configuration.
Everything else has sensible defaults. See Configuration Reference for all options.
macOS must grant your terminal permission to control Notes.app. This dialog only appears the first time the script tries to access Notes — it will not appear until you run the script in Step 6. When it does, click OK.
If the dialog never appears, or you accidentally denied it:
- Go to System Settings → Privacy & Security → Automation.
- Find the entry for your terminal app (Terminal, iTerm2, etc.).
- Enable the toggle for Notes.
Note: The Notes entry only appears in System Settings after the script has attempted access at least once. If it's not there yet, just run Step 6 and approve the dialog when it pops up.
This exports and cleans your notes but does not upload anything to OneNote. Use this to verify the export pipeline and inspect the output before committing.
source venv/bin/activate
python migrate.py --dry-run --verboseCheck the output in /tmp/notes2onenote/clean/ — you should see your notes as clean HTML files organised by folder.
source venv/bin/activate # if not already active from Step 6
python migrate.pyThe first time you run this:
- The script will print a URL and a short code.
- Open the URL in your browser and enter the code.
- Sign in with your Microsoft account and approve the permissions.
- The terminal will detect your login and begin uploading.
On all subsequent runs, the cached token is used — no browser interaction required.
Open OneNote. Your notes will appear in a notebook called "Apple Notes Import" (or whatever name you set in config.yaml), with each Apple Notes folder as a section and each note as a page.
python migrate.py [OPTIONS]
Options:
--folder TEXT Export only notes from this Apple Notes folder name
Default: all folders
--notebook TEXT Target OneNote notebook name
Default: value from config.yaml, or "Apple Notes Import"
--dry-run Export and clean HTML, but skip uploading to OneNote
--skip-export Skip export and clean steps — upload already-cleaned HTML
from clean_dir. Useful if auth failed mid-run and the
temp files are still present.
--keep-tmp Keep /tmp/notes2onenote/ after migration (for inspection)
--verbose, -v Show detailed per-note debug logging
--config PATH Path to config file (default: config.yaml)
# Migrate everything
python migrate.py
# Migrate a single folder — useful for testing first
python migrate.py --folder "Work Notes" --verbose
# Export and clean only — inspect before uploading
python migrate.py --dry-run --keep-tmp
# Import into a specific notebook name
python migrate.py --notebook "Imported from Apple"
# Full debug run keeping all temp files
python migrate.py --verbose --keep-tmp
# Re-run upload only (auth failed last time, temp files still exist)
python migrate.py --skip-export --verboseAll settings live in config.yaml. The file is YAML — indentation doesn't matter, but colons and quotes do.
| Key | Required | Default | Description |
|---|---|---|---|
client_id |
✅ | — | Azure App client ID from Step 3 |
tenant_id |
✅ | — | Use consumers for personal accounts (live.com, outlook.com). Use your org's tenant ID for work/school accounts |
notebook_name |
❌ | Apple Notes Import |
OneNote notebook to create or import into |
export_dir |
❌ | /tmp/notes2onenote/html |
Temporary directory for raw HTML export |
clean_dir |
❌ | /tmp/notes2onenote/clean |
Temporary directory for cleaned HTML |
rate_limit_delay |
❌ | 0.5 |
Seconds between Graph API calls. Increase to 1.0 if you see 429 errors |
notes2onenote/
├── migrate.py Main entry point — orchestrates all steps
├── exporter.py Step 1: Apple Notes → HTML via macnotesapp/AppleScript
├── cleaner.py Step 2: Clean HTML for Graph API compatibility
├── importer.py Steps 3–4: Auth (MSAL) + upload to OneNote (Graph API)
├── config.yaml Your local config (git-ignored)
├── config.yaml.example Template — copy this to config.yaml
├── requirements.txt Python dependencies
├── token_cache.json OAuth2 token cache (auto-created, git-ignored)
├── migration.log Full log of the last migration run
└── scripts/
└── com.notes2onenote.migrate.plist Optional launchd agent
migrate.py
The CLI entry point. Parses arguments, loads and validates config, then calls each step in sequence. Handles cleanup of temp directories at the end.
exporter.py
Uses the macnotesapp library (a Python wrapper over the macOS AppleScript Notes bridge) to iterate every account → folder → note. Each note's HTML body is wrapped in a proper HTML document and written to disk, with folder structure preserved as subdirectories. Filename conflicts are resolved by appending a counter.
cleaner.py
Uses BeautifulSoup4 to parse and transform Apple Notes HTML:
- Decomposes unsupported tags completely (
script,style,svg, etc.) - Unwraps unrecognised tags (content kept, wrapper tag removed)
- Converts local
file:///image references to base64 data URIs so images survive the upload - Strips Apple-proprietary attributes and inline styles
- Normalises charset meta tags
- Removes broken local links (keeps text)
importer.py
Handles authentication using MSAL's device-code flow and persists the token in token_cache.json. On successful auth, walks the cleaned HTML directory and for each note: gets or creates the target notebook and section via Graph API, then POSTs the HTML as a OneNote page.
Key behaviours:
- Duplicate detection: before uploading into a section, fetches all existing page titles via the Graph API (following pagination with
@odata.nextLink) and skips any note whose<title>tag already exists in OneNote. This makes re-runs safe. - Image handling: extracts base64 images from the HTML and sends each as a separate named part in the
multipart/form-datarequest, avoiding the API's HTML body size limit. - Rate limiting: on 429 responses, reads the
Retry-Afterheader and waits the server-specified seconds before retrying. - Token refresh: on 401 responses mid-run, silently re-acquires a fresh token via MSAL's refresh-token flow and retries the failed request — no re-authentication prompt needed.
- 409 Conflict: treated as "already exists" (counted as skipped, not as an error).
| Apple Notes | OneNote |
|---|---|
| Account | (not mapped — all accounts merged) |
| Folder | Section (inside target notebook) |
| Note | Page (inside matching section) |
| Note title | Page title (from <title> tag) |
| Note body (HTML) | Page content (cleaned HTML) |
| Inline images | Embedded images (sent as separate multipart parts) |
Notes not in any folder (top-level) are placed in a section called "Unsorted".
Attachments other than images PDF, audio, video, and other file attachments in Apple Notes are not carried across. The OneNote Graph API requires binary attachments to be uploaded as separate multipart blocks, which significantly complicates the pipeline. Images are handled via base64 inline embedding.
Locked notes Password-protected Apple Notes cannot be exported via AppleScript. They will be skipped with a warning in the log.
Handwriting and drawings Apple Pencil sketches stored in Apple Notes are binary objects with no HTML representation and will not survive the migration.
Tables Basic HTML tables are preserved. Complex nested tables or Apple's custom table format may flatten.
Shared notes Notes shared with you by others are visible in Notes.app and will be exported as long as they are synced locally. Shared note metadata (sharer identity, collaboration history) is not preserved.
OneNote page size limit The Graph API has a 25 MB limit per page. Images are sent as separate multipart parts (not inline in the HTML) so image-heavy notes work correctly. Notes exceeding 25 MB total — including all image data — will still fail. The script logs these as errors and continues.
iCloud sync must be current The export reads Notes.app as it appears on your Mac. If a note exists only in iCloud and hasn't synced down, it won't be exported. Open Notes.app and wait for sync to complete before running the migration.
After your first migration and successful token cache, subsequent runs require no interaction. You can schedule the migration to run automatically using macOS launchd.
# Edit the plist — replace YOUR_USERNAME with your actual username
nano scripts/com.notes2onenote.migrate.plist
# Copy to LaunchAgents
cp scripts/com.notes2onenote.migrate.plist ~/Library/LaunchAgents/
# Load the agent (starts scheduling)
launchctl load ~/Library/LaunchAgents/com.notes2onenote.migrate.plist
# Test it runs immediately
launchctl start com.notes2onenote.migrate
# To disable
launchctl unload ~/Library/LaunchAgents/com.notes2onenote.migrate.plistLogs are written to migration.log and migration_err.log in the project directory.
macnotesapp can't access Notes — Permission denied
Go to System Settings → Privacy & Security → Automation and enable Notes for Terminal (or whatever shell you're using).
ModuleNotFoundError: No module named 'macnotesapp'
Make sure your virtual environment is activated: source venv/bin/activate
AADSTS50059: No tenant-identifying information found
You are using common as the tenant ID. Change it to consumers (personal accounts) or your org's tenant ID (work accounts) in config.yaml.
AADSTS700016: Application not found in directory 'Microsoft Accounts'
Your app registration was created as Single tenant. Delete it and re-register, choosing "Accounts in any organizational directory and personal Microsoft accounts (Multitenant)" as the supported account type.
AADSTS9002346: Application is configured for use by Microsoft Account users only — use /consumers endpoint
Change tenant_id to consumers in config.yaml.
AADSTS70002: The client application must be marked as 'mobile'
The app is missing the Mobile/desktop platform configuration. In the Azure portal:
- Go to your app registration → Authentication
- Click Add a platform → Mobile and desktop applications
- Check
https://login.microsoftonline.com/common/oauth2/nativeclient→ Configure - Set Allow public client flows to Yes → Save
Authentication fails — general checklist
- Confirm
client_idin config.yaml matches the Application ID in Azure portal. - Confirm the API permissions (
Notes.Create,Notes.ReadWrite,User.Read) have been added. - Confirm the app is registered as Multitenant (not Single tenant).
- Confirm the Mobile/desktop platform and public client flows are enabled (see above).
429 Too Many Requests errors
Increase rate_limit_delay in config.yaml to 1.0 or higher. The script already retries on 429, but a longer delay prevents hitting the limit in the first place.
Notes appear in OneNote but images are missing
Apple Notes stores some images as references to the local Notes database, not as standard file paths. If base64 encoding fails for those, the image is omitted. Run with --verbose to see which images couldn't be resolved.
No HTML files found after export
Run --dry-run --keep-tmp --verbose and check whether any .html files appear in /tmp/notes2onenote/html/. If not, the AppleScript export may have produced zero notes — this usually means Notes.app didn't have the notes synced locally.
Duplicate notes appear in OneNote This happens if the migration ran multiple times (e.g., after repeated auth failures). The importer now checks for existing page titles and skips duplicates automatically. If you already have duplicates:
- Delete the "Apple Notes Import" notebook from OneNote entirely (right-click → Delete in the OneNote app).
- Run a fresh migration:
python migrate.py --verbose
Token cache expired / need to re-authenticate
Delete token_cache.json and run the migration again. You'll be prompted to sign in once more.