Allow hyphens in tags
[ublog.git] / ublog
blob5f9abb1b2510d4948d8d5f2d2a8e6a0ef66738f8
1 #!/usr/bin/perl
4 # A minimal distributed multiuser blogging software [well ... better: hack]
5 # that is based on Git
7 # Copyright 2011 Daniel Borkmann <borkmann@gnumaniacs.org>
8 # Subject to the GNU GPL, version 2.
10 # ublog via Git Benefits:
12 # + Distributed for a better censorship resistance
13 # + Secure since all sites are static HTML
14 # + Fast since all sites are static HTML
15 # + Backuped, since DVCS is used
16 # + Easy usage and setup, single blog text file
17 # + Can be used with multiple authors
18 # + RSS per author, tag and full RSS
19 # + Automatic content creation triggered on Git push
21 # txt2html syntax: http://txt2html.sourceforge.net/
22 # On Debian: apt-get install txt2html
24 # More Readme on ToDo. Here's what you need to setup:
26 # - Have git installed on your server
27 # - Plus a webserver, e.g. nginx
28 # - You setup a single Git repo with your blog.txt in it
29 # - Then, add a Git hook for each push that triggers ublog
30 # - ublog will then generate the blog files, e.g. for /var/www/htdocs/
31 # - Done. People should have the possibilty to clone your
32 # blog content (blog.txt), thus git server should run
34 # Usage, i.e.: ublog blog.txt /var/www/htdocs/
37 use strict;
38 use warnings;
40 use HTML::TextToHTML;
42 my $prognam = "ublog";
43 my $version = "0.1";
45 my $line = 0;
46 my $verbose = 0;
48 use constant {
49 NONE => -1,
50 HEADER => 0,
51 FOOTER => 1,
52 ABOUT => 2,
53 SETTINGS => 3,
54 AUTHOR => 4,
55 ENTRY => 5,
58 my @abbr = qw( Jan Feb Mar Apr May Jun Jul Aug Sep Oct Nov Dec );
60 my $header_txt = "";
61 my $footer_txt = "";
62 my $about_txt = "";
64 my %authors;
65 my %entries;
66 my %settings;
67 my %tagsh;
68 my %got;
70 sub debug
72 my $info = shift;
73 if ($verbose) {
74 print $info;
78 sub parse_settings
80 my $l = shift;
81 $_ = $l;
82 if (/^\s*title\s*=\s*(.*)\s*$/) {
83 $settings{title} = $1;
84 debug("Title: $settings{title}\n");
85 } elsif (/^\s*entries_per_site\s*=\s*(\d+)\s*$/) {
86 $settings{entries} = $1;
87 debug("Entries/site: $settings{entries}\n");
88 } elsif (/^\s*git_clone\s*=\s*(.*)\s*$/) {
89 $settings{clone} = $1;
90 debug("Clone URL: $settings{clone}\n");
91 } elsif (/^\s*root_url\s*=\s*(.*)\s*$/) {
92 $settings{root} = $1;
93 debug("Root URL: $settings{root}\n");
94 } elsif (/^\s*show_all_tags\s*=\s*(0|1)\s*$/) {
95 $settings{tags} = $1;
96 debug("Tagcloud: ".$settings{tags}."\n");
97 } elsif (/^\s*use_txt2html\s*=\s*(0|1)\s*$/) {
98 $settings{txt2html} = $1;
99 debug("Txt2HTML: ".$settings{txt2html}."\n");
100 } else {
101 die "Syntax error in l.$line!\n";
105 sub check_settings
107 if (not $settings{title}) {
108 die "Syntax error! No blog title!\n";
110 if (not $settings{entries}) {
111 die "Syntax error! No entries/site defined!\n";
113 if ($settings{clone}) {
114 if (not ($settings{clone} =~ /^git:\/\//)) {
115 die "Syntax error! False clone URL!\n";
118 if (not $settings{root}) {
119 die "Syntax error! No blog root URL provided!\n";
121 if (not ($settings{root} =~ /^http:\/\//)) {
122 die "Syntax error! False root URL!\n";
126 sub parse_author
128 my $author = shift;
129 my $l = shift;
130 $_ = $l;
131 if (/^\s*name\s*=\s*(.*)\s*$/) {
132 $authors{$author}->{name} = $1;
133 debug("Name: ".$authors{$author}->{name}."\n");
134 } elsif (/^\s*email\s*=\s*(.*)\s*$/) {
135 $authors{$author}->{email} = $1;
136 debug("E-Mail: ".$authors{$author}->{email}."\n");
137 } elsif (/^\s*web\s*=\s*(.*)\s*$/) {
138 $authors{$author}->{web} = $1;
139 debug("Website: ".$authors{$author}->{web}."\n");
140 } elsif (/^\s*show_nic\s*=\s*(0|1)\s*$/) {
141 $authors{$author}->{usenic} = $1;
142 debug("Use nic: ".$authors{$author}->{usenic}."\n");
143 } else {
144 die "Syntax error in l.$line!\n";
148 sub check_author
150 my $author = shift;
151 if (not $authors{$author}->{name}) {
152 die "Syntax error! No author name!\n";
156 sub parse_entry
158 my $time = shift;
159 my $l = shift;
160 $entries{$time}->{text} .= $l;
163 sub check_entry
165 my $time = shift;
166 my $nic = $entries{$time}->{author};
167 my $good = 0;
168 foreach (keys(%authors)) {
169 if ($_ eq $nic) {
170 $good = 1;
173 if (not $good) {
174 die "Syntax error! Wrong author given!\n";
178 sub check_sections
180 if (not $got{header}) {
181 die "Syntax error! No header section present!\n";
183 if (not $got{footer}) {
184 die "Syntax error! No footer section present!\n";
186 if (not $got{about}) {
187 die "Syntax error! No about section present!\n";
189 if (not $got{settings}) {
190 die "Syntax error! No settings section present!\n";
192 if (not $got{author}) {
193 die "Syntax error! No author section present!\n";
195 if (not $got{entry}) {
196 die "Syntax error! No entry section present!\n";
200 sub parse
202 my $blog = shift;
203 my $state = NONE;
204 my ($author, $time, @tags);
205 open BLOG, "<", $blog, or die $!;
206 while (<BLOG>) {
207 $line++;
208 next if (/^\s*#/ and $state == NONE);
209 next if (/^\s+$/ and $state == NONE);
210 if (/^\s*header\s*=\s*{\s*$/) {
211 die "Syntax error in l.$line!\n" if ($state != NONE);
212 $state = HEADER;
213 $got{header} = 1;
214 debug("Found header!\n");
215 } elsif (/^\s*footer\s*=\s*{\s*$/) {
216 die "Syntax error in l.$line!\n" if ($state != NONE);
217 $state = FOOTER;
218 $got{footer} = 1;
219 debug("Found footer!\n");
220 } elsif (/^\s*about\s*=\s*{\s*$/) {
221 die "Syntax error in l.$line!\n" if ($state != NONE);
222 $state = ABOUT;
223 $got{about} = 1;
224 debug("Found about!\n");
225 } elsif (/^\s*settings\s*=\s*{\s*$/) {
226 die "Syntax error in l.$line!\n" if ($state != NONE);
227 $state = SETTINGS;
228 $got{settings} = 1;
229 debug("Found settings!\n");
230 } elsif (/^\s*author\s+(\w+)\s*=\s*{\s*$/) {
231 die "Syntax error in l.$line!\n" if ($state != NONE);
232 $state = AUTHOR;
233 $got{author} = 1;
234 $author = $1;
235 debug("Found author '$author'!\n");
236 } elsif (/^\s*entry\s+(\d+)\s+(\w+)\s+\[([\w\s,-]*)\]\s*=\s*{\s*$/) {
237 die "Syntax error in l.$line!\n" if ($state != NONE);
238 $state = ENTRY;
239 $got{entry} = 1;
240 $time = $1;
241 $author = $2;
242 @tags = split(/,\s*/, $3);
243 $entries{$time}->{author} = $author;
244 push @{$entries{$time}->{tags}}, @tags;
245 $entries{$time}->{text} = "";
246 foreach my $tag (@tags) {
247 $tagsh{$tag}++;
249 debug("Found header with '$time', '$author', '@tags'!\n");
250 } elsif (/^\s*}\s*$/) {
251 die "Syntax error in l.$line!\n" if ($state == NONE);
252 $state = NONE;
253 debug("Found close!\n");
254 } elsif ($state != NONE) {
255 if ($state == HEADER) {
256 $header_txt .= $_;
257 } elsif ($state == FOOTER) {
258 $footer_txt .= $_;
259 } elsif ($state == ABOUT) {
260 $about_txt .= $_;
261 } elsif ($state == SETTINGS) {
262 parse_settings($_);
263 } elsif ($state == AUTHOR) {
264 parse_author($author, $_);
265 } elsif ($state == ENTRY) {
266 parse_entry($time, $_);
267 } else {
268 die "Wrong state in l.$line!\n";
270 } else {
271 die "Syntax error in l.$line!\n";
274 close BLOG;
277 sub check_content
279 check_sections();
280 check_settings();
281 foreach (keys(%authors)) {
282 check_author($_);
284 foreach (keys(%entries)) {
285 check_entry($_);
289 sub generate_index
291 my $folder = shift;
292 my $conv = new HTML::TextToHTML();
293 my $sites = int(scalar(keys(%entries)) / $settings{entries}) + 1;
294 my $last = scalar(keys(%entries)) % $settings{entries};
295 my @keys = sort {$b <=> $a} keys(%entries);
296 my @ldate = localtime(0);
298 if ($last == 0) {
299 if ($sites > 1) {
300 $sites--;
301 $last = $settings{entries};
305 for (my $i = 0; $i < $sites; $i++) {
306 my $output = "";
307 my ($file, $number);
309 $output .= "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.0 Transitional//EN\">\n";
310 $output .= "<html><head>\n";
311 $output .= "<title>".$settings{title}."</title>\n";
312 $output .= "<link href=\"style.css\" rel=\"stylesheet\" type=\"text/css\">";
313 $output .= "</head><body><h2>".$settings{title}."</h2>\n";
314 if ($settings{txt2html}) {
315 $output .= $conv->process_chunk($header_txt);
316 } else {
317 $output .= $header_txt;
319 $output .= "<a href=\"".$settings{root}."/about.html\">About</a>, ";
320 $output .= "<a href=\"".$settings{root}."/feed.xml\">RSS</a>";
321 if ($settings{clone}) {
322 $output .= ", Git: <a href=\"".$settings{clone}."\">".$settings{clone}."</a>";
324 $output .= "<br>\n<ul>";
326 if ($i == $sites - 1) {
327 $number = $last;
328 } else {
329 $number = $settings{entries};
331 for (my $j = 0; $j < $number; $j++) {
332 my $key = shift @keys;
333 my $pre = "[<a href=\"".$settings{root}."/$key.html\">p</a>, ";
334 my $author = $entries{$key}->{author};
335 my @cdate = localtime($key);
337 if ($authors{$author}->{usenic}) {
338 $pre .= "<a href=\"".$settings{root}."/a_$author.xml\">$author</a>";
339 } else {
340 $pre .= "<a href=\"".$settings{root}."/a_$author.xml\">".$authors{$author}->{name}."</a>";
342 if ($authors{$author}->{email} or $authors{$author}->{web}) {
343 my $elem = 0;
344 $pre .= " (";
345 if ($authors{$author}->{email}) {
346 $pre .= "<a href=\"mailto:".$authors{$author}->{email}."\">e</a>";
347 $elem++;
349 if ($authors{$author}->{web}) {
350 $pre .= ", " if $elem > 0;
351 $pre .= "<a href=\"".$authors{$author}->{web}."\">w</a>";
353 $pre .= ")";
355 if (scalar(@{$entries{$key}->{tags}}) > 0) {
356 $pre .= ", tags: ";
357 foreach my $tag (@{$entries{$key}->{tags}}) {
358 $pre .= "<a href=\"".$settings{root}."/t_$tag.xml\">$tag</a> ";
361 $pre .= "] ";
363 if ($ldate[3] != $cdate[3] || $ldate[4] != $cdate[4] ||
364 $ldate[5] != $cdate[5]) {
365 @ldate = @cdate;
366 $output .= "\n</ul>\n<h3>$abbr[$ldate[4]] $ldate[3], ".(1900 + $ldate[5])."</h3>\n<ul>";
369 if ($settings{txt2html}) {
370 $output .= "\n<li>$pre".$conv->process_chunk($entries{$key}->{text}, is_fragment => 1)."</li>";
371 } else {
372 $output .= "\n<li>$pre".$entries{$key}->{text}."</li>";
376 if ($i == $sites - 1) {
377 $output .= "\n</ul>\n<hr>\n";
378 } else {
379 $output .= "</ul><a href=\"".$settings{root}."/index_".
380 ($i + 1).".html\">Next</a><hr>\n";
382 if ($settings{tags}) {
383 $output .= "All tags: ";
384 foreach my $tag (keys(%tagsh)) {
385 $output .= "<a href=\"".$settings{root}."/t_$tag.xml\">$tag</a> ($tagsh{$tag}) ";
388 if ($settings{txt2html}) {
389 $output .= $conv->process_chunk($footer_txt);
390 } else {
391 $output .= $footer_txt;
393 $output .= "</body></html>";
395 if ($i == 0) {
396 $file = "index.html";
397 } else {
398 $file = "index_$i.html";
401 open OUT, ">", "$folder/$file" or die $!;
402 print OUT $output;
403 close OUT;
407 sub generate_about
409 my $folder = shift;
410 my $conv = new HTML::TextToHTML();
411 my $output = "";
413 $output .= "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.0 Transitional//EN\">\n";
414 $output .= "<html><head>\n";
415 $output .= "<title>".$settings{title}."</title>\n";
416 $output .= "<link href=\"style.css\" rel=\"stylesheet\" type=\"text/css\">";
417 $output .= "</head><body><h2>".$settings{title}."</h2>\n";
418 if ($settings{txt2html}) {
419 $output .= $conv->process_chunk($about_txt);
420 } else {
421 $output .= $about_txt;
423 $output .= "<a href=\"".$settings{root}."/index.html\">Blog</a>, ";
424 $output .= "<a href=\"".$settings{root}."/feed.xml\">RSS</a>";
425 if ($settings{clone}) {
426 $output .= ", Git: <a href=\"".$settings{clone}."\">".$settings{clone}."</a>";
428 $output .= "<br><hr>\n";
429 if ($settings{txt2html}) {
430 $output .= $conv->process_chunk($footer_txt);
431 } else {
432 $output .= $footer_txt;
434 $output .= "</body></html>";
436 open OUT, ">", "$folder/about.html" or die $!;
437 print OUT $output;
438 close OUT;
441 sub generate_entries
443 my $folder = shift;
444 my $conv = new HTML::TextToHTML();
446 foreach my $key (keys(%entries)) {
447 my $output = "";
448 my $author = $entries{$key}->{author};
449 my @ldate = localtime($key);
450 my $pre = "[<a href=\"".$settings{root}."/$key.html\">p</a>, ";
452 if ($authors{$author}->{usenic}) {
453 $pre .= "<a href=\"".$settings{root}."/a_$author.xml\">$author</a>";
454 } else {
455 $pre .= "<a href=\"".$settings{root}."/a_$author.xml\">".$authors{$author}->{name}."</a>";
457 if ($authors{$author}->{email} or $authors{$author}->{web}) {
458 my $elem = 0;
459 $pre .= " (";
460 if ($authors{$author}->{email}) {
461 $pre .= "<a href=\"mailto:".$authors{$author}->{email}."\">e</a>";
462 $elem++;
464 if ($authors{$author}->{web}) {
465 $pre .= ", " if $elem > 0;
466 $pre .= "<a href=\"".$authors{$author}->{web}."\">w</a>";
468 $pre .= ")";
470 if (scalar(@{$entries{$key}->{tags}}) > 0) {
471 $pre .= ", tags: ";
472 foreach my $tag (@{$entries{$key}->{tags}}) {
473 $pre .= "<a href=\"".$settings{root}."/t_$tag.xml\">$tag</a> ";
476 $pre .= "] ";
478 $output .= "<!DOCTYPE HTML PUBLIC \"-//W3C//DTD HTML 4.0 Transitional//EN\">\n";
479 $output .= "<html><head>\n";
480 $output .= "<title>".$settings{title}."</title>\n";
481 $output .= "<link href=\"style.css\" rel=\"stylesheet\" type=\"text/css\">";
482 $output .= "</head><body><h2>".$settings{title}."</h2>\n";
483 if ($settings{txt2html}) {
484 $output .= $conv->process_chunk($header_txt);
485 } else {
486 $output .= $header_txt;
488 $output .= "<a href=\"".$settings{root}."/about.html\">About</a>, ";
489 $output .= "<a href=\"".$settings{root}."/feed.xml\">RSS</a>";
490 if ($settings{clone}) {
491 $output .= ", Git: <a href=\"".$settings{clone}."\">".$settings{clone}."</a>";
493 $output .= "<br>\n";
494 $output .= "<h3>$abbr[$ldate[4]] $ldate[3], ".(1900 + $ldate[5])."</h3>\n<ul>";
495 if ($settings{txt2html}) {
496 $output .= "\n<li>$pre".$conv->process_chunk($entries{$key}->{text}, is_fragment => 1)."</li>";
497 } else {
498 $output .= "\n<li>$pre".$entries{$key}->{text}."</li>";
500 $output .= "\n</ul>\n<hr>\n";
501 if ($settings{txt2html}) {
502 $output .= $conv->process_chunk($footer_txt);
503 } else {
504 $output .= $footer_txt;
506 $output .= "</body></html>";
508 open OUT, ">", "$folder/$key.html" or die $!;
509 print OUT $output;
510 close OUT;
514 sub generate_full_rss
516 my $folder = shift;
517 my $output = "";
518 my @keys = sort {$b <=> $a} keys(%entries);
520 $output .= "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
521 $output .= "<rss version=\"2.0\">\n";
522 $output .= "<channel>\n";
523 $output .= "<title>".$settings{title}."</title>\n";
524 $output .= "<link>".$settings{root}."</link>\n";
525 $output .= "<description>".$settings{title}.", all ramblings</description>\n";
526 $output .= "<language>en</language>\n";
528 foreach my $key (@keys) {
529 $output .= "<item>\n";
530 $output .= "<title>".$entries{$key}->{text}."</title>\n";
531 $output .= "<link>".$settings{root}."/$key.html</link>\n";
532 $output .= "<guid>".$settings{root}."/$key.html</guid>\n";
533 $output .= "</item>\n";
536 $output .= "</channel>\n";
537 $output .= "</rss>\n";
539 open OUT, ">", "$folder/feed.xml" or die $!;
540 print OUT $output;
541 close OUT;
544 sub generate_author_rss
546 my $folder = shift;
547 my @keys = sort {$b <=> $a} keys(%entries);
548 my %aitems;
550 foreach my $key (@keys) {
551 my $author = $entries{$key}->{author};
552 if (not $aitems{$author}) {
553 $aitems{$author} = "";
555 $aitems{$author} .= "<item>\n";
556 $aitems{$author} .= "<title>".$entries{$key}->{text}."</title>\n";
557 $aitems{$author} .= "<link>".$settings{root}."/$key.html</link>\n";
558 $aitems{$author} .= "<guid>".$settings{root}."/$key.html</guid>\n";
559 $aitems{$author} .= "</item>\n";
562 foreach my $author (keys(%aitems)) {
563 my $output = "";
564 $output .= "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
565 $output .= "<rss version=\"2.0\">\n";
566 $output .= "<channel>\n";
567 $output .= "<title>".$settings{title}."</title>\n";
568 $output .= "<link>".$settings{root}."</link>\n";
569 $output .= "<description>".$settings{title}.", $author\'s ramblings</description>\n";
570 $output .= "<language>en</language>\n";
571 $output .= $aitems{$author};
572 $output .= "</channel>\n";
573 $output .= "</rss>\n";
575 open OUT, ">", "$folder/a_$author.xml" or die $!;
576 print OUT $output;
577 close OUT;
581 sub generate_tag_rss
583 my $folder = shift;
584 my @keys = sort {$b <=> $a} keys(%entries);
585 my %titems;
587 foreach my $key (@keys) {
588 my @tags = @{$entries{$key}->{tags}};
589 foreach my $tag (@tags) {
590 if (not $titems{$tag}) {
591 $titems{$tag} = "";
593 $titems{$tag} .= "<item>\n";
594 $titems{$tag} .= "<title>".$entries{$key}->{text}."</title>\n";
595 $titems{$tag} .= "<link>".$settings{root}."/$key.html</link>\n";
596 $titems{$tag} .= "<guid>".$settings{root}."/$key.html</guid>\n";
597 $titems{$tag} .= "</item>\n";
601 foreach my $tag (keys(%titems)) {
602 my $output = "";
603 $output .= "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n";
604 $output .= "<rss version=\"2.0\">\n";
605 $output .= "<channel>\n";
606 $output .= "<title>".$settings{title}."</title>\n";
607 $output .= "<link>".$settings{root}."</link>\n";
608 $output .= "<description>".$settings{title}.", $tag-tagged ramblings</description>\n";
609 $output .= "<language>en</language>\n";
610 $output .= $titems{$tag};
611 $output .= "</channel>\n";
612 $output .= "</rss>\n";
614 open OUT, ">", "$folder/t_$tag.xml" or die $!;
615 print OUT $output;
616 close OUT;
620 sub generate_content
622 my $folder = shift;
623 mkdir($folder, 0777);
624 generate_index($folder);
625 generate_about($folder);
626 generate_entries($folder);
627 # Yeah, sloppy and inefficient
628 generate_full_rss($folder);
629 generate_author_rss($folder);
630 generate_tag_rss($folder);
633 sub main
635 my $blog = shift;
636 my $folder = shift;
637 print "Parsing $blog blog ...\n";
638 parse($blog);
639 print "Checking ...\n";
640 check_content();
641 print "Generate html files in $folder ...\n";
642 generate_content($folder);
643 print "Done!\n";
646 sub help
648 print "\n$prognam $version\n";
649 print "http://gnumaniacs.org\n\n";
650 print "Usage: $prognam <blog-text-file> <output-folder>\n\n";
651 print "Please report bugs to <borkmann\@gnumanics.org>\n";
652 print "Copyright (C) 2011 Daniel Borkmann <borkmann\@gnumanics.org>,\n";
653 print "License: GNU GPL version 2\n";
654 print "This is free software: you are free to change and redistribute it.\n";
655 print "There is NO WARRANTY, to the extent permitted by law.\n\n";
657 exit;
660 if ($#ARGV + 1 != 2) {
661 help();
664 main(@ARGV);