directory

package
v0.1.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Oct 27, 2026 License: MIT Imports: 34 Imported by: 0

README

Directory

A guide to organisations, shops, venues or services by category, with contact details, an address, opening hours, a search and CSV import.

An entry has the same name and address in every language; its summary, description, keywords and hours note are translated. Use one instance per guide.

Add it to the site

app.Use(
    directory.Module("directory", "Directory"),
)

The key (directory) is the instance's name in URLs and templates and must be unique on the site. The title (Directory) is what editors see in the admin navigation and the page editor. The module has no options.

Place it on a page

In the admin, open a page and choose the instance under Shows on this page. With the page at /directory:

Address Template Shows
/directory directory/list.gohtml all entries, grouped by category
/directory?q=bakery directory/list.gohtml the entries that match a search
/directory?category=<slug> directory/list.gohtml the entries of one category
/directory/<category> directory/category.gohtml one category with its entries
/directory/<category>/<entry> directory/entry.gohtml one entry

Entries are sorted by name; groups and categories follow the order set in the admin. The search on the list needs every word in the entry's name, summary or keywords, ignoring case and accents, like the site search. An entry that moves to another category, or whose category gets a new web address, keeps working at its old address, which redirects.

Templates are looked up by instance key first, then by module type, so a second instance directory.Module("shops", "Shops") uses shops/list.gohtml when it exists and directory/list.gohtml otherwise. Generate starting points with every field of the data:

task templates -- directory

A minimal list with a search form:

{{/* @dot github.com/donseba/go-webthings/modules/directory.ListView */}}
<h1>{{.Title}}</h1>

<form action="{{.URL}}">
    <input type="search" name="q" value="{{.Query}}">
    <button>Search</button>
</form>

{{range .Categories}}
    <a href="{{.URL}}">{{.Name}} ({{.Count}})</a>
{{end}}

{{range .Groups}}
    <h2>{{.Category.Name}}</h2>
    {{range .Entries}}
        <p><a href="{{.URL}}">{{.Name}}</a>{{if .OpenNow}} · open now{{end}}<br>{{.Summary}}</p>
    {{end}}
{{end}}

entry.gohtml gets a single directory.Entry. .Description is the rendered Markdown, .ImageURL the logo or image as pages show it, at most 1600 pixels wide, and .HasLocation tells whether .Latitude and .Longitude are known, for a map. .ImageSrcset, .ImageWidth and .ImageHeight complete the img tag, and .ImageOriginalURL is the uploaded file:

{{if .ImageURL}}<img src="{{.ImageURL}}" srcset="{{.ImageSrcset}}" sizes="20rem" width="{{.ImageWidth}}" height="{{.ImageHeight}}" alt="">{{end}}

Opening hours:

{{if .Hours.Known}}
    <table>
        {{range .Hours.Week}}
            <tr><th>{{weekday .Weekday}}</th><td>{{if .Closed}}closed{{else}}{{.Open}}–{{.Close}}{{end}}</td></tr>
        {{end}}
    </table>
    <p>{{.HoursNote}}</p>
{{end}}

.Hours.Week runs from Monday to Sunday. .Hours.Known is false when no day has times, so such entries show no hours rather than "closed" every day. .OpenNow is worked out when the page renders, in the site's timezone.

Show entries anywhere

{{range directoryCategories "directory"}}
    <a href="{{.URL}}">{{.Name}} ({{.Count}})</a>
{{end}}

{{range directoryEntries "directory" "food" 5}}
    <a href="{{.URL}}">{{.Name}}</a>
{{end}}

{{with directoryEntry "directory" "bakery-jansen"}}
    <p>{{.Name}}, {{.Street}}, {{.City}}{{if .OpenNow}} · open now{{end}}</p>
{{end}}

directoryEntries takes a category slug ("" for all categories) and a limit (0 or less for all). directoryCategories returns every published category, also those without entries. Links point to the page the instance is placed on; while it is on no page in the current language, .URL of entries and categories is empty.

Content API

While the site's content API is on (Config.API), /api/v1/<key>/entries and /api/v1/<key>/categories serve the published entries and categories as JSON, in the language of ?locale=, or the site's default language without it. With the instance directory:

Endpoint Route name Answers with
/api/v1/directory/entries api.directory.entries The published entries of the published categories, by name, as a list
/api/v1/directory/entries?category=<slug> The entries of one category; a category that does not exist or is not published answers 404 not_found
/api/v1/directory/entries?slug=<slug> The entry with that slug
/api/v1/directory/entries/{id} api.directory.entry One entry, with its description
/api/v1/directory/categories api.directory.categories The published categories, in the editors' order, as a list

An entry has its id, slug and name, its summary, keywords and hours_note in the language of the answer, its category and its image as an image object. The contact details, address, position and opening hours are those the entry's page shows, each null when not given:

Field
website, email, phone As the editors wrote them
address street, postcode and city
location latitude and longitude
opening_hours The days from Monday to Sunday, each with day, such as monday, and open and close, such as "08:00", both null on a closed day

url is the entry's page, null while the instance is on no page; api_url and updated_at follow. /entries/{id} adds description_html, when the entry has a description. The API has no open_now, which changes with the time of day; work it out from opening_hours. A category has its id, slug, name and summary, entry_count, the number of its published entries, its page as url and its entries in the API as entries_url.

/entries/{id} sends no Last-Modified, and neither do the lists: an entry's category holds entry_count, which another entry changes. The ETag answers If-None-Match; see Caching.

Editing

The admin section (/admin/directory; /admin is the default admin path) lists the entries by name, with their category, and links to Categories and Import CSV.

  • An entry has a name, a web address, a category, a logo or image, a website, e-mail, phone, an address with an optional latitude and longitude, and weekly opening hours. Per language it has a summary, a Markdown description, search keywords (separated by commas) and a note on the hours, such as "closed on public holidays".
  • Latitude and longitude are number fields in decimal degrees, such as 52.3731 and 4.8922, with a decimal point; a comma is not accepted. Fill in both or neither; the latitude lies between -90 and 90 and the longitude between -180 and 180, or the field gets an error.
  • An empty Web address, of an entry or a category, is made from the name and kept when the name changes later: lowercase letters and digits joined by dashes, accents dropped, at most 80 characters, so "Café Noord" becomes cafe-noord. A name without Latin letters or digits needs a web address typed by hand. Web addresses are unique per instance. The website must be a full address starting with http:// or https://; anything else gets an error at its field.
  • Opening hours are an opening and a closing time per day; a day without times is closed. A closing time at or before the opening time runs past midnight (22:00–02:00), 24:00 closes at midnight and 00:00–00:00 is open all day.
  • Categories have a web address, and per language a name and summary. They are ordered by hand. A category that still has entries cannot be deleted. Deleted entries keep their category, so it can only be deleted for good after them.
  • Entries and categories are published one by one, without revisions. An entry only shows while its category is published. Entries show in each language they were saved in, categories in each language they have a name in.
  • Move to trash moves an entry or a category to the trash, where it can be restored or deleted for good. Its web address is free again meanwhile; restoring it is refused when another item has taken it.

Editors' rights are set per instance in the groups' permission matrix; categories and the import share the rights of their instance.

Once the instance is on a page, published entries appear in the site search, found by name, summary, keywords and description, and every category and entry address appears in sitemap.xml.

An entry's page describes the entry to search engines in its structured data, which core's head tags write with {{structuredData}}. The entry is a schema.org LocalBusiness when it has opening hours, with them as openingHoursSpecification; a Place when it has a street, a postcode, a city or a latitude and longitude, without the e-mail address, which a Place does not have; and an Organization otherwise. Each has the name, the summary as description, the page as url, the image, the phone number, the e-mail address, the website as sameAs, the address as a PostalAddress and the latitude and longitude as geo, as far as the entry has them. The list and a category's page are an ItemList of the entries they show, in their order. See Search and SEO.

Links to addresses on the site in the descriptions and websites of published entries count for Links to a page: the editor of a page lists the entries that link to it, and Pages › Broken links lists their links that lead nowhere.

The editor of a published entry lists the published content that links to the entry and asks before it is unpublished or moved to the trash. With the redirects module, editors who may create redirects also get Unpublish and redirect, and saving a published entry with a new Web address redirects its old address to the new one; see Links to an item. The editor of a published category does the same for the category's page, such as /directory/food, and counts the links to its entries' addresses, such as /directory/food/bakery, apart from those: "1 published item links to an address below this item.", since unpublishing the category takes its entries offline too; a category that still has entries cannot be moved to the trash. While only such links exist, it asks "Published items link to addresses below this item. Unpublish it anyway?". Unpublish and redirect redirects the entries' addresses too, to the same page. When the category gets a new Web address, only its own address gets a redirect; its entries' old addresses lead to their new ones without one, as for an entry that moved to another category.

Import entries from CSV

Import CSV takes an uploaded file or pasted text. The file must be a CSV, TSV or plain-text file (.csv, .tsv, .txt); another kind gets an error at the field. The first row names the columns, in any order: name and category are required, summary, website, email, phone, street, postcode, city and keywords are optional. Values are separated by commas, semicolons or tabs, whichever the first row uses most.

name;category;summary;website;city;keywords
Bakery Jansen;Food;Bread and cakes;bakery-jansen.example;Amsterdam;bread, cake
Town Hall;Services;Passports and permits;;Amsterdam;passport
  • A row matches an existing entry by the web address made from its name. Existing entries are skipped, unless Update entries that already exist is checked; columns missing from the file then keep their values.
  • The category is found by its name in the default language, ignoring case, or by its web address. Missing categories are created.
  • A website without a scheme gets https://: bakery-jansen.example becomes https://bakery-jansen.example.
  • The summary, keywords and new category names are stored in the site's default language. Descriptions, images, coordinates and opening hours are not imported.
  • New entries and categories stay unpublished unless Publish new entries and categories is checked. Existing categories keep their state, so entries imported into an unpublished category stay hidden until it is published.

The result counts what was created, updated and skipped, and lists each skipped row with the reason. The import needs the create right; updating also needs the edit right and publishing the publish right.

From Go

directory.Of(app, "directory") returns the instance once the app has started:

guide := directory.Of(app, "directory")

bakeries, err := guide.Entries(ctx, "en", directory.Query{Search: "bread", Category: "food"})

Entry(ctx, locale, slug) returns one entry and ListCategories(ctx, locale) the categories. While the instance is on no page in that language, they still return the published entries and categories, with an empty URL. Store() and Categories() give write access for seed data, through the same rules the admin uses, so an entry without a category is refused. Import runs the CSV import on any io.Reader:

options := directory.ImportOptions{Locale: "en", Update: true, Publish: true}

result, err := guide.Import(ctx, file, options)
if err != nil {
    return err
}

log.Printf("%d created, %d updated, %d skipped", result.Created, result.Updated, result.Skipped)
  • People presents the people of an organisation rather than organisations.
  • Events keeps an agenda with locations.
  • Lists keeps short ordered lists, such as services, without addresses or hours.

Documentation

Overview

Package directory is a categorised directory: organisations, shops, venues or a neighbourhood guide. Categories have a translated name and summary and are ordered by hand. Entries belong to one category and have a name, a slug, a translated summary and Markdown description, search keywords, a logo or image, contact details, an address with an optional map position and weekly opening hours. Add an instance with:

app.Use(directory.Module("guide", "Neighbourhood guide"))

Names and slugs are not translated: an organisation has the same name and URL in every language. Categories and entries show in a language once they are saved in it, and an entry only shows while its category is published. A category with entries cannot be deleted.

Pages

Below the page the module is mounted on it serves:

/                      directory/list.gohtml      ListView
/?q=bakery             the same, searched
/?category=food        the same, one category
/<category>            directory/category.gohtml  CategoryView
/<category>/<entry>    directory/entry.gohtml     Entry

The search matches entries whose name, summary or keywords contain every word of q, ignoring case and accents like the site search. A search form is a plain GET form:

<form action="{{.URL}}"><input type="search" name="q" value="{{.Query}}"></form>

sitemap.xml lists the page the module is mounted on (like every page) and the address of every published category and entry; searches and filtered lists are left out. The site search finds entries by name, summary, keywords and description, and signed-in editors get a link to the entry or category in the admin.

Template functions

Every template can show the directory without mounting it:

{{range directoryCategories "guide"}}<a href="{{.URL}}">{{.Name}} ({{.Count}})</a>{{end}}
{{range directoryEntries "guide" "food" 5}}<a href="{{.URL}}">{{.Name}}</a>{{end}}
{{with directoryEntry "guide" "bakery-jansen"}}{{.Name}}{{if .OpenNow}} · open{{end}}{{end}}

directoryEntries takes a category slug ("" for all categories) and a limit (0 or less for no limit). URLs point to the page the module is mounted on; while the module is on no page in the current language they are empty.

Opening hours

Entry.Hours is the weekly schedule and Entry.OpenNow tells whether the entry is open while the page renders, in the site's timezone:

{{if .Hours.Known}}{{range .Hours.Week}}{{.Weekday}}: {{if .Closed}}closed{{else}}{{.Open}}–{{.Close}}{{end}}{{end}}{{end}}

Import

The admin imports entries from a CSV file whose first row names the columns: name, category, summary, website, email, phone, street, postcode, city and keywords; name and category are required, others may be left out. Commas, semicolons and tabs separate the values. Missing categories are created. Text columns are stored in the site's default language.

"Publish new entries and categories" publishes what the import creates. It never publishes an existing category: an editor may keep a category hidden on purpose, so entries imported into an unpublished category stay off the site until the category is published.

Index

Constants

This section is empty.

Variables

This section is empty.

Functions

func Module

func Module(key, title string) webthings.Module

Module adds a Directory instance. key is used in URLs of the admin and must be unique on the site; title is shown in the admin and the page editor.

Types

type APIAddress

type APIAddress struct {
	Street   string `json:"street"`
	Postcode string `json:"postcode"`
	City     string `json:"city"`
}

APIAddress is an entry's postal address.

type APICategory

type APICategory struct {
	ID      string `json:"id"`
	Slug    string `json:"slug"`
	Name    string `json:"name"`
	Summary string `json:"summary"`
	// EntryCount is the number of published entries in the category.
	EntryCount int `json:"entry_count"`
	// URL is the category's page, null while the directory is on no page;
	// EntriesURL its entries in the API.
	URL        *string `json:"url"`
	EntriesURL string  `json:"entries_url"`
}

APICategory is a published category in the content API, at /api/v1/<key>/categories and with each of its entries.

type APIDay

type APIDay struct {
	Day   string  `json:"day"`
	Open  *string `json:"open"`
	Close *string `json:"close"`
}

APIDay is one day of an entry's opening hours: the weekday in English lower case, such as "monday", and its opening and closing times as "15:04", both null on a closed day (see Day).

type APIEntry

type APIEntry struct {
	ID       string   `json:"id"`
	Slug     string   `json:"slug"`
	Name     string   `json:"name"`
	Summary  string   `json:"summary"`
	Keywords []string `json:"keywords"`
	// DescriptionHTML is the entry's description as HTML (api.HTML); only
	// the entry's own endpoint has it.
	DescriptionHTML string      `json:"description_html,omitempty"`
	Category        APICategory `json:"category"`
	Image           *api.Image  `json:"image"`
	Website         *string     `json:"website"`
	Email           *string     `json:"email"`
	Phone           *string     `json:"phone"`
	Address         *APIAddress `json:"address"`
	Location        *APIPoint   `json:"location"`
	// OpeningHours are the days from Monday to Sunday, null for an entry
	// without opening hours; HoursNote is the editors' note about them.
	OpeningHours []APIDay `json:"opening_hours"`
	HoursNote    string   `json:"hours_note"`
	// URL is the entry's page, null while the directory is on no page;
	// APIURL the entry in the API.
	URL       *string   `json:"url"`
	APIURL    string    `json:"api_url"`
	UpdatedAt time.Time `json:"updated_at"`
}

APIEntry is a published entry in the content API (see package api), at /api/v1/<key>/entries/{id} and in the list of /api/v1/<key>/entries, which leaves out DescriptionHTML. The contact details, address, position and opening hours are those the entry's page shows, also in its structured data; each is null when not given.

type APIPoint

type APIPoint struct {
	Latitude  float64 `json:"latitude"`
	Longitude float64 `json:"longitude"`
}

APIPoint is an entry's position on a map.

type Category

type Category struct {
	ID      string
	Name    string
	Slug    string
	Summary string
	URL     string
	// Count is the number of published entries in the category.
	Count int
	// Active is set on the category the list is filtered by.
	Active bool
}

Category is a published category as templates see it.

type CategoryFields

type CategoryFields struct {
	Slug string `db:"slug"`
}

CategoryFields are the language-independent parts of a category.

type CategoryItem

type CategoryItem = content.Item[CategoryFields, CategoryText]

CategoryItem is a category with its translations, as the store keeps it.

type CategoryText

type CategoryText struct {
	Name    string `db:"name"`
	Summary string `db:"summary"`
}

CategoryText is a category in one language.

type CategoryView

type CategoryView struct {
	Category Category
	Entries  []Entry
	// ListURL links back to the whole directory.
	ListURL string
}

CategoryView is the data of directory/category.gohtml.

type Day

type Day struct {
	Open  string `json:"open,omitempty"`
	Close string `json:"close,omitempty"`
}

Day is the opening time of one weekday as "15:04" wall-clock times. A day without times is closed. A Close at or before Open means the entry closes after midnight; "24:00" closes at midnight and "00:00"–"00:00" is open all day.

func (Day) Closed

func (d Day) Closed() bool

Closed reports whether the entry is closed all day.

type Entry

type Entry struct {
	ID          string
	Name        string
	Slug        string
	URL         string
	Category    Category
	Summary     string
	Description template.HTML
	Keywords    []string
	// ImageURL is the entry's logo or image as pages show it, at most media.ImageWidth
	// wide (see frontend.Frontend.Image); ImageSrcset lists smaller
	// copies for a srcset attribute, ImageWidth and ImageHeight are the
	// size of ImageURL and ImageOriginalURL is the uploaded file, for
	// downloads. All are empty when the entry has none.
	ImageURL         string
	ImageSrcset      string
	ImageOriginalURL string
	ImageWidth       int
	ImageHeight      int
	Website          string
	Email            string
	Phone            string
	Street           string
	Postcode         string
	City             string
	// HasLocation is set when Latitude and Longitude are known.
	HasLocation bool
	Latitude    float64
	Longitude   float64
	Hours       OpeningHours
	HoursNote   string
	// OpenNow is Hours.OpenNow at the time the page renders, in the site's
	// timezone.
	OpenNow bool
}

Entry is a published entry as templates see it.

type EntryFields

type EntryFields struct {
	Category  string          `db:"category_id,null"`
	Slug      string          `db:"slug"`
	Name      string          `db:"name"`
	Image     string          `db:"image_id,null"`
	Website   string          `db:"website"`
	Email     string          `db:"email"`
	Phone     string          `db:"phone"`
	Street    string          `db:"street"`
	Postcode  string          `db:"postcode"`
	City      string          `db:"city"`
	Latitude  sql.NullFloat64 `db:"latitude"`
	Longitude sql.NullFloat64 `db:"longitude"`
	Hours     OpeningHours    `db:"opening_hours"`
}

EntryFields are the language-independent parts of an entry. Website is a full http(s) URL. Latitude and Longitude are both set or both empty.

type EntryItem

type EntryItem = content.Item[EntryFields, EntryText]

EntryItem is an entry with its translations, as the store keeps it.

type EntryText

type EntryText struct {
	Summary     string `db:"summary"`
	Description string `db:"description"`
	Keywords    string `db:"keywords"`
	HoursNote   string `db:"hours_note"`
}

EntryText is an entry in one language. Description is Markdown; Keywords are comma-separated words the search also matches.

type Group

type Group struct {
	Category Category
	Entries  []Entry
}

Group is a category with its entries on the list page.

type ImportOptions

type ImportOptions struct {
	// Locale is the language of the summary, keywords and new category names.
	Locale string
	// Update changes entries whose slug already exists; otherwise such rows
	// are skipped.
	Update bool
	// Publish publishes the entries and categories the import creates.
	// Existing categories keep their state: an editor may hide a category on
	// purpose, so entries imported into an unpublished category stay off the
	// site until the category is published.
	Publish bool
}

ImportOptions change what Import does with the rows.

type ImportProblem

type ImportProblem struct {
	Row    int
	Reason string
}

ImportProblem is a row that was skipped. Reason is an admin message key.

type ImportResult

type ImportResult struct {
	Created    int
	Updated    int
	Skipped    int
	Categories int
	Problems   []ImportProblem
}

ImportResult tells what an import did.

type ImportView

type ImportView struct {
	Title      string
	BackURL    string
	Columns    string
	CanUpdate  bool
	CanPublish bool
	Form       form.RenderModel
	Errors     form.FieldErrors
	Result     *ImportResult
}

ImportView is the data of the import screen. The template lays out Form field by field: Update and Publish only for editors who may use them.

type ListView

type ListView struct {
	// Title is the title of the page the module is mounted on.
	Title string
	// URL is the list's address, the action of a search form.
	URL string
	// Query is the search (?q=); empty shows every entry.
	Query string
	// Category is set when the list is filtered (?category=).
	Category *Category
	// Categories are all categories with entries, for a filter menu.
	Categories []Category
	// Groups hold the matching entries per category, in category order.
	Groups []Group
	// Count is the number of matching entries.
	Count int
}

ListView is the data of directory/list.gohtml.

type OpeningHours

type OpeningHours [7]Day

OpeningHours is a weekly schedule indexed by time.Weekday, so hours[time.Monday] is Monday. It is stored as JSON in one column.

func (OpeningHours) Known

func (h OpeningHours) Known() bool

Known reports whether the schedule has any opening time. Entries without one show no opening hours at all rather than "closed" every day.

func (OpeningHours) OpenNow

func (h OpeningHours) OpenNow(t time.Time) bool

OpenNow reports whether the entry is open at t. t must be in the timezone the hours are written in; the module's own views use the site's timezone.

func (*OpeningHours) Scan

func (h *OpeningHours) Scan(value any) error

Scan implements sql.Scanner, reading the JSON column back; an empty or NULL column is no opening hours.

func (OpeningHours) Value

func (h OpeningHours) Value() (driver.Value, error)

Value stores the schedule as JSON, or "" when no day has times.

func (OpeningHours) Week

func (h OpeningHours) Week() []WeekDay

Week returns the days from Monday to Sunday, for example {{range .Hours.Week}}{{.Weekday}} {{.Open}}–{{.Close}}{{end}}.

type Query

type Query struct {
	// Search holds words that must all appear in the name, summary or
	// keywords. Case and accents are ignored, as in the site search.
	Search string
	// Category is a category slug.
	Category string
}

Query selects entries; the zero Query selects every published entry.

type Service

type Service struct {
	// contains filtered or unexported fields
}

Service is one Directory instance, for the website's own code.

func Of

func Of(app *webthings.App, key string) *Service

Of returns the Directory instance with key from a started app, or nil.

func (*Service) Categories

func (s *Service) Categories() content.Store[CategoryFields, CategoryText]

Categories gives write access to this instance's categories.

func (*Service) Entries

func (s *Service) Entries(ctx context.Context, locale string, query Query) ([]Entry, error)

Entries returns the published entries in locale that match query, sorted by name, for use outside the module's own pages. URLs are empty while the module is on no page in locale.

func (*Service) Entry

func (s *Service) Entry(ctx context.Context, locale, slug string) (Entry, error)

Entry returns the published entry with slug in locale, or content.ErrNotFound.

func (*Service) Import

func (s *Service) Import(ctx context.Context, source io.Reader, options ImportOptions) (ImportResult, error)

Import reads entries from CSV (see the package documentation for the columns) and creates or updates them. A row that cannot be saved is skipped and reported; other errors stop the import.

func (*Service) ListCategories

func (s *Service) ListCategories(ctx context.Context, locale string) ([]Category, error)

ListCategories returns the published categories in locale in their order, with the number of entries in each. URLs are empty while the module is on no page in locale.

func (*Service) Store

func (s *Service) Store() content.Store[EntryFields, EntryText]

Store gives write access to this instance's entries, for imports and seeds. Admin edits go through the same store.

type WeekDay

type WeekDay struct {
	Weekday time.Weekday
	Day
}

WeekDay is one row of a weekly schedule, for templates.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL