Sun, 2008 Oct 05
Posted in Computing
at 19:35
by jmorgan
I’m playing Minesweeper a lot these days. I know, classic Windows game. The one
the haven’t yet messed up with an “Internet version”. Anyway, it’s a fun game,
doesn’t take too long per game, although in sum, I can wile away many hours.
Complaint: I’m a big fan of resolution increases. I can fit more stuff on the
screen, and it’s increasingly clear. The problem is that things keep getting
physically smaller, which is particularly noticeable with the little cheesy
games which are sensitive to click location. Say, Minesweeper. Also, websites
(and no, I don’t particularly care IE7’s weird resizing thing. It ends up
looking fuzzy. Of course, I avoid IE anyway).
So, what about per application/window resolution? Is this technically feasible?
Obviously, it could be a pain for the user–“Hey, why is the menu bar in Word
so much bigger”–but it could be very nice as well. I have my mouse reactions
tuned to my normal state of work–bookkeeping and programming. I want quick
movement not fine interactions, which doesn’t work so well in super-mini-looking
Minesweeper.
General purpose typically means multiple purpose. But resolution settings tend
to hope for one-size-fits-all. Of course, there’s probably many other solutions.
Fri, 2008 Sep 12
Posted in Wistle
at 09:00
by jmorgan
Multi Site views and public files
Parts
1
2
3
4
Finally, I want to be able to create the views, and do so using haml, erb, etc,
and store them in Subversion, and have different views (and that means different
stylesheets, etc) for each site. That involves three major actions:
- Decide how to organize the per-site files.
- Figure out how to get those files updates.
- Tell Merb where to find the files.
My organization goes like this
/app
/sites
/SITENAME
/views
(possibly /helpers here in the future).
/public
/sites
/SITENAME
The file updates is more tricky. An easy option would be to use svn:externals.
This could be a hassle, though, if the Wistle app is hosting a lot of sites.
Instead, I'm going to update SiteSync to also update the views and public files.
This will be done by deleting the current directory (when there has been an
update) and exporting the most recent files. First thing, a few more properties
in Site:
class Site
property :views_uri, Text
property :views_revision, Integer, :default => 0
property :public_uri, Text
property :public_revision, Integer, :default => 0
# A URI based off of contents_uri to use as the base for building URI's
# for public and views
def base_uri
ary = contents_uri.split("/")
ary.pop if ary[-1].blank?
ary.pop
ary.join("/") + "/"
end
def views_uri
@views_uri || (base_uri + "app/views")
end
def public_uri
@public_uri || (base_uri + "public")
end
end
The additional methods give me some default URIs based on the contents_uri. This
is based on my preferred organization.
SiteSync is where the big updates happen. Basically, I add some methods to check
if there are updates to the views or public files. If so, the current are
deleted and an export is done. This means that the files could be inaccessible
for a few seconds (depending on connection speed and repository size). I'm also
not sure if/when reboots would be required in a production environment.
class SiteSync
def run
super
export_views
export_public
end
def export_views
export("views", File.join(Merb::root, "app", "sites", @model_row.name, "views"))
end
def export_public
export("public", File.join(Merb::root, "public", "sites", @model_row.name))
end
def export(name, export_path)
export_path = File.expand_path(export_path)
uri = @model_row.__send__("#{name}_uri")
rev = @model_row.__send__("#{name}_revision")
connect(uri)
return false if @repos.latest_revnum <= rev
updated_rev = @repos.stat(uri[(@repos.repos_root.length)..-1], @repos.latest_revnum).created_rev
return false if updated_rev <= rev
FileUtils.mkdir_p(export_path)
FileUtils.rm_rf(export_path)
@ctx.export(uri, export_path)
@model_row.update_attributes("#{name}_revision" => @repos.latest_revnum)
true
end
end
Method #export is the workhorse here, and the bulk is checking if we really need to do
any work and that the path is ready for the export. The actual @ctx.export line
is anticlimatic.
This does require some updates in Wistle::SvnSync because we may be accessing
multiple repositories within on instance. In short, #connect and #context both
need to accept a uri option rather than relying on @config.uri. Probably some
refactoring is in order (move all connection work to another class, for
example).
Telling Merb where to find the files
This turns out to be surprisingly easy, so long as the "correct" helper methods
are used. On that note, I'll look first at the public files. This requires two
override methods in GlobalHelpers.
module Merb
module GlobalHelpers
def image_tag(img, opts ={})
opts[:path] ||= "/sites/#{@site.name}/images/"
super(img, opts)
end
def asset_path(asset_type, filename, local_path = false)
path = super(asset_type, filename, local_path)
"/sites/#{@site.name}#{path}"
end
end
end
image_tag generates a :path option to the site-specific image directory, unless
:path has been set manually. It then calls super to let the original method do
the real work.
asset_path is similar but, well, backwards. This is called by js_include_tag
and css_include_tag to generate the appropriate path. I call super to let the
parent method again do the real work. Then I prepend its result with the
site-specific public path.
Another option is if I were using Lighttpd or Apache or something similar to
serve public files, I could use the web server's url rewriting capabilities.
The approach I take for the views is fun. In Application, I add this little
jewel:
class Application < Merb::Controller
before :update_template_roots
after :revert_template_roots
def update_template_roots
self.class._template_roots = [
["#{Merb.root}/app/views", :_template_location],
["#{Merb.root}/app/sites/#{@site.name}/views", :_template_location]
]
end
def revert_template_roots
self.class._template_roots = [
["#{Merb.root}/app/views", :_template_location]
]
end
end
Is that an ugly hack or what? Surely there's a better way than back and forth
modifying a class variable. Please? Well, there probably is, but I don't know
Merb's internals well enough.
The key is the class method (I believe representing a class variable),
_template_roots . If I understand it all correctly, this is used by render to
determine possible base paths and what method to use with that path. So, with
each request to render, I tack on the current site's view path as a possible
root, call super, then revert to the default. Why this back and forth?
Because one request could be directly followed by a request for a different
Site.
I half expect to be beaten in my sleep for that one. But it works.
Revision 79
Conclusion
Of course, this is just the starting point, but it's met my goals, and I hope
it's illustrated both some basics of Merb and DataMapper as well as how these
can be used to interact with data that is not stored in an relational database.
After all, great frameworks and libraries can really free us to focus on the
important bits, but they can also make it difficult to see all the
possibilities.
After that cheesy statement, here's a few pieces I'd like to expand Wistle with
in the future:
- Tags (now supported)
- Search
- Date links (i.e. /2008 gets all articles from 2008)
- RSS/Atom
- A sync action (for use by, e.g. subversion hooks; now supported)
- Pagination
- Per-site Helpers (maybe; I've debated whether there's any likely value in
this)
- Better support for STI (I've played with this a bit)
Finally, it's worth mentioning that my intent with these articles is illustrative
and/or tutorial, rather than to start a "project". That is, I hope this helps
people who are writing their own blog or similar application. However, should
you decide to use Wistle, that's great, and I'd be happy to receive bug reports,
feature requests, etc. Whether I will do anything with them probably depends on
the day.
Fri, 2008 Sep 05
Posted in Wistle
at 09:00
by jmorgan
Multi Site models
Ah, point #4, multiple sites hosted on one Wistle instance. I'm not going to
create additional "library" functionality to support this. After all, this is
getting pretty application-specific. But, I am going to take advantage of the
existing Wistle library.
The key point here is going to be a Site model, that: a) Articles belong to; and
b) takes over storing per-site configuration. In essence, it will replace
Wistle::Config. The key is that "site-wide" configuration will subsitute for
model-wide configuration. So, let's start with the Site model.
class Site
include DataMapper::Resource
has n, :articles
property :id, Integer, :serial => true
property :name, String, :unique => true, :nullable => false
property :domain_regex, String
# Subversion
property :contents_uri, Text
property :contents_revision, Integer, :default => 0
property :username, String
property :password, String
property :property_prefix, String, :default => "ws:"
property :extension, String, :default => "txt"
# Content Filters
property :article_filter, String
property :comment_filter, String
# Timestamps
property :created_at, DateTime
property :updated_at, DateTime
end
Here's the properties (and has n, :articles).
Note that the properties under the "Subversion" heading
are pretty close to the instance variables of Svn::Config. Also, notice
contents_uri and contents_revision. These match with Config's uri and revision.
Why the prefix? Because I want to able to use a different uri (possibly in
another repo) for views and public files. But that is for the next section. I
could set up username, etc this same way; I won't for now, because I have no
use for doing so. If I were, however, I would probably create yet another model,
called "Config" or something that belongs to a Site, with a role property.
Like I said, it's not needed for now, so I won't bother.
The contents_* fields could create a problem though, because Wistle::SvnSync
expects different names. A simple solution is some (not-quite) aliasing:
class Site
def uri
@contents_uri
end
def revision
@contents_revision
end
def revision=(rev)
attribute_set(:contents_revision, rev)
end
def body_property
:body
end
end
The revision= is also used by Wistle::SvnSync, and body_property is another
configuration option that SvnSync expects. With body_property, there's only one
option, at least so long as I only use the one model (Article). So,
body_property always returns :body. I'll show how all this hooks into SvnSync in
a moment. Before that, though, a bit about the :domain_regex property.
Wistle is not designed to be user-friendly in the traditional sense, except
when the user is defined as me. For example, adding Sites, deleting Comments,
etc. must, at this point, be done through a console. That's great by me, but for
someone without programming experience, Wistle would probably not be a good
choice. Another example is the domain regex property. It's used by
Site.by_domain (below) to find a site based on a domain. Except, as it's name
implies, domain_regex is a regular expression. Great for me, might be less
attractive to others.
class Site
class << self
# Find a Site by domain regex, prefer longest match.
def by_domain(val)
possible = []
# Find matching Sites
Site.all.each do |s|
r = Regexp.new(s.domain_regex.to_s, true)
m = r.match(val)
if m
possible << [s, m[0].length]
end
end
# Sort for longest match.
possible.sort!{ |a, b| b[1] <=> a[1] }
possible[0] ? possible[0][0] : nil
end
end
end
I no longer need to include Wistle::Svn in the Article model, but I do need to
add in the properties that Wistle::Svn took care of.
class Article
# Subversion-specific properties
property :path, String
property :svn_created_at, DateTime
property :svn_updated_at, DateTime
property :svn_created_rev, String
property :svn_updated_rev, String
property :svn_created_by, String
property :svn_updated_by, String
end
I also update how Filters works to deal with the *_filter properties. To utilize
these properties, in Article and Comment, I change the :filter option of the
body property to set :default => :site . This tells the Filters::Resource
module to use the Site model to determine default filters. In Comment, I also
add a method #site, because Filters may try to call this method.
class Comment
def site
@article.site
end
end
Now, you may have noticed a few weird methods that didn't do much in SvnSync,
partically get and new_record. Here's where they come in. To use SvnSync
with the new Site model (instead of the Wistle::Model Model), a few things have
to change. First, Site doesn't have a config method, pointing to a
Wistle::Config object. It does, however, respond to the the same methods as a
Config object. Second, when creating or getting the content, we need to scope by
Site. What to do? Inherit Wistle::SvnSync and override a few key methods.
class SiteSync < Wistle::SvnSync
def initialize(model_row)
@model_row = model_row
@model = Article
@config = model_row
end
# Get an Article by site and path.
def get(path)
Article.first(:site_id => @model_row.id, :path => short_path(path))
end
def new_record
@model.new(:site_id => @model_row.id)
end
end
Awesome-sauce.
Now, just hook in Site to SiteSync and all the ugly work is done!
class Site
def sync
SiteSync.new(self).run
end
class << self
def sync_all
Site.all.each do |site|
site.sync if site.contents_uri
end
end
end
end
The controllers need a few updates to filter by Site (and the application view
needs one for the list of recent articles, but I'm ignoring views). Application
needs updates first:
class Application < Merb::Controller
before :sync_articles
before :choose_site
protected
def sync_articles
Site.sync_all
end
def choose_site
@site = Site.by_domain(request.host)
end
end
I change the syncarticles method to use Site.sync_all. Then, I add a
choosesite before filter to assign @site, using Site.by_domain (request.host
is the full host name including any port number).
One other bit I want to do that might as well fall in this section is folders
as categories. My approach here is definately I reflection of my personal
organizations styles; in addition, the code is probably not a good solution.
Anyway, I want each top-level folder under the articles directory to represent
a category; I want to be able to add additional subfolders without them creating
additional categories. I also prefer to use only one category per article, with
additional "categorization" through tags (which I will not be implementing in
this already way too long article).
To do so, I need to add a category property, which I'll update with a before
:save hook
class Article
property :category, String
before :save, :update_category
def update_category
if attribute_dirty?(:category) || @category.nil?
attribute_set(:category, @path.split('/')[0]) if @path
end
end
end
I then add two new methods to Site, one to get a list of categories, the second
to find published articles by category.
class Site
def categories
repository.adapter.query('SELECT category FROM articles WHERE site_id = ? group by category order by category', self.id)
end
def published_by_category(category = nil, options = {})
conditions = "datetime(published_at) <= datetime('now') "
if category
conditions << "and path like '#{category}/%' "
end
Article.all(options.merge(
:conditions => [conditions + "and site_id = ?", self.id],
:order => [:published_at.desc]))
end
end
Now is also a nice time for some routing updates, both to take advantage of
categories, and for "permalink" paths for the Articles. I'm taking advantage
of Merb's support for regular expressions in routes:
Merb::Router.prepare do |r|
r.resources :articles do | article |
article.resources :comments
end
r.match('/').to(:controller => 'articles', :action =>'index')
r.match(%r[/categories/(.*)]).to(
:controller => 'articles', :action => 'index', :category => '[1]')
r.match(%r[/(.*)]).to(
:controller => 'articles', :action => 'show', :path => '[1]')
end
The articles resource remains to support comments, although it is probably not
needed.
The last match is the "permalink" one, so that there's not "articles" or other
prefixes in permalinks; doing this obviously depends on the particular
application.
And the Articles controller gets a couple of updates to take advantage of these
routes:
class Articles < Application
# provides :xml, :yaml, :js
def index
@articles = @site.published_by_category(params[:category])
display @articles
end
def show
if params[:path]
@article = Article.first(:path => params[:path], :site_id => @site.id)
else
@article = Article.first(:id => params[:id], :site_id => @site.id)
end
raise NotFound unless @article
display @article
end
end
Revision 73
And, next, the views...