Merge branch 'master' into rails-6.1

This commit is contained in:
drusepth 2021-10-26 10:13:17 -07:00
commit 36fe52ab65
81 changed files with 2311 additions and 1122 deletions

View File

@ -3,13 +3,13 @@ ruby "~> 2.7"
# Server
gem 'rails'
gem 'puma', '~> 5.3'
gem 'puma', '~> 5.5'
gem 'puma-heroku'
# gem 'bootsnap', require: false
gem 'sprockets', '~> 3.7.2'
# Storage
gem 'aws-sdk', '~> 3.0'
gem 'aws-sdk', '~> 3.1'
gem 'aws-sdk-s3'
gem 'filesize'
@ -42,7 +42,6 @@ gem 'paranoia'
# Javascript
gem 'coffee-rails'
gem 'rails-jquery-autocomplete'
gem 'animate-rails'
gem 'webpacker'
gem 'react-rails'
@ -91,7 +90,7 @@ gem 'redis'
gem 'csv'
# Admin
gem 'rails_admin', '~> 2.1'
gem 'rails_admin', '~> 2.2'
# Tech debt & hacks
gem 'binding_of_caller' # see has_changelog.rb

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,4 @@
= Notebook.ai
= Notebook.ai
{<img src="https://codeclimate.com/github/indentlabs/notebook/badges/gpa.svg" />}[https://codeclimate.com/github/indentlabs/notebook]
{<img src="https://codeclimate.com/github/indentlabs/notebook/badges/coverage.svg" />}[https://codeclimate.com/github/indentlabs/notebook/coverage]
{<img src="http://inch-ci.org/github/indentlabs/notebook.svg?branch=master" alt="Inline docs" />}[http://inch-ci.org/github/indentlabs/notebook]
@ -34,54 +34,7 @@ TL;DR Milestones are independent of each other -- work on whatever you want to s
== Installing the notebook development stack locally
Install ruby 2.6.6 (using `rbenv`, `rvm`, any other Ruby version manager, or just plain ol' ruby)
rbenv install 2.6.6
Install necessary libraries
sudo apt install imagemagick libmagickwand-dev
sudo apt install libpq-dev
Clone the code
git clone git@github.com:indentlabs/notebook.git
Install gems
bundle install
Create database
rake db:create
Run initial database migrations
rake db:migrate
rake billing_plans:initialize_defaults
rake data_migrations:create_default_billing_plans
rake db:seed
Finally, run the server with
bundle exec rails server
You should now see a copy of the site running locally at http://localhost:3000/!
You can also run background workers with sidekiq (and can run specific queues with the `-q <queue_name>` flag):
bundle exec sidekiq -C config/sidekiq.yml
== Running the notebook stack locally with Docker
Please note that the Docker installation is managed by the community, so it may not always be up to date. I recommend installing the stack manually.
- install {Docker}[https://www.docker.com/products/overview]
- install {Docker Compose}[https://docs.docker.com/compose/install]
- clone this git repo
- cd into the root of this repo, and then run
docker-compose up
- You should now see a copy of the site running locally at http://localhost:3000/
Please see the {installation Guide}[https://github.com/indentlabs/notebook/wiki/Setup-Instructions] in the wiki for setup instructions.
== Testing

View File

@ -104,13 +104,35 @@ $(document).ready(function () {
// Replace this element's content with the name of the page
var tag = $(this);
$.get(
'/api/internal/' + tag.data('klass') + '/' + tag.data('id') + '/name'
).done(function (response) {
tag.find('.name-container').text(response);
}).fail(function() {
tag.find('.name-conainer').text("Unknown " + tag.data('klass'));
});
// Instantiate a cache for all page lookup queries (if not already created)
window.load_page_name_cache = window.load_page_name_cache || {};
var page_name_key = tag.data('klass') + '/' + tag.data('id');
if (page_name_key in window.load_page_name_cache) {
// If we've already made a request for this klass+id, we can just insta-load the
// cached result instead of requesting it again.
tag.find('.name-container').text(window.load_page_name_cache[page_name_key]);
} else {
// If we haven't made a request for this klass+id, look it up and cache it
$.get(
'/api/internal/' + page_name_key + '/name'
).done(function (response) {
tag.find('.name-container').text(response);
window.load_page_name_cache[page_name_key] = response;
// Go ahead and pre-fill all tags on the page for this klass+id, too
$('.js-load-page-name[data-klass=' + tag.data('klass') + '][data-id=' + tag.data('id') + ']')
.find('.name-container')
.text(response);
}).fail(function() {
tag.find('.name-container').text("Unknown " + tag.data('klass'));
});
}
});
});

View File

@ -0,0 +1,3 @@
# Place all the behaviors and hooks related to the matching controller here.
# All this logic will automatically be available in application.js.
# You can use CoffeeScript in this file: http://coffeescript.org/

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

@ -9,4 +9,5 @@
.card-panel-title {
font-size: 1.3em;
font-weight: bold;
margin-top: 0.5em;
}

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

@ -7,6 +7,7 @@ class DocumentAuthorizer < ApplicationAuthorizer
def readable_by?(user)
return true if user && resource.user_id == user.id
return true if user && user.site_administrator?
return true if resource.privacy == 'public'
return true if resource.universe.present? && resource.universe.privacy == 'public'
return true if user && resource.universe.present? && resource.universe.contributors.pluck(:user_id).include?(user.id)

View File

@ -1,3 +1,4 @@
# frozen_string_literal: true
class ApplicationController < ActionController::Base
protect_from_forgery
@ -27,7 +28,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 +37,115 @@ 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
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,69 @@ 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
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 }
# Add contributor content
if @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)
.where.not(user_id: current_user.id)
else
page_type.constantize.where(universe_id: @contributable_universe_ids)
.where.not(id: existing_page_ids)
.where.not(user_id: current_user.id)
end
# If we're scoped to a universe, also scope contributor content pulled to that
# universe. If we're not, leave it as all contributor content.
if @universe_scope && pages_to_add.klass.respond_to?(:universe)
pages_to_add = pages_to_add.where(universe: @universe_scope)
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

@ -26,7 +26,7 @@ class AttributeFieldsController < ContentController
return redirect_back fallback_location: root_path
end
if @attribute_field.update(attribute_field_params.merge({ migrated_from_legacy: true }))
if @attribute_field.update(content_params.merge({ migrated_from_legacy: true }))
@content = @attribute_field
successful_response(
@attribute_field,

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,49 +27,35 @@ 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(
page_type: @content_type_class.name,
page_id: @content.pluck(:id)
).order(:tag)
if params.key?(:tag)
@filtered_page_tags = @page_tags.where(slug: params[:tag])
if params.key?(:slug)
@filtered_page_tags = @page_tags.where(slug: params[:slug])
@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?
@ -126,6 +118,10 @@ class ContentController < ApplicationController
end
@content.save!
# If the user doesn't have this content type enabled, go ahead and automatically enable it for them
current_user.user_content_type_activators.find_or_create_by(content_type: @content.class.name)
return redirect_to edit_polymorphic_path(@content)
else
return redirect_to(subscription_path, notice: "#{@content.class.name.pluralize} require a Premium subscription to create.")
@ -194,6 +190,9 @@ class ContentController < ApplicationController
end
end
# If the user doesn't have this content type enabled, go ahead and automatically enable it for them
current_user.user_content_type_activators.find_or_create_by(content_type: content_type.name)
successful_response(content_creation_redirect_url, t(:create_success, model_name: @content.try(:name).presence || humanized_model_name))
else
failed_response('new', :unprocessable_entity, "Unable to save page. Error code: " + @content.errors.to_json.to_s)
@ -289,7 +288,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 +420,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 +455,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 +469,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 +493,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 +561,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 +637,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 +652,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

@ -115,6 +115,10 @@ class DataController < ApplicationController
@content = current_user.content
end
def tags
@tags = current_user.page_tags
end
def discussions
@topics = Thredded::Topic.where(user_id: current_user.id)
@posts = Thredded::Post.where(user_id: current_user.id)
@ -140,6 +144,9 @@ class DataController < ApplicationController
@share_link = "https://www.notebook.ai/?referral=#{current_user.referral_code.code}"
end
def green
end
private
def set_sidenav_expansion

View File

@ -4,12 +4,18 @@ 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]
before_action :set_navbar_actions, except: [:edit, :plaintext]
before_action :set_footer_visibility, only: [:edit]
# Skip UI-heavy calls for API endpoints
skip_before_action :cache_most_used_page_information, only: [:update]
skip_before_action :cache_forums_unread_counts, only: [:update]
# TODO: verify_user_can_read, verify_user_can_edit, etc before_actions instead of inlining them
before_action :cache_linkable_content_for_each_content_type, only: [:edit]
@ -181,21 +187,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
@ -35,13 +34,37 @@ class MainController < ApplicationController
def sascon
end
def paper
@navbar_color = '#4CAF50'
@total_notebook_pages = 0
@total_pages_equivalent = 0
@total_trees_saved = 0
@per_page_savings = {}
(Rails.application.config.content_types[:all] + [Timeline, Document]).each do |content_type|
physical_page_equivalent = GreenService.total_physical_pages_equivalent(content_type)
tree_equivalent = physical_page_equivalent.to_f / GreenService::SHEETS_OF_PAPER_PER_TREE
@per_page_savings[content_type.name] = {
digital: content_type.last.try(:id) || 0,
pages: physical_page_equivalent,
trees: tree_equivalent
}
@total_notebook_pages += @per_page_savings.dig(content_type.name, :digital)
@total_pages_equivalent += @per_page_savings.dig(content_type.name, :pages)
@total_trees_saved += @per_page_savings.dig(content_type.name, :trees)
end
end
def prompts
@sidenav_expansion = 'writing'
@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
@ -50,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
@ -80,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

@ -0,0 +1,23 @@
class PageTagsController < ApplicationController
# Remove a tag and all of its links to a page
def remove
# Params
# {"page_type"=>"Location", "slug"=>"mountains", "controller"=>"page_tags", "action"=>"remove"
return unless params.key?(:page_type) && params.key?(:slug)
PageTag.where(
page_type: params[:page_type],
slug: params[:slug],
user_id: current_user.id
).destroy_all
return redirect_back fallback_location: root_path, notice: 'Tag(s) deleted successfully.'
end
# Destroy a specific tag by ID
def destroy
PageTag.find_by(id: params[:id], user_id: current_user.id).destroy!
return redirect_back fallback_location: root_path, notice: 'Tag(s) deleted successfully.'
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

@ -0,0 +1,2 @@
module PageTagsHelper
end

View File

@ -0,0 +1,30 @@
class CacheAttributeWordCountJob < ApplicationJob
queue_as :cache
def perform(*args)
attribute_id = args.shift
attribute = Attribute.find_by(id: attribute_id)
return if attribute.nil?
return if attribute.value.nil? || attribute.value.blank?
word_count = WordCountAnalyzer::Counter.new(
ellipsis: 'no_special_treatment',
hyperlink: 'count_as_one',
contraction: 'count_as_one',
hyphenated_word: 'count_as_one',
date: 'no_special_treatment',
number: 'count',
numbered_list: 'ignore',
xhtml: 'remove',
forward_slash: 'count_as_multiple_except_dates',
backslash: 'count_as_one',
dotted_line: 'ignore',
dashed_line: 'ignore',
underscore: 'ignore',
stray_punctuation: 'ignore'
).count(attribute.value)
attribute.update!(word_count_cache: word_count)
end
end

View File

@ -0,0 +1,20 @@
class CacheSumAttributeWordCountJob < ApplicationJob
queue_as :cache
def perform(*args)
entity_type = args.shift
entity_id = args.shift
entity = entity_type.constantize.find_by(id: entity_id)
return if entity.nil?
sum_attribute_word_count = Attribute.where(entity_type: entity_type, entity_id: entity_id).sum(:word_count_cache)
update = entity.word_count_updates.find_or_initialize_by(
for_date: DateTime.current,
)
update.word_count = sum_attribute_word_count
update.user_id ||= entity.user_id
update.save!
end
end

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

@ -4,13 +4,21 @@ class SaveDocumentRevisionJob < ApplicationJob
def perform(*args)
document_id = args.shift
document = Document.find(document_id)
return unless document.present?
document = Document.find_by(id: document_id)
return unless document
# Update cached word count for the document regardless of how often this is called
new_word_count = document.computed_word_count
document.update(cached_word_count: new_word_count)
# Save a WordCountUpdate for this document for today
update = document.word_count_updates.find_or_initialize_by(
for_date: DateTime.current,
)
update.word_count = new_word_count
update.user_id ||= document.user_id
update.save!
# Make sure we're only storing revisions at least every 5 min
latest_revision = document.document_revisions.order('created_at DESC').limit(1).first
if latest_revision.present? && latest_revision.created_at > 5.minutes.ago

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

@ -9,23 +9,34 @@ module HasImageUploads
# todo: destroy from s3 on destroy
def public_image_uploads
self.image_uploads.where(privacy: 'public').presence || ["card-headers/#{self.class.name.downcase.pluralize}.jpg"]
self.image_uploads.where(privacy: 'public').presence || [header_asset_for(self.class.name)]
end
def private_image_uploads
self.image.uploads.where(privacy: 'private').presence || ["card-headers/#{self.class.name.downcase.pluralize}.jpg"]
self.image.uploads.where(privacy: 'private').presence || [header_asset_for(self.class.name)]
end
def random_image_including_private(format: :medium)
image_uploads.sample.try(:src, format).presence || "card-headers/#{self.class.name.downcase.pluralize}.jpg"
@random_image_including_private_cache ||= {}
key = self.class.name + self.id.to_s
return @random_image_including_private_cache[key] if @random_image_including_private_cache.key?(key)
result = image_uploads.sample.try(:src, format).presence || header_asset_for(self.class.name)
@random_image_including_private_cache[key] = result
result
end
def first_public_image(format: :medium)
public_image_uploads.first.try(:src, format).presence || "card-headers/#{self.class.name.downcase.pluralize}.jpg"
public_image_uploads.first.try(:src, format).presence || header_asset_for(self.class.name)
end
def random_public_image(format: :medium)
public_image_uploads.sample.try(:src, format).presence || "card-headers/#{self.class.name.downcase.pluralize}.jpg"
public_image_uploads.sample.try(:src, format).presence || header_asset_for(self.class.name)
end
def header_asset_for(class_name)
ActionController::Base.helpers.asset_path("card-headers/#{class_name.downcase.pluralize}.jpg")
end
end
end

View File

@ -4,6 +4,6 @@ module HasPageTags
extend ActiveSupport::Concern
included do
has_many :page_tags, as: :page
has_many :page_tags, as: :page, dependent: :destroy
end
end

View File

@ -18,6 +18,11 @@ module IsContentPage
has_many :timeline_events, through: :timeline_event_entities
has_many :timelines, -> { distinct }, through: :timeline_events
has_many :word_count_updates, as: :entity, dependent: :destroy
def latest_word_count_cache
word_count_updates.order('for_date DESC').limit(1).first.try(:word_count) || 0
end
scope :unarchived, -> { where(archived_at: nil) }
def archive!
update!(archived_at: DateTime.now)

View File

@ -26,6 +26,12 @@ class Document < ApplicationRecord
attr_accessor :tagged_text
# Duplicated from is_content_page since we don't include that here yet
has_many :word_count_updates, as: :entity, dependent: :destroy
def latest_word_count_cache
word_count_updates.order('for_date DESC').limit(1).first.try(:word_count) || 0
end
KEYS_TO_TRIGGER_REVISION_ON_CHANGE = %w(title body synopsis notes_text)
def self.color

View File

@ -51,7 +51,7 @@ class PageCollection < ApplicationRecord
end
# If all else fails, fall back on default header
"card-headers/#{self.class.name.downcase.pluralize}.jpg"
ActionController::Base.helpers.asset_path("card-headers/#{self.class.name.downcase.pluralize}.jpg")
end
def first_public_image

View File

@ -19,6 +19,16 @@ class Attribute < ApplicationRecord
end
end
after_commit do
if saved_changes.key?('value')
# Cache the updated word count on this attribute
CacheAttributeWordCountJob.perform_later(self.id)
# Cache the updated word count on the page this attribute belongs to
CacheSumAttributeWordCountJob.perform_later(self.entity_type, self.entity_id)
end
end
after_save do
entity.touch
end

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) || ActionController::Base.helpers.asset_path("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

@ -20,7 +20,7 @@ class Creature < ApplicationRecord
include Serendipitous::Concern
include Authority::Abilities
self.authorizer_name = 'ExtendedContentAuthorizer'
self.authorizer_name = 'CoreContentAuthorizer'
# Locations
relates :habitats, with: :wildlifeships

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,13 +119,12 @@ 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)
@contributable_universe_ids += Contributor.where(universe_id: my_universe_ids).pluck(:universe_id)
@contributable_universe_ids.uniq
end
# TODO: rename this to #{content_type}_shared_with_me
@ -156,7 +155,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

@ -0,0 +1,4 @@
class WordCountUpdate < ApplicationRecord
belongs_to :user
belongs_to :entity, polymorphic: true
end

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

@ -0,0 +1,107 @@
class GreenService < Service
AVERAGE_WORDS_PER_PAGE = 500
AVERAGE_TIMELINE_EVENTS_PER_PAGE = 3
SHEETS_OF_PAPER_PER_TREE = 11_000 # source: https://ribble-pack.co.uk/blog/much-paper-comes-one-tree
def self.physical_pages_equivalent_for(worldbuilding_page_type)
# TODO: This would be better estimated with [average] word counts from pages (or a real total),
# but we don't have that data computed (and definitely don't want to do so on each page load).
# Until we have a better solution, these page counts come from printing out notebook pages
# from http://www.notebook-paper.com/
case worldbuilding_page_type
when "Universe" then 2
when "Character" then 6
when "Location" then 4
when "Item" then 2
when "Building" then 8
when "Condition" then 4
when "Continent" then 6
when "Country" then 5
when "Creature" then 8
when "Deity" then 5
when "Flora" then 4
when "Food" then 5
when "Government" then 6
when "Group" then 4
when "Job" then 4
when "Landmark" then 3
when "Language" then 5
when "Lore" then 8
when "Magic" then 4
when "Planet" then 6
when "Race" then 4
when "Religion" then 3
when "Scene" then 2
when "School" then 6
when "Sport" then 4
when "Technology" then 4
when "Town" then 4
when "Tradition" then 3
when "Vehicle" then 4
else
raise "Unknown green estimate: #{worldbuilding_page_type}"
end
end
def self.trees_saved_by(worldbuilding_page_type)
(physical_pages_equivalent_for(worldbuilding_page_type) * (worldbuilding_page_type.constantize.last.try(:id) || 0)) / SHEETS_OF_PAPER_PER_TREE.to_f
end
def self.total_document_pages_equivalent
total_pages = 0
# Treat all <1-page documents as 1 page per document, since they'd print on separate pages
total_pages += Document.with_deleted.where('cached_word_count <= ?', AVERAGE_WORDS_PER_PAGE).count
# For all >1-page documents, do a quick estimate of word count sum + num docs to also cover EOD page breaks
docs = Document.with_deleted.where.not(cached_word_count: nil).where('cached_word_count > ?', AVERAGE_WORDS_PER_PAGE)
total_pages += (docs.sum(:cached_word_count) / AVERAGE_WORDS_PER_PAGE.to_f).round
total_pages += docs.count
total_pages
end
def self.total_timeline_pages_equivalent
((TimelineEvent.last.try(:id) || 0) / AVERAGE_TIMELINE_EVENTS_PER_PAGE.to_f).to_i
end
def self.total_physical_pages_equivalent(content_type)
case content_type.name
when 'Timeline'
GreenService.total_timeline_pages_equivalent
when 'Document'
GreenService.total_document_pages_equivalent
else
GreenService.physical_pages_equivalent_for(content_type.name) * (content_type.last.try(:id) || 0)
end
end
def self.total_pages_saved_by(user)
total_pages = 0
user.content.each do |content_type, content_list|
physical_page_equivalent_for_content_type = case content_type
when 'Timeline'
AVERAGE_TIMELINE_EVENTS_PER_PAGE * TimelineEvent.where(timeline_id: content_list.map(&:id)).count
when 'Document'
[
content_list.inject(0) { |sum, doc| sum + (doc.cached_word_count || 0) } / GreenService::AVERAGE_WORDS_PER_PAGE.to_f,
content_list_count
].max
else
physical_pages_equivalent_for(content_type) * content_list.count
end
total_pages += physical_page_equivalent_for_content_type
end
total_pages
end
def self.total_trees_saved_by(user)
total_pages_saved_by(user).to_f / GreenService::SHEETS_OF_PAPER_PER_TREE
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,8 +30,8 @@
</li>
<li class="divider" tabindex="-1"></li>
<%
linkable_universes_with_this_kind_of_content = current_user.linkable_universes.select do |universe|
@current_user_content[content_type.name].any? { |content| content.universe_id == universe.id }
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
%>
<% linkable_universes_with_this_kind_of_content.each do |universe| %>
@ -60,7 +60,7 @@
</a>
<ul id='tag-filter-dropdown' class='dropdown-content'>
<li>
<%= link_to params.permit(:tag, :favorite_only).merge({ tag: nil }), class: "#{content_type.text_color}" do %>
<%= link_to params.permit(:tag, :favorite_only).merge({ slug: nil }), class: content_type.text_color do %>
<i class="material-icons"><%= PageTag.icon %></i>
All <%= content_type.name.downcase.pluralize %>
<% end %>
@ -69,7 +69,7 @@
<% if @page_tags %>
<% @page_tags.each do |page_tag| %>
<li>
<%= link_to params.permit(:tag, :favorite_only).merge({ tag: PageTagService.slug_for(page_tag.tag) }), class: "#{content_type.text_color}" do %>
<%= link_to params.permit(:tag, :favorite_only).merge({ slug: PageTagService.slug_for(page_tag.tag) }), class: content_type.text_color do %>
<i class="material-icons"><%= PageTag.icon %></i>
<small class="grey-text">tagged</small>
<%= page_tag.tag %>

View File

@ -5,7 +5,6 @@
changed_fields = AttributeField.where(id: changed_attributes.pluck(:attribute_field_id)).includes([:attribute_category])
%>
<div class="row">
<div class="col s12 m12 l10 offset-l1">
<h1 class="flow-text grey-text">
@ -72,10 +71,14 @@
<span>
<%= link_to(change_event.user.display_name, change_event.user, class: "#{User.text_color}") %>
</span>
<div class="grey-text right"><%= time_ago_in_words change_event.created_at %> ago</div>
<div class="grey-text right">
<%= time_ago_in_words change_event.created_at %> ago
&middot;
<%= change_event.created_at.strftime('%B %d, %H:%M %Z') %>
</div>
</div>
</div>
<div style="margin-bottom: 4em">
<div style="margin-bottom: 4em" class="black-text">
<%=
render partial: "content/changelog/field_change/#{related_field.field_type}",
locals: { old_value: old_value, new_value: new_value }

View File

@ -9,8 +9,12 @@
on js-load-page-name to fetch & load in post-page-load if the content isn't ours.
%>
<%
content = @current_user_content.fetch(klass, []).detect do |page|
page.page_type === klass && page.id === id.to_i
content = if user_signed_in?
@current_user_content.fetch(klass, []).detect do |page|
page.page_type === klass && page.id === id.to_i
end
else
nil
end
%>

View File

@ -31,7 +31,7 @@
<%= link_to main_app.polymorphic_path(raw_model.class) do %>
<i class="material-icons left"><%= raw_model.class.icon %></i>
Your <%= raw_model.class.name.downcase.pluralize %>
<span class="badge hide-on-med-and-down"><%= @current_user_content[raw_model.class.name].count %></span>
<span class="badge hide-on-med-and-down"><%= @current_user_content.fetch(raw_model.class.name, []).count %></span>
<% end %>
</li>
<% else %>

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

@ -58,10 +58,10 @@
<% content.page_tags.each do |tag| %>
<% if user_signed_in? && content.user == current_user %>
<%= link_to params.permit(:tag).merge({ tag: PageTagService.slug_for(tag.tag) }) do %>
<span class="new badge <%= params[:tag] == tag.slug ? @content_type_class.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<span class="new badge <%= params[:tag] == tag.slug ? content.class.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<% end %>
<% else %>
<span class="new badge <%= params[:tag] == tag.slug ? @content_type_class.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<span class="new badge <%= params[:tag] == tag.slug ? content.class.color : 'grey' %> left" data-badge-caption="<%= tag.tag %>"></span>
<% end %>
<% end %>
<% end %>

View File

@ -2,10 +2,6 @@
TODO: everything here (probably) should move to the ContentSerializer so we don't have this
heavy logic in a random partial
%>
<%
relations = Rails.application.config.content_relations[content.class.name]
relations ||= []
%>
<div id="associations_panel" class="panel">
<%= render partial: 'notice_dismissal/messages/07' %>
@ -29,37 +25,5 @@
locals: { value: link_codes, content: content }
%>
<% end %>
<% if user_signed_in? %>
<div class="card-panel yellow lighten-5 black-text">
Notice: The newest Notebook.ai release is adding the ability to create your own link
fields, which includes a rather large migration of all existing link fields into a new linking system.
Links that haven't been migrated yet can be seen below this message; links on the new system appear above.
<br /><br />
Thank you for your patience during this large rewrite! This notice will automatically disappear after the
migration has completed for everyone.
<br /><br />
&mdash; Andrew
</div>
<% end %>
<%# TODO: remove these after finishing link migration script %>
<% relations.each do |name, params| %>
<%
results = params[:related_class].where("#{params[:through_relation].downcase}_id": content.id)
.map { |content| content.send(params[:inverse_class].downcase) }
.select { |content| content && content.readable_by?(current_user || User.new) }
%>
<% next unless results.any? %>
<div class="uppercase grey-text">
<%= params[:relation_text].to_s.titleize.downcase %> of
</div>
<%=
link_codes = results.map { |page| "#{page.page_type}-#{page.id}" }
render partial: "content/display/attribute_value/link",
locals: { value: link_codes, content: content }
%>
<% end %>
</div>
</div>

View File

@ -0,0 +1,186 @@
<h4 class="white-text">
<%= link_to data_vault_path, class: 'grey-text tooltipped', style: 'position: relative; top: 4px;', data: {
position: 'bottom',
enterDelay: '500',
tooltip: "Back to your Data Vault"
} do %>
<i class="material-icons blue-text text-lighten-3">arrow_back</i>
<% end %>
Your paper footprint
</h4>
<%
total_pages_equivalent = 0
%>
<div class="row">
<div class="col s12">
<div class="card-panel">
All of the added functionality of Notebook.ai over a traditional paper notebook isn't the only benefit of going digital. Backing your ideas up in the cloud
instead of on paper also saves trees!
</div>
</div>
<div class="col s12">
<div class="card">
<div class="card-content">
<div class="card-title">Your personal paper footprint</div>
<table>
<thead>
<tr>
<th>Digital content</th>
<th>Equivalent physical pages</th>
<th>Equivalent trees</th>
</tr>
</thead>
<tbody>
<% @current_user_content.each do |content_type, content_list| %>
<%
content_list_count = content_list.count
physical_page_equivalent = case content_type
when 'Timeline'
GreenService::AVERAGE_TIMELINE_EVENTS_PER_PAGE * TimelineEvent.where(timeline_id: content_list.map(&:id)).count
when 'Document'
[
content_list.inject(0) { |sum, doc| sum + (doc.cached_word_count || 0) } / GreenService::AVERAGE_WORDS_PER_PAGE.to_f,
content_list_count
].max
else
GreenService.physical_pages_equivalent_for(content_type) * content_list_count
end
tree_equivalent = physical_page_equivalent.to_f / GreenService::SHEETS_OF_PAPER_PER_TREE
total_pages_equivalent += physical_page_equivalent
%>
<tr>
<td>
<i class="material-icons left <%= content_class_from_name(content_type).color %>-text">
<%= content_class_from_name(content_type).icon %>
</i>
<%= pluralize content_list_count, content_type.pluralize %>
</td>
<td>
<i class="material-icons left grey-text">copy_all</i>
<%= number_with_delimiter physical_page_equivalent %>
<%= 'page'.pluralize physical_page_equivalent %>
</td>
<td>
<i class="material-icons left green-text">park</i>
<%= pluralize tree_equivalent.round(5), 'tree' %>
</td>
</tr>
<% end %>
<tr>
<td>Totals</td>
<td>
<strong>
<i class="material-icons left grey-text">copy_all</i>
<%= number_with_delimiter total_pages_equivalent.round %>
<%= 'page'.pluralize total_pages_equivalent %>
</strong>
</td>
<td>
<strong>
<i class="material-icons left green-text">park</i>
<% trees_saved = total_pages_equivalent.to_f / GreenService::SHEETS_OF_PAPER_PER_TREE %>
<%= number_with_delimiter trees_saved.round(5) %>
<%= 'tree'.pluralize trees_saved %>
saved
</strong>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<%
total_pages_equivalent = 0
%>
<div class="row">
<div class="col s12">
<div class="card">
<div class="card-content">
<div class="card-title">Our community paper footprint</div>
<p>Across all Notebook.ai pages...</p>
<table>
<thead>
<tr>
<th>Digital content</th>
<th>Equivalent physical pages</th>
<th>Equivalent trees</th>
</tr>
</thead>
<tbody>
<% (Rails.application.config.content_type_names[:all] + ["Timeline", "Document"]).each do |content_type| %>
<%
content_list_count = content_class_from_name(content_type).last.try(:id) || 0
physical_page_equivalent = case content_type
when 'Timeline'
GreenService.total_timeline_pages_equivalent
when 'Document'
GreenService.total_document_pages_equivalent
else
GreenService.physical_pages_equivalent_for(content_type) * content_list_count
end
tree_equivalent = physical_page_equivalent.to_f / GreenService::SHEETS_OF_PAPER_PER_TREE
total_pages_equivalent += physical_page_equivalent
%>
<tr>
<td>
<i class="material-icons left <%= content_class_from_name(content_type).color %>-text">
<%= content_class_from_name(content_type).icon %>
</i>
<%= number_with_delimiter content_list_count %>
<%= content_type.pluralize content_list_count %>
</td>
<td>
<i class="material-icons left grey-text">copy_all</i>
<%= number_with_delimiter physical_page_equivalent %>
<%= 'page'.pluralize physical_page_equivalent %>
</td>
<td>
<i class="material-icons left green-text">park</i>
<%= number_with_delimiter tree_equivalent.round(5) %>
<%= 'tree'.pluralize tree_equivalent %>
</td>
</tr>
<% end %>
<tr>
<td>Totals</td>
<td>
<strong>
<i class="material-icons left grey-text">copy_all</i>
<%= number_with_delimiter total_pages_equivalent %>
<%= 'page'.pluralize total_pages_equivalent %>
</strong>
</td>
<td>
<strong>
<i class="material-icons left green-text">park</i>
<% trees_saved = total_pages_equivalent.to_f / GreenService::SHEETS_OF_PAPER_PER_TREE %>
<%= number_with_delimiter trees_saved.round(5) %>
<%= 'tree'.pluralize trees_saved %>
saved
</strong>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>

View File

@ -69,6 +69,20 @@
<% end %>
</div>
<div class="col s12 m12 l6">
<%= link_to tags_path, class: 'black-text' do %>
<div class="hoverable card purple lighten-5">
<div class="card-content">
<i class="material-icons right purple-text text-lighten-4"><%= PageTag.icon %></i>
<div class="card-title">Tag management</div>
<p>
Manage the tags you've used for your worldbuilding.
</p>
</div>
</div>
<% end %>
</div>
<div class="col s12 m12 l6">
<%= link_to notebook_export_path, class: 'black-text' do %>
<div class="hoverable card brown lighten-3">
@ -111,6 +125,20 @@
<% end %>
</div>
<div class="col s12 m12 l6">
<%= link_to green_path, class: 'black-text' do %>
<div class="hoverable card green lighten-2">
<div class="card-content">
<i class="material-icons right blue-text text-lighten-1">public</i>
<div class="card-title">Eco footprint</div>
<p>
How many trees have you saved by switching to digital?
</p>
</div>
</div>
<% end %>
</div>
<div class="col s12 m12 l6">
<%= link_to discussions_path, class: 'black-text' do %>
<div class="hoverable card blue lighten-4">
@ -182,7 +210,7 @@
</div>
<%# this could actually be a cool s12 banner if we flesh out the help center with guides and stuff %>
<div class="col s12 m12 l12">
<div class="col s12 m12 l6">
<%= link_to help_center_path, class: 'black-text' do %>
<div class="hoverable card pink lighten-4">
<div class="card-content">

View File

@ -14,7 +14,7 @@
<div class="col s12">
<div class="card-panel">
<p class="card-panel-title">
<i class="left material-icons orange-text">cake</i>
<i class="left material-icons large orange-text">cake</i>
You signed up to Notebook.ai on <%= current_user.created_at.strftime("%B %d of this year, a very fine %A") %>!
</p>
@ -32,7 +32,7 @@
<div class="col s12 m5">
<div class="card-panel">
<div class="card-panel-title">
<i class="material-icons left <%= @earliest_page.class.text_color %>">
<i class="material-icons left large <%= @earliest_page.class.text_color %>">
<%= @earliest_page.class.icon %>
</i>
It all started with <%= @earliest_page.name %>...
@ -45,9 +45,11 @@
</div>
</div>
<div class="col s12 m7">
<div class="card horizontal">
<div class="hoverable card horizontal">
<div class="card-image">
<%= image_tag @earliest_page.random_image_including_private %>
<%= link_to @earliest_page do %>
<%= image_tag @earliest_page.random_image_including_private %>
<% end %>
</div>
<div class="card-stacked">
<div class="card-content">
@ -75,8 +77,8 @@
<div class="col s12">
<%= render partial: 'content/components/parallax_header', locals: { content_type: 'Universe', content_class: Universe, image_only: true } %>
<div class="card-panel">
<i class="left material-icons large <%= Universe.text_color %>"><%= Universe.icon %></i>
<p class="card-panel-title">
<i class="left material-icons <%= Universe.text_color %>"><%= Universe.icon %></i>
You started creating <%= pluralize @created_content['Universe'].count, 'new universe' %> in <%= @year %>!
</p>
@ -103,8 +105,8 @@
<div class="row">
<div class="col s12">
<div class="card-panel">
<i class="material-icons left large blue-text">dashboard</i>
<div class="card-panel-title">
<i class="material-icons left blue-text">dashboard</i>
In your worlds, you created
<%= pluralize @total_created_non_universe_content, 'notebook pages' %>
this year!
@ -138,7 +140,10 @@
</div>
<div class="row">
<div class="col s12">
You also uploaded <%= pluralize @created_content['ImageUpload'].count, 'image' %> to your notebook pages this year!
<p class="right">
<i class="material-icons left">upload</i>
You also uploaded <%= pluralize @created_content['ImageUpload'].count, 'image' %> to your notebook pages this year!
</p>
</div>
</div>
</div>
@ -153,8 +158,8 @@
<%= render partial: 'content/components/parallax_header', locals: { content_type: 'Document', content_class: Document, image_only: true } %>
<div class="card-panel">
<i class="material-icons left large <%= Document.text_color %>"><%= Document.icon %></i>
<div class="card-panel-title">
<i class="material-icons left <%= Document.text_color %>"><%= Document.icon %></i>
You started writing <%= pluralize @created_content['Document'].count, 'document' %> in <%= @year %>!
</div>
<p>
@ -164,11 +169,11 @@
<% @created_content['Document'].each do |document| %>
<div class="col s12 m4 l3">
<%= link_to edit_document_path(document) do %>
<div class="hoverable card-panel fixed-card-content <%= Document.color %> white-text">
<i class="material-icons left"><%= Document.icon %></i>
<%= document.title %>
</div>
<%= link_to document_path(document) do %>
<div class="hoverable card-panel fixed-card-content <%= Document.color %> white-text">
<i class="material-icons left"><%= Document.icon %></i>
<%= document.title %>
</div>
<% end %>
</div>
<% end %>
@ -273,14 +278,14 @@
<div class="row">
<div class="col s12">
<div class="card-panel">
<i class="material-icons left large blue-text">date_range</i>
<div class="card-panel-title">
<i class="material-icons left blue-text">date_range</i>
<%= @year %> was a productive year for you!
</div>
<p>
Thanks for spending it on Notebook.ai. I can't wait to see what you accomplish in <%= @year + 1 %>! :)
</p>
<p>
<p style="text-align: right">
&mdash; Andrew
</p>
</div>

View File

@ -0,0 +1,82 @@
<%
showed_any_tags = false
%>
<h2 class="grey-text" style="font-size: 2rem">Your Notebook.ai tags</h2>
<ul class="collapsible">
<% Rails.application.config.content_types[:all].each do |content_type| %>
<%
grouped_tags = PageTag.where(page_type: content_type.name, user_id: current_user).order('tag ASC').group_by(&:tag)
next if grouped_tags.values.length == 0
showed_any_tags = true
%>
<li>
<div class="collapsible-header">
<i class="material-icons <%= content_type.text_color %>"><%= content_type.icon %></i>
<%= content_type.name %> tags
<span class="badge"><%= grouped_tags.values.length %></span>
</div>
<div class="collapsible-body">
<% grouped_tags.each do |tag, page_list| %>
<div class="row" style="border-bottom: 1px solid #ccc; padding-bottom: 2em; margin-bottom: 2em">
<div class="col s12 m6 l4">
<div>
<%=
link_to send(
"#{content_type.name.downcase.pluralize}_path",
slug: PageTagService.slug_for(tag)
) do
%>
<span class="<%= content_type.color %> white-text" style="padding: 0.3em 0.4em; font-size: 1em">
<%= tag %>
</span>
<% end %>
</div>
<div class="grey-text" style="padding-top: 1em">
Used by <%= pluralize page_list.length, 'page' %>
</div>
<div>
<%=
link_to 'Delete this tag', tag_remove_path(
page_type: content_type.name,
slug: PageTagService.slug_for(tag)
), data: {
confirm: "Are you sure? This will delete this tag and remove it from all pages."
}, class: 'red-text'
%>
</div>
</div>
<div class="col s12 m6 l8">
<% page_list.each do |page_tag| %>
<div class="chip js-load-page-name" data-klass="<%= page_tag.page_type %>" data-id="<%= page_tag.page_id %>">
<%= link_to send("#{page_tag.page_type.downcase}_path", page_tag.page_id) do %>
<span class="<%= content_type.text_color %>">
<i class="material-icons left">
<%= content_type.icon %>
</i>
</span>
<span class="name-container">
<em>Loading <%= content_type.name %> name...</em>
</span>
<% end %>
<%= link_to destroy_specific_tag_path(page_tag), method: :delete, class: 'tooltipped', data: { tooltip: 'Remove this tag from this page' }, remote: true do %>
<i class="close material-icons">close</i>
<% end %>
</div>
<% end %>
</div>
</div>
<% end %>
</div>
</li>
<% end %>
</ul>
<% if !showed_any_tags %>
<div class="card-panel">
When you create tags for your pages, they'll appear here. Come back later when you've added some!
</div>
<% end %>

View File

@ -81,4 +81,4 @@
</div>
</div>
</div>
</div>
</div>

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

@ -42,10 +42,13 @@
<div class="col s12 m4 l4">
<div class="card">
<div class="card-content spaced-paragraphs">
<h2 class="card-title">
<i class="material-icons green-text left">searchable</i>
Search your <%= pluralized_class_name.downcase %>
</h2>
<div class="center">
<i class="material-icons green-text large">searchable</i>
<h2 class="card-title">
Search your <%= pluralized_class_name.downcase %>
</h2>
</div>
<br />
<p>
If you've got a lot of <%= pluralized_class_name.downcase %>, you can quickly get around to the right
one with Notebook.ai's full-text search. It searches your entire notebook for anything you wrote about
@ -65,11 +68,13 @@
<div class="col s12 m4 l4">
<div class="card">
<div class="card-content spaced-paragraphs">
<h2 class="card-title">
<i class="material-icons left orange-text">lock</i>
Private by default
</h2>
<div class="center">
<i class="material-icons orange-text large">lock</i>
<h2 class="card-title">
Private by default
</h2>
</div>
<br />
<p>
Your ideas are valuable. Every <%= singular_class_name.downcase %> you create in Notebook.ai is owned by you, completely private by default, and only accessible to you.
</p>
@ -83,10 +88,13 @@
<div class="col s12 m4 l4">
<div class="card">
<div class="card-content spaced-paragraphs">
<h2 class="card-title">
<i class="material-icons left blue-text">star</i>
<%= (premium_page) ? 'Premium page' : 'Free page' %>
</h2>
<div class="center">
<i class="material-icons blue-text large">star</i>
<h2 class="card-title">
<%= (premium_page) ? 'Premium page' : 'Free page' %>
</h2>
</div>
<br />
<% if premium_page %>
<p>
<%= singular_class_name %> notebook pages require an active Premium subscription to create, but users on free plans can
@ -116,92 +124,100 @@
</div>
</div>
<div class="col s12">
<div class="col s12">&nbsp;<!-- spacer --></div>
<div class="col s12 m12 l5">
<div class="card">
<div class="card-content">
<h2 class="card-title">
<i class="material-icons <%= @content_type.text_color %> left">
<%= @content_type.icon %>
</i>
Get a head start with a rich
<span class="<%= @content_type.text_color %>"><%= singular_class_name.downcase %></span>
template
</h2>
<br />
<div class="row">
<div class="col s12 m12 l5">
<div class="spaced-paragraphs">
<p>
Templates on Notebook.ai are what help our unique worldbuilding system better understand your world.
</p>
<p>
You can fill out as little or as much as you'd like on every new <%= singular_class_name.downcase %>. You'll see
progress indicators every time you edit it to show where you can make progress on, and our system will
intelligently generate questions for you around the site that will automatically save your answers
to the proper place on your <%= singular_class_name.downcase %> page.
</p>
<p>
Templates are also fully customizable across every <%= singular_class_name.downcase %> in your notebook.
</p>
<p>
You can browse the default template for <%= pluralized_class_name.downcase %> here; click any category
to see its questions.
</p>
</div>
</div>
<div class="col s12 m12 l7">
<ul class="collapsible popout">
<%
YAML.load_file(Rails.root.join('config', 'attributes', "#{singular_class_name.downcase}.yml")).map do |category_name, category_details|
%>
<% next if category_name == :contributors %>
<li>
<div class="collapsible-header <%= @content_type.color %> darken-3 white-text">
<i class="material-icons"><%= category_details[:icon] %></i>
<%= category_details[:label] %>
</div>
<div class="collapsible-body">
<% if category_name == :gallery %>
<p>
This category lets you upload images to this <%= singular_class_name.downcase %>'s notebook page.
</p>
<br />
<p>
It's great if you have sketches or artwork for your <%= singular_class_name.downcase %>,
but also works well for collecting visual inspiration, too!
</p>
<% end %>
<% category_details.fetch(:attributes, []).each do |field| %>
<p>
<strong><%= field[:label] %></strong>
</p>
<p>
<% if field[:field_type] == 'link' || field[:field_type] == 'universe' %>
This field allows you to link your other Notebook.ai pages to this <%= singular_class_name.downcase %>.
<% elsif field[:field_type] == 'tags' %>
This field lets you add clickable tags to your <%= pluralized_class_name.downcase %>.
<% else %>
<%=
I18n.translate "attributes.#{singular_class_name.downcase}.#{field[:label].downcase.gsub(/\s/, '_')}",
scope: :serendipitous_questions,
name: "this #{singular_class_name.downcase}",
default: 'Write as little or as much as you want!'
%>
<br />
<span class="grey-text"><%= field[:description].try(:html_safe) %></span>
<% end %>
</p>
<br />
<% end %>
</div>
</li>
<%
end
%>
</ul>
</div>
<div class="spaced-paragraphs">
<p>
Templates on Notebook.ai are what help our unique worldbuilding system better understand your world.
</p>
<p>
You can fill out as little or as much as you'd like on every new <%= singular_class_name.downcase %>. You'll see
progress indicators every time you edit it to show where you can make progress on, and our system will
intelligently generate questions for you around the site that will automatically save your answers
to the proper place on your <%= singular_class_name.downcase %> page.
</p>
<p>
Templates are also fully customizable across every <%= singular_class_name.downcase %> in your notebook.
</p>
<p>
You can browse the default template for <%= pluralized_class_name.downcase %> here; click any category
to see its questions.
</p>
</div>
</div>
</div>
</div>
<div class="col s12 m12 l7">
<ul class="collapsible popout">
<%
YAML.load_file(Rails.root.join('config', 'attributes', "#{singular_class_name.downcase}.yml")).map do |category_name, category_details|
%>
<% next if category_name == :contributors %>
<li>
<div class="collapsible-header <%= @content_type.color %> darken-3 white-text">
<i class="material-icons"><%= category_details[:icon] %></i>
<%= category_details[:label] %>
</div>
<div class="collapsible-body">
<% if category_name == :gallery %>
<p>
This category lets you upload images to this <%= singular_class_name.downcase %>'s notebook page.
</p>
<br />
<p>
It's great if you have sketches or artwork for your <%= singular_class_name.downcase %>,
but also works well for collecting visual inspiration, too!
</p>
<% end %>
<% category_details.fetch(:attributes, []).each do |field| %>
<div>
<strong><%= field[:label] %></strong>
</div>
<div>
<% if field[:field_type] == 'link' || field[:field_type] == 'universe' %>
This field allows you to link your other Notebook.ai pages to this <%= singular_class_name.downcase %>.
<% elsif field[:field_type] == 'tags' %>
This field lets you add clickable tags to your <%= pluralized_class_name.downcase %>.
<% else %>
<%=
I18n.translate "attributes.#{singular_class_name.downcase}.#{field[:label].downcase.gsub(/\s/, '_')}",
scope: :serendipitous_questions,
name: "this #{singular_class_name.downcase}",
default: 'Write as little or as much as you want!'
%>
<br />
<span class="grey-text"><%= field[:description].try(:html_safe) %></span>
<% end %>
</div>
<br />
<% end %>
</div>
</li>
<%
end
%>
</ul>
</div>
<div class="col s12">&nbsp;<!-- spacer --></div>
<div class='col s12 grey-text uppercase center'><%= singular_class_name %> features</div>
<div class="col s12 m12 l6">
<div class="card">
<div class="card-content spaced-paragraphs">
@ -209,12 +225,13 @@
<i class="material-icons left red-text">photo_library</i>
Upload images to your <%= singular_class_name.downcase %>
</h2>
<br />
<p>
<%= pluralized_class_name %> and other worldbuilding pages all come with a dedicated area to upload and showcase your own uploaded images.
</p>
<p>
All users start out with 50MB of image storage space available for their notebook (usually around 250-500 images), but
Premium users receive an extra 10GB (10,000MB) of storage space for plenty of wiggle room when decking your <%= singular_class_name.downcase %>
Premium users receive an extra 10GB (10,000MB) of storage space for plenty of wiggle room when decking your <%= pluralized_class_name.downcase %>
out with all kinds of images.
</p>
<p>
@ -232,6 +249,7 @@
<i class="material-icons left <%= Universe.text_color %>">vpn_lock</i>
Focus on <%= pluralized_class_name.downcase %> from a single universe
</h2>
<br />
<p>
Building entire fictional worlds is hard, and it gets even harder when you've got ideas spanning multiple universes or worlds.
</p>
@ -254,6 +272,7 @@
<i class="material-icons left green-text">group_add</i>
Invite others to collaborate
</h2>
<br />
<p>
Notebook.ai lets you add an unlimited number of collaborators to your universes. Each one has full access to work alongside you
on your <%= pluralized_class_name.downcase %> and other pages within that universe.
@ -272,6 +291,7 @@
<i class="material-icons left white-text">brightness_4</i>
Work in light or dark mode
</h2>
<br />
<p>
Protect your eyes at night by choosing between light and dark themes at the click of a button, available across the entire website.
</p>
@ -292,6 +312,7 @@
<i class="material-icons left <%= @content_type.text_color %>"><%= @content_type.icon %></i>
Build your <%= singular_class_name.downcase %> piece by piece with personalized writing prompts
</h2>
<br />
<p>
Once you've started creating a <%= singular_class_name.downcase %> or two, you'll start noticing our worldbuilding tool asking you
personalized questions about those <%= pluralized_class_name.downcase %> around the site.
@ -310,7 +331,14 @@
<br />
Our users have created
<div class="h1-size <%= @content_type.text_color %>" style="margin: 0"><%= number_with_delimiter @content_type.last.try(:id) || 0 %></div>
<%= pluralized_class_name.downcase %> on Notebook.ai!
<%= pluralized_class_name.downcase %> on Notebook.ai
<div>
<%= link_to green_paper_path, class: 'green-text' do %>
and saved
<%= number_with_delimiter GreenService.trees_saved_by(singular_class_name).round(2) %>
trees!
<% end %>
</div>
</div>
</div>
<div class="col s12 m12 l4">
@ -342,22 +370,21 @@
<i class="material-icons <%= Document.text_color %> left"><%= Document.icon %></i>
Smoothly transition from worldbuilding to storytelling
</h2>
<br />
<div class="row">
<div class="col s12 m12 l6">
<div class="spaced-paragraphs">
<p>
Every account on Notebook.ai comes with unlimited document storage for you to bring your fictional worlds to life.
Every account on Notebook.ai comes with unlimited document storage and an integrated word processor for you to bring your fictional worlds to life.
<!-- todo link to document info page -->
</p>
<p>
There are a variety of writing tools for you to use, building on top of the foundation you lay from a world of <%= pluralized_class_name.downcase %>
Other integrated tools allow you to build upon the foundation you lay from a world of <%= pluralized_class_name.downcase %>
and other notebook pages. Each one offers new ways to enrich your world and write better stories within them.
</p>
<p>
Some ways you can use the writing tools in Notebook.ai with your worlds are listed here:
</p>
</div>
</div>
<div class="col s12 m12 l5 offset-l1">
<div class="col s12 m12 l6">
<ul>
<li class="clearfix" style="padding-bottom: 1em">
<i class="material-icons <%= Timeline.text_color %> left"><%= Timeline.icon %></i>
@ -391,6 +418,8 @@
</div>
</div>
<div class="col s12">&nbsp;<!-- spacer --></div>
<div class="col s12 center spaced-paragraphs">
<p>
<%= pluralized_class_name %> are just one of the <%= Rails.application.config.content_types[:all].count %> types of worldbuilding pages available

View File

@ -92,7 +92,7 @@
</a>
<div class="collapsible-body" style="">
<ul>
<% @current_user_content['Document'].each do |document| %>
<% @current_user_content.fetch('Document', []).each do |document| %>
<li>
<%= link_to edit_document_path(document), class: 'waves-effect tooltipped', data: { tooltip: "Last edited #{time_ago_in_words document.updated_at} ago", position: 'right' } do %>
<i class="material-icons <%= Document.text_color %>">

View File

@ -17,7 +17,7 @@
</a>
<div class="collapsible-body blue lighten-1">
<ul>
<% ((@current_user_content['Universe'] || []) + current_user.contributable_universes).sort_by(&:name).each do |universe| %>
<% @current_user_content.fetch('Universe', []).sort_by(&:name).each do |universe| %>
<li>
<%= link_to "?universe=#{universe.id}", class: 'waves-effect' do %>
<i class="material-icons <%= Universe.text_color %>">
@ -70,7 +70,7 @@
@universe_scope.send(pluralized_name).count
else
(
@current_user_content[content_type] || [] +
@current_user_content.fetch(content_type, []) +
current_user.send("contributable_#{pluralized_name}") +
(content_type_klass == Universe ? [] : content_type_klass.where(universe_id: current_user.universes.pluck(:id)))
).uniq.count

View File

@ -3,15 +3,11 @@
<div class="hoverable card <%= Document.color %>" style="height: 88px;">
<div class="card-content white-text">
<span class="card-title">
<span class="right white-text"><%= @current_user_content['Document'].count %></span>
<span class="right white-text"><%= @current_user_content.fetch('Document', []).count %></span>
<i class="material-icons left"><%= Document.icon %></i>
<span class="hide-on-large-only hide-on-small-only">Docs</span>
<span class="hide-on-med-and-down show-on-small">Documents</span>
</span>
<div style="margin: 0" class="center">
<strong>New</strong>: Folders, stats, and more</strong>
</div>
</div>
</div>
<% 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">
@ -125,7 +127,7 @@
<% end %>
<% else %>
<%
accessible_universes = ((@current_user_content['Universe'] || []) + current_user.contributable_universes)
accessible_universes = @current_user_content.fetch('Universe', []) + current_user.contributable_universes
if accessible_universes.count > 1
%>
<div class="grey-text uppercase center">
@ -184,8 +186,10 @@
<% end %>
<div class="grey-text uppercase center">Create something new</div>
<% shown_creatures = false %>
<% @activated_content_types.sample(3).each do |type| %>
<% klass = content_class_from_name(type) %>
<% shown_creatures = true if type == 'Creature' %>
<div>
<%= link_to send("new_#{type.downcase}_path"), class: "white-text", style: 'width: 100%' do %>
<div class="hoverable card-panel <%= klass.color %>" style="margin-bottom: 4px">
@ -196,7 +200,7 @@
<%= type %><br />
<small>
You've created
<%= pluralize (@current_user_content[type] || []).count, type.downcase %>
<%= pluralize @current_user_content.fetch(type, []).count, type.downcase %>
<% if @universe_scope %>
in this universe
<% end %>
@ -207,6 +211,24 @@
<% end %>
</div>
<% end %>
<br />
<div>
<%= link_to new_creature_path, class: "white-text", style: 'width: 100%' do %>
<div class="hoverable card-panel <%= Creature.color %>" style="margin-bottom: 4px">
<div class="valign-wrapper">
<i class="material-icons grey-bordered-text" class="left" style="font-size: 3em;"><%= Creature.icon %></i>
<span style="font-size: 1.1em; margin-left: 1em">
New Creature<br />
<small>
Free for all users in October
</small>
</span>
</div>
</div>
<% end %>
</div>
</div>
</div>

View File

@ -0,0 +1,140 @@
<div class="row">
<div class="col s12">
<div class="card">
<div class="card-content">
<%=
image_tag 'tristan/small.png', class: 'right hide-on-small-only', style: 'margin-left: 2em'
%>
<div class="card-title green-text text-darken-2">Going digital saves paper and trees!</div>
<br />
<p>
It's well-known that Notebook.ai offers a wide variety of features that just aren't
possible with traditional paper notebooks. However, those aren't the only benefits of
going digital!
</p>
<br />
<p>
Whether you're worldbuilding with our specialized notebook pages, outlining story
timelines, or writing your next novel, every page you create in Notebook.ai directly
contributes to less paper being used &mdash; and fewer trees cut down!
</p>
<div class="clearfix"></div>
</div>
</div>
</div>
<div class="col s12 m12 l4">
<div class="card-panel center green white-text">
<h1 style="margin: 0"><%= number_with_delimiter @total_notebook_pages %></h1>
digital ideas stored
</div>
</div>
<div class="col s12 m12 l4">
<div class="card-panel center green white-text">
<h1 style="margin: 0"><%= number_with_delimiter @total_pages_equivalent %></h1>
equivalent physical pages
</div>
</div>
<div class="col s12 m12 l4">
<div class="card-panel center green darken-1 white-text">
<i class="material-icons left large">park</i>
<h1 style="margin: 0"><strong><%= number_with_delimiter @total_trees_saved.round %></strong></h1>
trees saved
</div>
</div>
<div class="col s12 m12 l9 offset-l3">
<div class="card-panelf">
<p class="grey-text text-darken-1">
<i class="material-icons left green-text">park</i>
The average 40-foot pine tree typically yields between 10,000 and 20,000 notebook-quality sheets of paper.
For the estimations on this page, we're using a conservative estimate of
<%= number_with_delimiter GreenService::SHEETS_OF_PAPER_PER_TREE %> pages per tree.
</p>
</div>
</div>
</div>
<div class="row">
<div class="col s12">
<div class="card">
<div class="card-content">
<div class="card-title">Our community paper footprint</div>
<!-- good place for a little bit of copy -->
<table>
<thead>
<tr>
<th>Digital content</th>
<th>Equivalent physical pages</th>
<th>Equivalent trees</th>
</tr>
</thead>
<tbody>
<% (Rails.application.config.content_type_names[:all] + ["Timeline", "Document"]).each do |content_type| %>
<%
content_list_count = @per_page_savings.dig(content_type, :digital)
physical_page_equivalent = @per_page_savings.dig(content_type, :pages)
tree_equivalent = @per_page_savings.dig(content_type, :trees)
%>
<tr>
<td>
<i class="material-icons left <%= content_class_from_name(content_type).color %>-text">
<%= content_class_from_name(content_type).icon %>
</i>
<% if ["Timeline", "Document"].include?(content_type) %>
<%= number_with_delimiter content_list_count %>
<%= content_type.pluralize content_list_count %>
<% else %>
<%= link_to send("#{content_type.downcase}_worldbuilding_info_path"), class: 'black-text' do %>
<%= number_with_delimiter content_list_count %>
<%= content_type.pluralize content_list_count %>
<% end %>
<% end %>
</td>
<td>
<i class="material-icons left grey-text">copy_all</i>
<% if content_type == 'Timeline' %>
<% tooltip = "This estimate is based on the average timeline having approximately #{GreenService::AVERAGE_TIMELINE_EVENTS_PER_PAGE} detailed events per page when printed." %>
<% elsif content_type == 'Document' %>
<% tooltip = "This estimate is based on the total word count across all documents on Notebook.ai." %>
<% else %>
<% tooltip = "This estimate is based on the average #{content_type} page being approximately #{GreenService.physical_pages_equivalent_for(content_type)} physical pages long when printed." %>
<% end %>
<span class="tooltipped" data-tooltip="<%= tooltip %>" data-position="bottom">
<%= number_with_delimiter physical_page_equivalent %>
<%= 'page'.pluralize physical_page_equivalent %>
</span>
</td>
<td>
<i class="material-icons left green-text">park</i>
<%= number_with_delimiter tree_equivalent.round(5) %>
<%= 'tree'.pluralize tree_equivalent %>
</td>
</tr>
<% end %>
<tr>
<td>Totals</td>
<td>
<strong>
<i class="material-icons left grey-text">copy_all</i>
<%= number_with_delimiter @total_pages_equivalent %>
<%= 'page'.pluralize @total_pages_equivalent %>
</strong>
</td>
<td>
<strong>
<i class="material-icons left green-text">park</i>
<%= number_with_delimiter @total_trees_saved.round(5) %>
<%= 'tree'.pluralize @total_trees_saved %>
saved
</strong>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>

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

View File

@ -66,7 +66,7 @@ Rails.application.config.content_types = {
# Content types to label as "new" around the site
new: [
Lore
]
}

View File

@ -6,7 +6,7 @@ Rails.application.config.promos[:promo_bogo] = {}
Rails.application.config.promos[:promo_bogo][:start_date] = 'March 21, 2020'.to_date
Rails.application.config.promos[:promo_bogo][:end_date] = Rails.application.config.promos[:promo_bogo][:start_date] + 2.weeks
# Lore free during the month of April
# Lore free during the month of April, 2020
# Need to change Lore.rb authorizer at the end lol
if Date.current >= 'March 1, 2020'.to_date
if Date.current < 'April 1, 2020'.to_date
@ -14,3 +14,12 @@ if Date.current >= 'March 1, 2020'.to_date
Rails.application.config.content_types[:premium] -= [Lore]
end
end
# Lore free during the month of October
# Need to change Creature.rb authorizer at the end
if Date.current >= 'October 1, 2021'.to_date
if Date.current < 'November 1, 2021'.to_date
Rails.application.config.content_types[:free] << Creature
Rails.application.config.content_types[:premium] -= [Creature]
end
end

View File

@ -98,6 +98,9 @@ Rails.application.routes.draw do
get '/scratchpad', to: 'main#notes', as: :notes
get 'tag/remove', to: 'page_tags#remove'
delete 'tag/:id/destroy', to: 'page_tags#destroy', as: :destroy_specific_tag
# Legacy route: left intact so /my/documents/X URLs continue to work for everyone's bookmarks
resources :documents
@ -133,12 +136,14 @@ Rails.application.routes.draw do
scope '/data' do
get '/', to: 'data#index', as: :data_vault
get '/usage', to: 'data#usage'
get '/tags', to: 'data#tags'
get '/recyclebin', to: 'data#recyclebin'
get '/archive', to: 'data#archive'
get '/documents', to: 'data#documents', as: :data_documents
get '/uploads', to: 'data#uploads'
get '/discussions', to: 'data#discussions'
get '/collaboration', to: 'data#collaboration'
get '/green', to: 'data#green'
scope 'yearly' do
get '/', to: 'data#yearly_index', as: :year_in_review
get '/:year', to: 'data#review_year', as: :review_year
@ -167,6 +172,7 @@ Rails.application.routes.draw do
# Info pages
scope '/about' do
get '/paper', to: 'main#paper', as: :green_paper
get '/privacy', to: 'main#privacyinfo', as: :privacy_policy
end
@ -281,6 +287,8 @@ Rails.application.routes.draw do
scope '/worldbuilding' do
Rails.application.config.content_types[:all].each do |content_type|
get content_type.name.downcase.pluralize, to: "information##{content_type.name.downcase.pluralize}", as: "#{content_type.name.downcase}_worldbuilding_info"
# TODO: documents info page
# TODO: timelines info page
end
end

View File

@ -0,0 +1,7 @@
class RemoveServiceNameFromActiveStorageBlobs < ActiveRecord::Migration[6.0]
def up
if column_exists?(:active_storage_blobs, :service_name)
remove_column :active_storage_blobs, :service_name
end
end
end

View File

@ -0,0 +1,5 @@
class AddWordCountCacheToAttributes < ActiveRecord::Migration[6.0]
def change
add_column :attributes, :word_count_cache, :integer
end
end

View File

@ -0,0 +1,12 @@
class CreateWordCountUpdates < ActiveRecord::Migration[6.0]
def change
create_table :word_count_updates do |t|
t.references :user, null: false, foreign_key: true
t.references :entity, polymorphic: true, null: false
t.integer :word_count
t.date :for_date
t.timestamps
end
end
end

View File

@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
ActiveRecord::Schema.define(version: 2021_07_29_092030) do
ActiveRecord::Schema.define(version: 2021_10_07_234707) do
create_table "active_storage_attachments", force: :cascade do |t|
t.string "name", null: false
@ -172,6 +172,7 @@ ActiveRecord::Schema.define(version: 2021_07_29_092030) do
t.datetime "created_at"
t.datetime "updated_at"
t.datetime "deleted_at"
t.integer "word_count_cache"
t.index ["attribute_field_id", "deleted_at", "entity_id", "entity_type"], name: "attributes_afi_deleted_at_entity_id_entity_type"
t.index ["attribute_field_id", "deleted_at"], name: "index_attributes_on_attribute_field_id_and_deleted_at"
t.index ["attribute_field_id", "user_id", "entity_type", "entity_id", "deleted_at"], name: "attributes_afi_ui_et_ei_da"
@ -3643,6 +3644,18 @@ ActiveRecord::Schema.define(version: 2021_07_29_092030) do
t.integer "habitat_id"
end
create_table "word_count_updates", force: :cascade do |t|
t.integer "user_id", null: false
t.string "entity_type", null: false
t.integer "entity_id", null: false
t.integer "word_count"
t.date "for_date"
t.datetime "created_at", precision: 6, null: false
t.datetime "updated_at", precision: 6, null: false
t.index ["entity_type", "entity_id"], name: "index_word_count_updates_on_entity_type_and_entity_id"
t.index ["user_id"], name: "index_word_count_updates_on_user_id"
end
add_foreign_key "active_storage_attachments", "active_storage_blobs", column: "blob_id"
add_foreign_key "active_storage_variant_records", "active_storage_blobs", column: "blob_id"
add_foreign_key "api_keys", "users"
@ -4055,4 +4068,5 @@ ActiveRecord::Schema.define(version: 2021_07_29_092030) do
add_foreign_key "vehicles", "users"
add_foreign_key "votes", "users"
add_foreign_key "votes", "votables"
add_foreign_key "word_count_updates", "users"
end

View File

@ -1,4 +1,58 @@
namespace :backfill do
desc "Backfill cached word counts on all attributes"
task attribute_word_count_caches: :environment do
Attribute.where(word_count_cache: nil).where.not(value: ["", " ", ".", nil]).find_each do |attribute|
word_count = WordCountAnalyzer::Counter.new(
ellipsis: 'no_special_treatment',
hyperlink: 'count_as_one',
contraction: 'count_as_one',
hyphenated_word: 'count_as_one',
date: 'no_special_treatment',
number: 'count',
numbered_list: 'ignore',
xhtml: 'remove',
forward_slash: 'count_as_multiple_except_dates',
backslash: 'count_as_one',
dotted_line: 'ignore',
dashed_line: 'ignore',
underscore: 'ignore',
stray_punctuation: 'ignore'
).count(attribute.value)
attribute.update_column(:word_count_cache, word_count)
end
end
task :most_used_attribute_word_counts: :environment do
word_counts = {}
Attribute.where(word_count_cache: nil).group(:value).order('count_id DESC').limit(500).count(:id).each do |value, count|
word_count = WordCountAnalyzer::Counter.new(
ellipsis: 'no_special_treatment',
hyperlink: 'count_as_one',
contraction: 'count_as_one',
hyphenated_word: 'count_as_one',
date: 'no_special_treatment',
number: 'count',
numbered_list: 'ignore',
xhtml: 'remove',
forward_slash: 'count_as_multiple_except_dates',
backslash: 'count_as_one',
dotted_line: 'ignore',
dashed_line: 'ignore',
underscore: 'ignore',
stray_punctuation: 'ignore'
).count(value)
word_counts[word_count] ||= []
word_counts[word_count].push value
puts "#{value} x #{count}: #{word_count} words"
end
word_counts.each do |count, values|
Attribute.where(word_count_cache: nil, value: values).update_all(word_count_cache: count)
end
end
desc "Backfill cached word counts on all documents"
task document_word_count_caches: :environment do
Document.where(cached_word_count: nil).where.not(body: [nil, ""]).find_each(batch_size: 500) do |document|

View File

@ -1,4 +1,35 @@
namespace :one_off do
desc "Clean up orphaned page tags"
task clean_orphaned_page_tags: :environment do
PageTag.find_each do |page_tag|
referenced_page = page_tag.page
if referenced_page.nil?
page_tag.destroy
end
end
end
desc "Alert users who've saved at least one tree"
task trees_notification: :environment do
reference_code = 'green-trees'
User.find_each do |user|
trees = GreenService.total_trees_saved_by(user)
if trees >= 1
user.notifications.create!(
message_html: "<div>You've saved #{trees.round} tree#{'s' if trees.round > 1} by going digital!</div><div class='blue-text text-darken-3'>That's AWESOME! Click here to see how.</div>",
icon: 'park',
icon_color: 'green',
happened_at: DateTime.current,
passthrough_link: 'https://www.notebook.ai/my/data/green',
reference_code: reference_code
) unless user.notifications.where(reference_code: reference_code).any?
end
end
end
desc "Create a notification for all users telling them about the new notifications"
task notifications_announcement: :environment do
User.all.find_each do |user|

View File

@ -6,7 +6,7 @@
"@material-ui/lab": "^4.0.0-alpha.41",
"@rails/webpacker": "^5.2.1",
"@types/react": "^17.0.13",
"axios": "^0.21.1",
"axios": "^0.21.2",
"babel-plugin-transform-react-remove-prop-types": "^0.4.24",
"pluralize": "^8.0.0",
"prop-types": "^15.7.2",

View File

@ -0,0 +1,9 @@
require 'test_helper'
class PageTagsControllerTest < ActionDispatch::IntegrationTest
test "should get remove" do
get page_tags_remove_url
assert_response :success
end
end

15
test/fixtures/word_count_updates.yml vendored Normal file
View File

@ -0,0 +1,15 @@
# Read about fixtures at https://api.rubyonrails.org/classes/ActiveRecord/FixtureSet.html
one:
user: one
entity: one
entity_type: Entity
word_count: 1
for_date: 2021-10-07
two:
user: two
entity: two
entity_type: Entity
word_count: 1
for_date: 2021-10-07

View File

@ -0,0 +1,7 @@
require 'test_helper'
class WordCountUpdateTest < ActiveSupport::TestCase
# test "the truth" do
# assert true
# end
end

View File

@ -1478,12 +1478,12 @@ autoprefixer@^9.6.1:
postcss "^7.0.32"
postcss-value-parser "^4.1.0"
axios@^0.21.1:
version "0.21.1"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.1.tgz#22563481962f4d6bde9a76d516ef0e5d3c09b2b8"
integrity sha512-dKQiRHxGD9PPRIUNIWvZhPTPpl1rf/OxTYKsqKUDjBwYylTvV7SjSHJb9ratfyzM6wCdLCOYLzs73qpg5c4iGA==
axios@^0.21.2:
version "0.21.2"
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.2.tgz#21297d5084b2aeeb422f5d38e7be4fbb82239017"
integrity sha512-87otirqUw3e8CzHTMO+/9kh/FSgXt/eVDvipijwDtEuwbkySWZ9SBm6VEubmJ/kLKEoLQV/POhxXFb66bfekfg==
dependencies:
follow-redirects "^1.10.0"
follow-redirects "^1.14.0"
babel-loader@^8.2.2:
version "8.2.2"
@ -3084,10 +3084,10 @@ flush-write-stream@^1.0.0:
inherits "^2.0.3"
readable-stream "^2.3.6"
follow-redirects@^1.0.0, follow-redirects@^1.10.0:
version "1.14.1"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.1.tgz#d9114ded0a1cfdd334e164e6662ad02bfd91ff43"
integrity sha512-HWqDgT7ZEkqRzBvc2s64vSZ/hfOceEol3ac/7tKwzuvEyWx3/4UegXh5oBOIotkGsObyk3xznnSRVADBgWSQVg==
follow-redirects@^1.0.0, follow-redirects@^1.14.0:
version "1.14.4"
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.4.tgz#838fdf48a8bbdd79e52ee51fb1c94e3ed98b9379"
integrity sha512-zwGkiSXC1MUJG/qmeIFH2HBJx9u0V46QGUe3YR1fXG8bXQxq7fLj0RjLZQ5nubr9qNJUZrH+xUcwXEoXNpfS+g==
for-in@^1.0.2:
version "1.0.2"
@ -6584,9 +6584,9 @@ tapable@^1.0.0, tapable@^1.1.3:
integrity sha512-4WK/bYZmj8xLr+HUCODHGF1ZFzsYffasLUgEiMBY4fgtltdO6B4WJtlSbPaDTLpYTcGVwM2qLnFTICEcNxs3kA==
tar@^6.0.2:
version "6.1.0"
resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.0.tgz#d1724e9bcc04b977b18d5c573b333a2207229a83"
integrity sha512-DUCttfhsnLCjwoDoFcI+B2iJgYa93vBnDUATYEeRx6sntCTdN01VnqsIuTlALXla/LWooNg0yEGeB+Y8WdFxGA==
version "6.1.11"
resolved "https://registry.yarnpkg.com/tar/-/tar-6.1.11.tgz#6760a38f003afa1b2ffd0ffe9e9abbd0eab3d621"
integrity sha512-an/KZQzQUkZCkuoAA64hM92X0Urb6VpRhAFllDzz44U2mcD5scmT3zBc4VgVpkugF580+DQn8eAFSyoQt0tznA==
dependencies:
chownr "^2.0.0"
fs-minipass "^2.0.0"
@ -6841,9 +6841,9 @@ urix@^0.1.0:
integrity sha1-2pN/emLiH+wf0Y1Js1wpNQZ6bHI=
url-parse@^1.4.3, url-parse@^1.5.1:
version "1.5.1"
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.1.tgz#d5fa9890af8a5e1f274a2c98376510f6425f6e3b"
integrity sha512-HOfCOUJt7iSYzEx/UqgtwKRMC6EU91NFhsCHMv9oM03VJcVo2Qrp8T8kI9D7amFf1cu+/3CEhgb3rF9zL7k85Q==
version "1.5.3"
resolved "https://registry.yarnpkg.com/url-parse/-/url-parse-1.5.3.tgz#71c1303d38fb6639ade183c2992c8cc0686df862"
integrity sha512-IIORyIQD9rvj0A4CLWsHkBBJuNqWpFQe224b6j9t/ABmquIS0qDU2pY6kl6AuOrL5OkCXHMCFNe1jBcuAggjvQ==
dependencies:
querystringify "^2.1.1"
requires-port "^1.0.0"