Merge pull request #1003 from indentlabs/august-perf

Site-wide performance improvements
This commit is contained in:
Andrew Brown 2021-09-30 23:06:19 -07:00 committed by GitHub
commit e34a211813
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 488 additions and 210 deletions

View File

@ -42,7 +42,6 @@ gem 'paranoia'
# Javascript
gem 'coffee-rails'
gem 'rails-jquery-autocomplete'
gem 'animate-rails'
gem 'webpacker'
gem 'react-rails'

View File

@ -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

View File

@ -13,7 +13,6 @@
*= require font-awesome
*= require medium-editor/medium-editor
*= require medium-editor/themes/beagle
*= require animate
*= require tribute
*= require_tree .
*/

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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"

View File

@ -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)

View File

@ -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

View File

@ -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,

View File

@ -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|

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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:)

View File

@ -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(

View File

@ -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?

View File

@ -100,7 +100,7 @@
<div class="card col s12 m10 offset-m1 l8 offset-l2">
<div class="card-content">
<div class="card-title">
Gain full access to
Gain full access to millions of
<% Rails.application.config.content_types[:all_non_universe].each do |type| %>
<span class="<%= type.text_color %>"><%= type.name.downcase.pluralize %>,</span>
<% end %>
@ -110,7 +110,7 @@
<div class="col s12 m12 l6">
<p>
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 &mdash; all without leaving your app.
</p>
<br />

View File

@ -6,22 +6,25 @@
<% if defined?(field) && field.present? %>
<ul class="hoverable collapsible white">
<li class="<%= 'active' if defined?(expand_by_default) && !!expand_by_default %>">
<div class="collapsible-header <%= content.class.color %> white-text">
<i class="material-icons tooltipped" data-tooltip="Answer this randomly-generated question to have it automatically saved to your <%= content.name_field_value %> <%= content.class.name.downcase %> page.">help</i>
<div class="collapsible-header <%= content.color %> white-text">
<i class="material-icons tooltipped" data-tooltip="Answer this randomly-generated question to have it automatically saved to your <%= content.name %> <%= content.page_type.downcase %> page.">help</i>
<%=
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}?"
)
%>
</div>
<div class="collapsible-body">
<%= 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 %>
<i class="material-icons right">vertical_split</i>
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 %>
<i class="material-icons left white-text"><%= content.class.icon %></i>
<%= 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 %>
<i class="material-icons left white-text"><%= content.icon %></i>
View
<% end %>
<% end %>

View File

@ -30,7 +30,7 @@
</li>
<li class="divider" tabindex="-1"></li>
<%
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
%>

View File

@ -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
%>
<div class="input-field content-field">
<% unless defined?(show_label) && !show_label %>
<%= f.label field.id do %>
<%= field.label.present? ? field.label : ' ' %>
<% if defined?(autocomplete) && autocomplete %>
<i class="material-icons grey-text lighten-2 tooltipped" style="font-size: 100%" data-tooltip="This field may suggest some ideas for you when you start typing." data-position="right">
offline_bolt
</i>
<% 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
%>
</div>
<% 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 %>

View File

@ -13,7 +13,7 @@
<div class="col s12">
<%= render partial: 'cards/serendipitous/content_question', locals: {
content: @questioned_content,
field: @attribute_field_to_question
field: @attribute_field_to_question
} %>
</div>
<% end %>

View File

@ -4,14 +4,7 @@
<div class="hoverable card sticky-action" style="margin-bottom: 2px">
<div class="card-image waves-effect waves-block waves-light">
<%= 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 %>
<div class="activator" style="height: 265px; background: url('<%= content_image %>'); background-size: cover;"></div>
<div class="activator" style="height: 265px; background: url('<%= content.random_image_including_private(format: :medium) %>'); background-size: cover;"></div>
<span class="card-title js-content-name activator">
<div class="bordered-text">
@ -35,13 +28,13 @@
</div>
<div class="card-action">
<% 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 %>
<i class="material-icons left"><%= content_type.icon %></i>
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 %>
<i class="material-icons left"><%= content_type.icon %></i>
View
<% end %>

View File

@ -1,11 +1,11 @@
<% if @universe_scope %>
<p class="center help-text">
<p class="center help-text teal card-panel lighten-5 black-text">
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.&nbsp;
<%= link_to(
"See documents from all universes.",
"See documents from all universes instead.",
'?universe=all',
class: Universe.color + '-text')
class: Universe.text_color)
%>
</p>
<% end %>

View File

@ -72,14 +72,16 @@
your worlds
<% end %>
</div>
<% 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 %>
<div class="hoverable card-panel orange white-text" style="margin: 0; margin-bottom: 2em">

View File

@ -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
} %>
<p class="grey-text text-lighten-1 center">
@ -23,8 +24,8 @@
<% if @attribute_field_to_question.present? %>
<div class="col m12 l4">
<%= link_to @content, class: 'entity-trigger sidenav-trigger', data: { target: "quick-reference-#{@content.class.name}-#{@content.id}"} do %>
<div class="hoverable card <%= @content.class.color %>">
<%= link_to @content.view_path, class: 'entity-trigger sidenav-trigger', data: { target: "quick-reference-#{@content.page_type}-#{@content.id}"} do %>
<div class="hoverable card <%= @content.color %>">
<div class="card-content white-text">
<i class="material-icons right">vertical_split</i>
Quick-reference <%= @content.name %>

View File

@ -1,5 +1,9 @@
<%# TODO: put this in more of a timeline design %>
<div class="row">
<div class="col s12">
<br />
<div class="grey-text uppercase center">Your recent worldbuilding activity</div>
</div>
<% @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 %>

View File

@ -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)
<ul id="quick-reference-<%= content.class.name %>-<%= content.id %>" class="sidenav quick-reference-sidenav">
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)
%>
<ul id="quick-reference-<%= content.page_type %>-<%= content.id %>" class="sidenav quick-reference-sidenav">
<li>
<div class="user-view">
<div class="background">
<%= image_tag "card-headers/#{content.class.name.downcase.pluralize}.jpg", width: '100%' %>
<%= image_tag "card-headers/#{content.page_type.downcase.pluralize}.jpg", width: '100%' %>
</div>
<a href="#name">
<h1 class="white-text name bordered-text">
<i class="material-icons <%= content.class.text_color %> left">
<%= content.class.icon %>
<i class="material-icons <%= content.text_color %> left">
<%= content.icon %>
</i>
<%= content.name %>
</h1>
@ -83,15 +92,15 @@
<li><a href="#" class="subheader">Actions</a></li>
<li>
<%= link_to polymorphic_path(content), class: "blue-text", target: '_new' do %>
<i class="material-icons left <%= content.class.text_color %>"><%= content.class.icon %></i>
<%= link_to content.view_path, class: "blue-text", target: '_new' do %>
<i class="material-icons left <%= content.text_color %>"><%= content.icon %></i>
<i class="material-icons right grey-text">exit_to_app</i>
View <%= content.name %>
<% end %>
</li>
<li>
<%= link_to edit_polymorphic_path(content), class: "green-text" do %>
<i class="material-icons left <%= content.class.text_color %>"><%= content.class.icon %></i>
<%= link_to content.edit_path, class: "green-text" do %>
<i class="material-icons left <%= content.text_color %>"><%= content.icon %></i>
<i class="material-icons right grey-text">exit_to_app</i>
Edit <%= content.name %>
<% end %>