notebook/app/controllers/documents_controller.rb
Andrew Brown 3d6ce69488 Fix tag disappearing issue during timeline filtering and optimize performance
- Initialize eventTags object from server-rendered DOM data on page load
- Add fallback in getEventTags() to scrape tags from DOM when cache is undefined
- Optimize updateMainPanelTags with DOM query caching and content change detection
- Remove redundant tag update calls during filtering to improve performance
- Add proper cache invalidation when tags are added/removed
- Only update highlight styling when actually needed during filter mode changes

This resolves the issue where tags would disappear when filtering timeline events
by ensuring the JavaScript tag data is always synchronized with the server-rendered content.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-01 22:23:45 -07:00

355 lines
12 KiB
Ruby

class DocumentsController < ApplicationController
layout :determine_layout
before_action :authenticate_user!, except: [:show, :analysis]
# todo Uh, this is a hack. The CSRF token on document editor model to add entities is being rejected... for whatever reason.
skip_before_action :verify_authenticity_token, only: [:link_entity]
before_action :set_document, only: [:show, :analysis, :plaintext, :queue_analysis, :edit, :destroy]
before_action :set_sidenav_expansion, except: [:plaintext]
before_action :set_navbar_color, except: [:plaintext]
before_action :set_navbar_actions, except: [:edit, :plaintext]
before_action :set_footer_visibility, only: [:edit]
# Skip UI-heavy calls for API endpoints
skip_before_action :cache_most_used_page_information, only: [:update]
skip_before_action :cache_forums_unread_counts, only: [:update]
# TODO: verify_user_can_read, verify_user_can_edit, etc before_actions instead of inlining them
before_action :cache_linkable_content_for_each_content_type, only: [:edit]
def index
@page_title = "My documents"
@recent_documents = current_user
.linkable_documents.order('updated_at DESC')
.includes([:user, :page_tags, :universe])
@documents = current_user
.linkable_documents
.order('favorite DESC, title ASC, updated_at DESC')
.includes([:user, :page_tags, :universe])
@folders = current_user
.folders
.where(context: 'Document', parent_folder_id: nil)
.order('title ASC')
# TODO: can we reuse this content to skip a few queries in this controller action?
cache_linkable_content_for_each_content_type
# TODO: all of this filtering code is repeated everywhere and would be nice to abstract out somewhere
if params.key?(:favorite_only)
@documents = @documents.where(favorite: true)
end
if @universe_scope
@documents = @documents.where(universe: @universe_scope)
@recent_documents = @recent_documents.where(universe: @universe_scope)
end
@recent_documents = @recent_documents.limit(6)
@page_tags = PageTag.where(
page_type: Document.name,
page_id: @documents.map(&:id)
).order(:tag)
@filtered_page_tags = []
if params.key?(:tag)
@filtered_page_tags = @page_tags.where(slug: params[:tag])
@documents = @documents.to_a.select { |document| @filtered_page_tags.pluck(:page_id).include?(document.id) }
# @documents = @documents.where(page_tags: { slug: params[:tag] })
end
@page_tags = @page_tags.uniq(&:tag)
@suggested_page_tags = (@page_tags.pluck(:tag) + PageTagService.suggested_tags_for('Document')).uniq
end
def show
unless @document.present? && (current_user || User.new).can_read?(@document)
return redirect_to(root_path, notice: "That document either doesn't exist or you don't have permission to view it.", status: :not_found)
end
if @document.user.nil? || @document.user.thredded_user_detail.moderation_state == "blocked"
return redirect_to(root_path, notice: "That document either doesn't exist or you don't have permission to view it.", status: :not_found)
end
# Put the focus on the document by removing Notebook.ai actions
@navbar_actions = []
end
def analysis
unless @document.present? && (current_user || User.new).can_read?(@document)
redirect_to(root_path, notice: "That document either doesn't exist or you don't have permission to view it.", status: :not_found)
end
@analysis = @document.document_analysis.where.not(queued_at: nil).order('updated_at DESC').first
@navbar_actions.unshift({
label: (@document.name || 'Untitled document'),
href: document_path(@document)
})
@navbar_actions.unshift({
label: 'Analysis',
href: analysis_document_path(@document)
})
end
def queue_analysis
return redirect_back(fallback_location: documents_path, notice: "That document doesn't exist!", status: :not_found) unless @document.present?
return redirect_back(fallback_location: documents_path, notice: "Document analysis is a feature for Premium users.") unless @document.user.on_premium_plan?
return redirect_back(fallback_location: documents_path, notice: "You don't have permission to do that!") unless @document.user == current_user
@document.analyze!
redirect_to analysis_document_path(@document)
end
# todo this function is an embarassment
def link_entity
# Preconditions lol
unless (Rails.application.config.content_types[:all].map(&:name) + [Timeline.name, Document.name]).include?(linked_entity_params[:entity_type])
raise "Invalid entity type #{linked_entity_params[:entity_type]}"
end
# Take this out of the params upfront in case we need to modify the value (after creating one, for example)
document_analysis_id = linked_entity_params[:document_analysis_id].to_i
if (document_analysis_id == -1)
# If there's no document analysis present, we're creating an entity without an associated analysis yet
# So we just create a, uh, placeholder I guess
document = Document.find_by(id: linked_entity_params[:document_id], user: current_user.id)
analysis = Documents::Analysis::DocumentAnalysisService.create_placeholder_analysis(document)
document_analysis_id = analysis.id
# todo document entities might make more sense to be tied to documents instead of analyses
end
if (linked_entity_params[:document_entity_id].to_i == -1)
# If we pass in an ID of -1, then we're adding a new DocumentEntity (rather than linking an existing one)
# Therefore, we need to create one.
document_analysis = DocumentAnalysis.joins(:document).find_by(
id: document_analysis_id,
documents: { user: current_user }
)
raise "No document analysis found for id=#{document_analysis_id} / user=#{current_user.id}" if document_analysis.nil?
# Now that we have the analysis reference, we just create a new DocumentEntity on it for the associated page
page = linked_entity_params[:entity_type].constantize.find(linked_entity_params[:entity_id]) # raises exception if not found :+1:
document_entity = document_analysis.document_entities.create!(
entity_type: linked_entity_params[:entity_type],
entity_id: linked_entity_params[:entity_id],
text: page.name
)
# # Finally, we need to kick off another analysis job to fetch information about this entity
document_entity.analyze! if current_user.on_premium_plan?
return redirect_back(fallback_location: analysis_document_path(document_entity.document_analysis.document), notice: "Page linked!")
else
# If we pass in an actual ID for the document entity, we're modifying an existing one
document_entity = DocumentEntity.find_by(id: linked_entity_params[:document_entity_id].to_i)
# todo some real perms?
if document_entity && document_entity.document_owner == current_user
# todo strong params update sans DEI?
document_entity.update(
entity_type: linked_entity_params[:entity_type],
entity_id: linked_entity_params[:entity_id].to_i
)
return redirect_to(analysis_document_path(document_entity.document_analysis.document), notice: "Page linked!")
end
end
end
def new
document = current_user.documents.create({
universe: @universe_scope,
folder_id: params.fetch('folder', nil).try(:to_i)
})
redirect_to edit_document_path(document)
end
def edit
redirect_to(root_path, notice: "You don't have permission to edit that!") unless @document.updatable_by?(current_user)
@linked_entities = @document.document_entities
.where.not(entity_id: nil)
.group_by(&:entity_type)
end
def create
created_document = current_user.documents.create(document_params)
redirect_to edit_document_path(created_document), notice: "Your document has been saved!"
end
def update
document = Document.with_deleted.find_or_initialize_by(id: params[:id])
unless document.updatable_by?(current_user)
redirect_to(dashboard_path, notice: "You don't have permission to do that!")
return
end
# We can't pass actual-nil from HTML (for no universe), so we pass a string instead and convert it back here.
d_params = document_params.clone
if d_params.fetch(:universe_id, nil) == "nil"
d_params[:universe_id] = nil
end
# Only queue document mentions for analysis if the document body has changed
DocumentMentionJob.perform_later(document.id) if d_params.key?(:body)
update_page_tags(document) if document_tag_params
if document.update(d_params)
head 200, content_type: "text/html"
else
head 501, content_type: "text/html"
end
end
def plaintext
unless @document.present? && (current_user || User.new).can_read?(@document)
return redirect_to(root_path, notice: "That document either doesn't exist or you don't have permission to view it.", status: :not_found)
end
render
end
def toggle_favorite
document = Document.with_deleted.find_or_initialize_by(id: params[:id])
unless document.updatable_by?(current_user)
flash[:notice] = "You don't have permission to edit that!"
return redirect_back fallback_location: document
end
document.update!(favorite: !document.favorite)
end
def destroy
if current_user.can_delete?(@document)
@document.destroy
redirect_to(documents_path, notice: "The document was successfully deleted.")
else
redirect_to(root_path, notice: "You don't have permission to do that!")
end
end
def destroy_document_entity
entity = DocumentEntity.find_by(id: params[:id])
document = entity.document_analysis.document
return unless document.user == current_user
entity.destroy
redirect_back(fallback_location: analysis_document_path(document), notice: "Entity removed from analysis.")
end
def unlink_entity
document = Document.find_by(id: params[:id])
return unless document.present?
entity = document.document_entities.find_by(
entity_type: params[:page_type],
entity_id: params[:page_id]
)
return unless entity.present?
return unless user_signed_in? && document.user == current_user
entity.destroy
redirect_back(fallback_location: document, notice: "Page unlinked.")
end
def destroy_analysis
# todo move this to analysis controller
document = Document.find_by(id: params[:id])
return unless document.user == current_user
document.document_analysis.destroy_all
redirect_back(fallback_location: analysis_document_path(document), notice: "Analysis deleted.")
end
def set_sidenav_expansion
@sidenav_expansion = 'writing'
end
def set_navbar_color
content_type = content_type_from_controller(self.class)
@navbar_color = content_type.hex_color
end
def set_navbar_actions
@navbar_actions = []
end
def set_footer_visibility
@show_footer = false
end
# Determines which layout to use based on the current action
def determine_layout
if action_name == 'edit'
'editor'
elsif action_name == 'plaintext'
'plaintext'
else
'application' # Default layout for other actions
end
end
private
def update_page_tags(document)
tag_list = document_tag_params.fetch('value', '').split(PageTag::SUBMISSION_DELIMITER)
current_tags = document.page_tags.pluck(:tag)
tags_to_add = tag_list - current_tags
tags_to_remove = current_tags - tag_list
tags_to_add.each do |tag|
# TODO: create changelog event for AddedTag
document.page_tags.find_or_create_by(
tag: tag,
slug: PageTagService.slug_for(tag),
user: document.user
)
end
tags_to_remove.each do |tag|
# TODO: create changelog event for RemovedTag or use destroy_all
document.page_tags.find_by(tag: tag).destroy
end
end
def document_params
params.require(:document).permit(:title, :body, :deleted_at, :privacy, :universe_id, :folder_id, :notes_text, :synopsis)
end
def document_tag_params
params.require(:field).permit(:value)
rescue ActionController::ParameterMissing
nil
end
def linked_entity_params
params.permit(:entity_id, :entity_type, :document_entity_id, :document_id, :document_analysis_id)
end
def set_document
@document = Document.find_by(id: params[:id])
unless @document
redirect_to root_path, notice: "Either that document doesn't exist or you don't have permission to view it!", status: :not_found
return
end
end
end