Hi,
I'd like to ask if you're using tags with your tasks like:
- Task1 #task #project1
Can SB manage nested tasks like: #project/todo #project/mails, does anybody use them?.
Regards.
Hi,
I'd like to ask if you're using tags with your tasks like:
Can SB manage nested tasks like: #project/todo #project/mails, does anybody use them?.
Regards.
Great!, nice page, is there any kind of template/plugin?...
Thanks for helping.
Functions:
Renders the body of the [[tagCloud|Tag Cloud]] virtual page (implemented in [[Library/Personal/VirtualPages/TagCloud]]):
```
${tagCloud.render()}
```
Two parts. On top, a **weighted cloud** of every tag in the space, alphabetical, with type size scaled to how often the tag is used (six log-spaced steps, so `#news` at 146 doesn't flatten everything below 10 into the same size). Underneath, a **card per namespace** โ the top-level segment of slash tags like `shopping/grocery` โ listing its tags with counts, biggest first, heaviest namespace first. A namespace holding only one tag (`#thoughts`, or a lone `#coffee/espresso`) doesn't earn a card; those collect into a single trailing "Ungrouped" card.
Cards with more than a dozen tags start collapsed; every card is a `<details>`, so the open/closed state is the browser's business, not ours.
Counts are index records, one per page ร tag ร context โ the unit `tag.count()` has always reported, so the numbers match what the space showed before. A tag that appears both in a page's frontmatter and in a list item on that same page therefore counts twice. Everything comes from **one** query, counted in Lua; the previous implementation ran a `tag.count()` query per tag (~140 queries per render).
```space-lua
tagCloud = tagCloud or {}
-- Tags under this prefix are internal plumbing (#meta, #meta/repository)
-- and stay out of the cloud โ same filter tag.list() has always used.
local HIDDEN_PREFIX = "meta"
-- Cards above this many tags start collapsed.
local COLLAPSE_OVER = 12
-- Number of type sizes in the cloud; sb-tc-s1 (rarest) .. sb-tc-s6.
local SIZE_STEPS = 6
-- Emoji per top-level namespace. Unmapped namespaces fall back to ๐ท๏ธ,
-- so this only needs entries for tags that actually exist.
local ICONS = {
booking = "๐ซ",
coffee = "โ",
contact = "๐",
cooking = "๐ณ",
docker = "๐ณ",
espresso = "โ",
finance = "๐ฐ",
flights = "โ๏ธ",
food = "๐ฝ๏ธ",
health = "๐ฉบ",
home = "๐ ",
homelab = "๐ฅ๏ธ",
immigration = "๐",
important = "โญ",
inbox = "๐ฅ",
journal = "๐",
location = "๐",
marriage = "๐",
media = "๐๏ธ",
medical = "๐ฉบ",
music = "๐ต",
news = "๐ฐ",
NixOS = "โ๏ธ",
parts = "๐ฉ",
pto = "๐
",
["self-hosting"] = "๐ฅ๏ธ",
shopping = "๐",
silverbullet = "๐ฅ",
tasks = "๐",
thoughts = "๐ญ",
travel = "๐งณ",
want = "๐ฏ",
work = "๐ผ",
}
local function plural(n, word)
if n == 1 then return n .. " " .. word end
return n .. " " .. word .. "s"
end
-- Mirrors encodePageURI (lib/ref.ts): percent-encode, keep slashes.
-- Only the characters a tag name can actually contain need handling โ
-- the parser already rules out whitespace and most punctuation.
local function tagHref(name)
local ref = "tag:" .. name
local encoded = ref:gsub("%%", "%%25")
encoded = encoded:gsub(":", "%%3A")
return "/" .. encoded
end
-- The same anchor the markdown renderer emits for a `#hashtag`
-- (markdown_renderer/markdown_render.ts): `data-tag-name` is what every
-- rule in [[Library/Personal/Styles/Tags]] keys off for colour, and
-- `data-ref` is what the widget click handler turns into a local
-- navigate. Built by hand rather than passed in as a markdown string so
-- that tag names containing `_` or `*` don't come out italicised.
local function chip(name, count)
return dom.a {
class = "hashtag sb-hashtag",
["data-tag-name"] = name,
["data-ref"] = "tag:" .. name,
href = tagHref(name),
title = "#" .. name .. " ยท " .. count,
__rawText = "#" .. name,
}
end
-- One record per page ร tag ร context, folded into one entry per tag.
local function collectTags()
local records = query[[
from t = index.tag "tag"
where not t.name:startsWith(HIDDEN_PREFIX)
]]
local byName, tags, total = {}, {}, 0
for _, rec in ipairs(records) do
local entry = byName[rec.name]
if not entry then
entry = { name = rec.name, count = 0, sort = rec.name:lower() }
byName[rec.name] = entry
table.insert(tags, entry)
end
entry.count = entry.count + 1
total = total + 1
end
return tags, total
end
-- Group by first path segment. A namespace holding a single tag isn't a
-- hierarchy worth a card of its own โ bare `#thoughts`, but also a lone
-- `#coffee/espresso` โ so those collect into one trailing "Ungrouped"
-- card instead of dozens of cards holding one row each.
local function groupTags(tags)
local byNs, order = {}, {}
for _, t in ipairs(tags) do
local slash = t.name:find("/")
local ns = slash and t.name:sub(1, slash - 1) or t.name
local g = byNs[ns]
if not g then
g = { ns = ns, tags = {}, total = 0 }
byNs[ns] = g
table.insert(order, g)
end
table.insert(g.tags, t)
g.total = g.total + t.count
end
local sections = {}
local flat = { ns = "Ungrouped", icon = "๐ท๏ธ", tags = {}, total = 0 }
for _, g in ipairs(order) do
if #g.tags > 1 then
g.icon = ICONS[g.ns] or "๐ท๏ธ"
table.insert(sections, g)
else
for _, t in ipairs(g.tags) do
table.insert(flat.tags, t)
flat.total = flat.total + t.count
end
end
end
local function byWeight(a, b)
if a.count ~= b.count then return a.count > b.count end
return a.sort < b.sort
end
table.sort(sections, function(a, b)
if a.total ~= b.total then return a.total > b.total end
return a.ns:lower() < b.ns:lower()
end)
for _, s in ipairs(sections) do
table.sort(s.tags, byWeight)
end
table.sort(flat.tags, byWeight)
-- The leftovers card always comes last, however heavy it is, and
-- doesn't count towards the namespace tally in the stats line.
local namespaceCount = #sections
if #flat.tags > 0 then
table.insert(sections, flat)
end
return sections, namespaceCount
end
-- Log-spaced so a handful of huge tags don't flatten the long tail:
-- counts of 1, 5, 20 and 130 land in visibly different steps.
local function sizeClass(count, lowest, highest)
if highest <= lowest then return "sb-tc-s3" end
local span = math.log(highest) - math.log(lowest)
local pos = (math.log(count) - math.log(lowest)) / span
local step = math.floor(pos * (SIZE_STEPS - 1) + 0.5) + 1
if step < 1 then step = 1 end
if step > SIZE_STEPS then step = SIZE_STEPS end
return "sb-tc-s" .. step
end
local function buildCloud(tags)
local lowest, highest = tags[1].count, tags[1].count
for _, t in ipairs(tags) do
if t.count < lowest then lowest = t.count end
if t.count > highest then highest = t.count end
end
local alpha = {}
for _, t in ipairs(tags) do table.insert(alpha, t) end
table.sort(alpha, function(a, b) return a.sort < b.sort end)
local cloud = { class = "sb-tagcloud" }
for _, t in ipairs(alpha) do
-- Size lives on a wrapper, not the anchor: the built-in
-- `#sb-main .cm-editor .sb-hashtag` rule sets font-size at a
-- specificity a space-style class selector can't outrank, but the
-- anchor's 0.9em happily scales against an inherited em.
table.insert(cloud, dom.span {
class = "sb-tc-item " .. sizeClass(t.count, lowest, highest),
chip(t.name, t.count),
})
end
return dom.div(cloud)
end
local function buildCard(section)
local body = { class = "sb-tagns-body" }
for _, t in ipairs(section.tags) do
table.insert(body, dom.div {
class = "sb-tagns-row",
chip(t.name, t.count),
dom.span { class = "sb-tagns-count", __rawText = tostring(t.count) },
})
end
local card = {
class = "sb-tagns",
dom.summary {
class = "sb-tagns-head",
dom.span { class = "sb-tagns-icon", __rawText = section.icon },
dom.span { class = "sb-tagns-name", __rawText = section.ns },
dom.span {
class = "sb-tagns-meta",
__rawText = plural(#section.tags, "tag") .. " ยท " .. section.total,
},
},
dom.div(body),
}
if #section.tags <= COLLAPSE_OVER then
card.open = ""
end
return dom.details(card)
end
local function buildStats(tags, uses, namespaces, busiest)
local singles = 0
for _, t in ipairs(tags) do
if t.count == 1 then singles = singles + 1 end
end
local parts = {
plural(#tags, "tag"),
plural(uses, "use"),
plural(namespaces, "namespace"),
}
if singles > 0 then
table.insert(parts, singles .. " used once")
end
local line = {
class = "sb-tagcloud-stats",
dom.span { __rawText = table.concat(parts, " ยท ") },
dom.span { class = "sb-tagcloud-sep", __rawText = "ยท" },
dom.span { __rawText = "busiest" },
chip(busiest.name, busiest.count),
}
return dom.div(line)
end
function tagCloud.render()
local tags, uses = collectTags()
if #tags == 0 then
return widget.new {
html = dom.div { class = "sb-tagcloud-empty", __rawText = "No tags in this space yet." },
display = "block",
}
end
local busiest = tags[1]
for _, t in ipairs(tags) do
if t.count > busiest.count then busiest = t end
end
local sections, namespaces = groupTags(tags)
local groups = { class = "sb-tagcloud-groups" }
for _, section in ipairs(sections) do
table.insert(groups, buildCard(section))
end
return widget.new {
html = dom.div {
buildStats(tags, uses, namespaces, busiest),
buildCloud(tags),
dom.div {
class = "sb-tagcloud-legend",
__rawText = "Bigger means used more often โ click a tag for everything it's on.",
},
dom.div(groups),
},
display = "block",
}
end
```
```space-style
/* ---------- Tag cloud: stats line ---------- */
.sb-tagcloud-stats {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: center;
gap: 2px 6px;
font-size: 0.85em;
opacity: 0.7;
margin: 4px 0 2px;
}
.sb-tagcloud-sep {
opacity: 0.5;
}
.sb-tagcloud-legend {
text-align: center;
font-size: 0.78em;
opacity: 0.45;
margin: 2px 0 4px;
}
/* ---------- Tag cloud: the cloud itself ---------- */
.sb-tagcloud {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: center;
gap: 3px 7px;
line-height: 1.85;
padding: 14px 12px;
margin: 8px 0 2px;
border-radius: 10px;
background: color-mix(in srgb, var(--editor-widget-background-color) 50%, transparent);
}
.sb-tc-item {
display: inline-flex;
transition: transform 0.1s ease-out;
}
.sb-tc-item:hover {
transform: translateY(-1px);
}
/* Rarer tags recede, common ones lead โ size does most of the work,
opacity keeps the small end from turning into visual noise. */
.sb-tc-s1 { font-size: 0.8em; opacity: 0.6; }
.sb-tc-s2 { font-size: 0.95em; opacity: 0.75; }
.sb-tc-s3 { font-size: 1.15em; opacity: 0.85; }
.sb-tc-s4 { font-size: 1.4em; }
.sb-tc-s5 { font-size: 1.7em; }
.sb-tc-s6 { font-size: 2.05em; }
/* ---------- Tag cloud: namespace cards ---------- */
.sb-tagcloud-groups {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
align-items: start;
gap: 10px;
margin-top: 14px;
}
.sb-tagns {
background: var(--editor-widget-background-color);
border-radius: 8px;
padding: 7px 10px 4px;
}
.sb-tagns-head {
display: flex;
align-items: baseline;
gap: 6px;
cursor: pointer;
list-style: none;
}
/* Swap the native disclosure triangle for one that lines up with the
emoji column and turns as the card opens. */
.sb-tagns-head::-webkit-details-marker {
display: none;
}
.sb-tagns-head::before {
content: "โธ";
opacity: 0.45;
font-size: 0.8em;
transition: transform 0.12s ease-out;
}
.sb-tagns[open] > .sb-tagns-head::before {
transform: rotate(90deg);
}
.sb-tagns-name {
font-weight: 600;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb-tagns-meta {
font-size: 0.78em;
opacity: 0.55;
white-space: nowrap;
}
.sb-tagns-body {
margin: 6px 0 4px;
}
.sb-tagns-row {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
padding: 1px 0;
}
/* Long tags (media/game/political-thriller) truncate rather than
pushing the count off the card; the full name is in the tooltip. */
.sb-tagns-row .sb-hashtag {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sb-tagns-count {
font-size: 0.8em;
font-variant-numeric: tabular-nums;
opacity: 0.5;
}
.sb-tagns-row:hover .sb-tagns-count {
opacity: 0.85;
}
.sb-tagcloud-empty {
opacity: 0.6;
font-style: italic;
margin: 10px 0;
}
Virtual page:
---
description: Implements the TagCloud virtual page
tags: meta
---
Implements a virtual page for the Tag Cloud.
The body is `tagCloud.render()` from [[Library/Personal/Scripts/Widgets/TagCloud]] โ a size-weighted cloud of every tag in the space plus a card per namespace. It runs a single index query, so the page costs one query however many tags there are.
# Implementation
```space-lua
-- priority: 10
virtualPage.define {
pattern = "tagCloud",
run = function()
return "---\npageDecoration:\n disableTOC: true\n---\n"
.. "# ๐ท๏ธ Tag Cloud\n\n${tagCloud.render()}\n"
end
}
There are more theming items that aren't present here -- like the tag colors. This is just a scaffold for the tag browser ![]()
Thanks a lot!, now I have a lot of stuff to study (and understand) ![]()