Fixed bug in topic and story metadata saving.
[gruta.git] / Gruta / Source / FS.pm
blob001cc2c38f237e20aa11b50a1f6c67b02a310695
1 package Gruta::Source::FS;
3 use base 'Gruta::Source::BASE';
5 use strict;
6 use warnings;
8 use Gruta::Data;
10 package Gruta::Data::FS::BASE;
12 use Carp;
14 sub ext {
15 return '.M';
18 sub _filename {
19 my $self = shift;
21 $self->_assert();
22 $self->source->_assert();
24 return $self->source->{path} . $self->base() .
25 $self->get('id') . $self->ext();
29 sub load {
30 my $self = shift;
31 my $driver = shift;
33 $self->source( $driver );
35 # rename old .META files into .M
36 my $filename = $self->_filename();
37 rename($filename . 'ETA', $filename);
39 if (not open F, $filename) {
40 return undef;
43 while (<F>) {
44 chop;
46 if(/^([^:]*): (.*)$/) {
47 my ($key, $value) = ($1, $2);
49 $key =~ s/-/_/g;
51 if (grep (/^$key$/, $self->fields())) {
52 $self->set($key, $value);
57 close F;
59 return $self;
62 sub save {
63 my $self = shift;
64 my $driver = shift;
66 $self->source( $driver ) if $driver;
68 my $filename = $self->_filename();
70 open F, '>' . $filename or croak "Can't write " . $filename . ': ' . $!;
72 foreach my $k ($self->fields()) {
73 my $f = $k;
75 $f =~ s/_/-/g;
77 print F $f . ': ' . ($self->get($k) || '') . "\n";
80 close F;
82 return $self;
86 sub delete {
87 my $self = shift;
88 my $driver = shift;
90 $self->source( $driver ) if $driver;
92 unlink $self->_filename();
94 return $self;
97 package Gruta::Data::FS::Story;
99 use base 'Gruta::Data::Story';
100 use base 'Gruta::Data::FS::BASE';
102 use Carp;
104 sub base {
105 return Gruta::Data::FS::Topic::base() . $_[0]->get('topic_id') . '/';
108 sub fields {
109 grep !/(content|topic_id|abstract|body)/, $_[0]->SUPER::fields();
112 sub vfields {
113 return ($_[0]->SUPER::vfields(), 'content', 'topic_id', 'abstract', 'body');
116 sub _destroy_index {
117 my $self = shift;
119 my $filename = $self->_filename();
121 # destroy the topic index, to be rewritten
122 # in the future by _topic_index()
123 $filename =~ s!/[^/]+$!/.INDEX!;
124 unlink $filename;
127 sub save {
128 my $self = shift;
129 my $driver = shift;
131 $self->SUPER::save( $driver );
133 my $filename = $self->_filename();
134 $filename =~ s/\.M$//;
136 my @d = ('', 'content', '.A', 'abstract', '.B', 'body');
138 while (@d) {
139 my $ext = shift(@d);
140 my $field = shift(@d);
142 open F, '>' . $filename . $ext or
143 croak "Cannot write " . $filename . $ext . ': ' . $!;
144 print F $self->get($field) || '';
145 close F;
148 $self->_destroy_index();
150 return $self;
153 sub touch {
154 my $self = shift;
156 if (! $self->source->dummy_touch()) {
157 my $hits = $self->get('hits') + 1;
159 $self->set('hits', $hits);
161 # call $self->SUPER::save() instead of $self->save()
162 # to avoid saving content (unnecessary) and deleting
163 # the topic INDEX (even probably dangerous)
164 $self->SUPER::save();
166 $self->source->_update_top_ten($hits, $self->get('topic_id'),
167 $self->get('id'));
170 return $self;
173 sub tags {
174 my $self = shift;
175 my @ret = ();
177 my $filename = $self->_filename();
178 $filename =~ s/\.M$/.T/;
180 if (scalar(@_)) {
181 if (open F, '>' . $filename) {
182 print F join(', ', map { s/^\s+//; s/\s+$//; lc($_) } @_), "\n";
183 close F;
186 else {
187 if (open F, $filename) {
188 my $l = <F>;
189 close F;
191 chomp($l);
192 @ret = split(/\s*,\s*/, $l);
196 return @ret;
199 sub delete {
200 my $self = shift;
201 my $driver = shift;
203 my $file = $self->_filename();
205 $self->SUPER::delete($driver);
207 # also delete content and tags
208 $file =~ s/\.M$//;
210 unlink $file;
211 unlink $file . '.A';
212 unlink $file . '.B';
213 unlink $file . '.T';
215 $self->_destroy_index();
217 return $self;
221 sub load {
222 my $self = shift;
223 my $driver = shift;
225 if (!$self->SUPER::load( $driver )) {
226 return undef;
229 my $filename = $self->_filename();
230 $filename =~ s/\.M$//;
232 rename($filename . '.TAGS', $filename . '.T');
234 return $self;
238 package Gruta::Data::FS::Topic;
240 use base 'Gruta::Data::Topic';
241 use base 'Gruta::Data::FS::BASE';
243 sub base {
244 return '/topics/';
247 sub save {
248 my $self = shift;
249 my $driver = shift;
251 $self->SUPER::save( $driver );
253 my $filename = $self->_filename();
254 $filename =~ s/\.M$//;
256 mkdir $filename;
258 return $self;
261 package Gruta::Data::FS::User;
263 use base 'Gruta::Data::User';
264 use base 'Gruta::Data::FS::BASE';
266 sub ext {
267 return '';
270 sub base {
271 return '/users/';
274 package Gruta::Data::FS::Session;
276 use base 'Gruta::Data::Session';
277 use base 'Gruta::Data::FS::BASE';
279 sub ext {
280 return '';
283 sub base {
284 return '/sids/';
287 package Gruta::Data::FS::Template;
289 use base 'Gruta::Data::Template';
290 use base 'Gruta::Data::FS::BASE';
292 sub base {
293 return '/templates/';
296 sub ext {
297 return '';
300 sub load {
301 my $self = shift;
302 my $driver = shift;
304 $self->source($driver);
306 if (not open(F, $self->_filename())) {
307 return undef;
310 $self->set('content', join('', <F>));
311 close F;
313 return $self;
317 sub save {
318 my $self = shift;
319 my $driver = shift;
321 $self->source($driver) if $driver;
323 if (not open(F, '>' . $self->_filename())) {
324 return undef;
327 print F $self->get('content');
328 close F;
330 return $self;
334 package Gruta::Data::FS::Comment;
336 use base 'Gruta::Data::Comment';
337 use base 'Gruta::Data::FS::BASE';
339 use Carp;
341 sub base {
342 if (!ref($_[0])) {
343 return '/comments/';
346 return '/comments/' . $_[0]->get('topic_id') . '/'
347 . $_[0]->get('story_id') . '/';
350 sub fields {
351 grep !/content/, $_[0]->SUPER::fields();
354 sub vfields {
355 return ($_[0]->SUPER::vfields(), 'content');
359 sub pending_file {
360 my $self = shift;
362 my @p = split('/', $self->_filename());
363 pop(@p);
364 pop(@p);
365 pop(@p);
367 my $pending = join('/', @p) . '/.pending/' .
368 join(':',
369 $self->get('topic_id'),
370 $self->get('story_id'),
371 $self->get('id')
374 return $pending;
378 sub save {
379 my $self = shift;
380 my $driver = shift;
382 $self->source($driver) if $driver;
384 # create the directory tree
385 my @p = split('/', $self->_filename());
386 pop(@p);
387 my $s = pop(@p);
388 mkdir join('/', @p);
389 push(@p, $s);
390 mkdir join('/', @p);
392 $self->SUPER::save($driver);
394 # write content
395 my $filename = $self->_filename();
396 $filename =~ s/\.M$//;
398 open F, '>' . $filename or
399 croak "Cannot write " . $filename . ': ' . $!;
401 print F $self->get('content') || '';
402 close F;
404 # write pending
405 open F, '>' . $self->pending_file();
406 close F;
408 return $self;
412 sub load {
413 my $self = shift;
414 my $driver = shift;
416 if (!$self->SUPER::load($driver)) {
417 return undef;
420 my $filename = $self->_filename();
421 $filename =~ s/\.M$//;
423 if (open F, $filename) {
424 $self->set('content', join('', <F>));
425 close F;
428 return $self;
432 sub delete {
433 my $self = shift;
434 my $driver = shift;
436 # delete content
437 my $file = $self->_filename();
438 $file =~ s/\.M$//;
439 unlink $file;
441 # delete (possible) pending
442 unlink $self->pending_file();
444 $self->SUPER::delete($driver);
448 sub approve {
449 my $self = shift;
451 $self->set('approved', 1);
452 $self->save();
454 # delete (possible) pending
455 unlink $self->pending_file();
457 return $self;
461 package Gruta::Source::FS;
463 use Carp;
465 sub _assert {
466 my $self = shift;
468 $self->{path} or croak "Invalid path";
470 return $self;
473 sub _one {
474 my $self = shift;
475 my $id = shift;
476 my $class = shift;
478 my $o = ${class}->new( id => $id );
479 $o->load( $self );
482 sub topic {
483 return _one( @_, 'Gruta::Data::FS::Topic' );
486 sub topics {
487 my $self = shift;
489 my @ret = ();
491 my $path = $self->{path} . Gruta::Data::FS::Topic::base();
493 if (opendir D, $path) {
494 while (my $id = readdir D) {
495 next unless -d $path . $id;
496 next if $id =~ /^\./;
498 push @ret, $id;
501 closedir D;
504 return @ret;
507 sub user {
508 return _one( @_, 'Gruta::Data::FS::User' );
511 sub users {
512 my $self = shift;
514 my @ret = ();
516 my $path = $self->{path} . Gruta::Data::FS::User::base();
518 if (opendir D, $path) {
519 while (my $id = readdir D) {
520 next if -d $path . $id;
521 push @ret, $id;
524 closedir D;
527 return @ret;
530 sub template {
531 return _one(@_, 'Gruta::Data::FS::Template');
534 sub templates {
535 my $self = shift;
537 my @ret = ();
539 my $path = $self->{path} . Gruta::Data::FS::Template::base();
541 if (opendir D, $path) {
542 while (my $id = readdir D) {
543 next if -d $path . $id;
544 push @ret, $id;
547 closedir D;
550 return @ret;
554 sub comment {
555 my $self = shift;
556 my $topic_id = shift;
557 my $story_id = shift;
558 my $id = shift;
560 my $comment = Gruta::Data::FS::Comment->new(
561 topic_id => $topic_id,
562 story_id => $story_id,
563 id => $id
566 if (not $comment->load($self)) {
567 return undef;
570 return $comment;
574 sub pending_comments {
575 my $self = shift;
577 my @ret = ();
579 my $path = $self->{path} . Gruta::Data::FS::Comment::base()
580 . '/.pending/';
582 if (opendir D, $path) {
583 while (my $id = readdir D) {
584 my $f = $path . $id;
586 next if -d $f;
588 # too old? delete
589 if (-M $f >= 7) {
590 unlink $f;
591 next;
594 push @ret, [ split(':', $id) ];
597 closedir D;
600 return @ret;
604 sub story_comments {
605 my $self = shift;
606 my $story = shift;
607 my $all = shift;
609 my @ret = ();
611 my $topic_id = $story->get('topic_id');
612 my $story_id = $story->get('id');
614 my $base_path = $self->{path} . Gruta::Data::FS::Comment::base();
616 my $pend_path = $base_path . '/.pending/';
617 my $path = join('/', ($base_path, $topic_id, $story_id)) . '/';
619 if (opendir D, $path) {
620 while (my $id = readdir D) {
621 my $f = $path . $id;
623 next if -d $f;
624 next if $f =~ /\.M$/;
626 my $pf = $pend_path . join(':', ($topic_id, $story_id, $id));
628 # too old? delete
629 if (-M $f >= 7) {
630 unlink $f;
631 unlink $pf;
632 next;
635 # not all wanted and this comment not approved? skip
636 if (!$all && -f $pf) {
637 next;
640 push @ret, [ $topic_id, $story_id, $id ];
643 closedir D;
646 return @ret;
650 sub story {
651 my $self = shift;
652 my $topic_id = shift;
653 my $id = shift;
655 my $story;
657 if ($story = $self->cache_story($topic_id, $id)) {
658 return $story;
661 $story = Gruta::Data::FS::Story->new( topic_id => $topic_id, id => $id );
663 if (not $story->load( $self )) {
665 $story = Gruta::Data::FS::Story->new( topic_id => $topic_id . '-arch',
666 id => $id );
668 if (not $story->load( $self )) {
669 return undef;
673 # now load the content
674 my $file = $story->_filename();
675 $file =~ s/\.M$//;
677 my @d = ('', 'content', '.A', 'abstract', '.B', 'body');
679 while (@d) {
680 my $ext = shift(@d);
681 my $field = shift(@d);
683 if (open F, $file . $ext) {
684 $story->set($field, join('', <F>));
685 close F;
689 $self->cache_story($topic_id, $id, $story);
691 return $story;
694 sub stories {
695 my $self = shift;
696 my $topic_id = shift;
698 my @ret = ();
700 if (!$self->topic($topic_id)) {
701 return @ret;
704 my $path = $self->{path} . Gruta::Data::FS::Topic::base() . $topic_id;
706 if (opendir D, $path) {
707 while (my $id = readdir D) {
708 if ($id =~ s/\.M$// || $id =~ s/\.META$//) {
709 push(@ret, $id);
713 closedir D;
716 return @ret;
720 sub _topic_index {
721 my $self = shift;
722 my $topic_id = shift;
724 my $index = $self->{path} . Gruta::Data::FS::Topic::base() . $topic_id;
726 if (! -d $index) {
727 return undef;
730 $index .= '/.INDEX';
732 if (not open I, $index) {
734 my @i = ();
735 foreach my $id ($self->stories($topic_id)) {
736 my $story = $self->story($topic_id, $id);
738 push(@i, ($story->get('date') || ('0' x 14)). ':' . $id);
741 open I, '>' . $index or croak "Can't create INDEX for $topic_id: $!";
742 flock I, 2;
744 foreach my $l (reverse(sort(@i))) {
745 print I $l, "\n";
749 close I;
751 return $index;
755 sub _update_top_ten {
756 my $self = shift;
757 my $hits = shift;
758 my $topic_id = shift;
759 my $id = shift;
761 my $index = $self->{path} . Gruta::Data::FS::Topic::base() . '/.top_ten';
763 my $u = 0;
764 my @l = ();
766 if (open F, $index) {
767 flock F, 1;
768 while (my $l = <F>) {
769 chomp($l);
771 my ($h, $t, $i) = split(':', $l);
773 if ($u == 0 && $h < $hits) {
774 $u = 1;
775 push(@l, "$hits:$topic_id:$id");
778 if ($i ne $id or $t ne $topic_id) {
779 push(@l, $l);
783 close F;
786 if ($u == 0 && scalar(@l) < $self->{hard_top_ten_limit}) {
787 $u = 1;
788 push(@l, "$hits:$topic_id:$id");
791 if ($u) {
792 if (open F, '>' . $index) {
793 flock F, 2;
794 my $n = 0;
796 foreach my $l (@l) {
797 print F $l, "\n";
799 if (++$n == $self->{hard_top_ten_limit}) {
800 last;
804 close F;
808 return undef;
812 sub _stories_by_date {
813 my $self = shift;
814 my $topic_id = shift;
815 my %args = @_;
817 my @r = ();
819 my $i = $self->_topic_index($topic_id) or return @r;
820 open I, $i or return @r;
821 flock I, 1;
823 my $o = 0;
825 while (<I>) {
826 chomp;
828 my ($date, $id) = split(/:/);
830 # skip future stories
831 next if not $args{future} and $date gt Gruta::Data::today();
833 # skip if date is above the threshold
834 next if $args{'to'} and $date gt $args{'to'};
836 # exit if date is below the threshold
837 last if $args{'from'} and $date lt $args{'from'};
839 # skip offset stories
840 next if $args{'offset'} and ++$o <= $args{'offset'};
842 push(@r, [ $topic_id, $id, $date ]);
844 # exit if we have all we need
845 last if $args{'num'} and $args{'num'} == scalar(@r);
848 close I;
850 return @r;
854 sub stories_by_date {
855 my $self = shift;
856 my $topics = shift;
857 my %args = @_;
859 my @topics;
861 if (!$topics) {
862 @topics = $self->topics();
864 else {
865 @topics = @{ $topics };
868 $args{offset} += 0;
869 $args{offset} = 0 if $args{offset} < 0;
871 # only one topic? execute it and return
872 if (scalar(@topics) == 1) {
873 return $self->_stories_by_date($topics[0], %args);
876 # more than one topic; 'num' and 'offset' need to be
877 # calculated from the full set
878 my @R = ();
880 foreach my $topic_id (@topics) {
882 my @r = $self->_stories_by_date($topic_id,
883 %args, num => 0, offset => 0);
885 push(@R, @r);
888 # sort by date
889 @R = sort { $b->[2] cmp $a->[2] } @R;
891 # split now
892 if ($args{num}) {
893 @R = @R[$args{offset} .. ($args{offset} + $args{num} - 1)];
895 else {
896 @R = @R[$args{offset} .. (scalar(@R) - 1)];
899 return grep { defined $_ } @R;
902 sub search_stories {
903 my $self = shift;
904 my $topic_id = shift;
905 my $query = shift;
906 my $future = shift;
908 my @q = split(/\s+/,$query);
910 my %r = ();
912 foreach my $id ($self->stories($topic_id)) {
914 my $story = $self->story($topic_id, $id);
916 if (!$future and $story->get('date') gt Gruta::Data::today()) {
917 next;
920 my $content = $story->get('content');
921 my $found = 0;
923 # try complete query first
924 if($content =~ /\b$query\b/i) {
925 $found = scalar(@q);
927 else {
928 # try separate words
929 foreach my $q (@q) {
930 if(length($q) > 1 and $content =~ /\b$q\b/i) {
931 $found++;
936 if ($found == scalar(@q)) {
937 $r{$id} = $story->get('title');
941 return sort { $r{$a} cmp $r{$b} } keys %r;
944 sub stories_by_text {
945 my $self = shift;
946 my $topics = shift;
947 my $query = shift;
948 my $future = shift;
950 my @ret;
951 my @topics;
953 if (!$topics) {
954 @topics = $self->topics();
956 else {
957 @topics = @{ $topics };
960 foreach my $t (@topics) {
961 foreach my $id ($self->search_stories($t, $query, $future)) {
962 push(@ret, [ $t, $id ]);
966 return @ret;
969 sub stories_top_ten {
970 my $self = shift;
971 my $num = shift;
973 my @r = ();
975 my $index = $self->{path} . Gruta::Data::FS::Topic::base() . '/.top_ten';
977 if (open F, $index) {
978 flock F, 1;
980 while (defined(my $l = <F>) and $num--) {
981 chomp($l);
982 push(@r, [ split(':', $l) ]);
985 close F;
988 return @r;
992 sub _collect_tags {
993 my $self = shift;
994 my @topics = @_;
996 my @ret = ();
998 foreach my $topic_id (@topics) {
1000 my $topic = $self->topic($topic_id);
1002 my $files = $topic->_filename();
1003 $files =~ s/\.M$/\/*.T/;
1005 my @ls = glob($files);
1007 foreach my $f (@ls) {
1008 if (open F, $f) {
1009 my $tags = <F>;
1010 chomp $tags;
1011 close F;
1013 my ($id) = ($f =~ m{/([^/]+)\.T});
1015 push(@ret,
1016 [ $topic_id, $id, [ split(/\s*,\s*/, $tags) ] ]
1022 return @ret;
1026 sub stories_by_tag {
1027 my $self = shift;
1028 my $topics = shift;
1029 my $tag = shift;
1030 my $future = shift;
1032 my @tags = map { lc($_) } split(/\s*,\s*/, $tag);
1034 my @topics;
1036 if (!$topics) {
1037 @topics = $self->topics();
1039 else {
1040 @topics = @{ $topics };
1043 my %r = ();
1045 foreach my $tr ($self->_collect_tags(@topics)) {
1047 my @ts = @{$tr->[2]};
1049 # skip stories with less tags than the wanted ones
1050 if (scalar(@ts) < scalar(@tags)) {
1051 next;
1054 # count matches
1055 my $c = 0;
1057 foreach my $t (@ts) {
1058 if (grep(/^$t$/, @tags)) {
1059 $c++;
1063 if ($c >= scalar(@tags)) {
1065 my $story = $self->story($tr->[0], $tr->[1]);
1067 # if no future stories are wanted, discard them
1068 if (!$future) {
1069 if ($story->get('date') gt Gruta::Data::today()) {
1070 next;
1074 $r{$story->get('title')} =
1075 [ $tr->[0], $tr->[1], $story->get('date') ];
1079 return map { $r{$_} } sort keys %r;
1083 sub tags {
1084 my $self = shift;
1086 my @ret = ();
1087 my %h = ();
1089 foreach my $tr ($self->_collect_tags($self->topics())) {
1091 foreach my $t (@{$tr->[2]}) {
1092 $h{$t}++;
1096 foreach my $k (keys(%h)) {
1097 push(@ret, [ $k, $h{$k} ]);
1100 return sort { $a->[0] cmp $b->[0] } @ret;
1104 sub session {
1105 return _one( @_, 'Gruta::Data::FS::Session' );
1108 sub purge_old_sessions {
1109 my $self = shift;
1111 my $path = $self->{path} . Gruta::Data::FS::Session::base();
1113 if (opendir D, $path) {
1114 while(my $s = readdir D) {
1115 my $f = $path . $s;
1117 next if -d $f;
1119 if (-M $f > 1) {
1120 unlink $f;
1124 closedir D;
1127 return undef;
1131 sub _insert {
1132 my $self = shift;
1133 my $obj = shift;
1134 my $class = shift;
1136 bless($obj, $class);
1137 $obj->save( $self );
1139 return $obj;
1142 sub insert_topic {
1143 $_[0]->_insert($_[1], 'Gruta::Data::FS::Topic');
1146 sub insert_user {
1147 $_[0]->_insert($_[1], 'Gruta::Data::FS::User');
1150 sub insert_template {
1151 $_[0]->_insert($_[1], 'Gruta::Data::FS::Template');
1154 sub insert_comment {
1155 my $self = shift;
1156 my $comment = shift;
1158 if (!$comment->setup($self)) {
1159 return undef;
1162 $self->_insert($comment, 'Gruta::Data::FS::Comment');
1165 sub insert_story {
1166 my $self = shift;
1167 my $story = shift;
1169 if (not $story->get('id')) {
1170 # alloc an id for the story
1171 my $id = undef;
1173 do {
1174 $id = $story->new_id();
1176 } while $self->story($story->get('topic_id'), $id);
1178 $story->set('id', $id);
1181 $self->_insert($story, 'Gruta::Data::FS::Story');
1182 return $story;
1185 sub insert_session {
1186 $_[0]->_insert($_[1], 'Gruta::Data::FS::Session');
1190 sub create {
1191 my $self = shift;
1193 my @l = map { $self->{path} . $_ } (
1194 Gruta::Data::FS::Topic::base(),
1195 Gruta::Data::FS::User::base(),
1196 Gruta::Data::FS::Session::base(),
1197 Gruta::Data::FS::Template::base(),
1198 Gruta::Data::FS::Comment::base(),
1199 Gruta::Data::FS::Comment::base() . '/.pending/'
1202 foreach my $d (@l) {
1203 if (! -d $d) {
1204 mkdir $d, 0755 or die "Cannot mkdir $d";
1208 return $self;
1212 sub new {
1213 my $class = shift;
1215 my $s = bless( { @_ }, $class);
1217 $s->{hard_top_ten_limit} ||= 100;
1219 $s->_assert();
1221 $s->create();
1223 return $s;