FS Story touch() doesn't call save(), but SUPER::save().
[gruta.git] / Gruta / Source / FS.pm
blob13a80b9e453c7af5c1db2cbd471f1f495c02a364
1 package Gruta::Source::FS;
3 use strict;
4 use warnings;
6 use Gruta::Data;
8 package Gruta::Data::FS::BASE;
10 use Carp;
12 sub ext { return '.META'; }
14 sub _filename {
15 my $self = shift;
17 $self->_assert();
18 $self->source->_assert();
20 return $self->source->{path} . $self->base() .
21 $self->get('id') . $self->ext();
25 sub load {
26 my $self = shift;
27 my $driver = shift;
29 $self->source( $driver );
31 if (not open F, $self->_filename()) {
32 return undef;
35 while (<F>) {
36 chop;
38 if(/^([^:]*): (.*)$/) {
39 my ($key, $value) = ($1, $2);
41 $key =~ s/-/_/g;
43 if (grep (/^$key$/, $self->fields())) {
44 $self->set($key, $value);
49 close F;
51 return $self;
54 sub save {
55 my $self = shift;
56 my $driver = shift;
58 $self->source( $driver ) if $driver;
60 my $filename = $self->_filename();
62 open F, '>' . $filename or croak "Can't write " . $filename . ': ' . $!;
64 foreach my $k ($self->fields()) {
65 my $f = $k;
67 $f =~ s/_/-/g;
69 print F $f . ': ' . ($self->get($k) || '') . "\n";
72 close F;
74 return $self;
78 sub delete {
79 my $self = shift;
80 my $driver = shift;
82 $self->source( $driver ) if $driver;
84 unlink $self->_filename();
86 return $self;
89 package Gruta::Data::FS::Story;
91 use base 'Gruta::Data::Story';
92 use base 'Gruta::Data::FS::BASE';
94 use Carp;
96 sub base { return Gruta::Data::FS::Topic::base() . $_[0]->get('topic_id') . '/'; }
98 sub fields { grep !/content/, $_[0]->SUPER::fields(); }
99 sub vfields { return ($_[0]->SUPER::vfields(), 'content'); }
101 sub load {
102 my $self = shift;
103 my $driver = shift;
105 # save current topic id
106 # (as it may be stored in the .META file and
107 # be false, v.g. if archived)
108 my $topic_id = $self->get('topic_id');
110 $self->SUPER::load( $driver );
112 # restore topic id
113 $self->set('topic_id', $topic_id);
115 return $self;
119 sub _destroy_index {
120 my $self = shift;
122 my $filename = $self->_filename();
124 # destroy the topic index, to be rewritten
125 # in the future by _topic_index()
126 $filename =~ s!/[^/]+$!/.INDEX!;
127 unlink $filename;
130 sub save {
131 my $self = shift;
132 my $driver = shift;
134 $self->SUPER::save( $driver );
136 my $filename = $self->_filename();
137 $filename =~ s/\.META$//;
139 open F, '>' . $filename or croak "Can't write " . $filename . ': ' . $!;
141 print F $self->get('content') || '';
142 close F;
144 $self->_destroy_index();
146 return $self;
149 sub touch {
150 my $self = shift;
152 my $hits = $self->get('hits') + 1;
154 $self->set('hits', $hits);
156 # call $self->SUPER::save() instead of $self->save()
157 # to avoid saving content (unnecessary) and deleting
158 # the topic INDEX (even probably dangerous)
159 $self->SUPER::save();
161 $self->source->_update_top_ten($hits, $self->get('topic_id'),
162 $self->get('id'));
164 return $self;
167 sub tags {
168 my $self = shift;
169 my @ret = ();
171 my $filename = $self->_filename();
172 $filename =~ s/\.META$/.TAGS/;
174 if (scalar(@_)) {
175 if (open F, '>' . $filename) {
176 print F join(', ', map { lc($_) } @_), "\n";
177 close F;
180 else {
181 if (open F, $filename) {
182 my $l = <F>;
183 close F;
185 chomp($l);
186 @ret = split(/,\s+/, $l);
190 return @ret;
193 sub delete {
194 my $self = shift;
195 my $driver = shift;
197 my $file = $self->_filename();
199 $self->SUPER::delete($driver);
201 # also delete content and TAGS
202 $file =~ s/\.META$//;
204 unlink $file;
205 unlink $file . '.TAGS';
207 return $self;
211 package Gruta::Data::FS::Topic;
213 use base 'Gruta::Data::Topic';
214 use base 'Gruta::Data::FS::BASE';
216 sub base { return '/topics/'; }
218 sub save {
219 my $self = shift;
220 my $driver = shift;
222 $self->SUPER::save( $driver );
224 my $filename = $self->_filename();
225 $filename =~ s/\.META$//;
227 mkdir $filename;
229 return $self;
232 package Gruta::Data::FS::User;
234 use base 'Gruta::Data::User';
235 use base 'Gruta::Data::FS::BASE';
237 sub ext { return ''; }
238 sub base { return '/users/'; }
240 package Gruta::Data::FS::Session;
242 use base 'Gruta::Data::Session';
243 use base 'Gruta::Data::FS::BASE';
245 sub ext { return ''; }
246 sub base { return '/sids/'; }
248 package Gruta::Source::FS;
250 use Carp;
252 sub _assert {
253 my $self = shift;
255 $self->{path} or croak "Invalid path";
257 return $self;
260 sub _one {
261 my $self = shift;
262 my $id = shift;
263 my $class = shift;
265 my $o = ${class}->new( id => $id );
266 $o->load( $self );
269 sub topic { return _one( @_, 'Gruta::Data::FS::Topic' ); }
271 sub topics {
272 my $self = shift;
274 my @ret = ();
276 my $path = $self->{path} . Gruta::Data::FS::Topic::base();
278 if (opendir D, $path) {
279 while (my $id = readdir D) {
280 next unless -d $path . $id;
281 next if $id =~ /^\./;
283 push @ret, $id;
286 closedir D;
289 return @ret;
292 sub user { return _one( @_, 'Gruta::Data::FS::User' ); }
294 sub users {
295 my $self = shift;
297 my @ret = ();
299 my $path = $self->{path} . Gruta::Data::FS::User::base();
301 if (opendir D, $path) {
302 while (my $id = readdir D) {
303 next if -d $path . $id;
304 push @ret, $id;
307 closedir D;
310 return @ret;
313 sub story {
314 my $self = shift;
315 my $topic_id = shift;
316 my $id = shift;
318 my $story = Gruta::Data::FS::Story->new( topic_id => $topic_id, id => $id );
319 if (not $story->load( $self )) {
321 $story = Gruta::Data::FS::Story->new( topic_id => $topic_id . '-arch',
322 id => $id );
324 if (not $story->load( $self )) {
325 return undef;
329 # now load the content
330 my $file = $story->_filename();
331 $file =~ s/\.META$//;
333 open F, $file or croak "Can't open $file content: $!";
335 $story->set('content', join('', <F>));
336 close F;
338 return $story;
341 sub stories {
342 my $self = shift;
343 my $topic_id = shift;
345 my @ret = ();
347 my $path = $self->{path} . Gruta::Data::FS::Topic::base() . $topic_id;
349 if (opendir D, $path) {
350 while (my $id = readdir D) {
351 if ($id =~ s/\.META$//) {
352 push(@ret, $id);
356 closedir D;
359 return @ret;
363 sub _topic_index {
364 my $self = shift;
365 my $topic_id = shift;
367 my $index = $self->{path} . Gruta::Data::FS::Topic::base() .
368 $topic_id . '/.INDEX';
370 if (not open I, $index) {
372 my @i = ();
373 foreach my $id ($self->stories($topic_id)) {
374 my $story = $self->story($topic_id, $id);
376 push(@i, $story->get('date') . ':' . $id);
379 open I, '>' . $index or croak "Can't create INDEX for $topic_id: $!";
380 flock I, 2;
382 foreach my $l (reverse(sort(@i))) {
383 print I $l, "\n";
387 close I;
389 return $index;
393 sub _update_top_ten {
394 my $self = shift;
395 my $hits = shift;
396 my $topic_id = shift;
397 my $id = shift;
399 my $index = $self->{path} . Gruta::Data::FS::Topic::base() . '/.top_ten';
401 my $u = 0;
402 my @l = ();
404 if (open F, $index) {
405 flock F, 1;
406 while (my $l = <F>) {
407 chomp($l);
409 my ($h, $t, $i) = split(':', $l);
411 if ($u == 0 && $h < $hits) {
412 $u = 1;
413 push(@l, "$hits:$topic_id:$id");
416 if ($t ne $topic_id or $i ne $id) {
417 push(@l, $l);
421 close F;
424 if ($u == 0 && scalar(@l) < 100) {
425 $u = 1;
426 push(@l, "$hits:$topic_id:$id");
429 if ($u) {
430 if (open F, '>' . $index) {
431 flock F, 2;
432 my $n = 0;
434 foreach my $l (@l) {
435 print F $l, "\n";
437 if (++$n == 100) {
438 last;
442 close F;
446 return undef;
450 sub stories_by_date {
451 my $self = shift;
452 my $topic_id = shift;
453 my %args = @_;
455 $args{offset} += 0;
456 $args{offset} = 0 if $args{offset} < 0;
458 open I, $self->_topic_index($topic_id);
459 flock I, 1;
461 my @r = ();
462 my $o = 0;
464 while(<I>) {
465 chomp;
467 my ($date, $id) = (/^(\d*):(.*)$/);
469 # skip future stories
470 next if not $args{future} and
471 $args{today} and
472 $date > $args{today};
474 # skip if date is above the threshold
475 next if $args{'to'} and $date > $args{'to'};
477 # exit if date is below the threshold
478 last if $args{'from'} and $date < $args{'from'};
480 # skip offset stories
481 next if $args{'offset'} and ++$o <= $args{'offset'};
483 push(@r, $id);
485 # exit if we have all we need
486 last if $args{'num'} and $args{'num'} == scalar(@r);
489 close I;
491 return @r;
494 sub search_stories {
495 my $self = shift;
496 my $topic_id = shift;
497 my $query = shift;
499 my @q = split(/\s+/,$query);
501 my @r = ();
503 foreach my $id ($self->stories_by_date( $topic_id )) {
505 my $story = $self->story($topic_id, $id);
506 my $content = $story->get('content');
507 my $found = 0;
509 # try complete query first
510 if($content =~ /\b$query\b/i) {
511 $found = 1;
513 else {
514 # try separate words
515 foreach my $q (@q) {
516 if(length($q) > 1 and $content =~ /\b$q\b/i) {
517 $found = 1;
518 last;
523 push(@r, $id) if $found;
526 return @r;
529 sub stories_top_ten {
530 my $self = shift;
531 my $num = shift;
533 my @r = ();
535 my $index = $self->{path} . Gruta::Data::FS::Topic::base() . '/.top_ten';
537 if (open F, $index) {
538 flock F, 1;
540 while (defined(my $l = <F>) and $num--) {
541 chomp($l);
542 push(@r, [ split(':', $l) ]);
545 close F;
548 return @r;
552 sub search_stories_by_tag {
553 my $self = shift;
554 my @tags = shift;
556 my @ret = ();
558 foreach my $topic_id ($self->topics()) {
560 my $topic = $self->topic($topic_id);
562 my $files = $topic->_filename();
563 $files =~ s/\.META$/\/*.TAGS/;
565 my @ls = glob($files);
567 foreach my $f (@ls) {
568 if (open F, $f) {
569 my $tags = <F>;
570 chomp $tags;
571 close F;
573 foreach my $t (split(/,\s+/, $tags)) {
574 if (grep(/$t/, @tags)) {
575 my ($id) = ($f =~ m{/([^/]+)\.TAGS});
577 push(@ret, [ $topic_id, $id ]);
578 last;
585 return @ret;
589 sub tags {
590 my $self = shift;
592 return ();
596 sub session { return _one( @_, 'Gruta::Data::FS::Session' ); }
598 sub purge_old_sessions {
599 my $self = shift;
601 my $path = $self->{path} . Gruta::Data::FS::Session::base();
603 if (opendir D, $path) {
604 while(my $s = readdir D) {
605 my $f = $path . $s;
607 next if -d $f;
609 if (-M $f > 1) {
610 unlink $f;
614 closedir D;
617 return undef;
621 sub _insert {
622 my $self = shift;
623 my $obj = shift;
624 my $class = shift;
626 bless($obj, $class);
627 $obj->save( $self );
629 return $obj;
632 sub insert_topic { $_[0]->_insert($_[1], 'Gruta::Data::FS::Topic'); }
633 sub insert_user { $_[0]->_insert($_[1], 'Gruta::Data::FS::User'); }
635 sub insert_story {
636 my $self = shift;
637 my $story = shift;
639 if (not $story->get('id')) {
640 # alloc an id for the story
641 my $id = time();
643 while ($self->story($story->get('topic_id'), $id)) {
644 $id++;
647 $story->set('id', $id);
650 $self->_insert($story, 'Gruta::Data::FS::Story');
651 return $story;
654 sub insert_session { $_[0]->_insert($_[1], 'Gruta::Data::FS::Session'); }
657 sub create {
658 my $self = shift;
660 mkdir $self->{path}, 0755;
661 mkdir $self->{path} . Gruta::Data::FS::Topic::base(), 0755;
662 mkdir $self->{path} . Gruta::Data::FS::User::base(), 0755;
663 mkdir $self->{path} . Gruta::Data::FS::Session::base(), 0755;
667 sub new {
668 my $class = shift;
670 my $s = bless( { @_ }, $class);
672 $s->_assert();
674 return $s;