diff --git a/Gemfile b/Gemfile index 7c0f68b5..18979c09 100644 --- a/Gemfile +++ b/Gemfile @@ -42,7 +42,6 @@ gem 'paranoia' # Javascript gem 'coffee-rails' gem 'rails-jquery-autocomplete' -gem 'animate-rails' gem 'webpacker' gem 'react-rails' diff --git a/Gemfile.lock b/Gemfile.lock index 985186c1..ff4e7ef5 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -108,8 +108,6 @@ GEM activerecord (>= 4.2) addressable (2.7.0) public_suffix (>= 2.0.2, < 5.0) - animate-rails (1.0.10) - rails authority (3.3.0) activesupport (>= 3.0.0) autoprefixer-rails (9.8.4) @@ -1629,7 +1627,6 @@ PLATFORMS DEPENDENCIES active_storage_validations acts_as_list - animate-rails authority aws-sdk (~> 3.1) aws-sdk-s3 diff --git a/app/assets/stylesheets/application.css b/app/assets/stylesheets/application.css index 670b96cc..e0b1691b 100644 --- a/app/assets/stylesheets/application.css +++ b/app/assets/stylesheets/application.css @@ -13,7 +13,6 @@ *= require font-awesome *= require medium-editor/medium-editor *= require medium-editor/themes/beagle - *= require animate *= require tribute *= require_tree . */ diff --git a/app/authorizers/content_page_authorizer.rb b/app/authorizers/content_page_authorizer.rb new file mode 100644 index 00000000..3bc57016 --- /dev/null +++ b/app/authorizers/content_page_authorizer.rb @@ -0,0 +1,48 @@ +class ContentPageAuthorizer < CoreContentAuthorizer + def self.creatable_by?(user) + return false unless user.present? + return false if ENV.key?('CONTENT_BLACKLIST') && ENV['CONTENT_BLACKLIST'].split(',').include?(user.email) + + if resource.page_type == 'Universe' + return true if PermissionService.user_has_fewer_owned_universes_than_plan_limit?(user: user) + + else + is_premium_page = Rails.application.config.content_types[:premium].include?(resource.page_type) + return true if !is_premium_page + return true if is_premium_page && PermissionService.user_is_on_premium_plan?(user: user) + end + + return false + end + + def readable_by?(user) + return true if PermissionService.content_is_public?(content: resource) + return true if PermissionService.user_owns_content?(user: user, content: resource) + + if resource.page_type == 'Universe' + return true if PermissionService.user_can_contribute_to_universe?(user: user, universe: resource) + else + return true if PermissionService.user_can_contribute_to_containing_universe?(user: user, content: resource) + end + + return false + end + + def updatable_by?(user) + return true if PermissionService.user_owns_content?(user: user, content: resource) + + if resource.page_type == 'Universe' + return true if PermissionService.user_can_contribute_to_universe?(user: user, universe: resource) + else + return true if PermissionService.user_can_contribute_to_containing_universe?(user: user, content: resource) + end + + return false + end + + def deletable_by?(user) + [ + PermissionService.user_owns_content?(user: user, content: resource) + ].any? + end +end diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index 547bdd67..175b9ba6 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -1,3 +1,4 @@ +# frozen_string_literal: true class ApplicationController < ActionController::Base protect_from_forgery @@ -5,7 +6,6 @@ class ApplicationController < ActionController::Base before_action :set_universe_scope before_action :cache_most_used_page_information - before_action :cache_forums_unread_counts before_action :set_metadata @@ -27,7 +27,7 @@ class ApplicationController < ActionController::Base session.delete(:universe_id) elsif params[:universe].is_a?(String) && params[:universe].to_i.to_s == params[:universe] found_universe = Universe.find_by(id: params[:universe]) - found_universe = nil unless current_user.universes.include?(found_universe) || current_user.contributable_universes.include?(found_universe) + found_universe = nil unless found_universe.user_id == current_user.id || found_universe.contributors.pluck(:user_id).include?(current_user.id) session[:universe_id] = found_universe.id if found_universe end end @@ -36,52 +36,116 @@ class ApplicationController < ActionController::Base def set_universe_scope if user_signed_in? && session.key?(:universe_id) @universe_scope = Universe.find_by(id: session[:universe_id]) - @universe_scope = nil unless current_user.universes.include?(@universe_scope) || current_user.contributable_universes.include?(@universe_scope) + + if @universe_scope && @universe_scope.user_id != current_user.try(:id) + # Verify the current user has access to this universe by looking up their + # universe contributorship + contributorship = Contributor.find_by( + user: current_user, + universe: @universe_scope + ) + + if contributorship.nil? + # If the user doesn't have current contributor access to this universe, + # then revert back to unscoped universe actions + @universe_scope = nil + end + end + else @universe_scope = nil end end - # Cache some super-common stuff we need for every page. For example, content lists for the side nav. + # Cache some super-common stuff we need for every page. For example, content lists for the side nav. This is a catch-all for most pages that render + # UI, but methods are also free to skip this filter and call the individual cache methods they need instead. def cache_most_used_page_information + return unless user_signed_in? + + cache_activated_content_types + cache_current_user_content + cache_notifications + cache_recently_edited_pages + cache_forums_unread_counts + end + + def cache_activated_content_types + @activated_content_types ||= if user_signed_in? + ( + # Use config to dictate order, but AND to only include what a user has turned on + Rails.application.config.content_type_names[:all] & current_user.user_content_type_activators.pluck(:content_type) + ) + else + [] + end + end + + def cache_current_user_content + return if @current_user_content + @current_user_content = {} return unless user_signed_in? - @activated_content_types = ( - Rails.application.config.content_types[:all].map(&:name) & # Use config to dictate order, but AND to only include what a user has turned on - current_user.user_content_type_activators.pluck(:content_type) - ) + cache_activated_content_types # We always want to cache Universes, even if they aren't explicitly turned on. - @current_user_content = current_user.content(content_types: @activated_content_types + ['Universe'], universe_id: @universe_scope.try(:id)) + @current_user_content = current_user.content( + content_types: @activated_content_types + [Universe.name], + universe_id: @universe_scope.try(:id) + ) # Likewise, we should also always cache Timelines & Documents if @universe_scope @current_user_content['Timeline'] = current_user.timelines.where(universe_id: @universe_scope.try(:id)).to_a - @current_user_content['Document'] = current_user.linkable_documents.includes([:user]).where(universe_id: @universe_scope.try(:id)).order('updated_at DESC').to_a + @current_user_content['Document'] = current_user.documents.where(universe_id: @universe_scope.try(:id)).order('updated_at DESC').to_a else @current_user_content['Timeline'] = current_user.timelines.to_a - @current_user_content['Document'] = current_user.linkable_documents.includes([:user]).order('updated_at DESC').to_a + @current_user_content['Document'] = current_user.documents.order('updated_at DESC').to_a end + end - # Fetch notifications - @user_notifications = current_user.notifications.order('happened_at DESC').limit(100) + def cache_notifications + @user_notifications ||= if user_signed_in? + current_user.notifications.order('happened_at DESC').limit(100) + else + [] + end + end - # Cache recently-edited pages - @recently_edited_pages = @current_user_content.values.flatten - .sort_by(&:updated_at) - .last(50) - .reverse + def cache_recently_created_pages(amount=50) + cache_current_user_content + + @recently_created_pages = if user_signed_in? + @current_user_content.values.flatten + .sort_by(&:created_at) + .last(amount) + .reverse + else + [] + end + end + + def cache_recently_edited_pages(amount=50) + cache_current_user_content + + @recently_edited_pages ||= if user_signed_in? + @current_user_content.values.flatten + .sort_by(&:updated_at) + .last(amount) + .reverse + else + [] + end end def cache_forums_unread_counts - @unread_threads = if user_signed_in? + @unread_threads ||= if user_signed_in? Thredded::Topic.unread_followed_by(current_user).count else 0 end - @unread_private_messages = if user_signed_in? + @unread_private_messages ||= if user_signed_in? Thredded::PrivateTopic .for_user(current_user) .unread(current_user) @@ -91,33 +155,60 @@ class ApplicationController < ActionController::Base end end + def cache_contributable_universe_ids + cache_current_user_content + + @contributable_universe_ids ||= if user_signed_in? + current_user.contributable_universe_ids + @current_user_content.fetch('Universe', []).map(&:id) + else + [] + end + end + def cache_linkable_content_for_each_content_type - linkable_classes = Rails.application.config.content_types[:all].map(&:name) & current_user.user_content_type_activators.pluck(:content_type) + cache_contributable_universe_ids + cache_current_user_content + + linkable_classes = @activated_content_types linkable_classes += %w(Document Timeline) - @linkables_cache = {} - @linkables_raw = {} - linkable_classes.each do |class_name| - # class_name = "Character" + @linkables_cache = {} # Cache is list of [[page_name, page_id], [page_name, page_id], ...] + @linkables_raw = {} # Raw is list of objects [#{page}, #{page}, ...] - @linkables_cache[class_name] = current_user - .send("linkable_#{class_name.downcase.pluralize}") - .in_universe(@universe_scope) + @current_user_content.each do |page_type, content_list| + # We already have our own list of content by the current user in @current_user_content, + # so all we need to grab is additional pages in contributable universes + @linkables_raw[page_type] = @current_user_content[page_type] - if @content.present? && @content.persisted? - @linkables_cache[class_name] = @linkables_cache[class_name] - .in_universe(@content.universe) - .reject { |content| content.class.name == class_name && content.id == @content.id } + if !@universe_scope && @contributable_universe_ids.any? + existing_page_ids = @linkables_raw[page_type].map(&:id) + + pages_to_add = if page_type == Universe.name + page_type.constantize.where(id: @contributable_universe_ids) + .where.not(id: existing_page_ids) + else + page_type.constantize.where(universe_id: @contributable_universe_ids) + .where.not(id: existing_page_ids) + end + + filtered_fields = ContentPage.polymorphic_content_fields.map(&:to_s) + filtered_fields.push 'universe_id' unless page_type == Universe.name + pages_to_add.each do |page_data| + filtered_page_data = page_data.attributes.slice(*filtered_fields) + @linkables_raw[page_type].push ContentPage.new(filtered_page_data) + end end - @linkables_raw[class_name] = @linkables_cache[class_name] - .sort_by { |p| p.name.downcase } - .compact + # We can't properly display or @-mention content without a name set, so we explicitly + # reject it here. However, this is a bit of a code-smell: why is there content without + # a name set? + @linkables_raw[page_type].reject! { |page| page.name.nil? } - @linkables_cache[class_name] = @linkables_cache[class_name] - .sort_by { |p| p.name.downcase } - .map { |c| [c.name, c.id] } - .compact + # Finally, we want to sort our linkables cache once so we don't have to sort it again + @linkables_raw[page_type].sort_by! { |page| page.name.downcase }.compact! + + # Lastly, build our name/id cache as well + @linkables_cache[page_type] = @linkables_raw[page_type].map { |page| [page.name, page.id] } end end end diff --git a/app/controllers/content_controller.rb b/app/controllers/content_controller.rb index fa7eafec..51b7f2b2 100644 --- a/app/controllers/content_controller.rb +++ b/app/controllers/content_controller.rb @@ -1,9 +1,15 @@ -class ContentController < ApplicationController - # todo we should probably spin off an Api::ContentController for #api_sort and anything else api-wise we need +# frozen_string_literal: true +# TODO: we should probably spin off an Api::ContentController for #api_sort and anything else +# api-wise we need +class ContentController < ApplicationController before_action :authenticate_user!, except: [:show, :changelog, :api_sort] \ + Rails.application.config.content_types[:all_non_universe].map { |type| type.name.downcase.pluralize.to_sym } + skip_before_action :cache_most_used_page_information, only: [ + :name_field_update, :text_field_update, :tags_field_update, :universe_field_update, :api_sort + ] + before_action :migrate_old_style_field_values, only: [:show, :edit] before_action :cache_linkable_content_for_each_content_type, only: [:new, :edit, :index] @@ -21,27 +27,13 @@ class ContentController < ApplicationController @page_title = "My #{pluralized_content_name}" # Create the default fields for this user if they don't have any already + # TODO: uh, this probably doesn't belong here! @content_type_class.attribute_categories(current_user) - if @universe_scope.present? && @content_type_class != Universe - @content = @universe_scope.send(pluralized_content_name) - .includes(:page_tags, :image_uploads) - .unarchived + # Linkables cache is already scoped per-universe, includes contributor pages + @content = @linkables_raw.fetch(@content_type_class.name, []) - @show_scope_notice = true - else - @content = ( - current_user.send(pluralized_content_name).unarchived.includes(:page_tags, :image_uploads) + - current_user.send("contributable_#{pluralized_content_name}").unarchived.includes(:page_tags, :image_uploads) - ) - - if @content_type_class != Universe - my_universe_ids = current_user.universes.pluck(:id) - @content.concat(@content_type_class.where(universe_id: my_universe_ids).unarchived) - end - end - - @content = @content.to_a.flatten.uniq + @show_scope_notice = @universe_scope.present? && @content_type_class != Universe # Filters @page_tags = PageTag.where( @@ -52,18 +44,18 @@ class ContentController < ApplicationController @filtered_page_tags = @page_tags.where(slug: params[:tag]) @content.select! { |content| @filtered_page_tags.pluck(:page_id).include?(content.id) } end + @page_tags = @page_tags.uniq(&:tag) if params.key?(:favorite_only) @content.select!(&:favorite?) end - @page_tags = @page_tags.uniq(&:tag) - @content = @content.sort_by {|x| [x.favorite? ? 0 : 1, x.name] } @questioned_content = @content.sample @attribute_field_to_question = SerendipitousService.question_for(@questioned_content) + # Uh, do we ever actually make JSON requests to logged-in user pages? respond_to do |format| format.html { render 'content/index' } format.json { render json: @content } @@ -72,7 +64,7 @@ class ContentController < ApplicationController def show content_type = content_type_from_controller(self.class) - return redirect_to(root_path, notice: "That page doesn't exist!") unless valid_content_types.map(&:name).include?(content_type.name) + return redirect_to(root_path, notice: "That page doesn't exist!") unless valid_content_types.include?(content_type.name) @content = content_type.find_by(id: params[:id]) return redirect_to(root_path, notice: "You don't have permission to view that content.") if @content.nil? @@ -289,7 +281,7 @@ class ContentController < ApplicationController def changelog content_type = content_type_from_controller(self.class) - return redirect_to root_path unless valid_content_types.map(&:name).include?(content_type.name) + return redirect_to root_path unless valid_content_types.include?(content_type.name) @content = content_type.find_by(id: params[:id]) return redirect_to(root_path, notice: "You don't have permission to view that content.") if @content.nil? @serialized_content = ContentSerializer.new(@content) @@ -421,6 +413,7 @@ class ContentController < ApplicationController set_entity referencing_page = @entity + # TODO: move this into a link mention update job valid_reference_ids = [] referenced_page_codes = JSON.parse(attribute_value.value) referenced_page_codes.each do |page_code| @@ -455,7 +448,7 @@ class ContentController < ApplicationController # We also need to update the cached `name` field on the content page itself entity_type = entity_params.fetch(:entity_type) - raise "Invalid entity type: #{entity_params.fetch(:entity_type)}" unless valid_content_types.map(&:name).include?(entity_params.fetch('entity_type')) + raise "Invalid entity type: #{entity_params.fetch(:entity_type)}" unless valid_content_types.include?(entity_params.fetch('entity_type')) entity = entity_type.constantize.find_by(id: entity_params.fetch(:entity_id).to_i) entity.update(name: field_params.fetch('value', '')) end @@ -469,35 +462,16 @@ class ContentController < ApplicationController attribute_value.value = text attribute_value.save! - # Create PageReferences for mentioned pages - tokens = ContentFormatterService.tokens_to_replace(text) - if tokens.any? - set_entity + UpdateTextAttributeReferencesJob.perform_later(attribute_value.id) - valid_reference_ids = [] - tokens.each do |token| - reference = @entity.outgoing_page_references.find_or_initialize_by( - referenced_page_type: token[:content_type], - referenced_page_id: token[:content_id], - attribute_field_id: @attribute_field.id, - reference_type: 'mentioned' - ) - reference.cached_relation_title = @attribute_field.label - reference.save! - - valid_reference_ids << reference.reload.id - end - - # Delete all other references still attached to this field, but not present in this request - @entity.outgoing_page_references - .where(attribute_field_id: @attribute_field.id) - .where.not(id: valid_reference_ids) - .destroy_all + respond_to do |format| + format.html { redirect_back(fallback_location: root_path, notice: "#{@attribute_field.label} updated!") } + format.json { render json: attribute_value, status: :success } end end def tags_field_update - return unless valid_content_types.map(&:name).include?(entity_params.fetch('entity_type')) + return unless valid_content_types.include?(entity_params.fetch('entity_type')) @attribute_field = AttributeField.find_by(id: params[:field_id].to_i) attribute_value = @attribute_field.attribute_values.order('created_at desc').find_or_initialize_by(entity_params) @@ -512,7 +486,7 @@ class ContentController < ApplicationController end def universe_field_update - return unless valid_content_types.map(&:name).include?(entity_params.fetch('entity_type')) + return unless valid_content_types.include?(entity_params.fetch('entity_type')) @attribute_field = AttributeField.find_by(id: params[:field_id].to_i) attribute_value = @attribute_field.attribute_values.order('created_at desc').find_or_initialize_by(entity_params) @@ -580,7 +554,7 @@ class ContentController < ApplicationController end def valid_content_types - Rails.application.config.content_types[:all] + Rails.application.config.content_type_names[:all] end def initialize_object @@ -656,7 +630,7 @@ class ContentController < ApplicationController def set_attributes_content_type @content_type = params[:content_type] # todo make this a before_action load_content_type - unless valid_content_types.map { |c| c.name.downcase }.include?(@content_type) + unless valid_content_types.map(&:downcase).include?(@content_type) raise "Invalid content type on attributes customization page: #{@content_type}" end @content_type_class = @content_type.titleize.constantize @@ -671,7 +645,7 @@ class ContentController < ApplicationController entity_page_type = entity_params.fetch(:entity_type) entity_page_id = entity_params.fetch(:entity_id) - return unless valid_content_types.map(&:name).include?(entity_page_type) + return unless valid_content_types.include?(entity_page_type) @entity = entity_page_type.constantize.find_by(id: entity_page_id) end diff --git a/app/controllers/documents_controller.rb b/app/controllers/documents_controller.rb index 4ded2037..a1f5b731 100644 --- a/app/controllers/documents_controller.rb +++ b/app/controllers/documents_controller.rb @@ -4,6 +4,8 @@ class DocumentsController < ApplicationController # 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] + skip_before_action :cache_most_used_page_information, only: [:update] + 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] @@ -181,21 +183,21 @@ class DocumentsController < ApplicationController def update document = Document.with_deleted.find_or_initialize_by(id: params[:id]) - d_params = document_params.clone # TODO: why are we duplicating the params here? unless document.updatable_by?(current_user) redirect_to(dashboard_path, notice: "You don't have permission to do that!") return end - # Only queue document mentions for analysis if the document body has changed - DocumentMentionJob.perform_later(document.id) if d_params.key?(:body) - # 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) diff --git a/app/controllers/main_controller.rb b/app/controllers/main_controller.rb index 00de0c7c..3c44b5cd 100644 --- a/app/controllers/main_controller.rb +++ b/app/controllers/main_controller.rb @@ -25,8 +25,7 @@ class MainController < ApplicationController def dashboard @page_title = "My notebook" - set_random_content # for questions - @attribute_field_to_question = SerendipitousService.question_for(@content) + set_questionable_content # for questions end def infostack @@ -65,8 +64,7 @@ class MainController < ApplicationController @navbar_color = '#FF9800' @page_title = "Writing prompts" - set_random_content # for question - @attribute_field_to_question = SerendipitousService.question_for(@content) + set_questionable_content # for question end # deprecated path just kept around for bookmarks for a while @@ -75,13 +73,6 @@ class MainController < ApplicationController end def recent_content - # todo optimize this / use Attributes - return [] if @activated_content_types.nil? - - @recently_created_pages = @current_user_content.values.flatten - .sort_by(&:created_at) - .last(50) - .reverse end def for_writers @@ -105,27 +96,8 @@ class MainController < ApplicationController private - def set_random_content - @activated_content_types.shuffle.each do |content_type| - if content_type == Universe.name - if @universe_scope.present? - @content = content_type.constantize.where(user: current_user, id: @universe_scope.id).includes(:user) - else - @content = content_type.constantize.where(user: current_user).includes(:user) - end - else - if @universe_scope.present? - # when we want to enable prompts for contributing universes we can remove the user: - # selector here, but we will need to verify the user has permission to see the universe - # when we do that, or else prompts could open leak - @content = content_type.constantize.where(user: current_user, universe: @universe_scope).includes(:user, :universe) - else - @content = content_type.constantize.where(user: current_user).includes(:user, :universe) - end - end - - @content = @content.sample - return if @content.present? - end + def set_questionable_content + @content = @current_user_content.except(*%w(Timeline Document)).values.flatten.sample + @attribute_field_to_question = SerendipitousService.question_for(@content) end end diff --git a/app/controllers/timelines_controller.rb b/app/controllers/timelines_controller.rb index 2bd4f351..3e243cce 100644 --- a/app/controllers/timelines_controller.rb +++ b/app/controllers/timelines_controller.rb @@ -7,6 +7,8 @@ class TimelinesController < ApplicationController # GET /timelines def index + cache_linkable_content_for_each_content_type + @timelines = current_user.timelines @page_title = "My timelines" diff --git a/app/jobs/document_entity_analysis_job.rb b/app/jobs/document_entity_analysis_job.rb index 35ffb337..f91f1c1d 100644 --- a/app/jobs/document_entity_analysis_job.rb +++ b/app/jobs/document_entity_analysis_job.rb @@ -4,7 +4,7 @@ class DocumentEntityAnalysisJob < ApplicationJob def perform(*args) document_entity_id = args.shift - entity = DocumentEntity.find(document_entity_id) + entity = DocumentEntity.find_by(id: document_entity_id) return unless entity.present? Documents::Analysis::ThirdParty::IbmWatsonService.analyze_entity(document_entity_id) diff --git a/app/jobs/update_text_attribute_references_job.rb b/app/jobs/update_text_attribute_references_job.rb new file mode 100644 index 00000000..598d7eaa --- /dev/null +++ b/app/jobs/update_text_attribute_references_job.rb @@ -0,0 +1,36 @@ +class UpdateTextAttributeReferencesJob < ApplicationJob + queue_as :mentions + + def perform(*args) + attribute_id = args.shift + + attribute = Attribute.find_by(id: attribute_id) + return unless attribute.present? + + # Create PageReferences for mentioned pages + tokens = ContentFormatterService.tokens_to_replace(attribute.value) + if tokens.any? + entity = attribute.entity + + valid_reference_ids = [] + tokens.each do |token| + reference = entity.outgoing_page_references.find_or_initialize_by( + referenced_page_type: token[:content_type], + referenced_page_id: token[:content_id], + attribute_field_id: attribute.attribute_field_id, + reference_type: 'mentioned' + ) + reference.cached_relation_title = AttributeField.find_by(id: attribute.attribute_field_id).try(:label) + reference.save! + + valid_reference_ids << reference.reload.id + end + + # Delete all other references still attached to this field, but not present in this request + entity.outgoing_page_references + .where(attribute_field_id: attribute.attribute_field_id) + .where.not(id: valid_reference_ids) + .destroy_all + end + end +end diff --git a/app/models/concerns/has_attributes.rb b/app/models/concerns/has_attributes.rb index 00c28e76..b7a07108 100644 --- a/app/models/concerns/has_attributes.rb +++ b/app/models/concerns/has_attributes.rb @@ -42,17 +42,28 @@ module HasAttributes end def self.attribute_categories(user, show_hidden: false) + # TODO: this is a code smell; we should probably either be whitelisting or fixing whatever is calling + # this with the wrong models return [] if ['attribute_category', 'attribute_field'].include?(content_name) # Cache the result in case we call this function multiple times this request @cached_attribute_categories_for_this_content = begin # Always include the flatfile categories (but create AR versions if they don't exist) + categories_list = AttributeCategory.with_deleted.where(user: user).to_a + full_fields_list = AttributeField.with_deleted.where(user: user, attribute_category_id: categories_list.map(&:id)) categories = YAML.load_file(Rails.root.join('config', 'attributes', "#{content_name}.yml")).map do |category_name, details| - category = ::AttributeCategory.with_deleted.find_or_initialize_by( - entity_type: self.content_name, - name: category_name.to_s, - user: user - ) + category = categories_list.detect do |persisted_category| + persisted_category.entity_type == self.content_name && + persisted_category.name == category_name.to_s + end + if category.nil? + category = AttributeCategory.new( + entity_type: self.content_name, + name: category_name.to_s, + user: user + ) + end + # Default new categories to some sane defaults unless category.persisted? category.icon = details[:icon] @@ -60,13 +71,21 @@ module HasAttributes end category.save! if user && category.new_record? + fields_list = full_fields_list.select do |field| + field.attribute_category_id == category.id + end category.attribute_fields << details[:attributes].map do |field| - af_field = category.attribute_fields.with_deleted.find_or_initialize_by( - # label: field[:label], - old_column_source: field[:name], - user: user, - field_type: field[:field_type].presence || "text_area" - ) + af_field = fields_list.detect do |persisted_field| + persisted_field.old_column_source == field[:name] && + persisted_field.field_type == field[:field_type].presence || "text_area" + end + if af_field.nil? + af_field = category.attribute_fields.new( + old_column_source: field[:name], + user: user, + field_type: field[:field_type].presence || "text_area" + ) + end if af_field.label.nil? af_field.label = field[:label] end @@ -180,6 +199,7 @@ module HasAttributes end end + # All of these helpers are spooky and rife for N+1s def name_field category_ids = AttributeCategory.where( user_id: user_id, diff --git a/app/models/concerns/has_content.rb b/app/models/concerns/has_content.rb index eff6dd03..c12f6f4c 100644 --- a/app/models/concerns/has_content.rb +++ b/app/models/concerns/has_content.rb @@ -16,18 +16,49 @@ module HasContent has_many :attribute_categories has_many :attribute_values, class_name: 'Attribute', dependent: :destroy + # Alternative approach to #content for benchmarking in prod + def content_with_multiple_queries( + content_types: Rails.application.config.content_type_names[:all], + page_scoping: { user_id: self.id }, + universe_id: nil + ) + @content_by_page_type = {} + content_types.each do |content_type| + type_specific_fields = ContentPage.polymorphic_content_fields + type_specific_fields.push 'universe_id' unless content_type == 'Universe' + + pages_of_this_type = content_type.constantize + .where(page_scoping) + .select(type_specific_fields) + + if content_type != 'Universe' && universe_id.present? + pages_of_this_type = pages_of_this_type.where(universe_id: universe_id) + end + + @content_by_page_type[content_type] = [] + pages_of_this_type.each do |page_data| + @content_by_page_type[content_type].push ContentPage.new(page_data.attributes) + end + end + + @content_by_page_type + end + # { # characters: [...], # locations: [...] # } def content( - content_types: Rails.application.config.content_types[:all].map(&:name), + content_types: Rails.application.config.content_type_names[:all], page_scoping: { user_id: self.id }, universe_id: nil ) - return {} if content_types.empty? + # return content_with_multiple_queries(content_types: content_types, page_scoping: page_scoping, universe_id: universe_id) - polymorphic_content_fields = [:id, :name, :page_type, :user_id, :created_at, :updated_at, :deleted_at, :archived_at, :privacy] + return {} if content_types.empty? + # TODO: we should return early if we already have @content_by_page_type!!! + + polymorphic_content_fields = ContentPage.polymorphic_content_fields where_conditions = page_scoping.map { |key, value| "#{key} = #{value}" }.join(' AND ') + ' AND deleted_at IS NULL AND archived_at IS NULL' sql = content_types.uniq.map do |page_type| @@ -67,7 +98,7 @@ module HasContent ) # todo we can't select for universe_id here which kind of sucks, so we need to research 1) the repercussions, 2) what to do instead - polymorphic_content_fields = [:id, :name, :page_type, :user_id, :created_at, :updated_at, :deleted_at, :archived_at, :privacy] + polymorphic_content_fields = ContentPage.polymorphic_content_fields where_conditions = page_scoping.map { |key, value| "#{key} = #{value}" }.join(' AND ') + ' AND deleted_at IS NULL AND archived_at IS NULL' sql = content_types.uniq.map do |page_type| diff --git a/app/models/page_types/content_page.rb b/app/models/page_types/content_page.rb index 41581e4a..c476d831 100644 --- a/app/models/page_types/content_page.rb +++ b/app/models/page_types/content_page.rb @@ -1,9 +1,16 @@ class ContentPage < ApplicationRecord + include Rails.application.routes.url_helpers + belongs_to :user belongs_to :universe + attr_accessor :favorite + + include Authority::Abilities + self.authorizer_name = 'ContentPageAuthorizer' + def random_image_including_private(format: :small) - ImageUpload.where(content_type: self.page_type, content_id: self.id).sample.try(:src, format) || "card-headers/#{self.page_type.downcase.pluralize}.jpg" + ImageUpload.where(content_type: self.page_type, content_id: self.id).sample.try(:src, format) || "/assets/card-headers/#{self.page_type.downcase.pluralize}.jpg" end def icon @@ -17,4 +24,20 @@ class ContentPage < ApplicationRecord def text_color self.page_type.constantize.text_color end + + def favorite? + !!favorite + end + + def view_path + send("#{self.page_type.downcase}_path", self.id) + end + + def edit_path + send("edit_#{self.page_type.downcase}_path", self.id) + end + + def self.polymorphic_content_fields + [:id, :name, :favorite, :page_type, :user_id, :created_at, :updated_at, :deleted_at, :archived_at, :privacy] + end end diff --git a/app/models/timelines/timeline_event.rb b/app/models/timelines/timeline_event.rb index fc68f032..9cbe6c9e 100644 --- a/app/models/timelines/timeline_event.rb +++ b/app/models/timelines/timeline_event.rb @@ -1,7 +1,7 @@ class TimelineEvent < ApplicationRecord acts_as_paranoid - belongs_to :timeline + belongs_to :timeline, touch: true has_many :timeline_event_entities, dependent: :destroy diff --git a/app/models/users/user.rb b/app/models/users/user.rb index f9f3448a..9c7583af 100644 --- a/app/models/users/user.rb +++ b/app/models/users/user.rb @@ -119,10 +119,6 @@ class User < ApplicationRecord @cached_user_contributable_universes ||= Universe.where(id: contributable_universe_ids) end - def linkable_universes - @cached_linkable_universes ||= Universe.where(id: my_universe_ids + contributable_universes) - end - def contributable_universe_ids # TODO: email confirmation needs to happen for data safety / privacy (only verified emails) @contributable_universe_ids ||= Contributor.where('email = ? OR user_id = ?', self.email, self.id).pluck(:universe_id) @@ -156,7 +152,7 @@ class User < ApplicationRecord universe_id IN (#{(my_universe_ids + contributable_universe_ids + [-1]).uniq.join(',')}) OR (universe_id IS NULL AND user_id = #{self.id.to_i}) - """) + """).includes([:user]) end def linkable_timelines diff --git a/app/services/field_type_service.rb b/app/services/field_type_service.rb index 19f7b197..0d5798e0 100644 --- a/app/services/field_type_service.rb +++ b/app/services/field_type_service.rb @@ -15,4 +15,13 @@ class FieldTypeService < Service raise "Unexpected/unhandled field type: #{field[:type]}" end end + + def self.form_path_from_attribute_field(field) + field_data = { + type: field.field_type, + internal_id: field.id + } + + form_path(field_data) + end end diff --git a/app/services/permission_service.rb b/app/services/permission_service.rb index 654e123c..bfc15d90 100644 --- a/app/services/permission_service.rb +++ b/app/services/permission_service.rb @@ -5,7 +5,7 @@ class PermissionService < Service end def self.user_owns_content?(user:, content:) - content.user && user && content.user.try(:id) == user.try(:id) + content.user && user && content.try(:user_id) == user.try(:id) end def self.user_owns_any_containing_universe?(user:, content:) @@ -27,7 +27,11 @@ class PermissionService < Service def self.user_can_contribute_to_containing_universe?(user:, content:) return false if user.nil? return true if [AttributeCategory, AttributeField, Attribute].include?(content.class) #todo audit this - content.universe.present? && user.contributable_universes.pluck(:id).include?(content.universe.id) + + return true if user.contributable_universe_ids.include?(content.universe_id) + return true if user.universes.pluck(:id).include?(content.universe_id) + + return false end def self.content_has_no_containing_universe?(content:) diff --git a/app/services/serendipitous_service.rb b/app/services/serendipitous_service.rb index 0f81cb3d..eb77ea37 100644 --- a/app/services/serendipitous_service.rb +++ b/app/services/serendipitous_service.rb @@ -4,7 +4,7 @@ class SerendipitousService < Service categories_for_this_type = AttributeCategory.where( user: content.user, - entity_type: content.class.name.downcase, + entity_type: content.page_type.downcase, hidden: [nil, false] ) @@ -25,7 +25,7 @@ class SerendipitousService < Service #raise fields_for_these_categories.pluck(:label).inspect attribute_fields_with_values = Attribute.where( - entity_type: content.class.name, + entity_type: content.page_type, entity_id: content.id, attribute_field_id: fields_for_these_categories.pluck(:id) ).where.not( diff --git a/app/services/temporary_field_migration_service.rb b/app/services/temporary_field_migration_service.rb index e7b0e969..cdf4b19c 100644 --- a/app/services/temporary_field_migration_service.rb +++ b/app/services/temporary_field_migration_service.rb @@ -11,6 +11,7 @@ class TemporaryFieldMigrationService < Service def self.migrate_fields_for_content(content_model, user, force: false) return unless content_model.present? && user.present? return unless content_model.user == user + return if content_model.is_a?(ContentPage) return if !force && content_model.persisted? && content_model.created_at > 'May 1, 2018'.to_datetime return if !!content_model.columns_migrated_from_old_style? diff --git a/app/views/api/api_docs/index.html.erb b/app/views/api/api_docs/index.html.erb index 62964333..7481809a 100644 --- a/app/views/api/api_docs/index.html.erb +++ b/app/views/api/api_docs/index.html.erb @@ -100,7 +100,7 @@
- Gain full access to + Gain full access to millions of <% Rails.application.config.content_types[:all_non_universe].each do |type| %> <%= type.name.downcase.pluralize %>, <% end %> @@ -110,7 +110,7 @@

After a user authenticates your application, you'll have full access to integrate their worldbuilding pages into your app. - You can show them their characters, let them edit one of their existing creatures, pin their towns and landmarks to your maps, + You can show them their characters, let them import pictures from their locations, pin their towns and landmarks to your maps, create new items, and more — all without leaving your app.


diff --git a/app/views/cards/serendipitous/_content_question.html.erb b/app/views/cards/serendipitous/_content_question.html.erb index 182875c0..b02d7103 100644 --- a/app/views/cards/serendipitous/_content_question.html.erb +++ b/app/views/cards/serendipitous/_content_question.html.erb @@ -6,22 +6,25 @@ <% if defined?(field) && field.present? %>
  • -
    - help +
    + help <%= t( - "serendipitous_questions.attributes.#{content.class.name.downcase}.#{field.label.downcase}", - name: content.name_field_value, - default: "What is #{content.name_field_value}'s #{field.label.downcase}?" + "serendipitous_questions.attributes.#{content.page_type.downcase}.#{field.label.downcase}", + name: content.name, + default: "What is #{content.name}'s #{field.label.downcase}?" ) %>
    - <%= form_for content do |f| %> + <%= form_for content, url: FieldTypeService.form_path_from_attribute_field(field), method: :patch do |f| %> <%= hidden_field(:override, :redirect_path, value: redirect_path) if defined?(redirect_path) %> + <%= hidden_field_tag "entity[entity_id]", content.id %> + <%= hidden_field_tag "entity[entity_type]", content.page_type %> + <%= - render 'content/form/text_input', + render 'content/form/text_input_for_content_page', f: f, content: content, field: field @@ -32,14 +35,14 @@ <% end %> <% if include_quick_reference %> - <%= link_to content, class: 'entity-trigger sidenav-trigger orange white-text btn tooltipped', data: { target: "quick-reference-#{@content.class.name}-#{@content.id}", tooltip: "View this #{@content.class.name.downcase} without leaving this page" } do %> + <%= link_to content.view_path, class: 'entity-trigger sidenav-trigger orange white-text btn tooltipped', data: { target: "quick-reference-#{content.page_type}-#{content.id}", tooltip: "View this #{content.page_type.downcase} without leaving this page" } do %> vertical_split Quick-reference <% end %> <% end %> <% if !defined?(show_view_button) || !!show_view_button %> - <%= link_to content, class: "btn #{content.class.color} white-text tooltipped", target: '_new', data: { tooltip: "View this #{@content.class.name.downcase} in a new tab" } do %> - <%= content.class.icon %> + <%= link_to content.view_path, class: "btn #{content.color} white-text tooltipped", target: '_new', data: { tooltip: "View this #{content.name.downcase} in a new tab" } do %> + <%= content.icon %> View <% end %> <% end %> diff --git a/app/views/content/components/_list_filter_bar.html.erb b/app/views/content/components/_list_filter_bar.html.erb index ef00c91d..94b68d40 100644 --- a/app/views/content/components/_list_filter_bar.html.erb +++ b/app/views/content/components/_list_filter_bar.html.erb @@ -30,7 +30,7 @@
  • <% - linkable_universes_with_this_kind_of_content = current_user.linkable_universes.select do |universe| + linkable_universes_with_this_kind_of_content = @linkables_raw.fetch('Universe', []).select do |universe| @current_user_content.fetch(content_type.name, []).any? { |content| content.universe_id == universe.id } end %> diff --git a/app/views/content/form/_text_input_for_content_page.html.erb b/app/views/content/form/_text_input_for_content_page.html.erb new file mode 100644 index 00000000..00837ccb --- /dev/null +++ b/app/views/content/form/_text_input_for_content_page.html.erb @@ -0,0 +1,62 @@ +<% + content_name = content.page_type.downcase + + field_id = "#{content_name}_#{field.name}" + value = field.attribute_values.find_by( + user: content.user, + entity_type: content.page_type, + entity_id: content.id + ).try(:value) + + should_autocomplete = defined?(autocomplete) && !!autocomplete + should_autosave = defined?(autosave) && !!autosave +%> + +
    + <% unless defined?(show_label) && !show_label %> + <%= f.label field.id do %> + <%= field.label.present? ? field.label : ' ' %> + <% if defined?(autocomplete) && autocomplete %> + + offline_bolt + + <% end %> + <% end %> + <% end %> + <% + placeholder = I18n.translate "attributes.#{content_name}.#{field.label.downcase.gsub(/\s/, '_')}", + scope: :serendipitous_questions, + name: content.name || "this #{content_name}", + default: 'Write as little or as much as you want!' + %> + + <%= hidden_field_tag "field[name]", field[:id] %> + <%= + text_area_tag "field[value]", + value, + class: "js-can-mention-pages materialize-textarea" \ + + "#{' autocomplete js-autocomplete-' + field[:id].to_s if should_autocomplete}" \ + + "#{' autosave-closest-form-on-change' if should_autosave}", + placeholder: placeholder + %> +
    + +<% if defined?(autocomplete) && autocomplete %> + <%= content_for :javascript do %> + $(function() { + // This setTimeout is an unfortunate hack to ensure this runs after initializing materialize + setTimeout(function() { + console.log("Initializing autocomplete for #<%= "#{content_name}_#{field.label}" %>"); + + $('.js-autocomplete-<%= field.id.to_s %>').autocomplete({ + limit: 5, + data: { + <% autocomplete.each do |autocomplete_option| %> + "<%= autocomplete_option %>": null, + <% end %> + } + }); + }, 1000); + }); + <% end %> +<% end %> diff --git a/app/views/content/index.html.erb b/app/views/content/index.html.erb index 8a58a7d2..c76b2bb9 100644 --- a/app/views/content/index.html.erb +++ b/app/views/content/index.html.erb @@ -13,7 +13,7 @@
    <%= render partial: 'cards/serendipitous/content_question', locals: { content: @questioned_content, - field: @attribute_field_to_question + field: @attribute_field_to_question } %>
    <% end %> diff --git a/app/views/content/list/_cards.html.erb b/app/views/content/list/_cards.html.erb index a08b5ece..f0e04816 100644 --- a/app/views/content/list/_cards.html.erb +++ b/app/views/content/list/_cards.html.erb @@ -4,14 +4,7 @@
    <%= render partial: 'content/display/favorite_control', locals: { content: content } %> - <% content_image = asset_path("card-headers/#{content_type.name.downcase.pluralize}.jpg") %> - <% if content.respond_to?(:image_uploads) %> - <% images = content.image_uploads %> - <% if images.any? %> - <% content_image = images.sample.src(:medium) %> - <% end %> - <% end %> -
    +
    @@ -35,13 +28,13 @@
    <% if current_user.can_update?(content) %> - <%= link_to edit_polymorphic_path(content), class: 'green-text right', target: content.is_a?(Document) ? '_new' : '_self' do %> + <%= link_to content.edit_path, class: 'green-text right', target: content.is_a?(Document) ? '_new' : '_self' do %> <%= content_type.icon %> Edit <% end %> <% end %> <% if current_user.can_read?(content) %> - <%= link_to polymorphic_path(content), class: 'blue-text text-lighten-1' do %> + <%= link_to content.view_path, class: 'blue-text text-lighten-1' do %> <%= content_type.icon %> View <% end %> diff --git a/app/views/documents/index.html.erb b/app/views/documents/index.html.erb index d89f01df..3992c228 100644 --- a/app/views/documents/index.html.erb +++ b/app/views/documents/index.html.erb @@ -1,11 +1,11 @@ <% if @universe_scope %> -

    +

    Only showing documents - in the <%= link_to @universe_scope.name, @universe_scope, class: Universe.color + '-text' %> universe. + in the <%= link_to @universe_scope.name, @universe_scope, class: Universe.text_color %> universe.  <%= link_to( - "See documents from all universes.", + "See documents from all universes instead.", '?universe=all', - class: Universe.color + '-text') + class: Universe.text_color) %>

    <% end %> diff --git a/app/views/main/dashboard.html.erb b/app/views/main/dashboard.html.erb index b6b52bcc..692d507d 100644 --- a/app/views/main/dashboard.html.erb +++ b/app/views/main/dashboard.html.erb @@ -72,14 +72,16 @@ your worlds <% end %>
    - <% if @content %> - <%= render partial: 'cards/serendipitous/content_question', locals: { - content: @content, - field: @attribute_field_to_question, - expand_by_default: true, - include_quick_reference: false - } %> - <% end %> + <%= + if @content + render partial: 'cards/serendipitous/content_question', locals: { + content: @content, + field: @attribute_field_to_question, + expand_by_default: true, + include_quick_reference: false + } + end + %> <%= link_to prompts_path do %>
    diff --git a/app/views/main/prompts.html.erb b/app/views/main/prompts.html.erb index d0d4585b..ac6a01cf 100644 --- a/app/views/main/prompts.html.erb +++ b/app/views/main/prompts.html.erb @@ -12,7 +12,8 @@ redirect_path: prompts_path, show_empty_prompt: true, expand_by_default: true, - show_view_button: false + show_view_button: false, + include_quick_reference: false } %>

    @@ -23,8 +24,8 @@ <% if @attribute_field_to_question.present? %>

    - <%= link_to @content, class: 'entity-trigger sidenav-trigger', data: { target: "quick-reference-#{@content.class.name}-#{@content.id}"} do %> -
    + <%= link_to @content.view_path, class: 'entity-trigger sidenav-trigger', data: { target: "quick-reference-#{@content.page_type}-#{@content.id}"} do %> +
    vertical_split Quick-reference <%= @content.name %> diff --git a/app/views/main/recent_content.html.erb b/app/views/main/recent_content.html.erb index a3014da2..25f47f98 100644 --- a/app/views/main/recent_content.html.erb +++ b/app/views/main/recent_content.html.erb @@ -1,5 +1,9 @@ <%# TODO: put this in more of a timeline design %>
    +
    +
    +
    Your recent worldbuilding activity
    +
    <% @recently_edited_pages.each do |page| %> <% action = page.created_at === page.updated_at ? 'Created' : 'Updated' %> <% klass = page.is_a?(ContentPage) ? content_class_from_name(page.page_type) : page.class %> diff --git a/app/views/prompts/_smart_sidebar.html.erb b/app/views/prompts/_smart_sidebar.html.erb index e72ea6d7..0edd19fb 100644 --- a/app/views/prompts/_smart_sidebar.html.erb +++ b/app/views/prompts/_smart_sidebar.html.erb @@ -1,16 +1,25 @@ -<%# todo extract "sidebar" and call it with @content, then also do the same in documents/components/smart_sidebar %> -<% serialized_entity = ContentSerializer.new(content) %> +<%# + This sidebar partial uses instantiated ContentPage models instead of the persisted content models (Character, Location, etc) -
      + TODO: merge this with documents/components/smart_sidebar +%> + +<%# todo extract "sidebar" and call it with @content, then also do the same in documents/components/smart_sidebar %> +<% + raw_model = content.page_type.constantize.find_by(id: content.id, user: current_user) + serialized_entity = ContentSerializer.new(raw_model) +%> + +
      • - <%= image_tag "card-headers/#{content.class.name.downcase.pluralize}.jpg", width: '100%' %> + <%= image_tag "card-headers/#{content.page_type.downcase.pluralize}.jpg", width: '100%' %>

        - - <%= content.class.icon %> + + <%= content.icon %> <%= content.name %>

        @@ -83,15 +92,15 @@
      • Actions
      • - <%= link_to polymorphic_path(content), class: "blue-text", target: '_new' do %> - <%= content.class.icon %> + <%= link_to content.view_path, class: "blue-text", target: '_new' do %> + <%= content.icon %> exit_to_app View <%= content.name %> <% end %>
      • - <%= link_to edit_polymorphic_path(content), class: "green-text" do %> - <%= content.class.icon %> + <%= link_to content.edit_path, class: "green-text" do %> + <%= content.icon %> exit_to_app Edit <%= content.name %> <% end %>