1 module vibelog.post;
2 
3 import vibelog.settings;
4 
5 import vibe.data.bson;
6 import vibe.textfilter.markdown;
7 import vibe.textfilter.html;
8 
9 import std.array;
10 import std.conv;
11 import std.string : strip;
12 public import std.datetime;
13 
14 import stringex.unidecode;
15 
16 
17 final class Post {
18 	BsonObjectID id;
19 	bool isPublic;
20 	bool commentsAllowed;
21 	string slug; // url entity to identify this post - generated from the header by default
22 	string author;  // user name
23 	string category; // can be hierarchical using dotted.syntax.format
24 	SysTime date;
25 	string header; // Title/heading
26 	string headerImage; // URL of large header image
27 	string summaryTitle; // Short title used for the summary (<=70chars)
28 	string summary; // Short summary of the article (<=240 chars), displayed on cards
29 	string subHeader; // First paragraph of the articule, displayed on overview pages
30 	string content;
31 	string[] filters;
32 	string[] tags;
33 	string[] trackbacks;
34 
35 	this()
36 	{
37 		id  = BsonObjectID.generate();
38 		date = Clock.currTime().toUTC();
39 	}
40 
41 	@property string name() const { return slug.length ? slug : id.toString(); }
42 
43 	static Post fromBson(Bson bson)
44 	{
45 		auto ret = new Post;
46 		ret.id = cast(BsonObjectID)bson["_id"];
47 		ret.isPublic = cast(bool)bson["isPublic"];
48 		ret.commentsAllowed = cast(bool)bson["commentsAllowed"];
49 		ret.slug = cast(string)bson["slug"];
50 		ret.author = cast(string)bson["author"];
51 		ret.category = cast(string)bson["category"];
52 		ret.date = SysTime.fromISOExtString(cast(string)bson["date"]);
53 		ret.headerImage = cast(string)bson["headerImage"];
54 		ret.header = cast(string)bson["header"];
55 		ret.subHeader = cast(string)bson["subHeader"];
56 		ret.content = cast(string)bson["content"];
57 		ret.summary = bson["summary"].opt!string;
58 		ret.summaryTitle = bson["summaryTitle"].opt!string;
59 
60 		if (bson["filters"].isNull) ret.filters = ["markdown"];
61 		else {
62 			foreach (f; cast(Bson[])bson["filters"])
63 				ret.filters ~= cast(string)f;
64 		}
65 
66 		if (!bson["tags"].isNull)
67 			foreach (t; cast(Bson[])bson["tags"])
68 				ret.tags ~= cast(string)t;
69 
70 		return ret;
71 	}
72 
73 	Bson toBson()
74 	const {
75 
76 		Bson[string] ret;
77 		ret["_id"] = Bson(id);
78 		ret["isPublic"] = Bson(isPublic);
79 		ret["commentsAllowed"] = Bson(commentsAllowed);
80 		ret["slug"] = Bson(slug);
81 		ret["author"] = Bson(author);
82 		ret["category"] = Bson(category);
83 		ret["date"] = Bson(date.toISOExtString());
84 		ret["headerImage"] = Bson(headerImage);
85 		ret["header"] = Bson(header);
86 		ret["subHeader"] = Bson(subHeader);
87 		ret["content"] = Bson(content);
88 		ret["summary"] = Bson(summary);
89 		ret["summaryTitle"] = Bson(summaryTitle);
90 
91 		import std.algorithm : map;
92 		import std.array : array;
93 		ret["filters"] = Bson(filters.map!Bson.array);
94 		ret["tags"] = Bson(tags.map!Bson.array);
95 
96 		return Bson(ret);
97 	}
98 
99 	string renderSubHeaderAsHtml(VibeLogSettings settings)
100 	const {
101 		import std.algorithm : canFind;
102 		if (filters.canFind("markdown"))
103 		{
104 			auto ret = appender!string();
105 			filterMarkdown(ret, subHeader, settings.markdownSettings);
106 			return ret.data;
107 		}
108 		else
109 		{
110 			return subHeader;
111 		}
112 	}
113 
114 	string renderContentAsHtml(VibeLogSettings settings, string page_path = "", int header_level_nesting = 0)
115 	const {
116 
117 		import std.algorithm : canFind;
118 		string html = content;
119 		if (filters.canFind("markdown"))
120 		{
121 			scope ms = new MarkdownSettings;
122 			ms.flags = settings.markdownSettings.flags;
123 			ms.headingBaseLevel = settings.markdownSettings.headingBaseLevel + header_level_nesting;
124 			if (page_path != "")
125 			{
126 				ms.urlFilter = (lnk, is_image) {
127 					import std.algorithm : startsWith;
128 					if (lnk.startsWith("http://") || lnk.startsWith("https://"))
129 						return lnk;
130 					if (lnk.startsWith("#")) return lnk;
131 					auto pp = InetPath(page_path);
132 					if (!pp.endsWithSlash)
133 						pp = pp.parentPath;
134 					return (settings.siteURL.path~("posts/"~slug~"/"~lnk)).relativeTo(pp).toString();
135 				};
136 			}
137 			html = filterMarkdown(html, ms);
138 		}
139 		foreach (flt; settings.textFilters)
140 			html = flt(html);
141 		return html;
142 	}
143 }
144 
145 string makeSlugFromHeader(string header)
146 {
147 	Appender!string ret;
148 	auto decoded_header = unidecode(header).replace("[?]", "-");
149 	foreach (dchar ch; strip(decoded_header)) {
150 		switch (ch) {
151 			default:
152 				ret.put('-');
153 				break;
154 			case '"', '\'', '´', '`', '.', ',', ';', '!', '?', '¿', '¡':
155 				break;
156 			case 'A': .. case 'Z'+1:
157 				ret.put(cast(dchar)(ch - 'A' + 'a'));
158 				break;
159 			case 'a': .. case 'z'+1:
160 			case '0': .. case '9'+1:
161 				ret.put(ch);
162 				break;
163 		}
164 	}
165 	return ret.data;
166 }
167 
168 unittest {
169 	assert(makeSlugFromHeader("sample title") == "sample-title");
170 	assert(makeSlugFromHeader("Sample Title") == "sample-title");
171 	assert(makeSlugFromHeader("  Sample Title2  ") == "sample-title2");
172 	assert(makeSlugFromHeader("反清復明") == "fan-qing-fu-ming");
173 	assert(makeSlugFromHeader("φύλλο") == "phullo");
174 	assert(makeSlugFromHeader("ខេមរភាសា") == "khemrbhaasaa");
175 	assert(makeSlugFromHeader("zweitgrößte der Europäischen Union") == "zweitgrosste-der-europaischen-union");
176 	assert(makeSlugFromHeader("østlige og vestlige del udviklede sig uafhængigt ") == "ostlige-og-vestlige-del-udviklede-sig-uafhaengigt");
177 	assert(makeSlugFromHeader("¿pchnąć w tę łódź jeża lub ośm skrzyń fig?") == "pchnac-w-te-lodz-jeza-lub-osm-skrzyn-fig");
178 	assert(makeSlugFromHeader("¼ €") == "1-4-eu");
179 }