Skip to content

Commit 484ddd4

Browse files
committed
Note import functionality
This commit adds a new import functionality to the settings menu, where users can upload CSV files to mark which messages they have read already. This is mainly useful to import existing reading status from mailboxes, but for now we provide no export scripts. The format also supports specifying notes, which allows users to import messages with tags.
1 parent ff79d84 commit 484ddd4

File tree

7 files changed

+299
-0
lines changed

7 files changed

+299
-0
lines changed

Gemfile

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ gem "jbuilder"
2222
gem "slim"
2323
gem "kaminari"
2424
gem "nokogiri"
25+
gem "csv"
2526

2627
# Use Active Model has_secure_password [https://guides.rubyonrails.org/active_model_basics.html#securepassword]
2728
gem "bcrypt", "~> 3.1"

Gemfile.lock

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ GEM
108108
crass (1.0.6)
109109
cssbundling-rails (1.4.3)
110110
railties (>= 6.0.0)
111+
csv (3.3.5)
111112
date (3.5.0)
112113
debug (1.11.0)
113114
irb (~> 1.10)
@@ -484,6 +485,7 @@ DEPENDENCIES
484485
bullet
485486
capybara
486487
cssbundling-rails
488+
csv
487489
debug
488490
factory_bot_rails
489491
faker
Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
# frozen_string_literal: true
2+
3+
require "csv"
4+
require "set"
5+
6+
class ReadStatusImportsController < ApplicationController
7+
before_action :require_authentication
8+
9+
def new
10+
@summary = nil
11+
end
12+
13+
def create
14+
uploaded = params[:import_file]
15+
unless uploaded.respond_to?(:read)
16+
flash.now[:alert] = "Please choose a CSV file to upload."
17+
@summary = nil
18+
return render :new
19+
end
20+
21+
@summary = import_csv(uploaded)
22+
render :new
23+
rescue CSV::MalformedCSVError => e
24+
flash.now[:alert] = "Invalid CSV file: #{e.message}"
25+
@summary = nil
26+
render :new
27+
end
28+
29+
private
30+
31+
def import_csv(uploaded)
32+
content = uploaded.read.to_s
33+
rows = parse_csv_rows(content)
34+
35+
result = {
36+
new_notes: 0,
37+
replaced_notes: 0,
38+
marked_read: 0,
39+
skipped_message_ids: [],
40+
warnings: []
41+
}
42+
43+
read_message_ids = Set.new
44+
note_builder = NoteBuilder.new(author: current_user)
45+
46+
rows.each do |message_id_raw, note_mode_raw, note_text_raw|
47+
message_id = message_id_raw.to_s.strip
48+
next if message_id.blank?
49+
50+
message = Message.find_by(message_id: message_id)
51+
unless message
52+
result[:skipped_message_ids] << message_id
53+
next
54+
end
55+
56+
if read_message_ids.add?(message.id)
57+
MessageReadRange.add_range(user: current_user, topic: message.topic, start_id: message.id, end_id: message.id)
58+
ThreadAwareness.mark_until(user: current_user, topic: message.topic, until_message_id: message.id)
59+
end
60+
61+
note_mode = note_mode_raw.to_s.strip.downcase
62+
next if note_mode.blank? || note_mode == "none"
63+
64+
note_text = note_text_raw.to_s
65+
if note_text.strip.blank?
66+
result[:warnings] << "Message #{message.message_id}: note text missing; skipped note import."
67+
next
68+
end
69+
70+
target_message =
71+
case note_mode
72+
when "message"
73+
message
74+
when "topic"
75+
nil
76+
else
77+
result[:warnings] << "Message #{message.message_id}: unknown note mode '#{note_mode_raw}'."
78+
next
79+
end
80+
81+
note_body = note_text.sub(/\A!autoimport\s*/i, "").strip
82+
note_body = "!autoimport #{note_body}".strip
83+
84+
imported_notes = Note.active
85+
.where(author: current_user, topic_id: message.topic_id, message_id: target_message&.id)
86+
.where("notes.body LIKE ?", "!autoimport%")
87+
.order(:id)
88+
89+
imported_note = imported_notes.first
90+
91+
if imported_note
92+
note_builder.update!(note: imported_note, body: note_body)
93+
result[:replaced_notes] += 1
94+
else
95+
note_builder.create!(topic: message.topic, message: target_message, body: note_body)
96+
result[:new_notes] += 1
97+
end
98+
99+
imported_notes.offset(1).each do |extra|
100+
extra.transaction do
101+
extra.update!(deleted_at: Time.current)
102+
extra.note_mentions.delete_all
103+
extra.note_tags.delete_all
104+
extra.activities.update_all(hidden: true)
105+
end
106+
end
107+
rescue NoteBuilder::Error, ActiveRecord::RecordInvalid => e
108+
result[:warnings] << "Message #{message.message_id}: #{e.message}"
109+
end
110+
111+
result[:marked_read] = read_message_ids.size
112+
result
113+
end
114+
115+
def parse_csv_rows(content)
116+
return [] if content.blank?
117+
118+
with_headers = CSV.parse(content, headers: true)
119+
headers = with_headers.headers.compact.map { |h| h.to_s.strip.downcase }
120+
121+
if headers.include?("message_id")
122+
with_headers.map do |row|
123+
[
124+
row["message_id"],
125+
row["notemode"] || row["note_mode"],
126+
row["note"]
127+
]
128+
end
129+
else
130+
CSV.parse(content, headers: false).map do |row|
131+
[row[0], row[1], row[2]]
132+
end
133+
end
134+
end
135+
end
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
- content_for :title, "Import read status"
2+
3+
.settings-page
4+
h1 Import existing read status
5+
6+
.settings-section
7+
ul
8+
li
9+
p Upload a CSV file with rows like:
10+
pre message_id,notemode,note
11+
li All mentioned message_ids that are present in hackorum will be marked as read.
12+
li If a proper note is present for the message_id, it will be also created or updated
13+
li This is mainly intended for importing existing mailbox folders/tags
14+
li The header row is optional. If present, use message_id, notemode, note as column names.
15+
li message_id is required and should be the email Message-ID value without the &lt; and &gt; characters (same as the postgresql.org archive message ID)
16+
li notemode is optional and can be none, message, or topic (case-insensitive).
17+
li note is optional. If note is empty or missing, no note is imported even when notemode is set.
18+
li For notemode=message, notes attach to that message. For notemode=topic, notes attach to the topic (thread).
19+
li Notes are stored as "!autoimport &lt;your note&gt;" so you can include @mentions and #tags.
20+
li Each message/topic can only have one autoimport message - if there's already one, the upload will overwrite the existing note.
21+
22+
= form_with url: read_status_imports_path, method: :post, local: true, multipart: true, data: { turbo: false } do
23+
.form-group
24+
= label_tag :import_file, "CSV file"
25+
= file_field_tag :import_file, accept: ".csv,text/csv", required: true
26+
= submit_tag "Import", class: "button-primary"
27+
28+
- if @summary
29+
.settings-section
30+
h2 Import results
31+
ul
32+
li New notes: #{@summary[:new_notes]}
33+
li Replaced notes: #{@summary[:replaced_notes]}
34+
li Message IDs marked as read: #{@summary[:marked_read]}
35+
li Skipped message IDs: #{@summary[:skipped_message_ids].size}
36+
37+
- if @summary[:warnings].any?
38+
h3 Warnings
39+
ul
40+
- @summary[:warnings].each do |warning|
41+
li = warning
42+
43+
- if @summary[:skipped_message_ids].any?
44+
h3 Skipped message IDs
45+
ul
46+
- @summary[:skipped_message_ids].each do |mid|
47+
li = mid

app/views/settings/show.html.slim

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,3 +93,7 @@
9393
.settings-section
9494
h2 Teams
9595
= link_to "Manage teams", teams_path, class: "button-secondary"
96+
97+
.settings-section
98+
h2 Import
99+
= link_to "Import existing read status", new_read_status_import_path, class: "button-secondary"

config/routes.rb

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
post :mark_all_read, on: :collection
4040
end
4141
resources :notes, only: [:create, :update, :destroy]
42+
resources :read_status_imports, only: [:new, :create]
4243
get "stats", to: "stats#show", as: :stats
4344
get "stats/data", to: "stats#data", as: :stats_data
4445
get "person/*email/contributions/:year", to: "people#contributions", as: :person_contributions, format: false
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
require "rails_helper"
2+
require "cgi"
3+
4+
RSpec.describe "Read status imports", type: :request do
5+
def sign_in(email:, password: "secret")
6+
post session_path, params: { email: email, password: password }
7+
expect(response).to redirect_to(root_path)
8+
end
9+
10+
def attach_verified_alias(user, email:, primary: true)
11+
al = create(:alias, user: user, email: email)
12+
if primary && user.person&.default_alias_id.nil?
13+
user.person.update!(default_alias_id: al.id)
14+
end
15+
Alias.by_email(email).update_all(verified_at: Time.current)
16+
al
17+
end
18+
19+
def upload_csv(content)
20+
file = Tempfile.new(["read-status", ".csv"])
21+
file.write(content)
22+
file.rewind
23+
Rack::Test::UploadedFile.new(file.path, "text/csv")
24+
ensure
25+
file.close
26+
end
27+
28+
it "requires authentication" do
29+
get new_read_status_import_path
30+
expect(response).to redirect_to(new_session_path)
31+
end
32+
33+
it "imports read status and notes with warnings and skips" do
34+
user = create(:user, password: "secret", password_confirmation: "secret")
35+
attach_verified_alias(user, email: "[email protected]")
36+
sign_in(email: "[email protected]")
37+
38+
message1 = create(:message)
39+
message2 = create(:message)
40+
41+
csv = <<~CSV
42+
message_id,notemode,note
43+
#{message1.message_id},message,Imported #tag
44+
#{message2.message_id},message,
45+
<[email protected]>,message,Missing message
46+
CSV
47+
48+
post read_status_imports_path, params: { import_file: upload_csv(csv) }
49+
50+
note = Note.active.find_by(author: user, message_id: message1.id)
51+
expect(note).to be_present
52+
expect(note.body).to start_with("!autoimport")
53+
expect(note.note_tags.pluck(:tag)).to include("tag")
54+
55+
expect(Note.active.find_by(author: user, message_id: message2.id)).to be_nil
56+
57+
expect(MessageReadRange.covering?(user: user, topic: message1.topic, message_id: message1.id)).to be(true)
58+
expect(MessageReadRange.covering?(user: user, topic: message2.topic, message_id: message2.id)).to be(true)
59+
expect(ThreadAwareness.covering?(user: user, topic: message1.topic, message_id: message1.id)).to be(true)
60+
expect(ThreadAwareness.covering?(user: user, topic: message2.topic, message_id: message2.id)).to be(true)
61+
62+
expect(response.body).to include("New notes: 1")
63+
expect(response.body).to include("Replaced notes: 0")
64+
expect(response.body).to include("Message IDs marked as read: 2")
65+
expect(response.body).to include("Skipped message IDs: 1")
66+
escaped_message_id = CGI.escapeHTML(message2.message_id)
67+
expect(response.body).to include("Message #{escaped_message_id}: note text missing; skipped note import.")
68+
expect(response.body).to include(CGI.escapeHTML("<[email protected]>"))
69+
end
70+
71+
it "replaces existing imported notes" do
72+
user = create(:user, password: "secret", password_confirmation: "secret")
73+
attach_verified_alias(user, email: "[email protected]")
74+
sign_in(email: "[email protected]")
75+
76+
message = create(:message)
77+
Note.create!(topic: message.topic, message: message, author: user, body: "!autoimport old text")
78+
79+
csv = <<~CSV
80+
#{message.message_id},message,New text
81+
CSV
82+
83+
post read_status_imports_path, params: { import_file: upload_csv(csv) }
84+
85+
note = Note.active.find_by(author: user, message_id: message.id)
86+
expect(note.body).to eq("!autoimport New text")
87+
expect(response.body).to include("New notes: 0")
88+
expect(response.body).to include("Replaced notes: 1")
89+
end
90+
91+
it "imports topic-level notes for a message's topic" do
92+
user = create(:user, password: "secret", password_confirmation: "secret")
93+
attach_verified_alias(user, email: "[email protected]")
94+
sign_in(email: "[email protected]")
95+
96+
message = create(:message)
97+
98+
csv = <<~CSV
99+
#{message.message_id},topic,Thread note #tag
100+
CSV
101+
102+
post read_status_imports_path, params: { import_file: upload_csv(csv) }
103+
104+
note = Note.active.find_by(author: user, topic_id: message.topic_id, message_id: nil)
105+
expect(note).to be_present
106+
expect(note.body).to eq("!autoimport Thread note #tag")
107+
expect(note.note_tags.pluck(:tag)).to include("tag")
108+
end
109+
end

0 commit comments

Comments
 (0)