Skip to content

davidajacobus/notes2onenote

Repository files navigation

notes2onenote

Automated migration from Apple Notes to Microsoft OneNote — no copy-paste, no screen macros.

Apple Notes (AppleScript) → HTML export → Graph API cleanup → Microsoft OneNote

Table of Contents


Overview

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.


How It Works

Pipeline

┌─────────────────────────────────────────────────────────────────────┐
│  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                      │
└─────────────────────────────────────────────────────────────────────┘

Key design decisions

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.


Requirements

  • 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)

lxml is listed in requirements.txt as an optional but recommended faster HTML parser used by BeautifulSoup4. It installs automatically with pip 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.


Setup

Step 1: Set up the project folder

Option A — Git clone (recommended)

git clone https://github.com/YOUR_USERNAME/notes2onenote.git
cd notes2onenote

Option B — Zip download

If you received the project as a zip file, unzip it and navigate into the folder:

cd ~/Downloads/notes2onenote

Either 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.yaml and token_cache.json — contain sensitive credentials. They are already listed in .gitignore and must never be committed or shared. Treat token_cache.json like a password file: it grants access to your Microsoft account without a login prompt.

Step 2: Create a Python virtual environment

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.txt

If python3.13 is not found, install it via Homebrew: brew install python@3.13

Step 3: Register an Azure App (one-time, ~5 min)

This is needed so the tool can authenticate with Microsoft on your behalf. It's free and takes about 5 minutes.

  1. Go to https://portal.azure.com and sign in with your Microsoft account.

  2. Search for "App registrations" and click New registration.

  3. 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
  4. Click Register.

  5. On the app overview page, copy the Application (client) ID. You'll paste this into config.yaml.

  6. In the left sidebar, click API permissionsAdd a permissionMicrosoft GraphDelegated permissions.

  7. Search for and add:

    • Notes.Create
    • Notes.ReadWrite
    • User.Read
  8. 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.

  9. In the left sidebar, click Authentication. If you see "Welcome to the new experience", click "To switch to the old experience, please click here".

  10. Click Add a platformMobile and desktop applications.

    • Check the box for https://login.microsoftonline.com/common/oauth2/nativeclient
    • Click Configure
  11. 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 causes AADSTS70002 errors.

Step 4: Configure the project

cp config.yaml.example config.yaml

Open 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 causes AADSTS50059 errors with this app configuration.

Everything else has sensible defaults. See Configuration Reference for all options.

Step 5: Grant Notes.app access on macOS

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:

  1. Go to System Settings → Privacy & Security → Automation.
  2. Find the entry for your terminal app (Terminal, iTerm2, etc.).
  3. 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.

Step 6: Run a dry run first

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 --verbose

Check the output in /tmp/notes2onenote/clean/ — you should see your notes as clean HTML files organised by folder.

Step 7: Run the full migration

source venv/bin/activate   # if not already active from Step 6
python migrate.py

The first time you run this:

  1. The script will print a URL and a short code.
  2. Open the URL in your browser and enter the code.
  3. Sign in with your Microsoft account and approve the permissions.
  4. 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.


Command-Line Options

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)

Examples

# 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 --verbose

Configuration Reference

All 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

Code Architecture

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

Module breakdown

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-data request, avoiding the API's HTML body size limit.
  • Rate limiting: on 429 responses, reads the Retry-After header 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).

Structure Mapping

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".


Limitations & Known Issues

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.


Scheduling Automated Runs (Optional)

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.plist

Logs are written to migration.log and migration_err.log in the project directory.


Troubleshooting

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:

  1. Go to your app registration → Authentication
  2. Click Add a platformMobile and desktop applications
  3. Check https://login.microsoftonline.com/common/oauth2/nativeclientConfigure
  4. Set Allow public client flows to YesSave

Authentication fails — general checklist

  • Confirm client_id in 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:

  1. Delete the "Apple Notes Import" notebook from OneNote entirely (right-click → Delete in the OneNote app).
  2. 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.

About

Automated migration from Apple Notes to Microsoft OneNote via Microsoft Graph API

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors

Languages