#!/usr/bin/perl

use strict;
use warnings;

use File::Temp;

use Gruta;
use Gruta::Source::DBI;
use Gruta::Source::FS;
use Gruta::Renderer::Grutatxt;
use Gruta::Renderer::HTML;
use Gruta::Renderer::Text;

my $X = {
	'copy' =>	[
		'copy {src} {dst}',
		'Copies the full source {src} to {dst}',
		sub {
			my $g		= init();
			my $new_src	= arg();
			my $dst		= new_source( $new_src );

			$dst->create();

			$g->transfer_to_source( $dst );
		}
	],
	'topics' =>	[
		'topics {src}',
		'Lists the topics in {src}',
		sub {
			my $g = init();

			foreach my $t ($g->source->topics()) {
				print $t, "\n";
			}
		}
	],
	'topic' =>	[
		'topic {src} {topic_id}',
		'Dumps topic data',
		sub {
			my $g		= init();
			my $topic_id	= arg();

			print get_topic($g, $topic_id);
		}
	],
	'new_topic' =>	[
		'new_topic {src} {topic_id}',
		'Creates a new topic from STDIN',
		sub {
			my $g		= init();
			my $topic_id	= arg();

			my $fh = File::Temp->new();
			print $fh join('', <>);
			my $fn = $fh->filename();
			$fh->close();

			save_topic($g, $topic_id, $fn, 1);
		}
	],
	'update_topic' =>	[
		'update_topic {src} {topic_id}',
		'Updates a topic from STDIN',
		sub {
			my $g		= init();
			my $topic_id	= arg();

			my $fh = File::Temp->new();
			print $fh join('', <>);
			my $fn = $fh->filename();
			$fh->close();

			save_topic($g, $topic_id, $fn);
		}
	],
	'edit_topic' =>	[
		'edit_topic {src} {topic_id}',
		'Edits topic data',
		sub {
			my $g		= init();
			my $topic_id	= arg();

			my $fh = File::Temp->new();
			print $fh get_topic($g, $topic_id);
			my $fn = $fh->filename();
			$fh->close();

			my $mtime = (stat($fn))[9];
			system('$EDITOR ' . $fn);

			if ($mtime != (stat($fn))[9]) {
				save_topic($g, $topic_id, $fn);
			}
		}
	],
	'stories' =>	[
		'stories {src} {topic_id}',
		'Lists all stories of a topic',
		sub {
			my $g		= init();
			my $topic_id	= arg();

			foreach my $s ($g->source->stories($topic_id)) {
				print $s, "\n";
			}
		}
	],
	'story' =>	[
		'story {src} {topic_id} {id}',
		'Dumps story data',
		sub {
			my $g		= init();
			my $topic_id	= arg();
			my $id		= arg();

			print get_story($g, $topic_id, $id);
		}
	],
	'delete_story' =>	[
		'delete_story {src} {topic_id} {id}',
		'Deletes a story',
		sub {
            my $g           = init();
            my $topic_id    = arg();
            my $id          = arg();

        	my $story = $g->source->story($topic_id, $id)
    		  	or die "Cannot find story '${topic_id}/${id}'";

            $story->delete();
		}
	],
	'copy_story' =>	[
		'copy_story {src} {topic_id} {id} {new_topic_id} [{new_id}]',
        'Copies a story to a new topic',
		sub {
            my $g           = init();
            my $topic_id    = arg();
            my $id          = arg();
            my $n_topic     = arg();
            my $n_id        = arg_o();

        	my $story = $g->source->story($topic_id, $id)
    		  	or die "Cannot find story '${topic_id}/${id}'";

            if (!$n_id) {
                $n_id = $story->new_id();
            }

            my @tags = $story->tags();

            # pick the list of comments
            my @c = $g->source->story_comments($story, 1);

            $story->set('id',       $n_id);
            $story->set('topic_id', $n_topic);

            foreach my $c (@c) {
                my $comment = $g->source->comment($c->[0], $c->[1], $c->[2])
                    or die "Cannot find comment '$c->[0]/$c->[1]/$c->[2]";

                $comment->set('topic_id',   $n_topic);
                $comment->set('story_id',   $n_id);

                $comment->save();
            }

            $story->tags(@tags);

            $story->save();
		}
	],
	'edit_story' =>	[
		'edit_story {src} {topic_id} {id}',
		'Calls $EDITOR to edit story data',
		sub {
			my $g		= init();
			my $topic_id	= arg();
			my $id		= arg();

			my $fh = File::Temp->new();
			print $fh get_story($g, $topic_id, $id);
			my $fn = $fh->filename();
			$fh->close();

			my $mtime = (stat($fn))[9];
			system('$EDITOR ' . $fn);

			if ($mtime != (stat($fn))[9]) {
				save_story($g, $topic_id, $id, $fn);
			}
		}
	],
	'new_story' =>	[
		'new_story {src} {topic_id} [{id}]',
		'Creates a new story from STDIN',
		sub {
			my $g		= init();
			my $topic_id	= arg();
			my $id		= arg_o();

			my $fh = File::Temp->new();
			print $fh join('', <>);
			my $fn = $fh->filename();
			$fh->close();

			save_story($g, $topic_id, $id, $fn, 1);
		}
	],
	'update_story' =>	[
		'update_story {src} {topic_id} {id}',
		'Updates a story from STDIN',
		sub {
			my $g		= init();
			my $topic_id	= arg();
			my $id		= arg();

			my $fh = File::Temp->new();
			print $fh join('', <>);
			my $fn = $fh->filename();
			$fh->close();

			save_story($g, $topic_id, $id, $fn);
		}
	],
	'filter_story' => [
		'filter_story {src} {topic_id} {id} {command}',
		'Filters story data through command (STDIN, STDOUT)',
		sub {
			my $g		= init();
			my $topic_id	= arg();
			my $id		= arg();
			my $filter_cmd	= arg();

			my $fhr = File::Temp->new();
			print $fhr get_story($g, $topic_id, $id);
			my $fnr = $fhr->filename();
			$fhr->close();

			my $fhw = File::Temp->new();
			my $fnw = $fhw->filename();
			$fhw->close();

			system("$filter_cmd < $fnr > $fnw");

			save_story($g, $topic_id, $id, $fnw);
		}
	],
	'create' =>	[
		'create {src}',
		'Creates {src}',
		sub {
			init();
		}
	],
	'tags' =>	[
		'tags {src}',
		'Lists all tags in {src}',
		sub {
			my $g	= init();

			foreach my $t ($g->source->tags()) {
				print join(' ', @{$t}), "\n";
			}
		}
	],
	'stories_by_date' => [
		'stories_by_date {src} {topic(s)} {num} {offset} [{from}] [{to}] [{future}]',
		'Searches stories by date',
		sub {
			my $g		= init();
			my $topics	= arg();
			my $num		= arg();
			my $offset	= arg();
			my $from	= arg_o();
			my $to		= arg_o();
			my $future	= arg_o();

			if ($topics) {
				$topics = [ split(':', $topics) ];
			}

			foreach my $s ($g->source->stories_by_date(
					$topics,
					num	=> $num,
					offset	=> $offset,
					from	=> $from,
					to	=> $to,
					future	=> $future
				) ) {
                my $story = $g->source->story($s->[0], $s->[1]);
				print join(' ', @{$s}), " ", $story->get('hits'), "\n";
			}
		}
	],
	'stats' =>	[
		'stats {src}',
		'Dumps statistics for {src}',
		sub {
			my $g		= init();
			my $n_topics	= 0;
			my $n_stories	= 0;
			my $n_hits	= 0;

			foreach my $t ($g->source->topics()) {
				$n_topics++;

				foreach my $s ($g->source->stories($t)) {
					$n_stories++;

					my $story = $g->source->story($t, $s);

					$n_hits += $story->get('hits') || 0;
				}
			}

			print "Topics: $n_topics, Stories: $n_stories, Hits: $n_hits\n";
		}
	],
	'stories_by_tag' => [
		'stories_by_tag {src} {topic(s)} {tag(s)}',
		'Searches stories by tag(s)',
		sub {
			my $g		= init();
			my $topics	= arg();
			my $tags	= arg();

			if ($topics) {
				$topics = [ split(':', $topics) ];
			}

			foreach my $s ($g->source->stories_by_tag($topics, $tags)) {
				print join(' ', @{$s}), "\n";
			}
		}
	],
	'untagged_stories' => [
		'untagged_stories {src}',
		'Searches stories without tags',
		sub {
			my $g		= init();

			foreach my $s ($g->source->untagged_stories()) {
				print join(' ', @{$s}), "\n";
			}
		}
	],
    'rename_tag' => [
        'rename_tag {src} {tag} [new tag]',
        'Renames (or removes) a tag',
        sub {
            my $g       = init();
            my $old_tag = arg();
            my $new_tag = arg_o();

            foreach my $s ($g->source->stories_by_tag('', $old_tag)) {
                my $story = $g->source->story($s->[0], $s->[1]);

                my @tags = $story->tags();

                my @new_tags = ();

                if ($new_tag) {
                    @tags = map { $_ eq $old_tag ? $new_tag : $_ } @tags;
                }
                else {
                    @tags = grep { ! /^$old_tag$/ } @tags;
                }

                $story->tags(@tags);
                $story->save();
            }
        }
    ],
	'search'	=> [
		'search {src} {topic(s)} {query}',
		'Searches stories by content',
		sub {
			my $g		= init();
			my $topics	= arg();
			my $query	= arg();

			if ($topics) {
				$topics = [ split(':', $topics) ];
			}

			foreach my $s ($g->source->stories_by_text($topics, $query)) {
				print join(' ', @{$s}), "\n";
			}
		}
	],
	'top_ten'	=> [
		'top_ten {src} [{num}]',
		'Shows the top N stories',
		sub {
			my $g		= init();
			my $num		= arg_o() || 10;

			foreach my $s ($g->source->stories_top_ten($num)) {
				print join(' ', @{$s}), "\n";
			}
		}
	],
	'users_by_xdate'	=> [
		'users_by_xdate {src} [{max_date}]',
		'Lists users by expiration date',
		sub {
			my $g		= init();
			my $max_date	= arg_o() || '99999999999999';

			foreach my $id ($g->source->users()) {
				my $u = $g->source->user($id);

				my $xdate = $u->get('xdate');

				if ($xdate &&
					$xdate gt Gruta::Data::today() &&
					$xdate lt $max_date) {
					print $id, ' ', $u->{email}, ' ', $xdate, "\n";
				}
			}
		}
	],
	'set_story_date' =>	[
		'set_story_date {src} {topic_id} {id} {date}',
		'Sets the date of a story (YYYYMMDDHHMMSS; -, now; =NNN, Unix time)',
		sub {
			my $g		= init();
			my $topic_id	= arg();
			my $id		= arg();
			my $date	= arg();

			my $story = $g->source->story($topic_id, $id)
			or die "Cannot find story '${topic_id}/${id}'";

			if ($date eq '-') {
				$date = Gruta::Data::today();
			}
            elsif ($date =~ /^=(\d+)/) {
                my ($S, $M, $H, $d, $m, $y) = (localtime($1))[0..5];

                $date = sprintf('%04d%02d%02d%02d%02d%02d',
                                1900 + $y, $m + 1, $d, $H, $M, $S
                );
            }

			$story->set('date', $date);
			$story->save();
		}
	],
	'pending_comments'	=> [
		'pending_comments {src}',
		'Lists all comments with approval pending',
		sub {
			my $g		= init();

			foreach my $e ($g->source->pending_comments()) {
				print join(' ', @{$e}), "\n";
			}
		}
	],
	'comments'	=> [
		'comments {src} [{max}]',
		'Lists all comments',
		sub {
			my $g		= init();
			my $max		= arg_o();

			foreach my $e ($g->source->comments($max)) {
				print join(' ', @{$e}), "\n";
			}
		}
	],
	'comment'			=> [
		'comment {src} {topic_id} {story_id} {id}',
		'Dumps comment data',
		sub {
			my $g			= init();
			my $topic_id	= arg();
			my $story_id	= arg();
			my $id			= arg();

			my $c = $g->source->comment($topic_id, $story_id, $id)
				or die "Cannot find comment '${topic_id}/${story_id}/${id}'";

			foreach my $f ($c->fields()) {
				if ($f ne 'content') {
					print $f, ': ', ($c->get($f) || ''), "\n";
				}
			}

			print "\n", $c->get('content'), "\n";
		}
	],
	'new_comment'		=> [
		'new_comment {src} {topic_id} {story_id} [{author}]',
		'Adds a new comment from STDIN',
		sub {
			my $g			= init();
			my $topic_id	= arg();
			my $story_id	= arg();
			my $author		= arg_o() || '';

			my $s = $g->source->story($topic_id, $story_id)
				or die "Cannot find story $topic_id, $story_id";

			my $content = join('', <>);

			my $c = new Gruta::Data::Comment(
				topic_id 	=> $topic_id,
				story_id	=> $story_id,
				author		=> $author,
				content		=> $content
			);

			$g->source->insert_comment($c);
		}
	],
	'approve_comment' => [
		'approve_comment {src} {topic_id} {story_id} {id}',
		'Approves a comment',
		sub {
			my $g			= init();
			my $topic_id	= arg();
			my $story_id	= arg();
			my $id			= arg();

			my $c = $g->source->comment($topic_id, $story_id, $id)
				or die "Cannot find comment '${topic_id}/${story_id}/${id}'";

			$c->approve();
		}
	],
	'delete_comment' => [
		'delete_comment {src} {topic_id} {story_id} {id}',
		'Deletes a comment',
		sub {
			my $g			= init();
			my $topic_id	= arg();
			my $story_id	= arg();
			my $id			= arg();

			my $c = $g->source->comment($topic_id, $story_id, $id)
				or die "Cannot find comment '${topic_id}/${story_id}/${id}'";

			$c->delete();
		}
	],
	'story_comments'	=> [
		'story_comments {src} {topic_id} {story_id} [{include_not_approved}]',
		'Lists all comments for a story',
		sub {
			my $g			= init();
			my $topic_id	= arg();
			my $story_id	= arg();
			my $all			= arg_o();

			my $story = $g->source->story($topic_id, $story_id)
				or die "Cannot find story '$topic_id, $story_id'";

			foreach my $c ($g->source->story_comments($story, $all)) {
					print join(" ", @{$c}), "\n";
			}
		}
	],
	'related_stories'	=> [
		'related_stories {src} {topic_id} {id} [{max}]',
		'Returns a list of stories related to the specified one',
		sub {
			my $g			= init();
			my $topic_id	= arg();
			my $story_id	= arg();
			my $max			= arg_o();

			my $story = $g->source->story($topic_id, $story_id)
				or die "Cannot find story '$topic_id, $story_id'";

			foreach my $i ($g->source->related_stories($story, $max)) {
					print join(" ", @{$i}), "\n";
			}
		}
	],
    'export_json'       => [
        'export_json {src}',
        'Exports a source to JSON format',
        sub {
            export_json(@_);
        }
    ],
	'story_set' => [
		'story_set {src} [{key} {value}...]',
		'Searches stories with different criteria (see docs)',
		sub {
			my $g		= init();
            my %args = ();

            for (;;) {
                my $k = arg_o();
                my $v = arg_o();

                if (!$k || !$v) {
                    last;
                }

                if ($k eq 'topics' or $k eq 'tags') {
                    $v = [ split(/\s*,\s*/, $v) ];
                }

                $args{$k} = $v;
            }

			foreach my $s ($g->source->story_set(%args)) {
                my $story = $g->source->story($s->[0], $s->[1]);
				print join(' ', @{$s}), " ", $story->get('hits'), "\n";
			}
		}
	],
	'import_rss'		=> [
		'import_rss {src} {topic_id} [{tag(s)}]',
		'Imports an RSS from STDIN into a topic',
		sub {
			my $g		= init();
			my $topic_id	= arg();
			my $tags	= arg_o() || '';

			my @tags = split(/,\s*/, $tags);

			use Digest::MD5;
			use Encode qw(encode_utf8);
			require XML::Feed;

			my $feed = XML::Feed->parse(\*STDIN) or die XML::Feed->errstr;

			foreach my $entry ($feed->entries()) {
				my $title = $entry->title();

				my $content = "<h1>$title</h1>" . $entry->content->body();

				my $d = $entry->modified() || $entry->issued();
				my $date;

				if ($d) {
					$date = sprintf("%04d%02d%02d%02d%02d%02d",
					$d->year(), $d->month(), $d->day(),
					$d->hour(), $d->minute(), $d->second());
				}
				else {
					$date = Gruta::Data::today();
				}

				my $md5 = Digest::MD5->new();
				$md5->add(encode_utf8($date));
				$md5->add(encode_utf8($content));
				my $id = $md5->hexdigest();

				my $story;

				if (not $story = $g->source->story($topic_id, $id)) {
					$story = Gruta::Data::Story->new (
						topic_id	=> $topic_id,
						id		=> $id
					);
				}

				$story->set('date', $date);
				$story->set('format', 'html');
				$story->set('content', $content);
				$story->set('ctime', time());

				$g->render($story);

				if (@tags) {
					$story->tags(@tags);
				}

				if ($story->source()) {
					$story = $story->save();
				}
				else {
					$story = $g->source->insert_story($story);
				}
			}
		}
	]
};

my $cmd = arg();

my $c;

if (not $c = $X->{$cmd}) {
	$cmd = undef;
	usage();
}

# execute
$c->[2]();

exit 0;


sub arg
{
	if (@ARGV) {
		return shift(@ARGV);
	}

	usage();
}


sub arg_o
{
	return shift(@ARGV) || shift;
}

sub init
{
	my $src = new_source(arg());
	my $g	= Gruta->new(
		source => $src,
		renderers	=> [
			Gruta::Renderer::Grutatxt->new(),
			Gruta::Renderer::HTML->new(),
			Gruta::Renderer::HTML->new( valid_tags => undef ),
			Gruta::Renderer::Text->new(),
		]
	);

	return $g;
}


sub usage
{
	print "Gruta - command line tool\n";
	print "=========================\n\n";

    print "Manipulates Gruta sources directly.\n\n";
	print "(C) Angel Ortega angel\@triptico.com\n\n";

	print "Usage:\n\n";
	print "    gruta {command} {src} [{arguments} ...]\n\n";

    print "Where {src} is a Gruta source spec. Examples:\n\n";
    print " * /var/www/site_dir/var (FS type)\n";
    print " * dbi:SQLite:/var/www/site_dir/var/gruta.db (Perl DBI type)\n";
    print "\n";

	my @keys = sort keys %{$X};

	if ($cmd) {
		@keys = ( $cmd );
	}

	foreach my $k (@keys) {
		my $c = $X->{$k};

		my $s = $k;
		print $s, "\n";
		$s =~ s/./-/g;
		print $s, "\n\n";

		print "    ", $c->[0], "\n\n";

		print $c->[1], "\n\n";
	}

	exit 1;
}

sub new_source
{
	my $src_str = shift;
	my $src;

	if ($src_str =~ /^dbi:/) {
		$src = Gruta::Source::DBI->new( string => $src_str );
	}
	else {
		$src = Gruta::Source::FS->new( path => $src_str );
	}

	return $src;
}

sub get_story
{
	my $g		= shift;
	my $topic_id	= shift;
	my $id		= shift;
	my @r		= ();

	my $story = $g->source->story($topic_id, $id)
			or die "Cannot find story '${topic_id}/${id}'";

	foreach my $f ($story->fields()) {
		if ($f ne 'content' && $f ne 'tags') {
			push (@r, $f . ': ' . ($story->get($f) || ''));
		}
	}

	push(@r, 'tags: ' . join(', ', $story->tags()));
	push(@r, '');
	push(@r, $story->get('content'));
	push(@r, '');

	return join("\n", @r);
}


sub get_topic
{
	my $g		= shift;
	my $topic_id	= shift;
	my @r		= ();

	my $topic = $g->source->topic($topic_id);

	foreach my $f ($topic->afields()) {
		push(@r, $f . ': ' . ($topic->get($f) || ''));
	}

	return join("\n", @r);
}


sub save_story
{
	my $g		= shift;
	my $topic_id	= shift;
	my $id		= shift;
	my $fn		= shift;
	my $new		= shift;
	my $tags;

	open F, $fn or die "Can't open $fn";

	my $story = undef;

	if ($id && !$new) {
		$story = $g->source->story($topic_id, $id)
			or die "Cannot find story '${topic_id}/${id}'";
	}
	else {
		$story = Gruta::Data::Story->new (
			topic_id	=> $topic_id,
			id		=> $id,
			date		=> Gruta::Data::today(),
			format		=> 'grutatxt'
		);
	}

	while (<F>) {
		chomp();

		last if /^$/;

		my ($key, $value) = (/^(\w+):\s*(.*)$/);

		if (!$key) {
			$_ .= "\n";
			last;
		}

		if ($key eq 'tags') {
			$tags = $value;
		}
		elsif ($value) {
			$story->set($key, $value);
		}
	}

	my $c = join('', $_, <F>);
	close F;

	$story->set('content', $c);
	$g->render($story);

	if ($tags) {
		$story->tags(split(/,\s*/, $tags));
	}

	if ($story->source()) {
		$story->save();
	}
	else {
		$g->source->insert_story($story);
	}
}


sub save_topic
{
	my $g		= shift;
	my $topic_id	= shift;
	my $fn		= shift;
	my $new		= shift;

	open F, $fn or die "Can't open $fn";

	my $topic = undef;

	if ($topic_id && !$new) {
		$topic = $g->source->topic($topic_id);
	}
	else {
		$topic = Gruta::Data::Topic->new (
			id	=> $topic_id,
		);
	}

	while (<F>) {
		chomp();

		last if /^$/;

		my ($key, $value) = (/^(\w+):\s*(.*)$/);

		$topic->set($key, $value);
	}

	if ($topic->source()) {
		$topic->save();
	}
	else {
		$g->source->insert_topic($topic);
	}
}


sub js_quote
{
    my $str = shift;

    $str ||= '';

    $str =~ s/\\/\\\\/g;
    $str =~ s/\r/\\r/g;
    $str =~ s/\n/\\n/g;
    $str =~ s/"/\\"/g;

    return $str;
}


sub export_json
{
    my $g   = init();

    print "{";

    # topics
    print "\n  \"topics\": {";

    print join(",",
        map {
            my $topic = $_;
            my $o = '';

            $o =    "\n    \"$topic\": {";

            my $t = $g->source->topic($topic);

            $o .= join(",",
                map {
                    my $f = $_;

                    # key
                    my $o = "\n      \"$_\": ";

                    if ($f eq 'editors') {
                        $o .= '[ ' . join(', ',
                            map { '"' . $_ . '"' }
                                split(/,\s*/, js_quote($t->get($f)))
                        ) . ' ]';
                    }
                    else {
                        $o .= "\"" . js_quote($t->get($f)) . "\"";
                    }

                    $_ = $o;
                } $t->afields()
            );

            $o .=   "\n    }";

            $_ = $o;
        } $g->source->topics()
    );

    # end of topics
    print "\n  },";

    # users
    print "\n  \"users\": {";

    print join(",",
        map {
            my $user = $_;
            my $o = '';

            $o =    "\n    \"$user\": {";

            my $t = $g->source->user($user);

            $o .= join(",",
                map {
                    my $f = $_;

                    # key
                    my $o = "\n      \"$_\": ";

                    $o .= "\"" . js_quote($t->get($f)) . "\"";

                    $_ = $o;
                } $t->afields()
            );

            $o .=   "\n    }";

            $_ = $o;
        } $g->source->users()
    );

    # end of users
    print "\n  },";

    # templates
    print "\n  \"templates\": {";

    print join(",",
        map {
            my $id = $_;
            my $o = '';

            $o =    "\n    \"$id\": {";

            my $t = $g->source->template($id);

            $o .= join(",",
                map {
                    my $f = $_;

                    # key
                    my $o = "\n      \"$f\": ";

                    $o .= "\"" . js_quote($t->get($f)) . "\"";

                    $_ = $o;
                } $t->afields()
            );

            $o .=   "\n    }";

            $_ = $o;
        } $g->source->templates()
    );

    # end of templates
    print "\n  },";

    # stories
    print "\n  \"stories\": {";

    print join(",",
        map {
            my $topic   = $_->[0];
            my $id      = $_->[1];

            my $o = '';

            $o =    "\n    \"$topic\@$id\": {";

            my $t = $g->source->story($topic, $id);

            $o .= "\n      \"comments\": { " .
                join(', ', map {
                        my $topic   = $_->[0];
                        my $story   = $_->[1];
                        my $id      = $_->[2];

                        my $c = $g->source->comment($topic, $story, $id);

                        my $o;

                        $o .= "\n        \"" . $id . "\": {";

                        $o .= join(',', map {
                                "\n          \"" . $_ .
                                "\": \"" . js_quote($c->get($_)) . "\"";
                            } $c->afields()
                        );

                        $o .= "\n        }";
                    } $g->source->story_comments($t)
                ) . "\n      },";

            $o .= join(",",
                map {
                    my $f = $_;

                    # key
                    my $o = "\n      \"$_\": ";

                    if ($f eq 'editors' or $f eq 'tags') {
                        $o .= '[ ' . join(', ',
                            map { '"' . $_ . '"' }
                                split(/,\s*/, js_quote($t->get($f)))
                        ) . ' ]';
                    }
                    else {
                        $o .= "\"" . js_quote($t->get($f)) . "\"";
                    }

                    $_ = $o;
                } $t->afields()
            );

            $o .=   "\n    }";

            $_ = $o;
        } $g->source->stories_by_date([ $g->source->topics() ],
                future => 1, num => 0, offset => 0)
    );

    # end of stories
    print "\n  }";

    print "\n}\n";
}
