From eb66de30f4705e3bb805251fdf1286d484b68cb9 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 2 Aug 2026 20:10:27 +0000
Subject: [PATCH 1/9] Add Day One JSON ZIP importer for PRO users
Import Day One journal exports asynchronously: parse JSON entries,
convert markdown to HTML, merge same-day entries, attach photos
(or a collage), and tag with Inspiration category Day One.
Co-authored-by: Paul Arterburn
---
PROJECT_INTENT.md | 2 +-
README.md | 9 +-
app/controllers/import_controller.rb | 17 ++
app/helpers/application_helper.rb | 2 +-
app/jobs/import_day_one_job.rb | 54 +++++
app/models/entry.rb | 6 +-
app/models/inspiration.rb | 6 +-
app/services/collage_generator.rb | 19 ++
app/services/day_one_importer.rb | 212 ++++++++++++++++++
app/services/import_upload_store.rb | 16 +-
app/views/import/show.html.erb | 34 ++-
app/views/welcome/ohlife_alternative.html.erb | 2 +-
db/seeds.rb | 2 +
spec/controllers/import_controller_spec.rb | 39 ++++
spec/jobs/import_day_one_job_spec.rb | 40 ++++
spec/services/day_one_importer_spec.rb | 135 +++++++++++
spec/services/import_upload_store_spec.rb | 14 ++
17 files changed, 592 insertions(+), 17 deletions(-)
create mode 100644 app/jobs/import_day_one_job.rb
create mode 100644 app/services/day_one_importer.rb
create mode 100644 spec/jobs/import_day_one_job_spec.rb
create mode 100644 spec/services/day_one_importer_spec.rb
diff --git a/PROJECT_INTENT.md b/PROJECT_INTENT.md
index 38efcd12..a5b1ae48 100644
--- a/PROJECT_INTENT.md
+++ b/PROJECT_INTENT.md
@@ -22,7 +22,7 @@ Dabble Me was built as a **direct replacement for OhLife** — a much-loved emai
- There's a dedicated `OhLife` import flow ([entries/import/ohlife](app/controllers/import_controller.rb)) including image-archive upload.
- There's an SEO landing page at `/ohlife-alternative` ([welcome_controller.rb](app/controllers/welcome_controller.rb)).
-- Imported entries are tagged with `Inspiration.category = "OhLife"` so they're identifiable forever.
+- Imported entries are tagged with `Inspiration.category = "OhLife"` (and similarly Day One / Ahhlife / Trailmix) so they're identifiable forever.
If you're modifying behavior around imports, scheduling, or the email reply format, **do not break OhLife parity unless explicitly asked** — that audience is part of the product's reason for existing.
diff --git a/README.md b/README.md
index c5bbd6aa..af7ab9cd 100644
--- a/README.md
+++ b/README.md
@@ -69,21 +69,24 @@ rake
The Admin emails are accounts that have access to the Admin Dropdown in the navbar (lock icon) that give you details into the number of entries and users in the system.
-### Inspirations and OhLife Importer
+### Inspirations and Importers
-If you want random bits of inspiration, you can load up different quotes in the Inspiration table to be shown above the New Posts page and at the bottom of emails. If you plan on using OhLife, the system will tag imported posts with ```inspiration_id``` of 1 - so create the first Inspiration with a category name of "OhLife".
+If you want random bits of inspiration, you can load up different quotes in the Inspiration table to be shown above the New Posts page and at the bottom of emails. Import sources are tagged via Inspiration categories — seed OhLife / Ahhlife / Day One / Trailmix rows (see `db/seeds.rb`), or the Day One importer will create its category on first use.
```ruby
Inspiration.create(category: 'OhLife', body: 'Imported from OhLife')
+Inspiration.create(category: 'Day One', body: 'Imported from Day One')
```
+PRO users can import from OhLife (text + photo ZIP), Day One (JSON ZIP export), Ahhlife (JSON paste), and Trailmix.life (JSON upload) at `/entries/import`.
+
=====
**Current features:**
* Read past entries by month/year
* Create new entries with simple formatting
-* OhLife Importer
+* Importers: OhLife, Day One, Ahhlife, Trailmix.life
* Email: Reply-to-post new entries on days of the week you choose (with random past entries embedded)
* Associate 1 image to a specific entry
* Search with basic analytics around posting
diff --git a/app/controllers/import_controller.rb b/app/controllers/import_controller.rb
index ef581095..ebd3f218 100644
--- a/app/controllers/import_controller.rb
+++ b/app/controllers/import_controller.rb
@@ -14,6 +14,8 @@ def update
import_ahhlife_entries(params[:entry][:text])
elsif params[:type]&.downcase == "trailmix"
enqueue_trailmix_import
+ elsif params[:type]&.downcase == "day_one"
+ enqueue_day_one_import
else
import_ohlife_entries(params[:entry][:text])
end
@@ -66,6 +68,21 @@ def enqueue_trailmix_import
redirect_to import_path(type: "trailmix")
end
+ def enqueue_day_one_import
+ stored_path = ImportUploadStore.store!(
+ uploaded_file: params[:zip_file],
+ user_key: current_user.user_key,
+ kind: "day_one"
+ )
+ ImportDayOneJob.perform_later(current_user.id, stored_path)
+ flash[:notice] = "Day One import has started. You will receive an email when it is finished."
+ redirect_to entries_path
+ rescue ImportUploadStore::Error => e
+ FileUtils.rm_f(params[:zip_file]&.tempfile&.path) if params[:zip_file]
+ flash[:alert] = e.message
+ redirect_to import_path(type: "day_one")
+ end
+
def import_ohlife_entries(data)
errors = []
user = current_user
diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb
index fa7a0822..677350c3 100644
--- a/app/helpers/application_helper.rb
+++ b/app/helpers/application_helper.rb
@@ -184,7 +184,7 @@ def self.faqs
},
{
q: "Can I import from OhLife or other services?",
- a: "Yes! PRO members can visit the Importer to import entries from OhLife, Ahhlife, and Trailmix.life."
+ a: "Yes! PRO members can visit the Importer to import entries from OhLife, Day One, Ahhlife, and Trailmix.life."
},
{
q: "Can I get a refund?",
diff --git a/app/jobs/import_day_one_job.rb b/app/jobs/import_day_one_job.rb
new file mode 100644
index 00000000..4c3c2785
--- /dev/null
+++ b/app/jobs/import_day_one_job.rb
@@ -0,0 +1,54 @@
+# frozen_string_literal: true
+
+class ImportDayOneJob < ActiveJob::Base
+ queue_as :default
+
+ def perform(user_id, stored_path)
+ @user = User.find(user_id)
+ result = DayOneImporter.new(user: @user, zip_path: stored_path).import!
+ deliver_completion_email(result)
+ rescue StandardError => e
+ Sentry.capture_exception(e, extra: { user_id: user_id, stored_path: stored_path })
+ deliver_failure_email(e)
+ ensure
+ ImportUploadStore.cleanup!(stored_path)
+ end
+
+ private
+
+ def deliver_completion_email(result)
+ parts = []
+ parts << "Finished importing #{ActionController::Base.helpers.pluralize(result.imported, 'entry')} from Day One."
+ parts << "You can view them at #{::Rails.application.routes.url_helpers.entries_url}."
+
+ if result.skipped.present?
+ Sentry.capture_message("Day One import skipped entries", level: :info, extra: { skipped: result.skipped, user_id: @user.id })
+ parts << "Skipped: #{result.skipped.join('; ')}."
+ end
+
+ if result.errors.present?
+ Sentry.capture_message("Day One import errors", level: :info, extra: { errors: result.errors, user_id: @user.id })
+ parts << "Errors: #{result.errors.join('; ')}."
+ end
+
+ ActionMailer::Base.mail(
+ from: "Paul from Dabble Me ",
+ to: @user.email,
+ subject: "Import of Day One entries is complete",
+ content_type: "text/html",
+ body: parts.join(" ")
+ ).deliver_later
+ end
+
+ def deliver_failure_email(error)
+ return unless @user
+
+ ActionMailer::Base.mail(
+ from: "Paul from Dabble Me ",
+ to: @user.email,
+ subject: "Day One import failed",
+ content_type: "text/html",
+ body: "Your Day One import could not be completed (#{error.message}). Please try again with a Day One JSON ZIP export, or reply to this email for help."
+ ).deliver_later
+ end
+end
diff --git a/app/models/entry.rb b/app/models/entry.rb
index 7a74ed96..5109670d 100644
--- a/app/models/entry.rb
+++ b/app/models/entry.rb
@@ -240,7 +240,11 @@ def relocate_image_on_date_change
end
def associate_inspiration
- self.inspiration = nil unless self.inspiration.in? Inspiration.without_imports_or_email_or_tips
+ return if inspiration.blank?
+ # Keep import/source tags (OhLife, Day One, etc.) so importers can label origin.
+ return if inspiration.category.in?(Inspiration::IMPORT_CATEGORIES)
+
+ self.inspiration = nil unless inspiration.in?(Inspiration.without_imports_or_email_or_tips)
end
def strip_out_base64
diff --git a/app/models/inspiration.rb b/app/models/inspiration.rb
index af8f8a2d..fd1d7a03 100644
--- a/app/models/inspiration.rb
+++ b/app/models/inspiration.rb
@@ -1,6 +1,8 @@
class Inspiration < ActiveRecord::Base
+ IMPORT_CATEGORIES = ["OhLife", "Ahhlife", "Email", "Seed", "Trailmix", "Day One"].freeze
+
has_many :entries
- scope :without_imports_or_email, -> { where("category NOT IN (?)", ['OhLife', 'Ahhlife', 'Email', 'Seed', 'Trailmix']) }
+ scope :without_imports_or_email, -> { where("category NOT IN (?)", IMPORT_CATEGORIES) }
scope :without_imports_or_email_or_tips, -> { without_imports_or_email.where("category != 'Tip'") }
scope :writing_prompts, -> { where(category: "Question") }
@@ -8,7 +10,7 @@ class Inspiration < ActiveRecord::Base
validates :body, presence: true
def inspired_by
- if ["OhLife", "Email", "Ahhlife", "Seed", "Trailmix"].include? category
+ if IMPORT_CATEGORIES.include?(category)
"Source: #{category}"
elsif category == "Tip"
"Tip"
diff --git a/app/services/collage_generator.rb b/app/services/collage_generator.rb
index 79156cad..3f44ed55 100644
--- a/app/services/collage_generator.rb
+++ b/app/services/collage_generator.rb
@@ -284,6 +284,14 @@ def fetch_bytes(url, redirects_left = MAX_REDIRECTS, truncation_retries_left = M
return nil if redirects_left.negative?
return nil if url.blank?
+ # Local filesystem paths (used by Day One ZIP import) bypass HTTP.
+ local_path = local_image_path(url)
+ if local_path
+ return File.binread(local_path) if File.file?(local_path)
+
+ return nil
+ end
+
# Malformed strings (e.g. internal sentinels like "mailgun_collage:…" that
# leaked out of the email processor) should be dropped silently rather than
# paged to Sentry — they're a caller-contract problem, not a runtime fault
@@ -336,6 +344,17 @@ def fetch_bytes(url, redirects_left = MAX_REDIRECTS, truncation_retries_left = M
nil
end
+ def local_image_path(url)
+ path = url.to_s
+ if path.start_with?("file://")
+ path = path.delete_prefix("file://")
+ end
+ return nil unless path.start_with?("/")
+ return nil if path.include?("\0")
+
+ path
+ end
+
def sanitize_url(url)
url.to_s.gsub(%r{//[^/@]+@}, "//***@")[0, 200]
end
diff --git a/app/services/day_one_importer.rb b/app/services/day_one_importer.rb
new file mode 100644
index 00000000..85073a1d
--- /dev/null
+++ b/app/services/day_one_importer.rb
@@ -0,0 +1,212 @@
+# frozen_string_literal: true
+
+require "zip"
+
+# Imports a Day One JSON ZIP export into Dabble Me entries.
+#
+# Expected ZIP layout (Day One 2.x+):
+# Journal.json # or any *.json with an "entries" array
+# photos/.jpeg # optional media
+# photos/.png
+#
+# Multiple Day One entries on the same calendar day are merged into one Dabble
+# entry (Dabble Me is one-entry-per-day). Existing Dabble entries for a date
+# are skipped. At most one image is attached per day (single photo, or a
+# collage of up to CollageGenerator::MAX_IMAGES photos).
+class DayOneImporter
+ MOMENT_EMBED = /!\[.*?\]\(dayone-moment:\/\/[^)]+\)/i
+ PHOTO_TYPES = {
+ "jpeg" => %w[.jpeg .jpg],
+ "jpg" => %w[.jpg .jpeg],
+ "png" => %w[.png],
+ "gif" => %w[.gif],
+ "heic" => %w[.heic .heif],
+ "heif" => %w[.heif .heic],
+ "webp" => %w[.webp]
+ }.freeze
+
+ Result = Struct.new(:imported, :skipped, :errors, keyword_init: true)
+
+ def initialize(user:, zip_path:)
+ @user = user
+ @zip_path = zip_path
+ @imported = 0
+ @skipped = []
+ @errors = []
+ end
+
+ def import!
+ Dir.mktmpdir("day_one_import") do |tmpdir|
+ extract_zip!(tmpdir)
+ json_paths = Dir.glob(File.join(tmpdir, "**", "*.json")).reject { |p| p.include?("/__MACOSX/") }
+ raise ArgumentError, "No JSON journal file found in the ZIP" if json_paths.empty?
+
+ json_paths.each { |path| import_json_file(path, tmpdir) }
+ end
+
+ Result.new(imported: @imported, skipped: @skipped, errors: @errors)
+ end
+
+ private
+
+ def extract_zip!(tmpdir)
+ Zip::File.open(@zip_path) do |zipfile|
+ zipfile.each do |entry|
+ next if entry.directory?
+ next if entry.name.include?("..")
+ next if entry.name.include?("__MACOSX/")
+
+ dest = File.join(tmpdir, entry.name)
+ FileUtils.mkdir_p(File.dirname(dest))
+ entry.extract(dest) { true }
+ end
+ end
+ end
+
+ def import_json_file(path, tmpdir)
+ data = JSON.parse(File.read(path))
+ entries = data["entries"]
+ unless entries.is_a?(Array)
+ @errors << "#{File.basename(path)}: missing entries array"
+ return
+ end
+
+ grouped = entries.group_by { |raw| entry_date(raw) }
+ grouped.each do |date, day_entries|
+ import_day(date, day_entries, tmpdir)
+ end
+ rescue JSON::ParserError => e
+ @errors << "#{File.basename(path)}: invalid JSON (#{e.message})"
+ end
+
+ def import_day(date, day_entries, tmpdir)
+ if date.blank?
+ @errors << "Entry missing creationDate"
+ return
+ end
+
+ if @user.existing_entry(date).present?
+ @skipped << "Entry already exists for #{date}"
+ return
+ end
+
+ bodies = day_entries.sort_by { |e| e["creationDate"].to_s }.filter_map { |e| format_body(e["text"]) }
+ body = bodies.join("")
+ if body.blank? && day_entries.none? { |e| Array(e["photos"]).any? }
+ @skipped << "Empty entry for #{date}"
+ return
+ end
+
+ entry = @user.entries.new(
+ date: date,
+ body: body.presence || "",
+ inspiration: day_one_inspiration
+ )
+
+ photo_paths = collect_photo_paths(day_entries, tmpdir)
+ attach_photos!(entry, photo_paths)
+
+ if entry.save
+ @imported += 1
+ else
+ @errors << "#{date}: #{entry.errors.full_messages.to_sentence}"
+ end
+ rescue StandardError => e
+ @errors << "#{date}: #{e.message}"
+ Sentry.capture_exception(e, extra: { date: date, user_id: @user.id })
+ end
+
+ def entry_date(raw)
+ created = raw["creationDate"].presence
+ return nil if created.blank?
+
+ zone = Time.find_zone(raw["timeZone"].presence) || ActiveSupport::TimeZone["UTC"]
+ zone.parse(created).to_date
+ rescue ArgumentError, TypeError
+ Time.zone.parse(created).to_date
+ rescue StandardError
+ nil
+ end
+
+ def format_body(text)
+ cleaned = text.to_s.gsub(MOMENT_EMBED, "").strip
+ return nil if cleaned.blank?
+
+ html = markdown.render(cleaned)
+ html.gsub!(/\A(\s*
\s*<\/p>\s*)/, "")
+ html.gsub!(/(\s*
\s*<\/p>\s*)\z/, "")
+ html.presence
+ end
+
+ def markdown
+ @markdown ||= Redcarpet::Markdown.new(
+ Redcarpet::Render::HTML.new(hard_wrap: true, filter_html: true, escape_html: true),
+ autolink: true,
+ tables: true,
+ fenced_code_blocks: true,
+ strikethrough: true,
+ no_intra_emphasis: true
+ )
+ end
+
+ def day_one_inspiration
+ @day_one_inspiration ||= Inspiration.find_or_create_by!(category: "Day One") do |inspiration|
+ inspiration.body = "Imported from Day One"
+ end
+ end
+
+ def collect_photo_paths(day_entries, tmpdir)
+ paths = []
+ day_entries.sort_by { |e| e["creationDate"].to_s }.each do |raw|
+ Array(raw["photos"]).sort_by { |p| p["orderInEntry"].to_i }.each do |photo|
+ path = resolve_photo_path(photo, tmpdir)
+ paths << path if path
+ break if paths.size >= CollageGenerator::MAX_IMAGES
+ end
+ break if paths.size >= CollageGenerator::MAX_IMAGES
+ end
+ paths
+ end
+
+ def resolve_photo_path(photo, tmpdir)
+ md5 = photo["md5"].to_s
+ return nil if md5.blank? || md5.include?("..") || md5.include?("/")
+
+ type = photo["type"].to_s.downcase.presence || "jpeg"
+ extensions = PHOTO_TYPES[type] || [".#{type}", ".jpeg", ".jpg", ".png"]
+
+ extensions.each do |ext|
+ candidate = File.join(tmpdir, "photos", "#{md5}#{ext}")
+ return candidate if File.file?(candidate)
+ end
+
+ # Some exports nest media under a journal folder or use unexpected extensions.
+ Dir.glob(File.join(tmpdir, "**", "#{md5}.*")).find do |path|
+ next if path.include?("__MACOSX/")
+ next if path.include?("..")
+
+ File.file?(path)
+ end
+ end
+
+ def attach_photos!(entry, photo_paths)
+ return if photo_paths.blank?
+
+ if photo_paths.one?
+ File.open(photo_paths.first, "rb") { |f| entry.image = f }
+ return
+ end
+
+ collage = CollageGenerator.new(urls: photo_paths, user: @user).tempfile
+ if collage
+ entry.image = collage
+ else
+ File.open(photo_paths.first, "rb") { |f| entry.image = f }
+ end
+ ensure
+ if defined?(collage) && collage
+ collage.close
+ collage.unlink
+ end
+ end
+end
diff --git a/app/services/import_upload_store.rb b/app/services/import_upload_store.rb
index cfc6c203..0eafc170 100644
--- a/app/services/import_upload_store.rb
+++ b/app/services/import_upload_store.rb
@@ -5,14 +5,20 @@
class ImportUploadStore
BASE_DIR = Rails.root.join("tmp", "imports").freeze
+ ZIP_CONTENT_TYPES = %w[
+ application/zip
+ application/x-zip-compressed
+ application/octet-stream
+ ].freeze
+
ALLOWED = {
"ohlife" => {
extensions: %w[.zip].freeze,
- content_types: %w[
- application/zip
- application/x-zip-compressed
- application/octet-stream
- ].freeze
+ content_types: ZIP_CONTENT_TYPES
+ }.freeze,
+ "day_one" => {
+ extensions: %w[.zip].freeze,
+ content_types: ZIP_CONTENT_TYPES
}.freeze,
"trailmix" => {
extensions: %w[.json].freeze,
diff --git a/app/views/import/show.html.erb b/app/views/import/show.html.erb
index fa0b96c9..826f485b 100644
--- a/app/views/import/show.html.erb
+++ b/app/views/import/show.html.erb
@@ -1,5 +1,10 @@
-<% type = params[:type].present? ? params[:type].humanize : "OhLife" %>
+<% type = case params[:type]&.downcase
+ when "day_one" then "Day One"
+ when nil, "" then "OhLife"
+ else params[:type].humanize
+ end %>
<% content_for :title, "Import #{type} to Dabble Me" %>
+<% add_class = current_user.is_free? ? "blur" : nil %>
<% if type == "Ahhlife" %>
<% format_placeholder = '{"Jul-2015": {"-JtqbI_fF_2348SFrMqPQ": {"content": "Your entry here...","timestamp": 1436486400000}}}' %>
@@ -11,7 +16,6 @@
<% if current_user.is_free? %>
- <% add_class = "blur" %>
+ <%= submit_tag "Upload Day One ZIP", class: "btn btn-primary" %>
+
+ <% end %>
+
+
+ In Day One, export your journal as JSON and include media if you want photos imported.
+
+
+
+ Upload the ZIP Day One creates (it should contain a .json file and optional
+ photos/ folder). Multiple entries on the same day are combined into one Dabble Me entry.
+ Existing Dabble Me entries for a date are left unchanged.
+
+
+ If you want the habit in your inbox instead of (or alongside) Day One’s apps, Dabble Me can import a Day One
+ JSON ZIP export. That migration path is covered separately so this page can stay focused on MCP.
+
diff --git a/app/views/welcome/day_one_alternative.html.erb b/app/views/welcome/day_one_alternative.html.erb
new file mode 100644
index 00000000..153b0091
--- /dev/null
+++ b/app/views/welcome/day_one_alternative.html.erb
@@ -0,0 +1,135 @@
+<% content_for :title, "Day One Alternative: Import Your Journal into Dabble Me" %>
+<% content_for :social_title, "Day One alternative — email journaling with Dabble Me" %>
+<% content_for :description, "Looking for a Day One alternative? Import your Day One JSON export into Dabble Me and keep journaling by email — private prompts, reply-to-post entries, and optional remote MCP for ChatGPT and Claude." %>
+<% content_for :keywords, "Day One alternative,import Day One journal,Day One JSON export,switch from Day One,email journaling alternative,Day One to Dabble Me" %>
+
+<% content_for :head do %>
+
+<% end %>
+
+
+
+
+
Day One alternative
+
+ Import Day One. Journal by email.
+
+
+ Dabble Me is a private, email-first journal. Export your Day One journal as JSON, import the ZIP into Dabble Me PRO,
+ and keep writing by replying to a daily prompt — no new app to open every day.
+
+ 3
+ Upload the ZIP Day One created. Dabble Me imports your entries and emails you when it finishes.
+
+
+
+ Multiple Day One entries on the same calendar day are combined into one Dabble Me entry. Existing Dabble Me dates are left unchanged.
+
+
+
+
+
+
+
+
If you care about…
+
Day One
+
Dabble Me
+
+
+
+
+
Daily writing habit
+
Native apps on Apple platforms and Android
+
Email prompts; reply to create an entry
+
+
+
Bringing your history
+
JSON / PDF / Markdown exports
+
Import Day One JSON ZIP (plus OhLife, Ahhlife, Trailmix)
+
+
+
Photos
+
Rich media in the Day One apps
+
One image per day (collage if several photos import)
+
+
+
AI / MCP
+
Official local MCP on Mac
+
Hosted remote MCP for ChatGPT and Claude
+
+
+
Best fit
+
People who love Day One’s native apps
+
People who want the habit to live in their inbox
+
+
+
+
+
+
+
Choose Dabble Me as a Day One alternative when…
+
+
✓You want journaling reminders in email — and replies that become private entries.
+
✓You already have years in Day One and need a straightforward JSON ZIP import.
+
✓You use more than one device or OS and do not want the habit tied to a single app.
+
✓You want optional AI reflection through a remote MCP connection, not a local Mac server.
+
+
+
+
+
Stay with Day One when…
+
+
✓Day One’s native apps, multi-journal layout, and media features are central to how you write.
+
✓You prefer local MCP on a Mac over a hosted email-first journal.
+
✓You do not want to change your daily capture workflow.
+
+
+
+
+
Comparing AI access separately?
+
+ If you are choosing between Dabble Me and Day One specifically for ChatGPT or Claude MCP setup,
+ read the focused comparison — it stays on remote vs. local AI architecture, not migration.
+
+
diff --git a/config/routes.rb b/config/routes.rb
index 6a548437..cb32b36b 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -74,6 +74,7 @@
get 'support', to: 'welcome#support'
get 'mcp-server', to: 'welcome#mcp_server', as: :mcp_server_docs
get 'dabble-me-vs-day-one-ai-journaling', to: 'welcome#day_one_ai_journaling', as: :day_one_ai_journaling
+ get 'day-one-alternative', to: 'welcome#day_one_alternative', as: :day_one_alternative
get 'best-journaling-apps-with-mcp', to: 'welcome#best_journaling_apps_with_mcp', as: :best_journaling_apps_with_mcp
match 'mcp', to: 'mcp#invoke', via: %i[get post], format: :json, as: :mcp
diff --git a/public/llms.txt b/public/llms.txt
index 5dbf313e..47197fd8 100644
--- a/public/llms.txt
+++ b/public/llms.txt
@@ -6,6 +6,7 @@
- [Dabble Me MCP Server](https://dabble.me/mcp-server): Supported clients, setup, OAuth authentication, permissions, live tool reference, technical metadata, privacy notes, and example prompts.
- [Dabble Me vs. Day One for AI journaling](https://dabble.me/dabble-me-vs-day-one-ai-journaling): A balanced remote-vs-local MCP comparison.
+- [Day One alternative](https://dabble.me/day-one-alternative): Import a Day One JSON export into Dabble Me and journal by email.
- [Best journaling apps with MCP / Claude & ChatGPT Connectors](https://dabble.me/best-journaling-apps-with-mcp): Evaluation criteria, example prompts, and current vendor-supported options.
- [Privacy policy](https://dabble.me/privacy): How Dabble Me stores journal data and handles connected apps.
- [Support](https://dabble.me/support): Product FAQs.
diff --git a/public/sitemap.xml b/public/sitemap.xml
index f76206c4..c9835f69 100644
--- a/public/sitemap.xml
+++ b/public/sitemap.xml
@@ -22,10 +22,16 @@
https://dabble.me/dabble-me-vs-day-one-ai-journaling
- 2026-07-23
+ 2026-08-02monthly0.7
+
+ https://dabble.me/day-one-alternative
+ 2026-08-02
+ monthly
+ 0.8
+https://dabble.me/best-journaling-apps-with-mcp2026-07-23
diff --git a/spec/features/welcome_spec.rb b/spec/features/welcome_spec.rb
index 09faa0a1..6b13b8d0 100644
--- a/spec/features/welcome_spec.rb
+++ b/spec/features/welcome_spec.rb
@@ -75,6 +75,21 @@
expect(page).to have_content 'Remote Streamable HTTP'
expect(page).to have_content 'Local stdio process'
expect(page).to have_link('official MCP guide')
+ expect(page).to have_link('Day One alternative & journal import', href: day_one_alternative_path)
+ end
+
+ it 'publishes a Day One alternative page focused on import and email journaling' do
+ visit day_one_alternative_path
+
+ expect(page).to have_title 'Day One Alternative: Import Your Journal into Dabble Me — Dabble me.'
+ expect(page).to have_content 'Import Day One. Journal by email.'
+ expect(page).to have_content 'export your journal as JSON'
+ expect(page).to have_link('Open the Day One importer', href: import_path('day_one'))
+ expect(page).to have_link('Dabble Me vs. Day One for AI journaling', href: day_one_ai_journaling_path)
+ expect(page).to have_css('script[type="application/ld+json"]', visible: false)
+
+ description = page.find('meta[name="description"]', visible: false)['content']
+ expect(description).to include('Day One alternative')
end
it 'publishes a guide to journaling apps with MCP' do
diff --git a/spec/requests/mcp_public_discovery_spec.rb b/spec/requests/mcp_public_discovery_spec.rb
index 4afefed8..93824dfc 100644
--- a/spec/requests/mcp_public_discovery_spec.rb
+++ b/spec/requests/mcp_public_discovery_spec.rb
@@ -24,6 +24,7 @@
expect(sitemap).to include("#{public_url}/mcp-server")
expect(sitemap).to include("#{public_url}/dabble-me-vs-day-one-ai-journaling")
+ expect(sitemap).to include("#{public_url}/day-one-alternative")
expect(sitemap).to include("#{public_url}/best-journaling-apps-with-mcp")
expect(robots).to include("Sitemap: #{public_url}/sitemap.xml")
end
From cf528df535ccc01a22c529985874b1802404141f Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 2 Aug 2026 20:24:04 +0000
Subject: [PATCH 4/9] Bump Day One MCP page dateModified after cross-link
Co-authored-by: Paul Arterburn
---
app/views/welcome/day_one_ai_journaling.html.erb | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/views/welcome/day_one_ai_journaling.html.erb b/app/views/welcome/day_one_ai_journaling.html.erb
index a177937d..c8bdeb37 100644
--- a/app/views/welcome/day_one_ai_journaling.html.erb
+++ b/app/views/welcome/day_one_ai_journaling.html.erb
@@ -10,7 +10,7 @@
headline: "Dabble Me vs. Day One for AI Journaling and MCP",
description: content_for(:description),
url: "#{ApplicationHelper.site_public_base_url}/dabble-me-vs-day-one-ai-journaling",
- dateModified: "2026-07-23",
+ dateModified: "2026-08-02",
author: { "@type": "Organization", name: "Dabble Dev LLC" }
}.to_json)) %>
<% end %>
From 6f8c08bd78578c9f10b538c6c839ad2fc93e6088 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 2 Aug 2026 20:25:37 +0000
Subject: [PATCH 5/9] Update marketing features for Secure AI Connector
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Replace the outdated “no AI” privacy blurb with Secure AI Connector (MCP)
copy that keeps the no-social / journal-stays-yours message.
Co-authored-by: Paul Arterburn
---
app/views/welcome/_pro_features.html.erb | 9 +++++----
app/views/welcome/index.html.erb | 20 ++++++++++++--------
2 files changed, 17 insertions(+), 12 deletions(-)
diff --git a/app/views/welcome/_pro_features.html.erb b/app/views/welcome/_pro_features.html.erb
index 96968b8d..495929f7 100644
--- a/app/views/welcome/_pro_features.html.erb
+++ b/app/views/welcome/_pro_features.html.erb
@@ -98,16 +98,17 @@
-
+
-
MCP for Claude & more
+
Secure AI Connector
- Connect compatible assistants over the Model Context Protocol to search, analyze, and add entries.
+ Optional MCP for ChatGPT and Claude — you approve access, and nothing runs until you connect.
+ No social features. No sharing. No algorithms. Your journal stays yours.
<%= link_to "How to connect", mcp_server_docs_path, class: "text-accent hover:text-primary underline" %>.
- No artitifial intelligence. No social features. No sharing. No algorithms. Your journal stays yours.
+ Optional MCP for ChatGPT and Claude — you approve access, and nothing runs until you connect.
+ No social features. No sharing. No algorithms. Your journal stays yours.
- Optional MCP for ChatGPT and Claude — you approve access, and nothing runs until you connect.
+ Optional AI connector for ChatGPT and Claude — you approve access, and nothing runs until you connect.
No social features. No sharing. No algorithms. Your journal stays yours.
<%= link_to "How to connect", mcp_server_docs_path, class: "text-accent hover:text-primary underline" %>.
- Optional MCP for ChatGPT and Claude — you approve access, and nothing runs until you connect.
+ Optional AI connector for ChatGPT and Claude — you approve access, and nothing runs until you connect.
No social features. No sharing. No algorithms. Your journal stays yours.
@@ -554,7 +554,7 @@ sample_entry = sample_entries.sample
- Secure AI connector (MCP)
+ AI connector
-
AI Connector
+
Always Private
- Optional AI connector for ChatGPT and Claude — you approve access, and nothing runs until you connect.
- No social features. No sharing. No algorithms. Your journal stays yours.
+ No social features. No sharing. No algorithms. Optional AI connector for ChatGPT and Claude — you approve access, and nothing runs until you connect. Your journal stays yours.
<%= link_to "How to connect", mcp_server_docs_path, class: "text-accent hover:text-primary underline" %>.
- Optional AI connector for ChatGPT and Claude — you approve access, and nothing runs until you connect.
- No social features. No sharing. No algorithms. Your journal stays yours.
+ No social features. No sharing. No algorithms. Optional AI connector for ChatGPT and Claude — you approve access, and nothing runs until you connect. Your journal stays yours.
From 1ecc9ba66b436f1bc236f7ec615ee33faec4da18 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Sun, 2 Aug 2026 20:31:21 +0000
Subject: [PATCH 9/9] Clarify remote vs local AI connectors on Day One
comparison
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Spell out that Dabble Me’s remote connector works on mobile and
desktop, while Day One’s local connector is a Mac desktop setup.
Co-authored-by: Paul Arterburn
---
.../welcome/day_one_ai_journaling.html.erb | 67 ++++++++++++++-----
spec/features/welcome_spec.rb | 3 +
2 files changed, 53 insertions(+), 17 deletions(-)
diff --git a/app/views/welcome/day_one_ai_journaling.html.erb b/app/views/welcome/day_one_ai_journaling.html.erb
index c8bdeb37..bc030bd6 100644
--- a/app/views/welcome/day_one_ai_journaling.html.erb
+++ b/app/views/welcome/day_one_ai_journaling.html.erb
@@ -1,7 +1,7 @@
<% content_for :title, "Dabble Me vs. Day One for AI Journaling and MCP" %>
<% content_for :social_title, "Dabble Me vs. Day One for AI journaling" %>
-<% content_for :description, "Compare Dabble Me and Day One for MCP-enabled AI journaling: remote vs. local setup, ChatGPT and Claude access, writing workflows, security, and best fit." %>
-<% content_for :keywords, "Dabble Me vs Day One AI journaling,Day One MCP alternative,AI journal with MCP,journal app for ChatGPT,Claude journal integration" %>
+<% content_for :description, "Compare Dabble Me and Day One AI connectors: remote vs. local MCP, mobile and desktop access, ChatGPT and Claude setup, writing workflows, security, and best fit." %>
+<% content_for :keywords, "Dabble Me vs Day One AI journaling,Day One MCP alternative,AI journal with MCP,journal app for ChatGPT,Claude journal integration,remote MCP vs local MCP" %>
<% content_for :head do %>