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" %>
<%= 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/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/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/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/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/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("") From cb78f2085106c99eb718921adbfceabb41f1b2d7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 20:11:13 +0000 Subject: [PATCH 2/9] Fix Day One ZIP extraction under rubyzip 3 Use get_input_stream writes instead of Entry#extract, which raises ENOENT in this environment, and keep path-traversal guards. Co-authored-by: Paul Arterburn --- app/services/day_one_importer.rb | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/app/services/day_one_importer.rb b/app/services/day_one_importer.rb index 85073a1d..5f179e19 100644 --- a/app/services/day_one_importer.rb +++ b/app/services/day_one_importer.rb @@ -56,9 +56,13 @@ def extract_zip!(tmpdir) next if entry.name.include?("..") next if entry.name.include?("__MACOSX/") - dest = File.join(tmpdir, entry.name) + 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)) - entry.extract(dest) { true } + File.open(dest, "wb") do |f| + f.write(entry.get_input_stream.read) + end end end end From c9a2ca0441db78f2590eb2c10ce6d650ba3a9eee Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 20:23:56 +0000 Subject: [PATCH 3/9] Add Day One alternative SEO page for journal import Keep MCP comparison focused on AI architecture; add /day-one-alternative for migration/import intent, with sitemap, llms.txt, and cross-links. Co-authored-by: Paul Arterburn --- PROJECT_INTENT.md | 1 + app/controllers/welcome_controller.rb | 4 + .../best_journaling_apps_with_mcp.html.erb | 1 + .../welcome/day_one_ai_journaling.html.erb | 11 ++ .../welcome/day_one_alternative.html.erb | 135 ++++++++++++++++++ config/routes.rb | 1 + public/llms.txt | 1 + public/sitemap.xml | 8 +- spec/features/welcome_spec.rb | 15 ++ spec/requests/mcp_public_discovery_spec.rb | 1 + 10 files changed, 177 insertions(+), 1 deletion(-) create mode 100644 app/views/welcome/day_one_alternative.html.erb diff --git a/PROJECT_INTENT.md b/PROJECT_INTENT.md index a5b1ae48..fa771d00 100644 --- a/PROJECT_INTENT.md +++ b/PROJECT_INTENT.md @@ -22,6 +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)). +- 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/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/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..a177937d 100644 --- a/app/views/welcome/day_one_ai_journaling.html.erb +++ b/app/views/welcome/day_one_ai_journaling.html.erb @@ -107,6 +107,17 @@

    +
    +

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

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

    Example prompts for either journal

    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/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-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/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" %>.

    diff --git a/app/views/welcome/index.html.erb b/app/views/welcome/index.html.erb index 1a5e7112..999738f8 100644 --- a/app/views/welcome/index.html.erb +++ b/app/views/welcome/index.html.erb @@ -295,15 +295,19 @@ sample_entry = sample_entries.sample

    -
    -
    - - - +
    +
    +
    + + + +
    + PRO
    -

    Always Private

    +

    Secure AI Connector

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

    @@ -550,7 +554,7 @@ sample_entry = sample_entries.sample - MCP (Claude & compatible apps) + Secure AI connector (MCP)
  • From dc196116e68ecc28220265f3986a8c348c1494e0 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 20:29:10 +0000 Subject: [PATCH 6/9] Add MCP footer link and Search page AI callout Surface the MCP docs from the logged-in app footer, and market the Secure AI connector on Search with a Write-page-style callout. Co-authored-by: Paul Arterburn --- app/assets/stylesheets/application.scss | 5 ++++- app/views/searches/show.html.haml | 14 ++++++++++++++ app/views/shared/_footer.html.haml | 3 +++ 3 files changed, 21 insertions(+), 1 deletion(-) 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/views/searches/show.html.haml b/app/views/searches/show.html.haml index 3d38259e..fad4a547 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 Secure 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..8aaad2c9 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 "MCP", 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;"  ·  From 31367afd415a2f44511ab213afcc68a8f049ccb2 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 20:29:44 +0000 Subject: [PATCH 7/9] =?UTF-8?q?Use=20=E2=80=9CAI=20connector=E2=80=9D=20in?= =?UTF-8?q?stead=20of=20MCP=20in=20product=20marketing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep MCP on technical docs pages; plain-language surfaces now say AI connector on features, pricing, footer, and Search callout. Co-authored-by: Paul Arterburn --- app/views/searches/show.html.haml | 2 +- app/views/shared/_footer.html.haml | 2 +- app/views/welcome/_pro_features.html.erb | 6 +++--- app/views/welcome/index.html.erb | 6 +++--- 4 files changed, 8 insertions(+), 8 deletions(-) diff --git a/app/views/searches/show.html.haml b/app/views/searches/show.html.haml index fad4a547..acab2647 100644 --- a/app/views/searches/show.html.haml +++ b/app/views/searches/show.html.haml @@ -86,7 +86,7 @@ %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 Secure AI connector + %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 diff --git a/app/views/shared/_footer.html.haml b/app/views/shared/_footer.html.haml index 8aaad2c9..ab959dbb 100644 --- a/app/views/shared/_footer.html.haml +++ b/app/views/shared/_footer.html.haml @@ -10,7 +10,7 @@ =link_to "Support", support_path, title: "Support & FAQs", style: "font-size: 15px;" - if user_signed_in?  ·  - = link_to "MCP", mcp_server_docs_path, title: "Connect ChatGPT or Claude to your journal", style: "font-size: 15px;" + = 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 495929f7..84013f09 100644 --- a/app/views/welcome/_pro_features.html.erb +++ b/app/views/welcome/_pro_features.html.erb @@ -98,16 +98,16 @@

    - +
    -

    Secure AI Connector

    +

    AI Connector

    - 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" %>.

    diff --git a/app/views/welcome/index.html.erb b/app/views/welcome/index.html.erb index 999738f8..aab5077c 100644 --- a/app/views/welcome/index.html.erb +++ b/app/views/welcome/index.html.erb @@ -304,9 +304,9 @@ sample_entry = sample_entries.sample
    PRO -

    Secure AI Connector

    +

    AI Connector

    - 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
  • From 379d3f473baaec879c5b10126e9f03d9746f9480 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Sun, 2 Aug 2026 20:30:16 +0000 Subject: [PATCH 8/9] Keep Always Private header on features callout Lead with privacy; mention the optional AI connector in the body copy. Co-authored-by: Paul Arterburn --- app/views/welcome/_pro_features.html.erb | 5 ++--- app/views/welcome/index.html.erb | 18 +++++++----------- 2 files changed, 9 insertions(+), 14 deletions(-) diff --git a/app/views/welcome/_pro_features.html.erb b/app/views/welcome/_pro_features.html.erb index 84013f09..03af4526 100644 --- a/app/views/welcome/_pro_features.html.erb +++ b/app/views/welcome/_pro_features.html.erb @@ -105,10 +105,9 @@ -

    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" %>.

    diff --git a/app/views/welcome/index.html.erb b/app/views/welcome/index.html.erb index aab5077c..85140528 100644 --- a/app/views/welcome/index.html.erb +++ b/app/views/welcome/index.html.erb @@ -295,19 +295,15 @@ sample_entry = sample_entries.sample

    -
    -
    -
    - - - -
    - PRO +
    +
    + + +
    -

    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.

    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 %>