mark: A photo of Mark kneeling on top of the Taal Volcano in the Philippines. It was a long hike. (Default)
Mark Smith ([staff profile] mark) wrote in [site community profile] changelog2009-09-05 05:56 am

[dw-free] implement watch groups

[commit: http://hg.dwscoalition.org/dw-free/rev/16b221eba307]

http://bugs.dwscoalition.org/show_bug.cgi?id=166

Okay, this is the alpha commit of subscription filters (watch filters,
reading groups, etc). It works, everything is functional, but the UI needs
a lot of improvement. Patches and suggestions welcome.

Patch by [staff profile] mark.

Files modified:
  • bin/test/test-filters
  • bin/upgrading/update-db-general.pl
  • cgi-bin/Apache/LiveJournal.pm
  • cgi-bin/DW/Logic/LogItems.pm
  • cgi-bin/DW/User/ContentFilters.pm
  • cgi-bin/DW/User/ContentFilters/Filter.pm
  • cgi-bin/LJ/Entry.pm
  • cgi-bin/LJ/S2/FriendsPage.pm
  • cgi-bin/LJ/User.pm
  • cgi-bin/ljdefaults.pl
  • cgi-bin/ljlib.pl
  • htdocs/js/json2.js
  • htdocs/js/subfilters.js
  • htdocs/manage/circle/editfilters.bml
  • htdocs/manage/circle/editfilters.bml.text
  • htdocs/manage/subscriptions/filters.bml
  • htdocs/stc/subfilters.css
  • htdocs/tools/endpoints/contentfilters.bml
  • htdocs/tools/endpoints/general.bml
--------------------------------------------------------------------------------
diff -r 6d723288b164 -r 16b221eba307 bin/test/test-filters
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/bin/test/test-filters	Sat Sep 05 05:55:56 2009 +0000
@@ -0,0 +1,147 @@
+#!/usr/bin/perl
+# yes, there are so many better testing modules than just doing this in a crappy
+# half-ass way.
+
+use strict;
+use lib "$ENV{LJHOME}/cgi-bin";
+require 'ljlib.pl';
+
+use Data::Dumper;
+
+# much time passes ...
+my $u1 = LJ::load_user( 'tester0394' ) or die 'no tester0394';
+my $u2 = LJ::load_user( 'tester0395' ) or die 'no tester0395';
+my $uc = LJ::load_user( 'testcomm' ) or die 'no testcomm';
+$uc->is_community or die 'testcomm not a community :(';
+
+my @tests;
+print "Beginning tests...\n";
+print "    u1 = " . $u1->user . '(' . $u1->id . ")\n";
+print "    u2 = " . $u2->user . '(' . $u2->id . ")\n";
+print "    uc = " . $uc->user . '(' . $uc->id . ")\n\n";
+
+my $dbh = LJ::get_db_writer();
+
+# reset, delete, etc
+sub rst {
+    foreach my $u ( $u1, $u2, $uc ) {
+        foreach my $tbl ( qw/ content_filters content_filter_data / ) {
+            $u->do( "DELETE FROM $tbl WHERE userid = ?", undef, $u->id );
+        }
+
+        foreach my $mc ( qw/ content_filters / ) {
+            LJ::memcache_kill( $u, $mc );
+        }
+    }
+}
+
+################################################################################
+push @tests, [ 'no filters', sub
+{
+    rst();
+    my @f = $u1->content_filters;
+    return $#f == -1;  # empty list
+} ];
+
+
+################################################################################
+push @tests, [ 'make empty filter', sub
+{
+    my $fid = $u1->create_content_filter( name => 'foob', public => 1, sortorder => 13 );
+    return $fid > 0;
+} ];
+
+
+################################################################################
+push @tests, [ 'get empty filter', sub
+{
+    my $filter = $u1->content_filters( name => 'foob' );
+    return $filter->name eq 'foob';
+} ];
+
+
+################################################################################
+push @tests, [ 'make another filter', sub
+{
+    my $fid = $u1->create_content_filter( name => 'isfd', public => 0, sortorder => 31 );
+    my $f = $u1->content_filters( id => $fid );
+    return $fid > 0 && $f->name eq 'isfd';
+} ];
+
+
+################################################################################
+push @tests, [ 'get second filter', sub
+{
+    my $filter = $u1->content_filters( name => 'isfd' );
+    return $filter->name eq 'isfd';
+} ];
+
+
+################################################################################
+push @tests, [ 'get bogus filter', sub
+{
+    my $filter = $u1->content_filters( name => 'sodf' );
+    return ! defined $filter;
+} ];
+
+
+################################################################################
+push @tests, [ 'get both filters', sub
+{
+    my @filters = $u1->content_filters;
+    return $#filters == 1;
+} ];
+
+
+################################################################################
+push @tests, [ 'get data, is empty', sub
+{
+    my $f = $u1->content_filters( name => 'foob' );
+    my $data = $f->data;
+    return defined $data && ref $data eq 'HASH' && scalar keys %$data == 0;
+} ];
+
+
+################################################################################
+push @tests, [ 'add a row', sub
+{
+    my $f = $u1->content_filters( name => 'foob' );
+    my $rv = $f->add_row( userid => $u2->id );
+    return $rv == 1;
+} ];
+
+
+################################################################################
+push @tests, [ 'get data, has u2', sub
+{
+    my $f = $u1->content_filters( name => 'foob' );
+    my $data = $f->data;
+    return $data && exists $data->{$u2->id};
+} ];
+
+
+################################################################################
+push @tests, [ 'delete filter', sub
+{
+    my $f = $u1->delete_content_filter( name => 'foob' );
+    return $f > 0;
+} ];
+
+
+################################################################################
+$| = 1;
+my $id = 1;
+foreach my $test ( @tests ) {
+    print "Test #$id: $test->[0]: ";
+    my $rv = 0;
+    eval {
+        $rv = $test->[1]->();
+    };
+    if ( $@ || $rv == 0 ) {
+        print "failure!\n\n\$@ = $@\n\$! = $!\nrv = $rv\n\n";
+        die;
+    } else {
+        print "success\n";
+    }
+    $id++;
+}
diff -r 6d723288b164 -r 16b221eba307 bin/upgrading/update-db-general.pl
--- a/bin/upgrading/update-db-general.pl	Thu Sep 03 22:46:30 2009 +0000
+++ b/bin/upgrading/update-db-general.pl	Sat Sep 05 05:55:56 2009 +0000
@@ -3176,6 +3176,29 @@ CREATE TABLE users_for_paid_accounts (
 )
 EOC
 
+register_tablecreate('content_filters', <<'EOC');
+CREATE TABLE content_filters (
+  userid int(10) unsigned NOT NULL,
+  filterid int(10) unsigned NOT NULL,
+  filtername varchar(255) NOT NULL,
+  is_public enum('0','1') NOT NULL default '0',
+  sortorder smallint(5) unsigned NOT NULL default '0',
+
+  PRIMARY KEY (userid,filterid),
+  UNIQUE KEY userid (userid,filtername)
+)
+EOC
+
+register_tablecreate('content_filter_data', <<'EOC');
+CREATE TABLE content_filter_data (
+  userid int(10) unsigned NOT NULL,
+  filterid int(10) unsigned NOT NULL,
+  data mediumblob NOT NULL,
+
+  PRIMARY KEY (userid,filterid)
+) 
+EOC
+
 
 # NOTE: new table declarations go ABOVE here ;)
 
diff -r 6d723288b164 -r 16b221eba307 cgi-bin/Apache/LiveJournal.pm
--- a/cgi-bin/Apache/LiveJournal.pm	Thu Sep 03 22:46:30 2009 +0000
+++ b/cgi-bin/Apache/LiveJournal.pm	Sat Sep 05 05:55:56 2009 +0000
@@ -1432,16 +1432,16 @@ sub journal_content
     {
         # give a real 404 to the journal owner
         if ($remote && $remote->{'user'} eq $user) {
-            $status = "404 Reading filter does not exist";
+            $status = "404 Content filter does not exist";
             $html = "<h1>Not Found</h1>" .
-                    "<p>The reading filter you are trying to access does not exist.</p>";
+                    "<p>The content filter you are trying to access does not exist.</p>";
 
         # otherwise be vague with a 403
         } else {
             # send back a 403 and don't reveal if the group existed or not
-            $status = "403 Reading filter does not exist, or is not public";
+            $status = "403 Content filter does not exist, or is not public";
             $html = "<h1>Denied</h1>" .
-                    "<p>Sorry, the reading filter you are trying to access does not exist " .
+                    "<p>Sorry, the content filter you are trying to access does not exist " .
                     "or is not public.</p>\n";
 
             $html .= "<p>You're not logged in.  If you're the owner of this journal, " .
diff -r 6d723288b164 -r 16b221eba307 cgi-bin/DW/Logic/LogItems.pm
--- a/cgi-bin/DW/Logic/LogItems.pm	Thu Sep 03 22:46:30 2009 +0000
+++ b/cgi-bin/DW/Logic/LogItems.pm	Sat Sep 05 05:55:56 2009 +0000
@@ -28,58 +28,47 @@ use Carp qw/ confess /;
 #           - remote
 #           - itemshow
 #           - skip
-#           - filter  (opt) defaults to all
 #           - friends (opt) friends rows loaded via [func[LJ::get_friends]]
 #           - friends_u (opt) u objects of all friends loaded
 #           - idsbycluster (opt) hashref to set clusterid key to [ [ journalid, itemid ]+ ]
 #           - dateformat:  either "S2" for S2 code, or anything else for S1
-#           - common_filter:  set true if this is the default view
 #           - friendsoffriends: load friends of friends, not just friends
 #           - showtypes: /[PICNYF]/
 #           - events_date: date to load events for ($u must have friendspage_per_day)
+#           - content_filter: object of type DW::User::ContentFilters::Filter
 # returns: Array of item hashrefs containing the same elements
 sub watch_items
 {
     my ( $u, %args ) = @_;
     $u = LJ::want_user( $u ) or confess 'invalid user object';
 
-    my $userid = $u->id;
-    return () if $LJ::FORCE_EMPTY_FRIENDS{$userid};
+    # bail very, very early for accounts that are too big for a reading page
+    return () if $LJ::FORCE_EMPTY_FRIENDS{$u->id};
 
-    my $dbr = LJ::get_db_reader()
-        or return ();
+    # not sure where best to do this, so we're doing it here: don't allow
+    # content filters for community reading pages.
+    delete $args{content_filter} unless $u->is_individual;
 
-    my $remote = LJ::want_user( delete $args{remote} );
-    my $remoteid = $remote ? $remote->id : 0;
+    # we only allow viewing protected content on your own reading page, if you
+    # are viewing someone else's reading page, we assume you're logged out
+    my $remote = LJ::get_remote();
+    $remote = undef if $remote && $remote->id != $u->id;
 
-    # if ONLY_USER_VHOSTS is on (where each user gets his/her own domain),
-    # then assume we're also using domain-session cookies, and assume
-    # domain session cookies should be as most useless as possible,
-    # so don't let friends pages on other domains have protected content
-    # because really, nobody reads other people's friends pages anyway
-    if ($LJ::ONLY_USER_VHOSTS && $remote && $remoteid != $userid) {
-        $remote = undef;
-        $remoteid = 0;
-    }
-
+    # prepare some variables for later... many variables
     my @items = ();
     my $itemshow = $args{itemshow}+0;
     my $skip = $args{skip}+0;
+    $skip = 0 if $skip < 0;
     my $getitems = $itemshow + $skip;
 
-    # friendspage per day is allowed only for journals with
-    # special cap 'friendspage_per_day'
-    my $events_date = ( ( $remoteid == $userid ) && $u->get_cap('friendspage_per_day') )
-                        ? $args{events_date}
-                        : '';
+    # friendspage per day is allowed only for journals with the special cap 'friendspage_per_day'
+    my $events_date = $args{event_date};
+    $events_date = '' unless $remote && $u->get_cap( 'friendspage_per_day' );
 
-    my $filter  = int $args{filter};
+    my $filter  = $args{content_filter};
     my $max_age = $LJ::MAX_FRIENDS_VIEW_AGE || 3600*24*14;  # 2 week default.
     my $lastmax = $LJ::EndOfTime - ($events_date || (time() - $max_age));
     my $lastmax_cutoff = 0; # if nonzero, never search for entries with rlogtime higher than this (set when cache in use)
-
-    # sanity check:
-    $skip = 0 if $skip < 0;
 
     # given a hash of friends rows, strip out rows with invalid journaltype
     my $filter_journaltypes = sub {
@@ -122,6 +111,14 @@ sub watch_items
         # get all friends for this user and groupmask
         my $friends = $u->watch_list( community_okay => 1 );
         my %friends_u;
+
+        # strip out people who aren't in the filter, if we have one
+        if ( $filter ) {
+            foreach my $fid ( keys %$friends ) {
+                delete $friends->{$fid}
+                    unless $filter->contains_userid( $fid );
+            }
+        }
 
         # strip out rows with invalid journal types
         $filter_journaltypes->($friends, \%friends_u);
@@ -182,8 +179,8 @@ sub watch_items
         foreach my $fid (keys %$friends) {
             $allfriends{$fid}++;
 
-            # delete from friends if it doesn't match the filter
-            next unless $filter && ! ($friends->{$fid}->{'groupmask'}+0 & $filter+0);
+            # if the person is in an active filter, allow them, else delete them
+            next unless $filter && ! $filter->contains_userid( $fid );
             delete $friends->{$fid};
         }
 
@@ -203,7 +200,7 @@ sub watch_items
             my $ct = 0;
             while (my $ffid = each %$ff) {
                 last if $ct > 100;
-                next if $allfriends{$ffid} || $ffid == $userid;
+                next if $allfriends{$ffid} || $ffid == $u->id;
                 $ffriends{$ffid} = $ff->{$ffid};
                 $ct++;
             }
@@ -259,25 +256,24 @@ sub watch_items
         $args{friends_u}->{$friendid} = $fr->[4]; # friend u object
 
         my @newitems = LJ::get_log2_recent_user({
-            'clusterid'   => $fr->[2],
-            'userid'      => $friendid,
-            'remote'      => $remote,
-            'itemshow'    => $itemsleft,
-            'notafter'    => $lastmax,
-            'dateformat'  => $args{dateformat},
-            'update'      => $LJ::EndOfTime - $fr->[1], # reverse back to normal
-            'events_date' => $events_date,
+            clusterid   => $fr->[2],
+            userid      => $friendid,
+            remote      => $remote,
+            itemshow    => $itemsleft,
+            filter      => $filter,
+            notafter    => $lastmax,
+            dateformat  => $args{dateformat},
+            update      => $LJ::EndOfTime - $fr->[1], # reverse back to normal
+            events_date => $events_date,
         });
 
         # stamp each with clusterid if from cluster, so ljviews and other
         # callers will know which items are old (no/0 clusterid) and which
         # are new
-        if ($fr->[2]) {
-            foreach (@newitems) { $_->{'clusterid'} = $fr->[2]; }
-        }
+        $_->{clusterid} = $fr->[2]
+            foreach @newitems;
 
-        if (@newitems)
-        {
+        if (@newitems) {
             push @items, @newitems;
 
             $itemsleft--;
@@ -295,8 +291,7 @@ sub watch_items
             @items = splice(@items, 0, $getitems) if (@items > $getitems);
         }
 
-        if (@items == $getitems)
-        {
+        if (@items == $getitems) {
             $lastmax = $items[-1]->{'rlogtime'};
             $lastmax = $lastmax_cutoff if $lastmax_cutoff && $lastmax > $lastmax_cutoff;
 
diff -r 6d723288b164 -r 16b221eba307 cgi-bin/DW/User/ContentFilters.pm
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cgi-bin/DW/User/ContentFilters.pm	Sat Sep 05 05:55:56 2009 +0000
@@ -0,0 +1,160 @@
+#!/usr/bin/perl
+#
+# DW::User::ContentFilters
+#
+# This module allows working with watch filters, the constructs that enable
+# a user to filter content on their reading page.
+#
+# Authors:
+#      Mark Smith <mark@dreamwidth.org>
+#
+# Copyright (c) 2009 by Dreamwidth Studios, LLC.
+#
+# This program is free software; you may redistribute it and/or modify it under
+# the same terms as Perl itself.  For a copy of the license, please reference
+# 'perldoc perlartistic' or 'perldoc perlgpl'.
+#
+
+###############################################################################
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
+###############################################################################
+
+package DW::User::ContentFilters;
+use strict;
+
+use DW::User::ContentFilters::Filter;
+
+# loads a list of a user's filters, returns them in a list sorted by their
+# given sort order.  note that you can specify an argument to return only
+# public filters or not.
+#
+#    my @filters = $u->content_filters( public => 1 );
+#
+# or don't include the argument to return all filters (default).  returns
+# objects that are some subclass of DW::User::ContentFilters::Abstract.
+sub content_filters {
+    my $u = LJ::want_user( shift() )
+        or die 'Must call on a user object';
+    my %args = ( @_ );
+
+    # now return what they want, remember these are objects now, so sort them
+    # by the sortorder
+    my $sort_filters = sub {
+        my @list =
+            sort { $a->sortorder <=> $b->sortorder }
+            grep { $args{public} ? $_->public : 1 }
+            grep { $args{name} ? $_->name eq $args{name} : 1 }
+            grep { $args{id} ? $_->id == $args{id} : 1 }
+            @_;
+        return wantarray ? @list : $list[0];
+    };
+
+    # we do this here because we don't want to try to memcache the actual
+    # objects which might contain random data, so instead we're just caching
+    # what the db tells us.  so we have to reconstitute the objects every
+    # time, which is what this does.
+    my $build_filters = sub {
+        # now promote everything to an object
+        return 
+            sort {
+                $a->sortorder <=> $b->sortorder ||
+                $a->name      cmp $b->name
+            }
+            map {
+                DW::User::ContentFilters::Filter->new(
+                    ownerid   => $u->id,
+                    id        => $_->[0],
+                    name      => $_->[1],
+                    public    => $_->[2],
+                    sortorder => $_->[3],
+                )
+            } @_;
+    };
+
+    # if on the user object, they're already built
+    return $sort_filters->( @{ $u->{_content_filters} } )
+        if $u->{_content_filters};
+
+    # if in memcache, build and return
+    my $filters = $u->memc_get( 'content_filters' );
+    return $sort_filters->( $build_filters->( @$filters ) )
+        if $filters;
+
+    # try the database now
+    $filters = $u->selectall_arrayref(
+        q{SELECT filterid, filtername, is_public, sortorder
+          FROM content_filters
+          WHERE userid = ?},
+        undef, $u->id
+    );
+
+    # and make sure it goes into memcache for an hour
+    $u->memc_set( 'content_filters', $filters, 3600 );
+
+    # store on the user object in case they call us later so we don't have to
+    # do more memcache roundtrips
+    $u->{_content_filters} = [ $build_filters->( @$filters ) ];
+    return $sort_filters->( @{ $u->{_content_filters} } );
+}
+*LJ::User::content_filters = \&content_filters;
+
+
+# makes a new watch filter for the user.  pretty easy to use, everything is actually
+# optional...
+sub create_content_filter {
+    my ( $u, %args ) = @_;
+
+    # FIXME: this is probably the point we should implement limits on how many
+    # filters you can create...
+
+    # check if a filter with this name already exists
+    my $name = LJ::trim( LJ::text_trim( delete $args{name}, 255, 100 ) ) || '';
+    return undef
+        if $u->content_filters( name => $name );
+
+    # we need a filterid, or #-1 FAILURE MODE IMMINENT
+    my $fid = LJ::alloc_user_counter( $u, 'F' )
+        or die 'unable to allocate counter';
+
+    # database insert
+    $u->do(
+        q{INSERT INTO content_filters (userid, filterid, filtername, is_public, sortorder)
+          VALUES (?, ?, ?, ?, ?)},
+        undef, $u->id, $fid, $name,
+        ( $args{public} ? '1' : '0' ), ( $args{sortorder} + 0 ),
+    );
+    die $u->errstr if $u->err;
+
+    # everything is OK, so clear memcache, user object
+    delete $u->{_content_filters};
+    $u->memc_delete( 'content_filters' );
+    return $fid;
+}
+*LJ::User::create_content_filter = \&create_content_filter;
+
+
+# removes a content filter.  arguments are the same as what you'd pass to content_filters
+# to get a filter, and if it returns just one filter, we'll nuke it.
+sub delete_content_filter {
+    my ( $u, %args ) = @_;
+
+    # import to use the array return so that we get all of the filters that match
+    # the query and can make sure it only returns one.
+    my @filters = $u->content_filters( %args );
+    die "tried to delete more than one content filter in a single call to delete_content_filter\n"
+        if scalar( @filters ) > 1;
+    return undef unless @filters;
+
+    # delete
+    $u->do( 'DELETE FROM content_filters WHERE userid = ? AND filterid = ?', undef, $u->id, $filters[0]->id );
+    $u->do( 'DELETE FROM content_filter_data WHERE userid = ? AND filterid = ?', undef, $u->id, $filters[0]->id );
+    delete $u->{_content_filters};
+    $u->memc_delete( 'content_filters' );
+
+    # return the id of the deleted filter
+    return $filters[0]->id;
+}
+*LJ::User::delete_content_filter = \&delete_content_filter;
+
+
+1;
diff -r 6d723288b164 -r 16b221eba307 cgi-bin/DW/User/ContentFilters/Filter.pm
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/cgi-bin/DW/User/ContentFilters/Filter.pm	Sat Sep 05 05:55:56 2009 +0000
@@ -0,0 +1,363 @@
+#!/usr/bin/perl
+#
+# DW::User::ContentFilters::Filter
+#
+# This represents the actual filters that we can apply to a reading page or
+# general content view.
+#
+# Authors:
+#      Mark Smith <mark@dreamwidth.org>
+#
+# Copyright (c) 2009 by Dreamwidth Studios, LLC.
+#
+# This program is free software; you may redistribute it and/or modify it under
+# the same terms as Perl itself.  For a copy of the license, please reference
+# 'perldoc perlartistic' or 'perldoc perlgpl'.
+#
+
+###############################################################################
+# # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # # #
+###############################################################################
+
+package DW::User::ContentFilters::Filter;
+use strict;
+
+use Storable qw/ nfreeze thaw /;
+
+
+# this just returns a base object from the input parameters.  the object itself
+# is not particularly useful, as it won't have loaded the data for the filter
+# itself.  things are lazy loaded only when needed.
+sub new {
+    my ( $class, %args ) = @_;
+
+    my $self = bless { %args }, $class;
+    return undef unless $self->_valid;
+    return $self;
+}
+
+
+# internal validator, returns undef if we are an invalid object
+sub _valid {
+    my $self = $_[0];
+
+    return 0 unless
+        $self->ownerid > 0 &&      # valid userid/owner
+        $self->id > 0;
+
+    return 1;
+}
+
+
+# method for creating a new row to the filter.  available arguments:
+#
+#   $filter->add_row(
+#       userid => 353,     # userid to add to the filter
+#
+#       tags =>
+#           [
+#               tagid,
+#               tagid,
+#               tagid,
+#               ...
+#           ],
+#
+#       tag_mode => '...',             # enum( 'any_of', 'all_of', 'none_of' )
+#
+#       adult_content => '...',        # enum( 'any', 'nonexplicit', 'sfw' )
+#
+#       poster_type => '...',          # enum( 'any', 'maintainer', 'moderator' )
+#   );
+#
+sub add_row {
+    my ( $self, %args ) = @_;
+
+    # ensure the data they gave us is sufficient
+    my $t_userid = delete $args{userid};
+    my $tu = LJ::load_userid( $t_userid )
+        or die "add_row: userid invalid\n";
+
+    # see if they gave poster_type
+    my $poster_type = delete $args{postertype} || 'any';
+    die "invalid poster_type\n"
+        if $poster_type && $poster_type !~ /^(?:any|maintainer|moderator)$/;
+
+    # adult_content
+    my $adult_content = delete $args{adultcontent} || 'any';
+    die "invalid adult_content\n"
+        if $adult_content && $adult_content !~ /^(?:any|nonexplicit|sfw)$/;
+
+    # tag mode
+    my $tag_mode = delete $args{tagmode} || 'any_of';
+    die "invalid tag_mode\n"
+        if $tag_mode && $tag_mode !~ /^(?:any_of|all_of|none_of)$/;
+
+    # tags
+    # FIXME: validate that the tagids are valid for this user...?
+    my $tags = delete $args{tags} || [];
+    die "tags must be an arrayref\n"
+        if ref $tags ne 'ARRAY';
+
+    # if any more args, something is bunk
+    die "add_row: extraneous arguments.\n"
+        if %args;
+
+    # build the row we're going to add
+    my %newrow = (
+        tags => $tags,
+        tagmode => $tag_mode,
+        adultcontent => $adult_content,
+        postertype => $poster_type,
+    );
+
+    # now delete the defaults
+    delete $newrow{tagmode} if $newrow{tagmode} eq 'any_of';
+    delete $newrow{postertype} if $newrow{postertype} eq 'any';
+    delete $newrow{adultcontent} if $newrow{adultcontent} eq 'any';
+
+    # now get the data for this filter
+    my $data = $self->data;
+    $data->{$t_userid} = \%newrow;
+    $self->_save;
+
+    return 1;
+}
+
+
+# make sure that our data is loaded up
+sub data {
+    my $self = $_[0];
+
+    # object first
+    return $self->{_data}
+        if exists $self->{_data};
+
+    # try memcache second
+    my $u = $self->owner;
+    my $data = $u->memc_get( 'cfd:' . $self->id );
+    return $self->{_data} = $data
+        if $data;
+
+    # fall back to the database
+    my $data = $u->selectrow_array(
+        q{SELECT data FROM content_filter_data WHERE userid = ? AND filterid = ?},
+        undef, $u->id, $self->id
+    );
+    die $u->errstr if $u->err;
+
+    # now decompose it
+    $data = thaw( $data );
+
+    # we default to using an empty hashref, just in case this filter doesn't
+    # have a data row already
+    $data ||= {};
+
+    # now save it in memcache and then the object
+    $u->memc_set( 'cfd:' . $self->id, $data, 3600 );
+    return $self->{_data} = $data;
+}
+
+
+# if this filter contains someone.  this is a very basic level check you can use
+# to quickly and easily filter out accounts that don't exist in a filter at all
+# before having to do some heavy lifting to load tags, statuses, etc.
+sub contains_userid {
+    my ( $self, $userid ) = @_;
+
+    return 1 if exists $self->data->{$userid};
+    return 0;
+}
+
+
+# called with an item hashref or LJ::Entry object, determines whether or not this
+# filter allows this entry to be shown
+sub show_entry {
+    my ( $self, $item ) = @_;
+
+    # these helpers are mostly for debugging help so we can make sure that the
+    # various logic paths work ok
+    my ( $ok, $fail, $u );
+    if ( $LJ::IS_DEV_SERVER ) {
+        $ok = sub {
+            warn sprintf( "[$$] %s(%d): OK journalid=%d, jitemid=%d, ownerid=%d: %s\n",
+                    $u->user, $u->id, $item->{journalid}, $item->{jitemid}, $item->{ownerid}, shift() );
+            return 1;
+        };
+        $fail = sub {
+            warn sprintf( "[$$] %s(%d): FAIL journalid=%d, jitemid=%d, ownerid=%d: %s\n",
+                    $u->user, $u->id, $item->{journalid}, $item->{jitemid}, $item->{ownerid}, shift() );
+            return 0;
+        };
+
+    } else {
+        $ok = sub { return 1; };
+        $fail = sub { return 0; };
+    }
+
+    # short circuit: if our owner is not a paid account, then we never run any of
+    # the below checks.  (this saves us in the situation where a paid users has
+    # custom filters and expires.  they go back to being basic filters, but the
+    # user doesn't LOSE the filters.)
+    $u = $self->owner;
+    return $ok->( 'free_user' ) unless $u->is_paid;
+
+    # okay, we need the entry object.  a little note here, this is fairly efficient
+    # because LJ::get_log2_recent_log actually creates all of the singletons for
+    # the entries it touches.  so when we call some sort of 'load data on something'
+    # on one of the entries, then it loads on all of them.  (FIXME: verify this
+    # by watching memcache/db queries.)
+    my $entry = LJ::Entry->new_from_item_hash( $item );
+    my ( $journalu, $posteru ) = ( $entry->journal, $entry->poster );
+
+    # now we have to get the parameters to this particular filter row
+    my $opts = $self->data->{$journalu->id} || {};
+
+    # step 1) community poster type
+    if ( $journalu->is_community && $opts->{postertype} && $opts->{postertype} ne 'any' ) {
+        my $is_admin = LJ::can_manage_other( $posteru, $journalu );
+        my $is_moderator = $is_admin || LJ::check_rel( $journalu, $posteru, 'M' );
+
+        return $fail->( 'not_maintainer' )
+            if $opts->{postertype} eq 'maintainer' && ! $is_admin;
+
+        return $fail->( 'not_moderator_or_maintainer' )
+            if $opts->{postertype} eq 'moderator' && ! ( $is_admin || $is_moderator );
+    }
+
+    # step 2) adult content flag
+    if ( $opts->{adultcontent} ne 'any' ) {
+        my $aclevel = $entry->adult_content_calculated;
+
+        if ( $aclevel ) {
+            return $fail->( 'explicit_content' )
+                if $opts->{adultcontent} eq 'nonexplicit' && $aclevel eq 'explicit';
+
+            return $fail->( 'not_safe_for_work' )
+                if $opts->{adultcontent} eq 'sfw' && $aclevel ne 'none';
+        }
+    }
+
+    # step 3) tags, but only if they actually selected some
+    my @tagids = @{ $opts->{tags} || [] };
+    if ( scalar @tagids > 0 ) {
+        # we change the initial state to make the logic below easier
+        my $include = {
+                none_of => 1,
+                any_of => 0,
+                all_of => 0,
+            }->{$opts->{tagmode} || 'any_of'};
+        return $fail->( 'bad_tagmode' ) unless defined $include;
+
+        # now iterate over each tag and alter $include
+        my $tags = $entry->tag_map || {};
+        foreach my $id ( @tagids ) {
+            foreach my $id2 ( keys %$tags ) {
+
+                # any_of: unconditionally turn on this entry if we match one tag
+                $include = 1
+                    if $opts->{tagmode} eq 'any_of' && $id2 == $id;
+
+                # none_of: unconditionally turn off this entry if we match one tag
+                $include = 0
+                    if $opts->{tagmode} eq 'none_of' && $id2 == $id;
+
+                # all_of: increment $include for each matched tag
+                $include++
+                    if $opts->{tagmode} eq 'all_of' && $id2 == $id;
+            }
+        }
+
+        # failed all_of if include doesn't match size of tags
+        return $fail->( 'failed_all_of_tag_select' )
+            if $opts->{tagmode} eq 'all_of' && ( $include != scalar @tagids );
+
+        # otherwise, treat it as a boolean
+        return $fail->( 'failed_tag_select' ) unless $include;
+    }
+
+    # if we get here, then this entry looks good, include it
+    return $ok->( 'success' );
+}
+
+
+# meant to be called internally only by the filter object and not by cowboys
+# that think they're smarter than us.  that's why it has a prefixed underscore.
+# sometimes I do wish for a real language with real OO concepts like private
+# methods and such.
+sub _save {
+    my $self = $_[0];
+
+    my $u = $self->owner;
+    my $data = $self->data;  # do this in case we called _save before load
+
+    $u->do(
+        q{REPLACE INTO content_filter_data (userid, filterid, data) VALUES (?, ?, ?)},
+        undef, $u->id, $self->id, nfreeze( $data )
+    );
+    die $u->errstr if $u->err;
+
+    $u->memc_set( 'cfd:' . $self->id, $data, 3600 );
+
+    return 1;
+}
+
+
+# some simple accessors... we don't really support using these as setters
+# FIXME: we should sanitize on the object creation, not in these getters,
+# ...just hacking this together right now
+sub id        { $_[0]->{id}+0           }
+sub ownerid   { $_[0]->{ownerid}+0      }
+
+
+# getter/setters
+sub name      { $_[0]->_getset( name => $_[1] );      }
+sub public    { $_[0]->_getset( public => $_[1] );    }
+sub sortorder { $_[0]->_getset( sortorder => $_[1] ); }
+
+
+# other helpers
+sub owner     { LJ::load_userid( $_[0]->{ownerid}+0 ) }
+
+
+# generic helper thingy
+sub _getset {
+    my ( $self, $which, $val ) = @_;
+
+    # if no argument, just bail
+    return $self->{$which} unless defined $val;
+
+    # FIXME: we should probably have generic vetters somewhere... or something, I don't know,
+    # I just know that I don't really like doing this here
+    if ( $which eq 'name' ) {
+        $val = LJ::trim( LJ::text_trim( $val, 255, 100 ) ) || '';
+    } elsif ( $which eq 'public' ) {
+        $val = $val ? 1 : 0;
+    } elsif ( $which eq 'sortorder' ) {
+        $val += 0;
+    } else {
+        # this should never happen if you updated this function right...
+        die 'Programmer needs food badly.  Programmer is about to die!';
+    }
+
+    # make sure to update this object
+    $self->{$which} = $val;
+
+    # stupid hack for column name mapping
+    $which = 'is_public' if $which eq 'public';
+    $which = 'filtername' if $which eq 'name';
+
+    # update the database
+    my $u = $self->owner;
+    $u->do( "UPDATE content_filters SET $which = ? WHERE userid = ? AND filterid = ?",
+            undef, $val, $u->id, $self->id );
+    die $u->errstr if $u->err;
+
+    # clear memcache and the object
+    delete $u->{_content_filters};
+    $u->memc_delete( 'content_filters' );
+
+    return $val;
+}
+
+
+1;
diff -r 6d723288b164 -r 16b221eba307 cgi-bin/LJ/Entry.pm
--- a/cgi-bin/LJ/Entry.pm	Thu Sep 03 22:46:30 2009 +0000
+++ b/cgi-bin/LJ/Entry.pm	Sat Sep 05 05:55:56 2009 +0000
@@ -1530,6 +1530,7 @@ sub get_log2_recent_log
     return $construct_singleton->();
 }
 
+# get recent entries for a user
 sub get_log2_recent_user
 {
     my $opts = shift;
@@ -1541,6 +1542,7 @@ sub get_log2_recent_user
     my $left     = $opts->{'itemshow'};
     my $notafter = $opts->{'notafter'};
     my $remote   = $opts->{'remote'};
+    my $filter   = $opts->{filter};
 
     my %mask_for_remote = (); # jid => mask for $remote
     foreach my $item (@$log) {
@@ -1583,6 +1585,10 @@ sub get_log2_recent_user
         } else {
             confess "We removed S1 support, sorry.";
         }
+
+        # now see if this item matches the filter
+        next if $filter && ! $filter->show_entry( $item );
+
         push @$ret, $item;
     }
 
diff -r 6d723288b164 -r 16b221eba307 cgi-bin/LJ/S2/FriendsPage.pm
--- a/cgi-bin/LJ/S2/FriendsPage.pm	Thu Sep 03 22:46:30 2009 +0000
+++ b/cgi-bin/LJ/S2/FriendsPage.pm	Sat Sep 05 05:55:56 2009 +0000
@@ -19,8 +19,6 @@ sub FriendsPage
     $p->{'entries'} = [];
     $p->{'friends'} = {};
     $p->{'friends_title'} = LJ::ehtml($u->{'friendspagetitle'});
-    $p->{'filter_active'} = 0;
-    $p->{'filter_name'} = "";
 
     # Add a friends-specific XRDS reference
     $p->{'head_content'} .= qq{<meta http-equiv="X-XRDS-Location" content="}.LJ::ehtml($u->journal_base).qq{/data/yadis/friends" />\n};
@@ -82,62 +80,46 @@ sub FriendsPage
     if ($skip < 0) { $skip = 0; }
     my $itemload = $itemshow+$skip;
 
-    my $filter;
-    my $group_name    = '';
-    my $common_filter = 1;
     my $events_date   = ($get->{date} =~ m!^(\d{4})-(\d\d)-(\d\d)$!)
                         ? LJ::mysqldate_to_time("$1-$2-$3")
                         : 0;
 
-    if (defined $get->{'filter'} && $remote && $remote->{'user'} eq $user) {
-        $filter = $get->{'filter'};
-        $common_filter = 0;
-        $p->{'filter_active'} = 1;
-        $p->{'filter_name'} = "";
-    } else {
+    # allow toggling network mode
+    $p->{friends_mode} = 'network'
+        if $opts->{view} eq 'network';
 
-        # Show group or day log
-        if ($opts->{'pathextra'}) {
-            $group_name = $opts->{'pathextra'};
-            $group_name =~ s!^/!!;
-            $group_name =~ s!/$!!;
-
-            if ($group_name) {
-                $group_name    = LJ::durl($group_name);
-                $common_filter = 0;
-
-                $p->{'filter_active'} = 1;
-                $p->{'filter_name'}   = LJ::ehtml($group_name);
-            }
-        }
-
-# TODO(mark): WTF broke this, as we no longer use friend groups for watching
-#             and therefore have to implement watch groups.  bug #166
-        my $grp = {};#LJ::get_friend_group($u, { 'name' => $group_name || "Default View" });
-        my $bit = $grp->{'groupnum'};
-        my $public = $grp->{'is_public'};
-        if ($bit && ($public || ($remote && $remote->{'user'} eq $user))) {
-            $filter = (1 << $bit);
-        } elsif ($group_name){
-            $opts->{'badfriendgroup'} = 1;
-            return 1;
-        }
+    # try to get a group name if they specified one
+    my $group_name = '';
+    if ( $group_name = $opts->{pathextra} ) {
+        $group_name =~ s!^/!!;
+        $group_name =~ s!/$!!;
+        $group_name = LJ::durl( $group_name );
     }
 
-    if ($opts->{'view'} eq "network") {
-        $p->{'friends_mode'} = "network";
+    # try to get a content filter, try a specified group name first, fall back to Default,
+    # and failing that try Default View (for the old school diehards)
+    my $cf = $u->content_filters( name => $group_name || "Default" ) ||
+             $u->content_filters( name => "Default View" );
+
+    # but we can't just use a filter, we have to make sure the person is allowed to
+    my $filter;
+    if ( $cf && ( $u->equals( $remote ) || $cf->public ) ) {
+        $filter = $cf;
+
+    # if we couldn't use the group, then we can throw an error, but ONLY IF they specified
+    # a group name manually.  if we tried to load the default on our own, don't toss an
+    # error as that would let a user disable their friends page.
+    } elsif ( $group_name ) {
+        $opts->{badfriendgroup} = 1;  # nobiscuit
+        return 1;
     }
 
     ## load the itemids
-    my %friends;
-    my %friends_row;
-    my %idsbycluster;
+    my ( %friends, %friends_row, %idsbycluster );
     my @items = $u->watch_items(
-        remote            => $remote,
         itemshow          => $itemshow,
         skip              => $skip,
-#        filter            => $filter,
-        common_filter     => $common_filter,
+        content_filter    => $filter,
         friends_u         => \%friends,
         friends           => \%friends_row,
         idsbycluster      => \%idsbycluster,
@@ -364,22 +346,13 @@ sub FriendsPage
         'count' => $eventnum,
     };
 
-    my $base = "$u->{'_journalbase'}/$opts->{'view'}";
-    if ($group_name) {
-        $base .= "/" . LJ::eurl($group_name);
-    }
-
-    # $linkfilter is distinct from $filter: if user has a default view,
-    # $filter is now set according to it but we don't want it to show in the links.
-    # $incfilter may be true even if $filter is 0: user may use filter=0 to turn
-    # off the default group
-    my $linkfilter = $get->{'filter'} + 0;
-    my $incfilter = defined $get->{'filter'};
+    my $base = "$u->{_journalbase}/$opts->{view}";
+    $base .= "/" . LJ::eurl( $group_name )
+        if $group_name;
 
     # if we've skipped down, then we can skip back up
     if ($skip) {
         my %linkvars;
-        $linkvars{'filter'} = $linkfilter if $incfilter;
         $linkvars{'show'} = $get->{'show'} if $get->{'show'} =~ /^\w+$/;
         my $newskip = $skip - $itemshow;
         if ($newskip > 0) { $linkvars{'skip'} = $newskip; }
@@ -388,7 +361,7 @@ sub FriendsPage
         $nav->{'forward_url'} = LJ::make_link($base, \%linkvars);
         $nav->{'forward_skip'} = $newskip;
         $nav->{'forward_count'} = $itemshow;
-        $p->{head_content} .= qq{<link rel="next" href="$nav->{forward_url}" />\n}
+        $p->{head_content} .= qq#<link rel="next" href="$nav->{forward_url}" />\n#;
     }
 
     ## unless we didn't even load as many as we were expecting on this
@@ -397,7 +370,6 @@ sub FriendsPage
     # Must remember to count $hiddenentries or we'll have no skiplinks when > 1
     unless (($eventnum + $hiddenentries) != $itemshow || $skip == $maxskip) {
         my %linkvars;
-        $linkvars{'filter'} = $linkfilter if $incfilter;
         $linkvars{'show'} = $get->{'show'} if $get->{'show'} =~ /^\w+$/;
         $linkvars{'date'} = $get->{'date'} if $get->{'date'};
         my $newskip = $skip + $itemshow;
@@ -405,14 +377,11 @@ sub FriendsPage
         $nav->{'backward_url'} = LJ::make_link($base, \%linkvars);
         $nav->{'backward_skip'} = $newskip;
         $nav->{'backward_count'} = $itemshow;
-        $p->{head_content} .= qq{<link rel="prev" href="$nav->{backward_url}" />\n};
+        $p->{head_content} .= qq#<link rel="prev" href="$nav->{backward_url}" />\n#;
     }
 
-    $p->{'nav'} = $nav;
+    $p->{nav} = $nav;
 
-    if ($get->{'mode'} eq "framed") {
-        $p->{'head_content'} .= "<base target='_top' />";
-    }
     return $p;
 }
 
diff -r 6d723288b164 -r 16b221eba307 cgi-bin/LJ/User.pm
--- a/cgi-bin/LJ/User.pm	Thu Sep 03 22:46:30 2009 +0000
+++ b/cgi-bin/LJ/User.pm	Sat Sep 05 05:55:56 2009 +0000
@@ -23,8 +23,10 @@ use LJ::Constants;
 use LJ::Constants;
 use LJ::MemCache;
 use LJ::Session;
+
+use DW::Logic::ProfilePage;
+use DW::User::ContentFilters;
 use DW::User::Edges;
-use DW::Logic::ProfilePage;
 
 use Class::Autouse qw(
                       LJ::Subscription
@@ -1365,6 +1367,21 @@ sub rollback {
 }
 
 
+sub selectall_arrayref {
+    my $u = shift;
+    my $dbcm = $u->{'_dbcm'} ||= LJ::get_cluster_master($u)
+        or croak $u->nodb_err;
+
+    my $rv = $dbcm->selectall_arrayref(@_);
+
+    if ($u->{_dberr} = $dbcm->err) {
+        $u->{_dberrstr} = $dbcm->errstr;
+    }
+
+    return $rv;
+}
+
+
 sub selectall_hashref {
     my $u = shift;
     my $dbcm = $u->{'_dbcm'} ||= LJ::get_cluster_master($u)
@@ -5652,9 +5669,8 @@ sub unset_remote
 #       'Q' == Notification Inbox, 
 #       'D' == 'moDule embed contents', 'I' == Import data block
 #       'Z' == import status item, 'X' == eXternal account
-#
-# FIXME: both phonepost and vgift are ljcom.  need hooks. but then also
-#        need a separate namespace.  perhaps a separate function/table?
+#       'F' == filter id
+#
 sub alloc_user_counter
 {
     my ($u, $dom, $opts) = @_;
@@ -5662,7 +5678,7 @@ sub alloc_user_counter
 
     ##################################################################
     # IF YOU UPDATE THIS MAKE SURE YOU ADD INITIALIZATION CODE BELOW #
-    return undef unless $dom =~ /^[LTMPSRKCOVEQGDIZX]$/;             #
+    return undef unless $dom =~ /^[LTMPSRKCOVEQGDIZXF]$/;            #
     ##################################################################
 
     my $dbh = LJ::get_db_writer();
@@ -5779,6 +5795,9 @@ sub alloc_user_counter
                                       undef, $uid);
     } elsif ($dom eq "X") {
         $newmax = $u->selectrow_array("SELECT MAX(acctid) FROM externalaccount WHERE userid=?",
+                                      undef, $uid);
+    } elsif ($dom eq "F") {
+        $newmax = $u->selectrow_array("SELECT MAX(filterid) FROM watch_filters WHERE userid=?",
                                       undef, $uid);
     } else {
         die "No user counter initializer defined for area '$dom'.\n";
diff -r 6d723288b164 -r 16b221eba307 cgi-bin/ljdefaults.pl
--- a/cgi-bin/ljdefaults.pl	Thu Sep 03 22:46:30 2009 +0000
+++ b/cgi-bin/ljdefaults.pl	Sat Sep 05 05:55:56 2009 +0000
@@ -284,6 +284,8 @@
                        widget         => "tools/endpoints/widget.bml",
                        multisearch    => "tools/endpoints/multisearch.bml",
                        extacct_auth   => "tools/endpoints/extacct_auth.bml",
+                       contentfilters => "tools/endpoints/contentfilters.bml",
+                       general        => "tools/endpoints/general.bml",
                        );
 
     foreach my $src (keys %ajaxmapping) {
diff -r 6d723288b164 -r 16b221eba307 cgi-bin/ljlib.pl
--- a/cgi-bin/ljlib.pl	Thu Sep 03 22:46:30 2009 +0000
+++ b/cgi-bin/ljlib.pl	Sat Sep 05 05:55:56 2009 +0000
@@ -96,6 +96,7 @@ sub END { LJ::end_request(); }
                     "embedcontent", "usermsg", "usermsgtext", "usermsgprop",
                     "notifyarchive", "notifybookmarks", "pollprop2", "embedcontent_preview",
                     "logprop_history", "import_status", "externalaccount",
+                    "content_filters", "content_filter_data",
                     );
 
 # keep track of what db locks we have out
diff -r 6d723288b164 -r 16b221eba307 htdocs/js/json2.js
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/htdocs/js/json2.js	Sat Sep 05 05:55:56 2009 +0000
@@ -0,0 +1,476 @@
+/*
+    http://www.JSON.org/json2.js
+    2009-06-29
+
+    Public Domain.
+
+    NO WARRANTY EXPRESSED OR IMPLIED. USE AT YOUR OWN RISK.
+
+    See http://www.JSON.org/js.html
+
+    This file creates a global JSON object containing two methods: stringify
+    and parse.
+
+        JSON.stringify(value, replacer, space)
+            value       any JavaScript value, usually an object or array.
+
+            replacer    an optional parameter that determines how object
+                        values are stringified for objects. It can be a
+                        function or an array of strings.
+
+            space       an optional parameter that specifies the indentation
+                        of nested structures. If it is omitted, the text will
+                        be packed without extra whitespace. If it is a number,
+                        it will specify the number of spaces to indent at each
+                        level. If it is a string (such as '\t' or '&nbsp;'),
+                        it contains the characters used to indent at each level.
+
+            This method produces a JSON text from a JavaScript value.
+
+            When an object value is found, if the object contains a toJSON
+            method, its toJSON method will be called and the result will be
+            stringified. A toJSON method does not serialize: it returns the
+            value represented by the name/value pair that should be serialized,
+            or undefined if nothing should be serialized. The toJSON method
+            will be passed the key associated with the value, and this will be
+            bound to the object holding the key.
+
+            For example, this would serialize Dates as ISO strings.
+
+                Date.prototype.toJSON = function (key) {
+                    function f(n) {
+                        // Format integers to have at least two digits.
+                        return n < 10 ? '0' + n : n;
+                    }
+
+                    return this.getUTCFullYear()   + '-' +
+                         f(this.getUTCMonth() + 1) + '-' +
+                         f(this.getUTCDate())      + 'T' +
+                         f(this.getUTCHours())     + ':' +
+                         f(this.getUTCMinutes())   + ':' +
+                         f(this.getUTCSeconds())   + 'Z';
+                };
+
+            You can provide an optional replacer method. It will be passed the
+            key and value of each member, with this bound to the containing
+            object. The value that is returned from your method will be
+            serialized. If your method returns undefined, then the member will
+            be excluded from the serialization.
+
+            If the replacer parameter is an array of strings, then it will be
+            used to select the members to be serialized. It filters the results
+            such that only members with keys listed in the replacer array are
+            stringified.
+
+            Values that do not have JSON representations, such as undefined or
+            functions, will not be serialized. Such values in objects will be
+            dropped; in arrays they will be replaced with null. You can use
+            a replacer function to replace those with JSON values.
+            JSON.stringify(undefined) returns undefined.
+
+            The optional space parameter produces a stringification of the
+            value that is filled with line breaks and indentation to make it
+            easier to read.
+
+            If the space parameter is a non-empty string, then that string will
+            be used for indentation. If the space parameter is a number, then
+            the indentation will be that many spaces.
+
+            Example:
+
+            text = JSON.stringify(['e', {pluribus: 'unum'}]);
+            // text is '["e",{"pluribus":"unum"}]'
+
+
+            text = JSON.stringify(['e', {pluribus: 'unum'}], null, '\t');
+            // text is '[\n\t"e",\n\t{\n\t\t"pluribus": "unum"\n\t}\n]'
+
+            text = JSON.stringify([new Date()], function (key, value) {
+                return this[key] instanceof Date ?
+                    'Date(' + this[key] + ')' : value;
+            });
+            // text is '["Date(---current time---)"]'
+
+
+        JSON.parse(text, reviver)
+            This method parses a JSON text to produce an object or array.
+            It can throw a SyntaxError exception.
+
+            The optional reviver parameter is a function that can filter and
+            transform the results. It receives each of the keys and values,
+            and its return value is used instead of the original value.
+            If it returns what it received, then the structure is not modified.
+            If it returns undefined then the member is deleted.
+
+            Example:
+
+            // Parse the text. Values that look like ISO date strings will
+            // be converted to Date objects.
+
+            myData = JSON.parse(text, function (key, value) {
+                var a;
+                if (typeof value === 'string') {
+                    a =
+/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2}(?:\.\d*)?)Z$/.exec(value);
+                    if (a) {
+                        return new Date(Date.UTC(+a[1], +a[2] - 1, +a[3], +a[4],
+                            +a[5], +a[6]));
+                    }
+                }
+                return value;
+            });
+
+            myData = JSON.parse('["Date(09/09/2001)"]', function (key, value) {
+                var d;
+                if (typeof value === 'string' &&
+                        value.slice(0, 5) === 'Date(' &&
+                        value.slice(-1) === ')') {
+                    d = new Date(value.slice(5, -1));
+                    if (d) {
+                        return d;
+                    }
+                }
+                return value;
+            });
+
+
+    This is a reference implementation. You are free to copy, modify, or
+    redistribute.
+
+    This code should be minified before deployment.
+    See http://javascript.crockford.com/jsmin.html
+
+    USE YOUR OWN COPY. IT IS EXTREMELY UNWISE TO LOAD CODE FROM SERVERS YOU DO
+    NOT CONTROL.
+*/
+
+/*jslint evil: true */
+
+/*members "", "\b", "\t", "\n", "\f", "\r", "\"", JSON, "\\", apply,
+    call, charCodeAt, getUTCDate, getUTCFullYear, getUTCHours,
+    getUTCMinutes, getUTCMonth, getUTCSeconds, hasOwnProperty, join,
+    lastIndex, length, parse, prototype, push, replace, slice, stringify,
+    test, toJSON, toString, valueOf
+*/
+
+// Create a JSON object only if one does not already exist. We create the
+// methods in a closure to avoid creating global variables.
+
+var JSON = JSON || {};
+
+(function () {
+
+    function f(n) {
+        // Format integers to have at least two digits.
+        return n < 10 ? '0' + n : n;
+    }
+
+    if (typeof Date.prototype.toJSON !== 'function') {
+
+        Date.prototype.toJSON = function (key) {
+
+            return isFinite(this.valueOf()) ?
+                   this.getUTCFullYear()   + '-' +
+                 f(this.getUTCMonth() + 1) + '-' +
+                 f(this.getUTCDate())      + 'T' +
+                 f(this.getUTCHours())     + ':' +
+                 f(this.getUTCMinutes())   + ':' +
+                 f(this.getUTCSeconds())   + 'Z' : null;
+        };
+
+        String.prototype.toJSON =
+        Number.prototype.toJSON =
+        Boolean.prototype.toJSON = function (key) {
+            return this.valueOf();
+        };
+    }
+
+    var cx = /[\u0000\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,
+        escapable = /[\\\"\x00-\x1f\x7f-\x9f\u00ad\u0600-\u0604\u070f\u17b4\u17b5\u200c-\u200f\u2028-\u202f\u2060-\u206f\ufeff\ufff0-\uffff]/g,
+        gap,
+        indent,
+        meta = {    // table of character substitutions
+            '\b': '\\b',
+            '\t': '\\t',
+            '\n': '\\n',
+            '\f': '\\f',
+            '\r': '\\r',
+            '"' : '\\"',
+            '\\': '\\\\'
+        },
+        rep;
+
+
+    function quote(string) {
+
+// If the string contains no control characters, no quote characters, and no
+// backslash characters, then we can safely slap some quotes around it.
+// Otherwise we must also replace the offending characters with safe escape
+// sequences.
+
+        escapable.lastIndex = 0;
+        return escapable.test(string) ?
+            '"' + string.replace(escapable, function (a) {
+                var c = meta[a];
+                return typeof c === 'string' ? c :
+                    '\\u' + ('0000' + a.charCodeAt(0).toString(16)).slice(-4);
+            }) + '"' :
+            '"' + string + '"';
+    }
+
+
+    function str(key, holder) {
+
+// Produce a string from holder[key].
+
+        var i,          // The loop counter.
+            k,          // The member key.
+            v,          // The member value.
+            length,
+            mind = gap,
+            partial,
+            value = holder[key];
+
+// If the value has a toJSON method, call it to obtain a replacement value.
+
+        if (value && typeof value === 'object' &&
+                typeof value.toJSON === 'function') {
+            value = value.toJSON(key);
+        }
+
+// If we were called with a replacer function, then call the replacer to
+// obtain a replacement value.
+
+        if (typeof rep === 'function') {
+            value = rep.call(holder, key, value);
+        }
+
+// What happens next depends on the value's type.
+
+        switch (typeof value) {
+        case 'string':
+            return quote(value);
+
+        case 'number':
+
+// JSON numbers must be finite. Encode non-finite numbers as null.
+
+            return isFinite(value) ? String(value) : 'null';
+
+        case 'boolean':
+        case 'null':
+
+// If the value is a boolean or null, convert it to a string. Note:
+// typeof null does not produce 'null'. The case is included here in
+// the remote chance that this gets fixed someday.
+
+            return String(value);
+
+// If the type is 'object', we might be dealing with an object or an array or
+// null.
+
+        case 'object':
+
+// Due to a specification blunder in ECMAScript, typeof null is 'object',
+// so watch out for that case.
+
+            if (!value) {
+                return 'null';
+            }
+
+// Make an array to hold the partial results of stringifying this object value.
+
+            gap += indent;
+            partial = [];
+
+// Is the value an array?
+
+            if (Object.prototype.toString.apply(value) === '[object Array]') {
+
+// The value is an array. Stringify every element. Use null as a placeholder
+// for non-JSON values.
+
+                length = value.length;
+                for (i = 0; i < length; i += 1) {
+                    partial[i] = str(i, value) || 'null';
+                }
+
+// Join all of the elements together, separated with commas, and wrap them in
+// brackets.
+
+                v = partial.length === 0 ? '[]' :
+                    gap ? '[\n' + gap +
+                            partial.join(',\n' + gap) + '\n' +
+                                mind + ']' :
+                          '[' + partial.join(',') + ']';
+                gap = mind;
+                return v;
+            }
+
+// If the replacer is an array, use it to select the members to be stringified.
+
+            if (rep && typeof rep === 'object') {
+                length = rep.length;
+                for (i = 0; i < length; i += 1) {
+                    k = rep[i];
+                    if (typeof k === 'string') {
+                        v = str(k, value);
+                        if (v) {
+                            partial.push(quote(k) + (gap ? ': ' : ':') + v);
+                        }
+                    }
+                }
+            } else {
+
+// Otherwise, iterate through all of the keys in the object.
+
+                for (k in value) {
+                    if (Object.hasOwnProperty.call(value, k)) {
+                        v = str(k, value);
+                        if (v) {
+                            partial.push(quote(k) + (gap ? ': ' : ':') + v);
+                        }
+                    }
+                }
+            }
+
+// Join all of the member texts together, separated with commas,
+// and wrap them in braces.
+
+            v = partial.length === 0 ? '{}' :
+                gap ? '{\n' + gap + partial.join(',\n' + gap) + '\n' +
+                        mind + '}' : '{' + partial.join(',') + '}';
+            gap = mind;
+            return v;
+        }
+    }
+
+// If the JSON object does not yet have a stringify method, give it one.
+
+    if (typeof JSON.stringify !== 'function') {
+        JSON.stringify = function (value, replacer, space) {
+
+// The stringify method takes a value and an optional replacer, and an optional
+// space parameter, and returns a JSON text. The replacer can be a function
+// that can replace values, or an array of strings that will select the keys.
+// A default replacer method can be provided. Use of the space parameter can
+// produce text that is more easily readable.
+
+            var i;
+            gap = '';
+            indent = '';
+
+// If the space parameter is a number, make an indent string containing that
+// many spaces.
+
+            if (typeof space === 'number') {
+                for (i = 0; i < space; i += 1) {
+                    indent += ' ';
+                }
+
+// If the space parameter is a string, it will be used as the indent string.
+
+            } else if (typeof space === 'string') {
+                indent = space;
+            }
+
+// If there is a replacer, it must be a function or an array.
+// Otherwise, throw an error.
+
+            rep = replacer;
+            if (replacer && typeof replacer !== 'function' &&
+                    (typeof replacer !== 'object' ||
+                     typeof replacer.length !== 'number')) {
+                throw new Error('JSON.stringify');
+            }
+
+// Make a fake root object containing our value under the key of ''.
+// Return the result of stringifying the value.
+
+            return str('', {'': value});
+        };
+    }
+
+
+// If the JSON object does not yet have a parse method, give it one.
+
+    if (typeof JSON.parse !== 'function') {
+        JSON.parse = function (text, reviver) {
+
+// The parse method takes a text and an optional reviver function, and returns
+// a JavaScript value if the text is a valid JSON text.
+
+            var j;
+
+            function walk(holder, key) {
+
+// The walk method is used to recursively walk the resulting structure so
+// that modifications can be made.
+
+                var k, v, value = holder[key];
+                if (value && typeof value === 'object') {
+                    for (k in value) {
+                        if (Object.hasOwnProperty.call(value, k)) {
+                            v = walk(value, k);
+                            if (v !== undefined) {
+                                value[k] = v;
+                            } else {
+                                delete value[k];
+                            }
+                        }
+                    }
+                }
+                return reviver.call(holder, key, value);
+            }
+
+
+// Parsing happens in four stages. In the first stage, we replace certain
+// Unicode characters with escape sequences. JavaScript handles many characters
+// incorrectly, either silently deleting them, or treating them as line endings.
+
+            cx.lastIndex = 0;
+            if (cx.test(text)) {
+                text = text.replace(cx, function (a) {
+                    return '\\u' +
+                        ('0000' + a.charCodeAt(0).toString(16)).slice(-4);
+                });
+            }
+
+// In the second stage, we run the text against regular expressions that look
+// for non-JSON patterns. We are especially concerned with '()' and 'new'
+// because they can cause invocation, and '=' because it can cause mutation.
+// But just to be safe, we want to reject all unexpected forms.
+
+// We split the second stage into 4 regexp operations in order to work around
+// crippling inefficiencies in IE's and Safari's regexp engines. First we
+// replace the JSON backslash pairs with '@' (a non-JSON character). Second, we
+// replace all simple value tokens with ']' characters. Third, we delete all
+// open brackets that follow a colon or comma or that begin the text. Finally,
+// we look to see that the remaining characters are only whitespace or ']' or
+// ',' or ':' or '{' or '}'. If that is so, then the text is safe for eval.
+
+            if (/^[\],:{}\s]*$/.
+test(text.replace(/\\(?:["\\\/bfnrt]|u[0-9a-fA-F]{4})/g, '@').
+replace(/"[^"\\\n\r]*"|true|false|null|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?/g, ']').
+replace(/(?:^|:|,)(?:\s*\[)+/g, ''))) {
+
+// In the third stage we use the eval function to compile the text into a
+// JavaScript structure. The '{' operator is subject to a syntactic ambiguity
+// in JavaScript: it can begin a block or an object literal. We wrap the text
+// in parens to eliminate the ambiguity.
+
+                j = eval('(' + text + ')');
+
+// In the optional fourth stage, we recursively walk the new structure, passing
+// each name/value pair to a reviver function for possible transformation.
+
+                return typeof reviver === 'function' ?
+                    walk({'': j}, '') : j;
+            }
+
+// If the text is not JSON parseable, then a SyntaxError is thrown.
+
+            throw new SyntaxError('JSON.parse');
+        };
+    }
+}());
diff -r 6d723288b164 -r 16b221eba307 htdocs/js/subfilters.js
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/htdocs/js/subfilters.js	Sat Sep 05 05:55:56 2009 +0000
@@ -0,0 +1,594 @@
+/*
+
+  contentfilters.js
+
+  Provides the various functions that we use on the content filters management
+  page to enable easy filter management.
+
+  Authors:
+       Mark Smith <mark@dreamwidth.org>
+
+  Copyright (c) 2009 by Dreamwidth Studios, LLC.
+
+  This program is free software; you may redistribute it and/or modify it under
+  the same terms as Perl itself.  For a copy of the license, please reference
+  'perldoc perlartistic' or 'perldoc perlgpl'.
+
+*/
+
+
+/*
+ 
+   data structures...  I was going nuts not having this referencable, so I
+   have broken down all of the globals and what's in them.
+
+
+    cfSubs = {
+        userid => {
+            showbydefault => 0/1,
+            fgcolor => '#000000',
+            bgcolor => '#ffffff',
+            groupmask => 2394829347,
+            journaltype => 'P',
+            username => 'test3',
+        },
+        userid => ...,
+        userid => ...,
+    };
+
+
+    cfFilters = {
+        filterid => {
+            id => 1,
+            name => 'filter',
+            public => 1/0,
+            sortorder => 234,
+
+            // populated only when a filter is clicked on, initially null
+            members => {
+                userid => {
+                    user => 'username',
+
+                    // all of these are optional and may not be present
+                    adult_content => enum('any', 'nonexplicit', 'sfw'),
+                    poster_type => enum('any', 'maintainer', 'moderator'),
+                    tags_mode => enum('any_of', 'all_of', 'none_of'),
+                    tags => [ 1, 3, 59, 23, ... ],
+                },
+                userid => ...,
+                userid => ...,
+            },
+        },
+        filterid => ...,
+        filterid => ...,
+    };
+
+
+    cfTags = {
+        userid => {
+            tagid => {
+                name => 'tagname',
+                uses => 13,
+            },
+            tagid => ...,
+            tagid => ...,
+        },
+        userid => ...,
+        userid => ...,
+    };
+
+*/
+
+var cfSelectedFilterId = null, cfCurrentUserid = null;
+var cfSubs = {}, cfTags = {}, cfFilters = {};
+
+// [ total count, selected ]
+var cfTagCount = [ 0, 0 ];
+
+// current save timer
+var cfTimerId = null, cfSaveTicksLeft = 0;
+
+
+function cfPopulateLists() {
+    // whenever we repopulate the lists, we lose what is selected
+    cfHideOptions();
+
+    // short circuit, if we have no filter, just empty both
+    if ( ! cfSelectedFilterId ) {
+        $('#cf-in-list, #cf-notin-list').empty();
+        $('#cf-rename, #cf-delete, #cf-edit').hide();
+        $('#cf-intro').show();
+        return;
+    }
+
+    // show our rename button
+    $('#cf-rename, #cf-delete, #cf-edit').show();
+    $('#cf-intro').hide();
+
+    var filt = cfFilters[cfSelectedFilterId];
+
+    // FIXME: ...sort the lists, plz
+    //cfSubs.sort( function( a, b ) { return ( a.username <  b.username ) ? -1 : ( a.username > b.username ) ? 1 : 0; } );
+
+    var inOpts = '', outOpts = '';
+    for ( i in cfSubs ) {
+        var isIn = false;
+
+        for ( j in filt.members ) {
+            if ( filt.members[j].user == cfSubs[i].username )
+                isIn = true;
+        }
+
+        if ( isIn )
+            inOpts += '<option value="' + i + '">' + cfSubs[i].username + '</option>';
+        else
+            outOpts += '<option value="' + i + '">' + cfSubs[i].username + '</option>';
+    }
+
+    $('#cf-in-list').html( inOpts );
+    $('#cf-notin-list').html( outOpts );
+}
+
+
+function cfSelectedFilter() {
+    // filter options are not implemented yet, so don't show that box :)
+    //$('#cf-filtopts').show();
+
+    cfPopulateLists();
+}
+
+
+function cfSelectFilter( filtid ) {
+    // do nothing case
+    if ( filtid < 1 ) filtid = null;
+    if ( cfSelectedFilterId != null && cfSelectedFilterId == filtid )
+        return;
+
+    // store this for usefulness
+    cfSelectedFilterId = filtid;
+
+    // have to hide the options now, as we're not sure what the user is doing
+    cfHideOptions();
+
+    // if they've chosen nothing...
+    if ( filtid == null )
+        return cfPopulateLists();
+
+    // if this filter already has loaded members, just return
+    if ( cfFilters[filtid].members != null )
+        return cfSelectedFilter();
+
+    // get the members of this filter
+    $.getJSON( '/__rpc_contentfilters?mode=list_members&user=' + DW.currentUser + '&filterid=' + filtid,
+        function( data ) {
+            cfFilters[filtid].members = data.members;
+            cfSelectedFilter();
+        }
+    );
+}
+
+
+function cfUpdateTags( data ) {
+//    cfTags[cfCurrentUser] = data.tags
+
+    var member = cfFilters[cfSelectedFilterId].members[cfCurrentUserid];
+    if ( ! member )
+        return;
+
+    // initialize our tag structure
+    if ( ! member.tags )
+        member.tags = {};
+
+    // determine tag cloud sizing
+    var maxuses = 0;
+    for ( i in data.tags ) {
+        if ( data.tags[i].uses > maxuses )
+            maxuses = data.tags[i].uses;
+    }
+
+    // reset the global tag counts
+    cfTagCount = [ 0, 0 ];
+
+    var html = '', htmlin = '';
+    for ( id in data.tags ) {
+        // count every tag
+        cfTagCount[0]++;
+
+        // see if this tag is in the list ...
+        var isin = member.tags[id] ? true : false;
+
+        // now we can do different things ...
+        var cclass = 'cf-cloud-' + Math.round( data.tags[id].uses / maxuses * 5 + 1 );
+
+        if ( isin ) {
+            // count every selected tag and build our HTML
+            cfTagCount[1]++
+            htmlin += '<a href="javascript:void(0);" class="cf-tag-on cf-tag ' + cclass + '" id="' + id + '">' + data.tags[id].name + '</a> ';
+        } else {
+            html += '<a href="javascript:void(0);" class="cf-tag ' + cclass + '" id="' + id + '">' + data.tags[id].name + '</a> ';
+        }
+    }
+
+    // do some default stuff if nothing is selected
+    $('#cf-notagsavail').toggle( html == '' );
+    $('#cf-notagssel').toggle( htmlin == '' );
+
+    // and now show/hide the other boxes
+    $('#cf-t-box2').toggle( html != '' );
+    $('#cf-t-box3').toggle( htmlin != '' );
+
+    // now pass this into the page for the user to view
+    $('#cf-t-box2').html( html );
+    $('#cf-t-box3').html( htmlin );
+
+    // now, all of these tags need an onclick handler
+    $('a.cf-tag').bind( 'click', function( e ) { cfClickTag( $(e.target).attr( 'id' ) ); } );
+}
+
+
+function cfClickTag( id ) {
+    var obj = $('a.cf-tag#' + id);
+    var filt = cfFilters[cfSelectedFilterId];
+    var member = filt.members[cfCurrentUserid];
+    if ( !obj || !filt || !member || !DW.userIsPaid )
+        return;
+
+    // first, let's toggle the class that boldens the tag
+    obj.toggleClass( 'cf-tag-on' );
+
+    // now make sure we have a tags array...
+    if ( ! member.tags )
+        member.tags = {};
+
+    // now, if the class is ON, we need to move this tag to the bucket
+    if ( obj.hasClass( 'cf-tag-on') ) {
+        $('#cf-t-box3').append(' '); // thanks Janine!
+        obj.appendTo('#cf-t-box3');
+        member.tags[id] = true;
+        cfTagCount[1]++;
+
+    // and if it's off, remove it
+    } else {
+        $('#cf-t-box2').append(' '); // damn splits...
+        obj.appendTo('#cf-t-box2');
+        delete member.tags[id];
+        cfTagCount[1]--;
+    }
+
+    // now show/hide our UI markers
+    $('#cf-notagssel').toggle( cfTagCount[1] == 0 );
+    $('#cf-notagsavail').toggle( cfTagCount[0] == 0 || ( cfTagCount[0] - cfTagCount[1] == 0 ) );
+
+    // and now... tag contents
+    $('#cf-t-box3').toggle( cfTagCount[1] > 0 );
+    $('#cf-t-box2').toggle( cfTagCount[0] > 0 && ( cfTagCount[0] - cfTagCount[1] > 0 ) );
+
+    // kick off a save
+    cfSaveChanges();
+}
+
+
+function cfSaveChanges() {
+    // if we have a save timer, nuke it
+    if ( cfTimerId )
+        clearTimeout( cfTimerId );
+
+    // set a timer... then try to save, which actually sets a new timer
+    // for us to use.
+    cfSaveTicksLeft = 6;
+    cfTrySave();
+}
+
+
+function cfTrySave() {
+    // our timer has fired
+    cfTimerId = null;
+
+    // now, if we're out of ticks just save
+    if ( --cfSaveTicksLeft <= 0 )
+        return cfDoSave();
+
+    // okay, wait another second
+    cfTimerId = setTimeout( cfTrySave, 1000 );
+
+    // now update the text
+    $('#cf-unsaved').html( 'Saving in ' + cfSaveTicksLeft + ' seconds...' );
+    $('#cf-unsaved, #cf-hourglass').show();
+}
+
+
+function cfDoSave() {
+    // this actually posts the save
+    $.post( '/__rpc_contentfilters?mode=save_filters&user=' + DW.currentUser,
+        { 'json': JSON.stringify( cfFilters ) },
+        function( data ) {
+            // FIXME: error handling...
+            if ( !data.ok )
+                return;
+
+            // we're saved
+            cfTimerId = null;
+            $('#cf-unsaved, #cf-hourglass').hide();
+        },
+        'json'
+    );
+}
+
+
+function cfSelectMember( sel ) {
+    // if we have selected more than one (or less than one) thing, then hide the
+    // options box and call it good
+    if ( sel.length < 1 || sel.length > 1 ) {
+        cfCurrentUserid = null;
+        return cfHideOptions();
+    }
+
+    // some variables we're going to use later, in particular cfCurrentUserid is
+    // used in many places so we know who we're editing
+    var userid = sel[0];
+    user = cfSubs[userid];
+    cfCurrentUserid = userid;
+    
+    // these have to be true, or we have serious issues
+    var filt = cfFilters[cfSelectedFilterId];
+    var member = filt.members[cfCurrentUserid];
+    if ( !filt || !member )
+        return;
+
+    // FIXME: don't always reget the tags
+    $.getJSON( '/__rpc_general?mode=list_tags&user=' + user.username, cfUpdateTags );
+
+    // clear out both of the tag lists
+    $('#cf-t-box2, #cf-t-box3').empty();
+
+    // if this is a comm show the extra community options
+    $('#cf-pt-box').toggle( user.journaltype == 'C' );
+
+    // default the member to a few options...
+    if ( ! member.adultcontent )
+        member.adultcontent = 'any';
+    if ( ! member.postertype )
+        member.postertype = 'any';
+    if ( ! member.tagmode )
+        member.tagmode = 'any_of';
+
+    // now fill the options in
+    $('#cf-adultcontent').val( member.adultcontent );
+    $('#cf-postertype').val( member.postertype );
+    $('#cf-tagmode').val( member.tagmode );
+
+    // and now show the actual options box
+    cfShowOptions();
+}
+
+
+function cfHideOptions() {
+    $('#cf-options').hide();
+}
+
+
+function cfShowOptions() {
+    // if the user is not paid, make sure we disable things, etc
+    if ( ! DW.userIsPaid ) {
+        $('#cf-adultcontent, #cf-postertype, #cf-tagmode').attr( 'disabled', 'disabled' );
+        $('#cf-free-warning').show();
+    }
+
+    $('#cf-options').show();
+}
+
+
+function cfAddMembers() {
+    var members = $('#cf-notin-list').val();
+    var filt = cfFilters[cfSelectedFilterId];
+    if ( !filt || members.length <= 0 )
+        return;
+
+    // simply create a new row in the filter members list for this person
+    for ( i in members ) {
+        var userid = members[i];
+
+        filt.members[userid] = {
+            'user': cfSubs[userid].username
+        };
+    }
+
+    // kick off a save event
+    cfSaveChanges();
+
+    // and then redisplay our lists
+    cfPopulateLists();
+}
+
+
+function cfRemoveMembers() {
+    var members = $('#cf-in-list').val();
+    var filt = cfFilters[cfSelectedFilterId];
+    if ( ! filt || members.length <= 0 )
+        return;
+
+    // simply delete the row in the filter members list for this person
+    for ( i in members ) {
+        var userid = members[i];
+
+        delete filt.members[userid];
+    }
+
+    // kick off a save event
+    cfSaveChanges();
+
+    // and then redisplay our lists
+    cfPopulateLists();
+}
+
+
+function cfChangedTagMode() {
+    var tagmode = $('#cf-tagmode').val();
+    var filt = cfFilters[cfSelectedFilterId];
+    var member = filt.members[cfCurrentUserid];
+    if ( !tagmode || !filt || !member )
+        return;
+
+    member.tagmode = tagmode;
+    cfSaveChanges();
+}
+
+
+function cfChangedAdultContent() {
+    var adultcontent = $('#cf-adultcontent').val();
+    var filt = cfFilters[cfSelectedFilterId];
+    var member = filt.members[cfCurrentUserid];
+    if ( !adultcontent || !filt || !member )
+        return;
+
+    member.adultcontent = adultcontent;
+    cfSaveChanges();
+}
+
+
+function cfChangedPosterType() {
+    var postertype = $('#cf-postertype').val();
+    var filt = cfFilters[cfSelectedFilterId];
+    var member = filt.members[cfCurrentUserid];
+    if ( !postertype || !filt || !member )
+        return;
+
+    member.postertype = postertype;
+    cfSaveChanges();
+}
+
+
+function cfNewFilter() {
+    // prompt the user for a filter name...
+    var name = prompt( 'New filter name:', '' );
+    if ( ! name )
+        return;
+
+    // now that we have a name, kick off a request to make a new filter
+    $.getJSON( '/__rpc_contentfilters?mode=create_filter&user=' + DW.currentUser + '&name=' + name,
+        function( data ) {
+            // no id means some sort of failure
+            // FIXME: error handling so the user knows what's up
+            if ( !data.id || !data.name )
+                return;
+
+            // save a roundtrip, we don't have to hit the server since it just gave us
+            // the filter information
+            cfFilters[data.id] = {
+                'id': data.id,
+                'name': data.name,
+                'public': data.public,
+                'sortorder': data.sortorder
+            };
+
+            // we have to do this first, before the update of the filter select, due to the
+            // way the code is structured
+            cfSelectFilter( data.id );
+
+            // happens last
+            cfUpdateFilterSelect();
+        }
+    );
+}
+
+
+function cfRenameFilter() {
+    var filt = cfFilters[cfSelectedFilterId];
+    if ( !filt )
+        return;
+
+    // FIXME: don't think dialogs are accessible at all
+    var renamed = prompt( 'Rename filter to:', filt.name );
+    filt.name = renamed;
+
+    // and now update the select dialog
+    cfUpdateFilterSelect();
+
+    // kick off a saaaaaaaave!
+    cfSaveChanges();
+}
+
+
+function cfUpdateFilterSelect() {
+    // regenerate HTML for the Filter: dropdown
+    var options = '<option value="0">( select filter )</option><option value="0"></option>';
+    for ( i in cfFilters ) {
+        cfFilters[i]['members'] = null;
+        options += '<option value="' + i + '">' + cfFilters[i].name + '</option>';
+    }
+    $('#cf-filters').html( options );
+
+    // and if we have a current filter id, reselect
+    if ( cfSelectedFilterId )
+        $('#cf-filters').val( cfSelectedFilterId );
+}
+
+
+function cfRefreshFilterList() {
+    // in a function because we call from multiple places
+    $.getJSON( '/__rpc_contentfilters?mode=list_filters&user=' + DW.currentUser, function( data ) {
+        cfFilters = data.filters;
+        cfUpdateFilterSelect();
+    } );
+}
+
+
+function cfDeleteFilter() {
+    var filt = cfFilters[cfSelectedFilterId];
+    if ( !filt )
+        return;
+
+    // confirm!
+    if ( ! confirm( 'Really delete this content filter?  There is no turning back if you say yes.' ) )
+        return;
+
+    $.getJSON( '/__rpc_contentfilters?mode=delete_filter&user=' + DW.currentUser + '&id=' + filt.id,
+        function( data ) {
+            // FIXME: error handling ...
+            if ( !data.ok )
+                return;
+
+            // the filter is gone, so nuke from some of our stuff
+            delete cfFilters[filt.id];
+
+            // and update the UI, again, the order of these two calls matters
+            cfSelectFilter( null );
+            cfUpdateFilterSelect();
+        }
+    );
+}
+
+
+DW.whenPageLoaded( function() {
+
+    // load the current filters into the box
+    cfRefreshFilterList();
+
+    // and get who this person is subscribed to, we're going to need this later
+    $.getJSON( '/__rpc_general?mode=list_subscriptions&user=' + DW.currentUser, function( data ) {
+        cfSubs = {};
+        for ( i in data.subs ) {
+            cfSubs[i] = data.subs[i];
+        }
+        cfPopulateLists();
+    } );
+
+    // setup our click handlers
+    $('#cf-filters').bind( 'change', function(e) { cfSelectFilter( $(e.target).val() ); } );
+    $('#cf-in-list').bind( 'change', function(e) { cfSelectMember( $(e.target).val() ); } );
+    $('#cf-add-btn').bind( 'click', function(e) { cfAddMembers(); } );
+    $('#cf-del-btn').bind( 'click', function(e) { cfRemoveMembers(); } );
+    $('#cf-new').bind( 'click', function(e) { cfNewFilter(); } );
+    $('#cf-rename').bind( 'click', function(e) { cfRenameFilter(); } );
+    $('#cf-delete').bind( 'click', function(e) { cfDeleteFilter(); } );
+
+    // if the user is paid, we bind these.  note that even if someone goes through the
+    // trouble of hacking up the form and submitting data, the server won't actually give
+    // you an advanced filter.  so don't waste your time!
+    if ( DW.userIsPaid ) {
+        $('#cf-adultcontent').bind( 'change', function(e) { cfChangedAdultContent(); } );
+        $('#cf-postertype').bind( 'change', function(e) { cfChangedPosterType(); } );
+        $('#cf-tagmode').bind( 'change', function(e) { cfChangedTagMode(); } );
+    }
+
+} );
diff -r 6d723288b164 -r 16b221eba307 htdocs/manage/circle/editfilters.bml
--- a/htdocs/manage/circle/editfilters.bml	Thu Sep 03 22:46:30 2009 +0000
+++ b/htdocs/manage/circle/editfilters.bml	Sat Sep 05 05:55:56 2009 +0000
@@ -86,10 +86,13 @@
     my $trust_list = $u->trust_list;
     my $trusted_us = LJ::load_userids( keys %$trust_list );
 
+    # redirect to managing subscription filters
+    $body .= "<?standout " . BML::ml( '.subfilters', { aopts => "href='$LJ::SITEROOT/manage/subscriptions/filters'" } ) . " standout?>\n\n";
+
     # authas switcher form
-    $body .= "<form method='get' action='editfilters'>\n";
+    $body .= "<?p <form method='get' action='editfilters'>\n";
     $body .= LJ::make_authas_select($remote, { 'authas' => $GET{'authas'} }) . "\n";
-    $body .= "</form>\n\n";
+    $body .= "</form> p?>\n\n";
 
     $body .= "<?p $ML{'.text'} p?><?p $ML{'.text.sec'} p?><p>";
     $body .= "<form method='post' name='fg' action='editfilters$getextra'>";
diff -r 6d723288b164 -r 16b221eba307 htdocs/manage/circle/editfilters.bml.text
--- a/htdocs/manage/circle/editfilters.bml.text	Thu Sep 03 22:46:30 2009 +0000
+++ b/htdocs/manage/circle/editfilters.bml.text	Sat Sep 05 05:55:56 2009 +0000
@@ -39,6 +39,8 @@
 
 .saved.text=Your access filters are now saved.
 
+.subfilters=You are currently managing your access filters.  <a [[aopts]]>Manage your subscription filters?</a>
+
 .text=This page allows you to edit your filters.  Currently, you can only edit your access filters, which are used for setting security on items.  Reading filters, which are used for filtering your reading page, are not yet currently supported.  This page requires JavaScript to work.
 
 .text.sec=<strong>Security note:</strong> If you wish to delete an access filter and make a new access filter, do <strong>not</strong> do this by renaming one access filter and then editing it. If you do this, all your old entries which are accessible to the old access filter will then be accessible to the new access filter.
diff -r 6d723288b164 -r 16b221eba307 htdocs/manage/subscriptions/filters.bml
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/htdocs/manage/subscriptions/filters.bml	Sat Sep 05 05:55:56 2009 +0000
@@ -0,0 +1,145 @@
+<?_c
+#
+# manage/subscriptions/filters.bml
+#
+# Page to manage your subscription filters.
+#
+# Authors:
+#      Mark Smith <mark@dreamwidth.org>
+#
+# Copyright (c) 2009 by Dreamwidth Studios, LLC.
+#
+# This program is free software; you may redistribute it and/or modify it under
+# the same terms as Perl itself. For a copy of the license, please reference
+# 'perldoc perlartistic' or 'perldoc perlgpl'.
+#
+_c?><?page
+body<=
+<?_code
+{
+    use strict;
+    use vars qw/ %GET %POST $title $windowtitle $headextra @errors @warnings /;
+
+    # this is how you include custom CSS/JS/etc
+    LJ::set_active_resource_group( 'jquery' );
+    LJ::need_res( {group=>'jquery'}, qw# js/subfilters.js stc/subfilters.css js/json2.js # );
+
+    # for pages that require authentication
+    my $remote = LJ::get_remote();
+    return "<?needlogin?>" unless $remote;
+
+    # stick in some JS to set the current user
+    # FIXME: this should be done automatically as part of the templates!
+    my $ret = "<script type='text/javascript'>\n" .
+              "DW.currentUser = '" . $remote->user . "';\n" .
+              "DW.userIsPaid = " . ( $remote->is_paid ? 'true' : 'false' ) . ";\n" .
+              "</script>\n";
+
+    # and now the main page HTML
+    $ret .= <<EOF;
+
+<div id='cf-select'>
+    <div id='cf-select-filter'>
+        Filter:
+        <select id='cf-filters'>
+            <option value="0">Loading, please wait...</option>
+        </select>
+    </div>
+    <div id='cf-save'>
+        <img id='cf-hourglass' src='$LJ::IMGPREFIX/hourglass.gif' />
+        <span id='cf-unsaved'>Saving in N seconds...</span>
+    </div>
+    <div id='cf-make-new'>
+        <a href='javascript:void(0);' id='cf-delete'>Delete</a>
+        <input id='cf-rename' type='button' value='Rename' />
+        <input id='cf-new' type='button' value='New' />
+    </div>
+    <div style='clear: both;'></div>
+</div>
+
+<div id='cf-filtopts'>
+    Options for <span id='cf-foname'>selected filter</span>:<br /><br />
+
+    (filter options are not implemented yet, sorry!)
+</div>
+
+<div id='cf-intro'>
+    <p>Hello!</p>
+    <p>Welcome to the Dreamwidth Subscription Filters editing page.  If you have already created a filter,
+    you may select it in the dropdown above.</p>
+    <p>If this is your first time here, you should click the New button in the top right to make a new
+    subscription filter.</p>
+</div>
+
+<div id='cf-edit'>
+    <div id='cf-notin'>
+        Not in filter:<br />
+        <select id='cf-notin-list' size='20' multiple></select><br />
+        <div id='cf-add-box'>
+            <input id='cf-add-btn' type='button' value='Add &gt;&gt;' />
+        </div>
+    </div>
+
+    <div id='cf-in'>
+        In filter:<br />
+        <select id='cf-in-list' size='20' multiple></select><br />
+        <div id='cf-del-box'>
+            <input id='cf-del-btn' type='button' value='&lt;&lt; Remove' />
+        </div>
+    </div>
+
+    <div id='cf-options'>
+        Options:<br />
+        <div id='cf-opts-box'>
+            <div id='cf-free-warning'>
+                Your account type does not permit the use of advanced subscription filters.  These options
+                are not active.  If you would like to use these options, please consider supporting the
+                site and upgrading your account.
+            </div>
+            Show only content that ...
+            <div id='cf-ac-box'>
+                ... is rated
+                <select id='cf-adultcontent'>
+                    <option value='any'>anything (show all content)</option>
+                    <option value='nonexplicit'>safe or non-explicit</option>
+                    <option value='sfw'>safe for work only</option>
+                </select>
+            </div>
+            <div id='cf-pt-box'>
+                ... and the poster is
+                <select id='cf-postertype'>
+                    <option value='any'>anybody</option>
+                    <option value='moderator'>a moderator or maintainer</option>
+                    <option value='maintainer'>a maintainer</option>
+                </select>
+            </div>
+            <div id='cf-t-box'>
+                ... and the entry is tagged with
+                <select id='cf-tagmode'>
+                    <option value='any_of'>any</option>
+                    <option value='all_of'>all</option>
+                    <option value='none_of'>none</option>
+                </select>
+                of the selected tags:
+                <div id='cf-notagssel'>(no tags selected)</div>
+                <div id='cf-t-box3'>
+                </div>
+                Available tags (click to select):
+                <div id='cf-notagsavail'>(no tags available)</div>
+                <div id='cf-t-box2'>
+                </div>
+            </div>
+        </div>
+    </div>
+
+    <div style='clear: both;'></div>
+</div>
+
+EOF
+
+    return $ret;
+}
+_code?>
+<=body
+title=>Manage Subscription Filters
+page?>
diff -r 6d723288b164 -r 16b221eba307 htdocs/stc/subfilters.css
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/htdocs/stc/subfilters.css	Sat Sep 05 05:55:56 2009 +0000
@@ -0,0 +1,39 @@
+#cf-select, #cf-edit, #cf-filtopts, #cf-intro {
+    background-color: #ddd;
+    border: solid 1px #bbb;
+    margin: 5px;
+    padding: 3px 5px 2px 5px;
+    clear: both;
+}
+
+#cf-edit { display: none; }
+#cf-save { float: left; width: 50%; text-align: center; vertical-align: center; }
+#cf-hourglass { display: none; }
+#cf-make-new { float: right; width: 30%; text-align: right; }
+#cf-rename { display: none; }
+#cf-select-filter { float: left; width: 20%; }
+#cf-delete { margin-right: 10px; display: none; text-decoration: underline; }
+#cf-unsaved { color: red; font-weight: bold; font-size: larger; display: none; }
+#cf-notin { width: 18%; float: left; padding: 5px; }
+#cf-in { width: 18%; float: left; padding: 5px; }
+#cf-options { width: 60%; float: right; padding: 5px; }
+#cf-in-list { width: 100%; }
+#cf-notin-list { width: 100%; }
+#cf-add-box { float: left; }
+#cf-del-box { float: right; }
+#cf-t-box, #cf-ac-box, #cf-pt-box { padding: 5px; }
+#cf-t-box2, #cf-t-box3 { padding: 10px; display: none; }
+#cf-filtopts, #cf-options { display: none; }
+#cf-opts-box { border: solid 1px #bbb; width: 98%; padding: 5px; }
+#cf-notagsavail, #cf-notagssel { display: none; font-style: italic; padding: 10px; }
+#cf-free-warning { display: none; margin: 10px; font-weight: bold; }
+
+.cf-tag-on { font-weight: bold; }
+.cf-tag { text-decoration: underline; margin: 3px; }
+.cf-tag:hover { background-color: #ccc; }
+
+.cf-cloud-1 { font-size: 1.0em; }
+.cf-cloud-2 { font-size: 1.5em; }
+.cf-cloud-3 { font-size: 2.0em; }
+.cf-cloud-4 { font-size: 2.5em; }
+.cf-cloud-5 { font-size: 3.0em; }
diff -r 6d723288b164 -r 16b221eba307 htdocs/tools/endpoints/contentfilters.bml
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/htdocs/tools/endpoints/contentfilters.bml	Sat Sep 05 05:55:56 2009 +0000
@@ -0,0 +1,169 @@
+<?_code # -*-bml-*-
+{
+    #
+    # htdocs/tools/endpoints/contentfilters.bml
+    #
+    # The AJAX endpoint for content filter related calls.
+    #
+    # Authors:
+    #      Mark Smith <mark@dreamwidth.org>
+    #
+    # Copyright (c) 2009 by Dreamwidth Studios, LLC.
+    #
+    # This program is free software; you may redistribute it and/or modify it under
+    # the same terms as Perl itself.  For a copy of the license, please reference
+    # 'perldoc perlartistic' or 'perldoc perlgpl'.
+    #
+
+    use strict;
+    use JSON;
+    use vars qw/ %GET /;
+
+    my $err = sub {
+        return JSON::objToJson( {
+            alert => $_[0],
+            error => 1,
+        } );
+    };
+
+    # make sure we have a user of some sort
+    my $remote = LJ::get_remote();
+    my $u = LJ::get_authas_user( $GET{user} || $remote->user )
+        or return $err->( 'Unable to load user for call.' );
+
+    # in theory, they're passing a mode in the GET arguments
+    my $mode = $GET{mode}
+        or return $err->( 'No mode passed.' );
+
+    my %ret;
+
+    # list_filters mode is very simple: it returns an array of the filters with the
+    # pertinent information about those filters
+    if ( $mode eq 'list_filters' ) {
+        $ret{filters} = {};
+
+        my @filters = $u->content_filters;
+        foreach my $filt ( @filters ) {
+            $ret{filters}->{$filt->id} =
+                {
+                    id => $filt->id,
+                    name => $filt->name,
+                    sortorder => $filt->sortorder,
+                    public => $filt->public,
+                };
+        }
+
+    # list the names of the people who are on a filter
+    } elsif ( $mode eq 'list_members' ) {
+        $ret{members} = {};
+
+        my $filter = $u->content_filters( id => $GET{filterid} )
+            or return $err->( 'No such filter.' );
+
+        my $data = $filter->data;
+        foreach my $uid ( keys %$data ) {
+            my $member = $data->{$uid};
+
+            # FIXME: use load_userids_multiple to get the user objects...
+            $ret{members}->{$uid} = {
+                user => LJ::load_userid( $uid )->user,
+                adultcontent => $member->{adultcontent} || 'any',
+                postertype => $member->{postertype} || 'any',
+                tagmode => $member->{tagmode} || 'any_of',
+                tags => { map { $_ => 1 } @{ $member->{tags} || [] } },
+            };
+        }
+
+    # called to make a brand new filter
+    } elsif ( $mode eq 'create_filter' ) {
+        return $err->( 'Can only create filters for people.' )
+            unless $u->is_individual;
+
+        return $err->( 'No name provided.' )
+            unless $GET{name} =~ /\S/;
+
+        my $fid = $u->create_content_filter( name => $GET{name} );
+        return $err->( 'Failed to create content filter.' )
+            unless $fid;
+
+        my $cf = $u->content_filters( id => $fid );
+        return $err->( 'Failed to retrieve content filter.' )
+            unless $cf;
+
+        %ret = (
+            id => $cf->id,
+            name => $cf->name,
+            public => $cf->public,
+            sortorder => $cf->sortorder,
+        );
+
+    # delete a content filter
+    } elsif ( $mode eq 'delete_filter' ) {
+        return $err->( 'Can only create filters for people.' )
+            unless $u->is_individual;
+
+        return $err->( 'No/invalid id provided.' )
+            unless $GET{id} =~ /^\d+$/;
+
+        my $id = $u->delete_content_filter( id => $GET{id} );
+        return $err->( 'Failed to delete the content filter.' )
+            unless $id == $GET{id};
+
+        $ret{ok} = 1;
+
+    # save incoming changes
+    } elsif ( $mode eq 'save_filters' ) {
+        local $JSON::UnMapping = 1;
+        my $obj = JSON::jsonToObj( $POST{json} );
+
+        foreach my $fid ( keys %$obj ) {
+            my $filt = $obj->{$fid};
+
+            # load this filter
+            my $cf = $u->content_filters( id => $fid );
+            return $err->( "Filter id $fid does not exist." )
+                unless $cf;
+
+            # update the name if necessary, this has to be before the members check
+            # because they might not have loaded members (or it might have none)
+            $cf->name( $filt->{name} )
+                if $filt->{name} && $filt->{name} ne $cf->name;
+
+            # skip the filter if it hasn't actually been loaded
+            next unless defined $filt->{members};
+
+            # get data object for use later
+            my $data = $cf->data;
+
+            # fix up the member list
+            foreach my $uid ( keys %{ $filt->{members} } ) {
+                my $member = $filt->{members}->{$uid};
+
+                # don't need this, we can look it up
+                delete $member->{user};
+
+                # tags are given to us as a hashref, we need to flatten to an array
+                $member->{tags} = [ keys %{ $member->{tags} || {} } ];
+
+                # these may or may not be present, nuke them if they're default
+                delete $member->{postertype}
+                    if $member->{postertype} && $member->{postertype} eq 'any';
+                delete $member->{adultcontent}
+                    if $member->{adultcontent} && $member->{adultcontent} eq 'any';
+                delete $member->{tagmode}
+                    if $member->{tagmode} && $member->{tagmode} eq 'any_of';
+
+                # now save this in the actual filter
+                $data->{$uid} = $member;
+            }
+
+            # save the filter, very important...
+            $cf->_save;
+        }
+
+        $ret{ok} = 1;
+    }
+
+    return JSON::objToJson( \%ret );
+}
+_code?>
diff -r 6d723288b164 -r 16b221eba307 htdocs/tools/endpoints/general.bml
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/htdocs/tools/endpoints/general.bml	Sat Sep 05 05:55:56 2009 +0000
@@ -0,0 +1,67 @@
+<?_code # -*-bml-*-
+{
+    #
+    # htdocs/tools/endpoints/general.bml
+    #
+    # The AJAX endpoint for general calls.
+    #
+    # Authors:
+    #      Mark Smith <mark@dreamwidth.org>
+    #
+    # Copyright (c) 2009 by Dreamwidth Studios, LLC.
+    #
+    # This program is free software; you may redistribute it and/or modify it under
+    # the same terms as Perl itself.  For a copy of the license, please reference
+    # 'perldoc perlartistic' or 'perldoc perlgpl'.
+    #
+
+    use strict;
+    use JSON;
+    use vars qw/ %GET /;
+
+    my $err = sub {
+        return JSON::objToJson( {
+            alert => $_[0],
+            error => 1,
+        } );
+    };
+
+    # make sure we have a user of some sort
+    my $remote = LJ::get_remote();
+    my $u = LJ::load_user( $GET{user} || $remote->user )
+        or return $err->( 'Unable to load user for call.' );
+
+    # in theory, they're passing a mode in the GET arguments
+    my $mode = $GET{mode}
+        or return $err->( 'No mode passed.' );
+
+    my %ret;
+
+    # gets the list of people that this account subscribes to
+    if ( $mode eq 'list_subscriptions' ) {
+        $ret{subs} = $u->watch_list;
+
+        my $uobjs = LJ::load_userids( keys %{ $ret{subs} } );
+        foreach my $userid ( keys %$uobjs ) {
+            $ret{subs}->{$userid}->{username} = $uobjs->{$userid}->user;            
+            $ret{subs}->{$userid}->{journaltype} = $uobjs->{$userid}->journaltype;
+        }
+
+    # get the list of someone's tags
+    } elsif ( $mode eq 'list_tags' ) {
+        $ret{tags} = LJ::Tags::get_usertags( $u, { remote => $remote } );
+        foreach my $val ( values %{ $ret{tags} } ) {
+            delete $val->{security_level};
+            delete $val->{security};
+            delete $val->{display};
+        }
+
+    # problems
+    } else {
+        return $err->( 'Unknown mode.' );
+
+    }
+
+    return JSON::objToJson( \%ret );
+}
+_code?>
--------------------------------------------------------------------------------
denise: Image: Me, facing away from camera, on top of the Castel Sant'Angelo in Rome (Default)

[staff profile] denise 2009-09-05 06:15 am (UTC)(link)
WOOHOO
cesy: *draws fangirly hearts* (Fangirl)

[personal profile] cesy 2009-09-05 07:50 am (UTC)(link)
Eeeeee!
cesy: Home is where the <3 is (Dreamwidth) (Dreamwidth)

First testing of subscription filters

[personal profile] cesy 2009-09-05 08:35 am (UTC)(link)
Do say if you'd rather have this as a comment on the bug or something else.

It's still possible to subscribe to an OpenID, if rather pointless, and they show up in the filters as ext_1, which is not friendly. Either this should be changed to their actual name (cesy.livejournal.com or whatever) or better still, exclude OpenIDs from subscription filters, since they can't post.

If I can create a filter of "safe for work posts from everyone" (which is very useful, thank you), I will probably then want to create a corresponding filter of "not safe for work posts from everyone", to catch up on the stuff I missed when I get home. Could other options be added to the rating drop-down?
cesy: "Cesy" - An old-fashioned quill and ink (Default)

Re: First testing of subscription filters

[personal profile] cesy 2009-09-05 09:07 am (UTC)(link)
The fact that the format is read/filtername rather than read?filter=filtername or anything else should probably go in a FAQ eventually.

On the navbar, where it has "Filter: All Subscriptions, Journals Only, Communities Only, Syndicated Feeds" in a drop-down, it might be good to add the filters into that drop-down - that was where I looked for it first when I was trying to figure out how to apply a filter to my reading page.
piranha: animated cartoon owl bouncing up and down from out of frame (celebrate)

[personal profile] piranha 2009-09-05 10:19 am (UTC)(link)
OMGOMGOMG! can i just say how excited i am about this!!!