Skip to content

Commit be49c03

Browse files
HamptonMakesclaude
andcommitted
Recognize readable document links, and stop leaking library handles
Three things the canonical-URL switch broke or exposed. **Cross-document links stopped reading as documents.** Reference only knew `/plans/<uuid>`, so every link copied out of the address bar after the switch — which is now every link — landed in the References footnote as a generic "link" with no target_plan. It recognizes the readable form too, resolving the path to an id through the same segment walk the router uses, aliases included: a link written before a rename still names the document it was always about. **The truncation lists never included libraries.** Two specs run without transactional fixtures and clean up by hand; their lists predate libraries and folders, so those rows accumulated across runs. A library handle is globally unique, so a leaked row keeps "alice" reserved — and the list is now named once instead of copy-pasted twice. **`/l` could omit your own library.** Libraries are materialized on first touch, but the index read the table directly, which is exactly the path that skips the invariant. A viewer who hadn't yet loaded a page linking their library got a list without it. It had looked fine only because a stale row from an earlier run happened to be sitting in the test DB. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
1 parent 23fcd45 commit be49c03

8 files changed

Lines changed: 135 additions & 19 deletions

File tree

engine/app/controllers/coplan/libraries_controller.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,12 @@ def mine
1515
# The top of the tree. `/l` is a real page because every prefix of a
1616
# browsable URL is one.
1717
def index
18+
# Your own library first, so the list can't omit it. Libraries are
19+
# materialized on first touch (User#library), and reading the table
20+
# directly is exactly the path that skips that — a user who'd never
21+
# loaded a page that links their library got a list without it.
22+
current_user.library
23+
1824
@libraries = Library.includes(:owner).order(:handle).to_a
1925
@plan_counts = Plan.visible_to(current_user).active
2026
.joins(:placement)

engine/app/models/coplan/reference.rb

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,13 +15,19 @@ class Reference < ApplicationRecord
1515
scope :extracted, -> { where(source: "extracted") }
1616
scope :explicit, -> { where(source: "explicit") }
1717

18+
# A CoPlan document link in either form. `/l/<handle>/…` is what
19+
# people copy out of the address bar now; `/plans/<uuid>` is the old
20+
# form that still shows up in anything written before the switch.
21+
PLAN_ID_PATH = %r{/plans/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})}
22+
READABLE_PLAN_PATH = %r{/l/([^/?#]+)/([^?#]+)}
23+
1824
def self.classify_url(url)
1925
case url
2026
when %r{\Ahttps?://github\.com/[^/]+/[^/]+/pull/\d+}
2127
"pull_request"
2228
when %r{\Ahttps?://github\.com/[^/]+/[^/]+/?(\z|#|\?|/tree/|/blob/|/commit/)}
2329
"repository"
24-
when %r{/plans/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}}
30+
when PLAN_ID_PATH, READABLE_PLAN_PATH
2531
"plan"
2632
when %r{\Ahttps?://docs\.google\.com/}, %r{\Ahttps?://drive\.google\.com/}
2733
"document"
@@ -34,10 +40,35 @@ def self.classify_url(url)
3440
end
3541
end
3642

43+
# The id of the document a link points at, so the References section
44+
# can say which plan it is rather than just showing a URL.
45+
#
46+
# A readable path has to be resolved, since the id isn't in it. That
47+
# walk goes through the alias table too, so a link written before a
48+
# rename still finds the document it was always about — the same way
49+
# following the link would.
3750
def self.extract_target_plan_id(url)
3851
return nil if url.blank?
39-
match = url.match(%r{/plans/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})})
40-
match&.[](1)
52+
53+
if (match = url.match(PLAN_ID_PATH))
54+
return match[1]
55+
end
56+
57+
match = url.match(READABLE_PLAN_PATH)
58+
return nil if match.nil?
59+
60+
resolve_readable(match[1], match[2])
61+
end
62+
63+
def self.resolve_readable(handle, slug_path)
64+
result = Urls::Resolve.call(handle: handle, slug_path: slug_path)
65+
return result.plan&.id if result.redirect_to_path.blank?
66+
67+
# A stale path the aliases recognized: walk the current one.
68+
current_handle, _, rest = result.redirect_to_path.partition("/")
69+
return nil if rest.blank?
70+
71+
Urls::Resolve.call(handle: current_handle, slug_path: rest).plan&.id
4172
end
4273

4374
def self.ransackable_attributes(auth_object = nil)

engine/app/services/coplan/references/extract_from_content.rb

Lines changed: 14 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -19,21 +19,23 @@ def call
1919
# Remove extracted references for URLs no longer in content
2020
@plan.references.extracted.where.not(url: found_urls.keys).delete_all
2121

22-
# Batch-check plan existence for plan-type references
23-
candidate_plan_ids = found_urls.keys
24-
.select { |url| Reference.classify_url(url) == "plan" }
25-
.filter_map { |url| Reference.extract_target_plan_id(url) }
26-
.reject { |id| id == @plan.id }
27-
existing_plan_ids = candidate_plan_ids.any? ? Plan.where(id: candidate_plan_ids).pluck(:id).to_set : Set.new
22+
# Classify once and resolve once. A readable `/l/…` link costs a
23+
# segment walk to turn into an id, so doing it per-URL rather than
24+
# per-mention keeps a body full of cross-links cheap.
25+
types = found_urls.keys.index_with { |url| Reference.classify_url(url) }
26+
candidates = {}
27+
types.each do |url, type|
28+
next unless type == "plan"
29+
id = Reference.extract_target_plan_id(url)
30+
candidates[url] = id if id.present? && id != @plan.id
31+
end
32+
existing_plan_ids = candidates.any? ? Plan.where(id: candidates.values).pluck(:id).to_set : Set.new
2833

2934
# Create or update references for found URLs
3035
found_urls.each do |url, meta|
31-
ref_type = Reference.classify_url(url)
32-
target_plan_id = nil
33-
if ref_type == "plan"
34-
candidate_id = Reference.extract_target_plan_id(url)
35-
target_plan_id = candidate_id if candidate_id && existing_plan_ids.include?(candidate_id)
36-
end
36+
ref_type = types[url]
37+
candidate_id = candidates[url]
38+
target_plan_id = candidate_id if candidate_id && existing_plan_ids.include?(candidate_id)
3739

3840
ref = @plan.references.find_or_initialize_by(url: url)
3941
# Don't overwrite explicit references

spec/models/coplan/reference_spec.rb

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,18 @@
6060
expect(described_class.classify_url("https://coplan.example.com/plans/019d54a7-ea13-72d5-bc54-fc44cb9b939a")).to eq("plan")
6161
end
6262

63+
# The readable form is what anyone copies out of the address bar now,
64+
# so it has to read as a plan link and not a generic one.
65+
it "classifies readable CoPlan document URLs" do
66+
expect(described_class.classify_url("https://coplan.example.com/l/hampton/cart-roadmap")).to eq("plan")
67+
expect(described_class.classify_url("https://coplan.example.com/l/hampton/liveorder/q3/cart-roadmap")).to eq("plan")
68+
end
69+
70+
# A bare library or a workspace path isn't a document.
71+
it "does not classify a library root as a plan" do
72+
expect(described_class.classify_url("https://coplan.example.com/l/hampton")).to eq("link")
73+
end
74+
6375
it "classifies Google Docs URLs" do
6476
expect(described_class.classify_url("https://docs.google.com/document/d/abc123")).to eq("document")
6577
expect(described_class.classify_url("https://drive.google.com/file/d/abc123")).to eq("document")
@@ -88,6 +100,36 @@
88100
it "returns nil for non-plan URLs" do
89101
expect(described_class.extract_target_plan_id("https://example.com")).to be_nil
90102
end
103+
104+
context "readable document URLs" do
105+
let(:author) { create(:coplan_user, username: "hampton") }
106+
let(:folder) { create(:folder, name: "LiveOrder", created_by_user: author) }
107+
let!(:plan) do
108+
create(:plan, :published, created_by_user: author, title: "Cart Roadmap").tap do |p|
109+
CoPlan::Plans::Place.call(plan: p, folder: folder, actor: author)
110+
end
111+
end
112+
113+
it "resolves the path to the document it names" do
114+
expect(described_class.extract_target_plan_id("https://coplan.example.com/l/hampton/liveorder/cart-roadmap"))
115+
.to eq(plan.id)
116+
end
117+
118+
# An inline link written before a rename still points at the same
119+
# document, so it should still name it — the alias walk is the same
120+
# one that makes following the link work.
121+
it "follows a rename through the alias table" do
122+
plan.reload.update!(title: "Basket Roadmap")
123+
expect(plan.reload.url_path).to eq("hampton/liveorder/basket-roadmap")
124+
125+
expect(described_class.extract_target_plan_id("https://coplan.example.com/l/hampton/liveorder/cart-roadmap"))
126+
.to eq(plan.id)
127+
end
128+
129+
it "returns nil for a path that names nothing" do
130+
expect(described_class.extract_target_plan_id("https://coplan.example.com/l/hampton/nope/gone")).to be_nil
131+
end
132+
end
91133
end
92134

93135
describe "scopes" do

spec/models/plan_spec.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -195,8 +195,7 @@
195195
self.use_transactional_tests = false
196196

197197
after do
198-
truncate_tables(*%w[coplan_plan_tags coplan_tags coplan_plan_versions coplan_plans
199-
coplan_plan_types coplan_search_queries coplan_users])
198+
truncate_plan_tables
200199
end
201200

202201
let!(:author) { create(:coplan_user, name: "Tessa Engineer") }

spec/requests/libraries_spec.rb

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,17 @@
1515
expect(response).to have_http_status(:ok)
1616
expect(response.body).to include("/l/alice", "/l/bob")
1717
end
18+
19+
# Libraries are materialized on first touch, so a viewer who has never
20+
# loaded a page that links theirs has no row yet. The list still has to
21+
# include it — "libraries you can browse" without your own is nonsense.
22+
it "includes your own library even when nothing has materialized it" do
23+
expect(CoPlan::Library.where(owner_id: alice.id)).not_to exist
24+
25+
get browse_root_path
26+
27+
expect(response.body).to include("/l/alice")
28+
end
1829
end
1930

2031
describe "GET /library" do

spec/requests/search_spec.rb

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
self.use_transactional_tests = false
99

1010
after do
11-
truncate_tables(*%w[coplan_plan_tags coplan_tags coplan_plan_versions coplan_plans
12-
coplan_plan_types coplan_search_queries coplan_users])
11+
truncate_plan_tables
1312
end
1413

1514
let!(:alice) { create(:coplan_user, name: "Alice Searcher") }

spec/support/truncation_helpers.rb

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,32 @@
22
# transactional fixtures (InnoDB FULLTEXT writes are invisible to
33
# MATCH … AGAINST inside the same transaction) and so must clean up manually.
44
module TruncationHelpers
5+
# Everything a plan-and-author fixture touches, children before parents
6+
# (only the SQLite branch below cares about the order).
7+
#
8+
# Libraries and folders belong here even though no such example reads
9+
# them: a library handle is globally unique, so a leaked row keeps
10+
# "alice" reserved and the *next* spec's alice quietly gets a different
11+
# URL. Truncating users without them leaves exactly that orphan.
12+
PLAN_TABLES = %w[
13+
coplan_plan_tags
14+
coplan_tags
15+
coplan_plan_placements
16+
coplan_plan_versions
17+
coplan_plans
18+
coplan_folders
19+
coplan_library_events
20+
coplan_libraries
21+
coplan_url_aliases
22+
coplan_plan_types
23+
coplan_search_queries
24+
coplan_users
25+
].freeze
26+
27+
def truncate_plan_tables
28+
truncate_tables(*PLAN_TABLES)
29+
end
30+
531
def truncate_tables(*tables)
632
conn = ActiveRecord::Base.connection
733
case conn.adapter_name

0 commit comments

Comments
 (0)