robin_cms 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +7 -0
- data/LICENSE.txt +21 -0
- data/README.md +183 -0
- data/lib/jekyll/commands/serve.rb +35 -0
- data/lib/robin_cms/auth.rb +39 -0
- data/lib/robin_cms/cms.rb +200 -0
- data/lib/robin_cms/collection_item.rb +82 -0
- data/lib/robin_cms/configuration-schema.json +116 -0
- data/lib/robin_cms/configuration.rb +181 -0
- data/lib/robin_cms/data_item.rb +88 -0
- data/lib/robin_cms/flash.rb +24 -0
- data/lib/robin_cms/helpers.rb +60 -0
- data/lib/robin_cms/item.rb +34 -0
- data/lib/robin_cms/itemable.rb +124 -0
- data/lib/robin_cms/queryable.rb +47 -0
- data/lib/robin_cms/sluggable.rb +22 -0
- data/lib/robin_cms/static_item.rb +92 -0
- data/lib/robin_cms/version.rb +5 -0
- data/lib/robin_cms/views/change_password.erb +22 -0
- data/lib/robin_cms/views/delete_dialog.erb +25 -0
- data/lib/robin_cms/views/error.erb +1 -0
- data/lib/robin_cms/views/filter_form.erb +37 -0
- data/lib/robin_cms/views/flash.erb +6 -0
- data/lib/robin_cms/views/hidden_field.erb +6 -0
- data/lib/robin_cms/views/image_field.erb +20 -0
- data/lib/robin_cms/views/input_field.erb +9 -0
- data/lib/robin_cms/views/layout.erb +59 -0
- data/lib/robin_cms/views/library.erb +70 -0
- data/lib/robin_cms/views/library_actions.erb +4 -0
- data/lib/robin_cms/views/library_item.erb +44 -0
- data/lib/robin_cms/views/login.erb +17 -0
- data/lib/robin_cms/views/logo.erb +82 -0
- data/lib/robin_cms/views/nav.erb +11 -0
- data/lib/robin_cms/views/new_tab.erb +4 -0
- data/lib/robin_cms/views/profile.erb +12 -0
- data/lib/robin_cms/views/richtext_field.erb +10 -0
- data/lib/robin_cms/views/select_field.erb +23 -0
- data/lib/robin_cms/views/style.erb +441 -0
- data/lib/robin_cms.rb +45 -0
- metadata +139 -0
@@ -0,0 +1,181 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RobinCMS
|
4
|
+
class Configuration < Hash
|
5
|
+
DEFAULTS = {
|
6
|
+
title: "Robin CMS",
|
7
|
+
build_command: nil,
|
8
|
+
accent_color: "#fd8a13"
|
9
|
+
}.freeze
|
10
|
+
|
11
|
+
COLLECTION_DEFAULTS = {
|
12
|
+
type: "collection",
|
13
|
+
location: ".",
|
14
|
+
static_location: "assets",
|
15
|
+
filetype: "html",
|
16
|
+
description: "",
|
17
|
+
can_create: true,
|
18
|
+
can_delete: true,
|
19
|
+
fields: []
|
20
|
+
}.freeze
|
21
|
+
|
22
|
+
DATA_DEFAULTS = {
|
23
|
+
type: "data",
|
24
|
+
location: ".",
|
25
|
+
static_location: "assets",
|
26
|
+
filetype: "yml",
|
27
|
+
description: "",
|
28
|
+
can_create: true,
|
29
|
+
can_delete: true,
|
30
|
+
fields: []
|
31
|
+
}.freeze
|
32
|
+
|
33
|
+
FIELD_DEFAULTS = {
|
34
|
+
type: "text",
|
35
|
+
default: nil,
|
36
|
+
required: false,
|
37
|
+
readonly: false
|
38
|
+
}.freeze
|
39
|
+
|
40
|
+
AUTOMATIC_FIELDS = [{
|
41
|
+
label: "Title",
|
42
|
+
id: "title",
|
43
|
+
type: "text",
|
44
|
+
required: true,
|
45
|
+
order: 1
|
46
|
+
}, {
|
47
|
+
label: "Published date",
|
48
|
+
id: "created_at",
|
49
|
+
type: "hidden"
|
50
|
+
}, {
|
51
|
+
label: "Last edited",
|
52
|
+
id: "updated_at",
|
53
|
+
type: "hidden"
|
54
|
+
}].freeze
|
55
|
+
|
56
|
+
AUTOMATIC_DRAFT_FIELDS = [{
|
57
|
+
label: "Published",
|
58
|
+
id: "published",
|
59
|
+
type: "select",
|
60
|
+
default: false,
|
61
|
+
options: [{
|
62
|
+
label: "Draft",
|
63
|
+
value: false
|
64
|
+
}, {
|
65
|
+
label: "Published",
|
66
|
+
value: true
|
67
|
+
}],
|
68
|
+
order: 2
|
69
|
+
}].freeze
|
70
|
+
|
71
|
+
AUTOMATIC_IMAGE_FIELDS = [{
|
72
|
+
id: "image_src",
|
73
|
+
type: "hidden"
|
74
|
+
}, {
|
75
|
+
id: "image_alt",
|
76
|
+
type: "text",
|
77
|
+
label: "Alt text"
|
78
|
+
}].freeze
|
79
|
+
|
80
|
+
def self.parse(config_file: "_cms.yml", jekyll_plugin: false)
|
81
|
+
data = if jekyll_plugin
|
82
|
+
YAML.load_file("_config.yml", symbolize_names: true).then do |yaml|
|
83
|
+
# If running as a Jekyll plugin, get the url and title from the
|
84
|
+
# Jekyll config, and get everything else from the 'cms' key.
|
85
|
+
{url: yaml[:url], title: yaml[:title], **yaml[:cms]}
|
86
|
+
end
|
87
|
+
else
|
88
|
+
YAML.load_file(config_file, symbolize_names: true)
|
89
|
+
end
|
90
|
+
|
91
|
+
schema_file = File.join(__dir__, "configuration-schema.json")
|
92
|
+
schema = JSON.parse(File.read(schema_file))
|
93
|
+
JSON::Validator.validate!(schema, data)
|
94
|
+
|
95
|
+
config = from_h(DEFAULTS).merge_config(data).merge_automatic_fields
|
96
|
+
config.validate!
|
97
|
+
config.freeze
|
98
|
+
end
|
99
|
+
|
100
|
+
def self.from_h(hash)
|
101
|
+
hash.each_with_object(new) { |(k, v), h| h[k] = v }
|
102
|
+
end
|
103
|
+
|
104
|
+
def find_library(kind)
|
105
|
+
self[:libraries].find { |c| c[:id] == kind }
|
106
|
+
end
|
107
|
+
|
108
|
+
def sorted_fields(kind)
|
109
|
+
find_library(kind)
|
110
|
+
&.dig(:fields)
|
111
|
+
&.sort_by { |field| field[:order] || Float::INFINITY }
|
112
|
+
end
|
113
|
+
|
114
|
+
def drafts_enabled?(library)
|
115
|
+
collection?(library)
|
116
|
+
end
|
117
|
+
|
118
|
+
def collection?(library)
|
119
|
+
library[:type].nil? || library[:type] == "collection"
|
120
|
+
end
|
121
|
+
|
122
|
+
def data?(library)
|
123
|
+
!collection?(library)
|
124
|
+
end
|
125
|
+
|
126
|
+
def has_image?(library)
|
127
|
+
library[:fields].find { |f| f[:type] == "image" }
|
128
|
+
end
|
129
|
+
|
130
|
+
def merge_config(config)
|
131
|
+
merge(config, {
|
132
|
+
libraries: config[:libraries].map do |library|
|
133
|
+
library_defaults =
|
134
|
+
collection?(library) ? COLLECTION_DEFAULTS : DATA_DEFAULTS
|
135
|
+
|
136
|
+
library_defaults.merge(library, {
|
137
|
+
fields: library[:fields].map do |field|
|
138
|
+
FIELD_DEFAULTS.merge(field)
|
139
|
+
end
|
140
|
+
})
|
141
|
+
end
|
142
|
+
})
|
143
|
+
end
|
144
|
+
|
145
|
+
def merge_automatic_fields
|
146
|
+
config = clone
|
147
|
+
|
148
|
+
config[:libraries].each do |library|
|
149
|
+
library[:fields].append(*AUTOMATIC_FIELDS)
|
150
|
+
|
151
|
+
if drafts_enabled?(library)
|
152
|
+
library[:fields].append(*AUTOMATIC_DRAFT_FIELDS)
|
153
|
+
end
|
154
|
+
|
155
|
+
if has_image?(library)
|
156
|
+
library[:fields].append(*AUTOMATIC_IMAGE_FIELDS)
|
157
|
+
end
|
158
|
+
|
159
|
+
library[:fields].uniq! { |f| f[:id] }
|
160
|
+
end
|
161
|
+
|
162
|
+
config
|
163
|
+
end
|
164
|
+
|
165
|
+
# This method provides custom validation which can't be expressed (or is
|
166
|
+
# too difficult to express) using the Json Schema standard.
|
167
|
+
def validate!
|
168
|
+
self[:libraries].each do |library|
|
169
|
+
next if library[:fields].nil?
|
170
|
+
|
171
|
+
if library[:fields].count { |f| f[:type] == "richtext" } > 1
|
172
|
+
raise ValidationError, "Only one richtext field per library allowed"
|
173
|
+
end
|
174
|
+
|
175
|
+
if library[:fields].count { |f| f[:type] == "image" } > 1
|
176
|
+
raise ValidationError, "Only one image field per library allowed"
|
177
|
+
end
|
178
|
+
end
|
179
|
+
end
|
180
|
+
end
|
181
|
+
end
|
@@ -0,0 +1,88 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RobinCMS
|
4
|
+
class DataItem
|
5
|
+
include Itemable
|
6
|
+
extend Queryable
|
7
|
+
|
8
|
+
def self.blank(library)
|
9
|
+
new(nil, library)
|
10
|
+
end
|
11
|
+
|
12
|
+
def self.create!(library, attributes)
|
13
|
+
new(nil, library, attributes).tap do |item|
|
14
|
+
item.save!
|
15
|
+
end
|
16
|
+
end
|
17
|
+
|
18
|
+
def self.find_one(library, id)
|
19
|
+
all(library)[id.to_i]
|
20
|
+
end
|
21
|
+
|
22
|
+
def self.all(library)
|
23
|
+
location = library[:location]
|
24
|
+
name = library[:id]
|
25
|
+
ext = library[:filetype]
|
26
|
+
path = File.join(location, "#{name}.#{ext}")
|
27
|
+
|
28
|
+
return [] unless File.exist?(path)
|
29
|
+
|
30
|
+
deserialize(library, path)
|
31
|
+
end
|
32
|
+
|
33
|
+
def self.deserialize(library, path)
|
34
|
+
raw = File.read(path)
|
35
|
+
items = YAML.load(raw, symbolize_names: true)
|
36
|
+
|
37
|
+
return [] unless items
|
38
|
+
|
39
|
+
items.each_with_index.map do |attrs, i|
|
40
|
+
new(i, library, attrs)
|
41
|
+
end
|
42
|
+
end
|
43
|
+
|
44
|
+
private_class_method :deserialize
|
45
|
+
|
46
|
+
def initialize(id, library, attrs = {})
|
47
|
+
super
|
48
|
+
|
49
|
+
@mark_for_deletion = false
|
50
|
+
end
|
51
|
+
|
52
|
+
def filepath
|
53
|
+
location = @library[:location]
|
54
|
+
name = @library[:id]
|
55
|
+
ext = @library[:filetype]
|
56
|
+
File.join(location, "#{name}.#{ext}")
|
57
|
+
end
|
58
|
+
|
59
|
+
def delete!
|
60
|
+
@mark_for_deletion = true
|
61
|
+
save!
|
62
|
+
end
|
63
|
+
|
64
|
+
private
|
65
|
+
|
66
|
+
def serialize
|
67
|
+
# See comment in corresponding method in lib/collection_item.rb.
|
68
|
+
fm = frontmatter.to_h.transform_keys(&:to_s)
|
69
|
+
|
70
|
+
items = if File.exist?(filepath)
|
71
|
+
raw = File.read(filepath)
|
72
|
+
YAML.load(raw)
|
73
|
+
else
|
74
|
+
[]
|
75
|
+
end
|
76
|
+
|
77
|
+
if @mark_for_deletion
|
78
|
+
items[@id.to_i] = nil
|
79
|
+
elsif @id.nil?
|
80
|
+
items.append(fm)
|
81
|
+
else
|
82
|
+
items[@id.to_i] = fm
|
83
|
+
end
|
84
|
+
|
85
|
+
YAML.dump(items.compact, stringify_names: true)
|
86
|
+
end
|
87
|
+
end
|
88
|
+
end
|
@@ -0,0 +1,24 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RobinCMS
|
4
|
+
class Flash
|
5
|
+
attr_reader :now, :next
|
6
|
+
|
7
|
+
def initialize(hash)
|
8
|
+
@now = hash || {}
|
9
|
+
@next = {}
|
10
|
+
end
|
11
|
+
|
12
|
+
def []=(key, value)
|
13
|
+
@next[key] = value
|
14
|
+
end
|
15
|
+
|
16
|
+
def method_missing(method_name, *args)
|
17
|
+
@now.send(method_name, *args)
|
18
|
+
end
|
19
|
+
|
20
|
+
def respond_to_missing?(method_name)
|
21
|
+
true
|
22
|
+
end
|
23
|
+
end
|
24
|
+
end
|
@@ -0,0 +1,60 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RobinCMS
|
4
|
+
module Helpers
|
5
|
+
SORT_OPTIONS = [{
|
6
|
+
label: "Sort by name (a-z)",
|
7
|
+
value: "id"
|
8
|
+
}, {
|
9
|
+
label: "Sort by name (z-a)",
|
10
|
+
value: "-id"
|
11
|
+
}, {
|
12
|
+
label: "Sort by created date (newest - oldest)",
|
13
|
+
value: "created_at"
|
14
|
+
}, {
|
15
|
+
label: "Sort by created date (oldest - newest)",
|
16
|
+
value: "-created_at"
|
17
|
+
}, {
|
18
|
+
label: "Sort by updated date (newest - oldest)",
|
19
|
+
value: "updated_at"
|
20
|
+
}, {
|
21
|
+
label: "Sort by updated date (oldest - newest)",
|
22
|
+
value: "-updated_at"
|
23
|
+
}].freeze
|
24
|
+
|
25
|
+
PUBLISHED_OPTIONS = [{
|
26
|
+
label: "Any status",
|
27
|
+
value: ""
|
28
|
+
}, {
|
29
|
+
label: "Draft",
|
30
|
+
value: "false"
|
31
|
+
}, {
|
32
|
+
label: "Published",
|
33
|
+
value: "true"
|
34
|
+
}].freeze
|
35
|
+
|
36
|
+
def query_params
|
37
|
+
URI.decode_www_form(request.query_string).to_h
|
38
|
+
end
|
39
|
+
|
40
|
+
def current_page
|
41
|
+
_, libraries, item = request.path_info.split("/")
|
42
|
+
item || libraries
|
43
|
+
end
|
44
|
+
|
45
|
+
def home_page
|
46
|
+
"/libraries/#{@config[:libraries].first[:id]}"
|
47
|
+
end
|
48
|
+
|
49
|
+
# For generating safe id's where the id needs to be based on user
|
50
|
+
# input. Prevents potential id collisions.
|
51
|
+
def safe_id(id, prefix)
|
52
|
+
id = id.to_s.tr("-", "_").gsub(/\W+/, "")
|
53
|
+
"__#{prefix}_#{id}"
|
54
|
+
end
|
55
|
+
|
56
|
+
def flash
|
57
|
+
@flash ||= Flash.new(session ? session[:flash] : {})
|
58
|
+
end
|
59
|
+
end
|
60
|
+
end
|
@@ -0,0 +1,34 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RobinCMS
|
4
|
+
# This is a wrapper class for providing a uniform interface to both the
|
5
|
+
# DataItem class and the CollectionItem class. It's sole job is to dispatch
|
6
|
+
# methods to the appropriate class based on the settings of the library.
|
7
|
+
class Item
|
8
|
+
METHOD_NAMES = %i[all where create! find_one blank]
|
9
|
+
|
10
|
+
def self.method_missing(method_name, *args, **kwargs)
|
11
|
+
unless METHOD_NAMES.include?(method_name)
|
12
|
+
super
|
13
|
+
return
|
14
|
+
end
|
15
|
+
|
16
|
+
# All the class methods for DataItem and CollectionItem should have
|
17
|
+
# the library as the first argument.
|
18
|
+
library = args.first
|
19
|
+
|
20
|
+
case library[:type]
|
21
|
+
when "data"
|
22
|
+
DataItem.send(method_name, *args, **kwargs)
|
23
|
+
when "collection"
|
24
|
+
CollectionItem.send(method_name, *args, **kwargs)
|
25
|
+
else
|
26
|
+
super
|
27
|
+
end
|
28
|
+
end
|
29
|
+
|
30
|
+
def self.respond_to_missing?(method_name)
|
31
|
+
METHOD_NAMES.include?(method_name)
|
32
|
+
end
|
33
|
+
end
|
34
|
+
end
|
@@ -0,0 +1,124 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RobinCMS
|
4
|
+
module Itemable
|
5
|
+
attr_reader :id, :library, :attributes
|
6
|
+
|
7
|
+
DATETIME_FORMAT = "%Y-%m-%d"
|
8
|
+
|
9
|
+
# The keys which we don't want to serialize.
|
10
|
+
SERIALIZE_IGNORE_KEYS = [:id, :kind, :content, :image, :captures].freeze
|
11
|
+
|
12
|
+
def initialize(id, library, attrs = {})
|
13
|
+
[:id, :location, :filetype].each do |key|
|
14
|
+
unless library.has_key?(key)
|
15
|
+
raise TypeError, "Missing required field #{key}"
|
16
|
+
end
|
17
|
+
end
|
18
|
+
|
19
|
+
if !attrs.empty? && !attrs.has_key?(:title)
|
20
|
+
raise TypeError, "Missing required field `title'"
|
21
|
+
end
|
22
|
+
|
23
|
+
@id = id
|
24
|
+
@library = library
|
25
|
+
|
26
|
+
# Be sure to use the setter here so the keys get converted to symbols.
|
27
|
+
self.attributes = attrs
|
28
|
+
end
|
29
|
+
|
30
|
+
def attributes=(attributes)
|
31
|
+
@attributes = attributes.to_h.transform_keys(&:to_sym)
|
32
|
+
|
33
|
+
if attributes.has_key?(:published)
|
34
|
+
@attributes[:published] = attributes[:published].to_s == "true"
|
35
|
+
end
|
36
|
+
end
|
37
|
+
|
38
|
+
def kind
|
39
|
+
@library[:id]
|
40
|
+
end
|
41
|
+
|
42
|
+
def inspect
|
43
|
+
"<#{self.class} id=\"#{id}\" kind=\"#{kind}\">"
|
44
|
+
end
|
45
|
+
|
46
|
+
def published?
|
47
|
+
if @attributes.has_key?(:published)
|
48
|
+
@attributes[:published]
|
49
|
+
else
|
50
|
+
true
|
51
|
+
end
|
52
|
+
end
|
53
|
+
|
54
|
+
def published_label
|
55
|
+
if published?
|
56
|
+
"Published"
|
57
|
+
else
|
58
|
+
"Draft"
|
59
|
+
end
|
60
|
+
end
|
61
|
+
|
62
|
+
def created_at
|
63
|
+
if @attributes[:created_at]
|
64
|
+
Time.parse(@attributes[:created_at])
|
65
|
+
else
|
66
|
+
File.birthtime(filepath)
|
67
|
+
end
|
68
|
+
end
|
69
|
+
|
70
|
+
def updated_at
|
71
|
+
if @attributes[:updated_at]
|
72
|
+
Time.parse(@attributes[:updated_at])
|
73
|
+
else
|
74
|
+
File.mtime(filepath)
|
75
|
+
end
|
76
|
+
end
|
77
|
+
|
78
|
+
def display_name
|
79
|
+
return @attributes[:title] unless @library[:display_name_pattern]
|
80
|
+
|
81
|
+
@library[:display_name_pattern].clone.tap do |name|
|
82
|
+
@attributes.each { |key, value| name.gsub!(":#{key}", value) }
|
83
|
+
end
|
84
|
+
end
|
85
|
+
|
86
|
+
def save!
|
87
|
+
timestamp = Time.now.strftime(DATETIME_FORMAT)
|
88
|
+
@attributes[:created_at] = timestamp
|
89
|
+
@attributes[:updated_at] = timestamp
|
90
|
+
|
91
|
+
FileUtils.mkdir_p(File.dirname(filepath))
|
92
|
+
File.write(filepath, serialize)
|
93
|
+
end
|
94
|
+
|
95
|
+
def update!
|
96
|
+
timestamp = Time.now.strftime(DATETIME_FORMAT)
|
97
|
+
@attributes[:updated_at] = timestamp
|
98
|
+
|
99
|
+
if !@attributes.has_key?(:created_at) || @attributes[:created_at].empty?
|
100
|
+
@attributes[:created_at] = timestamp
|
101
|
+
end
|
102
|
+
|
103
|
+
FileUtils.mkdir_p(File.dirname(filepath))
|
104
|
+
File.write(filepath, serialize)
|
105
|
+
end
|
106
|
+
|
107
|
+
def delete!
|
108
|
+
File.delete(filepath)
|
109
|
+
end
|
110
|
+
|
111
|
+
def frontmatter
|
112
|
+
frontmatter = @attributes.clone
|
113
|
+
SERIALIZE_IGNORE_KEYS.each { |key| frontmatter.delete(key) }
|
114
|
+
|
115
|
+
if published?
|
116
|
+
frontmatter.delete(:published)
|
117
|
+
else
|
118
|
+
frontmatter[:published] = false
|
119
|
+
end
|
120
|
+
|
121
|
+
frontmatter
|
122
|
+
end
|
123
|
+
end
|
124
|
+
end
|
@@ -0,0 +1,47 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RobinCMS
|
4
|
+
module Queryable
|
5
|
+
def where(library, **kwargs)
|
6
|
+
by_published = lambda do |i|
|
7
|
+
case kwargs[:published]
|
8
|
+
when nil, ""
|
9
|
+
true
|
10
|
+
when "true", true
|
11
|
+
i.published?
|
12
|
+
when "false", false
|
13
|
+
!i.published?
|
14
|
+
end
|
15
|
+
end
|
16
|
+
|
17
|
+
by_search = lambda do |i|
|
18
|
+
return true if kwargs[:q].nil?
|
19
|
+
|
20
|
+
i.attributes[:title].match?(/#{kwargs[:q]}/i)
|
21
|
+
end
|
22
|
+
|
23
|
+
by_field = lambda do |a, b|
|
24
|
+
return 0 if kwargs[:sort].nil?
|
25
|
+
|
26
|
+
sort_by = kwargs[:sort].sub("-", "").to_sym
|
27
|
+
sort_direction = kwargs[:sort].start_with?("-") ? -1 : 1
|
28
|
+
|
29
|
+
case sort_by
|
30
|
+
when :id
|
31
|
+
a.id <=> b.id
|
32
|
+
when :created_at, :updated_at
|
33
|
+
b.attributes[sort_by] <=> a.attributes[sort_by]
|
34
|
+
end * sort_direction
|
35
|
+
end
|
36
|
+
|
37
|
+
all(library)
|
38
|
+
.filter(&by_search)
|
39
|
+
.filter(&by_published)
|
40
|
+
.sort(&by_field)
|
41
|
+
end
|
42
|
+
|
43
|
+
def count(library)
|
44
|
+
all(library).size
|
45
|
+
end
|
46
|
+
end
|
47
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RobinCMS
|
4
|
+
module Sluggable
|
5
|
+
def make_slug(str, pattern = nil)
|
6
|
+
title = str.gsub(/\s/, "-").gsub(/[^\.\w-]/, "").downcase
|
7
|
+
|
8
|
+
return title unless pattern
|
9
|
+
|
10
|
+
now = Time.now
|
11
|
+
|
12
|
+
# If the pattern starts with a colon and is without quotes in the yaml
|
13
|
+
# file, Ruby will interpret it as a symbol when it parses the file.
|
14
|
+
# Convert it to a string to be safe.
|
15
|
+
pattern.to_s
|
16
|
+
.gsub(":year", now.strftime("%Y"))
|
17
|
+
.gsub(":month", now.strftime("%m"))
|
18
|
+
.gsub(":day", now.strftime("%d"))
|
19
|
+
.gsub(":title", title)
|
20
|
+
end
|
21
|
+
end
|
22
|
+
end
|
@@ -0,0 +1,92 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
module RobinCMS
|
4
|
+
class StaticItem
|
5
|
+
attr_reader :library
|
6
|
+
|
7
|
+
def initialize(library, filename, tempfile = nil)
|
8
|
+
@library = library
|
9
|
+
@filename = filename
|
10
|
+
@tempfile = tempfile
|
11
|
+
end
|
12
|
+
|
13
|
+
def save!
|
14
|
+
if File.exist?(filepath)
|
15
|
+
raise ItemExistsError, "An item with the same name already exists"
|
16
|
+
end
|
17
|
+
|
18
|
+
image_field = @library[:fields].find { |f| f[:type] == "image" }
|
19
|
+
dimensions = image_field && image_field[:dimensions]
|
20
|
+
filetype = image_field && image_field[:filetype]
|
21
|
+
|
22
|
+
resize_image!(dimensions) if dimensions
|
23
|
+
format_image!(filetype) if filetype
|
24
|
+
|
25
|
+
FileUtils.mkdir_p(File.dirname(filepath))
|
26
|
+
FileUtils.cp(@tempfile, filepath)
|
27
|
+
end
|
28
|
+
|
29
|
+
def delete!
|
30
|
+
File.delete(filepath)
|
31
|
+
end
|
32
|
+
|
33
|
+
def filepath
|
34
|
+
File.join(@library[:static_location], File.basename(@filename))
|
35
|
+
end
|
36
|
+
|
37
|
+
class << self
|
38
|
+
include Sluggable
|
39
|
+
|
40
|
+
def create!(library, filename, tempfile)
|
41
|
+
sluggified_filename = make_slug(filename)
|
42
|
+
new(library, sluggified_filename, tempfile).tap do |item|
|
43
|
+
item.save!
|
44
|
+
end
|
45
|
+
end
|
46
|
+
|
47
|
+
def find_one(library, filename)
|
48
|
+
path = File.join(library[:static_location], File.basename(filename))
|
49
|
+
|
50
|
+
return unless File.exist?(path)
|
51
|
+
|
52
|
+
new(library, filename)
|
53
|
+
end
|
54
|
+
|
55
|
+
def delete_if_exists!(library, filename)
|
56
|
+
find_one(library, filename)&.delete!
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
private
|
61
|
+
|
62
|
+
def resize_image!(dimensions)
|
63
|
+
# The mogrify command edits images in place. For more info, see
|
64
|
+
# mogrify(1).
|
65
|
+
|
66
|
+
system("mogrify -resize #{dimensions} #{@tempfile.to_path}")
|
67
|
+
if $?.exitstatus != 0
|
68
|
+
raise ConversionError, "Could not resize image #{@tempfile.to_path}"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
def format_image!(filetype)
|
73
|
+
system("mogrify -format #{filetype} #{@tempfile.to_path}")
|
74
|
+
if $?.exitstatus != 0
|
75
|
+
raise ConversionError, "Could not format image #{@tempfile.to_path}"
|
76
|
+
end
|
77
|
+
|
78
|
+
# The name of the converted file will be the same as the original but
|
79
|
+
# with a new file extension.
|
80
|
+
converted = @tempfile.to_path.sub(/#{File.extname(@tempfile)}$/, ".#{filetype}")
|
81
|
+
|
82
|
+
# Mogrify's format command creates a new file with the new extension.
|
83
|
+
# Copy the contents of this file to the tempfile, then delete the newly
|
84
|
+
# created file.
|
85
|
+
IO.copy_stream(converted, @tempfile.to_path)
|
86
|
+
File.delete(converted)
|
87
|
+
|
88
|
+
# Update the file extension of the uploaded file.
|
89
|
+
@filename.sub!(/#{File.extname(@filename)}$/, ".#{filetype}")
|
90
|
+
end
|
91
|
+
end
|
92
|
+
end
|
@@ -0,0 +1,22 @@
|
|
1
|
+
<header class="card">
|
2
|
+
<h2>Change password</h2>
|
3
|
+
</header>
|
4
|
+
<form class="card" name="change-password" method="post">
|
5
|
+
<div class="field">
|
6
|
+
<label for="old-password">Old password</label>
|
7
|
+
<input id="old-password" type="password" name="old_password" required />
|
8
|
+
</div>
|
9
|
+
<div class="field">
|
10
|
+
<label for="new-password">New password</label>
|
11
|
+
<input id="new-password" type="password" name="new_password" required />
|
12
|
+
</div>
|
13
|
+
<div class="field">
|
14
|
+
<label for="confirm-password">Confirm password</label>
|
15
|
+
<input id="confirm-password" type="password" name="confirm_password" required />
|
16
|
+
</div>
|
17
|
+
<%= erb :flash %>
|
18
|
+
<footer class="controls --align-right">
|
19
|
+
<a href="/admin">Back</a>
|
20
|
+
<button type="submit">Update</button>
|
21
|
+
</footer>
|
22
|
+
</form>
|