Day One to Obsidian Conversion Script

A Python script that converts a directory of Day One JSON exports into Obsidian-compatible Markdown files. The distinguishing design choice versus similar tools (e.g. `dayone-to-obsidian`) is that it emits exactly one Markdown file per day, with all of that day’s Day One entries arranged chronologically inside it. That shape matches what the Obsidian daily-notes plugin ecosystem assumes, so the output drops into a daily-notes vault without further restructuring.

The implementation is deliberately simple — text templating rather than full JSON AST reconstruction — which makes it easy to adapt to a different note layout.

Known limitations

  • Day One inserts extra spaces and adds/removes line breaks inconsistently. The script corrects the common cases (runs of blank lines, media embeds jammed against text, over-escaped punctuation) but not all of them — expect a hand-editing pass over the generated files.
  • Fully fixing formatting would require parsing each entry’s internal JSON representation directly, a much larger script; the author judged manual review cheaper than writing that parser.

Usage

Run the script in a directory containing Day One JSON export files (*.json). It writes one YYYY-MM-DD DayName.md file per day into the same directory, each with frontmatter (title, date, Journal tag, aliases in several date formats) and each entry as a ## HH:MM | JournalName section with Dataview-style inline fields (dayOneId, datetime, tags, location, weather) above the entry text.

Media attachments (photos, videos, audio, PDFs) are rewritten from Day One dayone-moment: links into Obsidian !md5.ext“ embeds, so exported attachment files must be renamed to their md5 + extension for the embeds to resolve.

The script

#!/usr/bin/env python3
 
import os
import json
 
from datetime import datetime
from zoneinfo import ZoneInfo
 
# Read in all JSON files in the directory
#
rawDayOneData = {}
 
for file in os.listdir("."):
    if file.endswith(".json"):
        with open(file) as jsonData:
            rawDayOneData[file.replace(".json", "")] = json.load(jsonData)
 
# Create master document list
#
journalData = {}
 
for journalName, rawJournalData in rawDayOneData.items():
    for entry in rawJournalData["entries"]:
        entryDateTimeObject = datetime.fromisoformat(entry["creationDate"])
        entryDate = entryDateTimeObject.astimezone(ZoneInfo(entry["timeZone"])).strftime("%Y-%m-%d")
        entryTime = entryDateTimeObject.astimezone(ZoneInfo(entry["timeZone"])).strftime("%H:%M")
        entryDateTime = entryDateTimeObject.astimezone(ZoneInfo(entry["timeZone"])).strftime("%Y-%m-%dT%H:%M:%S")
 
        entryUuid = entry["uuid"].lower()
        entryIndex = entryDateTime + "|" + entryUuid
 
        entryTags = "#" + journalName.replace(" ", "")
        if "tags" in entry:
            for tag in entry["tags"]:
                entryTags = entryTags + " #" + tag.replace(" ", "")
 
        if ("#Art" in entryTags) and ("#Photography" in entryTags):
            entryHeader = "## " + entryTime + " | Photography"
        else:
            entryHeader = "## " + entryTime + " | " + journalName
 
        entryLocation = ""
        if "location" in entry:
            entryLocation = "[" + entry["location"]["placeName"] + "](geo:" + str(round(entry["location"]["latitude"], 4)) + "," + str(round(entry["location"]["longitude"], 4)) + ")"
 
        entryWeather = ""
        if "weather" in entry:
            entryWeather = str(int(round(entry["weather"]["temperatureCelsius"], 0))) + " °C"
            if "conditionsDescription" in entry["weather"]:
                entryWeather = entryWeather + "" + entry["weather"]["conditionsDescription"]
            elif "weatherCode" in entry["weather"]:
                entryWeather = entryWeather + "" + entry["weather"]["weatherCode"]
 
        entryMedia = {}
        if "audios" in entry:
            for audioFile in entry["audios"]:
                entryMedia["[dayone-moment:/audio/" + audioFile["identifier"] + "]"] = "![[" + audioFile["md5"] + audioFile["format"] + "]]"
        if "pdfs" in entry:
            for pdfFile in entry["pdfs"]:
                entryMedia["[dayone-moment:/pdfAttachment/" + pdfFile["identifier"] + "]"] = "![[" + pdfFile["md5"] + ".pdf" + "]]"
        if "photos" in entry:
            for imageFile in entry["photos"]:
                entryMedia["[dayone-moment://" + imageFile["identifier"] + "]"] = "![[" + imageFile["md5"] + "." + imageFile["type"] + "]]"
        if "videos" in entry:
            for videoFile in entry["videos"]:
                entryMedia["[dayone-moment:/video/" + videoFile["identifier"] + "]"] = "![[" + videoFile["md5"] + "." + videoFile["type"] + "]]"
 
        entryText = entry["text"]
 
        for oldMediaLink, newMediaLink in entryMedia.items():
            entryText = entryText.replace(oldMediaLink, newMediaLink)
 
        entryText = entryText.replace("\n\n\n", "\n\n")
        entryText = entryText.replace(" ![[", "\n\n![[")
        entryText = entryText.replace("]]\n\n![[", "]]\n![[")
        entryText = entryText.replace(".\n![[", ".\n\n![[")
        entryText = entryText.replace("!\n![[", "!\n\n![[")
        entryText = entryText.replace("?\n![[", "?\n\n![[")
        entryText = entryText.replace(")\n![[", ")\n\n![[")
        entryText = entryText.replace("\\&", "&")
        entryText = entryText.replace("\\~", "~")
        entryText = entryText.replace("\\-", "-")
        entryText = entryText.replace("\\_", "_")
        entryText = entryText.replace("\\+", "+")
        entryText = entryText.replace("\\.", ".")
        entryText = entryText.replace("\\!", "!")
        entryText = entryText.replace("\\(", "(")
        entryText = entryText.replace("\\)", ")")
        entryText = entryText.replace("\\{", "{")
        entryText = entryText.replace("\\}", "}")
        entryText = entryText.replace("\\[\\]", "[]")
        entryText = entryText.replace("\\\\", "\\")
        entryText = entryText.replace("[ *", "[*")
        entryText = entryText.replace("* ]", "*]")
        entryText = entryText.replace(" --- ", " - ")
        entryText = entryText.replace(" -- ", " - ")
        entryText = entryText.strip()
 
        if entryDate not in journalData:
            journalData[entryDate] = {}
 
        journalData[entryDate][entryIndex] = {}
 
        journalData[entryDate][entryIndex]["header"] = entryHeader
        journalData[entryDate][entryIndex]["uuid"] = entryUuid
        journalData[entryDate][entryIndex]["datetime"] = entryDateTime
        journalData[entryDate][entryIndex]["tags"] = entryTags
        if len(entryLocation) > 0:
            journalData[entryDate][entryIndex]["location"] = entryLocation
        if len(entryWeather) > 0:
            journalData[entryDate][entryIndex]["weather"] = entryWeather
        journalData[entryDate][entryIndex]["text"] = entryText
 
journalData = dict(sorted(journalData.items()))
 
for journalDate, journalEntries in journalData.items():
    journalData[journalDate] = dict(sorted(journalEntries.items()))
 
for journalDate, journalEntries in journalData.items():
    journalDateTime = datetime.strptime(journalDate, "%Y-%m-%d")
 
    journalDayNumber = journalDateTime.strftime("%-d")
 
    journalDaySuffix = "th"
    if (journalDayNumber == "1") or (journalDayNumber == "21") or (journalDayNumber == "31"):
        journalDaySuffix = "st"
    elif (journalDayNumber == "2") or (journalDayNumber == "22"):
        journalDaySuffix = "nd"
    elif (journalDayNumber == "3") or (journalDayNumber == "23"):
        journalDaySuffix = "rd"
 
    journalDayName = journalDateTime.strftime("%A")
    journalHumanDate = journalDateTime.strftime("%B") + " " + journalDayNumber + journalDaySuffix + " " + journalDateTime.strftime("%Y")
    journalAmericanDate = journalDateTime.strftime("%-m/%-d/%Y")
 
    journalFileName = journalDate + " " + journalDayName + ".md"
 
    with open (journalFileName, "w") as journalFile:
        print("---", file = journalFile)
        print("title: Journal for " + journalDayName + ", " + journalHumanDate, file = journalFile)
        print("date: " + journalDate, file = journalFile)
        print("tags:", file = journalFile)
        print("  - Journal", file = journalFile)
        print("aliases:", file = journalFile)
        print("  - Journal for " + journalDayName + ", " + journalHumanDate, file = journalFile)
        print("  - " + journalDayName + ", " + journalHumanDate, file = journalFile)
        print("  - " + journalHumanDate, file = journalFile)
        print("  - " + journalDate, file = journalFile)
        print("  - " + journalAmericanDate, file = journalFile)
        print("locations:", file = journalFile)
        print("---", file = journalFile)
 
        for entryId, entry in journalEntries.items():
 
            print(entry["header"], file = journalFile)
            print("[dayOneId:: " + entry["uuid"] + "]", file = journalFile)
            print("[datetime:: " + entry["datetime"] + "]", file = journalFile)
            print("[tags:: " + entry["tags"] + "]", file = journalFile)
            if "location" in entry:
                print("[location:: " + entry["location"] + "]", file = journalFile)
            if "weather" in entry:
                print("[weather:: " + entry["weather"] + "]", file = journalFile)
            print("", file = journalFile)
            print(entry["text"], file = journalFile)
            print("", file = journalFile)

Note: the media-link replacement keys above reproduce the original dayone-moment: URL forms; Day One’s exact link syntax has varied across export versions, so verify one converted file against its source entry before running a full archive through.

  • spaced-repetition — another page in this wiki about personal note-taking practice
  • the wiki index.md, itself maintained as Markdown under version control like the output of this script

Sources