diff --git a/PROJECT_INTENT.md b/PROJECT_INTENT.md index 38efcd12..fa771d00 100644 --- a/PROJECT_INTENT.md +++ b/PROJECT_INTENT.md @@ -22,7 +22,8 @@ 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. +- There's a Day One migration page at `/day-one-alternative` (import + email journaling), kept separate from the MCP comparison at `/dabble-me-vs-day-one-ai-journaling`. +- 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/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 2210b549..e043d132 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -376,7 +376,8 @@ strong { } } - .j-copy-to-clipboard { + .j-copy-to-clipboard, + .email-post-cta { background: $olive; color: white; border: none; @@ -389,6 +390,8 @@ strong { } &:hover { background: darken($olive, 10%); + color: white; + text-decoration: none; } } } 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/controllers/welcome_controller.rb b/app/controllers/welcome_controller.rb index 0ef2b676..ea911d63 100644 --- a/app/controllers/welcome_controller.rb +++ b/app/controllers/welcome_controller.rb @@ -38,6 +38,10 @@ def day_one_ai_journaling # Comparison page for people choosing an MCP-enabled journal. end + def day_one_alternative + # SEO landing page for Day One users considering email journaling + import. + end + def best_journaling_apps_with_mcp # Guide to vendor-supported journaling apps with MCP. end 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..5f179e19 --- /dev/null +++ b/app/services/day_one_importer.rb @@ -0,0 +1,216 @@ +# 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.expand_path(File.join(tmpdir, entry.name)) + next unless dest.start_with?(File.expand_path(tmpdir) + File::SEPARATOR) || dest == File.expand_path(tmpdir) + + FileUtils.mkdir_p(File.dirname(dest)) + File.open(dest, "wb") do |f| + f.write(entry.get_input_stream.read) + end + 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" %>
<%= link_to subscribe_path, class: "float-left", style: "margin-top: -7px;" do %> @@ -77,6 +81,28 @@
+ + <% elsif params[:type]&.downcase == "day_one" %> +
+ <%= form_tag(import_process_path(type: "day_one"), multipart: true, method: :put, style: "text-align: center; padding-top: 10px;") do %> +
<%= label_tag 'zip_file', "Choose Day One JSON ZIP export" %>
+
<%= file_field_tag 'zip_file', class: "center", accept: ".zip,application/zip" %>
+ <%= 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. + +

+
+
<% else %> <%= form_for :entry, url: import_process_path(type: type), method: :put, html: { class: add_class } do |f| %>
@@ -96,8 +122,10 @@ Try other import formats: <%= link_to "AhhLife", import_path('ahhlife') %> · + <%= link_to "Day One", import_path('day_one') %> + · <%= link_to "OhLife", import_path('ohlife') %> · <%= link_to "Trailmix.life", import_path('trailmix') %> -
+
diff --git a/app/views/searches/show.html.haml b/app/views/searches/show.html.haml index 3d38259e..acab2647 100644 --- a/app/views/searches/show.html.haml +++ b/app/views/searches/show.html.haml @@ -78,3 +78,17 @@ - else %i Tip: Use hashtags throughout your entries and you'll see a tag cloud appear here making it easy to search for posts containing those hashtags. + + .email-post-card + .email-post-flex + .email-post-content + %h3 Ask ChatGPT or Claude about your journal + %p.text-muted + Connect Dabble Me once, then ask things like “What was I writing about last spring?” or “When did I mention burnout?” + .email-address-box + %code AI connector + = link_to mcp_server_docs_path, class: "email-post-cta", style: "text-decoration: none; display: inline-block;" do + %i.fa.fa-external-link + How to connect + .email-post-footer + You approve access each time an app connects. Nothing runs until you choose to. PRO feature. diff --git a/app/views/shared/_footer.html.haml b/app/views/shared/_footer.html.haml index cd17973b..ab959dbb 100644 --- a/app/views/shared/_footer.html.haml +++ b/app/views/shared/_footer.html.haml @@ -8,6 +8,9 @@ =link_to "Features", root_path(s: 0, anchor: "features"), title: "Features", style: "font-size: 15px;"  ·  =link_to "Support", support_path, title: "Support & FAQs", style: "font-size: 15px;" + - if user_signed_in? +  ·  + = link_to "AI Connector", mcp_server_docs_path, title: "Connect ChatGPT or Claude to your journal", style: "font-size: 15px;"  ·  = link_to "Privacy", privacy_path, title: "Privacy Policy", style: "font-size: 15px;"  ·  diff --git a/app/views/welcome/_pro_features.html.erb b/app/views/welcome/_pro_features.html.erb index 96968b8d..03af4526 100644 --- a/app/views/welcome/_pro_features.html.erb +++ b/app/views/welcome/_pro_features.html.erb @@ -98,16 +98,16 @@

- +
- +
-

MCP for Claude & more

+

Always Private

- Connect compatible assistants over the Model Context Protocol to search, analyze, and add entries. + 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" %>.

diff --git a/app/views/welcome/best_journaling_apps_with_mcp.html.erb b/app/views/welcome/best_journaling_apps_with_mcp.html.erb index 9a427b0e..9b5554c5 100644 --- a/app/views/welcome/best_journaling_apps_with_mcp.html.erb +++ b/app/views/welcome/best_journaling_apps_with_mcp.html.erb @@ -139,6 +139,7 @@ diff --git a/app/views/welcome/day_one_ai_journaling.html.erb b/app/views/welcome/day_one_ai_journaling.html.erb index 9c2b4410..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 %> <% end %> @@ -21,13 +21,36 @@

AI journaling comparison

Dabble Me vs. Day One for AI journaling

- Both products now offer vendor-supported MCP access for ChatGPT, Claude, and other AI assistants. - The main difference is architectural: Dabble Me is a hosted, email-first remote MCP service, while Day One’s - official MCP server runs locally through the Day One app and CLI on a Mac. + Both products offer an official AI connector for ChatGPT, Claude, and other assistants. + The practical difference is remote vs. local: Dabble Me’s connector + lives in the cloud and works wherever you use ChatGPT or Claude — including on a phone — + while Day One’s connector runs on your Mac and is available when that desktop setup is running.

-

Feature information reviewed July 23, 2026.

+

Feature information reviewed August 2, 2026.

+
+

Remote connector vs. local connector

+
+
+

Dabble Me — remote

+

+ The AI connector is hosted by Dabble Me. You connect by URL once, approve access, and can use it + from the ChatGPT or Claude apps on mobile and desktop — + no Mac app or local process has to stay running. +

+
+
+

Day One — local

+

+ The AI connector runs on your computer through Day One for Mac and its CLI. + That means it is a desktop / Mac setup: it is not something you + carry with you as a phone-only connection the way a remote URL connector is. +

+
+
+
+
@@ -38,6 +61,16 @@ + + + + + + + + + + @@ -70,8 +103,8 @@ - - + +
Connector typeRemote (cloud-hosted URL)Local (runs on your Mac)
Where it worksMobile and desktop ChatGPT / Claude appsDesktop Mac setup — not a mobile-first connector
MCP transport Remote Streamable HTTP
Best fitEmail-first writers who want a cloud-hosted Claude or ChatGPT journal connectionMac users who want local MCP access to native Day One journalsWriters who want ChatGPT or Claude on phone and desktop without keeping a Mac process onlineMac users who want the connector to run locally against Day One journals on that computer
@@ -80,9 +113,9 @@

Choose Dabble Me when…

    -
  • You want a journal app for ChatGPT or Claude that connects by URL with no local MCP process.
  • +
  • You want a journal app for ChatGPT or Claude that works on mobile and desktop via a remote connector.
  • +
  • You do not want to leave a Mac app and CLI running for the AI connection to work.
  • Email is the habit you already keep: prompts arrive in your inbox and replies become journal entries.
  • -
  • You use more than one operating system or do not want the integration tied to a Mac that must be available.
  • You want built-in aggregate reflection tools for counts, top hashtags, writing volume, and sample highlights.
@@ -90,8 +123,8 @@

Choose Day One when…

    -
  • You already keep journals in Day One and use a Mac.
  • -
  • You prefer the MCP server to run locally and read the Day One data available on that computer.
  • +
  • You already keep journals in Day One and primarily use a Mac.
  • +
  • You prefer a local desktop connector that reads Day One data on that computer — accepting it is not a mobile-first setup.
  • You maintain multiple journals and want to authorize MCP access journal by journal.
  • Native Apple apps are more important to you than an email-first workflow.
@@ -100,13 +133,24 @@

Privacy trade-off: local server vs. remote service

- Day One’s MCP server makes its journal access locally on the Mac. Dabble Me’s server runs in Dabble Me’s hosted + Day One’s local connector keeps the MCP process on your Mac. Dabble Me’s remote connector runs in Dabble Me’s hosted environment and limits calls to the OAuth-authorized account. In either case, the AI client normally sends retrieved journal content to its model provider to answer your prompt. Review that provider’s privacy settings before connecting highly sensitive writing.

+
+

Already writing in Day One?

+

+ 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 AI connectors. +

+ + Day One alternative & journal import → + +
+

Example prompts for either journal

@@ -131,11 +175,11 @@
-

Try the email-first MCP journal

-

Build the writing habit first, then ask ChatGPT or Claude to help you reflect.

+

Try the email-first AI journal

+

Build the writing habit first, then ask ChatGPT or Claude to help you reflect — on phone or desktop.

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

+ +
+ +
+

How to move your Day One journal

+
    +
  1. + 1 + In Day One, export your journal as JSON and include media if you want photos. +
  2. +
  3. + 2 + Create a Dabble Me account (PRO unlocks imports) and open + Entries → Import → Day One. +
  4. +
  5. + 3 + Upload the ZIP Day One created. Dabble Me imports your entries and emails you when it finishes. +
  6. +
+

+ 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 OneDabble Me
Daily writing habitNative apps on Apple platforms and AndroidEmail prompts; reply to create an entry
Bringing your historyJSON / PDF / Markdown exportsImport Day One JSON ZIP (plus OhLife, Ahhlife, Trailmix)
PhotosRich media in the Day One appsOne image per day (collage if several photos import)
AI / MCPOfficial local MCP on MacHosted remote MCP for ChatGPT and Claude
Best fitPeople who love Day One’s native appsPeople 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. +

+ + Dabble Me vs. Day One for AI journaling → + +
+ +
+

Bring your Day One history with you

+

Sign up free, upgrade to PRO when you are ready to import, and keep the habit in your inbox.

+ +
+
+
diff --git a/app/views/welcome/index.html.erb b/app/views/welcome/index.html.erb index 1a5e7112..85140528 100644 --- a/app/views/welcome/index.html.erb +++ b/app/views/welcome/index.html.erb @@ -303,7 +303,7 @@ sample_entry = sample_entries.sample

Always Private

- No artitifial intelligence. 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.

@@ -550,7 +550,7 @@ sample_entry = sample_entries.sample - MCP (Claude & compatible apps) + AI connector
  • diff --git a/app/views/welcome/ohlife_alternative.html.erb b/app/views/welcome/ohlife_alternative.html.erb index e1d36b2a..30898a6c 100644 --- a/app/views/welcome/ohlife_alternative.html.erb +++ b/app/views/welcome/ohlife_alternative.html.erb @@ -33,7 +33,7 @@ - Import OhLife / Ahhlife / Trailmix.life Entries + Import OhLife / Day One / Ahhlife / Trailmix.life Entries
  • 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/db/seeds.rb b/db/seeds.rb index bb0e830f..5d93d573 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -4,6 +4,8 @@ Inspiration.create(category: 'Seed', body: 'Seeded into database') Inspiration.create(category: 'OhLife', body: 'Imported from OhLife') Inspiration.create(category: 'Ahhlife', body: 'Imported from Ahhlife') +Inspiration.create(category: 'Day One', body: 'Imported from Day One') +Inspiration.create(category: 'Trailmix', body: 'Imported from Trailmix') (1..30).each do |i| Inspiration.create(category: ['Question', 'Quote', 'Tip'].sample, body: Faker::Hipster.sentence) end 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-02 monthly 0.7 + + https://dabble.me/day-one-alternative + 2026-08-02 + monthly + 0.8 + https://dabble.me/best-journaling-apps-with-mcp 2026-07-23 diff --git a/spec/controllers/import_controller_spec.rb b/spec/controllers/import_controller_spec.rb index a0ca7915..42718584 100644 --- a/spec/controllers/import_controller_spec.rb +++ b/spec/controllers/import_controller_spec.rb @@ -51,6 +51,45 @@ end end + describe "PUT #update day_one" do + it "rejects non-zip uploads for day_one" do + sign_in paid_user + file = Tempfile.new(["not", ".json"]) + file.write("{}") + file.rewind + upload = Rack::Test::UploadedFile.new(file.path, "application/json", original_filename: "Journal.json") + + put :update, params: { type: "day_one", zip_file: upload } + + expect(response).to redirect_to(import_path(type: "day_one")) + expect(flash[:alert]).to match(/Only \.zip/) + expect(ImportDayOneJob).not_to have_been_enqueued + ensure + file.close! + end + + it "enqueues ImportDayOneJob with a path under tmp/imports" do + sign_in paid_user + file = Tempfile.new(["journal", ".zip"]) + file.write("PK\x03\x04fake") + file.rewind + upload = Rack::Test::UploadedFile.new(file.path, "application/zip", original_filename: "Journal.zip") + + expect { + put :update, params: { type: "day_one", zip_file: upload } + }.to have_enqueued_job(ImportDayOneJob).with { |_user_id, path| + expect(path).to start_with(ImportUploadStore::BASE_DIR.to_s) + expect(path).to include("/day_one/") + expect(File.basename(path)).to eq("upload.zip") + } + + expect(response).to redirect_to(entries_path) + expect(flash[:notice]).to match(/Day One import has started/) + ensure + file.close! + end + end + describe "POST #process_ohlife_images" do it "rejects non-zip uploads" do sign_in paid_user diff --git a/spec/features/welcome_spec.rb b/spec/features/welcome_spec.rb index 09faa0a1..0258faae 100644 --- a/spec/features/welcome_spec.rb +++ b/spec/features/welcome_spec.rb @@ -72,9 +72,27 @@ visit day_one_ai_journaling_path expect(page).to have_title 'Dabble Me vs. Day One for AI Journaling and MCP — Dabble me.' + expect(page).to have_content 'Remote connector vs. local connector' + expect(page).to have_content 'Mobile and desktop ChatGPT / Claude apps' + expect(page).to have_content 'Desktop Mac setup — not a mobile-first connector' 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/jobs/import_day_one_job_spec.rb b/spec/jobs/import_day_one_job_spec.rb new file mode 100644 index 00000000..bcd17415 --- /dev/null +++ b/spec/jobs/import_day_one_job_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require "rails_helper" + +RSpec.describe ImportDayOneJob, type: :job do + include_context "has all objects" + include ActiveJob::TestHelper + + after { FileUtils.rm_rf(ImportUploadStore::BASE_DIR) } + + it "imports entries, emails the user, and cleans up the upload" do + dir = ImportUploadStore::BASE_DIR.join("day_one", paid_user.user_key, SecureRandom.uuid) + FileUtils.mkdir_p(dir) + zip_path = dir.join("upload.zip") + + require "zip" + Zip::File.open(zip_path, create: true) do |zip| + zip.get_output_stream("Journal.json") do |f| + f.write({ + "entries" => [ + { + "creationDate" => "2018-05-05T10:00:00Z", + "timeZone" => "UTC", + "text" => "Job import works" + } + ] + }.to_json) + end + end + + expect { + described_class.perform_now(paid_user.id, zip_path.to_s) + }.to have_enqueued_job(ActionMailer::MailDeliveryJob) + + entry = paid_user.existing_entry("2018-05-05") + expect(entry.body).to include("Job import works") + expect(entry.inspiration.category).to eq("Day One") + expect(File.exist?(dir)).to eq(false) + end +end 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 diff --git a/spec/services/day_one_importer_spec.rb b/spec/services/day_one_importer_spec.rb new file mode 100644 index 00000000..9738dc47 --- /dev/null +++ b/spec/services/day_one_importer_spec.rb @@ -0,0 +1,135 @@ +# frozen_string_literal: true + +require "rails_helper" +require "zip" + +RSpec.describe DayOneImporter do + include_context "has all objects" + + def build_zip(entries:, photos: {}) + path = Rails.root.join("tmp", "day_one_spec_#{SecureRandom.hex}.zip") + FileUtils.mkdir_p(File.dirname(path)) + Zip::File.open(path, create: true) do |zip| + zip.get_output_stream("Journal.json") do |f| + f.write(JSON.pretty_generate("entries" => entries)) + end + photos.each do |filename, bytes| + zip.get_output_stream("photos/#{filename}") { |f| f.write(bytes) } + end + end + path.to_s + end + + # 1x1 JPEG + let(:tiny_jpeg) do + Base64.decode64("/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsLDBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAABAAEDASIAAhEBAxEB/8QAFQABAQAAAAAAAAAAAAAAAAAAAAn/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/8QAFQEBAQAAAAAAAAAAAAAAAAAAAAX/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oADAMBAAIQAxAAAAGfAP/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQEAAQUCf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQMBAT8Bf//EABQRAQAAAAAAAAAAAAAAAAAAAAD/2gAIAQIBAT8Bf//Z") + end + + after do + Dir.glob(Rails.root.join("tmp", "day_one_spec_*.zip")).each { |f| FileUtils.rm_f(f) } + end + + describe "#import!" do + it "imports markdown entries with the Day One inspiration tag" do + zip = build_zip(entries: [ + { + "creationDate" => "2020-06-15T18:30:00Z", + "timeZone" => "America/Los_Angeles", + "text" => "# Hello\n\nThis is **bold** and a photo ![](dayone-moment://ABC123)." + } + ]) + + result = described_class.new(user: paid_user, zip_path: zip).import! + + expect(result.imported).to eq(1) + expect(result.errors).to be_empty + entry = paid_user.entries.find_by("date >= ? AND date < ?", Date.new(2020, 6, 15), Date.new(2020, 6, 16)) + expect(entry).to be_present + # 18:30 UTC on 2020-06-15 is 11:30 PDT → still June 15 + expect(entry.date.to_date).to eq(Date.new(2020, 6, 15)) + expect(entry.body).to include("bold") + expect(entry.body).to include("Hello") + expect(entry.body).not_to include("dayone-moment") + expect(entry.inspiration.category).to eq("Day One") + end + + it "merges multiple Day One entries on the same local calendar day" do + zip = build_zip(entries: [ + { + "creationDate" => "2021-01-02T08:00:00Z", + "timeZone" => "UTC", + "text" => "Morning thoughts" + }, + { + "creationDate" => "2021-01-02T20:00:00Z", + "timeZone" => "UTC", + "text" => "Evening thoughts" + } + ]) + + result = described_class.new(user: paid_user, zip_path: zip).import! + + expect(result.imported).to eq(1) + entry = paid_user.existing_entry("2021-01-02") + expect(entry.body).to include("Morning thoughts") + expect(entry.body).to include("Evening thoughts") + expect(entry.body).to include("
    ") + end + + it "skips dates that already have a Dabble Me entry" do + paid_user.entries.create!(date: Date.new(2019, 3, 1), body: "

    Existing

    ") + zip = build_zip(entries: [ + { + "creationDate" => "2019-03-01T12:00:00Z", + "timeZone" => "UTC", + "text" => "Should not import" + } + ]) + + result = described_class.new(user: paid_user, zip_path: zip).import! + + expect(result.imported).to eq(0) + expect(result.skipped.join).to match(/already exists/) + expect(paid_user.existing_entry("2019-03-01").body).to include("Existing") + end + + it "attaches a single photo when present" do + md5 = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + zip = build_zip( + entries: [ + { + "creationDate" => "2022-04-01T12:00:00Z", + "timeZone" => "UTC", + "text" => "With photo", + "photos" => [ + { "identifier" => "ID1", "md5" => md5, "type" => "jpeg", "orderInEntry" => 0 } + ] + } + ], + photos: { "#{md5}.jpeg" => tiny_jpeg } + ) + + allow_any_instance_of(ImageUploader).to receive(:cache!) + allow_any_instance_of(ImageUploader).to receive(:store!) + + result = described_class.new(user: paid_user, zip_path: zip).import! + + expect(result.imported).to eq(1) + expect(result.errors).to be_empty + entry = paid_user.existing_entry("2022-04-01") + expect(entry.body).to include("With photo") + expect(entry.image_error).to be_blank + end + + it "rejects ZIPs without a journal JSON file" do + path = Rails.root.join("tmp", "day_one_spec_empty_#{SecureRandom.hex}.zip") + Zip::File.open(path, create: true) do |zip| + zip.get_output_stream("readme.txt") { |f| f.write("no json here") } + end + + expect { + described_class.new(user: paid_user, zip_path: path.to_s).import! + }.to raise_error(ArgumentError, /No JSON/) + end + end +end diff --git a/spec/services/import_upload_store_spec.rb b/spec/services/import_upload_store_spec.rb index d7332fd3..1f0f664e 100644 --- a/spec/services/import_upload_store_spec.rb +++ b/spec/services/import_upload_store_spec.rb @@ -38,6 +38,20 @@ def uploaded_file(filename:, content_type:, tempfile:) expect(File.stat(path).mode & 0o777).to eq(0o600) end + it "stores day_one ZIP outside public/ with a random filename" do + zip = Tempfile.new(["journal", ".zip"], tmpdir) + zip.write("PK\x03\x04") + zip.rewind + upload = uploaded_file(filename: "Journal.zip", content_type: "application/zip", tempfile: zip) + + path = described_class.store!(uploaded_file: upload, user_key: user_key, kind: "day_one") + + expect(path).to start_with(ImportUploadStore::BASE_DIR.to_s) + expect(path).to include("/day_one/") + expect(File.basename(path)).to eq("upload.zip") + expect(File.stat(path).mode & 0o777).to eq(0o600) + end + it "rejects non-json trailmix uploads" do html = Tempfile.new(["xss", ".html"], tmpdir) html.write("")