I had been a 1Password customer for years. My family and I used it to share passwords, and I kept separate vaults for different parts of my life. Those vaults held much more than logins: Secure Notes, Documents, files attached to accounts, and passwords with active verification codes. I knew where everything lived and trusted that setup.
Then the renewal came around. 1Password had raised its price, and I understand that a service needs to pay for the work behind it. Still, Apple Passwords was built into the devices I already used. Paying for another year of 1Password became harder to justify, so I decided to leave.
The snag was getting out one vault at a time. 1Password 8 exported my account, with all its vaults inside the same .1pux file; it did not offer a separate export for each vault. Importing everything into Apple Passwords in one pass would have put private and family logins together in All before I had worked out which ones to share. I would also have had to track down the Secure Notes and files that Passwords could not take.
I wanted to finish one vault before starting the next: import its logins, set up the right Shared Groups for my family, test passwords and verification codes, and account for its notes and attachments. I used ChatGPT to help build 1password-to-apple-v3.py for that job. It reads the account-wide .1pux export, lets me choose one vault inside it, and creates an Apple Passwords import file containing only eligible logins from that vault, along with reports and extracted files. I handle the import and checks myself, then run the script again for another vault. The Terminal examples below use sample names and counts from my brief; they show the script’s prompts, not a recording of my own vault.
Where the vaults go
A 1Password vault is a container for different item types and can also define who has access. Apple Passwords presents passwords, passkeys, verification codes, Wi-Fi credentials, and shared groups in its sidebar. Its All view is a credential collection, not a new private vault for every imported CSV. Apple’s Passwords overview shows that arrangement. The importer does not recreate 1Password vault boundaries from this script’s CSV, because the CSV has no vault field and Apple’s import flow does not offer a source-vault mapping step.
I use the source vault as a unit of work. The script makes one import file for Personal; I import and check those logins, make any sharing decisions, and then run it again for Work. Apple Passwords puts imported logins in its normal collection, so the old vault name does not become a private folder there. I move credentials into a Shared Group only when I want other people to have access. Group members can use and change shared credentials, and Apple allows a credential in only one shared group.
Apple Passwords has a text Notes field on a login, so I can keep written instructions with the credential. It has no place for a file attached to that login. Apple documents the Notes field; I handle files and 1Password Secure Notes outside Passwords.
| In 1Password | Destination in this workflow | What still needs my decision |
|---|---|---|
| Website Login | Apple Passwords entry from CSV | Test website matching, password, and duplicate handling. |
| TOTP seed on a Login | OTP CSV field, then a Passwords verification code |
Compare live codes and test a sign-in. |
| Login text notes | Passwords Notes field | Check formatting and any custom fields the CSV did not carry. |
| Secure Note | Apple Note in a migration folder, if I approve creation | Confirm the Notes account, content, and whether it can and should be locked. |
| Document or attachment | Extracted local file; optional Document note stub | Choose storage, attach manually if appropriate, verify the file and its protection. |
| Passkey | No transfer through the desktop 1PUX script | Use a supported transfer path or create a new passkey and test it. |
| 1Password vault | One local output directory and review cycle | Imported credentials enter Passwords’ collection; move only intended items into a Shared Group. |
Export the account from 1Password
I used a Mac with 1Password 8, Apple Passwords, Apple Notes, and Python 3. The script is included below as 1password-to-apple-v3.py. Its AppleScript can create Notes after I approve the prompt, so I read that part before running it. A small vault or test export is a sensible place to begin.
In 1Password on Mac, I unlock the app, choose File > Export, choose the account, enter the account password, select 1Password Unencrypted Export (.1pux), and save it locally. Those are 1Password’s current Mac export steps. The export is per account in 1Password 8; if I have multiple accounts, I repeat the export for each account. A 1PUX file contains vault and item data for that account, not a live connection back to 1Password. The script later offers the active vaults it finds inside that exported snapshot.
The 1PUX file contains plaintext secrets. The CSV and extracted files will too. I keep them on a trusted Mac, away from shared and synced folders, and leave identifying details out of any screenshots. 1Password warns against storing the export online.
Choosing one vault in Terminal
Save the Python source at the end of this article as 1password-to-apple-v3.py. With the script and export in Downloads, I run:
cd "$HOME/Downloads"
python3 1password-to-apple-v3.py "$HOME/Downloads/YourExport.1pux"The script checks that it is running on macOS, opens the 1PUX ZIP, reads export.data, and prints “No secrets will be printed.” It lists vaults with active items. A vault named Migrated appears for context but is marked [EXCLUDED] and gets no selection number. Here is the interface with the example counts from my brief:
Reading 1Password export...
No secrets will be printed.
1Password -> Apple Migration
=======================================================
Available vaults with ACTIVE items:
1. Personal 125
2. Work 87
Migrated 399 [EXCLUDED]
Select ONE vault (or q to quit):I enter 1 for Personal. Typing q leaves without preparing output; an invalid number stops with an error. The next screen is a category summary before any CSV or Note is created:
=======================================================
Vault: Personal
=======================================================
Active items: 125
Logins: [count]
CSV eligible: [count]
Missing URL: [count]
OTP detected: [count]
Multiple URLs: [count]
Login notes: [count]
Secure Notes: [count]
Documents: [count]
Other/manual review: [count]
Prepare migration output for 'Personal'? [y/N]The bracketed counts are placeholders. CSV eligible counts Logins with a URL. Missing URL and Other/manual review stay out of the CSV. I compare these totals with the vault in 1Password before answering the prompt. If I expected Documents and see zero, I answer n and check the export. Pressing Return also means no.
What’s in the output folder
After I answer y, the script creates ~/Downloads/1Password-Migration/Personal/. It stops if that directory already exists. In Finder, I expect to see:
1Password-Migration/
└── Personal/
├── Apple-Passwords.csv # only if eligible logins exist
├── Review.html
├── Migration-Summary.txt
├── Passkey-Workflow.html
└── Documents/ # when Secure Notes or Documents are presentThe CSV contains Title,URL,Username,Password,Notes,OTP. The script takes the first website URL for a Login, its username and password, plain-text notes, and a supported TOTP value. A Login with no URL is excluded; one with multiple URLs is flagged for review because the extra sites are not faithfully represented by the one URL column. Custom fields, structured item types, and attached files do not become equivalent Passwords fields. Review.html and Migration-Summary.txt tell me which items need attention. I read those files before importing, including any attachment-extraction failures.
The script handles Logins, Secure Notes, and Documents in different ways. It puts other categories, including a standalone 1Password Password item, credit card, identity, bank account, server record, or an unfamiliar category ID, in manual review. For a Login, it chooses the URL marked primary or the first URL, then looks for the first section field containing a totp value. It also counts structured fields and detects some OTP-related material elsewhere in the item, but those fields do not automatically make it into the CSV. I check them against 1Password.
I open Review.html to see the item list. Its three columns are Item, Type, and Migration status / review. A Login may be marked 2 URLs, OTP, Notes, or NO URL; an ordinary Login says “Standard login.” Secure Note rows say “Apple Notes,” Document rows show the number of file references, and other categories say “Manual review.” The page omits usernames, passwords, and OTP secrets. I use it to find items that need attention before I touch the originals.
An illustrative slice of that local page would read:
| Item | Type | Migration status / review |
|---|---|---|
| Example Shop | Login | 2 URLs, OTP, Notes, 1 structured field(s) |
| Local Router | Login | NO URL |
| Recovery Kit | Secure Note | Apple Notes |
| Travel Records | Document | 2 attachment(s) |
| Corporate Card | Credit Card | Manual review |
The script writes this report before it asks about Notes. A row marked “Apple Notes” says where that Secure Note could go; it does not confirm that a Note exists. “2 attachment(s)” means the script found two file references, which I still have to match against the extracted files.
Migration-Summary.txt adds the category totals and item titles for missing-URL Logins, multi-URL Logins, Logins with exported OTP values, and manual-review categories. It likewise omits OTP secrets. The report may contain sensitive titles, so I do not treat it as public just because it excludes passwords. A missing-URL Login stays in the original vault until I decide whether to add a website and handle it manually. A multi-URL Login needs a website-by-website check after import. A custom-field count tells me to open that original item and compare its extra data; the script has not recreated a custom field in Passwords.
The exception list in Migration-Summary.txt looks like this:
Vault: Personal
Logins missing URL: [count]
Multiple URL logins: [count]
Secure Notes: [count]
Documents: [count]
Other/manual review: [count]
Logins excluded because they have no URL:
- Local Router
Logins with multiple URLs:
- Example Shop: 2 URLs
Logins with OTP data detected:
- Example Shop
OTP secrets were NOT printed in this report.
Items requiring manual review:
- Corporate Card [Credit Card]The names and counts here are samples. The summary also reminds me that I still have to import the CSV into Passwords.
The output directory name is the source vault’s name. It does not create a Personal group inside Apple Passwords. Also, Passkey-Workflow.html scans candidates across all vaults and states, including Migrated, so its count should not be added to the selected vault’s active-item total. I treat it as a separate passkey checklist.
The script prints the output path and number of Logins written, followed by *** NOT IMPORTED ***. It has not opened Passwords or changed 1Password. If it found Secure Notes or Documents with extracted files, it asks about creating Notes:
APPLE NOTES
[count] note(s) can be created in:
1Password Migration - Personal
Create these notes automatically in Apple Notes? [y/N]Before I answer, I check the account list in Notes. The AppleScript uses the first account; this prompt does not let me choose iCloud or On My Mac. If the first account is wrong, I answer no and change the script or move the notes by hand. On My Mac notes stay on that Mac.
If I answer yes, the script creates a folder 1Password Migration - Personal, creates notes from Secure Note text, and creates stubs for Document items with extracted files. It prints created and failed counts. It does not attach the files to those Notes automatically; that was disabled after a duplicate-attachment test. If I answer no, the CSV and reports still exist, and the script says Notes were not modified. Either way, the terminal ends with the review and passkey-report paths and reminds me that 1Password and Apple Passwords were not changed by the script.
At the end of the run, Terminal points me to the next files:
Migration preparation complete.
=======================================================
Output:
~/Downloads/1Password-Migration/Personal
APPLE PASSWORDS
[count] login(s) written to:
~/Downloads/1Password-Migration/Personal/Apple-Passwords.csv
*** NOT IMPORTED ***
You control the import manually in Passwords.app.
REVIEW
~/Downloads/1Password-Migration/Personal/Review.html
~/Downloads/1Password-Migration/Personal/Migration-Summary.txt
PASSKEY WORKFLOW
[count] candidate(s) across ALL vaults/states
~/Downloads/1Password-Migration/Personal/Passkey-Workflow.html
1Password was NOT modified.
Apple Passwords was NOT modified.The real screen uses the full path instead of ~. It also shows attachment results, any Notes failures, and a warning about plaintext files. I read those lines before closing Terminal.
The script tries to set its generated directories to owner-only access and generated files to owner read/write. Those permission changes are best-effort; it ignores a chmod failure. I still inspect where the files were written and treat the whole folder as plaintext sensitive data. If the selected vault has no eligible Logins, there is no CSV and no APPLE PASSWORDS result block; I work from the review and Notes/file lanes instead.
Where the document files land
While it prepares the reports, the script also scans each Secure Note and Document in the selected vault for file fields. It creates a subfolder under Documents/ using the item’s title, then looks for each referenced file inside the 1PUX archive. It tries the recorded contentPath or path first. If those fail, it looks for a unique document-ID match, preferring a path under files/ when that resolves the match. The script copies what it finds into the item’s folder without another prompt.
With the sample items from the report above, the Finder folder would look like this:
~/Downloads/1Password-Migration/Personal/Documents/
├── Recovery Kit/
│ └── recovery.pdf
├── Travel Records/
│ ├── passport-scan.pdf
│ └── itinerary.pdf
└── Empty Document/The Recovery Kit folder could come from an attachment field on a Secure Note; Travel Records could be a Document item. The script sanitizes slashes, colons, control characters, and overlong names for filesystem use. If two attachments in one item would have the same filename, it keeps both by adding -2, -3, and so on. If two source items share a title, however, they can share the same output subfolder. I compare files to their originals instead of trusting the folder name alone. An Empty Document folder can appear because the script creates the per-item folder before it has found any extractable file references.
Terminal prints an EXTRACTED ATTACHMENTS line for each Secure Note and Document it processed:
EXTRACTED ATTACHMENTS
Recovery Kit: 1 extracted
Travel Records: 2 extracted
Empty Document: 0 extracted
WARNING: 1 attachment(s) could not be located.A warning means the script found a file reference but no matching ZIP member. Terminal gives me a failed-file count, not its path. Review.html counts the Document’s file references, while Terminal counts the files it copied. I compare both numbers with the original item and the files in Finder. If a file is missing, I retrieve it from 1Password by hand.
The extractor looks only in details.sections[].fields[].value.file. It will miss a file stored elsewhere in the export’s structure. I open every file it copied and compare it with the original. The script checks neither hashes nor contents after copying.
It also scans Secure Notes and Documents only. A Login with an attached file can appear in the CSV while its attachment stays behind. I check those Logins in 1Password and move their files by hand.
What the Notes prompt creates
The Notes prompt covers the whole batch. It appears when the vault contains a Secure Note or a Document whose file was extracted. I can answer yes or no, but I cannot select individual items. If I say yes, AppleScript creates or reuses 1Password Migration - Personal in the first Notes account. macOS may ask me to allow Automation access. If a note fails, Terminal prints its title and error; notes created earlier in the batch remain. If I say no, Terminal says “Apple Notes were not modified.”
For a Secure Note, the script copies the plain note body and converts supported fields into labeled text. It leaves file fields out because it already handled those under Documents/. If it finds no readable text, it creates a placeholder note. A concealed field can end up as ordinary text in Notes, so I check the new note against its 1Password original before deciding whether Notes is the right place for it. The script does not lock the note.
For a Document, it creates a short Note only if it extracted at least one file. The note has the item’s title and the number of extracted files; it does not contain the files. The source includes an attachment routine, but the main script does not call it after a duplicate-attachment test. If nothing was extracted, there is no Document note to create. I open the files in Finder, pick their final home, and check them there. Apple Passwords cannot receive them as attachments to a Login.
For the synthetic Travel Records item above, the Note body would say:
Migrated from 1Password.
Extracted attachments: 2
Attachments were extracted locally but not auto-attached because
Notes attachment automation is disabled after the duplicate-attachment test.The files themselves remain in Documents/Travel Records/ until I put them somewhere intentionally. If I decline the Notes prompt, even this stub is not created; the extracted files and reports remain on disk for manual review.
I also check protection. The script creates ordinary Notes. I can select a note and use More > Lock when Apple allows it, but a Note with a PDF, video, audio file, or document attachment cannot be locked under the current Mac guide. Shared notes and notes with tags have limits too. For a 1Password Secure Note with a PDF, I may keep the original, put the text in a lockable Note and the file in another protected location, or choose a different app. If I attach a file to Notes by hand, I open it there before moving the original.
Importing the CSV into Passwords
With the files from this vault accounted for, I open Passwords and choose File > Import Passwords from File. I click Choose File, select Apple-Passwords.csv, and click Import. Apple shows which entries it imported and which it could not. Its import guide says an imported password will not replace an existing Passwords entry. If I import a second CSV later, I still have to check for accounts already there.
The import screen offers Delete [filename] for the plaintext CSV. I keep the source export while I check the result, then remove the temporary CSV, reports, and extracted copies once I have a recovery path. Deleting the CSV alone leaves the 1PUX and other plaintext files on the Mac.
I click All in the sidebar and search for several of the imported websites. For each one I compare the site, username, password, and Notes field with 1Password. I compare Apple’s imported count with CSV eligible, then work through any failed imports alongside Review.html. If Passwords already had an account, I inspect and edit that record myself; a matching row count does not tell me it received the new password.
The import dialog has no “put these in Personal” choice. I keep track of the source vault while I work. When I later import Work, those Logins enter the same Passwords collection. For similarly named accounts, the URL and username tell me which source item I am checking.
Testing the verification codes
The script puts supported TOTP values in the CSV’s OTP column and reports how many it found. I check those accounts before moving anything in 1Password. A missing verification code can lock me out even when the password imported correctly.
I open the same Login in both apps, compare the codes during the same 30-second interval, and try a sign-in that asks for one. I repeat that with several accounts, including the ones I could least afford to lose. If the codes change while I am comparing them, I wait for the next interval. If Passwords shows no code, I leave the original in 1Password and set up the authenticator at the service again. Apple’s Edit > Set Up Code path accepts a setup key.
TOTP is the only second-factor data this CSV attempts to carry. Push approval and hardware keys need separate handling. 1Password says desktop 1PUX exports do not transfer passkeys. Its mobile Credential Exchange path is separate from this Python script. For a passkey account, I create or transfer the destination credential through a supported path, sign in with it, and keep the old credential until I know the new one works.
Passkey-Workflow.html lists Login items across all vaults and states. It uses 1Password’s passkeyAvailable Watchtower metadata to flag sites that may support an upgrade. That flag does not prove I already have a passkey in 1Password. Each row shows the title, vault, state, and an Open site link when a URL exists. I visit the site’s security settings, create a passkey in Apple Passwords, test it, and revoke the old 1Password passkey if there is one.
The local page has Done, Item, 1Password Vault, State, and Website columns. A sample row might read [ ] Example Shop | Personal | active | Open site. With no URL, it says No URL in export. The checkbox stays in that browser’s local storage; it does not update either password app.
Sharing a few of those logins
If my 1Password Family vault held shared Logins, I move those specific credentials into an Apple Passwords Shared Group after import. In Passwords I choose File > New Shared Group, name it, click Add People, choose the contacts, and click Create. Apple lets me select existing credentials to move. Later I can open the group, click Add Items, select more entries, and click Move. Apple documents both paths.
I check the recipients before moving anything. Members can use and change the shared credentials, and one credential can belong to only one Shared Group. A Login from a private source vault stays in All; I do not turn it into a shared item just to preserve a familiar vault label.
The script’s 1Password Migration - <vault> folder lives in Notes, with its own sharing rules. Apple does not allow sharing a folder that contains locked notes. I decide who should see those notes separately from who can use the Logins in a Passwords Shared Group.
Handling a partial run
The script asks me to choose a vault, confirm output preparation, and, when it has note candidates, approve Notes creation. Both yes/no prompts default to no; only y or yes proceeds. It has no per-item picker. I choose Passwords groups, Notes accounts, and final file locations outside the script.
It stops before writing output if I run it off macOS, leave out the 1PUX path, point to a missing or invalid export, or choose an invalid vault number. No selectable active vaults also ends the run. Answering no at the preparation prompt prints “No changes made.” If ~/Downloads/1Password-Migration/<vault-name>/ already exists, the script refuses to overwrite it. I check that folder and any Notes from the prior run before I rename or remove it; a rerun will not deduplicate Notes.
With no eligible Logins, it writes reports but no Passwords CSV. Unknown Secure Note field values may appear as serialized text in the new Note, so I inspect them. A Notes Automation error may leave some notes created and others missing. A missing attachment produces a warning, and I retrieve that file from 1Password. The original items stay where they were in all of these cases.
A file-copy or disk-write error can stop the run after it has created part of the output folder. The next run will hit the overwrite guard. I inspect the partial files before clearing that folder and starting over.
Moving verified items to Migrated
I move an item only after checking its destination. That means a sign-in for a Login, a working code for a TOTP account, the right text and account for a Secure Note, and a readable file for a Document. Passkeys get a sign-in test of their own. Items that fail a check stay in the original vault.
In 1Password on Mac I drag verified items to Migrated, or select them and use Edit > Move Item. 1Password documents both methods. I move only the items I checked. The script does not create the vault or move items; it ignores Migrated when I process the next export.
I take a new 1PUX export before the next pass. The old file is a snapshot from before I moved anything to Migrated; rerunning it would present the old vault contents again. If I need to reprocess the same source vault, I deal with its existing output folder first. Vaults with the same name in different accounts also share that folder path, so I keep their exports and results separate.
I keep the 1Password account available until its Logins, Notes, files, passkeys, and manual-review items have destinations I can use. Then I remove the plaintext export and working files. Deleting a file from an SSD does not promise physical erasure, which is why I kept those files off shared and synced storage in the first place.
The script below is the one I built for this move. It prepares a vault and shows what needs attention, while I make the changes in Passwords, Notes, and 1Password. Read the code before using it with your own export; it has not had a formal security audit.
Full Python source: 1password-to-apple-v3.py
#!/usr/bin/env python3
"""
1Password -> Apple Migration Helper
IMPORTANT:
- Reads a 1Password .1pux export.
- Processes ONE vault at a time.
- ACTIVE items only.
- Refuses to process a vault named "Migrated".
- Generates an Apple Passwords CSV.
- DOES NOT import anything into Apple Passwords.
- Can create Apple Notes for Secure Notes / Documents after confirmation.
- DOES NOT modify 1Password.
- Generates a passkey-upgrade HTML workflow with clickable site URLs.
- DOES NOT use the network itself; URLs open only when you click them.
Tested/designed for 1Password 8 1PUX exports on macOS.
"""
import csv
import html
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
from urllib.parse import urlparse
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
MIGRATED_VAULT_NAME = "Migrated"
# Categories observed / documented in 1PUX.
CATEGORY_LOGIN = "001"
CATEGORY_SECURE_NOTE = "003"
CATEGORY_PASSWORD = "005"
CATEGORY_DOCUMENT = "006"
CATEGORY_NAMES = {
"001": "Login",
"002": "Credit Card",
"003": "Secure Note",
"004": "Identity",
"005": "Password",
"006": "Document",
"100": "Bank Account",
"101": "Database",
"102": "Driver License",
"103": "Email Account",
"104": "Membership",
"105": "Outdoor License",
"106": "Passport",
"107": "Rewards Program",
"108": "Social Security Number",
"109": "Router",
"110": "Server",
"111": "Wireless Router",
}
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def die(message):
print(f"\nERROR: {message}\n", file=sys.stderr)
sys.exit(1)
def safe_name(value):
"""Return a filesystem-safe name."""
value = str(value or "Untitled")
value = value.replace("/", "-").replace(":", "-")
value = re.sub(r"[\x00-\x1f]", "", value)
value = value.strip()
if not value:
value = "Untitled"
return value[:150]
def yes_no(prompt, default=False):
suffix = " [Y/n] " if default else " [y/N] "
try:
answer = input(prompt + suffix).strip().lower()
except (EOFError, KeyboardInterrupt):
print()
return False
if not answer:
return default
return answer in ("y", "yes")
def chmod_private(path, directory=False):
try:
os.chmod(path, 0o700 if directory else 0o600)
except OSError:
pass
def category_name(uuid):
return CATEGORY_NAMES.get(uuid, f"Other ({uuid})")
def load_export(path):
try:
with zipfile.ZipFile(path) as archive:
if "export.data" not in archive.namelist():
die("This archive does not contain export.data.")
return json.loads(archive.read("export.data"))
except zipfile.BadZipFile:
die("The selected file is not a valid 1PUX/ZIP archive.")
except json.JSONDecodeError:
die("export.data does not contain valid JSON.")
def active_items(vault):
return [
item for item in vault.get("items", [])
if item.get("state") == "active"
]
def item_title(item):
return (
item.get("overview", {}).get("title")
or "Untitled"
)
# ---------------------------------------------------------------------------
# Vault selection
# ---------------------------------------------------------------------------
def collect_vaults(data):
result = []
for account in data.get("accounts", []):
account_name = (
account.get("attrs", {}).get("accountName")
or "(unknown account)"
)
for vault in account.get("vaults", []):
items = active_items(vault)
if not items:
continue
result.append({
"account": account_name,
"vault": vault,
"name": vault.get("attrs", {}).get("name", "Untitled"),
"count": len(items),
})
return result
def choose_vault(vaults):
selectable = []
print()
print("1Password -> Apple Migration")
print("=" * 55)
print()
print("Available vaults with ACTIVE items:")
print()
for vault_info in vaults:
if vault_info["name"].casefold() == MIGRATED_VAULT_NAME.casefold():
print(
f" {vault_info['name']:<30}"
f"{vault_info['count']:>5} [EXCLUDED]"
)
continue
selectable.append(vault_info)
print(
f"{len(selectable):2}. "
f"{vault_info['name']:<30}"
f"{vault_info['count']:>5}"
)
if not selectable:
die("No selectable vaults contain active items.")
print()
try:
selection = input("Select ONE vault (or q to quit): ").strip()
except (EOFError, KeyboardInterrupt):
print()
sys.exit(0)
if selection.lower() in ("q", "quit", "exit"):
sys.exit(0)
try:
index = int(selection)
except ValueError:
die("Invalid selection.")
if index < 1 or index > len(selectable):
die("Invalid selection.")
chosen = selectable[index - 1]
if chosen["name"].casefold() == MIGRATED_VAULT_NAME.casefold():
die("The Migrated vault cannot be processed.")
return chosen
# ---------------------------------------------------------------------------
# Login parsing
# ---------------------------------------------------------------------------
def extract_login(item):
overview = item.get("overview", {})
details = item.get("details", {})
username = ""
password = ""
extra_login_fields = []
for field in details.get("loginFields", []):
designation = field.get("designation")
if designation == "username":
username = str(field.get("value", "") or "")
elif designation == "password":
password = str(field.get("value", "") or "")
else:
extra_login_fields.append(field)
urls = overview.get("urls", []) or []
primary_url = ""
if urls:
primary = next(
(u for u in urls if u.get("primary")),
urls[0]
)
primary_url = str(primary.get("url", "") or "")
notes = str(details.get("notesPlain", "") or "")
otp = ""
for section in details.get("sections", []) or []:
for field in section.get("fields", []) or []:
value = field.get("value")
if isinstance(value, dict) and value.get("totp"):
otp = str(value["totp"])
break
if otp:
break
return {
"title": item_title(item),
"username": username,
"password": password,
"url": primary_url,
"urls": urls,
"notes": notes,
"otp": otp,
"extra_login_fields": extra_login_fields,
}
# ---------------------------------------------------------------------------
# OTP / custom field analysis
# ---------------------------------------------------------------------------
def walk_values(value, path=""):
"""
Recursively walk arbitrary 1PUX structures.
Returns tuples:
(path, value)
"""
if isinstance(value, dict):
for key, child in value.items():
child_path = f"{path}.{key}" if path else str(key)
yield from walk_values(child, child_path)
elif isinstance(value, list):
for index, child in enumerate(value):
child_path = f"{path}[{index}]"
yield from walk_values(child, child_path)
else:
yield path, value
def analyze_item_fields(item):
"""
Conservative analysis.
We identify likely OTP material without printing the secret.
We also count structured/custom fields so they can be reviewed.
"""
details = item.get("details", {})
otp_found = False
custom_count = 0
for section in details.get("sections", []) or []:
for field in section.get("fields", []) or []:
custom_count += 1
title = str(
field.get("title")
or field.get("t")
or ""
).lower()
field_blob = json.dumps(
field,
ensure_ascii=False
).lower()
if (
"otp" in title
or "one-time" in title
or "one time" in title
or "totp" in title
or "otpauth://" in field_blob
):
otp_found = True
# Some exports may expose OTP data elsewhere.
for path, value in walk_values(details):
if isinstance(value, str):
lower_value = value.lower()
lower_path = path.lower()
if (
lower_value.startswith("otpauth://")
or "totp" in lower_path
or "otp" in lower_path
):
otp_found = True
return {
"otp": otp_found,
"custom_fields": custom_count,
}
# ---------------------------------------------------------------------------
# Document handling
# ---------------------------------------------------------------------------
def document_attachments(item):
attachments = []
details = item.get("details", {})
for section in details.get("sections", []) or []:
for field in section.get("fields", []) or []:
value = field.get("value")
if not isinstance(value, dict):
continue
file_info = value.get("file")
if not isinstance(file_info, dict):
continue
attachments.append(file_info)
return attachments
def find_attachment_member(archive, document_id, file_info):
"""
Locate the actual attachment within the 1PUX.
1PUX implementations can vary slightly in how the internal
attachment path is represented, so we use metadata first and
conservative filename/document-id matching second.
"""
candidates = []
for key in ("contentPath", "path"):
value = file_info.get(key)
if value:
candidates.append(str(value).lstrip("/"))
names = archive.namelist()
for candidate in candidates:
if candidate in names:
return candidate
# Search using document ID.
if document_id:
matches = [
name for name in names
if document_id in name
and not name.endswith("/")
]
if len(matches) == 1:
return matches[0]
# Prefer paths under files/.
file_matches = [
name for name in matches
if name.startswith("files/")
]
if len(file_matches) == 1:
return file_matches[0]
return None
def extract_document(export_path, item, output_directory):
title = safe_name(item_title(item))
destination = output_directory / title
destination.mkdir(parents=True, exist_ok=True)
chmod_private(destination, directory=True)
attachments = document_attachments(item)
extracted = []
failed = []
with zipfile.ZipFile(export_path) as archive:
for number, info in enumerate(attachments, 1):
filename = (
info.get("fileName")
or info.get("name")
or f"Attachment-{number}"
)
filename = safe_name(filename)
document_id = str(
info.get("documentId")
or info.get("documentID")
or ""
)
member = find_attachment_member(
archive,
document_id,
info
)
if not member:
failed.append(filename)
continue
target = destination / filename
# Avoid accidental overwrite.
if target.exists():
stem = target.stem
suffix = target.suffix
counter = 2
while target.exists():
target = destination / (
f"{stem}-{counter}{suffix}"
)
counter += 1
with archive.open(member) as source:
with target.open("wb") as dest:
shutil.copyfileobj(source, dest)
chmod_private(target)
extracted.append(target)
return extracted, failed
# ---------------------------------------------------------------------------
# Secure note parsing
# ---------------------------------------------------------------------------
def field_display_name(field, number):
return (
field.get("title")
or field.get("t")
or f"Field {number}"
)
def printable_field_value(value):
"""
Convert non-file structured values to text.
This is used ONLY in Apple Notes output, never Terminal output.
"""
if value is None:
return ""
if isinstance(value, str):
return value
if isinstance(value, bool):
return "Yes" if value else "No"
if isinstance(value, (int, float)):
return str(value)
if isinstance(value, dict):
# Common 1PUX wrapped value types.
for key in (
"string",
"concealed",
"email",
"phone",
"url",
"date",
"monthYear",
):
if key in value:
return str(value[key])
if "file" in value:
info = value["file"]
if isinstance(info, dict):
return (
info.get("fileName")
or info.get("name")
or "[Attachment]"
)
return "[Attachment]"
# Unknown structure: preserve it rather than silently lose it.
return json.dumps(
value,
ensure_ascii=False,
indent=2
)
if isinstance(value, list):
return "\n".join(
printable_field_value(v)
for v in value
)
return str(value)
def secure_note_body(item):
details = item.get("details", {})
parts = []
notes_plain = str(
details.get("notesPlain", "") or ""
).strip()
if notes_plain:
parts.append(notes_plain)
for section in details.get("sections", []) or []:
fields = section.get("fields", []) or []
if not fields:
continue
section_title = (
section.get("title")
or ""
).strip()
section_lines = []
for number, field in enumerate(fields, 1):
value = field.get("value")
# Files are handled separately.
if (
isinstance(value, dict)
and "file" in value
):
continue
display_value = printable_field_value(value)
if not display_value:
continue
label = field_display_name(field, number)
section_lines.append(
f"{label}: {display_value}"
)
if section_lines:
if section_title:
parts.append(
f"{section_title}\n"
+ "\n".join(section_lines)
)
else:
parts.append(
"\n".join(section_lines)
)
return "\n\n".join(parts).strip()
# ---------------------------------------------------------------------------
# Apple Notes
# ---------------------------------------------------------------------------
def applescript_escape(value):
return (
str(value)
.replace("\\", "\\\\")
.replace('"', '\\"')
)
def note_html(text):
"""
Notes' AppleScript interface accepts HTML for the body.
"""
escaped = html.escape(text)
paragraphs = escaped.split("\n")
return "<br>".join(paragraphs)
def create_note(folder_name, title, body):
"""
Create a note in Notes.app.
Returns True on success.
"""
folder_name = applescript_escape(folder_name)
title = applescript_escape(title)
body = applescript_escape(note_html(body))
script = f'''
tell application "Notes"
set targetAccount to first account
if not (exists folder "{folder_name}" of targetAccount) then
make new folder at targetAccount with properties {{name:"{folder_name}"}}
end if
set targetFolder to folder "{folder_name}" of targetAccount
make new note at targetFolder with properties {{name:"{title}", body:"{body}"}}
end tell
'''
result = subprocess.run(
["/usr/bin/osascript", "-e", script],
capture_output=True,
text=True
)
if result.returncode != 0:
return False, result.stderr.strip()
return True, ""
def create_document_note(folder_name, title, attachment_paths):
"""
Create an Apple Note and attach extracted files.
This is intentionally done through Notes/Finder AppleScript,
not by modifying Notes' database.
"""
folder_name_e = applescript_escape(folder_name)
title_e = applescript_escape(title)
body_text = (
"Migrated from 1Password.\n\n"
f"Original item: {title}\n"
f"Attachments: {len(attachment_paths)}"
)
body_e = applescript_escape(note_html(body_text))
# First create note and capture its ID.
script = f'''
tell application "Notes"
set targetAccount to first account
if not (exists folder "{folder_name_e}" of targetAccount) then
make new folder at targetAccount with properties {{name:"{folder_name_e}"}}
end if
set targetFolder to folder "{folder_name_e}" of targetAccount
set newNote to make new note at targetFolder with properties {{name:"{title_e}", body:"{body_e}"}}
return id of newNote
end tell
'''
result = subprocess.run(
["/usr/bin/osascript", "-e", script],
capture_output=True,
text=True
)
if result.returncode != 0:
return False, result.stderr.strip()
note_id = result.stdout.strip()
# Attach each file.
for attachment in attachment_paths:
path_e = applescript_escape(
str(attachment.resolve())
)
note_id_e = applescript_escape(note_id)
attach_script = f'''
tell application "Notes"
set targetNote to note id "{note_id_e}"
tell targetNote
make new attachment with data POSIX file "{path_e}"
end tell
end tell
'''
attach_result = subprocess.run(
["/usr/bin/osascript", "-e", attach_script],
capture_output=True,
text=True
)
if attach_result.returncode != 0:
return (
False,
"Note created, but an attachment failed: "
+ attach_result.stderr.strip()
)
return True, ""
# ---------------------------------------------------------------------------
# Passkey upgrade workflow
# ---------------------------------------------------------------------------
def passkey_upgrade_candidate(item):
"""Watchtower candidate only; does not prove a stored passkey exists."""
overview = item.get("overview", {}) or {}
exclusions = overview.get("watchtowerExclusions", {}) or {}
return "passkeyAvailable" in exclusions
def item_primary_url(item):
overview = item.get("overview", {}) or {}
urls = overview.get("urls", []) or []
if urls:
primary = next((u for u in urls if u.get("primary")), urls[0])
return str(primary.get("url", "") or "")
return str(overview.get("url", "") or "")
def collect_passkey_candidates(data):
# Deliberately scans ALL vaults/states, including Migrated and archived,
# so this can be used as the final passkey-upgrade audit.
rows = []
for account in data.get("accounts", []):
for vault in account.get("vaults", []):
vault_name = vault.get("attrs", {}).get("name", "(unnamed)")
for item in vault.get("items", []):
if item.get("categoryUuid") != CATEGORY_LOGIN:
continue
if not passkey_upgrade_candidate(item):
continue
rows.append({
"vault": vault_name,
"state": item.get("state", "?"),
"title": item_title(item),
"url": item_primary_url(item),
})
return rows
def write_passkey_report(path, candidates):
rows = []
for i, entry in enumerate(candidates):
url = entry["url"]
link = (f'<a href="{html.escape(url, quote=True)}" target="_blank" '
f'rel="noopener noreferrer">Open site</a>') if url else '<span class="missing">No URL in export</span>'
rows.append(
'<tr>'
f'<td><input type="checkbox" id="pk-{i}" data-save></td>'
f'<td>{html.escape(entry["title"])}</td>'
f'<td>{html.escape(entry["vault"])}</td>'
f'<td>{html.escape(str(entry["state"]))}</td>'
f'<td>{link}</td>'
'</tr>'
)
document = """<!doctype html>
<html><head><meta charset="utf-8"><title>1Password Passkey Upgrade Workflow</title>
<style>
body {font-family:-apple-system,BlinkMacSystemFont,sans-serif;max-width:1100px;margin:40px auto;padding:0 24px;line-height:1.45}
h1{margin-bottom:4px}.notice{padding:16px;background:#f3f3f3;border-radius:10px;margin:20px 0}.steps{padding:16px 20px;border:1px solid #ddd;border-radius:10px}
table{border-collapse:collapse;width:100%;margin-top:22px}th,td{text-align:left;border-bottom:1px solid #ddd;padding:10px;vertical-align:top}th{font-weight:600}.missing{opacity:.65}.small{font-size:.9em;opacity:.75}
</style></head><body>
<h1>Passkey Upgrade Workflow</h1>
<p>__COUNT__ passkey-upgrade candidate(s) found across the entire 1PUX export.</p>
<div class="notice"><strong>Important:</strong> This report uses 1Password's <code>passkeyAvailable</code> Watchtower metadata. It means the site is a candidate for a passkey upgrade; it does <strong>not</strong> prove that a passkey is currently stored in 1Password. No passwords, usernames, OTP secrets, or passkey credentials are included here.</div>
<div class="steps"><strong>Workflow for each site</strong><ol>
<li>Open the site using the link below.</li><li>Let Apple Passwords fill the migrated login.</li><li>Open the site's security/passkey settings.</li><li>Create a new passkey and save it in Apple Passwords.</li><li>Confirm the new passkey works.</li><li>Revoke the old 1Password passkey manually if one exists.</li><li>Check the item off below.</li>
</ol></div>
<p class="small">Checkbox state is saved only in this browser on this Mac via localStorage.</p>
<table><thead><tr><th>Done</th><th>Item</th><th>1Password Vault</th><th>State</th><th>Website</th></tr></thead><tbody>__ROWS__</tbody></table>
<script>for(const box of document.querySelectorAll('[data-save]')){const k='onepassword-passkey-'+box.id;box.checked=localStorage.getItem(k)==='1';box.addEventListener('change',()=>localStorage.setItem(k,box.checked?'1':'0'));}</script>
</body></html>"""
document = document.replace("__COUNT__", str(len(candidates))).replace("__ROWS__", "".join(rows))
path.write_text(document, encoding="utf-8")
chmod_private(path)
# ---------------------------------------------------------------------------
# CSV
# ---------------------------------------------------------------------------
def write_password_csv(path, login_rows):
fieldnames = [
"Title",
"URL",
"Username",
"Password",
"Notes",
"OTP",
]
with path.open(
"w",
newline="",
encoding="utf-8"
) as handle:
writer = csv.DictWriter(
handle,
fieldnames=fieldnames
)
writer.writeheader()
for login in login_rows:
writer.writerow({
"Title": login["title"],
"URL": login["url"],
"Username": login["username"],
"Password": login["password"],
"Notes": login["notes"],
"OTP": login["otp"],
})
chmod_private(path)
# ---------------------------------------------------------------------------
# HTML review report
# ---------------------------------------------------------------------------
def write_review_report(
path,
vault_name,
login_analysis,
secure_notes,
documents,
other_items
):
rows = []
for entry in login_analysis:
flags = []
if len(entry["login"]["urls"]) > 1:
flags.append(
f'{len(entry["login"]["urls"])} URLs'
)
if entry["login"]["otp"]:
flags.append("OTP")
if entry["login"]["notes"]:
flags.append("Notes")
if entry["analysis"]["custom_fields"]:
flags.append(
f'{entry["analysis"]["custom_fields"]} structured field(s)'
)
if not entry["login"]["url"]:
flags.append("NO URL")
flag_text = ", ".join(flags) if flags else "Standard login"
rows.append(
"<tr>"
f"<td>{html.escape(entry['login']['title'])}</td>"
"<td>Login</td>"
f"<td>{html.escape(flag_text)}</td>"
"</tr>"
)
for item in secure_notes:
rows.append(
"<tr>"
f"<td>{html.escape(item_title(item))}</td>"
"<td>Secure Note</td>"
"<td>Apple Notes</td>"
"</tr>"
)
for item in documents:
count = len(document_attachments(item))
rows.append(
"<tr>"
f"<td>{html.escape(item_title(item))}</td>"
"<td>Document</td>"
f"<td>{count} attachment(s)</td>"
"</tr>"
)
for item in other_items:
uuid = item.get("categoryUuid")
rows.append(
"<tr>"
f"<td>{html.escape(item_title(item))}</td>"
f"<td>{html.escape(category_name(uuid))}</td>"
"<td>Manual review</td>"
"</tr>"
)
document = f"""<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>1Password Migration Review - {html.escape(vault_name)}</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, sans-serif;
max-width: 1000px;
margin: 40px auto;
padding: 0 24px;
line-height: 1.45;
}}
h1 {{ margin-bottom: 4px; }}
.warning {{
padding: 14px;
background: #f3f3f3;
border-radius: 10px;
margin: 20px 0;
}}
table {{
border-collapse: collapse;
width: 100%;
}}
th, td {{
text-align: left;
border-bottom: 1px solid #ddd;
padding: 10px;
}}
th {{ font-weight: 600; }}
</style>
</head>
<body>
<h1>1Password Migration Review</h1>
<p>Vault: <strong>{html.escape(vault_name)}</strong></p>
<div class="warning">
This report intentionally does not display passwords, OTP secrets,
or usernames. Apple Passwords import remains a manual step.
</div>
<table>
<thead>
<tr>
<th>Item</th>
<th>Type</th>
<th>Migration status / review</th>
</tr>
</thead>
<tbody>
{''.join(rows)}
</tbody>
</table>
</body>
</html>
"""
path.write_text(document, encoding="utf-8")
chmod_private(path)
# ---------------------------------------------------------------------------
# Main
# ---------------------------------------------------------------------------
def main():
if sys.platform != "darwin":
die("This utility is intended for macOS.")
if len(sys.argv) != 2:
print(
f"Usage:\n"
f" {Path(sys.argv[0]).name} /path/to/export.1pux"
)
sys.exit(1)
export_path = Path(
os.path.expanduser(sys.argv[1])
).resolve()
if not export_path.exists():
die(f"Export not found: {export_path}")
print()
print("Reading 1Password export...")
print("No secrets will be printed.")
data = load_export(export_path)
vaults = collect_vaults(data)
chosen = choose_vault(vaults)
vault = chosen["vault"]
vault_name = chosen["name"]
items = active_items(vault)
logins = []
secure_notes = []
documents = []
other_items = []
for item in items:
category = item.get("categoryUuid")
if category == CATEGORY_LOGIN:
login = extract_login(item)
analysis = analyze_item_fields(item)
logins.append({
"item": item,
"login": login,
"analysis": analysis,
})
elif category == CATEGORY_SECURE_NOTE:
secure_notes.append(item)
elif category == CATEGORY_DOCUMENT:
documents.append(item)
else:
other_items.append(item)
eligible_logins = [
entry["login"]
for entry in logins
if entry["login"]["url"]
]
no_url_logins = [
entry
for entry in logins
if not entry["login"]["url"]
]
otp_count = sum(
1 for entry in logins
if entry["login"]["otp"]
)
multiple_url_count = sum(
1 for entry in logins
if len(entry["login"]["urls"]) > 1
)
note_login_count = sum(
1 for entry in logins
if bool(entry["login"]["notes"])
)
print()
print("=" * 55)
print(f"Vault: {vault_name}")
print("=" * 55)
print()
print(f"Active items: {len(items)}")
print(f"Logins: {len(logins)}")
print(f" CSV eligible: {len(eligible_logins)}")
print(f" Missing URL: {len(no_url_logins)}")
print(f" OTP detected: {otp_count}")
print(f" Multiple URLs: {multiple_url_count}")
print(f" Login notes: {note_login_count}")
print(f"Secure Notes: {len(secure_notes)}")
print(f"Documents: {len(documents)}")
print(f"Other/manual review: {len(other_items)}")
print()
if other_items:
print("Manual-review item types:")
counts = {}
for item in other_items:
uuid = item.get("categoryUuid")
name = category_name(uuid)
counts[name] = counts.get(name, 0) + 1
for name, count in sorted(counts.items()):
print(f" {name}: {count}")
print()
if not yes_no(
f"Prepare migration output for '{vault_name}'?"
):
print("No changes made.")
return
base = Path.home() / "Downloads" / "1Password-Migration"
base.mkdir(exist_ok=True)
chmod_private(base, directory=True)
output = base / safe_name(vault_name)
if output.exists():
die(
f"Output directory already exists:\n{output}\n\n"
"Remove/rename it first. The script will not overwrite "
"previous migration output."
)
output.mkdir()
chmod_private(output, directory=True)
# Password CSV ----------------------------------------------------------
csv_path = output / "Apple-Passwords.csv"
if eligible_logins:
write_password_csv(
csv_path,
eligible_logins
)
# Documents -------------------------------------------------------------
document_results = []
documents_directory = output / "Documents"
attachment_items = secure_notes + documents
if attachment_items:
documents_directory.mkdir()
chmod_private(
documents_directory,
directory=True
)
for item in attachment_items:
extracted, failed = extract_document(
export_path,
item,
documents_directory
)
document_results.append({
"item": item,
"extracted": extracted,
"failed": failed,
})
# Review report ---------------------------------------------------------
report_path = output / "Review.html"
write_review_report(
report_path,
vault_name,
logins,
secure_notes,
documents,
other_items
)
# Final passkey-upgrade audit: all vaults, including Migrated/archived.
passkey_candidates = collect_passkey_candidates(data)
passkey_report_path = output / "Passkey-Workflow.html"
write_passkey_report(passkey_report_path, passkey_candidates)
# Summary ---------------------------------------------------------------
summary_path = output / "Migration-Summary.txt"
summary_lines = [
"1Password -> Apple Migration",
"=" * 50,
"",
f"Vault: {vault_name}",
"",
f"Active items: {len(items)}",
f"Logins: {len(logins)}",
f"CSV eligible: {len(eligible_logins)}",
f"Logins missing URL: {len(no_url_logins)}",
f"OTP detected: {otp_count}",
f"Multiple URL logins: {multiple_url_count}",
f"Login notes: {note_login_count}",
f"Secure Notes: {len(secure_notes)}",
f"Documents: {len(documents)}",
f"Other/manual review: {len(other_items)}",
f"Passkey-upgrade candidates (all vaults): {len(passkey_candidates)}",
"",
"IMPORTANT:",
"Apple Passwords has NOT been modified.",
"Import Apple-Passwords.csv manually using Passwords.app.",
"",
]
if no_url_logins:
summary_lines.append("Logins excluded because they have no URL:")
for entry in no_url_logins:
summary_lines.append(
f" - {entry['login']['title']}"
)
summary_lines.append("")
if multiple_url_count:
summary_lines.append("Logins with multiple URLs:")
for entry in logins:
if len(entry["login"]["urls"]) > 1:
summary_lines.append(
f" - {entry['login']['title']}: "
f"{len(entry['login']['urls'])} URLs"
)
summary_lines.append("")
if otp_count:
summary_lines.append("Logins with OTP data detected:")
for entry in logins:
if entry["login"]["otp"]:
summary_lines.append(
f" - {entry['login']['title']}"
)
summary_lines.append(
"OTP secrets were NOT printed in this report."
)
summary_lines.append("")
if other_items:
summary_lines.append("Items requiring manual review:")
for item in other_items:
summary_lines.append(
f" - {item_title(item)} "
f"[{category_name(item.get('categoryUuid'))}]"
)
summary_lines.append("")
summary_path.write_text(
"\n".join(summary_lines),
encoding="utf-8"
)
chmod_private(summary_path)
# Results ---------------------------------------------------------------
print()
print("Migration preparation complete.")
print("=" * 55)
print()
print("Output:")
print(f" {output}")
print()
if eligible_logins:
print("APPLE PASSWORDS")
print(f" {len(eligible_logins)} login(s) written to:")
print(f" {csv_path}")
print()
print(" *** NOT IMPORTED ***")
print(" You control the import manually in Passwords.app.")
print()
if document_results:
print("EXTRACTED ATTACHMENTS")
for result in document_results:
title = item_title(result["item"])
print(
f" {title}: "
f"{len(result['extracted'])} extracted"
)
if result["failed"]:
print(
f" WARNING: "
f"{len(result['failed'])} attachment(s) "
f"could not be located."
)
print()
# Apple Notes -----------------------------------------------------------
note_candidates = len(secure_notes) + len(
[
r for r in document_results
if r["extracted"] and r["item"].get("categoryUuid") == CATEGORY_DOCUMENT
]
)
if note_candidates:
folder_name = f"1Password Migration - {vault_name}"
print("APPLE NOTES")
print(
f" {note_candidates} note(s) can be created in:"
)
print(f" {folder_name}")
print()
if yes_no(
"Create these notes automatically in Apple Notes?"
):
created = 0
failures = []
# Secure Notes
for item in secure_notes:
title = item_title(item)
body = secure_note_body(item)
if not body:
body = (
"Migrated from 1Password.\n\n"
"The original Secure Note contained no "
"plain-text note body."
)
ok, error = create_note(
folder_name,
title,
body
)
if ok:
created += 1
else:
failures.append((title, error))
# Documents
for result in document_results:
if not result["extracted"]:
continue
if result["item"].get("categoryUuid") != CATEGORY_DOCUMENT:
continue
title = item_title(result["item"])
body = (
"Migrated from 1Password.\n\n"
f"Extracted attachments: {len(result['extracted'])}\n"
"Attachments were extracted locally but not auto-attached "
"because Notes attachment automation is disabled after the "
"duplicate-attachment test."
)
ok, error = create_note(
folder_name,
title,
body
)
if ok:
created += 1
else:
failures.append((title, error))
print()
print(
f"Apple Notes created: {created}"
)
if failures:
print(
f"Apple Notes failures: {len(failures)}"
)
for title, error in failures:
print(f" - {title}: {error}")
print()
print(
"Verify the created notes before moving "
"anything in 1Password."
)
else:
print()
print("Apple Notes were not modified.")
print()
print("REVIEW")
print(f" {report_path}")
print(f" {summary_path}")
print()
print("PASSKEY WORKFLOW")
print(f" {len(passkey_candidates)} candidate(s) across ALL vaults/states")
print(f" {passkey_report_path}")
print(" Use it for the final create-in-Apple / revoke-in-1Password pass.")
print()
print("SECURITY")
print(
" Apple-Passwords.csv and extracted documents may "
"contain sensitive plaintext data."
)
print(
" Delete the migration output after you have "
"verified the migration."
)
print()
print("1Password was NOT modified.")
print("Apple Passwords was NOT modified.")
print()
if __name__ == "__main__":
main()Sources
- 1Password export guide
- 1Password 1PUX format
- Apple Passwords CSV import on Mac
- Apple Passwords overview and credential categories
- Apple Passwords Shared Groups
- Apple Passwords login notes
- Apple Passwords verification codes
- Apple Notes accounts
- Apple Notes lock guide and attachment limits
- 1Password moving items between vaults
AI Usage Transparency Report
AI Era · Written during widespread use of AI tools
AI Signal Composition
Score: 0.44 · Moderate AI Influence
Summary
A Python script to migrate 1Password data to Apple Passwords, including logins, secure notes, and documents.
Related Posts
I Tried Apple Photos MCP on 100 Unfiled Photos
I tried Apple Photos MCP on 100 pictures missing from my albums. Here's how I installed it, where Codex got stuck, and what it actually filed.
Tracing a Mac Failure with Logger Utility
Use Logger Utility to filter and stream macOS logs, inspect an Ask AI prompt, and turn a focused export into a local Llama troubleshooting report.
Contour Brings Schema Checks to Mac Admin Configuration Work
Contour is a MacAdmins configuration toolkit that brings schema validation, repeatable generation, normalization, and AI-agent-safe command discovery to Apple configuration profiles, DDM declarations, PPPC, Santa, mSCP, Background Task Management, notifications, support app profiles, and osquery schema lookup.
Three Mac Update Helpers That Fit Below a Patch Platform
Latest, Applite, and an all-in-one macOS update script each solve a different part of lightweight Mac maintenance: app update visibility, Homebrew-backed app management, and command-line package updates that can be wrapped carefully for small teams or basic MDM.
Natural Makes the macOS Scroll Direction Toggle Less Buried
Natural is a small macOS menu bar utility that makes the natural scrolling toggle easy to reach, but building it from source also shows why open-source desktop utilities need clear prerequisite checks before they become recommended tools.
Why Mac Performance Monitor Belongs in the Mac Admin Toolbox
Mac Performance Monitor belongs in the Mac admin toolbox because it records local performance history, helps Help Desk review slow-Mac reports after the moment has passed, and keeps process telemetry on the Mac instead of sending it to a cloud service.
Deploying DDM OS Reminder 4.0.0 in Jamf
DDM OS Reminder 4.0.0 is deployed as two separate pieces in Jamf: a managed preferences profile and a one-time installer script that creates the reminder script, starter script, and LaunchDaemon heartbeat on the Mac.
When a Local AI Tool Belongs in My Workflow and When It Stays in the Lab
Running AI locally on a Mac has become a real part of my workflow, but only once I stopped treating local models like general-purpose answers and started treating them like constrained components inside a system I can still inspect.