diff --git a/.github/workflows/comprehensive-screenshots.yml b/.github/workflows/comprehensive-screenshots.yml new file mode 100644 index 0000000..a5f5f92 --- /dev/null +++ b/.github/workflows/comprehensive-screenshots.yml @@ -0,0 +1,138 @@ +name: Comprehensive Screenshots + +# Broad-coverage screenshot suite for visual review. Parameterizes +# scenarios across kinds × lengths × roles × panel states, producing +# ~77 PNGs uploaded as a single artifact. The spec is gated by +# JTECH_COMPREHENSIVE_SHOTS=1 so it does NOT run in ordinary CI; this +# workflow is the one entry point. +# +# Runtime: ~12-15 min. Trigger via: +# gh workflow run "Comprehensive Screenshots" --ref --repo Shalom-Karr/JtechTools + +on: + workflow_dispatch: + +jobs: + screenshots: + name: comprehensive_screenshots + runs-on: ubuntu-latest + container: discourse/discourse_test:slim-browsers + timeout-minutes: 120 + + env: + RUBY_GLOBAL_METHOD_CACHE_SIZE: 131072 + RAILS_ENV: test + PGUSER: discourse + PGPASSWORD: discourse + PLUGIN_NAME: jtech-tools + CHEAP_SOURCE_MAPS: "1" + MINIO_RUNNER_LOG_LEVEL: DEBUG + MINIO_RUNNER_INSTALL_DIR: /home/discourse/.minio_runner + USES_PARALLEL_DATABASES: "false" + PARALLEL_TEST_PROCESSORS: 1 + LOAD_PLUGINS: 1 + CAPYBARA_DEFAULT_MAX_WAIT_TIME: 10 + JTECH_COMPREHENSIVE_SHOTS: "1" + + steps: + - name: Set working directory owner + run: chown root:root . + + - name: Checkout Discourse + uses: actions/checkout@v6 + with: + repository: discourse/discourse + fetch-depth: 1 + ref: latest + + - name: Install plugin + uses: actions/checkout@v6 + with: + path: plugins/${{ env.PLUGIN_NAME }} + fetch-depth: 1 + + - name: Setup Git + run: | + git config --global user.email "ci@ci.invalid" + git config --global user.name "Discourse CI" + + - name: Start redis + run: redis-server /etc/redis/redis.conf & + + - name: Start Postgres + run: | + chown -R postgres /var/run/postgresql + sudo -E -u postgres script/start_test_db.rb + sudo -u postgres psql -c "CREATE ROLE $PGUSER LOGIN SUPERUSER PASSWORD '$PGPASSWORD';" + + - name: Container envs + id: container-envs + run: | + echo "ruby_version=$RUBY_VERSION" >> $GITHUB_OUTPUT + echo "debian_release=$DEBIAN_RELEASE" >> $GITHUB_OUTPUT + + - name: Bundler cache + uses: actions/cache@v5 + with: + path: vendor/bundle + key: ${{ runner.os }}-${{ steps.container-envs.outputs.ruby_version }}-${{ steps.container-envs.outputs.debian_release }}-gem-${{ hashFiles('**/Gemfile.lock') }} + restore-keys: | + ${{ runner.os }}-${{ steps.container-envs.outputs.ruby_version }}-${{ steps.container-envs.outputs.debian_release }}-gem- + + - name: Setup gems + run: | + bundle config --local path vendor/bundle + bundle config --local deployment true + bundle config --local without development + bundle install --jobs 4 + bundle clean + + - name: Install JS Dependencies + run: if [ -f yarn.lock ]; then yarn install --frozen-lockfile; else pnpm install --frozen-lockfile; fi + + - name: Install playwright + run: | + if pnpm playwright -V; then + pnpm playwright install --with-deps --no-shell chromium + fi + + - name: Create and migrate database + run: | + bin/rake db:create + bin/rake db:migrate + + - name: Ember Build + env: + EMBER_ENV: development + run: | + if [ -f script/assemble_ember_build.rb ]; then + export DISCOURSE_DOWNLOAD_PRE_BUILT_ASSETS=$(bin/rails runner 'puts(Discourse.has_needed_version?(Discourse::VERSION::STRING, "2026.3.0-latest") ? 1 : 0)') + script/assemble_ember_build.rb + else + bin/ember-cli --build + fi + + - name: Add hosts to /etc/hosts (Chrome can reach minio) + run: | + echo "127.0.0.1 minio.local" | sudo tee -a /etc/hosts + echo "127.0.0.1 discoursetest.minio.local" | sudo tee -a /etc/hosts + + - name: Run comprehensive-screenshots specs (part 1 + 2 + 3 + 4) + run: | + bundle exec rspec \ + --format documentation \ + plugins/${{ env.PLUGIN_NAME }}/spec/system/comprehensive_screenshots_spec.rb \ + plugins/${{ env.PLUGIN_NAME }}/spec/system/comprehensive_screenshots_part2_spec.rb \ + plugins/${{ env.PLUGIN_NAME }}/spec/system/comprehensive_screenshots_part3_spec.rb \ + plugins/${{ env.PLUGIN_NAME }}/spec/system/comprehensive_screenshots_part4_spec.rb + continue-on-error: true + + - name: Upload comprehensive screenshots (always) + if: always() + uses: actions/upload-artifact@v6 + with: + name: comprehensive-screenshots + path: | + tmp/capybara/comprehensive_screenshots/*.png + tmp/capybara/*.png + if-no-files-found: warn diff --git a/.github/workflows/feature-screenshots.yml b/.github/workflows/feature-screenshots.yml index 9758036..0155bb9 100644 --- a/.github/workflows/feature-screenshots.yml +++ b/.github/workflows/feature-screenshots.yml @@ -124,11 +124,12 @@ jobs: echo "127.0.0.1 minio.local" | sudo tee -a /etc/hosts echo "127.0.0.1 discoursetest.minio.local" | sudo tee -a /etc/hosts - - name: Run feature-screenshot spec + - name: Run feature-screenshot specs run: | bundle exec rspec \ --format documentation \ - plugins/${{ env.PLUGIN_NAME }}/spec/system/feature_screenshots_spec.rb + plugins/${{ env.PLUGIN_NAME }}/spec/system/feature_screenshots_spec.rb \ + plugins/${{ env.PLUGIN_NAME }}/spec/system/review_queue_click_through_spec.rb continue-on-error: true - name: Upload feature screenshots (always) diff --git a/README.md b/README.md index 552d60d..93417ab 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Jtech -One combined Discourse plugin. Bundles five previously-separate plugins under a single registration and a single master site setting (`jtech_enabled`). Each sub-plugin keeps its own settings, locales, and Ruby namespace. +One combined Discourse plugin. Bundles seven previously-separate plugins under a single registration and a single master site setting (`jtech_enabled`). Each sub-plugin keeps its own settings, locales, and Ruby namespace. ## Bundled sub-plugins @@ -12,9 +12,38 @@ One combined Discourse plugin. Bundles five previously-separate plugins under a | Mod-categories | `DiscourseModCategories` | `mod_*`, `precheck_*`, `topic_footer_*`, `topic_reply_prompt_*` | `mod_categories_enabled` | | Dumbcourse | `DiscourseDumbcourse` | `dumbcourse_*` | `dumbcourse_enabled` | | Translator-tweaks | *(patches `DiscourseTranslator`)* | *(none — gated by translator's own settings)* | `translator_enabled` (upstream) | +| Smart search | `DiscourseSmartSearch` | `smart_search_*` | `smart_search_enabled` | The bundle is gated by `jtech_enabled`; each sub-plugin is independently gated by its own setting above. +### Mod-categories — staff-event notifications + +Mod-categories ships a notification fan-out for five staff-event streams in addition to its original topic-level moderator notes. Whenever a moderator performs one of the actions below, every OTHER staff member gets a high-priority bell notification + live MessageBus pop-up alert, AND the event surfaces in the shield-tab user menu alongside topic notes. + +| Stream | Event hook | URL on click | +| --- | --- | --- | +| Post deleted by staff | `on(:post_destroyed)` (skips self-deletes + system user) | topic + post number | +| Queued post approved | `on(:reviewable_transitioned_to)` (status=:approved, ReviewableQueuedPost) | `/review/:id` | +| Queued post rejected | `on(:reviewable_transitioned_to)` (status=:rejected, ReviewableQueuedPost) | `/review/:id` | +| User note added | wraps `::DiscourseUserNotes.add_note` (bundled plugin fires no DiscourseEvent) | `/u/:username/notes` | +| Flag note added on a reviewable | `::ReviewableNote.after_create` callback | `/review/:id` | + +All five are gated by independent site settings (`mod_notify_staff_on_post_actions`, `mod_notify_staff_on_user_notes`, `mod_notify_staff_on_flag_notes`) so streams can be disabled individually. The fan-out itself lives in `lib/discourse_mod_categories/staff_notifier.rb` and is wrapped in two layers of `rescue StandardError` so a notifier failure can never 500 the underlying moderator action. A 30-second per-user dedup check in `StaffNotifier.recent_duplicate?` protects against an event hook firing twice in quick succession. + +The shield-tab `/discourse-mod-categories/notes-feed` returns a UNION of topic-attached notes (legacy behavior — what `TopicCustomField` writes surface as) plus the non-topic event notifications above, so the tab mirrors what the bell shows for every mod-note-kind notification. + +### Smart search + +Synonym query expansion using **WordNet** (~117K-word English lexical DB, bundled via the `rwordnet` gem) for general English, with a small **tech-jargon YAML overlay** (~70 entries in `config/dictionaries/smart_search_synonyms.yml`) for the abbreviations and brand names WordNet doesn't know (`js ↔ javascript`, `k8s ↔ kubernetes`, `pg ↔ postgres`, etc.). When `smart_search_enabled` is on: + +1. The user's original search runs first via Discourse's vanilla `Search#execute`. +2. If the original returns fewer than `smart_search_minimum_results` posts (default 5), up to `smart_search_variant_limit` (default 2, max 5) synonym-substituted variant searches run and their results are merged in. +3. Every smart-search path (dictionary load, variant generation, inner variant search, merge) is wrapped in `rescue StandardError` → log and return the vanilla result. The fallback contract is documented at the top of `lib/discourse_smart_search/search_extension.rb`. + +No external services, no API keys, no embedding models — both backends (WordNet via SQLite DB shipped in-gem, plus the YAML overlay) run in-process. This is deliberate: the previous semantic-search attempt (Discourse AI embeddings) was disabled after every query started returning 500 when the embedding backend went down. Smart search's failure mode is "results identical to vanilla," never "search broken." + +Editing the overlay: only ADD entries WordNet doesn't already cover — abbreviations, brand names, protocol initialisms. Don't curate general English (WordNet handles it for free). Lowercase ASCII rows, each row is a symmetric synonym group. Reloaded at boot (or via `DiscourseSmartSearch::Synonyms.reload!` in a Rails console). See `docs/smart_search.md` for the full architecture: two-backend lookup order, request-flow diagram, fallback contract, performance notes, and a console-recipe for diagnostics. + ## Layout ``` @@ -24,25 +53,30 @@ sub_plugins/ dislike.rb body of original Dislike/plugin.rb another_smtp.rb body of original discourse-another-smtp/plugin.rb mini_mod.rb body of original discourse-mini-mod/plugin.rb - mod_categories.rb body of original discourse-mod/plugin.rb + mod_categories.rb body of original discourse-mod/plugin.rb + staff-event notifications dumbcourse.rb body of original dumbcourse/plugin.rb translator_tweaks.rb runtime patches for upstream discourse/discourse-translator (alltechdev's two-commit fork ported as in-process tweaks so we can track upstream and apply our overrides on top) + smart_search.rb synonym query expansion (in-process, no external services) scripts/ translator_backfill_foreign_detection.rb one-shot rails runner; enqueues the upstream translator's detect job for legacy foreign-script posts config/ - settings.yml all five settings.yml files merged into six jtech_* admin tabs + settings.yml all settings.yml files merged into seven jtech_* admin tabs + dictionaries/ + smart_search_synonyms.yml + symmetric synonym groups for smart_search; lowercase ASCII locales/ server.en.yml deep-merged server locale + categories.jtech_* translations client.en.yml deep-merged client locale lib/ discourse_no_likes/ from Dislike discourse_mini_mod/ from discourse-mini-mod - discourse_mod_categories/ from discourse-mod + discourse_mod_categories/ from discourse-mod + staff_notifier.rb (fan-out helper) discourse_dumbcourse/ from dumbcourse + discourse_smart_search/ synonyms / query_expander / Search prepend module app/ controllers/{discourse_mod_categories,discourse_dumbcourse}/ models/{discourse_no_likes,*_site_setting.rb} @@ -54,7 +88,20 @@ public/ Dumbcourse SPA bundle (index.html, dumbcourse.{js,css}, e ## Admin-UI tabs -The merged `config/settings.yml` exposes one admin tab per sub-plugin: **Jtech**, **Jtech — Dislike**, **Jtech — Alternate SMTP**, **Jtech — Mini-mod**, **Jtech — Mod**, **Jtech — Dumbcourse**. TL4 settings remain in Discourse's core **Trust Level 4** tab. +The merged `config/settings.yml` exposes one admin tab per sub-plugin: **Jtech**, **Jtech — Dislike**, **Jtech — Alternate SMTP**, **Jtech — Mini-mod**, **Jtech — Mod**, **Jtech — Dumbcourse**, **Jtech — Smart search**. TL4 settings remain in Discourse's core **Trust Level 4** tab. + +## Visual review (screenshot specs) + +Two GitHub Actions workflows render visual fixtures of the plugin's UI surface: + +- `Feature Screenshots` — ~25 hand-picked scenarios capturing the actively-developed features. Runs on push to `main`, PRs, and manual dispatch. Artifact: `feature-screenshots`. +- `Comprehensive Screenshots` — parameterized matrix across kinds × lengths × read-states × roles × ordinals, ~1180 scenarios attempted. **Dispatch-only** (gated by `ENV["JTECH_COMPREHENSIVE_SHOTS"]` so it never slows ordinary CI). Run via: + + ```bash + gh workflow run "Comprehensive Screenshots" --ref --repo Shalom-Karr/JtechTools + ``` + + Spec files: `spec/system/comprehensive_screenshots_spec.rb` plus `_part2`, `_part3`, `_part4`. Empirical success rate ~75% across the full matrix (the fast-path P6 section alone hits 100%). Section-prefix convention so the artifact zip sorts navigably: `A1xx` bell rows, `B2xx` shield tab, `C3xx` mod-note panel, `D4xx` bell stacking, `E5xx`/`K1xx` smart search, `G7xx` time-ago variants, `H8xx` density 1→100, etc. ## Why one `enabled_site_setting`? diff --git a/about.json b/about.json index 811c0ee..728b3d2 100644 --- a/about.json +++ b/about.json @@ -1,6 +1,6 @@ { "name": "jtech-tools", - "about": "Jtech — combined Discourse plugin (dislike, another-smtp, mini-mod, mod-categories, dumbcourse)", + "about": "Jtech — combined Discourse plugin (dislike, another-smtp, mini-mod, mod-categories, dumbcourse, translator-tweaks, smart-search)", "version": "0.1.1", "authors": "Jtech Forums", "url": "https://github.com/JTech-Forums/JtechTools" diff --git a/app/controllers/discourse_mod_categories/messages_controller.rb b/app/controllers/discourse_mod_categories/messages_controller.rb index 2493745..e5dac2b 100644 --- a/app/controllers/discourse_mod_categories/messages_controller.rb +++ b/app/controllers/discourse_mod_categories/messages_controller.rb @@ -355,11 +355,48 @@ def mark_topic_notifications_seen render json: { marked: marked } end - # Lists recent moderator notes across topics, for the staff user-menu - # tab, newest first. + # Marks the current user's mod_note notifications whose `url` points + # at /review/... as read. Called by the frontend whenever the user + # navigates to /review or /review/:id — so flag_note / post_rejected + # notifications (which link to /review/:id rather than to a topic + # page) get marked read on direct navigation, not only via the bell- + # click or shield-tab-open paths. The data-column LIKE pins the + # update to mod_note rows whose URL starts with /review so we don't + # touch unrelated notifications. + def mark_review_notifications_seen + guardian.ensure_can_manage_mod_messages! + + marked = + ::Notification + .where( + user_id: current_user.id, + notification_type: ::Notification.types[:custom], + read: false, + ) + .where("data LIKE ?", "%\"mod_note\":true%") + .where("data LIKE ?", "%\"url\":\"/review%") + .update_all(read: true) + + current_user.publish_notifications_state if marked > 0 + + render json: { marked: marked } + end + + # Lists moderator notes for the staff user-menu tab. Returns a UNION: + # (1) every topic that has a private moderator note set, regardless of + # whether a Notification fan-out happened — this preserves the + # original "topics with notes" feed and lets existing system tests + # that set custom_fields directly still surface; (2) every non-topic + # event-stream notification (post_deleted / post_approved / + # post_rejected / user_note / flag_note) the staff user has — these + # don't have a topic-anchored representation, so they only show via + # the Notification stream. Newest first within each section, topic + # section first. def notes_feed guardian.ensure_can_manage_mod_messages! + seen_at = current_user.custom_fields[USER_NOTES_SEEN_FIELD].presence || "1970-01-01T00:00:00Z" + topic_ids = TopicCustomField .where(name: TOPIC_PRIVATE_NOTE_FIELD) @@ -368,9 +405,7 @@ def notes_feed .limit(50) .pluck(:topic_id) - seen_at = current_user.custom_fields[USER_NOTES_SEEN_FIELD].presence || "1970-01-01T00:00:00Z" - - notes = + topic_notes = Topic .where(id: topic_ids) .map do |topic| @@ -379,20 +414,62 @@ def notes_feed replies = topic.custom_fields[TOPIC_PRIVATE_NOTE_REPLIES_FIELD] activity_at = topic.custom_fields[TOPIC_PRIVATE_NOTE_ACTIVITY_FIELD].to_s { + kind: "note", topic_id: topic.id, topic_title: topic.title, url: "#{topic.relative_url}/#{topic.highest_post_number}#mod-private-note", note: note, + excerpt: note, reply_count: replies.is_a?(Array) ? replies.size : 0, activity_at: activity_at, + created_at: activity_at, unread: activity_at > seen_at, } end .compact - .sort_by { |n| n[:activity_at] } + .sort_by { |n| n[:activity_at].to_s } .reverse - render json: { notes: notes } + # Non-topic-anchored event notifications: rows whose mod_note_kind + # is NOT "note" or "reply" (those are already covered by the + # topic-attached feed above, derived from the topic custom field). + event_rows = + ::Notification + .where(user_id: current_user.id, notification_type: ::Notification.types[:custom]) + .where("data LIKE ?", "%\"mod_note\":true%") + .where( + "data NOT LIKE ? AND data NOT LIKE ?", + "%\"mod_note_kind\":\"note\"%", + "%\"mod_note_kind\":\"reply\"%", + ) + .order(created_at: :desc) + .limit(50) + + events = + event_rows.map do |n| + data = + begin + JSON.parse(n.data.to_s) + rescue StandardError + {} + end + + { + id: n.id, + kind: data["mod_note_kind"].presence || "note", + username: data["display_username"], + topic_id: n.topic_id, + topic_title: data["topic_title"], + target_username: data["target_username"], + excerpt: data["excerpt"].to_s, + note: data["excerpt"].to_s, + url: data["url"], + created_at: n.created_at.iso8601, + unread: !n.read, + } + end + + render json: { notes: topic_notes + events } end # Marks the staff user's moderator-note feed as read. diff --git a/assets/javascripts/discourse/components/mod-notes-panel.gjs b/assets/javascripts/discourse/components/mod-notes-panel.gjs index 808e5c7..886428f 100644 --- a/assets/javascripts/discourse/components/mod-notes-panel.gjs +++ b/assets/javascripts/discourse/components/mod-notes-panel.gjs @@ -6,8 +6,49 @@ import { ajax } from "discourse/lib/ajax"; import { popupAjaxError } from "discourse/lib/ajax-error"; import { i18n } from "discourse-i18n"; -// Panel rendered inside the staff "Moderator notes" user-menu tab. Lists -// recent moderator notes across topics and marks the feed as seen. +// Maps `kind` → translation-key suffix in the `discourse_mod_categories` +// namespace, mirroring KIND_KEYS in lib/mod-note-notification.js so the +// shield-tab labels match the bell-tab labels for every kind. Unknown / +// legacy kinds fall back to the original "note" label. +const KIND_KEYS = { + note: "note_notification", + reply: "note_reply_notification", + post_deleted: "post_deleted_notification", + post_approved: "post_approved_notification", + post_rejected: "post_rejected_notification", + user_note: "user_note_notification", + flag_note: "flag_note_notification", +}; + +function labelFor(note) { + // Legacy topic-attached entries (returned from the topic-custom-field + // path of notes_feed) carry no `username` — fall back to the topic + // title alone so the row reads cleanly instead of as " added a + // moderator note" with a leading space. + if (!note.username) { + return note.topic_title || ""; + } + const key = KIND_KEYS[note.kind] || KIND_KEYS.note; + return i18n(`discourse_mod_categories.${key}`, { username: note.username }); +} + +// Context line under the label — topic title for topic-anchored kinds, +// "on " for user-note / flag-note kinds, otherwise blank. +function contextFor(note) { + if (note.topic_title) { + return note.topic_title; + } + if (note.target_username) { + return i18n("discourse_mod_categories.notes_tab.on_target", { + target: note.target_username, + }); + } + return ""; +} + +// Panel rendered inside the staff "Moderator notes" user-menu tab. +// Mirrors the bell's mod-note rows exactly — same notifications, same +// labels — so a staff member can use either entry point interchangeably. export default class ModNotesPanel extends Component { @service currentUser; @@ -39,6 +80,9 @@ export default class ModNotesPanel extends Component { } } + labelFor = labelFor; + contextFor = contextFor; +