dev: discourse findings/suggestions

interesting. thanks for sharing that experience.

i have not personally looked at the posts:remap yet. i actually didn’t know it existed, but for something so finicky as bulk text modification, i’d want to roll my own anyways. and since you say the behavior is goofy, that goes double.

i’ve got some experience with rails now and have written my own rake tasks to do similar things.

i’ve learned it’s almost always a bad idea to deal with the db directly with a rails app. you should, at a minimum, be using methods like .update() on active record objects because it often does validations and may hook other relations to do good things.

i think this specific example is not such a hard one to tackle. food for thought…

if i wanted to bulk-replace /rules to /tos in all posts:

i’d start like this using a postgres regex for efficiency (just for initial identification of posts)

# your_regex is a trivial regex to find posts with links to the rules page
# i would use the cooked column here because it mostly guarantees finding real links
# because links in the cooked version are html tags, so you can match on <a href=...
rules_posts = Post.where('cooked ~ ?', your_regex)

before modifying, i’d hold onto the post ids (maybe even write to a file for safekeeping) so you can figure out rebaking later in another step.

# get an array of all the post ids
matching_post_ids = rules_posts.pluck(:id)

after that, i would iterate over the matching posts and use regex tools in ruby itself (not postgres) to replace the urls in the raw text and update each post like:

rules_posts.each do |p|
  # fix the urls
  old_post_text = p.raw
  # do your string replacements
  # new_post_text = ...some magic
  # update the post
  p.update(raw: new_post_text)
end

when all’s good, i would then rebake just those posts at a leisurely pace. no need to scale up your hardware.

# those ids you savd from before :)
matching_post_ids.sort.reverse.each do |id|
  puts "rebaking post #{id}"
  Post.find(id).rebake!
  sleep(1)
end

that will rebake them in descending chronological order (newest posts first), which i think makes sense. it will also wait a leisurely 1s between posts, which is probably unnecessary. look at matching_post_ids.length and experiment with delay. maybe just try the first 100 posts to start.

btw, you can put your script in a rake task like this:

rules_link_replace.rb

desc 'replace /rules liks to /tos'
task :rules_replace => :environment do
  require 'pry'
  #...
  # btw, you can drop into an interactive ruby env at any point in your code
  # just put this line wherever you want:
  binding.pry # debugging happens here
end

then run the script with a helper like this:

run_rules.sh

#!/usr/bin/env bash

container=app

docker cp ./rules_link_replace.rb "$container:/var/www/discourse/lib/tasks/"
docker exec -it "$container" rake rules_replace
1 Thank

This looks very useful, thanks! You might consider submitting that as a utility for inclusion in Discourse. I like how it will only rebake the modified posts. And what about rebaking the post immediately after its raw text gets modified within the same step of the loop?

nothing wrong with that.

i just assumed that it wouldn’t work right on the first try (and that maybe this would be done on a duplicated dev instance), so no reason to waste the cpu cycles until you’re satisfied with the results of the replacement.

also wasn’t sure how many posts. if it’s only a thousand or something (i mean come on, BLF isn’t exactly a fascist state, how many posts with links to rules could there be) then who cares. doesn’t hurt to rebake while experimenting in that case.

when my migration’s done, i might share my rake tasks, but honestly it’s all such a kludge.

writing general-purpose tools is hard. e.g. apparently posts:remap is ass (as you say), and idk if i could do better.

if anything, i think my code would just serve as a tutorial for things that are possible to do for someone unfamiliar with rails.

PS: after using my sidebar code for a while, it seems pretty bulletproof

I just implemented it here, and I love it! Really well done, clean code, and no irritating 100ms delay before the custom menus get injected.

There’s a minor detail that I’m trying to figure out, for anonymous (not logged in) visitors the menu structure is a bit different. It puts the “Users” link under the “More” menu for logged in users, which is fine, but for anonymous users it shows “Users” at same main level with the rest of the “Community” links. I’d prefer for the “Users” item to not even be visible for anonymous visitors. I tried to hide it with your JS code, assuming that it would only hide it when present under the main level of “Community”, but it also hides it when it’s present under “More” for logged in users:

const sidebarRemovals = [
  { sectionSelector: '.sidebar-section[data-section-name="community"] .sidebar-section-content',
    links: [
    	{ kebab: 'everything' },
    	{ kebab: 'about' },
    	{ kebab: 'faq' },
    	{ kebab: 'users' }
    ]
  },
  { sectionSelector: '.sidebar-section[data-section-name="community"] .sidebar-more-section-links-details',
    links: [
    	{ kebab: 'about' },
    	{ kebab: 'faq' }
    ]
  }
];

lol, i actually never tested it it in not logged in state! will add a feature to do what you want in a bit.

ok, first of all, there is SiteSetting.hide_user_profiles_from_public, which i am actually using. it has the additional effect of getting rid of the Users link from the sidebar for anon. does that fix your issue?

if not, under what situations (logged in, anon) do you and do you not want Users to show up? and in each case, do you want it in “…More” or not?

lastly, i am noticing some jank here because the “…More” thing is empty. it shouldn’t be there at all if it’s empty. do you want a feature added to the script to just remove “…More” and its contents? or maybe just remove “…More” if it’s already empty? maybe that would simply your sidebarRemovals as well.

Hi there, thanks for the reply!

For now I’d like to keep it enabled, since that hides everything including the user’s join date and number of posts.

I think that for both logged in and anonymous I’d like /u to be accessible but no menu item. Or it might be nice to have the option to keep the “Users” item visible under “More” only for logged in users.

So I guess this would probably make sense. I haven’t tried yet, but I think to remove the entire “More” menu it would just have to target the selector sidebar-more-section-links-details .

Also keep in mind that there are the special menu items “Admin” (main level) and “Review” (normally under “More” until an issue needs reviewing and then it moves to the main level) for admin / mods.

Again, thanks a ton for sharing this MutationObserver code, it’s a huge improvement over the original kludge, and I feel comfortable using it long-term even if Discourse eventually makes the “Community” menu more configurable.

ok, it should have enough options now for almost any use case. lmk if you find bugs.

i looked into doing it via the api instead of hacking it into the DOM directly, but i don’t think enough stuff has been implemented in the api for that to be possible. maybe it’s possible if you make a real plugin (with ruby), but i haven’t learned about that yet.

for your users link, you can do a removal without removeFor (it defaults to removing for both anon and logged in).

similarly, can remove ⋮More for community section wherever it’s empty. it’s actually not possible to check if it’s empty without expanding it (either a user clicking it or having the script click it), and i thought that was too janky, so i have no ‘auto remove if empty’ option. you can just remove it from a section for anon, logged in, or both. so check where it’s empty yourself and set it accordingly.

here’s my site’s settings so far fyi

  /**
   * link positioners:
   *   (bool) prepend, append: prepend/append link to section
   *   (str)  addBeforeKebab, addAfterKebab: add link before/after existing link
   * link visibility:
   *   (str)  injectFor: inject link only for 'anon' users, only for 'registered' users, or for 'both' (default: 'both')
   */
  injections: [
    { sectionSelector: '.sidebar-section[data-section-name="community"]',
      links: [
        { text: 'Guidelines', kebab: 'guidelines', title: 'Guidelines for using this site', href: '/guidelines', icon: 'gavel', addBeforeKebab: 'faq', injectFor: 'registered' }
      ]
    },
    { sectionSelector: '.sidebar-section[data-section-name="categories"]',
      links: [
        { text: 'Site Map', kebab: 'site-map', title: 'Site Map', href: '/t/32', icon: 'map-marker-alt', prepend: true }
      ]
    }
  ],

  /**
   * section options:
   *   (str) removeFor: remove section only for 'anon' users, only for 'registered' users, or for 'both' (default: 'none')
   *   (str) removeMore: remove '⋮More' only for 'anon' users, only for 'registered' users, or for 'both' (default: 'none')
   *                       useful if you know it's empty due to link removals
   * link removal options:
   *   (str) removeFor: remove link only for 'anon' users, only for 'registered' users, or for 'both' (default: 'both')
   */
  removals: [
    { sectionSelector: '.sidebar-section[data-section-name="community"]',
      links: [{ kebab: 'badges', removeFor: 'anon' }]
    },
    { sectionSelector: '.sidebar-custom-sections .sidebar-section[data-section-name="my-topics"]',
      removeFor: 'anon'
    }
  ],
1 Thank

Excellent! Sorry I didn’t reply until I had some time today to try it out. Seems to work flawlessly, and does exactly what I want now. Thanks so much!

  injections: [
    { sectionSelector: '.sidebar-section[data-section-name="community"]',
      links: [
        { text: 'BLF Rules', kebab: 'blf-rules', title: 'BLF Rules', href: '/rules', icon: 'gavel', append: true },
        { text: 'Commercial Sellers', kebab: 'commercial-sellers', title: 'Rules for Commercial Sellers', href: '/commercial', icon: 'gift', append: true },
        { text: 'Related Sites', kebab: 'related-sites', title: 'Additional websites for flashlight enthusiasts', href: '/related-sites', icon: 'globe', append: true },
        { text: 'Contact the Admin', kebab: 'contact-admin', title: 'Send a private message to the site administrator', href: '/new-message?username=sb56637', icon: 'envelope', append: true }
      ]
    }
  ],

  removals: [
    { sectionSelector: '.sidebar-section[data-section-name="community"]',
      links: [
          { kebab: 'about', removeFor: 'both' },
          { kebab: 'faq', removeFor: 'both' },
          { kebab: 'users', removeFor: 'anon' }
      ],
      removeMore: 'anon'
    },
    { sectionSelector: '.sidebar-custom-sections .sidebar-section[data-section-name="my-threads"]',
      removeFor: 'anon'
    }
.sidebar-section-message { /* Don't show the site slogan in the Community menu to anon users */
    display: none;
}
.sidebar-section[data-section-name="tags"] {
  display: none
}
.sidebar-section[data-section-name="categories"] {
  display: flex; /* Setup a flex layout so you can reorder things */
  flex-direction: column;
  order: +1;
}
.sidebar-custom-sections {
  display: flex; /* Setup a flex layout so you can reorder things */
  flex-direction: column;
  order: +1;
}