-
Notifications
You must be signed in to change notification settings - Fork 9
Browsable URLs: people at the root, one address per plan, live libraries #191
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
HamptonMakes
wants to merge
6
commits into
main
Choose a base branch
from
hampton/browsable-library-urls
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
0df3769
Make libraries browsable at readable URLs
HamptonMakes a3b0df7
One place per plan, one address per plan
HamptonMakes 23fcd45
Update a stale URL example in the service worker comment
HamptonMakes be49c03
Recognize readable document links, and stop leaking library handles
HamptonMakes 0559f1e
Put people at the root, and make every library the same live page
HamptonMakes 0691d8a
Stop folders shadowing documents, and count what clicking shows
HamptonMakes File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
161 changes: 161 additions & 0 deletions
161
db/migrate/20260821195123_add_url_segments_to_libraries_and_folders.co_plan.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,161 @@ | ||
| # This migration comes from co_plan (originally 20260821000000) | ||
| class AddUrlSegmentsToLibrariesAndFolders < ActiveRecord::Migration[8.1] | ||
| # Gives libraries and folders the URL segments that make them browsable: | ||
| # /<handle>/<folder-slug>/<folder-slug>. Resolution walks these one | ||
| # segment at a time, so nothing stores a joined path and renaming a | ||
| # folder touches only its own row. | ||
| # | ||
| # Slug logic is inlined rather than calling CoPlan::Slug — a migration | ||
| # has to keep producing the same backfill years from now, even if the | ||
| # app's slug rules move on. | ||
| # | ||
| # Raw portable SQL throughout (select_all / quote), same reasoning as | ||
| # RequirePlanTypeOnCoplanPlans: this runs on MySQL and PostgreSQL hosts. | ||
| # | ||
| # Note on the unique indexes: MySQL treats NULLs as distinct, so the | ||
| # folder index does not actually constrain root folders (parent_id | ||
| # NULL). Real enforcement is the Rails validation, which scopes with | ||
| # IS NULL correctly — matching how `name` uniqueness already works on | ||
| # this table. The index is for lookup speed on the resolver's hot path. | ||
| def up | ||
| add_column :coplan_libraries, :handle, :string | ||
| add_column :coplan_folders, :slug, :string | ||
|
|
||
| backfill_library_handles | ||
| backfill_folder_slugs | ||
|
|
||
| change_column_null :coplan_libraries, :handle, false | ||
| change_column_null :coplan_folders, :slug, false | ||
| add_index :coplan_libraries, :handle, unique: true, name: "index_coplan_libraries_on_handle" | ||
| add_index :coplan_folders, [ :library_id, :parent_id, :slug ], | ||
| unique: true, name: "index_coplan_folders_on_library_and_parent_and_slug" | ||
| end | ||
|
|
||
| def down | ||
| remove_index :coplan_folders, name: "index_coplan_folders_on_library_and_parent_and_slug" | ||
| remove_index :coplan_libraries, name: "index_coplan_libraries_on_handle" | ||
| remove_column :coplan_folders, :slug | ||
| remove_column :coplan_libraries, :handle | ||
| end | ||
|
|
||
| private | ||
|
|
||
| # A personal library's handle comes from the owner's username (their | ||
| # ldap), falling back to the email local part and then the display | ||
| # name. Anything else — a team library — uses the library's own name. | ||
| # | ||
| # `taken` starts out holding the app's root-level addresses, so a person | ||
| # whose ldap is "settings" gets "settings-2" rather than a handle the app | ||
| # would refuse to save. Spelled out here for the same reason the slug | ||
| # rules are: a migration has to keep producing the same backfill. | ||
| def backfill_library_handles | ||
| rows = connection.select_all(<<~SQL) | ||
| SELECT l.id, l.name, l.owner_type, l.owner_id, | ||
| u.username AS owner_username, u.email AS owner_email, u.name AS owner_name | ||
| FROM coplan_libraries l | ||
| LEFT JOIN coplan_users u | ||
| ON l.owner_type = 'CoPlan::User' AND l.owner_id = u.id | ||
| ORDER BY l.created_at, l.id | ||
| SQL | ||
|
|
||
| taken = %w[ | ||
| _ new edit all | ||
| plans people libraries library settings search notifications home welcome | ||
| api agent-instructions admin assets rails up sign_in sign_out integrations | ||
| ] | ||
| rows.each do |row| | ||
| source = row["owner_username"].presence || | ||
| row["owner_email"].to_s.split("@").first.presence || | ||
| row["owner_name"].presence || | ||
| row["name"].presence || | ||
| "library" | ||
| handle = unique_slug(ascii_slugify(source), taken, fallback: "library") | ||
| taken << handle | ||
| execute "UPDATE coplan_libraries SET handle = #{quote(handle)} WHERE id = #{quote(row['id'])}" | ||
| end | ||
|
|
||
| create_missing_user_libraries(taken) | ||
| end | ||
|
|
||
| # Libraries used to be materialized on first touch, so people who never | ||
| # loaded a page that needed one have no row. A library is a person's page | ||
| # now — /<handle> — so everyone needs theirs to exist, not just everyone | ||
| # who has filed something. | ||
| def create_missing_user_libraries(taken) | ||
| rows = connection.select_all(<<~SQL) | ||
| SELECT u.id, u.username, u.email, u.name | ||
| FROM coplan_users u | ||
| LEFT JOIN coplan_libraries l | ||
| ON l.owner_type = 'CoPlan::User' AND l.owner_id = u.id | ||
| WHERE l.id IS NULL | ||
| ORDER BY u.created_at, u.id | ||
| SQL | ||
|
|
||
| # Formatted rather than quoted: `quote` on a TimeWithZone writes a zone | ||
| # name into the literal, which strict MySQL refuses. | ||
| now = Time.current.utc.strftime("%Y-%m-%d %H:%M:%S") | ||
| rows.each do |row| | ||
| source = row["username"].presence || | ||
| row["email"].to_s.split("@").first.presence || | ||
| row["name"].presence || | ||
| "library" | ||
| handle = unique_slug(ascii_slugify(source), taken, fallback: "library") | ||
| taken << handle | ||
| execute <<~SQL | ||
| INSERT INTO coplan_libraries (id, name, handle, owner_type, owner_id, created_at, updated_at) | ||
| VALUES (#{quote(SecureRandom.uuid_v7)}, 'Library', #{quote(handle)}, | ||
| 'CoPlan::User', #{quote(row['id'])}, #{quote(now)}, #{quote(now)}) | ||
| SQL | ||
| end | ||
| end | ||
|
|
||
| # Folder slugs only have to be unique among siblings, so uniqueness is | ||
| # tracked per (library, parent). Deepest-last ordering isn't needed — | ||
| # a folder's slug depends on its own name alone. | ||
| def backfill_folder_slugs | ||
| rows = connection.select_all(<<~SQL) | ||
| SELECT id, library_id, parent_id, name | ||
| FROM coplan_folders | ||
| ORDER BY library_id, parent_id, created_at, id | ||
| SQL | ||
|
|
||
| taken = {} | ||
| rows.each do |row| | ||
| sibling_key = [ row["library_id"], row["parent_id"] ] | ||
| taken[sibling_key] ||= [] | ||
| slug = unique_slug(slugify(row["name"]), taken[sibling_key], fallback: "folder") | ||
| taken[sibling_key] << slug | ||
| execute "UPDATE coplan_folders SET slug = #{quote(slug)} WHERE id = #{quote(row['id'])}" | ||
| end | ||
| end | ||
|
|
||
| # Mirrors CoPlan::Slug.call / .handle, inlined so this backfill keeps | ||
| # producing the same slugs if the app's rules move on. Folders keep | ||
| # Unicode letters — a folder named 設計 gets a segment that says so — | ||
| # while handles stay ASCII because they're typed and read aloud. | ||
| def slugify(text) | ||
| trim(text.to_s.unicode_normalize(:nfc).downcase.gsub(/[^[[:alnum:]]]+/, "-")) | ||
| end | ||
|
|
||
| def ascii_slugify(text) | ||
| trim(text.to_s.unicode_normalize(:nfkd).downcase.gsub(/[^a-z0-9]+/, "-")) | ||
| end | ||
|
|
||
| def trim(hyphenated) | ||
| hyphenated.gsub(/-{2,}/, "-").delete_prefix("-").delete_suffix("-")[0, 60].to_s | ||
| .delete_suffix("-") | ||
| end | ||
|
|
||
| # Existing data predates any slug rule, so collisions are expected — | ||
| # "Team EBT" and "team-ebt" both want the same segment. Numeric | ||
| # suffixes here are a backfill concession; new records get better | ||
| # disambiguation from the app. | ||
| def unique_slug(slug, taken, fallback:) | ||
| candidate = slug.presence || fallback | ||
| return candidate unless taken.include?(candidate) | ||
|
|
||
| suffix = 2 | ||
| suffix += 1 while taken.include?("#{candidate}-#{suffix}") | ||
| "#{candidate}-#{suffix}" | ||
| end | ||
| end | ||
52 changes: 52 additions & 0 deletions
52
db/migrate/20260821200043_add_plan_slugs_and_url_aliases.co_plan.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,52 @@ | ||
| # This migration comes from co_plan (originally 20260821000001) | ||
| class AddPlanSlugsAndUrlAliases < ActiveRecord::Migration[8.1] | ||
| # The leaf segment of a browsable URL, plus the table that keeps old | ||
| # URLs resolving. | ||
| # | ||
| # `slug` is derived from the plan's title with redundancy stripped — a | ||
| # plan titled "LiveOrder Cart Roadmap" filed in "LiveOrder" is just | ||
| # "cart-roadmap", because the folder already said the rest. | ||
| # `slug_suffix` is set only when two plans in the same folder want the | ||
| # same slug, so it appears in a URL only where it earns something. | ||
| # | ||
| # No unique index on (slug, slug_suffix): a plan's uniqueness scope is | ||
| # the folder it's filed in, which lives in coplan_plan_placements, not | ||
| # here. Uniqueness is enforced on write against the canonical folder | ||
| # (Plans::AssignSlug). A real DB constraint becomes possible once a plan | ||
| # is filed in exactly one library — see the placements collapse. | ||
| # | ||
| # Backfill leaves slugs NULL and lets the app fill them in lazily, so | ||
| # this migration stays fast on a large table and doesn't have to | ||
| # reimplement the redundancy-stripping rules. | ||
| def up | ||
| add_column :coplan_plans, :slug, :string | ||
| add_column :coplan_plans, :slug_suffix, :string, limit: 8 | ||
| add_index :coplan_plans, [ :slug, :slug_suffix ], name: "index_coplan_plans_on_slug_and_suffix" | ||
|
|
||
| create_table :coplan_url_aliases, id: { type: :string, limit: 36 } do |t| | ||
| # The stale path, library handle first and no leading slash: | ||
| # "orders/liveorder/cart-roadmap". | ||
| t.string :path, null: false, limit: 512 | ||
| # "exact" matches one URL; "prefix" rewrites everything beneath it, | ||
| # which is how one row covers a renamed folder's whole subtree. | ||
| t.string :kind, null: false, default: "exact" | ||
| t.string :target_path, null: false, limit: 512 | ||
| # Cheap eviction signal: rows nobody has ever followed are safe to | ||
| # drop, because plan_events / library_events can rebuild them. | ||
| t.integer :resolve_count, null: false, default: 0 | ||
| t.timestamp :last_resolved_at | ||
| t.timestamps | ||
| end | ||
| add_index :coplan_url_aliases, [ :path, :kind ], unique: true, | ||
| name: "index_coplan_url_aliases_on_path_and_kind" | ||
| add_index :coplan_url_aliases, [ :kind, :resolve_count, :created_at ], | ||
| name: "index_coplan_url_aliases_for_pruning" | ||
| end | ||
|
|
||
| def down | ||
| drop_table :coplan_url_aliases | ||
| remove_index :coplan_plans, name: "index_coplan_plans_on_slug_and_suffix" | ||
| remove_column :coplan_plans, :slug_suffix | ||
| remove_column :coplan_plans, :slug | ||
| end | ||
| end |
68 changes: 68 additions & 0 deletions
68
db/migrate/20260821205749_collapse_plan_placements_to_one.co_plan.rb
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,68 @@ | ||
| # This migration comes from co_plan (originally 20260821000002) | ||
| class CollapsePlanPlacementsToOne < ActiveRecord::Migration[8.1] | ||
| # A plan now lives in exactly one library. The old model let the same | ||
| # plan sit on many shelves at once — filing someone else's published | ||
| # plan into your own library was a "bookmark", separate from the | ||
| # author's copy. In practice nobody used it, and it cost us the one | ||
| # property a readable URL needs: a document having a single address. | ||
| # | ||
| # Collapses existing rows, keeping the author's own placement where | ||
| # there is one (that's the shelf URLs already resolved to) and | ||
| # otherwise the oldest, then makes more than one unrepresentable. | ||
| def up | ||
| ids = surplus_placement_ids | ||
| say "Dropping #{ids.size} non-canonical placement(s)" if ids.any? | ||
| ids.each_slice(500) do |batch| | ||
| execute <<~SQL.squish | ||
| DELETE FROM coplan_plan_placements | ||
| WHERE id IN (#{batch.map { |id| quote(id) }.join(",")}) | ||
| SQL | ||
| end | ||
|
|
||
| # The new index subsumes index_..._on_plan_id_and_library_id, the old | ||
| # "one shelf per library" constraint. Added before the old one is | ||
| # dropped: MySQL requires an index on plan_id for its foreign key and | ||
| # refuses to drop the last one that satisfies it. | ||
| add_index :coplan_plan_placements, :plan_id, unique: true, | ||
| name: "index_coplan_plan_placements_on_plan_id" | ||
| remove_index :coplan_plan_placements, column: [ :plan_id, :library_id ] | ||
| end | ||
|
|
||
| def down | ||
| add_index :coplan_plan_placements, [ :plan_id, :library_id ], unique: true, | ||
| name: "index_coplan_plan_placements_on_plan_id_and_library_id" | ||
| remove_index :coplan_plan_placements, column: :plan_id | ||
| end | ||
|
|
||
| private | ||
|
|
||
| # Grouped in Ruby rather than SQL: picking a winner per plan needs a | ||
| # correlated "is this the author's library" test, and the portable | ||
| # forms of that are worse than reading the table. Placement counts are | ||
| # in the thousands at most. | ||
| def surplus_placement_ids | ||
| rows = connection.select_all(<<~SQL.squish).to_a | ||
| SELECT pp.id AS id, pp.plan_id AS plan_id, pp.created_at AS created_at, | ||
| lib.owner_type AS owner_type, lib.owner_id AS owner_id, | ||
| p.created_by_user_id AS author_id | ||
| FROM coplan_plan_placements pp | ||
| INNER JOIN coplan_plans p ON p.id = pp.plan_id | ||
| INNER JOIN coplan_libraries lib ON lib.id = pp.library_id | ||
| SQL | ||
|
|
||
| rows.group_by { |row| row["plan_id"] }.flat_map do |_plan_id, group| | ||
| next [] if group.size < 2 | ||
|
|
||
| keeper = group.find { |row| authors_own?(row) } || group.min_by { |row| row["created_at"].to_s } | ||
| (group - [ keeper ]).map { |row| row["id"] } | ||
| end | ||
| end | ||
|
|
||
| def authors_own?(row) | ||
| row["owner_type"] == "CoPlan::User" && row["owner_id"].to_s == row["author_id"].to_s | ||
| end | ||
|
|
||
| def quote(value) | ||
| connection.quote(value) | ||
| end | ||
| end |
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.