Forum OpenACS Q&A: Breakthrough on bookmarks module
I now have a working bookmarks module, including the ability to add and delete bookmarks, create folders, edit bookmarks (which includes moving them from one folder to another), and open/close folders. The complete functionality as it exists in the original ACS is not quite there, but it's getting close. In order to accomplish this, I had to make several adjustments in the original ACS code, including, but not limited to, the following: * bm_list_pkg becomes a simple table (not an Oracle package!) * some of the trigger mechanisms had to be moved from the data model to the edit-bookmark-2.tcl file, which is most likely OK, since the parent_id's of bookmarks probably only change when you use the "edit" part of the module, anyway * in place of the Oracle (proprietary) CONNECT BY construct, I decided to make judicious use of the "sort keys", making all recursive parent-child references unnecessary I have all the fixes I've done so far ready and available, and I will be posting them in the next day or two, as followup threads to this posting. Thus far, I've had to modify about 8 Tcl files, in addition to the bookmarks.sql data model. When I post the fixes, I'll try to be specific as to how the revised data model works, and why I made the changes I did. Stay tuned!
General Remarks
As I mentioned, I've made a breakthrough in the bookmarks module. To the best of my knowledge, the following files (at least) require adjustments in order to get the module to work:
- doc/sql/bookmarks.sql
(this file, with corrections applied, needs to be loaded into the data module) - /web/{server}/tcl/bookmarks-defs.tcl
- in www/bookmarks:
- create-folder-2.tcl
- delete-bookmark-2.tcl
- edit-bookmark-2.tcl
- index.tcl
- insert-one-2.tcl
- insert-one.tcl
- most-popular-public.tcl
- toggle-open-close.tcl
Since this is only (so far) a partial fix, there are probably other files that need to be adjusted. However, I've got some of the major items handled, including the ability to add and delete bookmarks, create folders, edit bookmarks (which includes moving them from one folder to another), and open/close folders. I will probably be publishing a more complete fix as time permits...
Please note that some of the triggers and functions are no longer in the data model. As a workaround, I found it necessary, in some cases, to move the functionality supplied by some of the triggers and functions directly into the context of the pertinent Tcl code. The workaround(s) for the functions and triggers in question now reside mostly in edit-bookmark-2.tcl.
The bookmarks.sql file requires the following components:
- Tables
- bm_urls
- bm_list
- bm_list_pkg (was originally a package in ACS)
- Sequences
- bm_url_id_seq
- bm_bookmark_id_seq
- Functions
(Note that some functions/procedures are no longer in the data model!)- inc_char_for_sort_key
- new_sort_key
- trig_bm_list_sort_key_i_tr
- trig_bm_list_sort_key_row_u_tr
- Triggers
(Likewise, note that some triggers are no longer in the data model!)- bm_list_sort_key_i_tr
- bm_list_sort_key_row_u_tr
The revised data model, bookmarks.sql
To the best of my knowledge, then, here's what's required in the file for the data model (bookmarks.sql):-- bookmarks.sql create sequence bm_url_id_seq; create table bm_urls ( url_id integer primary key, -- url title may be null in the case of bookmarks -- that are merely icons ie. AIM url_title varchar(500), -- host url is separated from complete_url for counting purposes host_url varchar(100) not null, complete_url varchar(500) not null, -- meta tags that could be looked up regularly meta_keywords varchar(4000), meta_description varchar(4000), last_checked_date datetime, -- the last time the site returned a "live" status last_live_date datetime ); -- this insertion is required for the change in create-folder-2.tcl -- (and possibly elsewhere): insert into bm_urls (url_id, host_url, complete_url) values (0, ' ', ' '); -- (folders in bm_list are joined to a "dummy" entry in bm_urls, as above) create sequence bm_bookmark_id_seq; create table bm_list ( bookmark_id integer primary key, -- sort keys contains 3 characters per level of depth, from -- 0-9, then A-Z, a-z. You can get the depth as -- length(parent_sort_key) / 3. -- the full sort key for any bookmark is -- parent_sort_key || local_sort_key parent_sort_key varchar(99), -- parent's sort key local_sort_key char(3) not null, owner_id integer not null references users(user_id), creation_date datetime not null, modification_date datetime, -- url_id may be null if the bookmark is a folder url_id integer references bm_urls, -- a person may rename any of his bookmarks so we keep a local title local_title varchar(500), private_p char(1) default 'f' check (private_p in ('t','f')), -- needed in addition to private_p for the case where a public bookmark -- is under a hidden folder hidden_p char(1) default 'f' check (hidden_p in ('t','f')), -- this is 't' if the bookmark is a folder folder_p char(1) default 'f' check (folder_p in ('t','f')), -- null parent_id indicates this is a top level folder/bookmark parent_id integer references bm_list(bookmark_id), -- refers to whether a folder is open or closed closed_p char(1) default 't' check (closed_p in ('t','f')), -- whether the bookmark is within a closed folder and therefore not shown in_closed_p char(1) default 'f' check (in_closed_p in ('t','f')) ); CREATE function inc_char_for_sort_key (char) returns char(2) AS ' DECLARE old_char char(2); carry_p integer; old_code INTEGER; new_code INTEGER; BEGIN old_char := $1; old_code := ascii(old_char); IF old_code = 57 THEN -- skip from 9 to A new_code := 65; carry_p := 0; ELSE IF old_code = 90 THEN -- skip from Z to a new_code := 97; carry_p := 0; ELSE IF old_code = 122 THEN -- wrap around new_code := 48; carry_p := 1; ELSE new_code := old_code + 1; carry_p := 0; END IF; END IF; END IF; --old_char := chr(new_code); old_char := ichar(new_code) || cast(carry_p as char(1)); return old_char; END; --END inc_char_for_sort_key; ' language 'plpgsql'; CREATE FUNCTION new_sort_key (CHAR(3)) RETURNS CHAR(3) AS ' DECLARE v_old_sort_key char(3); v_chr_1 char(2); v_chr_2 char(2); v_chr_3 char(2); v_carry INTEGER; BEGIN v_old_sort_key := $1; IF v_old_sort_key IS null or v_old_sort_key = '''' THEN RETURN ''000''; END IF; v_chr_1 := substr(v_old_sort_key, 1, 1); v_chr_2 := substr(v_old_sort_key, 2, 1); v_chr_3 := substr(v_old_sort_key, 3, 1); v_chr_3 := inc_char_for_sort_key(v_chr_3); v_carry := cast(substr(v_chr_3, 2, 1) as integer); v_chr_3 := substr(v_chr_3, 1, 1); IF v_carry = 1 THEN v_chr_2 := inc_char_for_sort_key(v_chr_2); v_carry := cast(substr(v_chr_2, 2, 1) as integer); v_chr_2 := substr(v_chr_2, 1, 1); IF v_carry = 1 THEN v_chr_1 := inc_char_for_sort_key(v_chr_1); v_carry := cast(substr(v_chr_1, 2, 1) as integer); v_chr_1 := substr(v_chr_1, 1, 1); END IF; END IF; RETURN trim(v_chr_1) || trim(v_chr_2) || trim(v_chr_3); END; --END new_sort_key; ' language 'plpgsql'; create function trig_bm_list_sort_key_i_tr() returns opaque as ' DECLARE v_last_sort_key bm_list.local_sort_key%TYPE; v_parent_sort_key bm_list.parent_sort_key%TYPE; BEGIN IF NEW.parent_id IS NULL THEN SELECT max(local_sort_key) INTO v_last_sort_key FROM bm_list WHERE parent_id IS NULL; v_parent_sort_key := null; ELSE SELECT max(local_sort_key) INTO v_last_sort_key FROM bm_list WHERE parent_id = NEW.parent_id; SELECT parent_sort_key || cast(local_sort_key as varchar(3)) INTO v_parent_sort_key FROM bm_list WHERE bookmark_id = NEW.parent_id; END IF; NEW.local_sort_key := new_sort_key(v_last_sort_key); NEW.parent_sort_key := v_parent_sort_key; RETURN NEW; END; ' language 'plpgsql'; CREATE trigger bm_list_sort_key_i_tr before INSERT ON bm_list FOR each row execute procedure trig_bm_list_sort_key_i_tr(); create table bm_list_pkg (v_updated_ids integer, v_num_entries integer); insert into bm_list_pkg (v_num_entries) values (0); create function trig_bm_list_sort_key_row_u_tr() returns opaque as ' DECLARE old_parent_id integer; new_parent_id integer; f_bookmark_id integer; f_num_entries integer; BEGIN old_parent_id := OLD.parent_id; new_parent_id := NEW.parent_id; IF old_parent_id is null THEN old_parent_id := 0; END IF; IF new_parent_id is null THEN new_parent_id := 0; END IF; IF old_parent_id != new_parent_id THEN f_bookmark_id := NEW.bookmark_id; select into f_num_entries max(v_num_entries)+1 from bm_list_pkg; insert into bm_list_pkg (v_updated_ids, v_num_entries) values (f_bookmark_id, f_num_entries); END IF; return NEW; END; ' language 'plpgsql'; CREATE trigger bm_list_sort_key_row_u_tr before UPDATE ON bm_list FOR each row execute procedure trig_bm_list_sort_key_row_u_tr(); -- the rest of the data model (functions and triggers) has moved, -- for the time being, primarily, to edit-bookmark-2.tcl! -- need two indices to support CONNECT BY create index bm_list_idx1 on bm_list(bookmark_id, parent_id); create index bm_list_idx2 on bm_list(parent_id, bookmark_id); -- and the jury's still out on these two indexes, since our data -- model has changed substantially (and does not use CONNECT BY)!
New proc in bookmarks-defs.tcl
The bookmarks-defs.tcl file, to the best of my recollection, just has the following proc added:
proc_doc bm_set_one_in_closed_p {db owner_id bookmark_id closed_p} {This procedure insures that the 'in_closed_p' column in the 'bm_list' table is consistent with the open/closed of the folder structure (by setting all contents of a single folder in_closed_p=t)} { set sort_key [database_to_tcl_string $db " select case when parent_sort_key is null then local_sort_key else parent_sort_key || cast(local_sort_key as varchar(3)) end as sort_key from bm_list where bookmark_id = $bookmark_id and folder_p = 't' and closed_p = '$closed_p' and owner_id = $owner_id"] if { $sort_key != "" } { ns_db dml $db "begin transaction" # ### This can't be done in postgresql, so we've rewritten it # Set as in_closed_p those bookmarks which have any parent as closed. # ns_db dml $db "update bm_list set in_closed_p = 't' # where bookmark_id in (select bookmark_id from bm_list # where owner_id = $owner_id # connect by prior bookmark_id = parent_id # start with parent_id in (select bookmark_id from bm_list where owner_id = $owner_id and folder_p = 't' and closed_p = 't'))" ns_db dml $db "update bm_list set in_closed_p = '$closed_p' where owner_id = $owner_id and parent_sort_key like '$sort_key%'" ns_db dml $db "end transaction" } }
It should be noted that my logic for working with the closed_p and in_closed_p fields was as follows:
In both of these remaining scenarios, all you need to do is update closed_p as appropriate, and then update in_closed_p similarly for all the children of the folder in question. That's what the new proc accomplishes.
Changes in create-folder-2.tcl
Numbers 1 and 2 below reflect the change in the treatment of folders, which now have a dummy entry in bm_urls (as described in the data model).
- Around line 39:
(bookmark_id, owner_id, local_title, parent_id, creation_date, folder_p, closed_p, url_id)
instead of
(bookmark_id, owner_id, local_title, parent_id, creation_date, folder_p, closed_p)
- Around line 41:
($bookmark_id, $user_id, '$local_title', [ns_dbquotevalue $parent_id], sysdate(), 't', 'f', 0)
instead of
($bookmark_id, $user_id, [db_postgres_doubleapos_null_sql $local_title]', [ns_dbquotevalue $parent_id], sysdate(), 't', 'f')
- Around line 57:
# bm_set_in_closed_p $db $user_id
instead of
bm_set_in_closed_p $db $user_id
<P>
The following changes were made to the <B>delete-bookmark-2.tcl</B> file:
<P>
<OL>
<LI>The following gets commented out:
<BLOCKQUOTE>
<PRE>
# set sql_child_delete "
# delete from bm_list
# where bookmark_id in (select bookmark_id
# from bm_list
# connect by prior bookmark_id = parent_id
# start with parent_id = $bookmark_id)
# or bookmark_id = $bookmark_id"
</PRE>
</BLOCKQUOTE>
<LI>... and replaced by:
<BLOCKQUOTE>
<PRE>
set bookmark_sort_key [database_to_tcl_string $db "
select case when parent_sort_key is null then local_sort_key
else parent_sort_key || cast(local_sort_key as varchar(3)) end
from bm_list where bookmark_id = $bookmark_id"]
set sql_child_delete "
delete from bm_list
where parent_sort_key like '$bookmark_sort_key%'
or bookmark_id = $bookmark_id"`
</PRE>
</BLOCKQUOTE>
</OL>
<P>
(The theory here, in case anyone is interested, is that we avoid having
to use the Oracle proprietary CONNECT BY construct by very cleverly
leveraging the "sort keys" to produce the identical result!) I'd be happy
to explain this in more detail, if anyone wants to hear about it, since
my breakthrough in the bookmarks module hinges <I>precisely</I> on judicious
use of the sort keys, allowing us to avoid both the CONNECT BY construct
<B>and</B> the use of recursive function (or procedure) calls.
<P>
<HR>
The revised edit-bookmark-2.tcl file
Here's the complete revised edit-bookmark-2.tcl file:
# edit-bookmark-2.tcl,v 3.0 2000/02/06 03:35:21 ron Exp # edit-bookmark-2.tcl # # edit a bookmark in your bookmark list # # by aure@arsdigita.com and dh@arsdigita.com set_the_usual_form_variables # local_title, complete_url, bookmark_id, parent_id, return_url validate_integer bookmark_id $bookmark_id set user_id [ad_verify_and_get_user_id] ad_maybe_redirect_for_registration set db [ns_db gethandle] # start error-checking set exception_text "" set exception_count 0 if {(![info exists bookmark_id])||([empty_string_p $bookmark_id])} { incr exception_count append exception_text "<li>No bookmark was specified" } # make sure that the user owns the bookmark set ownership_query " select count(*) from bm_list where owner_id=$user_id and bookmark_id=$bookmark_id" set ownership_test [database_to_tcl_string $db $ownership_query] if {$ownership_test==0} { incr exception_count append exception_text "<li>You can not edit this bookmark" } # return errors if { $exception_count> 0 } { ad_return_complaint $exception_count $exception_text return 0 } if { ![info exists parent_id] || [empty_string_p $parent_id] } { set parent_id "null" } ns_db dml $db "begin transaction" # ### not sure why I had to change this section, but it didn't work ### # if the bookmark to edit is a folder, complete_url won't be defined # if { ![info exists complete_url] } { # if { [empty_string_p $complete_url]||![info exists complete_url] } { # this is a folder so edit its name # set sql_update " # update bm_list # set local_title = '[DoubleApos $local_title]', # private_p = '$private_p', # parent_id = $parent_id # where owner_id = $user_id # and bookmark_id = $bookmark_id" # set sql_update " # update bm_list # set local_title = '$local_title', # private_p = '$private_p', # parent_id = $parent_id # where owner_id = $user_id # and bookmark_id = $bookmark_id" # ns_db dml $db $sql_update # } else { if { [info exists complete_url] } { # entry is a bookmark - need to update both name and url set host_url [bm_host_url $complete_url] # check to see if we already have the url in our database set url_query "select url_id from bm_urls where complete_url = '[DoubleApos $complete_url]'" set url_id [database_to_tcl_string_or_null $db $url_query] # if we don't have the url, then insert the url into the database if {[empty_string_p $url_id]} { set url_id [database_to_tcl_string $db "select bm_url_id_seq.nextval from dual"] ns_db dml $db " insert into bm_urls (url_id, host_url, complete_url) values ($url_id, '[DoubleApos $host_url]', '[DoubleApos $complete_url]')" } # have added the url if needed - now just update the name set sql_update " update bm_list set local_title = '[DoubleApos $local_title]', url_id = $url_id, private_p = '$private_p', parent_id = $parent_id where bookmark_id = $bookmark_id" ns_db dml $db $sql_update } else { # this is a folder so edit its name set sql_update " update bm_list set local_title = '[DoubleApos $local_title]', private_p = '$private_p', parent_id = $parent_id where owner_id = $user_id and bookmark_id = $bookmark_id" ns_db dml $db $sql_update } # the bookmark_id's are tagged by a "before update" trigger on bm_list # -- and they end up as v_updated_ids in bm_list_pkg # just checking to make sure we have some updates to do... set id_count [database_to_tcl_string $db " select count(*) from bm_list_pkg where v_num_entries > 0 and v_updated_ids is not null"] set i 0 # ### for some reason, I had to do the other selects in a different loop ### # ... so, in this loop, I collect everything into arrays if {$id_count > 0} { set selection [ns_db select $db " select distinct v_updated_ids, coalesce(parent_id, -1) as parent_id, case when parent_sort_key is null then local_sort_key else parent_sort_key || cast(local_sort_key as varchar(3)) end as parent_sort_key, folder_p from bm_list_pkg, bm_list where v_num_entries > 0 and v_updated_ids = bookmark_id"] while {[ns_db getrow $db $selection]} { set_variables_after_query set v_upd_ids($i) $v_updated_ids set new_parent_id($i) $parent_id set old_parent_sort_key($i) $parent_sort_key set is_folder($i) $folder_p incr i } while {$i > 0} { incr i -1 if {$new_parent_id($i) > -1} { set v_parent_sort_key [database_to_tcl_string $db " select case when parent_sort_key is null then local_sort_key else parent_sort_key || cast(local_sort_key as varchar(3)) end from bm_list where bookmark_id = $new_parent_id($i)"] set v_last_sort_key [database_to_tcl_string $db " select max(local_sort_key) from bm_list where parent_id = $new_parent_id($i) and bookmark_id != $v_upd_ids($i)"] ns_db dml $db "update bm_list set parent_sort_key = '$v_parent_sort_key', local_sort_key = new_sort_key('$v_last_sort_key') where bookmark_id = $v_upd_ids($i)" } else { set v_last_sort_key [database_to_tcl_string $db " select max(local_sort_key) from bm_list where parent_id is null and bookmark_id != $v_upd_ids($i)"] ns_db dml $db "update bm_list set parent_sort_key = null, local_sort_key = new_sort_key('$v_last_sort_key') where bookmark_id = $v_upd_ids($i) and parent_id is null" } set v_new_sort_key [database_to_tcl_string $db " select case when parent_sort_key is null then local_sort_key else parent_sort_key || cast(local_sort_key as varchar(3)) end from bm_list where bookmark_id = $v_upd_ids($i)"] set len_old_sort_key [string length $old_parent_sort_key($i)] ns_db dml $db "update bm_list set parent_sort_key = '$v_new_sort_key' || substr(parent_sort_key, $len_old_sort_key+1, length(parent_sort_key)-$len_old_sort_key) where parent_sort_key like '$old_parent_sort_key($i)%'" } } # ### need to delete all the entries "tagged" by the update trigger # ### now that we're done updating sort keys ns_db dml $db "delete from bm_list_pkg where v_num_entries > 0" # ### I tried to do this as a Tcl proc in tcl/bookmarks-defs.tcl, ### # ### but no luck on that (so far)... # bm_recalc_sort_keys $db $user_id # ### this may need to be changed, at some point... bm_set_hidden_p $db $user_id # ### the first of these is my modification of bm_set_in_closed_p, ### # ### which, as you can tell, does only ONE folder # ### forget why I had to comment it out, but it's NOT relevant to # ### this part of the bookmark module # ### (bm_set_in_closed_p is commented out because it didn't work) # bm_set_one_in_closed_p $db $user_id $bookmark_id # bm_set_in_closed_p $db $user_id ns_db dml $db "end transaction" # send the user back to where they came from before editing began ns_returnredirect $return_url
Query change in index.tcl
In index.tcl, comment out the following query:
and do the following query instead (to replace the outer join, plus a couple of other fixes):#set bookmark_query " # select bookmark_id, # bm_list.url_id, # coalesce(local_title, url_title) as bookmark_title, # hidden_p, # complete_url, # last_live_date, # last_checked_date, # folder_p, # closed_p, # length(parent_sort_key)*8 as indent_width # from bm_list, bm_urls # where owner_id = $user_id # and in_closed_p = 'f' # and bm_list.url_id = bm_urls.url_id(+) # order by parent_sort_key || local_sort_key"
set bookmark_query " select bookmark_id, bm_list.url_id, coalesce(local_title, url_title) as bookmark_title, hidden_p, complete_url, last_live_date, last_checked_date, folder_p, closed_p, case when parent_sort_key is null then 0 else length(parent_sort_key)*8 end as indent_width from bm_list, bm_urls where owner_id = $user_id and in_closed_p = 'f' and bm_list.url_id = bm_urls.url_id union select bookmark_id, '' as url_id, local_title as bookmark_title, hidden_p, '' as complete_url, NULL as last_live_date, NULL as last_checked_date, folder_p, closed_p, case when parent_sort_key is null then 0 else length(parent_sort_key)*8 end as indent_width from bm_list where owner_id = $user_id and in_closed_p = 'f' and not exists (select 1 from bm_urls where url_id = bm_list.url_id) order by case when parent_sort_key is null then '!' else '!' || parent_sort_key end || cast(local_sort_key as varchar(3))"
Changes in insert-one-2.tcl and insert-one.tcl
In insert-one-2.tcl, it looks like I had to replace this line:with the following one:($bookmark_id, $user_id, $url_id,'[db_postgres_doubleapos_null_sql $local_title]', [ns_dbquotevalue $parent_id], sysdate())"
($bookmark_id, $user_id, $url_id,'$local_title', $parent_id, sysdate())"
In insert-one.tcl, looks like for some reason I had to omit an extra < /ul> tag, so that the following:
had to be changed to:If this is correct, choose which folder to place the bookmark in: <ul> <table> <tr> <td> [bm_folder_selection $db $user_id $bookmark_id] <p> <input type=submit value=Submit> </td> </tr> </table> </form> </ul>"
If this is correct, choose which folder to place the bookmark in: <ul> <table> <tr> <td> [bm_folder_selection $db $user_id $bookmark_id] <p> <input type=submit value=Submit> </td> </tr> </table> </form>"
Changes in most-popular-public.tcl and toggle-open-close.tcl
In most-popular-public.tcl, I had to change two queries to reflect the presence of the "dummy" entry (for folders) that I added to bm_urls. The two queries now read as follows:and:# -- get the most popular hosts ----------- set selection [ns_db select $db "select host_url, count(*) as n_bookmarks from bm_urls, bm_list where bm_urls.url_id = bm_list.url_id and bm_list.private_p <> 't' and bm_urls.url_id > 0 group by host_url order by n_bookmarks desc"]
set selection [ns_db select $db "select complete_url, url_title, count(*) as n_bookmarks from bm_urls, bm_list where bm_urls.url_id = bm_list.url_id and bm_list.private_p <> 't' and bm_urls.url_id > 0 group by complete_url, url_title order by n_bookmarks desc"]
And, finally, for toggle-open-close.tcl, the part I changed was after the "begin transaction". Here's the corrected version (note that the final line needs to be commented out -- it's uncommented in the original):
if { [string compare $action "open_all"] == 0 } { # we're assuming if all are open, no bookmarks are in closed folders! ns_db dml $db "update bm_list set closed_p = 'f', in_closed_p = 'f' where owner_id = $user_id" } elseif { [string compare $action "close_all"] == 0 } { # ... and if all folders are closed, all bookmarks are in closed folders! ns_db dml $db "update bm_list set closed_p = 't' where owner_id = $user_id" ns_db dml $db "update bm_list set in_closed_p = 't' where owner_id = $user_id and parent_id is not null" } else { validate_integer bookmark_id $bookmark_id # determine current state of folder (closed/open) set closed_p [database_to_tcl_string $db " select closed_p from bm_list where bookmark_id = $bookmark_id"] if { $closed_p == "t" } { ns_db dml $db " update bm_list set closed_p = 'f' where bookmark_id = $bookmark_id and owner_id = $user_id" set closed_p "f" } else { ns_db dml $db " update bm_list set closed_p = 't' where bookmark_id = $bookmark_id and owner_id = $user_id" set closed_p "t" } bm_set_one_in_closed_p $db $user_id $bookmark_id $closed_p } # bm_set_in_closed_p $db $user_id
Hopefully this covers everything. Please let me know if there are any questions or problems.
Final observations
If I've communicated everything correctly, these changes should result in a working bookmarks module. However, although the basic functionality (as detailed in the "Original question") should now be available, there are definitely certain items still missing, such as import/export. However, they should be much easier to track down and fix, now that the basic module is (or should be) working, once these fixes have been applied.I'll be trying your code out on our company intranet in the next couple of days. I'll post how it goes.
-Alex
ACS as it stands. Thankfully, we haven't had *too* much of it. Look at
the bright side of things: there is enough interest out there in the
community that two separate efforts have ported the bookmarks module!
I guess we should've posted it in News, too.
Ben's right that OpenACS 4.1 will help a lot - modules will be individually downloadable, and those that aren't ported simply won't exist in the release tree.