Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/guide/html-template/backlinks.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,6 @@ Hovering a filled cell opens a context flyout (header `YYYY-MM-DD — title`, th
- Right-panel at `lg:`+ (compact 4×4 cells in the narrow column)
- Bottom strip at `<lg` (cells stretch as horizontal bars to fill the wider row)

Dates are parsed out of each linked note's title via a `YYYY-MM-DD` regex; entries without a parseable date are silently skipped (with the screen-reader fallback list still readable).
Dates come from the linked note's route via `Calendar.parseRouteDay`, the same parser used for the daily/non-daily split. A daily note can therefore have a custom title without disappearing from the heatmap.

See [[daily-notes]] for how the daily/non-daily split feeds these two surfaces.
1 change: 1 addition & 0 deletions emanote/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@

**Bug fixes**

- Timeline heatmap backlinks now use the daily note's route-derived date instead of parsing `YYYY-MM-DD` out of the rendered title, so custom-titled daily notes still appear in the heatmap.
- Cascade-declared `tags` no longer disappear from a child note that declares any of its own. The metadata-cascade merger previously inherited `aeson-extra`'s `lodashMerge`, which aligns arrays by index — `tags: [team-doc]` in `folder.yaml` plus `tags: [internal-note]` in `folder/note.md` produced `[internal-note]`, dropping the cascaded entry both from the per-page chip strip and from the `#352` global tag index. The merger now unions cascade arrays; see [yaml-config](docs/guide/yaml-config.md) for the full contract. `aeson-extra` is no longer a dependency (closes [#697](https://github.com/srid/emanote/issues/697)).
- Wiki link custom titles now render HTML entities like `&nbsp;` the same way regular Markdown link labels do. Previously `[[note|Spivak&nbsp;(2014)]]` rendered the entity text literally as `&nbsp;` (closes [#441](https://github.com/srid/emanote/issues/441)).
- Atom feed: a feed query that matches no notes no longer crashes the build; an empty-but-valid Atom document is emitted instead. Configuration errors (missing/invalid query block, missing `page.siteUrl`) still fail loudly ([#490](https://github.com/srid/emanote/issues/490), [#650](https://github.com/srid/emanote/pull/650))
Expand Down
14 changes: 7 additions & 7 deletions emanote/default/_emanote-static/js/timeline-heatmap.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
// Render daily-note backlinks (`<ema:note:backlinks:daily>`) as a year-stacked
// heatmap into #timeline-heatmap, using the hidden <ul id="timeline-data"> as
// the data source. Dates are parsed out of each backlink's title via a
// YYYY-MM-DD regex; entries without a parseable date are silently skipped
// the data source. Dates come from Haskell-emitted data-iso-date attributes;
// entries without a parseable date are silently skipped
// (the hidden list is also the screen-reader / no-JS fallback so they're
// still reachable).
//
Expand Down Expand Up @@ -30,8 +30,6 @@ import {
formatCellHeader,
} from '@emanote/calendar-grid';

const DATE_RE = /(\d{4})-(\d{2})-(\d{2})/;

const ROW_CLASSES = 'flex items-center gap-1.5';
const LABEL_CLASSES = 'w-7 text-[0.65rem] uppercase tracking-wider text-gray-500 dark:text-gray-400 select-none shrink-0';
const YEAR_LABEL_CLASSES = 'text-xs font-semibold tracking-tight text-gray-700 dark:text-gray-300 mb-1';
Expand All @@ -41,15 +39,17 @@ function parseEntries(listEl) {
for (const li of listEl.querySelectorAll('li')) {
const title = li.dataset.title || '';
const url = li.dataset.url || '';
const isoDate = li.dataset.isoDate || '';
// The <li>'s inner HTML is the rendered backlink context (paragraphs
// around where this note is referenced from the daily note). Used
// verbatim inside the cell's hover flyout — same context shape as
// backlinks-margin so timeline + backlinks read as one family.
const contextHTML = li.innerHTML.trim();
const m = title.match(DATE_RE);
if (!m) continue;
const [, y, mo, d] = m;
const parts = isoDate.split('-');
if (parts.length !== 3) continue;
const [y, mo, d] = parts;
const year = +y, month = +mo, day = +d;
if (!Number.isFinite(year) || !Number.isFinite(month) || !Number.isFinite(day)) continue;
if (!out.has(year)) out.set(year, new Map());
const yr = out.get(year);
if (!yr.has(month)) yr.set(month, new Map());
Expand Down
2 changes: 1 addition & 1 deletion emanote/default/templates/components/timeline.tpl
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
</h3>
<ul class="timeline-data" hidden>
<backlink>
<li data-url="${backlink:note:url}" data-title="${backlink:note:title}">
<li data-url="${backlink:note:url}" data-title="${backlink:note:title}" data-iso-date="${backlink:note:iso-date}">
<backlink:note:contexts>
<apply template="context" />
</backlink:note:contexts>
Expand Down
58 changes: 40 additions & 18 deletions emanote/src/Emanote/View/Template.hs
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,10 @@ module Emanote.View.Template (emanoteSiteOutput, render) where

import Control.Monad.Logger (MonadLoggerIO)
import Data.Aeson.Types qualified as Aeson
import Data.List (partition)
import Data.Map.Syntax ((##))
import Data.Set qualified as Set
import Data.Text qualified as T
import Data.Time.Calendar (Day)
import Data.Time.Format (defaultTimeLocale, formatTime)
import Data.Tree qualified as Tree
import Ema qualified
Expand Down Expand Up @@ -190,9 +190,16 @@ renderLmlHtml model note = do
else mempty
"ema:note:backlinks" ##
backlinksSplice model (G.modelLookupBacklinks r model)
let (backlinksDaily, backlinksNoDaily) = partition (Calendar.isDailyNote . fst) $ G.modelLookupBacklinks r model
let (backlinksDaily, backlinksNoDaily) =
partitionEithers
[ maybe
(Right (source, contexts))
(\day -> Left (source, day, contexts))
(Calendar.parseRouteDay source)
| (source, contexts) <- G.modelLookupBacklinks r model
]
"ema:note:backlinks:daily" ##
backlinksSplice model backlinksDaily
dailyBacklinksSplice model backlinksDaily
"ema:note:backlinks:nodaily" ##
backlinksSplice model backlinksNoDaily
let folgeAnc = G.modelFolgezettelAncestorTree model r
Expand All @@ -216,20 +223,35 @@ backlinksSplice :: Model -> [(R.LMLRoute, NonEmpty [B.Block])] -> HI.Splice Iden
backlinksSplice model (bs :: [(R.LMLRoute, NonEmpty [B.Block])]) =
Splices.listSplice bs "backlink"
$ \(source, contexts) -> do
let bnote = fromMaybe (error "backlink note missing - impossible") $ M.modelLookupNoteByRoute' source model
bmeta = Meta.getEffectiveRouteMetaWith (bnote ^. MN.noteMeta) source model
bctx = C.mkTemplateRenderCtx model source bmeta
-- TODO: reuse note splice
"backlink:note:title" ## C.titleSplice bctx (M.modelLookupTitle source model)
"backlink:note:url" ## HI.textSplice (SR.siteRouteUrl model $ SR.lmlSiteRoute (R.LMLView_Html, source))
"backlink:note:contexts" ##
Splices.listSplice (toList contexts) "context"
$ \backlinkCtx -> do
let ctxDoc = Pandoc mempty $ one $ B.Div B.nullAttr backlinkCtx
"context:body" ##
C.withInlineCtx bctx
$ \ctx' ->
Splices.pandocSplice ctx' ctxDoc
backlinkSplices model source contexts Nothing

dailyBacklinksSplice :: Model -> [(R.LMLRoute, Day, NonEmpty [B.Block])] -> HI.Splice Identity
dailyBacklinksSplice model bs =
Splices.listSplice bs "backlink"
$ \(source, day, contexts) ->
backlinkSplices model source contexts (Just day)

backlinkSplices :: Model -> R.LMLRoute -> NonEmpty [B.Block] -> Maybe Day -> H.Splices (HI.Splice Identity)
backlinkSplices model source contexts mDay = do
let bnote = fromMaybe (error "backlink note missing - impossible") $ M.modelLookupNoteByRoute' source model
bmeta = Meta.getEffectiveRouteMetaWith (bnote ^. MN.noteMeta) source model
bctx = C.mkTemplateRenderCtx model source bmeta
-- TODO: reuse note splice
"backlink:note:title" ## C.titleSplice bctx (M.modelLookupTitle source model)
"backlink:note:url" ## HI.textSplice (SR.siteRouteUrl model $ SR.lmlSiteRoute (R.LMLView_Html, source))
"backlink:note:iso-date" ## HI.textSplice (maybe "" dayIsoText mDay)
"backlink:note:contexts" ##
Splices.listSplice (toList contexts) "context"
$ \backlinkCtx -> do
let ctxDoc = Pandoc mempty $ one $ B.Div B.nullAttr backlinkCtx
"context:body" ##
C.withInlineCtx bctx
$ \ctx' ->
Splices.pandocSplice ctx' ctxDoc

dayIsoText :: Day -> Text
dayIsoText =
toText . formatTime defaultTimeLocale "%Y-%m-%d"

{- | Heist splice for the sidebar tree.

Expand All @@ -247,7 +269,7 @@ routeTreeSplices tCtx mCurrentRoute model = do
-- otherwise. Sole source of truth for daily-note dates on the JS side
-- (sidebar-calendar reads it via data-iso-date) so date detection
-- stays in Haskell, not duplicated as a regex per widget.
"node:iso-date" ## HI.textSplice (maybe "" (toText . formatTime defaultTimeLocale "%Y-%m-%d") $ Calendar.parseRouteDay nodeRoute)
"node:iso-date" ## HI.textSplice (maybe "" dayIsoText $ Calendar.parseRouteDay nodeRoute)
let isActiveNode = Just nodeRoute == mCurrentRoute
isActiveTree =
-- Active tree checking is applicable only when there is an
Expand Down
1 change: 1 addition & 0 deletions tests/features/smoke.feature
Original file line number Diff line number Diff line change
Expand Up @@ -142,6 +142,7 @@ Feature: Smoke
When I open "/dailyhost.html"
Then the Timeline panel links to "dailyhost/2025-01-01"
And the Timeline panel links to "dailyhost/2025-01-02"
And every Timeline data entry has an ISO date

Scenario: Non-daily backlinks land in the Backlinks panel and not the Timeline
When I open "/dailyhost.html"
Expand Down
2 changes: 1 addition & 1 deletion tests/fixtures/notebook/dailyhost/2025-01-02.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
# 2025-01-02
# Retitled daily note

Another daily-named note linking to [[dailyhost]].
25 changes: 25 additions & 0 deletions tests/step_definitions/smoke_steps.ts
Original file line number Diff line number Diff line change
Expand Up @@ -725,6 +725,31 @@ Then(
},
);

Then(
"every Timeline data entry has an ISO date",
async function (this: EmanoteWorld) {
const entries = await this.page.evaluate(() =>
Array.from(
document.querySelectorAll(".emanote-timeline .timeline-data li"),
).map((li) => ({
title: (li as HTMLElement).dataset.title ?? "",
isoDate: (li as HTMLElement).dataset.isoDate ?? "",
})),
);
assert.ok(
entries.length > 0,
"Expected at least one hidden Timeline data entry in the fixture.",
);
for (const entry of entries) {
assert.match(
entry.isoDate,
/^\d{4}-\d{2}-\d{2}$/,
`Expected Timeline entry ${JSON.stringify(entry.title)} to carry data-iso-date from Haskell, got ${JSON.stringify(entry.isoDate)}.`,
);
}
},
);

Then(
"the Backlinks panel links to {string}",
async function (this: EmanoteWorld, hrefSubstring: string) {
Expand Down
Loading