import csv import re import sys def clean_value(text): if not text: return "" # Convert escaped backslash-n sequences to actual newlines and strip bounding whitespace cleaned = text.replace("\\r\\n", "\n").replace("\\n", "\n").replace("\\r", "\n") return cleaned.strip() def parse_msecure_csv_lines(raw_text): """ Parses CSV rows without using _csv.reader to prevent: '_csv.Error: new-line character seen in unquoted field'. A genuine record starts with: - Title|,[Type] - Title,[Type], - Title,Token|Digit| (id-less records like 'relative who lives...') """ lines = raw_text.replace("\r\n", "\n").replace("\r", "\n").split("\n") # Strip mSecure header banner if present if lines and "msecure" in lines[0][:35].lower(): lines = lines[1:] record_start_pattern = re.compile( r'^(?:\"?[^,\n\r|]+\|\d{6,12},|\"?.*?,\s*(?:Login|Credit Card|Secure Note|Bank Account|Note),|\"?.*?,(?:Username|Password|Website|URL|Text|Notes|User Id|Email)\|\d+\|)', re.IGNORECASE ) merged_records = [] current_record = "" for line in lines: if not line.strip(): continue # Test line (also handle leading literal \n if present) test_line = line.lstrip("\\n").strip() if record_start_pattern.match(line) or record_start_pattern.match(test_line): if current_record: merged_records.append(current_record) current_record = line else: # Belongs to an unquoted multiline note from the previous record if current_record: current_record += "\n" + line else: current_record = line if current_record: merged_records.append(current_record) return merged_records def parse_record_cells(record_str): """ Splits a single record string into cells, respecting double quotes and handling tokens containing commas like "Password|8|x,xxxx~xxxx". """ cells = [] current = [] in_quotes = False for ch in record_str: if ch == '"': in_quotes = not in_quotes current.append(ch) elif ch == ',' and not in_quotes: cells.append("".join(current).strip()) current = [] else: current.append(ch) if current: cells.append("".join(current).strip()) # Strip enclosing quotes from each cell cleaned_cells = [] for c in cells: c_str = c.strip() if c_str.startswith('"') and c_str.endswith('"') and len(c_str) >= 2: c_str = c_str[1:-1] cleaned_cells.append(c_str.strip()) return cleaned_cells def parse_token(cell): """ Parses an mSecure cell: Label|TypeID|Value """ cell = cell.strip() if not cell or "|" not in cell: return None, None, None parts = cell.split("|", 2) if len(parts) == 3: return parts[0].strip(), parts[1].strip(), parts[2] elif len(parts) == 2: return parts[0].strip(), "", parts[1] return None, None, None def convert_msecure(input_file, output_file="bitwarden_ready.csv"): bw_headers = [ "folder", "favorite", "type", "name", "notes", "fields", "reprompt", "login_uri", "login_username", "login_password", "login_totp" ] with open(input_file, "r", encoding="utf-8-sig", errors="replace") as f: full_text = f.read() record_strings = parse_msecure_csv_lines(full_text) converted_rows = [] for rec in record_strings: row = parse_record_cells(rec) if not row or not any(c.strip() for c in row): continue raw_col0 = row[0].strip() if raw_col0.startswith("\\n"): raw_col0 = raw_col0[2:].strip() name = "Untitled" folder = "" item_type_raw = "Login" fields_start_idx = 1 # Check if column 0 follows the Title|ID format if "|" in raw_col0 and len(raw_col0.split("|")[-1]) >= 6 and raw_col0.split("|")[-1].isdigit(): name = raw_col0.rsplit("|", 1)[0].strip() item_type_raw = row[1].strip() if len(row) > 1 else "Login" folder = row[2].strip() if len(row) > 2 and row[2].strip() else "" fields_start_idx = 3 else: # ID-less record (like the 'relative who lives...' entry) name = raw_col0 fields_start_idx = 1 username = "" password = "" uri = "" totp = "" notes_list = [] custom_fields = [] for cell in row[fields_start_idx:]: cell_clean = cell.strip() if not cell_clean: continue label, type_id, val = parse_token(cell_clean) if label is None: notes_list.append(clean_value(cell_clean)) continue val_clean = clean_value(val) if not val_clean: continue lbl_lower = label.lower().replace(" ", "").replace("_", "").replace("-", "") # USERNAME (Type 7 or keywords) if type_id == "7" or any(k in lbl_lower for k in ["username", "user", "login", "email", "account", "memberid", "userid"]): if not username: username = val_clean else: notes_list.append(f"{label}: {val_clean}") # PASSWORD (Type 8 or keywords) elif type_id == "8" or any(k in lbl_lower for k in ["password", "passcode", "passphrase", "passwd", "pin", "secret"]): if not password: password = val_clean else: notes_list.append(f"{label}: {val_clean}") # URL / WEBSITE (Type 2 or keywords or http) elif type_id == "2" or any(k in lbl_lower for k in ["website", "url", "loginurl", "link", "address"]) or val_clean.startswith("http://") or val_clean.startswith("https://"): if not uri: uri = val_clean else: notes_list.append(f"{label}: {val_clean}") # TOTP elif any(k in lbl_lower for k in ["totp", "onetimepassword", "authenticator", "2fa", "otp"]): if not totp: totp = val_clean else: notes_list.append(f"{label}: {val_clean}") # NOTES / TEXT (Type 0 or note keywords) elif type_id == "0" or any(k in lbl_lower for k in ["text", "notes", "note", "comment", "memo", "description"]): notes_list.append(val_clean) # Custom fields else: custom_fields.append(f"{label}: {val_clean}") bw_type = "login" if "note" in item_type_raw.lower(): bw_type = "secure_note" elif "card" in item_type_raw.lower(): bw_type = "card" converted_rows.append({ "folder": folder, "favorite": "0", "type": bw_type, "name": name if name else "Untitled", "notes": "\n\n".join(notes_list), "fields": " \n".join(custom_fields), "reprompt": "0", "login_uri": uri, "login_username": username, "login_password": password, "login_totp": totp }) with open(output_file, "w", encoding="utf-8", newline="") as f: writer = csv.DictWriter(f, fieldnames=bw_headers, quoting=csv.QUOTE_ALL) writer.writeheader() writer.writerows(converted_rows) print(f"Successfully converted {len(converted_rows)} items to {output_file}") if __name__ == "__main__": src = sys.argv[1] if len(sys.argv) > 1 else "Desktop/mSecure Export File.csv" convert_msecure(src)