From 1fa2f3821497d5529a6753cee84cf5ca679e8bcc Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 5 Jul 2026 21:49:21 +0200 Subject: Fix admin search results colliding with other search widgets layouts/admin.html.erb's top-bar search results div shared id "search_results" with nodes#new/nodes#edit's parent-search widget - since the layout renders on every admin page, whichever widget's JS ran last silently won the shared ID, putting top-bar results into the parent-search box on affected pages. Renamed to "menu_search_results" and updated admin_search.js to match. Also modernizes admin_search.js's event bindings from keyup/keydown/ keypress/paste/cut + .attr("value") to input + .val() throughout (menu_items, parent_search, move_to_search) - the multi-event binding was working around old IE compatibility that .val() + "input" already handles correctly. parent_search's result rendering now matches menu_search's convention (real

/ markup with a .result_path span) instead of a bare in a throwaway wrapper div, so the same CSS rule now correctly applies to both. menu_search's JSON response gains node_path per result, matching what admin_search's own results already provide - not yet consumed by parent_search/move_to_search, which still render click-to-select links rather than navigable ones (correct for their purpose - selecting a value, not leaving the page). Known remaining gap, not fixed here: menu_items, parent_search, and move_to_search still all target the literal id "search_results" between themselves. No live collision today since none of the three currently share a page, but it's the same fragility as the bug above - tracked alongside the existing menu_items/parent_search/move_to_search consolidation backlog item rather than treated as resolved. --- public/javascripts/admin_search.js | 105 ++++++++++++++++++++++--------------- 1 file changed, 62 insertions(+), 43 deletions(-) (limited to 'public/javascripts') diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index 9bf878b..2565929 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -36,8 +36,8 @@ admin_search = { } }); } else { - $('#search_results').slideUp(); - $('#search_results').empty(); + $('#menu_search_results').slideUp(); + $('#menu_search_results').empty(); } }); }, @@ -52,33 +52,33 @@ admin_search = { }, show_results : function(results) { - $('#search_results').empty(); + $('#menu_search_results').empty(); if (results.length) { - $('#search_results').append( + $('#menu_search_results').append( "

Press Enter to see all results ⏎

" ); } for (result in results) { - $('#search_results').append( + $('#menu_search_results').append( "

" + results[result].title + "" + results[result].unique_name + "" + "

" ); } - $('#search_results').slideDown(); + $('#menu_search_results').slideDown(); } }; menu_items = { initialize_search : function() { - $("#menu_search_term").bind("keyup", function() { - if ($(this).attr("value")) { + $("#menu_search_term").bind("input", function() { + if ($(this).val()) { $.ajax({ type: "GET", url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + $(this).attr("value"), + data: "search_term=" + $(this).val(), dataType: "json", success : function(results) { menu_items.show_results(results); @@ -125,12 +125,12 @@ parent_search = { initialize_search : function() { parent_search.initialize_radio_buttons(); - $("#parent_search_term").bind("keyup", function() { - if ($(this).attr("value")) { + $("#parent_search_term").bind("input", function() { + if ($(this).val()) { $.ajax({ type: "GET", url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + $(this).attr("value"), + data: "search_term=" + $(this).val(), dataType: "json", success : function(results) { parent_search.show_results(results); @@ -142,22 +142,31 @@ parent_search = { $('#search_results').empty(); } }); + + $("#title").bind("input", function() { + parent_search.update_resulting_path(); + }); + + $("#copy_resulting_path").bind("click", function() { + var path = $("#resulting_path").text(); + if (path === "—" || !navigator.clipboard) return; + navigator.clipboard.writeText(path); + }); }, show_results : function(results) { $("#search_results").empty(); var found = false; for (result in results) { - var link = $((""+ results[result].title + "")); - $(link).bind("click", parent_search.link_closure(results[result])); + var link = $(( + "

" + results[result].title + + "" + results[result].unique_name + "" + + "

")); - // Sometimes I don't get jquery; wrap() didn't work *sigh* - // Guess I'll need a book someday or another framework - var wrapper = $("
"); - $(wrapper).append(link); + $(link).bind("click", parent_search.link_closure(results[result])); - $("#search_results").append(wrapper); + $("#search_results").append(link); found = true; } if (found) @@ -166,51 +175,61 @@ parent_search = { link_closure : function(node) { var barf = function(){ - $("#parent_search_term").attr("value", node.title); - $("#parent_id").attr("value", node.node_id); + $("#parent_search_term").val(node.title); + $("#parent_id").val(node.node_id).attr("data-unique-name", node.unique_name); $('#search_results').slideUp(); $('#search_results').empty(); + parent_search.update_resulting_path(); return false; } return barf; }, - initialize_radio_buttons : function() { - $("#kind_top_level").bind("change", function(){ - $("#parent_search_field").hide(); - }); + update_resulting_path : function() { + var kind = $("input[name='kind']:checked"); + var title = $("#title").val(); - $("#kind_update").bind("change", function(){ - $("#parent_search_field").hide(); - }); + if (title === "") { + $("#resulting_path").text("—"); + return; + } - $("#kind_press_release").bind("change", function(){ - $("#parent_search_field").hide(); - }); + var prefix = kind.val() === "generic" + ? ($("#parent_id").attr("data-unique-name") || "") + : (kind.attr("data-path-prefix") || ""); - $("#kind_generic").bind("change", function(){ - $("#parent_search_field").show(); - }); + clearTimeout(parent_search.path_timeout); + parent_search.path_timeout = setTimeout(function() { + $.get("/nodes/parameterize_preview", { title: title }, function(slug) { + $("#resulting_path").text(window.location.origin + "/" + (prefix ? prefix + "/" : "") + slug); + }); + }, 300); + }, + initialize_radio_buttons : function() { + $("input[name='kind']").bind("change", function(){ + if ($(this).val() === "generic") { + $("#parent_search_field").show(); + } else { + $("#parent_search_field").hide(); + } + parent_search.update_resulting_path(); + }); } } move_to_search = { initialize_search : function() { - $("#move_to_search_term").bind("keyup", function() { move_to_search.do_search($(this))}); - $("#move_to_search_term").bind("keydown", function() { move_to_search.do_search($(this))}); - $("#move_to_search_term").bind("keypress", function() { move_to_search.do_search($(this))}); - $("#move_to_search_term").bind("paste", function() { move_to_search.do_search($(this))}); - $("#move_to_search_term").bind("cut", function() { move_to_search.do_search($(this))}); + $("#move_to_search_term").bind("input", function() {move_to_search.do_search($(this))}); }, do_search : function(_this) { - if (_this.attr("value")) { + if (_this.val()) { $.ajax({ type: "GET", url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + _this.attr("value"), + data: "search_term=" + _this.val(), dataType: "json", success : function(results) { move_to_search.show_results(results); @@ -248,8 +267,8 @@ move_to_search = { link_closure : function(node) { var barf = function(){ - $("#move_to_search_term").attr("value", node.title); - $("#node_staged_parent_id").attr("value", node.node_id); + $("#move_to_search_term").val(node.title); + $("#node_staged_parent_id").val(node.node_id); $('#search_results').slideUp(); $('#search_results').empty(); return false; -- cgit v1.3 From 1bf719d6ac58187cf406d92a40b665d3fa6e658b Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 03:48:09 +0200 Subject: Add context-aware child-creation shortcuts to nodes#show parent_match Procs on CccConventions::NODE_KINDS, matched against unique_path, decide which "add child" kinds show on a given node. Fixes nodes#new not honoring a pre-selected kind (radio group and parent-field visibility both defaulted to "generic" unconditionally). --- app/controllers/nodes_controller.rb | 1 + app/helpers/nodes_helper.rb | 5 +++ app/views/nodes/new.html.erb | 4 +-- app/views/nodes/show.html.erb | 29 +++++++++++++----- lib/ccc_conventions.rb | 61 ++++++++++++++++++++----------------- public/javascripts/admin_search.js | 18 +++++++---- public/stylesheets/admin.css | 8 +++++ 7 files changed, 82 insertions(+), 44 deletions(-) (limited to 'public/javascripts') diff --git a/app/controllers/nodes_controller.rb b/app/controllers/nodes_controller.rb index 69aa268..a72be68 100644 --- a/app/controllers/nodes_controller.rb +++ b/app/controllers/nodes_controller.rb @@ -22,6 +22,7 @@ class NodesController < ApplicationController def new @node = Node.new node_params + @selected_kind = CccConventions::NODE_KINDS.key?(params[:kind]) ? params[:kind] : "generic" if params.has_key?(:parent_id) @parent_id = params[:parent_id] @parent_name = Node.find(@parent_id).title diff --git a/app/helpers/nodes_helper.rb b/app/helpers/nodes_helper.rb index 2baf813..a89f879 100644 --- a/app/helpers/nodes_helper.rb +++ b/app/helpers/nodes_helper.rb @@ -58,4 +58,9 @@ module NodesHelper t(:event_schedule_none) end end + + def matching_node_kinds(node) + path = node.unique_path + CccConventions::NODE_KINDS.select { |_, config| config[:parent_match]&.call(path) } + end end diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index 0a05325..71f2fbf 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -13,7 +13,7 @@
<% CccConventions::NODE_KINDS.each do |kind, config| %>

- <%= radio_button_tag :kind, kind, kind == "generic", + <%= radio_button_tag :kind, kind, kind == @selected_kind, data: { path_prefix: resolve_kind_text(config[:path_prefix]) } %> <%= resolve_kind_text(config[:label]) %> <% if config[:hint] %> @@ -29,7 +29,7 @@ A URL slug will be generated from this automatically. You can review or adjust it afterward under the "metadata" section's Slug field.

-
+
">
Parent
<%= text_field_tag :parent_search_term, @parent_name %> diff --git a/app/views/nodes/show.html.erb b/app/views/nodes/show.html.erb index 189adb8..963bc37 100644 --- a/app/views/nodes/show.html.erb +++ b/app/views/nodes/show.html.erb @@ -101,17 +101,30 @@ <%= link_to 'add event', new_event_path(node_id: @node.id, tag_list: mapping&.last, auto_tag_source: mapping&.first, return_to: request.path) %>
- <% if @node.children.any? %> + <% matches = matching_node_kinds(@node) %> + <% if @node.children.any? || matches.any? %>
Children
-
- <%= pluralize(@node.children.count, 'child', 'children') %> -
    - <% @node.children.order(:slug).each do |child| %> -
  • <%= link_to (child.head&.title || child.draft&.title || child.slug), node_path(child) %>
  • + <% if @node.children.any? %> +
    + <%= pluralize(@node.children.count, 'child', 'children') %> +
      + <% @node.children.order(:slug).each do |child| %> +
    • <%= link_to (child.head&.title || child.draft&.title || child.slug), node_path(child) %>
    • + <% end %> +
    +
    + <% end %> + <% if matches.any? %> +
-
+

+ <% end %>
<% end %> diff --git a/lib/ccc_conventions.rb b/lib/ccc_conventions.rb index c0f9943..b420452 100644 --- a/lib/ccc_conventions.rb +++ b/lib/ccc_conventions.rb @@ -4,44 +4,49 @@ module CccConventions NODE_KINDS = { "top_level" => { - parent: -> { Node.root }, - path_prefix: "", - label: "Top Level" + parent: -> { Node.root }, + parent_match: ->(path) { path == [] }, + path_prefix: "", + label: "Top Level" }, "generic" => { - label: "Generic", - hint: "Can be created anywhere - choose the parent below." - # no path_prefix - depends on whatever parent gets chosen; see below + parent_match: ->(path) { true }, + label: "Generic", + hint: "Can be created anywhere - choose the parent below." }, "update" => { - parent: -> { Update.find_or_create_parent }, - tags: ["update"], - path_prefix: -> { "updates/#{Time.now.year}" }, - label: "Update", - hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tag \"update\", and inherits the update template." } + parent: -> { Update.find_or_create_parent }, + parent_match: ->(path) { path[0] == "updates" && (path.length == 1 || path[1] =~ /\A\d{4}\z/) }, + tags: ["update"], + path_prefix: -> { "updates/#{Time.now.year}" }, + label: "Update", + hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tag \"update\", and inherits the update template." } }, "press_release" => { - parent: -> { Update.find_or_create_parent }, - tags: ["update", "pressemitteilung"], - path_prefix: -> { "updates/#{Time.now.year}" }, - label: "Pressemitteilung", - hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tags \"update, pressemitteilung\", and inherits the update template." } + parent: -> { Update.find_or_create_parent }, + parent_match: ->(path) { path[0] == "updates" && (path.length == 1 || path[1] =~ /\A\d{4}\z/) }, + tags: ["update", "pressemitteilung"], + path_prefix: -> { "updates/#{Time.now.year}" }, + label: "Pressemitteilung", + hint: -> { "Automatically created in /updates/#{Time.now.year}/, gets tags \"update, pressemitteilung\", and inherits the update template." } }, "erfa" => { - parent: -> { Node.find_by_unique_name!(ERFA_PARENT_NAME) }, - tags: ["erfa-detail"], - template: "chapter_detail", - path_prefix: ERFA_PARENT_NAME, - label: "Erfa", - hint: "Automatically created under the Erfa-Kreise overview page, gets tag \"erfa-detail\", and uses the chapter detail template." + parent: -> { Node.find_by_unique_name!(ERFA_PARENT_NAME) }, + parent_match: ->(path) { path == ["club", "erfas"] }, + tags: ["erfa-detail"], + template: "chapter_detail", + path_prefix: ERFA_PARENT_NAME, + label: "Erfa", + hint: "Automatically created under the Erfa-Kreise overview page, gets tag \"erfa-detail\", and uses the chapter detail template." }, "chaostreff" => { - parent: -> { Node.find_by_unique_name!(CHAOSTREFF_PARENT_NAME) }, - tags: ["chaostreff-detail"], - template: "chapter_detail", - path_prefix: CHAOSTREFF_PARENT_NAME, - label: "Chaostreff", - hint: "Automatically created under the Chaostreffs overview page, gets tag \"chaostreff-detail\", and uses the chapter detail template." + parent: -> { Node.find_by_unique_name!(CHAOSTREFF_PARENT_NAME) }, + parent_match: ->(path) { path == ["club", "chaostreffs"] }, + tags: ["chaostreff-detail"], + template: "chapter_detail", + path_prefix: CHAOSTREFF_PARENT_NAME, + label: "Chaostreff", + hint: "Automatically created under the Chaostreffs overview page, gets tag \"chaostreff-detail\", and uses the chapter detail template." } }.freeze end diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index 2565929..6ef9087 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -208,14 +208,20 @@ parent_search = { }, initialize_radio_buttons : function() { - $("input[name='kind']").bind("change", function(){ - if ($(this).val() === "generic") { - $("#parent_search_field").show(); - } else { - $("#parent_search_field").hide(); - } + parent_search.sync_parent_field(); + $("input[name='kind']").bind("change", function() { + parent_search.sync_parent_field(); parent_search.update_resulting_path(); }); + }, + + sync_parent_field : function() { + var kind = $("input[name='kind']:checked").val(); + if (kind === "generic") { + $("#parent_search_field").show(); + } else { + $("#parent_search_field").hide(); + } } } diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 1196d83..3033798 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -338,6 +338,14 @@ input[type=radio] { border: 1px solid #989898; } +.add_child_links { + margin-top: 0.5rem; +} + +.add_child_links a { + white-space: nowrap; +} + div#login_form input[type=text], div#login_form input[type=password] { width: 150px; } -- cgit v1.3 From 4be318cf508f164e3cac1ee3854a0497c8223275 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Tue, 7 Jul 2026 13:14:46 +0200 Subject: Add link and lists to tinymce, wash pasted blocks clean of classes --- public/javascripts/admin_interface.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) (limited to 'public/javascripts') diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index f68fee8..450438f 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -18,11 +18,12 @@ $(document).ready(function () { license_key: 'gpl', promotion: false, menubar: false, - plugins: 'code', + plugins: 'code link lists visualblocks', toolbar: 'bold italic underline | bullist numlist | link unlink | blocks | code', - extended_valid_elements: 'aggregate[tags|limit|order_by|order_direction|partial|conditions]', + extended_valid_elements: 'aggregate[children|tags|limit|order_by|order_direction|partial|conditions]', relative_urls: false, entity_encoding: 'raw', + valid_classes: { '*': '' }, setup: function(editor) { editor.on('init', function() { cccms.setup_autosave(); -- cgit v1.3 From 3d7ddaf18419c983dd179b50eabe253c3eee8194 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:55:25 +0200 Subject: WIP: dynamic rrule builder --- app/views/events/edit.html.erb | 30 ++++++++++++++++++++++++++++-- public/javascripts/admin_interface.js | 6 +++++- 2 files changed, 33 insertions(+), 3 deletions(-) (limited to 'public/javascripts') diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb index 5aee501..d37c299 100644 --- a/app/views/events/edit.html.erb +++ b/app/views/events/edit.html.erb @@ -18,8 +18,34 @@ <%= f.datetime_select :end_time %>

- <%= f.label :rrule %>
- <%= f.text_field :rrule %> + <%= f.label :rrule, "Recurrence" %> +

+

+ + +

+
+

+

+ <% %w[MO TU WE TH FR SA SU].each do |code| %> + + <% end %> +

+
+ +

+ + <%= select_tag "rrule_excluded_month", options_for_select((1..12).map { |m| [RruleHumanizer::MONTH_NAMES[:de][m-1], m] }), style: "display: none;" %> +

+ Builds the field below automatically. If your pattern is more complex than this covers, just edit it directly below - these controls won't touch it unless you use them. +
+ <%= f.text_field :rrule, id: "event_rrule" %>

<%= f.label :tag_list, "Tags" %>
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 450438f..cb32d5a 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -50,7 +50,11 @@ $(document).ready(function () { if ($("#move_to_search_term").length != 0) { move_to_search.initialize_search(); } - + + if ($("#rrule_builder").length != 0) { + rrule_builder.initialize(); + } + if ($('#recent_changes_toggle').length != 0) { hide_all(); $('#recent_changes_toggle').attr("class", "selected"); -- cgit v1.3 From a6140897f2fbb0f59a639a931e23751fb93719a2 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:57:32 +0200 Subject: Whitespace fixes --- public/javascripts/admin_interface.js | 72 +++++++++++++++++------------------ 1 file changed, 36 insertions(+), 36 deletions(-) (limited to 'public/javascripts') diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index cb32d5a..67bfc02 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -12,7 +12,7 @@ function hide_all() { $(document).ready(function () { admin_search.initialize(); - + tinymce.init({ selector: 'textarea.with_editor', license_key: 'gpl', @@ -29,24 +29,24 @@ $(document).ready(function () { cccms.setup_autosave(); }); } - }); + }); if ($("#menu_search_term").length != 0) { menu_items.initialize_search(); } - + if ($("#menu_item_list").length != 0) { menu_item_sorter.initialize(); } - + if ($("#metadata").length != 0) { meta_data.initialize(); } - + if ($("#parent_search_term").length != 0) { parent_search.initialize_search(); } - + if ($("#move_to_search_term").length != 0) { move_to_search.initialize_search(); } @@ -59,14 +59,14 @@ $(document).ready(function () { hide_all(); $('#recent_changes_toggle').attr("class", "selected"); $('#recent_changes_table').show(); - + $('#recent_changes_toggle').bind("click", function(){ hide_all(); $('#recent_changes_toggle').attr("class", "selected"); $('#recent_changes_table').show(); return false; }); - + $('#my_work_toggle').bind("click", function(){ hide_all(); $('#my_work_toggle').attr("class", "selected"); @@ -80,7 +80,7 @@ $(document).ready(function () { $('#my_work_table').show(); return false; }); - + $('#current_drafts_toggle').bind("click", function(){ hide_all(); $('#current_drafts_toggle').attr("class", "selected"); @@ -102,18 +102,18 @@ $(document).ready(function () { return false; }); } - - jQuery.ajaxSetup({ + + jQuery.ajaxSetup({ 'beforeSend': function(xhr) {xhr.setRequestHeader("Accept", "text/javascript");} }); - + $(document).ajaxSend(function(event, request, settings) { if (typeof(AUTH_TOKEN) == "undefined") return; // settings.data is a serialized string like "foo=bar&baz=boink" (or null) settings.data = settings.data || ""; settings.data += (settings.data ? "&" : "") + "authenticity_token=" + encodeURIComponent(AUTH_TOKEN); }); - + }); @@ -143,50 +143,50 @@ meta_data = { cccms = { setup_autosave : function() { - + var elements = { title : $('#page_title'), abstract : $('#page_abstract'), body : $('#page_body_ifr').contents().find('#tinymce'), } - - var page = { + + var page = { cached_title : elements.title.val(), cached_abstract : elements.abstract.val(), cached_body : elements.body.html(), - + title_has_changed : function() { return (elements.title.val() != this.cached_title) }, - + abstract_has_changed : function() { return (elements.abstract.val() != this.cached_abstract) }, - + body_has_changed : function() { return elements.body.html() != this.cached_body } } - + jQuery.fn.submitWithAjax = function(options) { if (page.title_has_changed() || page.abstract_has_changed() || page.body_has_changed()) { - + page.cached_title = elements.title.val(); page.cached_abstract = elements.abstract.val(); page.cached_body = elements.body.html(); - + $("#flash").append(""); $.post(this.attr("action"), $(this).serialize(), null, "script"); - + } }; - + setInterval('$("#page_editor > form").submitWithAjax()', 7000); } } menu_item_sorter = { - + initialize : function() { $("#menu_item_list").sortable({ axis: 'y', @@ -209,24 +209,24 @@ menu_item_sorter = { } }); }, - + placeholder_helper : function(e,ui) { $(".ui-state-highlight").html(""); } } image_interface = { - + initialize : function() { - + $("#image_browser").hide(); image_interface.initialize_sortable_image_box(); image_interface.connect_browser_and_box(); image_interface.set_droppable_behavior(); - image_interface.bind_image_browser_toggle(); + image_interface.bind_image_browser_toggle(); }, - - + + set_droppable_behavior : function() { $("ul#image_box").droppable({ out : function(event, ui) { @@ -242,7 +242,7 @@ image_interface = { } }); }, - + connect_browser_and_box : function() { $("#image_browser ul li").draggable({ connectToSortable : 'ul#image_box', @@ -250,9 +250,9 @@ image_interface = { revert : 'invalid' }); }, - + initialize_sortable_image_box : function() { - + $("ul#image_box").sortable({ revert : true, update : function(event, ui) { @@ -267,9 +267,9 @@ image_interface = { } }); } - }); + }); }, - + bind_image_browser_toggle : function() { $("#image_browser_toggle").bind("click", function(){ if ($("#image_browser_toggle").attr("class") == "unselected") { -- cgit v1.3 From 971cbc3f0087f288fec9ae0320bb2b22d600df5e Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:58:06 +0200 Subject: Link new metadata behaviour and layout in css and javascript --- public/javascripts/admin_interface.js | 20 ++------------------ public/stylesheets/admin.css | 4 ++++ 2 files changed, 6 insertions(+), 18 deletions(-) (limited to 'public/javascripts') diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 67bfc02..4b1d4a9 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -119,24 +119,8 @@ $(document).ready(function () { meta_data = { initialize : function() { - $("#metadata").hide(); - - $("#button").click(function () { - - $("#metadata").slideToggle(1200); - image_interface.initialize(); - - if ($("#button").attr("class") == "unselected") { - $("#button").attr("class", "selected"); - - } - else { - $("#button").attr("class", "unselected"); - $("#image_browser").hide(); - $("#image_browser_toggle").attr("class", "unselected"); - } - - return false; + document.getElementById("metadata_details").addEventListener("toggle", function() { + if (this.open) image_interface.initialize(); }); } }; diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 94248a6..0c0779f 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -93,6 +93,10 @@ input[type=radio] { margin-left: 5px; } +#metadata_details summary { + margin-bottom: 1em; +} + .right { text-align: right; } .clear_left { clear: left; } -- cgit v1.3 From 618aca78a5574de3d5f5f275c73d33a5cdcb9df8 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:58:36 +0200 Subject: WIP: dynamic rrule builder, javascript side --- public/javascripts/admin_interface.js | 101 ++++++++++++++++++++++++++++++++++ 1 file changed, 101 insertions(+) (limited to 'public/javascripts') diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 4b1d4a9..3c480aa 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -271,3 +271,104 @@ image_interface = { } +rrule_builder = { + initialize : function() { + rrule_builder.try_populate_from_rrule($("#event_rrule").val()); + + $("input[name='rrule_freq']").bind("change", function() { + if ($(this).val() === "weekly") { rrule_builder.show_weekly_options(); } + else { rrule_builder.show_monthly_options(); } + rrule_builder.sync(); + }); + $("#rrule_monthly_ordinal").bind("change", function() { + $("#rrule_ordinal_fields").toggle($(this).is(":checked")); + rrule_builder.sync(); + }); + $("#rrule_exclude_month").bind("change", function() { + $("#rrule_excluded_month").toggle($(this).is(":checked")); + rrule_builder.sync(); + }); + $("#rrule_builder input, #rrule_builder select").bind("change", rrule_builder.sync); + }, + + show_weekly_options : function() { + $("#rrule_weekly_options").show(); + $("#rrule_monthly_options").hide(); + }, + show_monthly_options : function() { + $("#rrule_weekly_options").hide(); + $("#rrule_monthly_options").show(); + }, + + sync : function() { + var freq = $("input[name='rrule_freq']:checked").val(); + var parts = []; + + if (freq === "weekly") { + parts.push("FREQ=WEEKLY"); + if ($("#rrule_biweekly").is(":checked")) parts.push("INTERVAL=2"); + var days = []; + $("input[id^='rrule_byday_']:checked").each(function() { + days.push($(this).attr("id").replace("rrule_byday_", "")); + }); + if (days.length > 0) parts.push("BYDAY=" + days.join(",")); + } else { + parts.push("FREQ=MONTHLY"); + if ($("#rrule_monthly_ordinal").is(":checked")) { + parts.push("BYDAY=" + $("#rrule_ordinal").val() + $("#rrule_ordinal_day").val()); + } + } + + if ($("#rrule_exclude_month").is(":checked")) { + var excluded = parseInt($("#rrule_excluded_month").val(), 10); + var months = []; + for (var m = 1; m <= 12; m++) { if (m !== excluded) months.push(m); } + parts.push("BYMONTH=" + months.join(",")); + } + + $("#event_rrule").val(parts.join(";")); + }, + + try_populate_from_rrule : function(rrule) { + if (!rrule) return; + var parts = {}; + rrule.split(";").forEach(function(p) { + var kv = p.split("="); + parts[kv[0]] = kv[1]; + }); + if (parts.COUNT || parts.UNTIL) return; + + if (parts.FREQ === "WEEKLY") { + $("input[name='rrule_freq'][value='weekly']").prop("checked", true); + rrule_builder.show_weekly_options(); + if (parts.INTERVAL === "2") $("#rrule_biweekly").prop("checked", true); + if (parts.BYDAY) { + parts.BYDAY.split(",").forEach(function(code) { + $("#rrule_byday_" + code).prop("checked", true); + }); + } + } else if (parts.FREQ === "MONTHLY") { + $("input[name='rrule_freq'][value='monthly']").prop("checked", true); + rrule_builder.show_monthly_options(); + var match = parts.BYDAY && parts.BYDAY.match(/^(-?\d+)([A-Z]{2})$/); + if (match) { + $("#rrule_monthly_ordinal").prop("checked", true); + $("#rrule_ordinal_fields").show(); + $("#rrule_ordinal").val(match[1]); + $("#rrule_ordinal_day").val(match[2]); + } + } else { + return; + } + + if (parts.BYMONTH) { + var included = parts.BYMONTH.split(",").map(function(s) { return parseInt(s, 10); }); + var missing = []; + for (var m = 1; m <= 12; m++) { if (included.indexOf(m) === -1) missing.push(m); } + if (missing.length === 1) { + $("#rrule_exclude_month").prop("checked", true); + $("#rrule_excluded_month").val(missing[0]).show(); + } + } + } +}; -- cgit v1.3 From 08b4cdb711ffe39153264a5c4024fac95cd8d91a Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 11:58:46 +0200 Subject: Whitespace fixes --- public/javascripts/admin_interface.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) (limited to 'public/javascripts') diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 3c480aa..41699a1 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -264,12 +264,11 @@ image_interface = { $("#image_browser_toggle").attr("class", "unselected"); $("#image_browser").hide(); } - + return false; }); } } - rrule_builder = { initialize : function() { -- cgit v1.3 From 43e7743ebff1b1f41bbb5ae39bcc5bed7d8982e3 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Wed, 8 Jul 2026 16:24:05 +0200 Subject: Autosave posts to its own endpoint surface lock loss immediately Previously piggybacked on the main form's action and relied on a .js.erb response to update #flash; now posts to the dedicated autosave route with an explicit _method=put override, and reacts to a 423 by stopping the timer and showing a persistent banner rather than letting the next Save discover the conflict on its own. --- app/views/nodes/edit.html.erb | 2 +- public/javascripts/admin_interface.js | 38 ++++++++++++++++++++++++++++++++--- public/stylesheets/admin.css | 5 +++++ 3 files changed, 41 insertions(+), 4 deletions(-) (limited to 'public/javascripts') diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 4693569..1c6cc3a 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -5,7 +5,7 @@ <% end %>

-<%= form_for(@node) do |f| %> + <%= form_for(@node, html: { data: { autosave_url: autosave_node_path(@node), show_url: node_path(@node) } }) do |f| %> <% if @node.errors.any? %>
    <% @node.errors.full_messages.each do |msg| %>
  • <%= msg %>
  • <% end %>
diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 41699a1..4107ce8 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -159,13 +159,45 @@ cccms = { page.cached_abstract = elements.abstract.val(); page.cached_body = elements.body.html(); - $("#flash").append(""); - $.post(this.attr("action"), $(this).serialize(), null, "script"); + var data = this.serializeArray().filter(function(field) { + return field.name !== "_method"; + }); + data.push({ name: "_method", value: "put" }); + + $.ajax({ + type: "POST", + url: this.attr("data-autosave-url"), + data: data, + error: function(xhr) { + if (xhr.status === 423) { + clearInterval(cccms.autosave_timer); + cccms.report_lock_lost($("#page_editor > form").attr("data-show-url")); + } + // any other failure: quietly retried on the next tick + } + }); } }; - setInterval('$("#page_editor > form").submitWithAjax()', 7000); + cccms.autosave_timer = setInterval(function() { + $("#page_editor > form").submitWithAjax(); + }, 7000); + }, + + report_lock_lost : function(show_url) { + var $flash = $("#flash"); + if ($flash.length === 0) { + $flash = $("
", { id: "flash" }).insertAfter(".admin_content_spacer"); + } + + var $banner = $("", { "class": "warning" }).text( + "This page is now locked by someone else — your changes here can no longer be saved. Copy anything you need, then " + ); + $banner.append($("", { href: show_url }).text("return to the published page")); + $banner.append("."); + + $flash.html($banner); } } diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 0c0779f..7ae374c 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -200,6 +200,11 @@ span#flash_error, span.warning { padding-bottom: 1px; } +span.warning a { + color: inherit; + text-decoration: underline; +} + /* ============================================================ Pagination ============================================================ */ -- cgit v1.3 From 83a74c6d01e0be50d5806d8253de9f2ad6d5c143 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Thu, 9 Jul 2026 18:55:25 +0200 Subject: Rework events#new with the new layout paradigm --- app/views/events/new.html.erb | 89 +++++++++++++++++++---------------- public/javascripts/admin_interface.js | 4 ++ public/javascripts/admin_search.js | 59 +++++++++++++++++++++++ 3 files changed, 112 insertions(+), 40 deletions(-) (limited to 'public/javascripts') diff --git a/app/views/events/new.html.erb b/app/views/events/new.html.erb index 4e1ef53..b20fe48 100644 --- a/app/views/events/new.html.erb +++ b/app/views/events/new.html.erb @@ -1,47 +1,56 @@

New event

+<%= link_to 'Back', safe_return_to(params[:return_to] || events_path) %> + <%= form_for(@event) do |f| %> <%= form_error_messages(f) %> -

- <%= f.label :start_time %>
- <%= f.datetime_select :start_time %> -

-

- <%= f.label :end_time %>
- <%= f.datetime_select :end_time %> -

-

- <%= f.label :rrule %>
- <%= f.text_field :rrule %> -

-

- <%= f.label :tag_list, "Tags" %>
- <%= f.text_field :tag_list %> -

-

- <%= f.label :allday %>
- <%= f.check_box :allday %> -

-

- <%= f.label :url %>
- <%= f.text_field :url %> -

-

- <%= f.label :latitude %>
- <%= f.text_field :latitude %> -

-

- <%= f.label :longitude %>
- <%= f.text_field :longitude %> -

-

- <%= f.hidden_field :node_id %> - <%= hidden_field_tag :return_to, params[:return_to] %> -

-

- <%= f.submit 'Create' %> -

+
+
+
Node
+
+ <%= text_field_tag :event_node_search_term %> +
+ <%= f.hidden_field :node_id %> + Optional — search and pick a node to associate this event with a page. +
+ +
Start time
+
<%= f.datetime_select :start_time %>
+ +
End time
+
<%= f.datetime_select :end_time %>
+ +
Rrule
+
<%= f.text_field :rrule %>
+ +
Title
+
+ <%= f.text_field :title %> + Optional — if left blank, the associated node's title is used. +
+ +
Tags
+
<%= f.text_field :tag_list %>
+ +
Allday
+
<%= f.check_box :allday %>
+ +
Url
+
<%= f.text_field :url %>
+ +
Latitude
+
<%= f.text_field :latitude %>
+ +
Longitude
+
<%= f.text_field :longitude %>
+ +
+
<%= hidden_field_tag :return_to, params[:return_to] %>
+ +
+
<%= f.submit 'Create' %>
+
+
<% end %> -<%= link_to 'Back', safe_return_to(params[:return_to] || events_path) %> diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 4107ce8..8ca5b5e 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -51,6 +51,10 @@ $(document).ready(function () { move_to_search.initialize_search(); } + if ($("#event_node_search_term").length != 0) { + event_search.initialize_search(); + } + if ($("#rrule_builder").length != 0) { rrule_builder.initialize(); } diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index 6ef9087..5be5d57 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -283,3 +283,62 @@ move_to_search = { return barf; } } + +event_search = { + initialize_search : function() { + $("#event_node_search_term").bind("input", function() { + if ($(this).val()) { + $.ajax({ + type: "GET", + url: ADMIN_MENU_SEARCH_URL, + data: "search_term=" + $(this).val(), + dataType: "json", + success : function(results) { + event_search.show_results(results); + } + }); + } + else { + $('#search_results').slideUp(); + $('#search_results').empty(); + } + }); + }, + + show_results : function(results) { + $("#search_results").empty(); + var found = false; + for (result in results) { + var link = $(( + "

" + results[result].title + + "" + results[result].unique_name + "" + + "

")); + + $(link).bind("click", event_search.link_closure(results[result])); + + $("#search_results").append(link); + found = true; + } + if (found) + $('#search_results').slideDown(); + else + $('#search_results').slideUp(); + }, + + link_closure : function(node) { + var barf = function(){ + $("#event_node_search_term").val(node.title); + $("#event_node_id").val(node.node_id); + $('#search_results').slideUp(); + $('#search_results').empty(); + + var title_field = $("#event_title"); + if (title_field.val() === "") { + $("#event_title_hint").text("Using \"" + node.title + "\" from the associated node."); + } + + return false; + } + return barf; + } +}; -- cgit v1.3 From a25d90335ad3738b6831288190132c2f7498465c Mon Sep 17 00:00:00 2001 From: erdgeist Date: Fri, 10 Jul 2026 00:35:24 +0200 Subject: Retire vendored cacycle_diff.js for a diff-lcs-based diff view Revisions#diff now computes an inline or side-by-side word diff server-side (Page#diff_against, lib/html_word_diff.rb) instead of shipping raw/escaped content to the browser for a 2008-era vendored JS differ to mangle. View mode is picked via a `view` param, settable either on the diff page itself or directly from the revisions#index sticky bar next to "Diff revisions". Also fixes a pre-existing bug in RevisionsController#diff's single- revision branch, which set params[:start]/params[:end] instead of params[:start_revision]/params[:end_revision]. --- Gemfile | 3 +- Gemfile.lock | 3 + app/controllers/revisions_controller.rb | 12 +- app/models/page.rb | 16 + app/views/revisions/diff.html.erb | 78 +- app/views/revisions/index.html.erb | 8 + lib/html_word_diff.rb | 52 ++ public/javascripts/cacycle_diff.js | 1112 ------------------------- public/stylesheets/admin.css | 25 + test/controllers/revisions_controller_test.rb | 29 + test/models/page_test.rb | 34 + 11 files changed, 204 insertions(+), 1168 deletions(-) create mode 100644 lib/html_word_diff.rb delete mode 100644 public/javascripts/cacycle_diff.js (limited to 'public/javascripts') diff --git a/Gemfile b/Gemfile index d136eb5..19d92d7 100644 --- a/Gemfile +++ b/Gemfile @@ -45,10 +45,11 @@ gem 'acts-as-taggable-on', git: 'https://github.com/mbleigh/acts-as-taggable-on.git', branch: 'master' -# ── XML / parsing ───────────────────────────────────────────────────────────── +# ── XML / parsing / diffing ─────────────────────────────────────────────────── gem 'libxml-ruby', '~> 5.0', require: 'xml' # body link rewriting in Page model gem 'nokogiri', '~> 1.18' +gem 'diff-lcs', require: 'diff/lcs' # ── Operational ─────────────────────────────────────────────────────────────── diff --git a/Gemfile.lock b/Gemfile.lock index 02c71ff..e6f5fa0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,6 +108,7 @@ GEM connection_pool (3.0.2) crass (1.0.7) date (3.5.1) + diff-lcs (2.0.0) drb (2.2.3) erb (6.0.4) erubi (1.13.1) @@ -330,6 +331,7 @@ DEPENDENCIES chaos_calendar! coffee-rails (~> 4.0) concurrent-ruby (~> 1.3) + diff-lcs exception_notification (~> 4.5) globalize (~> 7.0) jquery-rails @@ -377,6 +379,7 @@ CHECKSUMS connection_pool (3.0.2) sha256=33fff5ba71a12d2aa26cb72b1db8bba2a1a01823559fb01d29eb74c286e62e0a crass (1.0.7) sha256=94868719948664c89ddcaf0a37c65048413dfcb1c869470a5f7a7ceb5390b295 date (3.5.1) sha256=750d06384d7b9c15d562c76291407d89e368dda4d4fff957eb94962d325a0dc0 + diff-lcs (2.0.0) sha256=708a5d52ec2945b50f8f53a181174aa1ef2c496edf81c05957fe956dabb363d5 drb (2.2.3) sha256=0b00d6fdb50995fe4a45dea13663493c841112e4068656854646f418fda13373 erb (6.0.4) sha256=38e3803694be357fe2bfe312487c74beaf9fb4e5beb3e22498952fe1645b95d9 erubi (1.13.1) sha256=a082103b0885dbc5ecf1172fede897f9ebdb745a4b97a5e8dc63953db1ee4ad9 diff --git a/app/controllers/revisions_controller.rb b/app/controllers/revisions_controller.rb index 42d667e..9acb26f 100644 --- a/app/controllers/revisions_controller.rb +++ b/app/controllers/revisions_controller.rb @@ -13,16 +13,18 @@ class RevisionsController < ApplicationController def diff @node = Node.find(params[:node_id]) - + if @node.pages.length > 1 params[:start_revision] ||= @node.pages.all[-2].revision params[:end_revision] ||= @node.pages.all[-1].revision else - params[:start], params[:end] = 1, 1 + params[:start_revision], params[:end_revision] = 1, 1 end - - @start = @node.pages.find_by_revision( params[:start_revision] ) - @end = @node.pages.find_by_revision( params[:end_revision] ) + + @start = @node.pages.find_by_revision( params[:start_revision] ) + @end = @node.pages.find_by_revision( params[:end_revision] ) + @diff_view = params[:view] == "side_by_side" ? :side_by_side : :inline + @diff = @end.diff_against( @start, view: @diff_view ) end def show diff --git a/app/models/page.rb b/app/models/page.rb index 1a98e08..ea04cd6 100644 --- a/app/models/page.rb +++ b/app/models/page.rb @@ -175,6 +175,22 @@ class Page < ApplicationRecord self.save end + def diff_against other, view: :inline + if view == :side_by_side + { + title: HtmlWordDiff.side_by_side(other.title.to_s, title.to_s), + abstract: HtmlWordDiff.side_by_side(other.abstract.to_s, abstract.to_s), + body: HtmlWordDiff.side_by_side(other.body.to_s, body.to_s) + } + else + { + title: HtmlWordDiff.inline(other.title.to_s, title.to_s), + abstract: HtmlWordDiff.inline(other.abstract.to_s, abstract.to_s), + body: HtmlWordDiff.inline(other.body.to_s, body.to_s) + } + end + end + def public? published_at.nil? ? true : published_at < Time.now end diff --git a/app/views/revisions/diff.html.erb b/app/views/revisions/diff.html.erb index d8c6a47..d7bb528 100644 --- a/app/views/revisions/diff.html.erb +++ b/app/views/revisions/diff.html.erb @@ -7,58 +7,36 @@ <%= form_tag diff_node_revisions_path do %> <%= select_tag :start_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:start_revision].to_i) %> <%= select_tag :end_revision, options_for_select(@node.pages.map{|x| x.revision}, params[:end_revision].to_i) %> + <%= select_tag :view, options_for_select([['Inline', 'inline'], ['Side by side', 'side_by_side']], @diff_view) %> <%= submit_tag 'Diff' %> <% end %> - - - - - - - - -
-  
-
-
-  
-
- - - -
-

Title

-

- -

Abstract

-

- -

Body

-

+ <% if @diff_view == :side_by_side %> +
+
+

Title

+

<%= raw @diff[:title][0] %>

+

Abstract

+

<%= raw @diff[:abstract][0] %>

+

Body

+ <%= raw @diff[:body][0] %> +
+
+

Title

+

<%= raw @diff[:title][1] %>

+

Abstract

+

<%= raw @diff[:abstract][1] %>

+

Body

+ <%= raw @diff[:body][1] %> +
+
+ <% else %> +

Title

+

<%= raw @diff[:title] %>

+

Abstract

+

<%= raw @diff[:abstract] %>

+

Body

+ <%= raw @diff[:body] %> + <% end %>
diff --git a/app/views/revisions/index.html.erb b/app/views/revisions/index.html.erb index a6a981a..58c08b7 100644 --- a/app/views/revisions/index.html.erb +++ b/app/views/revisions/index.html.erb @@ -19,6 +19,8 @@ form: { id: 'diff_form', class: 'button_to computation' }, disabled: true %> + + @@ -68,6 +70,7 @@ document.getElementById('diff_form').addEventListener('submit', function(e) { var start = document.querySelector('input[name="start_revision"]:checked'); var end = document.querySelector('input[name="end_revision"]:checked'); + var view = document.querySelector('input[name="view"]:checked'); if (start) { var s = document.createElement('input'); s.type = 'hidden'; s.name = 'start_revision'; s.value = start.value; @@ -78,5 +81,10 @@ en.type = 'hidden'; en.name = 'end_revision'; en.value = end.value; this.appendChild(en); } + if (view) { + var v = document.createElement('input'); + v.type = 'hidden'; v.name = 'view'; v.value = view.value; + this.appendChild(v); + } }); diff --git a/lib/html_word_diff.rb b/lib/html_word_diff.rb new file mode 100644 index 0000000..1f28cf5 --- /dev/null +++ b/lib/html_word_diff.rb @@ -0,0 +1,52 @@ +require 'diff/lcs' + +module HtmlWordDiff + TOKEN_REGEXP = /<[^>]*>|[[:space:]]+|[[:alnum:]]+|[^[:space:][:alnum:]<>]/ + + def self.inline(old_html, new_html) + html = +'' + each_change(old_html, new_html) do |action, old_token, new_token| + case action + when '=' + html << new_token + when '-' + html << "#{old_token}" + when '+' + html << "#{new_token}" + when '!' + html << "#{old_token}#{new_token}" + end + end + html + end + + def self.side_by_side(old_html, new_html) + old_out = +'' + new_out = +'' + each_change(old_html, new_html) do |action, old_token, new_token| + case action + when '=' + old_out << old_token + new_out << new_token + when '-' + old_out << "#{old_token}" + when '+' + new_out << "#{new_token}" + when '!' + old_out << "#{old_token}" + new_out << "#{new_token}" + end + end + [old_out, new_out] + end + + def self.each_change(old_html, new_html) + Diff::LCS.sdiff(tokenize(old_html), tokenize(new_html)).each do |change| + yield change.action, change.old_element, change.new_element + end + end + + def self.tokenize(html) + html.to_s.scan(TOKEN_REGEXP) + end +end diff --git a/public/javascripts/cacycle_diff.js b/public/javascripts/cacycle_diff.js deleted file mode 100644 index 24f9d0b..0000000 --- a/public/javascripts/cacycle_diff.js +++ /dev/null @@ -1,1112 +0,0 @@ -//

- 
-/*
- 
-Name:    diff.js
-Version: 0.9.5a (April 6, 2008)
-Info:    http://en.wikipedia.org/wiki/User:Cacycle/diff
-Code:    http://en.wikipedia.org/wiki/User:Cacycle/diff.js
- 
-JavaScript diff algorithm by [[en:User:Cacycle]] (http://en.wikipedia.org/wiki/User_talk:Cacycle).
-Outputs html/css-formatted new text with highlighted deletions, inserts, and block moves.
- 
-The program uses cross-browser code and should work with all modern browsers. It has been tested with:
-* Mozilla Firefox 1.5.0.1
-* Mozilla SeaMonkey 1.0
-* Opera 8.53
-* Internet Explorer 6.0.2900.2180
-* Internet Explorer 7.0.5730.11
-This program is also compatibel with Greasemonkey
- 
-An implementation of the word-based algorithm from:
- 
-Communications of the ACM 21(4):264 (1978)
-http://doi.acm.org/10.1145/359460.359467
- 
-With the following additional feature:
- 
-* Word types have been optimized for MediaWiki source texts
-* Additional post-pass 5 code for resolving islands caused by adding
-  two common words at the end of sequences of common words
-* Additional detection of block borders and color coding of moved blocks and their original position
-* Optional "intelligent" omission of unchanged parts from the output
- 
-This code is used by the MediaWiki in-browser text editors [[en:User:Cacycle/editor]] and [[en:User:Cacycle/wikEd]]
-and the enhanced diff view tool wikEdDiff [[en:User:Cacycle/wikEd]].
- 
-Usage: var htmlText = WDiffString(oldText, newText);
- 
-This code has been released into the public domain.
- 
-Datastructures:
- 
-text: an object that holds all text related datastructures
-  .newWords: consecutive words of the new text (N)
-  .oldWords: consecutive words of the old text (O)
-  .newToOld: array of corresponding word number in old text (NA)
-  .oldToNew: array of corresponding word number in new text (OA)
-  .message:  output message for testing purposes
- 
-symbol['word']: symbol table for passes 1 - 3, holds words as a hash
-  .newCtr:  new word occurences counter (NC)
-  .oldCtr:  old word occurences counter (OC)
-  .toNew:   table last old word number
-  .toOld:   last new word number (OLNA)
- 
-block: an object that holds block move information
-  blocks indexed after new text:
-  .newStart:  new text word number of start of this block
-  .newLength: element number of this block including non-words
-  .newWords:  true word number of this block
-  .newNumber: corresponding block index in old text
-  .newBlock:  moved-block-number of a block that has been moved here
-  .newLeft:   moved-block-number of a block that has been moved from this border leftwards
-  .newRight:  moved-block-number of a block that has been moved from this border rightwards
-  .newLeftIndex:  index number of a block that has been moved from this border leftwards
-  .newRightIndex: index number of a block that has been moved from this border rightwards
-  blocks indexed after old text:
-  .oldStart:  word number of start of this block
-  .oldToNew:  corresponding new text word number of start
-  .oldLength: element number of this block including non-words
-  .oldWords:  true word number of this block
- 
-*/
- 
- 
-// css for change indicators
-if (typeof(wDiffStyleDelete) == 'undefined') { window.wDiffStyleDelete = 'font-weight: normal; text-decoration: none; color: #fff; background-color: #990033;'; }
-if (typeof(wDiffStyleInsert) == 'undefined') { window.wDiffStyleInsert = 'font-weight: normal; text-decoration: none; color: #fff; background-color: #009933;'; }
-if (typeof(wDiffStyleMoved)  == 'undefined') { window.wDiffStyleMoved  = 'font-weight: bold;  color: #000; vertical-align: text-bottom; font-size: xx-small; padding: 0; border: solid 1px;'; }
-if (typeof(wDiffStyleBlock)  == 'undefined') { window.wDiffStyleBlock  = [
-  'color: #000; background-color: #ffff80;',
-  'color: #000; background-color: #c0ffff;',
-  'color: #000; background-color: #ffd0f0;',
-  'color: #000; background-color: #ffe080;',
-  'color: #000; background-color: #aaddff;',
-  'color: #000; background-color: #ddaaff;',
-  'color: #000; background-color: #ffbbbb;',
-  'color: #000; background-color: #d8ffa0;',
-  'color: #000; background-color: #d0d0d0;'
-]; }
- 
-// html for change indicators, {number} is replaced by the block number
-// {block} is replaced by the block style, class and html comments are important for shortening the output
-if (typeof(wDiffHtmlMovedRight)  == 'undefined') { window.wDiffHtmlMovedRight  = ''; }
-if (typeof(wDiffHtmlMovedLeft)   == 'undefined') { window.wDiffHtmlMovedLeft   = ''; }
- 
-if (typeof(wDiffHtmlBlockStart)  == 'undefined') { window.wDiffHtmlBlockStart  = ''; }
-if (typeof(wDiffHtmlBlockEnd)    == 'undefined') { window.wDiffHtmlBlockEnd    = ''; }
- 
-if (typeof(wDiffHtmlDeleteStart) == 'undefined') { window.wDiffHtmlDeleteStart = ''; }
-if (typeof(wDiffHtmlDeleteEnd)   == 'undefined') { window.wDiffHtmlDeleteEnd   = ''; }
- 
-if (typeof(wDiffHtmlInsertStart) == 'undefined') { window.wDiffHtmlInsertStart = ''; }
-if (typeof(wDiffHtmlInsertEnd)   == 'undefined') { window.wDiffHtmlInsertEnd   = ''; }
- 
-// minimal number of real words for a moved block (0 for always displaying block move indicators)
-if (typeof(wDiffBlockMinLength) == 'undefined') { window.wDiffBlockMinLength = 3; }
- 
-// exclude identical sequence starts and endings from change marking
-if (typeof(wDiffWordDiff) == 'undefined') { window.wDiffWordDiff = true; }
- 
-// enable recursive diff to resolve problematic sequences
-if (typeof(wDiffRecursiveDiff) == 'undefined') { window.wDiffRecursiveDiff = true; }
- 
-// enable block move display
-if (typeof(wDiffShowBlockMoves) == 'undefined') { window.wDiffShowBlockMoves = true; }
- 
-// remove unchanged parts from final output
- 
-// characters before diff tag to search for previous heading, paragraph, line break, cut characters
-if (typeof(wDiffHeadingBefore)   == 'undefined') { window.wDiffHeadingBefore   = 1500; }
-if (typeof(wDiffParagraphBefore) == 'undefined') { window.wDiffParagraphBefore = 1500; }
-if (typeof(wDiffLineBeforeMax)   == 'undefined') { window.wDiffLineBeforeMax   = 1000; }
-if (typeof(wDiffLineBeforeMin)   == 'undefined') { window.wDiffLineBeforeMin   =  500; }
-if (typeof(wDiffBlankBeforeMax)  == 'undefined') { window.wDiffBlankBeforeMax  = 1000; }
-if (typeof(wDiffBlankBeforeMin)  == 'undefined') { window.wDiffBlankBeforeMin  =  500; }
-if (typeof(wDiffCharsBefore)     == 'undefined') { window.wDiffCharsBefore     =  500; }
- 
-// characters after diff tag to search for next heading, paragraph, line break, or characters
-if (typeof(wDiffHeadingAfter)   == 'undefined') { window.wDiffHeadingAfter   = 1500; }
-if (typeof(wDiffParagraphAfter) == 'undefined') { window.wDiffParagraphAfter = 1500; }
-if (typeof(wDiffLineAfterMax)   == 'undefined') { window.wDiffLineAfterMax   = 1000; }
-if (typeof(wDiffLineAfterMin)   == 'undefined') { window.wDiffLineAfterMin   =  500; }
-if (typeof(wDiffBlankAfterMax)  == 'undefined') { window.wDiffBlankAfterMax  = 1000; }
-if (typeof(wDiffBlankAfterMin)  == 'undefined') { window.wDiffBlankAfterMin  =  500; }
-if (typeof(wDiffCharsAfter)     == 'undefined') { window.wDiffCharsAfter     =  500; }
- 
-// maximal fragment distance to join close fragments
-if (typeof(wDiffFragmentJoin)  == 'undefined') { window.wDiffFragmentJoin = 1000; }
-if (typeof(wDiffOmittedChars)  == 'undefined') { window.wDiffOmittedChars = '…'; }
-if (typeof(wDiffOmittedLines)  == 'undefined') { window.wDiffOmittedLines = '
'; } -if (typeof(wDiffNoChange) == 'undefined') { window.wDiffNoChange = '
'; } - -// compatibility fix for old name of main function -window.StringDiff = window.WDiffString; - - -// WDiffString: main program -// input: oldText, newText, strings containing the texts -// returns: html diff - -window.WDiffString = function(oldText, newText) { - -// IE / Mac fix - oldText = oldText.replace(/(\r\n)/g, '\n'); - newText = newText.replace(/(\r\n)/g, '\n'); - - var text = {}; - text.newWords = []; - text.oldWords = []; - text.newToOld = []; - text.oldToNew = []; - text.message = ''; - var block = {}; - var outText = ''; - -// trap trivial changes: no change - if (oldText == newText) { - outText = newText; - outText = WDiffEscape(outText); - outText = WDiffHtmlFormat(outText); - return(outText); - } - -// trap trivial changes: old text deleted - if ( (oldText == null) || (oldText.length == 0) ) { - outText = newText; - outText = WDiffEscape(outText); - outText = WDiffHtmlFormat(outText); - outText = wDiffHtmlInsertStart + outText + wDiffHtmlInsertEnd; - return(outText); - } - -// trap trivial changes: new text deleted - if ( (newText == null) || (newText.length == 0) ) { - outText = oldText; - outText = WDiffEscape(outText); - outText = WDiffHtmlFormat(outText); - outText = wDiffHtmlDeleteStart + outText + wDiffHtmlDeleteEnd; - return(outText); - } - -// split new and old text into words - WDiffSplitText(oldText, newText, text); - -// calculate diff information - WDiffText(text); - -//detect block borders and moved blocks - WDiffDetectBlocks(text, block); - -// process diff data into formatted html text - outText = WDiffToHtml(text, block); - -// IE fix - outText = outText.replace(/> ( *) $1<'); - - return(outText); -} - - -// WDiffSplitText: split new and old text into words -// input: oldText, newText, strings containing the texts -// changes: text.newWords and text.oldWords, arrays containing the texts in arrays of words - -window.WDiffSplitText = function(oldText, newText, text) { - -// convert strange spaces - oldText = oldText.replace(/[\t\u000b\u00a0\u2028\u2029]+/g, ' '); - newText = newText.replace(/[\t\u000b\u00a0\u2028\u2029]+/g, ' '); - -// split old text into words - -// / | | | | | | | | | | | | | | / - var pattern = /[\w]+|\[\[|\]\]|\{\{|\}\}|\n+| +|&\w+;|'''|''|=+|\{\||\|\}|\|\-|./g; - var result; - do { - result = pattern.exec(oldText); - if (result != null) { - text.oldWords.push(result[0]); - } - } while (result != null); - -// split new text into words - do { - result = pattern.exec(newText); - if (result != null) { - text.newWords.push(result[0]); - } - } while (result != null); - - return; -} - - -// WDiffText: calculate diff information -// input: text.newWords and text.oldWords, arrays containing the texts in arrays of words -// optionally for recursive calls: newStart, newEnd, oldStart, oldEnd, recursionLevel -// changes: text.newToOld and text.oldToNew, containing the line numbers in the other version - -window.WDiffText = function(text, newStart, newEnd, oldStart, oldEnd, recursionLevel) { - - symbol = new Object(); - symbol.newCtr = []; - symbol.oldCtr = []; - symbol.toNew = []; - symbol.toOld = []; - -// set defaults - newStart = newStart || 0; - newEnd = newEnd || text.newWords.length; - oldStart = oldStart || 0; - oldEnd = oldEnd || text.oldWords.length; - recursionLevel = recursionLevel || 0; - -// limit recursion depth - if (recursionLevel > 10) { - return; - } - -// pass 1: parse new text into symbol table s - - var word; - for (var i = newStart; i < newEnd; i ++) { - word = text.newWords[i]; - -// add new entry to symbol table - if ( symbol[word] == null) { - symbol[word] = { newCtr: 0, oldCtr: 0, toNew: null, toOld: null }; - } - -// increment symbol table word counter for new text - symbol[word].newCtr ++; - -// add last word number in new text - symbol[word].toNew = i; - } - -// pass 2: parse old text into symbol table - - for (var j = oldStart; j < oldEnd; j ++) { - word = text.oldWords[j]; - -// add new entry to symbol table - if ( symbol[word] == null) { - symbol[word] = { newCtr: 0, oldCtr: 0, toNew: null, toOld: null }; - } - -// increment symbol table word counter for old text - symbol[word].oldCtr ++; - -// add last word number in old text - symbol[word].toOld = j; - } - -// pass 3: connect unique words - - for (var i in symbol) { - -// find words in the symbol table that occur only once in both versions - if ( (symbol[i].newCtr == 1) && (symbol[i].oldCtr == 1) ) { - var toNew = symbol[i].toNew; - var toOld = symbol[i].toOld; - -// do not use spaces as unique markers - if ( ! /\s/.test( text.newWords[toNew] ) ) { - -// connect from new to old and from old to new - text.newToOld[toNew] = toOld; - text.oldToNew[toOld] = toNew; - } - } - } - -// pass 4: connect adjacent identical words downwards - - for (var i = newStart; i < newEnd - 1; i ++) { - -// find already connected pairs - if (text.newToOld[i] != null) { - j = text.newToOld[i]; - -// check if the following words are not yet connected - if ( (text.newToOld[i + 1] == null) && (text.oldToNew[j + 1] == null) ) { - -// if the following words are the same connect them - if ( text.newWords[i + 1] == text.oldWords[j + 1] ) { - text.newToOld[i + 1] = j + 1; - text.oldToNew[j + 1] = i + 1; - } - } - } - } - -// pass 5: connect adjacent identical words upwards - - for (var i = newEnd - 1; i > newStart; i --) { - -// find already connected pairs - if (text.newToOld[i] != null) { - j = text.newToOld[i]; - -// check if the preceeding words are not yet connected - if ( (text.newToOld[i - 1] == null) && (text.oldToNew[j - 1] == null) ) { - -// if the preceeding words are the same connect them - if ( text.newWords[i - 1] == text.oldWords[j - 1] ) { - text.newToOld[i - 1] = j - 1; - text.oldToNew[j - 1] = i - 1; - } - } - } - } - -// recursively diff still unresolved regions downwards - - if (wDiffRecursiveDiff) { - i = newStart; - j = oldStart; - while (i < newEnd) { - if (text.newToOld[i - 1] != null) { - j = text.newToOld[i - 1] + 1; - } - -// check for the start of an unresolved sequence - if ( (text.newToOld[i] == null) && (text.oldToNew[j] == null) ) { - -// determine the ends of the sequences - var iStart = i; - var iEnd = i; - while ( (text.newToOld[iEnd] == null) && (iEnd < newEnd) ) { - iEnd ++; - } - var iLength = iEnd - iStart; - - var jStart = j; - var jEnd = j; - while ( (text.oldToNew[jEnd] == null) && (jEnd < oldEnd) ) { - jEnd ++; - } - var jLength = jEnd - jStart; - -// recursively diff the unresolved sequence - if ( (iLength > 0) && (jLength > 0) ) { - if ( (iLength > 1) || (jLength > 1) ) { - if ( (iStart != newStart) || (iEnd != newEnd) || (jStart != oldStart) || (jEnd != oldEnd) ) { - WDiffText(text, iStart, iEnd, jStart, jEnd, recursionLevel + 1); - } - } - } - i = iEnd; - } - else { - i ++; - } - } - } - -// recursively diff still unresolved regions upwards - - if (wDiffRecursiveDiff) { - i = newEnd - 1; - j = oldEnd - 1; - while (i >= newStart) { - if (text.newToOld[i + 1] != null) { - j = text.newToOld[i + 1] - 1; - } - -// check for the start of an unresolved sequence - if ( (text.newToOld[i] == null) && (text.oldToNew[j] == null) ) { - -// determine the ends of the sequences - var iStart = i; - var iEnd = i + 1; - while ( (text.newToOld[iStart - 1] == null) && (iStart >= newStart) ) { - iStart --; - } - var iLength = iEnd - iStart; - - var jStart = j; - var jEnd = j + 1; - while ( (text.oldToNew[jStart - 1] == null) && (jStart >= oldStart) ) { - jStart --; - } - var jLength = jEnd - jStart; - -// recursively diff the unresolved sequence - if ( (iLength > 0) && (jLength > 0) ) { - if ( (iLength > 1) || (jLength > 1) ) { - if ( (iStart != newStart) || (iEnd != newEnd) || (jStart != oldStart) || (jEnd != oldEnd) ) { - WDiffText(text, iStart, iEnd, jStart, jEnd, recursionLevel + 1); - } - } - } - i = iStart - 1; - } - else { - i --; - } - } - } - return; -} - - -// WDiffToHtml: process diff data into formatted html text -// input: text.newWords and text.oldWords, arrays containing the texts in arrays of words -// text.newToOld and text.oldToNew, containing the line numbers in the other version -// block data structure -// returns: outText, a html string - -window.WDiffToHtml = function(text, block) { - - var outText = text.message; - - var blockNumber = 0; - var i = 0; - var j = 0; - var movedAsInsertion; - -// cycle through the new text - do { - var movedIndex = []; - var movedBlock = []; - var movedLeft = []; - var blockText = ''; - var identText = ''; - var delText = ''; - var insText = ''; - var identStart = ''; - -// check if a block ends here and finish previous block - if (movedAsInsertion != null) { - if (movedAsInsertion == false) { - identStart += wDiffHtmlBlockEnd; - } - else { - identStart += wDiffHtmlInsertEnd; - } - movedAsInsertion = null; - } - -// detect block boundary - if ( (text.newToOld[i] != j) || (blockNumber == 0 ) ) { - if ( ( (text.newToOld[i] != null) || (i >= text.newWords.length) ) && ( (text.oldToNew[j] != null) || (j >= text.oldWords.length) ) ) { - -// block moved right - var moved = block.newRight[blockNumber]; - if (moved > 0) { - var index = block.newRightIndex[blockNumber]; - movedIndex.push(index); - movedBlock.push(moved); - movedLeft.push(false); - } - -// block moved left - moved = block.newLeft[blockNumber]; - if (moved > 0) { - var index = block.newLeftIndex[blockNumber]; - movedIndex.push(index); - movedBlock.push(moved); - movedLeft.push(true); - } - -// check if a block starts here - moved = block.newBlock[blockNumber]; - if (moved > 0) { - -// mark block as inserted text - if (block.newWords[blockNumber] < wDiffBlockMinLength) { - identStart += wDiffHtmlInsertStart; - movedAsInsertion = true; - } - -// mark block by color - else { - if (moved > wDiffStyleBlock.length) { - moved = wDiffStyleBlock.length; - } - identStart += WDiffHtmlCustomize(wDiffHtmlBlockStart, moved - 1); - movedAsInsertion = false; - } - } - - if (i >= text.newWords.length) { - i ++; - } - else { - j = text.newToOld[i]; - blockNumber ++; - } - } - } - -// get the correct order if moved to the left as well as to the right from here - if (movedIndex.length == 2) { - if (movedIndex[0] > movedIndex[1]) { - movedIndex.reverse(); - movedBlock.reverse(); - movedLeft.reverse(); - } - } - -// handle left and right block moves from this position - for (var m = 0; m < movedIndex.length; m ++) { - -// insert the block as deleted text - if (block.newWords[ movedIndex[m] ] < wDiffBlockMinLength) { - var movedStart = block.newStart[ movedIndex[m] ]; - var movedLength = block.newLength[ movedIndex[m] ]; - var str = ''; - for (var n = movedStart; n < movedStart + movedLength; n ++) { - str += text.newWords[n]; - } - str = WDiffEscape(str); - str = str.replace(/\n/g, '¶
'); - blockText += wDiffHtmlDeleteStart + str + wDiffHtmlDeleteEnd; - } - -// add a placeholder / move direction indicator - else { - if (movedBlock[m] > wDiffStyleBlock.length) { - movedBlock[m] = wDiffStyleBlock.length; - } - if (movedLeft[m]) { - blockText += WDiffHtmlCustomize(wDiffHtmlMovedLeft, movedBlock[m] - 1); - } - else { - blockText += WDiffHtmlCustomize(wDiffHtmlMovedRight, movedBlock[m] - 1); - } - } - } - -// collect consecutive identical text - while ( (i < text.newWords.length) && (j < text.oldWords.length) ) { - if ( (text.newToOld[i] == null) || (text.oldToNew[j] == null) ) { - break; - } - if (text.newToOld[i] != j) { - break; - } - identText += text.newWords[i]; - i ++; - j ++; - } - -// collect consecutive deletions - while ( (text.oldToNew[j] == null) && (j < text.oldWords.length) ) { - delText += text.oldWords[j]; - j ++; - } - -// collect consecutive inserts - while ( (text.newToOld[i] == null) && (i < text.newWords.length) ) { - insText += text.newWords[i]; - i ++; - } - -// remove leading and trailing similarities betweein delText and ins from highlighting - var preText = ''; - var postText = ''; - if (wDiffWordDiff) { - if ( (delText != '') && (insText != '') ) { - -// remove leading similarities - while ( delText.charAt(0) == insText.charAt(0) && (delText != '') && (insText != '') ) { - preText = preText + delText.charAt(0); - delText = delText.substr(1); - insText = insText.substr(1); - } - -// remove trailing similarities - while ( delText.charAt(delText.length - 1) == insText.charAt(insText.length - 1) && (delText != '') && (insText != '') ) { - postText = delText.charAt(delText.length - 1) + postText; - delText = delText.substr(0, delText.length - 1); - insText = insText.substr(0, insText.length - 1); - } - } - } - -// output the identical text, deletions and inserts - -// moved from here indicator - if (blockText != '') { - outText += blockText; - } - -// identical text - if (identText != '') { - outText += identStart + WDiffEscape(identText); - } - outText += preText; - -// deleted text - if (delText != '') { - delText = wDiffHtmlDeleteStart + WDiffEscape(delText) + wDiffHtmlDeleteEnd; - delText = delText.replace(/\n/g, '¶
'); - outText += delText; - } - -// inserted text - if (insText != '') { - insText = wDiffHtmlInsertStart + WDiffEscape(insText) + wDiffHtmlInsertEnd; - insText = insText.replace(/\n/g, '¶
'); - outText += insText; - } - outText += postText; - } while (i <= text.newWords.length); - - outText += '\n'; - outText = WDiffHtmlFormat(outText); - - return(outText); -} - - -// WDiffEscape: replaces html-sensitive characters in output text with character entities - -window.WDiffEscape = function(text) { - - text = text.replace(/&/g, '&'); - text = text.replace(//g, '>'); - text = text.replace(/\"/g, '"'); - - return(text); -} - - -// HtmlCustomize: customize indicator html: replace {number} with the block number, {block} with the block style - -window.WDiffHtmlCustomize = function(text, block) { - - text = text.replace(/\{number\}/, block); - text = text.replace(/\{block\}/, wDiffStyleBlock[block]); - - return(text); -} - - -// HtmlFormat: replaces newlines and multiple spaces in text with html code - -window.WDiffHtmlFormat = function(text) { - - text = text.replace(/ /g, '  '); - text = text.replace(/\n/g, '
'); - - return(text); -} - - -// WDiffDetectBlocks: detect block borders and moved blocks -// input: text object, block object - -window.WDiffDetectBlocks = function(text, block) { - - block.oldStart = []; - block.oldToNew = []; - block.oldLength = []; - block.oldWords = []; - block.newStart = []; - block.newLength = []; - block.newWords = []; - block.newNumber = []; - block.newBlock = []; - block.newLeft = []; - block.newRight = []; - block.newLeftIndex = []; - block.newRightIndex = []; - - var blockNumber = 0; - var wordCounter = 0; - var realWordCounter = 0; - -// get old text block order - if (wDiffShowBlockMoves) { - var j = 0; - var i = 0; - do { - -// detect block boundaries on old text - if ( (text.oldToNew[j] != i) || (blockNumber == 0 ) ) { - if ( ( (text.oldToNew[j] != null) || (j >= text.oldWords.length) ) && ( (text.newToOld[i] != null) || (i >= text.newWords.length) ) ) { - if (blockNumber > 0) { - block.oldLength[blockNumber - 1] = wordCounter; - block.oldWords[blockNumber - 1] = realWordCounter; - wordCounter = 0; - realWordCounter = 0; - } - - if (j >= text.oldWords.length) { - j ++; - } - else { - i = text.oldToNew[j]; - block.oldStart[blockNumber] = j; - block.oldToNew[blockNumber] = text.oldToNew[j]; - blockNumber ++; - } - } - } - -// jump over identical pairs - while ( (i < text.newWords.length) && (j < text.oldWords.length) ) { - if ( (text.newToOld[i] == null) || (text.oldToNew[j] == null) ) { - break; - } - if (text.oldToNew[j] != i) { - break; - } - i ++; - j ++; - wordCounter ++; - if ( /\w/.test( text.newWords[i] ) ) { - realWordCounter ++; - } - } - -// jump over consecutive deletions - while ( (text.oldToNew[j] == null) && (j < text.oldWords.length) ) { - j ++; - } - -// jump over consecutive inserts - while ( (text.newToOld[i] == null) && (i < text.newWords.length) ) { - i ++; - } - } while (j <= text.oldWords.length); - -// get the block order in the new text - var lastMin; - var currMinIndex; - lastMin = null; - -// sort the data by increasing start numbers into new text block info - for (var i = 0; i < blockNumber; i ++) { - currMin = null; - for (var j = 0; j < blockNumber; j ++) { - curr = block.oldToNew[j]; - if ( (curr > lastMin) || (lastMin == null) ) { - if ( (curr < currMin) || (currMin == null) ) { - currMin = curr; - currMinIndex = j; - } - } - } - block.newStart[i] = block.oldToNew[currMinIndex]; - block.newLength[i] = block.oldLength[currMinIndex]; - block.newWords[i] = block.oldWords[currMinIndex]; - block.newNumber[i] = currMinIndex; - lastMin = currMin; - } - -// detect not moved blocks - for (var i = 0; i < blockNumber; i ++) { - if (block.newBlock[i] == null) { - if (block.newNumber[i] == i) { - block.newBlock[i] = 0; - } - } - } - -// detect switches of neighbouring blocks - for (var i = 0; i < blockNumber - 1; i ++) { - if ( (block.newBlock[i] == null) && (block.newBlock[i + 1] == null) ) { - if (block.newNumber[i] - block.newNumber[i + 1] == 1) { - if ( (block.newNumber[i + 1] - block.newNumber[i + 2] != 1) || (i + 2 >= blockNumber) ) { - -// the shorter one is declared the moved one - if (block.newLength[i] < block.newLength[i + 1]) { - block.newBlock[i] = 1; - block.newBlock[i + 1] = 0; - } - else { - block.newBlock[i] = 0; - block.newBlock[i + 1] = 1; - } - } - } - } - } - -// mark all others as moved and number the moved blocks - j = 1; - for (var i = 0; i < blockNumber; i ++) { - if ( (block.newBlock[i] == null) || (block.newBlock[i] == 1) ) { - block.newBlock[i] = j++; - } - } - -// check if a block has been moved from this block border - for (var i = 0; i < blockNumber; i ++) { - for (var j = 0; j < blockNumber; j ++) { - - if (block.newNumber[j] == i) { - if (block.newBlock[j] > 0) { - -// block moved right - if (block.newNumber[j] < j) { - block.newRight[i] = block.newBlock[j]; - block.newRightIndex[i] = j; - } - -// block moved left - else { - block.newLeft[i + 1] = block.newBlock[j]; - block.newLeftIndex[i + 1] = j; - } - } - } - } - } - } - return; -} - - -// WDiffShortenOutput: remove unchanged parts from final output -// input: the output of WDiffString -// returns: the text with removed unchanged passages indicated by (...) - -window.WDiffShortenOutput = function(diffText) { - -// html
to newlines - diffText = diffText.replace(/]*>/g, '\n'); - -// scan for diff html tags - var regExpDiff = new RegExp('<\\w+ class=\\"(\\w+)\\"[^>]*>(.|\\n)*?', 'g'); - var tagStart = []; - var tagEnd = []; - var i = 0; - var found; - while ( (found = regExpDiff.exec(diffText)) != null ) { - -// combine consecutive diff tags - if ( (i > 0) && (tagEnd[i - 1] == found.index) ) { - tagEnd[i - 1] = found.index + found[0].length; - } - else { - tagStart[i] = found.index; - tagEnd[i] = found.index + found[0].length; - i ++; - } - } - -// no diff tags detected - if (tagStart.length == 0) { - return(wDiffNoChange); - } - -// define regexps - var regExpHeading = new RegExp('\\n=+.+?=+ *\\n|\\n\\{\\||\\n\\|\\}', 'g'); - var regExpParagraph = new RegExp('\\n\\n+', 'g'); - var regExpLine = new RegExp('\\n+', 'g'); - var regExpBlank = new RegExp('(<[^>]+>)*\\s+', 'g'); - -// determine fragment border positions around diff tags - var rangeStart = []; - var rangeEnd = []; - var rangeStartType = []; - var rangeEndType = []; - for (var i = 0; i < tagStart.length; i ++) { - var found; - -// find last heading before diff tag - var lastPos = tagStart[i] - wDiffHeadingBefore; - if (lastPos < 0) { - lastPos = 0; - } - regExpHeading.lastIndex = lastPos; - while ( (found = regExpHeading.exec(diffText)) != null ) { - if (found.index > tagStart[i]) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'heading'; - } - -// find last paragraph before diff tag - if (rangeStart[i] == null) { - lastPos = tagStart[i] - wDiffParagraphBefore; - if (lastPos < 0) { - lastPos = 0; - } - regExpParagraph.lastIndex = lastPos; - while ( (found = regExpParagraph.exec(diffText)) != null ) { - if (found.index > tagStart[i]) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'paragraph'; - } - } - -// find line break before diff tag - if (rangeStart[i] == null) { - lastPos = tagStart[i] - wDiffLineBeforeMax; - if (lastPos < 0) { - lastPos = 0; - } - regExpLine.lastIndex = lastPos; - while ( (found = regExpLine.exec(diffText)) != null ) { - if (found.index > tagStart[i] - wDiffLineBeforeMin) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'line'; - } - } - -// find blank before diff tag - if (rangeStart[i] == null) { - lastPos = tagStart[i] - wDiffBlankBeforeMax; - if (lastPos < 0) { - lastPos = 0; - } - regExpBlank.lastIndex = lastPos; - while ( (found = regExpBlank.exec(diffText)) != null ) { - if (found.index > tagStart[i] - wDiffBlankBeforeMin) { - break; - } - rangeStart[i] = found.index; - rangeStartType[i] = 'blank'; - } - } - -// fixed number of chars before diff tag - if (rangeStart[i] == null) { - rangeStart[i] = tagStart[i] - wDiffCharsBefore; - rangeStartType[i] = 'chars'; - if (rangeStart[i] < 0) { - rangeStart[i] = 0; - } - } - -// find first heading after diff tag - regExpHeading.lastIndex = tagEnd[i]; - if ( (found = regExpHeading.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffHeadingAfter) { - rangeEnd[i] = found.index + found[0].length; - rangeEndType[i] = 'heading'; - } - } - -// find first paragraph after diff tag - if (rangeEnd[i] == null) { - regExpParagraph.lastIndex = tagEnd[i]; - if ( (found = regExpParagraph.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffParagraphAfter) { - rangeEnd[i] = found.index; - rangeEndType[i] = 'paragraph'; - } - } - } - -// find first line break after diff tag - if (rangeEnd[i] == null) { - regExpLine.lastIndex = tagEnd[i] + wDiffLineAfterMin; - if ( (found = regExpLine.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffLineAfterMax) { - rangeEnd[i] = found.index; - rangeEndType[i] = 'break'; - } - } - } - -// find blank after diff tag - if (rangeEnd[i] == null) { - regExpBlank.lastIndex = tagEnd[i] + wDiffBlankAfterMin; - if ( (found = regExpBlank.exec(diffText)) != null ) { - if (found.index < tagEnd[i] + wDiffBlankAfterMax) { - rangeEnd[i] = found.index; - rangeEndType[i] = 'blank'; - } - } - } - -// fixed number of chars after diff tag - if (rangeEnd[i] == null) { - rangeEnd[i] = tagEnd[i] + wDiffCharsAfter; - if (rangeEnd[i] > diffText.length) { - rangeEnd[i] = diffText.length; - rangeEndType[i] = 'chars'; - } - } - } - -// remove overlaps, join close fragments - var fragmentStart = []; - var fragmentEnd = []; - var fragmentStartType = []; - var fragmentEndType = []; - fragmentStart[0] = rangeStart[0]; - fragmentEnd[0] = rangeEnd[0]; - fragmentStartType[0] = rangeStartType[0]; - fragmentEndType[0] = rangeEndType[0]; - var j = 1; - for (var i = 1; i < rangeStart.length; i ++) { - if (rangeStart[i] > fragmentEnd[j - 1] + wDiffFragmentJoin) { - fragmentStart[j] = rangeStart[i]; - fragmentEnd[j] = rangeEnd[i]; - fragmentStartType[j] = rangeStartType[i]; - fragmentEndType[j] = rangeEndType[i]; - j ++; - } - else { - fragmentEnd[j - 1] = rangeEnd[i]; - fragmentEndType[j - 1] = rangeEndType[i]; - } - } - -// assemble the fragments - var outText = ''; - for (var i = 0; i < fragmentStart.length; i ++) { - -// get text fragment - var fragment = diffText.substring(fragmentStart[i], fragmentEnd[i]); - var fragment = fragment.replace(/^\n+|\n+$/g, ''); - -// add inline marks for omitted chars and words - if (fragmentStart[i] > 0) { - if (fragmentStartType[i] == 'chars') { - fragment = wDiffOmittedChars + fragment; - } - else if (fragmentStartType[i] == 'blank') { - fragment = wDiffOmittedChars + ' ' + fragment; - } - } - if (fragmentEnd[i] < diffText.length) { - if (fragmentStartType[i] == 'chars') { - fragment = fragment + wDiffOmittedChars; - } - else if (fragmentStartType[i] == 'blank') { - fragment = fragment + ' ' + wDiffOmittedChars; - } - } - -// add omitted line separator - if (fragmentStart[i] > 0) { - outText += wDiffOmittedLines; - } - -// encapsulate span errors - outText += '
' + fragment + '
'; - } - -// add trailing omitted line separator - if (fragmentEnd[i - 1] < diffText.length) { - outText = outText + wDiffOmittedLines; - } - -// remove leading and trailing empty lines - outText = outText.replace(/^(
)\n+|\n+(<\/div>)$/g, '$1$2'); - -// convert to html linebreaks - outText = outText.replace(/\n/g, '
'); - - return(outText); -} - - -//

\ No newline at end of file
diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css
index 38c9e5a..1bb6cf4 100644
--- a/public/stylesheets/admin.css
+++ b/public/stylesheets/admin.css
@@ -511,6 +511,31 @@ table.revisions_table tr:hover {
   background-color: #f1f1f1;
 }
 
+#diffview del {
+  background: #ffd7d5;
+  color: #82071e;
+  text-decoration: line-through;
+  padding: 0 2px;
+  border-radius: 2px;
+}
+
+#diffview ins {
+  background: #ccffd8;
+  color: #055d20;
+  text-decoration: none;
+  padding: 0 2px;
+  border-radius: 2px;
+}
+
+#diffview .diff_side_by_side {
+  display: flex;
+  gap: 1rem;
+}
+
+#diffview .diff_column {
+  flex: 1;
+}
+
 table.user_table td.user_login {
   padding-right: 30px;
 }
diff --git a/test/controllers/revisions_controller_test.rb b/test/controllers/revisions_controller_test.rb
index b4dcd8f..e2fc976 100644
--- a/test/controllers/revisions_controller_test.rb
+++ b/test/controllers/revisions_controller_test.rb
@@ -59,4 +59,33 @@ class RevisionsControllerTest < ActionController::TestCase
     assert_equal @node.head, @node.pages.first
     assert_equal "first", @node.head.reload.body
   end
+
+  test "diffing two revisions renders real markup with only the changed words marked" do
+    login_as :quentin
+    post(
+      :diff, params: {
+        :node_id => @node.id,
+        :start_revision => @node.pages.first.revision,
+        :end_revision => @node.pages.last.revision
+      }
+    )
+    assert_response :success
+    assert_select "del", "first"
+    assert_select "ins", "second"
+    assert_no_match /</, response.body
+  end
+
+  test "diffing two revisions in side by side view renders two columns" do
+    login_as :quentin
+    post(
+      :diff, params: {
+        :node_id => @node.id,
+        :start_revision => @node.pages.first.revision,
+        :end_revision => @node.pages.last.revision,
+        :view => "side_by_side"
+      }
+    )
+    assert_response :success
+    assert_select ".diff_column", 2
+  end
 end
diff --git a/test/models/page_test.rb b/test/models/page_test.rb
index ad2742f..ac5691a 100644
--- a/test/models/page_test.rb
+++ b/test/models/page_test.rb
@@ -176,4 +176,38 @@ class PageTest < ActiveSupport::TestCase
     assert_not_equal first_page.id, first_page.node.head_id
     assert first_page.published_at.present?
   end
+
+  def test_diff_against_inline_keeps_tags_and_marks_only_the_changed_word
+    n = Node.root.children.create! :slug => "diff_against_test"
+    d = n.find_or_create_draft @user1
+    d.title = "Old heading"
+    d.save!
+    n.publish_draft!
+
+    d2 = n.find_or_create_draft @user1
+    d2.title = "New heading"
+    d2.save!
+
+    diff = d2.diff_against(n.head)
+
+    assert_match "Old", diff[:title]
+    assert_match "New", diff[:title]
+  end
+
+  def test_diff_against_side_by_side_returns_two_annotated_strings
+    n = Node.root.children.create! :slug => "diff_against_sbs_test"
+    d = n.find_or_create_draft @user1
+    d.title = "Old heading"
+    d.save!
+    n.publish_draft!
+
+    d2 = n.find_or_create_draft @user1
+    d2.title = "New heading"
+    d2.save!
+
+    old_html, new_html = d2.diff_against(n.head, view: :side_by_side)[:title]
+
+    assert_match "Old", old_html
+    assert_match "New", new_html
+  end
 end
-- 
cgit v1.3


From 85b20bbc20af850906050c2519dd6dc423126aef Mon Sep 17 00:00:00 2001
From: erdgeist 
Date: Sat, 11 Jul 2026 01:33:48 +0200
Subject: Make /nodes/parameterize_preview route independent

---
 app/views/layouts/admin.html.erb   | 1 +
 public/javascripts/admin_search.js | 2 +-
 2 files changed, 2 insertions(+), 1 deletion(-)

(limited to 'public/javascripts')

diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb
index fb7d6da..e714c28 100644
--- a/app/views/layouts/admin.html.erb
+++ b/app/views/layouts/admin.html.erb
@@ -16,6 +16,7 @@
     
   
   
diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js
index 5be5d57..0e70845 100644
--- a/public/javascripts/admin_search.js
+++ b/public/javascripts/admin_search.js
@@ -201,7 +201,7 @@ parent_search = {
 
     clearTimeout(parent_search.path_timeout);
     parent_search.path_timeout = setTimeout(function() {
-      $.get("/nodes/parameterize_preview", { title: title }, function(slug) {
+        $.get(PARAMETERIZE_PREVIEW_URL, { title: title }, function(slug) {
         $("#resulting_path").text(window.location.origin + "/" + (prefix ? prefix + "/" : "") + slug);
       });
     }, 300);
-- 
cgit v1.3


From 19e0ee821d5b2b6d3397a81411f4f3a4cbd2fa83 Mon Sep 17 00:00:00 2001
From: erdgeist 
Date: Sun, 12 Jul 2026 13:58:36 +0200
Subject: Unify the four field-level pickers, fix search visibility

menu_items/parent_search/move_to_search/event_search each duplicated
their own debounce-free AJAX call and result rendering. Replaced with
one shared initSearchPicker, taking each picker's distinctive
behavior (parent_search's live path preview, event_search's title
hint) as a callback rather than a whole reimplementation. Adds a real
debounce with an out-of-order-response guard -- none of the four had
either before. admin_search (the Alt+F top-bar search) now shares the
same function via its own url/isActive/header options, gaining the
same guard and fixing an inconsistency of its own (it previously
always slid its panel open, even on zero results).

Each picker's results container gets its own id instead of sharing
was ever supposed to be on screen at once, an assumption with no
actual enforcement behind it. Styling moved from an id-pair
(#menu_search_results, #search_results) to a shared .search_results
class so a future picker never needs this file touched again.

menu_search and the admin top-bar search now call Node.editor_search
instead of the public, head-only Node.search -- both are admin-only,
authenticated views, and had no reason to inherit the public search's
"can't find a draft" limitation. The always-ignored :per_page => 1000
on the latter is gone too; Node.search's second argument was never
read by the method at all.

Also removed a stale #metadata a { text-transform: lowercase } rule,
found while verifying the above -- written for the pre-subnav-removal
expand-toggle, which no longer exists; it had been silently
lowercasing nodes#edit's own, unrelated #metadata div (including
move_to_search's results) by id coincidence ever since. #main_navigation
and #overview_toggle intentionally left capitalized rather than
special-cased -- both belong to the nav bar already slated to shrink
to three icons, not worth polishing on the way out.
---
 app/controllers/admin_controller.rb |   4 +-
 app/views/events/edit.html.erb      |   4 +-
 app/views/events/new.html.erb       |   2 +-
 app/views/layouts/admin.html.erb    |   4 +-
 app/views/menu_items/edit.html.erb  |   2 +-
 app/views/menu_items/new.html.erb   |   4 +-
 app/views/nodes/edit.html.erb       |  14 +-
 app/views/nodes/new.html.erb        |   2 +-
 public/javascripts/admin_search.js  | 355 ++++++++++++------------------------
 public/stylesheets/admin.css        |  13 +-
 10 files changed, 137 insertions(+), 267 deletions(-)

(limited to 'public/javascripts')

diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb
index 435df57..3c45c49 100644
--- a/app/controllers/admin_controller.rb
+++ b/app/controllers/admin_controller.rb
@@ -31,7 +31,7 @@ class AdminController < ApplicationController
   end
 
   def search
-    @results = Node.search params[:search_term], :per_page => 1000
+    @results = Node.editor_search(params[:search_term])
 
     respond_to do |format|
       format.html do
@@ -53,7 +53,7 @@ class AdminController < ApplicationController
     if params[:search_term] == "Root"
       @results = [Node.root]
     else
-      @results = Node.search params[:search_term]
+      @results = Node.editor_search(params[:search_term])
     end
 
     respond_to do |format|
diff --git a/app/views/events/edit.html.erb b/app/views/events/edit.html.erb
index 6cb04bd..45b084f 100644
--- a/app/views/events/edit.html.erb
+++ b/app/views/events/edit.html.erb
@@ -19,12 +19,12 @@
           
Change node <%= text_field_tag :event_node_search_term %> -
+
This will re-link the event to a different node — rarely needed.
<% else %> <%= text_field_tag :event_node_search_term %> -
+
Optional — search and pick a node to associate this event with a page. <% end %> <%= f.hidden_field :node_id %> diff --git a/app/views/events/new.html.erb b/app/views/events/new.html.erb index b20fe48..7a1ee7a 100644 --- a/app/views/events/new.html.erb +++ b/app/views/events/new.html.erb @@ -10,7 +10,7 @@
Node
<%= text_field_tag :event_node_search_term %> -
+
<%= f.hidden_field :node_id %> Optional — search and pick a node to associate this event with a page.
diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index e714c28..079346b 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -56,9 +56,7 @@ Search: <%= text_field_tag :search_term, nil, autocomplete: "off" %> <% end %>
- +
diff --git a/app/views/menu_items/edit.html.erb b/app/views/menu_items/edit.html.erb index dc5e8f9..44e5a79 100644 --- a/app/views/menu_items/edit.html.erb +++ b/app/views/menu_items/edit.html.erb @@ -6,7 +6,7 @@
Search
<%= text_field_tag :menu_search_term %> -
+
Node Id
diff --git a/app/views/menu_items/new.html.erb b/app/views/menu_items/new.html.erb index 68081d0..223cb8e 100644 --- a/app/views/menu_items/new.html.erb +++ b/app/views/menu_items/new.html.erb @@ -7,9 +7,7 @@ Search <%= text_field_tag :menu_search_term %> -
- -
+ diff --git a/app/views/nodes/edit.html.erb b/app/views/nodes/edit.html.erb index 1c19410..13b78fc 100644 --- a/app/views/nodes/edit.html.erb +++ b/app/views/nodes/edit.html.erb @@ -45,14 +45,12 @@
parent
<%= text_field_tag :move_to_search_term, @node.parent.title rescue "" %> -
- -
- <%= f.hidden_field( - :staged_parent_id, - :value => @node.staged_parent_id || @node.parent_id - ) - %> +
+ <%= f.hidden_field( + :staged_parent_id, + :value => @node.staged_parent_id || @node.parent_id + ) + %>
<%= fields_for @page do |d| %> diff --git a/app/views/nodes/new.html.erb b/app/views/nodes/new.html.erb index afc632f..bb7e078 100644 --- a/app/views/nodes/new.html.erb +++ b/app/views/nodes/new.html.erb @@ -34,7 +34,7 @@
<%= text_field_tag :parent_search_term, @parent_name %> <%= hidden_field_tag :parent_id, @parent_id %> -
+
diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index 0e70845..f553334 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -1,5 +1,4 @@ admin_search = { - initialize : function() { $(document).bind("keydown", 'Alt+f', function(){ admin_search.display_toggle(); @@ -20,25 +19,12 @@ admin_search = { } }); - $("#search_term").bind("input", function() { - if (!$('#search_widget').is(':visible')) return; - if ($(this).val()) { - $.ajax({ - type: "GET", - url: ADMIN_SEARCH_URL, - data: "search_term=" + $(this).val(), - dataType: "json", - success: function(results) { - admin_search.show_results(results); - }, - error: function(xhr, status, error) { - console.log("Ajax error:", status, error, xhr.status, xhr.responseText); - } - }); - } else { - $('#menu_search_results').slideUp(); - $('#menu_search_results').empty(); - } + initSearchPicker({ + inputSelector: "#search_term", + resultsSelector: "#menu_search_results", + url: ADMIN_SEARCH_URL, + isActive: function() { return $('#search_widget').is(':visible'); }, + resultsHeaderHtml: "

Press Enter to see all results ⏎

" }); }, @@ -49,75 +35,97 @@ admin_search = { $('#search_widget').fadeIn(); $('#search_term').focus(); } - }, - - show_results : function(results) { - $('#menu_search_results').empty(); - if (results.length) { - $('#menu_search_results').append( - "

Press Enter to see all results ⏎

" - ); - } - for (result in results) { - $('#menu_search_results').append( - "

" + - results[result].title + - "" + results[result].unique_name + "" + - "

" - ); - } - $('#menu_search_results').slideDown(); } }; -menu_items = { +function initSearchPicker(options) { + var inputSelector = options.inputSelector; + var resultsSelector = options.resultsSelector; + var url = options.url || ADMIN_MENU_SEARCH_URL; + var onSelect = options.onSelect; // omit for a real-navigation search like admin_search + var isActive = options.isActive; // optional guard, checked before every search + var resultsHeaderHtml = options.resultsHeaderHtml; // optional, prepended only when results exist + var requestId = 0; + var timeout; + + $(inputSelector).bind("input", function() { + if (isActive && !isActive()) return; + + var term = $(this).val(); + var results = $(resultsSelector); + clearTimeout(timeout); + + if (!term) { + results.slideUp(); + results.empty(); + return; + } - initialize_search : function() { - $("#menu_search_term").bind("input", function() { - if ($(this).val()) { - $.ajax({ - type: "GET", - url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + $(this).val(), - dataType: "json", - success : function(results) { - menu_items.show_results(results); + timeout = setTimeout(function() { + var thisRequest = ++requestId; + $.ajax({ + type: "GET", + url: url, + data: "search_term=" + term, + dataType: "json", + success: function(nodes) { + if (thisRequest !== requestId) return; + results.empty(); + if (nodes.length && resultsHeaderHtml) { + results.append(resultsHeaderHtml); } - }); - } - else { - $('#search_results').slideUp(); - $('#search_results').empty(); + var found = false; + for (var i = 0; i < nodes.length; i++) { + (function(node) { + var link; + if (onSelect) { + link = $( + "

" + node.title + + "" + node.unique_name + "" + + "

" + ); + link.find("a").bind("click", function() { + onSelect(node); + results.slideUp(); + results.empty(); + return false; + }); + } else { + link = $( + "

" + node.title + + "" + node.unique_name + "" + + "

" + ); + } + results.append(link); + found = true; + })(nodes[i]); + } + if (found) { + results.slideDown(); + } else { + results.slideUp(); + } + }, + error: function(xhr, status, error) { + console.log("Ajax error:", status, error, xhr.status, xhr.responseText); + } + }); + }, 250); + }); +} + +menu_items = { + initialize_search : function() { + initSearchPicker({ + inputSelector: "#menu_search_term", + resultsSelector: "#menu_item_search_results", + onSelect: function(node) { + $("#menu_item_node_id").val(node.node_id); + $("#menu_item_path").val("/" + node.unique_name); + $("#menu_item_title").val(node.title); } }); - }, - - show_results : function(results) { - $("#search_results").empty(); - for (result in results) { - var link = $((""+ results[result].title + "")); - $(link).bind("click", menu_items.link_closure(results[result])); - - - // Sometimes I don't get jquery; wrap() didn't work *sigh* - // Guess I'll need a book someday or another framework - var wrapper = $("
"); - $(wrapper).append(link) - - $("#search_results").append(wrapper); - - } - }, - - link_closure : function(node) { - var barf = function(){ - $("#menu_item_node_id").val(node.node_id); - $("#menu_item_path").val("/" + node.unique_name); - $("#menu_item_title").val(node.title); - return false; - } - - return barf; } }; @@ -125,21 +133,13 @@ parent_search = { initialize_search : function() { parent_search.initialize_radio_buttons(); - $("#parent_search_term").bind("input", function() { - if ($(this).val()) { - $.ajax({ - type: "GET", - url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + $(this).val(), - dataType: "json", - success : function(results) { - parent_search.show_results(results); - } - }); - } - else { - $('#search_results').slideUp(); - $('#search_results').empty(); + initSearchPicker({ + inputSelector: "#parent_search_term", + resultsSelector: "#parent_search_results", + onSelect: function(node) { + $("#parent_search_term").val(node.title); + $("#parent_id").val(node.node_id).attr("data-unique-name", node.unique_name); + parent_search.update_resulting_path(); } }); @@ -154,38 +154,6 @@ parent_search = { }); }, - show_results : function(results) { - $("#search_results").empty(); - var found = false; - for (result in results) { - - var link = $(( - "

" + results[result].title + - "" + results[result].unique_name + "" + - "

")); - - $(link).bind("click", parent_search.link_closure(results[result])); - - $("#search_results").append(link); - found = true; - } - if (found) - $('#search_results').slideDown(); - }, - - link_closure : function(node) { - var barf = function(){ - $("#parent_search_term").val(node.title); - $("#parent_id").val(node.node_id).attr("data-unique-name", node.unique_name); - $('#search_results').slideUp(); - $('#search_results').empty(); - parent_search.update_resulting_path(); - return false; - } - - return barf; - }, - update_resulting_path : function() { var kind = $("input[name='kind']:checked"); var title = $("#title").val(); @@ -223,122 +191,35 @@ parent_search = { $("#parent_search_field").hide(); } } -} +}; move_to_search = { initialize_search : function() { - $("#move_to_search_term").bind("input", function() {move_to_search.do_search($(this))}); - }, - - do_search : function(_this) { - if (_this.val()) { - $.ajax({ - type: "GET", - url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + _this.val(), - dataType: "json", - success : function(results) { - move_to_search.show_results(results); - } - }); - } - else { - $('#search_results').slideUp(); - $('#search_results').empty(); - } - }, - - show_results : function(results) { - $("#search_results").empty(); - var found = false; - for (result in results) { - var link = $((""+ results[result].title + "")); - $(link).bind("click", move_to_search.link_closure(results[result])); - - - // Sometimes I don't get jquery; wrap() didn't work *sigh* - // Guess I'll need a book someday or another framework - var wrapper = $("
"); - $(wrapper).append(link) - - $("#search_results").append(wrapper); - found = true; - } - if (found) - $('#search_results').slideDown(); - else - $('#search_results').slideUp(); - - }, - - link_closure : function(node) { - var barf = function(){ - $("#move_to_search_term").val(node.title); - $("#node_staged_parent_id").val(node.node_id); - $('#search_results').slideUp(); - $('#search_results').empty(); - return false; - } - - return barf; + initSearchPicker({ + inputSelector: "#move_to_search_term", + resultsSelector: "#move_to_search_results", + onSelect: function(node) { + $("#move_to_search_term").val(node.title); + $("#node_staged_parent_id").val(node.node_id); + } + }); } -} +}; event_search = { initialize_search : function() { - $("#event_node_search_term").bind("input", function() { - if ($(this).val()) { - $.ajax({ - type: "GET", - url: ADMIN_MENU_SEARCH_URL, - data: "search_term=" + $(this).val(), - dataType: "json", - success : function(results) { - event_search.show_results(results); - } - }); - } - else { - $('#search_results').slideUp(); - $('#search_results').empty(); + initSearchPicker({ + inputSelector: "#event_node_search_term", + resultsSelector: "#event_search_results", + onSelect: function(node) { + $("#event_node_search_term").val(node.title); + $("#event_node_id").val(node.node_id); + + var title_field = $("#event_title"); + if (title_field.val() === "") { + $("#event_title_hint").text("Using \"" + node.title + "\" from the associated node."); + } } }); - }, - - show_results : function(results) { - $("#search_results").empty(); - var found = false; - for (result in results) { - var link = $(( - "

" + results[result].title + - "" + results[result].unique_name + "" + - "

")); - - $(link).bind("click", event_search.link_closure(results[result])); - - $("#search_results").append(link); - found = true; - } - if (found) - $('#search_results').slideDown(); - else - $('#search_results').slideUp(); - }, - - link_closure : function(node) { - var barf = function(){ - $("#event_node_search_term").val(node.title); - $("#event_node_id").val(node.node_id); - $('#search_results').slideUp(); - $('#search_results').empty(); - - var title_field = $("#event_title"); - if (title_field.val() === "") { - $("#event_title_hint").text("Using \"" + node.title + "\" from the associated node."); - } - - return false; - } - return barf; } }; diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index 5c1e489..da31535 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -155,7 +155,6 @@ input[type=radio] { padding-right: 5px; padding-top: 1px; padding-bottom: 1px; - text-transform: lowercase; } #metadata a:hover { @@ -1023,22 +1022,19 @@ div#draft_list table td.actions a { font-size: 18px; } -#menu_search_results p, -#search_results p { +.search_results p { margin: 0; padding: 4px 4px 0 4px; border-bottom: 1px solid #e8e8e8; } -#menu_search_results p a, -#search_results p a { +.search_results p a { display: block; font-weight: bold; font-size: 0.95rem; } -#menu_search_results p span.result_path, -#search_results p span.result_path { +.search_results p span.result_path { display: block; margin-top: 2px; font-size: 0.75rem; @@ -1047,8 +1043,7 @@ div#draft_list table td.actions a { padding-bottom: 4px; } -#menu_search_results p.search_more, -#search_results p.search_more { +.search_results p.search_more { margin: 0; padding: 6px 4px; font-size: 0.8rem; -- cgit v1.3 From 4ec70e5ecf792a56e868538738d6c7f63bb6cf24 Mon Sep 17 00:00:00 2001 From: erdgeist Date: Sun, 12 Jul 2026 16:33:12 +0200 Subject: Add a grouped tag+node search endpoint for the upcoming dashboard AdminController#dashboard_search returns tags and nodes as separate, labeled groups (via ActsAsTaggableOn::Tag.named_like and Node.editor_search) rather than the flat per-node list every existing picker returns. initSearchPicker gains a renderResults callback option that, when given, replaces the default per-node rendering entirely -- lets the dashboard render its two labeled groups without a second, parallel picker implementation. resultsHeaderHtml (the "Press Enter to see all results" hint) is now threaded through as a third argument to renderResults, so a picker with custom rendering can still show it -- previously only the default per-node branch ever did. Tags link straight to the existing /admin/nodes/tags/:tag view rather than becoming an in-place filter chip. --- app/controllers/admin_controller.rb | 18 +++++ app/views/layouts/admin.html.erb | 1 + config/routes.rb | 9 +-- public/javascripts/admin_interface.js | 4 ++ public/javascripts/admin_search.js | 109 +++++++++++++++++++++--------- public/stylesheets/admin.css | 10 +++ test/controllers/admin_controller_test.rb | 24 +++++++ 7 files changed, 139 insertions(+), 36 deletions(-) (limited to 'public/javascripts') diff --git a/app/controllers/admin_controller.rb b/app/controllers/admin_controller.rb index 3c45c49..8445997 100644 --- a/app/controllers/admin_controller.rb +++ b/app/controllers/admin_controller.rb @@ -49,6 +49,24 @@ class AdminController < ApplicationController end end + def dashboard_search + term = params[:search_term] + + if term.blank? + render json: { tags: [], nodes: [] } + return + end + + render json: { + tags: ActsAsTaggableOn::Tag.named_like(term).limit(5).map { |tag| + { name: tag.name, tag_path: tags_nodes_path(tags: tag.name) } + }, + nodes: Node.editor_search(term).limit(10).map { |node| + { node_id: node.id, title: node.title, unique_name: node.unique_name, node_path: node_path(node) } + } + } + end + def menu_search if params[:search_term] == "Root" @results = [Node.root] diff --git a/app/views/layouts/admin.html.erb b/app/views/layouts/admin.html.erb index 079346b..00d7075 100644 --- a/app/views/layouts/admin.html.erb +++ b/app/views/layouts/admin.html.erb @@ -17,6 +17,7 @@ var ADMIN_SEARCH_URL = "<%= admin_search_path %>"; var ADMIN_MENU_SEARCH_URL = "<%= admin_menu_search_path %>"; var PARAMETERIZE_PREVIEW_URL = "<%= parameterize_preview_nodes_path %>"; + var DASHBOARD_SEARCH_URL = "<%= admin_dashboard_search_path %>"; diff --git a/config/routes.rb b/config/routes.rb index bb34bd8..92301e5 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -66,10 +66,11 @@ Cccms::Application.routes.draw do end end - match '' => 'admin#index', :as => :admin, :via => :get - match 'search' => 'admin#search', :as => :admin_search, :via => :get - match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get - match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get + match '' => 'admin#index', :as => :admin, :via => :get + match 'search' => 'admin#search', :as => :admin_search, :via => :get + match 'menu_search' => 'admin#menu_search', :as => :admin_menu_search, :via => :get + match 'conventions' => 'admin#conventions', :as => :admin_conventions, :via => :get + match 'dashboard_search' => 'admin#dashboard_search', :as => :admin_dashboard_search, :via => :get end match '/logout' => 'sessions#destroy', :as => :logout, :via => :delete diff --git a/public/javascripts/admin_interface.js b/public/javascripts/admin_interface.js index 8ca5b5e..3f6a0a9 100644 --- a/public/javascripts/admin_interface.js +++ b/public/javascripts/admin_interface.js @@ -31,6 +31,10 @@ $(document).ready(function () { } }); + if ($("#dashboard_search_term").length != 0) { + dashboard_search.initialize(); + } + if ($("#menu_search_term").length != 0) { menu_items.initialize_search(); } diff --git a/public/javascripts/admin_search.js b/public/javascripts/admin_search.js index f553334..53bcb5e 100644 --- a/public/javascripts/admin_search.js +++ b/public/javascripts/admin_search.js @@ -42,9 +42,10 @@ function initSearchPicker(options) { var inputSelector = options.inputSelector; var resultsSelector = options.resultsSelector; var url = options.url || ADMIN_MENU_SEARCH_URL; - var onSelect = options.onSelect; // omit for a real-navigation search like admin_search - var isActive = options.isActive; // optional guard, checked before every search + var onSelect = options.onSelect; // omit for a real-navigation search like admin_search + var isActive = options.isActive; // optional guard, checked before every search var resultsHeaderHtml = options.resultsHeaderHtml; // optional, prepended only when results exist + var renderResults = options.renderResults; // optional, replaces the default per-node rendering entirely var requestId = 0; var timeout; @@ -68,39 +69,44 @@ function initSearchPicker(options) { url: url, data: "search_term=" + term, dataType: "json", - success: function(nodes) { + success: function(data) { if (thisRequest !== requestId) return; results.empty(); - if (nodes.length && resultsHeaderHtml) { - results.append(resultsHeaderHtml); - } - var found = false; - for (var i = 0; i < nodes.length; i++) { - (function(node) { - var link; - if (onSelect) { - link = $( - "

" + node.title + - "" + node.unique_name + "" + - "

" - ); - link.find("a").bind("click", function() { - onSelect(node); - results.slideUp(); - results.empty(); - return false; - }); - } else { - link = $( - "

" + node.title + - "" + node.unique_name + "" + - "

" - ); - } - results.append(link); - found = true; - })(nodes[i]); + + var found; + if (renderResults) { + found = renderResults(data, results, resultsHeaderHtml); + } else { + if (data.length && resultsHeaderHtml) { + results.append(resultsHeaderHtml); + } + found = false; + for (var i = 0; i < data.length; i++) { + (function(node) { + var link; + if (onSelect) { + link = $( + "

" + node.title + + "" + node.unique_name + "

" + ); + link.find("a").bind("click", function() { + onSelect(node); + results.slideUp(); + results.empty(); + return false; + }); + } else { + link = $( + "

" + node.title + + "" + node.unique_name + "

" + ); + } + results.append(link); + found = true; + })(data[i]); + } } + if (found) { results.slideDown(); } else { @@ -115,6 +121,45 @@ function initSearchPicker(options) { }); } +dashboard_search = { + initialize : function() { + initSearchPicker({ + inputSelector: "#dashboard_search_term", + resultsSelector: "#dashboard_search_results", + url: DASHBOARD_SEARCH_URL, + resultsHeaderHtml: "

Press Enter to see all results ⏎

", + renderResults: function(data, results, resultsHeaderHtml) { + var found = false; + + if ((data.tags.length || data.nodes.length) && resultsHeaderHtml) { + results.append(resultsHeaderHtml); + } + + if (data.tags.length) { + results.append("

Tags

"); + data.tags.forEach(function(tag) { + results.append("

" + tag.name + "

"); + found = true; + }); + } + + if (data.nodes.length) { + results.append("

Pages

"); + data.nodes.forEach(function(node) { + results.append( + "

" + node.title + + "" + node.unique_name + "

" + ); + found = true; + }); + } + + return found; + } + }); + } +}; + menu_items = { initialize_search : function() { initSearchPicker({ diff --git a/public/stylesheets/admin.css b/public/stylesheets/admin.css index da31535..ade3a62 100644 --- a/public/stylesheets/admin.css +++ b/public/stylesheets/admin.css @@ -1052,6 +1052,16 @@ div#draft_list table td.actions a { border-bottom: none; } +.search_results p.search_group_label { + margin: 8px 0 2px; + padding: 0; + border-bottom: none; + font-size: 0.75rem; + color: #969696; + text-transform: lowercase; + font-weight: normal; +} + /* ============================================================ Menu items (navigation editor) ============================================================ */ diff --git a/test/controllers/admin_controller_test.rb b/test/controllers/admin_controller_test.rb index 13cc1bb..9beaf58 100644 --- a/test/controllers/admin_controller_test.rb +++ b/test/controllers/admin_controller_test.rb @@ -34,4 +34,28 @@ class AdminControllerTest < ActionController::TestCase matches = assigns(:mynodes).select { |n| n.id == node.id } assert_equal 1, matches.length end + + test "dashboard_search returns matching tags and nodes grouped separately" do + node = Node.root.children.create!(:slug => "dashboard_search_test") + node.find_or_create_draft(User.find_by_login("aaron")) + node.draft.update(:title => "Biometrics Workshop") + node.draft.tag_list = "biometrics-workshop" + node.draft.save! + + login_as :quentin + get :dashboard_search, params: { :search_term => "biometr" }, :format => :json + + json = JSON.parse(response.body) + assert json["tags"].any? { |t| t["name"] == "biometrics-workshop" } + assert json["nodes"].any? { |n| n["title"] == "Biometrics Workshop" } + end + + test "dashboard_search returns empty results for a blank term" do + login_as :quentin + get :dashboard_search, params: { :search_term => "" }, :format => :json + + json = JSON.parse(response.body) + assert_equal [], json["tags"] + assert_equal [], json["nodes"] + end end -- cgit v1.3 From 65c7c40f74c315c1a39fb15da8ce341fb8b9b05e Mon Sep 17 00:00:00 2001 From: erdgeist Date: Mon, 13 Jul 2026 01:57:43 +0200 Subject: Wavy-underline: allowlist to default; destroy-button and search polish Flips the plain-link underline from an opt-in list (growing every time a new context needed it) to the default a rule, with opt-outs living as more specific overrides where something shouldn't look like plain content -- .action_button, the icon nav, the search dropdown's bold results. The allowlist's own history made the case for this: three of its entries were already hand-copied duplicates of the same five properties, and two of those had already silently drifted. form.button_to's icon+text buttons (Destroy, currently) get the same inline-flex/gap treatment .action_button already had -- they were never included in that fix, so they still baseline-aligned an SVG against text instead of centering it, plus an explicit icon size they'd never had either. Dashboard search results: tags render as a wrapped row of pills instead of one-per-line, for visual distinction from page results. The public-preview link and its Revoke button get their own row rather than loose inline flow, which wrapped unpredictably next to a long token URL -- aligned by box-top rather than center or baseline, since a