Compare commits
60 Commits
9f21cb7882
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f89c2fa776 | ||
|
|
c21619c53a | ||
|
|
b34b5851cd | ||
|
|
9581dc88aa | ||
|
|
40b0af6b01 | ||
|
|
2592b19c8f | ||
|
|
99edff828a | ||
|
|
b720759c89 | ||
|
|
a88d0dbac7 | ||
|
|
70fbe5156f | ||
|
|
049ce01ab5 | ||
|
|
f9906cba30 | ||
|
|
1b6c7542d7 | ||
|
|
70f9a6b718 | ||
|
|
5386fa66ea | ||
|
|
84a252e926 | ||
|
|
30eadaf21b | ||
|
|
4104e138c1 | ||
|
|
b8c9d07930 | ||
|
|
cb22c755df | ||
|
|
3a528c63e0 | ||
|
|
c2764deb49 | ||
|
|
57ef4a9e50 | ||
|
|
9d27aa024f | ||
|
|
2f8d3c24a4 | ||
|
|
08d2d804ff | ||
|
|
8f50259efe | ||
|
|
a04d169bd8 | ||
|
|
1b47101340 | ||
|
|
a7d846aa33 | ||
|
|
fac05af862 | ||
|
|
ad0a388f57 | ||
|
|
d76d63a198 | ||
|
|
7575e34f8b | ||
|
|
0f19482cf2 | ||
|
|
826f7a4ec9 | ||
|
|
e24f75a1cb | ||
|
|
2c3ce18e4d | ||
|
|
cef5873c95 | ||
|
|
c296921eaf | ||
|
|
8e9ca655a9 | ||
|
|
a5de08087e | ||
|
|
bff3db79f9 | ||
|
|
08ea81684e | ||
|
|
af7c87a18c | ||
|
|
4c29151f7c | ||
|
|
d455ae0000 | ||
|
|
03e7185abe | ||
|
|
5de84f135f | ||
|
|
72dee337b7 | ||
|
|
70d9d8a890 | ||
|
|
9380eace92 | ||
|
|
57bc63d43b | ||
|
|
ac3a641b22 | ||
|
|
cdfc90f7eb | ||
|
|
f8e0bbf9a7 | ||
|
|
f2ba56e936 | ||
|
|
bb3a4a4dad | ||
|
|
af2ffc3949 | ||
|
|
b77c097032 |
@@ -19,7 +19,7 @@ jobs:
|
||||
- name: Setup pnpm
|
||||
uses: pnpm/action-setup@v4
|
||||
with:
|
||||
version: 11.0.9
|
||||
version: 11.5.2
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
|
||||
11
CLAUDE.md
11
CLAUDE.md
@@ -25,7 +25,7 @@ Run a single test file: `pnpm test src/modules/repo/services/resolvePath.spec.ts
|
||||
- **Vue 3** (3.5) with Composition API, **Pinia** for state, **Vue Router**
|
||||
- **Vite 7** build tool, **TypeScript** strict mode
|
||||
- **Tailwind CSS v4** + **DaisyUI v5** for styling (PostCSS plugin approach, not tailwind.config.js)
|
||||
- **TanStack Vue Query** + **ts-rest** + **arktype** for typed API contracts
|
||||
- **arktype** for runtime type validation (used by the ATProto module)
|
||||
- **PouchDB** (IndexedDB) for local persistence
|
||||
- **Octokit** for GitHub API integration
|
||||
- **markdown-it** with plugins (KaTeX, Shikiji, Mermaid, checkboxes, GitHub alerts)
|
||||
@@ -38,6 +38,10 @@ Tailwind v4 uses the modern CSS-based config in `src/styles/app.css`:
|
||||
- DaisyUI configured via `@plugin 'daisyui' { themes: retro --default, coffee --prefersdark; }`
|
||||
- `@tailwindcss/typography` for prose styling
|
||||
|
||||
### Icons
|
||||
|
||||
Use [Tabler icons](https://tabler.io/icons) for every icon. Paste the outline SVG inline and keep `stroke="currentColor"` so it inherits color from its parent (icons in the note header render in `--color-accent`).
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```sh
|
||||
@@ -50,8 +54,7 @@ src/
|
||||
│ ├── user/ # Authentication, user settings
|
||||
│ ├── card/ # Spaced repetition
|
||||
│ ├── history/ # Edit history tracking
|
||||
│ ├── atproto/ # ATProto/Bluesky integration (DID resolution, blob URLs)
|
||||
│ └── post/ # ts-rest API client for public note publishing (api.remanso.space)
|
||||
│ └── atproto/ # ATProto/Bluesky integration (DID resolution, blob URLs)
|
||||
├── hooks/ # Composition hooks (useMarkdown, useBacklinks, useGitHubContent, etc.)
|
||||
├── data/ # PouchDB wrapper and data models
|
||||
├── utils/ # Utilities including custom markdown-it plugins
|
||||
@@ -73,7 +76,6 @@ src/
|
||||
- `src/modules/repo/store/userRepo.store.ts` - Central Pinia store for repo/file state
|
||||
- `src/modules/repo/services/octo.ts` - Octokit wrapper for GitHub API
|
||||
- `src/hooks/useMarkdown.hook.ts` - Markdown rendering with all plugins
|
||||
- `src/modules/post/data/client.ts` - ts-rest API contract definitions with arktype validation
|
||||
- `src/data/data.ts` - PouchDB database wrapper
|
||||
|
||||
### Patterns
|
||||
@@ -81,5 +83,4 @@ src/
|
||||
- All components use Composition API (`<script setup lang="ts">`)
|
||||
- Custom hooks encapsulate feature logic (`use*.hook.ts` or `use*.ts`)
|
||||
- Event buses (`noteEventBus`, `backlinkEventBus`) for cross-component communication
|
||||
- ts-rest contracts use arktype schemas for request/response validation
|
||||
- Path alias: `@` maps to `src/`
|
||||
|
||||
125
CONTEXT.md
Normal file
125
CONTEXT.md
Normal file
@@ -0,0 +1,125 @@
|
||||
# Remanso
|
||||
|
||||
Remanso displays markdown **Notes** from GitHub repositories with Zettelkasten-style backlinks, and publishes/reads decentralized public notes over ATProto. This glossary is the shared vocabulary for the domain — it must appear verbatim in code, tests, commits, and docs.
|
||||
|
||||
**Naming convention:** Themed river/Amazonian names (after *Remanso*, a still backwater) are used for **user-facing surfaces and places** (e.g. **Igarapé**). **Core technical concepts** keep their standard Zettelkasten/Obsidian names (**Note, Backlink, Path, Card, Draft, Fleeting**) for discoverability.
|
||||
|
||||
## Language
|
||||
|
||||
### Core note model
|
||||
|
||||
**Note**:
|
||||
A single markdown document in a repository, identified by its **Path**. Editing a Note does not change its identity.
|
||||
_Avoid_: "file" when you mean the semantic document.
|
||||
|
||||
**Path**:
|
||||
A Note's stable identity — its location within the repo (e.g. `ideas/zettel.md`). What authors link to and what survives edits.
|
||||
|
||||
**SHA**:
|
||||
The git blob hash of a Note's content — a *content version*, not the Note's identity. Changes on every edit. Legitimately used as the address of a **Snapshot reference** (an immutable shared version).
|
||||
_Avoid_: treating SHA as the Note's *identity* (identity is **Path**); but SHA *is* the right address for an immutable snapshot.
|
||||
|
||||
**File** / **RepoFile**:
|
||||
The repo artifact carrying a Note's metadata (`path`, `sha`, `size`, …) as returned by the GitHub API. A File is how a Note appears in a repo listing, not a separate domain entity.
|
||||
|
||||
### Note kinds
|
||||
|
||||
A Note's kind is inferred from its folder, not stored as a field.
|
||||
|
||||
**Fleeting Note**:
|
||||
A transient, quick-capture Note you later distill into a permanent one (Zettelkasten sense). Stored under the `inbox` / `_inbox` folder.
|
||||
_Avoid_: "Inbox Note" for the concept — `inbox` is only the storage folder convention; the domain term is Fleeting Note.
|
||||
|
||||
**Draft Note**:
|
||||
A work-in-progress Note, stored under the `drafts` / `_drafts` folder.
|
||||
|
||||
**Task**:
|
||||
An actionable item in `todo.txt` (todo.txt format); may recur. Completing a Task archives it to `done.txt`, and for a recurring Task schedules the next occurrence. A Task is not a Note.
|
||||
_Avoid_: "Todo Note" — Tasks live in a single todo.txt file, not as Notes.
|
||||
|
||||
### Reading & navigation
|
||||
|
||||
**Igarapé**:
|
||||
The primary reading surface — the channel you travel as you read a Note and follow its backlinks (Amazonian/Tupi: a narrow navigable waterway through the forest). Any kind of Note appears in the Igarapé. Named to extend the river metaphor set by *Remanso* (a still backwater).
|
||||
_Avoid_: "Flux" (dropped — internal jargon, no domain meaning).
|
||||
|
||||
**Index**:
|
||||
A flat listing of every Note in a repo (path, sha, size). A catalogue view, unrelated to time or edits.
|
||||
_Avoid_: "History" / "Historic Notes" for this — it is an index, not a chronological record.
|
||||
|
||||
**History**:
|
||||
The chronological log of repos the user has recently visited (powers the "last visited" navigation).
|
||||
_Avoid_: using "history" for the Note **Index**, or for edit/version tracking (no such feature exists; there is no "edit history").
|
||||
|
||||
**Backlink**:
|
||||
An inbound reference to a Note — another Note that links to it. The Zettelkasten/Obsidian standard term (kept plain per the naming convention).
|
||||
|
||||
**Stacked Notes**:
|
||||
The navigation pattern in the **Igarapé** where following a Backlink opens the target Note alongside the current one, accumulating a horizontal stack you can read across.
|
||||
|
||||
**Live reference**:
|
||||
A reference to a Note **by Path** that resolves to its *current* content (e.g. a Backlink you follow in your own repo). Reflects edits.
|
||||
|
||||
**Snapshot reference**:
|
||||
A reference to a Note **by SHA** that resolves to that *exact, immutable* version (e.g. a shared/bookmarked stack link). Content-addressed; deliberately unaffected by later edits, so what was shared cannot change underneath a reader. Not a fragility — an integrity feature.
|
||||
|
||||
### Spaced repetition
|
||||
|
||||
**Card**:
|
||||
A flashcard reviewed via spaced repetition — its content (front, back, references) and its review schedule together form one Card. Content lives in `_cards/` markdown; the schedule lives in the local DB (an implementation split, not two domain concepts).
|
||||
_Avoid_: using "Card" to mean only the content, or only the schedule. The pair (`Repetition` in code) **is** the Card.
|
||||
|
||||
**Level**:
|
||||
A Card's mastery level (1–8); higher levels space reviews further apart.
|
||||
|
||||
**Need-review**:
|
||||
A Card the user has manually flagged to resurface for review, independent of its scheduled date.
|
||||
|
||||
### Decentralized publishing (ATProto)
|
||||
|
||||
**Published Note**:
|
||||
A Note published to the decentralized ATProto network as a `space.remanso.note` record, addressed by **DID** + **rkey** (not by Path). A distinct entity created by publishing a Note; carries its own snapshot (title, images, content, theme, language).
|
||||
_Avoid_: "Public Note" — reserve "public" for a Note in a public GitHub repo (see below).
|
||||
|
||||
**Publish**:
|
||||
The act of making a Note public as a **Published Note**, via a single low-friction gesture — suffixing the file `*.pub.md` in the IDE — rather than an in-app form. "Lower every wall to make your voice public."
|
||||
|
||||
**DID**:
|
||||
The decentralized identifier of a Published Note's **Author** (e.g. `did:plc:…`). Standard ATProto term.
|
||||
|
||||
**rkey**:
|
||||
The record key identifying one Published Note within an Author's PDS repo. Standard ATProto term.
|
||||
|
||||
**Author**:
|
||||
The publisher of a Published Note — a `handle` plus a **PDS**, resolved from a DID.
|
||||
|
||||
**PDS**:
|
||||
Personal Data Server — where an Author's ATProto records (and image blobs) are hosted.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **Note** is identified by exactly one **Path**.
|
||||
- A **Note** has one current **SHA**, which changes on every edit.
|
||||
- A **File** is the repo-listing view of a **Note** (it carries the Note's Path and current SHA).
|
||||
- A **Note** can be published as a **Published Note**, which is then addressed by **DID** + **rkey** instead of Path.
|
||||
- A **Published Note** has exactly one **Author**; an **Author** is identified by a **DID** and hosted on a **PDS**.
|
||||
- Following a **Backlink** in the **Igarapé** produces **Stacked Notes**.
|
||||
|
||||
## Example dialogue
|
||||
|
||||
> **Dev:** "When I follow a **Backlink** in the **Igarapé**, do I navigate by **Path** or **SHA**?"
|
||||
> **Maintainer:** "The identity is the **Path** — that's what the link author wrote. The **SHA** is just the current content version; we happen to use it as a runtime handle, but if it ever disagrees with the Path, the Path wins."
|
||||
>
|
||||
> **Dev:** "And if I publish this **Note**?"
|
||||
> **Maintainer:** "It becomes a **Published Note** — a separate ATProto record addressed by **DID** + **rkey**, not by Path. The repo **Note** stays as it is."
|
||||
|
||||
## Flagged ambiguities
|
||||
|
||||
- **Identity = Path; two reference modes (refined 2026-06-28)** — see [ADR-0001](docs/adr/0001-note-identity-is-path.md). A Note's *identity* is its **Path**, so **Live references** (Backlinks in your repo) must resolve to current content. But a **Snapshot reference** (a shared `?stackedNotes=sha` link) deliberately pins a **SHA** for immutability — that is an integrity feature, not a fragility. The earlier framing of "SHA-as-handle = fragility" was too absolute; ADR-0001 is to be amended to record both modes.
|
||||
- `useNotes()` / `useFolderNotes()` are named "notes" but return `RepoFile[]`. Per this glossary they return **Files** (the repo-listing view), not `Note` objects — a naming drift to reconcile in code.
|
||||
- `FluxNote` / `FluxNoteView` should be renamed to **Igarapé** (`Igarape` / `IgarapeView`) in code — "Flux" is dropped jargon.
|
||||
- `HistoricNotes` view (route `/history`) is the **Index**, not history — rename to `NoteIndex` and move off the `/history` path.
|
||||
- `CLAUDE.md` describes `modules/history/` as "Edit history tracking" — incorrect; it is the visited-repos **History**. Correct the doc; there is no edit-history feature.
|
||||
- Card cluster: `Repetition` (the content+schedule pair) **is** the **Card** — rename `Repetition` → `Card`. The content-only struct and `RepetitionCard` (schedule) become named sub-parts (e.g. `CardContent`, `ReviewSchedule`). The hook already calls the pairs `cards` — make the types agree.
|
||||
- ATProto cluster: rename `PublicNote` / `PublicNoteRecord` / `PublicNoteListItem` → `PublishedNote*`, and move `PublicNoteListItem` out of `Note.ts` into the `atproto` module (it is not a repo-Note concept).
|
||||
- "Public" is overloaded: a Note in a **public GitHub repo** (auth-free access) vs the ATProto entity (now **Published Note**). Resolved — "public" refers only to repo visibility.
|
||||
420
DESIGN.md
Normal file
420
DESIGN.md
Normal file
@@ -0,0 +1,420 @@
|
||||
# Remanso — Design (QFD)
|
||||
|
||||
Goal-driven design for what Remanso is *for*: a **beautiful, primarily read-only** viewer for notes authored in the IDE (git push) and read as a Matuschak-style stacked-notes web — built to make notes feel **good-looking**, to evoke **pride and peace** (including reading *gracefully on a flaky mobile/metro connection*), and to **lower every wall** to making your voice public. Relies on [CONTEXT.md](CONTEXT.md) for vocabulary (Note, Path, SHA, Backlink, Igarapé, Stacked Notes, Live/Snapshot reference, Published Note, Publish), [ADR-0001](docs/adr/0001-note-identity-is-path.md) (Note identity = Path) and [ADR-0002](docs/adr/0002-two-reference-modes.md) (Live vs Snapshot reference). The earlier "Path↔SHA resolution layer" is not a goal in itself — it folds in under Peace (live references that never break) and integrity (immutable Snapshot references).
|
||||
|
||||
Strength weights used in matrices: **9** strong, **3** medium, **1** weak, blank none.
|
||||
|
||||
---
|
||||
|
||||
## House of Quality
|
||||
|
||||
```tikz
|
||||
\usetikzlibrary{arrows.meta, positioning, shapes.geometric, shapes.misc, calc, fit, backgrounds}
|
||||
|
||||
% Toggles
|
||||
\newif\ifqfdshowroof \qfdshowrooftrue
|
||||
\newif\ifqfdshowbasement \qfdshowbasementtrue
|
||||
\newif\ifqfdshowcompetitive \qfdshowcompetitivetrue
|
||||
\newif\ifqfdshowlegend \qfdshowlegendtrue
|
||||
\newif\ifqfdshowimportance \qfdshowimportancetrue
|
||||
\newif\ifqfdshowcorrlegend \qfdshowcorrlegendtrue
|
||||
\newif\ifqfdshowevallegend \qfdshowevallegendtrue
|
||||
\newif\ifqfdshowtitle \qfdshowtitletrue
|
||||
|
||||
% Dimensions
|
||||
\def\qfdNW{5}
|
||||
\def\qfdNH{5}
|
||||
\def\qfdWhatW{4.0}
|
||||
\def\qfdImpW{0.9}
|
||||
\def\qfdCmpW{3}
|
||||
\def\qfdHdrH{2.6}
|
||||
\def\qfdBasementN{4}
|
||||
|
||||
% Titles & labels
|
||||
\def\qfdWhatsTitle{Customer needs}
|
||||
\def\qfdImpTitle{Imp.\ \%}
|
||||
\def\qfdPerceptionTitle{Comparative evaluation}
|
||||
\def\qfdPoorLabel{poor}
|
||||
\def\qfdExcellentLabel{excellent}
|
||||
\def\qfdAltOneLabel{Our product}
|
||||
\def\qfdAltTwoLabel{Competitor A}
|
||||
\def\qfdAltThreeLabel{Competitor B}
|
||||
\def\qfdRelTitle{Relation}
|
||||
\def\qfdCorrTitle{Correlation}
|
||||
\def\qfdEvalTitle{Evaluation}
|
||||
|
||||
\def\qfdProjectTitle{}
|
||||
\def\qfdConcept{}
|
||||
|
||||
\tikzset{
|
||||
qfdthin/.style ={line width=0.35pt},
|
||||
qfdmed/.style ={line width=0.7pt},
|
||||
qfdstrong/.style={circle, draw, fill=black, minimum size=7pt, inner sep=0pt},
|
||||
qfdmod/.style ={circle, draw, minimum size=7pt, inner sep=0pt, line width=0.8pt},
|
||||
qfdweak/.style ={regular polygon, regular polygon sides=3, draw, minimum size=8.5pt, inner sep=0pt, line width=0.7pt},
|
||||
qfdrel/.is choice,
|
||||
qfdrel/S/.style={qfdstrong},
|
||||
qfdrel/M/.style={qfdmod},
|
||||
qfdrel/W/.style={qfdweak},
|
||||
qfdalt1mk/.style={circle, draw, fill=black, minimum size=6pt, inner sep=0pt, line width=1pt},
|
||||
qfdalt1ln/.style={line width=1.2pt},
|
||||
qfdalt2mk/.style={regular polygon, regular polygon sides=3, draw, fill=black, minimum size=6pt, inner sep=0pt, line width=0.7pt},
|
||||
qfdalt2ln/.style={line width=0.7pt, dashed},
|
||||
qfdalt3mk/.style={rectangle, draw, fill=black, minimum size=5pt, inner sep=0pt, line width=0.7pt},
|
||||
qfdalt3ln/.style={line width=0.7pt, dotted},
|
||||
}
|
||||
|
||||
\newcommand{\qfdDrawGrid}{%
|
||||
\foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, 0) -- (\c, -\qfdNW);
|
||||
\foreach \r in {1,...,\qfdNWm} \draw[qfdthin] (0, -\r) -- (\qfdNH, -\r);
|
||||
\foreach \r in {1,...,\qfdNWm} \draw[qfdthin] (\qfdLeftEdge, -\r) -- (0, -\r);
|
||||
\ifqfdshowroof
|
||||
\foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, 0) -- (\c, \qfdHdrH);
|
||||
\fi
|
||||
\ifqfdshowcompetitive
|
||||
\foreach \r in {1,...,\qfdNWm} \draw[qfdthin] (\qfdNH, -\r) -- (\qfdNH+\qfdCmpW, -\r);
|
||||
\fi
|
||||
\ifqfdshowbasement
|
||||
\foreach \r in {1,...,\qfdBasementN} \draw[qfdthin] (0, -\qfdNW-\r) -- (\qfdNH, -\qfdNW-\r);
|
||||
\foreach \c in {1,...,\qfdNHm} \draw[qfdthin] (\c, -\qfdNW) -- (\c, -\qfdNW-\qfdBasementN);
|
||||
\fi
|
||||
}
|
||||
|
||||
\newcommand{\qfdDrawRoof}{%
|
||||
\ifqfdshowroof
|
||||
\foreach \k in {1,...,\qfdNHm} {%
|
||||
\pgfmathsetmacro{\rx}{(\k+\qfdNH)/2}
|
||||
\pgfmathsetmacro{\ry}{\qfdHdrH + (\qfdNH-\k)/2}
|
||||
\pgfmathsetmacro{\lx}{\k/2}
|
||||
\pgfmathsetmacro{\ly}{\qfdHdrH + \k/2}
|
||||
\draw[qfdthin] (\k, \qfdHdrH) -- (\rx, \ry);
|
||||
\draw[qfdthin] (\k, \qfdHdrH) -- (\lx, \ly);
|
||||
}%
|
||||
\draw[qfdmed] (0, \qfdHdrH) -- (\qfdNH/2, \qfdApexY) -- (\qfdNH, \qfdHdrH);
|
||||
\foreach \i in {1,...,\qfdNH}
|
||||
\foreach \k in {1,...,\qfdNH} {%
|
||||
\pgfmathtruncatemacro{\jj}{\i+\k}
|
||||
\ifnum\jj>\qfdNH\relax\else
|
||||
\pgfmathsetmacro{\xx}{\i + \k/2 - 0.5}
|
||||
\pgfmathsetmacro{\yy}{\qfdHdrH + \k/2}
|
||||
\coordinate (C-\i-\jj) at (\xx, \yy);
|
||||
\fi
|
||||
}%
|
||||
\fi
|
||||
}
|
||||
|
||||
\newcommand{\qfdDrawScale}{%
|
||||
\ifqfdshowcompetitive
|
||||
\foreach \tk in {0,1,2,3,4,5} {%
|
||||
\pgfmathsetmacro{\tx}{\qfdNH + (\tk+0.5)*\qfdCmpW/6}
|
||||
\node[anchor=south, font=\scriptsize] at (\tx, 0.02) {\tk};
|
||||
}%
|
||||
\node[anchor=south, font=\scriptsize\bfseries, align=center] at ({\qfdNH + \qfdCmpW/2}, 0.7) {\qfdPerceptionTitle};
|
||||
\node[anchor=north, font=\scriptsize\itshape] at ({\qfdNH + 0.45}, -\qfdNW) {\qfdPoorLabel};
|
||||
\node[anchor=north, font=\scriptsize\itshape] at ({\qfdNH + \qfdCmpW - 0.45}, -\qfdNW) {\qfdExcellentLabel};
|
||||
\fi
|
||||
}
|
||||
|
||||
\newcommand{\qfdDrawZoneTitles}{%
|
||||
\ifqfdshowimportance
|
||||
\node[rotate=90, anchor=west, font=\footnotesize\bfseries] at ({-\qfdImpW/2}, 0.12) {\qfdImpTitle};
|
||||
\fi
|
||||
\node[font=\scriptsize\bfseries, align=center, text width=\qfdWhatW cm] at ({\qfdLeftEdge + \qfdWhatW/2}, {\ifqfdshowroof \qfdHdrH/2 \else 0.6 \fi}) {\qfdWhatsTitle};
|
||||
}
|
||||
|
||||
\newcommand{\qfdDrawTitle}{%
|
||||
\ifqfdshowtitle
|
||||
\ifx\qfdProjectTitle\empty\else
|
||||
\pgfmathsetmacro{\qfdTitleX}{\qfdNH/2}
|
||||
\pgfmathsetmacro{\qfdTitleY}{\ifqfdshowroof \qfdApexY \else \qfdHdrH \fi + 0.9}
|
||||
\pgfmathsetmacro{\qfdSubW}{\qfdNH + 2}
|
||||
\node[anchor=south, font=\large\bfseries, align=center] at (\qfdTitleX, \qfdTitleY) {\qfdProjectTitle};
|
||||
\ifx\qfdConcept\empty\else
|
||||
\node[anchor=north, font=\footnotesize\itshape, align=center, text width=\qfdSubW cm] at (\qfdTitleX, {\qfdTitleY - 0.1}) {\qfdConcept};
|
||||
\fi
|
||||
\fi
|
||||
\fi
|
||||
}
|
||||
|
||||
\newcommand{\qfdDrawFrames}{%
|
||||
\begin{scope}[qfdmed]
|
||||
\draw (\qfdLeftEdge, 0) rectangle (\qfdNH, -\qfdNW);
|
||||
\ifqfdshowimportance \draw (-\qfdImpW, 0) -- (-\qfdImpW, -\qfdNW); \fi
|
||||
\draw (0, 0) -- (0, -\qfdNW);
|
||||
\ifqfdshowroof \draw (0, 0) rectangle (\qfdNH, \qfdHdrH); \fi
|
||||
\ifqfdshowbasement \draw (0, -\qfdNW) rectangle (\qfdNH, -\qfdNW-\qfdBasementN); \fi
|
||||
\ifqfdshowcompetitive \draw (\qfdNH, 0) rectangle (\qfdNH+\qfdCmpW, -\qfdNW); \fi
|
||||
\end{scope}
|
||||
}
|
||||
|
||||
\newcommand{\qfdDrawLegend}{%
|
||||
\ifqfdshowlegend
|
||||
\pgfmathsetmacro{\qfdLegX}{\qfdNH + \ifqfdshowcompetitive \qfdCmpW + 0.7 \else 0.7 \fi}
|
||||
\pgfmathsetmacro{\qfdLegBottom}{-2.05 \ifqfdshowroof \ifqfdshowcorrlegend - 2.55 \fi \fi \ifqfdshowcompetitive \ifqfdshowevallegend - 2.20 \fi \fi}
|
||||
\pgfmathsetmacro{\qfdLegY}{\qfdHdrH - 0.4}
|
||||
\begin{scope}[shift={(\qfdLegX, \qfdLegY)}]
|
||||
\draw[qfdmed, rounded corners=2pt] (-0.15, 0.4) rectangle (4.5, \qfdLegBottom);
|
||||
\node[anchor=west, font=\footnotesize\bfseries] at (0, 0.1) {\qfdRelTitle};
|
||||
\draw[qfdthin] (0, -0.15) -- (4.35, -0.15);
|
||||
\node[qfdstrong] at (0.22, -0.5) {}; \node[anchor=west] at (0.5, -0.5) {Strong (9)};
|
||||
\node[qfdmod] at (0.22, -0.95) {}; \node[anchor=west] at (0.5, -0.95) {Medium (3)};
|
||||
\node[qfdweak] at (0.22, -1.4) {}; \node[anchor=west] at (0.5, -1.4) {Weak (1)};
|
||||
\ifqfdshowroof \ifqfdshowcorrlegend
|
||||
\node[anchor=west, font=\footnotesize\bfseries] at (0, -2.10) {\qfdCorrTitle};
|
||||
\draw[qfdthin] (0, -2.35) -- (4.35, -2.35);
|
||||
\node[anchor=west] at (0, -2.70) {{$+\!+$}\quad very positive};
|
||||
\node[anchor=west] at (0, -3.05) {{$+$\phantom{$+$}}\quad positive};
|
||||
\node[anchor=west] at (0, -3.40) {{$-$\phantom{$-$}}\quad negative};
|
||||
\node[anchor=west] at (0, -3.75) {{$-\!-$}\quad very negative};
|
||||
\fi \fi
|
||||
\ifqfdshowcompetitive \ifqfdshowevallegend
|
||||
\pgfmathsetmacro{\qfdEvalTop}{-2.10 \ifqfdshowroof\ifqfdshowcorrlegend - 2.55 \fi\fi}
|
||||
\node[anchor=west, font=\footnotesize\bfseries] at (0, \qfdEvalTop) {\qfdEvalTitle};
|
||||
\pgfmathsetmacro{\qfdEvalSep}{\qfdEvalTop - 0.25}
|
||||
\draw[qfdthin] (0, \qfdEvalSep) -- (4.35, \qfdEvalSep);
|
||||
\pgfmathsetmacro{\qfdLegA}{\qfdEvalTop - 0.55}
|
||||
\draw[qfdalt1ln] (0.05, \qfdLegA) -- (0.45, \qfdLegA); \node[qfdalt1mk] at (0.25, \qfdLegA) {}; \node[anchor=west, font=\bfseries] at (0.55, \qfdLegA) {\qfdAltOneLabel};
|
||||
\pgfmathsetmacro{\qfdLegB}{\qfdEvalTop - 0.95}
|
||||
\draw[qfdalt2ln] (0.05, \qfdLegB) -- (0.45, \qfdLegB); \node[qfdalt2mk] at (0.25, \qfdLegB) {}; \node[anchor=west] at (0.55, \qfdLegB) {\qfdAltTwoLabel};
|
||||
\pgfmathsetmacro{\qfdLegC}{\qfdEvalTop - 1.35}
|
||||
\draw[qfdalt3ln] (0.05, \qfdLegC) -- (0.45, \qfdLegC); \node[qfdalt3mk] at (0.25, \qfdLegC) {}; \node[anchor=west] at (0.55, \qfdLegC) {\qfdAltThreeLabel};
|
||||
\fi \fi
|
||||
\end{scope}
|
||||
\fi
|
||||
}
|
||||
|
||||
\newenvironment{qfdhouse}{%
|
||||
\begin{tikzpicture}[x=1cm, y=1cm, font=\scriptsize, line cap=round, line join=round]
|
||||
\ifqfdshowimportance
|
||||
\pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW-\qfdImpW}
|
||||
\else
|
||||
\pgfmathsetmacro{\qfdLeftEdge}{-\qfdWhatW}
|
||||
\fi
|
||||
\pgfmathsetmacro{\qfdApexY}{\qfdHdrH + \qfdNH/2}
|
||||
\pgfmathtruncatemacro{\qfdNHm}{\qfdNH - 1}
|
||||
\pgfmathtruncatemacro{\qfdNWm}{\qfdNW - 1}
|
||||
\qfdDrawGrid
|
||||
\qfdDrawRoof
|
||||
\qfdDrawScale
|
||||
\qfdDrawZoneTitles
|
||||
\qfdDrawTitle
|
||||
}{%
|
||||
\qfdDrawFrames
|
||||
\qfdDrawLegend
|
||||
\end{tikzpicture}%
|
||||
}
|
||||
|
||||
\begin{document}
|
||||
|
||||
\def\qfdNW{3}
|
||||
\def\qfdNH{8}
|
||||
\def\qfdWhatW{4.5}
|
||||
\def\qfdHdrH{3.6}
|
||||
\def\qfdImpTitle{Wt}
|
||||
\def\qfdWhatsTitle{Goals}
|
||||
\def\qfdProjectTitle{Remanso}
|
||||
\def\qfdConcept{A \textbf{beautiful}, read-only notes web for \textbf{pride} and \textbf{peace}, with one-gesture \textbf{public} publishing.}
|
||||
\qfdshowcompetitivefalse
|
||||
|
||||
\begin{qfdhouse}
|
||||
\pgfmathsetmacro{\qfdWhatTextW}{\qfdWhatW - 0.2}
|
||||
\foreach \r/\t in {1/{G1 Beauty}, 2/{G2 Pride \& Peace (graceful on mobile)}, 3/{G3 Effortless public voice}}
|
||||
\node[anchor=west, font=\scriptsize, text width=\qfdWhatTextW cm, align=left] at ({\qfdLeftEdge + 0.1}, {-\r + 0.5}) {\t};
|
||||
\foreach \r/\imp in {1/9, 2/10, 3/8}
|
||||
\node[font=\scriptsize] at ({-\qfdImpW/2}, {-\r + 0.5}) {\imp};
|
||||
|
||||
\foreach \c/\t in {1/{F1 Render fidelity}, 2/{F2 Typography}, 3/{F3 No broken refs}, 4/{F4 Snapshot integrity}, 5/{F5 Offline-resilient}, 6/{F6 Calm surface}, 7/{F7 Reveal web}, 8/{F8 1-gesture publish}}
|
||||
\node[rotate=90, anchor=west, font=\scriptsize] at ({\c - 0.5}, 0.15) {\t};
|
||||
|
||||
% Relations — G1 (row 1)
|
||||
\node[qfdrel/S] at ({1-0.5},{-1+0.5}){}; \node[qfdrel/S] at ({2-0.5},{-1+0.5}){};
|
||||
\node[qfdrel/M] at ({3-0.5},{-1+0.5}){}; \node[qfdrel/W] at ({4-0.5},{-1+0.5}){};
|
||||
\node[qfdrel/W] at ({5-0.5},{-1+0.5}){}; \node[qfdrel/M] at ({6-0.5},{-1+0.5}){};
|
||||
\node[qfdrel/W] at ({7-0.5},{-1+0.5}){}; \node[qfdrel/W] at ({8-0.5},{-1+0.5}){};
|
||||
% G2 (row 2)
|
||||
\node[qfdrel/M] at ({1-0.5},{-2+0.5}){}; \node[qfdrel/M] at ({2-0.5},{-2+0.5}){};
|
||||
\node[qfdrel/S] at ({3-0.5},{-2+0.5}){}; \node[qfdrel/S] at ({4-0.5},{-2+0.5}){};
|
||||
\node[qfdrel/S] at ({5-0.5},{-2+0.5}){}; \node[qfdrel/S] at ({6-0.5},{-2+0.5}){};
|
||||
\node[qfdrel/S] at ({7-0.5},{-2+0.5}){}; \node[qfdrel/M] at ({8-0.5},{-2+0.5}){};
|
||||
% G3 (row 3)
|
||||
\node[qfdrel/M] at ({1-0.5},{-3+0.5}){}; \node[qfdrel/M] at ({2-0.5},{-3+0.5}){};
|
||||
\node[qfdrel/W] at ({3-0.5},{-3+0.5}){}; \node[qfdrel/W] at ({4-0.5},{-3+0.5}){};
|
||||
\node[qfdrel/W] at ({5-0.5},{-3+0.5}){}; \node[qfdrel/W] at ({6-0.5},{-3+0.5}){};
|
||||
\node[qfdrel/M] at ({7-0.5},{-3+0.5}){}; \node[qfdrel/S] at ({8-0.5},{-3+0.5}){};
|
||||
|
||||
% Roof correlations
|
||||
\node[font=\scriptsize] at (C-1-2) {$+$};
|
||||
\node[font=\scriptsize] at (C-1-5) {$-\!-$};
|
||||
\node[font=\scriptsize] at (C-3-4) {$-$};
|
||||
\node[font=\scriptsize] at (C-3-5) {$+\!+$};
|
||||
\node[font=\scriptsize] at (C-2-6) {$-$};
|
||||
\node[font=\scriptsize] at (C-6-7) {$-$};
|
||||
\node[font=\scriptsize] at (C-2-8) {$+$};
|
||||
|
||||
% Basement: target / difficulty / abs / rel
|
||||
\foreach \c/\tgt/\diff/\abs/\rel in {1/{all}/3/135/14, 2/{taste}/2/135/14, 3/{0 brk}/3/125/13, 4/{100\%}/3/107/11, 5/{offln}/4/107/11, 6/{calm}/2/125/13, 7/{on-dmd}/2/123/13, 8/{1-gest}/3/111/12} {
|
||||
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 0.5}) {\tgt};
|
||||
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 1.5}) {\diff};
|
||||
\node[font=\scriptsize] at ({\c - 0.5}, {-\qfdNW - 2.5}) {\abs};
|
||||
\node[font=\scriptsize\bfseries] at ({\c - 0.5}, {-\qfdNW - 3.5}) {\rel};
|
||||
}
|
||||
\end{qfdhouse}
|
||||
\end{document}
|
||||
```
|
||||
|
||||
Basement rows per function: **target / difficulty (1–5) / absolute weight / relative weight %**.
|
||||
|
||||
---
|
||||
|
||||
## 1. Goals — the WHATs
|
||||
|
||||
| ID | Goal | Weight | Source |
|
||||
|-----|---------------------------------------------------------------------------------------|:------:|--------|
|
||||
| G1 | **Beauty** — notes render gorgeously; the page is something you want to look at | 9 | Author intent (this session) |
|
||||
| G2 | **Pride & Peace** — calm, trustworthy, quietly proud: nothing breaks, nothing misleads, nothing clutters; reads gracefully on a flaky mobile/metro connection | 10 | Author intent (this session) |
|
||||
| G3 | **Effortless public voice** — lower *every* wall to making your voice public: one gesture (`.pub.md`), open decentralized rails, no forms | 8 | Author intent (this session) |
|
||||
|
||||
## 2. Functions — the HOWs
|
||||
|
||||
| ID | Function | Dir | Target (now) | Target (future) |
|
||||
|-----|-------------------------------------------------------------------------|:---:|--------------|-----------------|
|
||||
| F1 | **Render rich content faithfully** (math, code, diagrams, images, alerts, checkboxes) — for repo Notes *and* Published Notes | ↑ | every supported type renders correctly; failures degrade gracefully (never raw error dumps) | — |
|
||||
| F2 | **Present beautiful typography & layout** (fonts, width, prose, theme, lightbox); Published Notes carry their own theme/fonts/language | ↑ | tasteful defaults + per-repo/per-note tuning | — |
|
||||
| F3 | **Never show a broken live reference** (backlinks/images/notes resolve to current, or degrade gracefully) | ↓ | 0 visible broken refs in normal use; graceful placeholder on flaky network | 0 incl. edge cases |
|
||||
| F4 | **Preserve Snapshot references immutably** (a shared SHA link renders the exact shared version; never silently substitutes current content) | → | 100% snapshot fidelity; on uncached miss, latest-cached + disclosure banner | — |
|
||||
| F5 | **Serve instantly, offline & resilient on mobile/flaky networks** (PouchDB cache-first; no spinner anxiety in the metro) | ↓ | full offline read; cached render fast | cached render ≤ ~100 ms; survives mid-read connection loss |
|
||||
| F6 | **Keep the surface calm & uncluttered** (reading-first, settings tucked away, the Remanso stillness) | → | minimal chrome; no nags/noise | — |
|
||||
| F7 | **Reveal the web of connections** (backlinks + stacked notes) — *calm by default, on demand* | ↑ | every Note shows inbound links; stacking fluid; nothing flooded | — |
|
||||
| F8 | **Publish in one gesture to open rails** (`.pub.md` → ATProto Published Note; no in-app form; decentralized, author-owned) | ↓ | publish = rename + push (1 gesture), 0 in-app steps | — |
|
||||
|
||||
## 3. Cascade — Goals → Functions → How → Components
|
||||
|
||||
- **G1 Beauty** _W:9_
|
||||
- **F1** Render rich content faithfully _Dir↑_
|
||||
- **How**: markdown-it pipeline with plugins; each renderer wrapped so a parse failure yields a styled fallback, not a stack trace
|
||||
- **Component**: C1 `useMarkdown` + plugins (KaTeX, Shikiji, Mermaid, TikZ, GitHub alerts, checkboxes)
|
||||
- **F2** Beautiful typography & layout _Dir↑_
|
||||
- **How**: DaisyUI theme + `@tailwindcss/typography` prose + per-repo `UserSettings`; Published Notes apply their stored theme/fonts
|
||||
- **Component**: C2 Theme & typography system (themes, fonts/width, image lightbox)
|
||||
- **G2 Pride & Peace** _W:10_
|
||||
- **F3** Never show a broken live reference _Dir↓ Target 0 broken_
|
||||
- **How**: a single bidirectional Path↔SHA index; resolve Live references by Path; fix the in-stack edit bug; graceful placeholder when a target is genuinely missing
|
||||
- **Component**: C3 Reference resolver (Path↔SHA index over `store.files`)
|
||||
- **F4** Preserve Snapshot references immutably _Dir→_ — see [ADR-0002](docs/adr/0002-two-reference-modes.md)
|
||||
- **How**: keep SHA-addressed stack URLs; render the pinned version; on cache miss show honest "unavailable", never substitute current
|
||||
- **Component**: C4 Snapshot pinning + unavailable state
|
||||
- **F5** Serve instantly, offline & resilient _Dir↓ Target offline read_
|
||||
- **How**: cache-first reads from PouchDB via the worker; render from cache before/without network; tolerate mid-read connection loss
|
||||
- **Component**: C5 PouchDB cache + `DataApi` worker; C9 Freshness/pull
|
||||
- **F6** Keep the surface calm & uncluttered _Dir→_
|
||||
- **How**: reading-first Igarapé; settings/chrome tucked away; no nags
|
||||
- **Component**: C6 Igarapé surface + calm chrome
|
||||
- **F7** Reveal the web of connections — calm by default _Dir↑_
|
||||
- **How**: backlinks present but quiet; a Note opens into the stack only when its Backlink is summoned (progressive disclosure)
|
||||
- **Component**: C7 Backlinks + Stacked Notes (reveal-on-demand)
|
||||
- **G3 Effortless public voice** _W:8_
|
||||
- **F8** Publish in one gesture to open rails _Dir↓ friction_
|
||||
- **How**: `.pub.md` suffix → external/CI publish to ATProto as a Published Note; in-app surfaces read those records, styled like private notes
|
||||
- **Component**: C8 Publish pipeline + ATProto read plumbing (`getUrl`, `withATProtoImages`, `Public*`→`Published*` views)
|
||||
|
||||
## 4. House — Goals × Functions
|
||||
|
||||
Cells: link strength (9/3/1/blank). Importance row = Σ(weight × strength).
|
||||
|
||||
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 |
|
||||
|-----------------------|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|
|
||||
| G1 Beauty (9) | 9 | 9 | 3 | 1 | 1 | 3 | 1 | 1 |
|
||||
| G2 Pride & Peace (10) | 3 | 3 | 9 | 9 | 9 | 9 | 9 | 3 |
|
||||
| G3 Public voice (8) | 3 | 3 | 1 | 1 | 1 | 1 | 3 | 9 |
|
||||
| **Σ** | 135 | 135 | 125 | 107 | 107 | 125 | 123 | 111 |
|
||||
|
||||
**Top engineering priorities:** F1/F2 (rendering beauty) lead on raw Σ because all three goals touch them; F3 and F6 follow on the strength of Pride & Peace. **Caveat the matrix hides:** the *primary read context is mobile on a flaky metro connection*, which makes F3 (graceful) + F5 (offline-resilient) the **reliability spine to watch hardest** — a broken note in the metro is far more peace-destroying than its mid-pack Σ suggests. Treat F3+F5 as critical despite F5's rank (see §7).
|
||||
|
||||
## 5. Roof — Function × Function tradeoffs
|
||||
|
||||
`◎` strong reinforce · `○` mild reinforce · `×` mild conflict · `⊗` strong conflict.
|
||||
|
||||
| | F1 | F2 | F3 | F4 | F5 | F6 | F7 | F8 |
|
||||
|--------|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
|
||||
| **F1** | — | ○ | | | ⊗ | | | |
|
||||
| **F2** | | — | | | | × | | ○ |
|
||||
| **F3** | | | — | × | ◎ | | | |
|
||||
| **F4** | | | | — | | | | |
|
||||
| **F5** | | | | | — | | | |
|
||||
| **F6** | | | | | | — | × | |
|
||||
| **F7** | | | | | | | — | |
|
||||
| **F8** | | | | | | | | — |
|
||||
|
||||
**Conflicts that actually shape the design:**
|
||||
- **F1 ⊗ F5** (rich rendering vs instant/offline-on-mobile) — heavy renderers fight load speed and battery. Mitigation: lazy-load renderers, cache rendered HTML, render-from-cache first (T2).
|
||||
- **F3 × F4** (never-broken vs immutable snapshot) — resolved by [ADR-0002](docs/adr/0002-two-reference-modes.md): pre-cache aggressively; on a true miss, show latest-cached + a disclosure banner — integrity via *disclosure, not refusal*, so the metro never dead-ends (T3).
|
||||
- **F6 × F7** (calm vs the web) — resolved: **calm by default, reveal on demand** (T1).
|
||||
- **F3 ◎ F5** (graceful refs reinforce offline resilience) — cache-first resolution makes both true at once; design them together.
|
||||
- **F2 × F6** (customization vs clutter) — keep settings tucked away, defaults tasteful.
|
||||
|
||||
## 6. Components & Function → Component map
|
||||
|
||||
| ID | Component | ADR |
|
||||
|-----|------------------------------------------------------------|----------|
|
||||
| C1 | `useMarkdown` + markdown-it plugins (math/code/diagrams/…) | — |
|
||||
| C2 | Theme & typography system (themes, fonts/width, lightbox) | — |
|
||||
| C3 | Reference resolver — Path↔SHA index over `store.files` | ADR-0001 |
|
||||
| C4 | Snapshot pinning + "unavailable" state | ADR-0002 |
|
||||
| C5 | PouchDB cache + `DataApi` worker (cache-first reads) | — |
|
||||
| C6 | Igarapé reading surface + calm chrome | — |
|
||||
| C7 | Backlinks + Stacked Notes (reveal-on-demand) | — |
|
||||
| C8 | Publish pipeline + ATProto read plumbing | — |
|
||||
| C9 | Freshness / pull (external-commit detection) | ADR-0001 |
|
||||
|
||||
| | C1 | C2 | C3 | C4 | C5 | C6 | C7 | C8 | C9 |
|
||||
|-----|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|:--:|
|
||||
| F1 | 9 | 3 | | | 3 | | | 1 | |
|
||||
| F2 | 1 | 9 | | | | 3 | | 1 | |
|
||||
| F3 | | | 9 | 1 | 3 | | 3 | | 9 |
|
||||
| F4 | | | 1 | 9 | 3 | | | | |
|
||||
| F5 | | | 3 | 3 | 9 | | | | 3 |
|
||||
| F6 | | 3 | | | | 9 | 3 | | |
|
||||
| F7 | | | 3 | | | 3 | 9 | | |
|
||||
| F8 | | 1 | | | | | | 9 | |
|
||||
|
||||
## 7. Critical performance budget
|
||||
|
||||
| Rank | Function | Target | Watched on | If we miss it |
|
||||
|------|----------|--------|------------|---------------|
|
||||
| 1 | F3 (never broken) | 0 visible broken live refs | manual mobile run + a "broken-ref" dev assertion; fix the StackedNote `saveCacheNote` path bug | render a calm placeholder ("note unavailable"), never a stack trace or empty column |
|
||||
| 2 | F5 (offline/mobile) | full offline read; survive mid-read connection loss | DevTools offline/slow-3G throttling on the stacked-notes flow | cache-first always; if uncached + offline, honest "not downloaded yet" placeholder |
|
||||
| 3 | F1 (render fidelity) | all content types render; failures degrade | snapshot-render a fixture note (math/code/mermaid/tikz/alerts) | per-block styled fallback; the rest of the note still renders |
|
||||
| 4 | F4 (snapshot integrity) | 100% pinned-version fidelity | test: edit a Note, re-open an old `?stackedNotes=sha` link | show pinned version; on a true miss, latest-cached + disclosure banner — never *silent* substitution (ADR-0002) |
|
||||
| 5 | F8 (publish friction) | 1 gesture, 0 in-app steps | end-to-end: `.pub.md` push → appears in `/notes` | surface the failure plainly; do not require an in-app fallback form (would re-raise the wall) |
|
||||
|
||||
## 8. Tradeoffs — Got / Paid / ADR
|
||||
|
||||
| ID | Tradeoff | Got | Paid | ADR |
|
||||
|-----|----------|-----|------|-----|
|
||||
| T1 | Calm-by-default over web-forward (F6 > F7 prominence) | Stillness; the Remanso peace | Connections less immediately visible (mitigated by reveal-on-demand) | — |
|
||||
| T2 | Lazy-load + cache rendered HTML (F1 ⊗ F5) | Fidelity *and* instant/offline render | Cache complexity; possible stale-render window | — |
|
||||
| T3 | Integrity via disclosure, not refusal (F3 + F4 in the unavailable edge) | No silent rewrite *and* no metro dead-end: latest-cached + a banner | A banner caveat instead of a guaranteed exact view when uncached | [ADR-0002](docs/adr/0002-two-reference-modes.md) |
|
||||
| T4 | Filename-suffix publishing (`.pub.md`) over in-app publish UI (F8) | Near-zero friction; publishing is a gesture, not a feature | Less in-app preview/control; coupling to a naming convention | — |
|
||||
|
||||
### Tensions being watched (unresolved by design)
|
||||
|
||||
- **Rename/move changes a Note's Path → its Live references break.** Under ADR-0001 that's a new identity; we accept it for now. **Trigger to revisit:** if reorganizing notes becomes common enough that broken backlinks hurt Peace, add git-rename following.
|
||||
- **F1 ⊗ F5 residual cost on low-end mobile.** Even lazy + cached, very heavy notes (large TikZ/Mermaid) may stutter in the metro. **Trigger to revisit:** if real mobile runs show jank, pre-render heavy blocks to images at publish/cache time.
|
||||
|
||||
## 9. Inconsistencies spotted and fixed
|
||||
|
||||
- **In-stack edit bug:** `StackedNote.performSave()` called `saveCacheNote()` without `path`, so `store.files` never got the new SHA after an in-Igarapé edit (`FleetingNotes` passed it correctly). Broke F3. **Fixed** — `StackedNote.vue` now passes `path`.
|
||||
- **Asymmetric file index:** `addFile` deduped by SHA only, `registerUploadedFile` by PATH — so an edit (same path, new SHA) left a stale duplicate entry. **Fixed** — `addFile` now dedups by SHA *and* path (a path is unique), with a regression test in `userRepo.store.spec.ts`. (Full bidirectional index C3 still the longer-term direction.)
|
||||
- **ADR-0001 over-absolute:** it called SHA-keyed references "a fragility". **Refined** by ADR-0002 into two intentional modes (Live/Snapshot).
|
||||
- **`CLAUDE.md` "edit history tracking"** mislabels the visited-repos History; no edit-history feature exists. (Flagged in CONTEXT.md.)
|
||||
- **Snapshot cache not immutable:** editing writes new content under the *viewed* (old) SHA's cache key (`prepareNoteCache` keys by the viewed sha), so a re-opened old-SHA snapshot can serve new content from cache. **Decided fix (ADR-0002):** key the immutable store by the content's *own* SHA (write-once), keep the Path key as the latest pointer. *(Not yet implemented — the C3/C4 cache slice; unlocks the snapshot banner.)*
|
||||
- **Naming drift carried from the language session:** `Flux`→`Igarapé`, `HistoricNotes`→`Index`, `Repetition`→`Card`, `PublicNote*`→`PublishedNote*`, `useNotes()` returns Files not Notes. (Tracked in CONTEXT.md "Flagged ambiguities".)
|
||||
|
||||
---
|
||||
|
||||
## How to keep this honest
|
||||
|
||||
- When a new ADR lands → add its components to §6 and re-score affected rows.
|
||||
- When a spike / measurement returns numbers → update §7 `Target` / `Watched on`.
|
||||
- WHATs change rarely; HOWs change with each release; matrices are recomputed when either side changes.
|
||||
- If a section becomes empty after edits, delete it — empty sections lie.
|
||||
15
docs/adr/0001-note-identity-is-path.md
Normal file
15
docs/adr/0001-note-identity-is-path.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Note identity is Path, not SHA
|
||||
|
||||
A **Note**'s stable identity is its **Path** (its location in the repo, e.g. `ideas/zettel.md`), not its content **SHA**. We decided this because authors link to Notes by path (markdown `[[…]]` resolved via `resolvePath()`) and expect those links to survive edits — whereas a Note's SHA changes on every edit.
|
||||
|
||||
This is surprising in the current code, which keys **Backlinks** (`Backlink { sha }`) and the stacked-notes URL (`?stackedNotes=sha1;sha2`) by SHA. Those are runtime *handles* that must resolve *through* the Path; if a SHA-handle ever disagrees with the Path, the Path wins.
|
||||
|
||||
## Considered options
|
||||
|
||||
- **Path as identity (chosen).** Survives edits; matches how authors write links. Cost: needs a resolution layer mapping Path → current SHA for lookup/navigation, and the SHA-keyed code is a known fragility to reconcile.
|
||||
- **SHA as identity (rejected).** Matches the current code literally and needs no resolution layer, but every edit produces a new identity — backlinks and open-note URLs would break on edit. Unacceptable for a notes app where editing is routine.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Backlinks and stacked-note navigation should ultimately be expressed in terms of Path; SHA-keying stays only as an internal lookup handle.
|
||||
- Caching by SHA remains valid (it is content-addressed versioning), but Note identity must never be conflated with it.
|
||||
21
docs/adr/0002-two-reference-modes.md
Normal file
21
docs/adr/0002-two-reference-modes.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
status: accepted (refines ADR-0001)
|
||||
---
|
||||
|
||||
# Two reference modes: Live (Path) and Snapshot (SHA)
|
||||
|
||||
A reference to a Note resolves in one of two deliberate modes. A **Live reference** (by **Path**) resolves to the Note's *current* content — this is a Backlink you follow in your own repo, and it reflects edits (Note identity = Path, per ADR-0001). A **Snapshot reference** (by **SHA**) resolves to that *exact, immutable* version — this is a shared or bookmarked stack link (`?stackedNotes=sha`), content-addressed so what was shared cannot change underneath a reader.
|
||||
|
||||
This refines ADR-0001, which framed SHA-keyed references as "a fragility." That framing was too absolute: SHA-pinning is the **correct** tool for a Snapshot reference. Pinning is an *integrity* feature — it guarantees a shared view can't be silently rewritten, so no reader is misled and no author is misrepresented.
|
||||
|
||||
## Considered options
|
||||
|
||||
- **Two explicit modes (chosen).** Live=Path, Snapshot=SHA, each with a clear purpose. Cost: the resolver must support both, and the UI must make the mode legible (you should know whether you're reading "now" or "as shared").
|
||||
- **Single mode, Path only (rejected).** Simpler, but a shared link would always re-resolve to current content — destroying the integrity guarantee that makes sharing safe.
|
||||
- **Single mode, SHA only (rejected by ADR-0001).** In-repo navigation would break on every edit.
|
||||
|
||||
## Consequences
|
||||
|
||||
- The system **pre-caches** aggressively (content is cached by both SHA and Path on every fetch / freshness pull) so the pinned version is usually present, including offline.
|
||||
- **Cache shape (C3/C4):** content is cached under two keys — its own **content SHA** (write-once, immutable → the snapshot store) and its **Path** (overwritten with the latest content → the live pointer). Both hold the full content, so the latest survives even if a SHA entry is evicted, maximizing offline availability for any version already encountered. (Today the code violates this by writing edited content under the *viewed* old SHA key; the fix is to write under the content's *new* SHA and never overwrite an existing SHA entry.)
|
||||
- When a Snapshot reference's pinned content is genuinely unavailable (never-fetched + offline — common on a flaky mobile connection), the system **falls back to the most up-to-date cached version and shows a banner** disclosing "this is the latest available, not the exact shared version." Integrity is preserved by **disclosure, not refusal**: the reader is never silently shown different content, but is also never dead-ended on the metro. (This chooses graceful continuity over a hard "unavailable" stop, given mobile is the primary read context.)
|
||||
@@ -2,7 +2,7 @@
|
||||
"name": "remanso",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@11.0.9",
|
||||
"packageManager": "pnpm@11.5.2",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
@@ -26,10 +26,7 @@
|
||||
"@octokit/rest": "^22.0.1",
|
||||
"@openpanel/web": "^1.3.0",
|
||||
"@tailwindcss/postcss": "^4.1.16",
|
||||
"@tanstack/vue-query": "^5.92.9",
|
||||
"@toycode/markdown-it-class": "^1.2.4",
|
||||
"@ts-rest/core": "^3.52.1",
|
||||
"@ts-rest/vue-query": "^3.52.1",
|
||||
"@vscode/markdown-it-katex": "^1.1.2",
|
||||
"@vueuse/components": "^14.2.1",
|
||||
"@vueuse/core": "^13.6.0",
|
||||
@@ -52,6 +49,7 @@
|
||||
"markdown-it-shikiji": "^0.10.2",
|
||||
"mermaid": "^11.12.1",
|
||||
"nanoid": "^5.1.6",
|
||||
"node-diff3": "^3.2.1",
|
||||
"notyf": "^3.10.0",
|
||||
"pastel-color": "^1.0.3",
|
||||
"pinia": "^2.2.6",
|
||||
@@ -62,6 +60,8 @@
|
||||
"retrobus": "^1.9.4",
|
||||
"sanitize-html": "^2.17.0",
|
||||
"shikiji-core": "0.10.2",
|
||||
"smol-toml": "^1.7.0",
|
||||
"valibot": "^1.4.2",
|
||||
"vue": "^3.5.18",
|
||||
"vue-i18n": "^11.1.11",
|
||||
"vue-router": "^4.5.1",
|
||||
|
||||
121
pnpm-lock.yaml
generated
121
pnpm-lock.yaml
generated
@@ -35,18 +35,9 @@ importers:
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.1.16
|
||||
version: 4.1.16
|
||||
'@tanstack/vue-query':
|
||||
specifier: ^5.92.9
|
||||
version: 5.92.9(vue@3.5.18(typescript@5.9.3))
|
||||
'@toycode/markdown-it-class':
|
||||
specifier: ^1.2.4
|
||||
version: 1.2.4
|
||||
'@ts-rest/core':
|
||||
specifier: ^3.52.1
|
||||
version: 3.52.1(@types/node@22.15.24)(zod@3.25.76)
|
||||
'@ts-rest/vue-query':
|
||||
specifier: ^3.52.1
|
||||
version: 3.52.1(@tanstack/vue-query@5.92.9(vue@3.5.18(typescript@5.9.3)))(@ts-rest/core@3.52.1(@types/node@22.15.24)(zod@3.25.76))(zod@3.25.76)
|
||||
'@vscode/markdown-it-katex':
|
||||
specifier: ^1.1.2
|
||||
version: 1.1.2
|
||||
@@ -113,6 +104,9 @@ importers:
|
||||
nanoid:
|
||||
specifier: ^5.1.6
|
||||
version: 5.1.6
|
||||
node-diff3:
|
||||
specifier: ^3.2.1
|
||||
version: 3.2.1
|
||||
notyf:
|
||||
specifier: ^3.10.0
|
||||
version: 3.10.0
|
||||
@@ -143,6 +137,12 @@ importers:
|
||||
shikiji-core:
|
||||
specifier: 0.10.2
|
||||
version: 0.10.2
|
||||
smol-toml:
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.0
|
||||
valibot:
|
||||
specifier: ^1.4.2
|
||||
version: 1.4.2(typescript@5.9.3)
|
||||
vue:
|
||||
specifier: ^3.5.18
|
||||
version: 3.5.18(typescript@5.9.3)
|
||||
@@ -2218,46 +2218,9 @@ packages:
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.0.0 || insiders || >=4.0.0-alpha.20 || >=4.0.0-beta.1'
|
||||
|
||||
'@tanstack/match-sorter-utils@8.19.4':
|
||||
resolution: {integrity: sha512-Wo1iKt2b9OT7d+YGhvEPD3DXvPv2etTusIMhMUoG7fbhmxcXCtIjJDEygy91Y2JFlwGyjqiBPRozme7UD8hoqg==}
|
||||
engines: {node: '>=12'}
|
||||
|
||||
'@tanstack/query-core@5.90.20':
|
||||
resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==}
|
||||
|
||||
'@tanstack/vue-query@5.92.9':
|
||||
resolution: {integrity: sha512-jjAZcqKveyX0C4w/6zUqbnqk/XzuxNWaFsWjGTJWULVFizUNeLGME2gf9vVSDclIyiBhR13oZJPPs6fJgfpIJQ==}
|
||||
peerDependencies:
|
||||
'@vue/composition-api': ^1.1.2
|
||||
vue: ^2.6.0 || ^3.3.0
|
||||
peerDependenciesMeta:
|
||||
'@vue/composition-api':
|
||||
optional: true
|
||||
|
||||
'@toycode/markdown-it-class@1.2.4':
|
||||
resolution: {integrity: sha512-hA4gHBK8moObkOYdWTjhy1wYcYy0MJeM3JjSKbsXHRpRMvIKhk6Jm+t3bXsSScTdz/byWqQbs8YIwVYjHp+SlQ==}
|
||||
|
||||
'@ts-rest/core@3.52.1':
|
||||
resolution: {integrity: sha512-tAjz7Kxq/grJodcTA1Anop4AVRDlD40fkksEV5Mmal88VoZeRKAG8oMHsDwdwPZz+B/zgnz0q2sF+cm5M7Bc7g==}
|
||||
peerDependencies:
|
||||
'@types/node': ^18.18.7 || >=20.8.4
|
||||
zod: ^3.22.3
|
||||
peerDependenciesMeta:
|
||||
'@types/node':
|
||||
optional: true
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
'@ts-rest/vue-query@3.52.1':
|
||||
resolution: {integrity: sha512-89u7aS9LGDC7uNUC5CagWX1EB7vTwyXohYcizLi1D9v7MD/Cnu5OTQNf8SY3PuAK62RcFJXB2XZGsMAPC0svNw==}
|
||||
peerDependencies:
|
||||
'@tanstack/vue-query': ^4.0.0
|
||||
'@ts-rest/core': ~3.52.0
|
||||
zod: ^3.22.3
|
||||
peerDependenciesMeta:
|
||||
zod:
|
||||
optional: true
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
resolution: {integrity: sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg==}
|
||||
|
||||
@@ -4027,7 +3990,7 @@ packages:
|
||||
|
||||
glob@7.2.3:
|
||||
resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==}
|
||||
deprecated: Glob versions prior to v9 are no longer supported
|
||||
deprecated: Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me
|
||||
|
||||
globals@13.20.0:
|
||||
resolution: {integrity: sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==}
|
||||
@@ -5032,7 +4995,7 @@ packages:
|
||||
|
||||
markdown-it-shikiji@0.10.2:
|
||||
resolution: {integrity: sha512-Y2Zfp6GblTuGBlirnBms2xS6+C3iFiUzCsVp+nZaMWavBcS9ZFxEglWdSpfjt4gqWlQunWmIEX1OUt9WaqFNvw==}
|
||||
deprecated: Shikiji is merged back to Shiki v1.0, please migrate over to get the latest updates
|
||||
deprecated: Deprecated, use @shikijs/markdown-it instead
|
||||
|
||||
markdown-it@14.1.0:
|
||||
resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==}
|
||||
@@ -5176,6 +5139,10 @@ packages:
|
||||
node-addon-api@7.1.1:
|
||||
resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==}
|
||||
|
||||
node-diff3@3.2.1:
|
||||
resolution: {integrity: sha512-eKZcJ8RtMQ3cIaA15EgmtwG927fYnRlhtdA4Q9HAcCpAZPQhhU2XptnTH9GeAkMiX2bAXxlJSAFa9shFbztPgw==}
|
||||
engines: {bun: '>=1.3.10'}
|
||||
|
||||
node-fetch@2.6.12:
|
||||
resolution: {integrity: sha512-C/fGU2E8ToujUivIO0H+tpQ6HWo4eEmchoPIoXtxCrVghxdKq+QOHqEZW7tuP3KlV3bC8FRMO5nMCC7Zm1VP6g==}
|
||||
engines: {node: 4.x || >=6.0.0}
|
||||
@@ -5704,9 +5671,6 @@ packages:
|
||||
resolution: {integrity: sha512-NZQZdC5wOE/H3UT28fVGL+ikOZcEzfMGk/c3iN9UGxzWHMa1op7274oyiUVrAG4B2EuFhus8SvkaYnhvW92p9Q==}
|
||||
hasBin: true
|
||||
|
||||
remove-accents@0.5.0:
|
||||
resolution: {integrity: sha512-8g3/Otx1eJaVD12e31UbJj1YzdtVvzH85HV7t+9MJYk/u3XmkOUJ5Ys9wQrf9PCPK8+xn4ymzqYCiZl6QWKn+A==}
|
||||
|
||||
remove-trailing-separator@1.1.0:
|
||||
resolution: {integrity: sha512-/hS+Y0u3aOfIETiaiirUFwDBDzmXPvO+jAfKTitUngIPzdKc6Z0LoFjM/CK5PL4C+eKwHohlHAb6H0VFfmmUsw==}
|
||||
|
||||
@@ -5932,10 +5896,11 @@ packages:
|
||||
|
||||
shikiji-transformers@0.10.2:
|
||||
resolution: {integrity: sha512-7IVTwl1af205ywYEq5bOAYOTOFW4V1dVX1EablP0nWKErqZeD1o93VMytxmtJomqS+YwbB8doY8SE3MFMn0aPQ==}
|
||||
deprecated: Deprecated, use @shikijs/transformers instead
|
||||
|
||||
shikiji@0.10.2:
|
||||
resolution: {integrity: sha512-wtZg3T0vtYV2PnqusWQs3mDaJBdCPWxFDrBM/SE5LfrX92gjUvfEMlc+vJnoKY6Z/S44OWaCRzNIsdBRWcTAiw==}
|
||||
deprecated: Shikiji is merged back to Shiki v1.0, please migrate over to get the latest updates
|
||||
deprecated: Deprecated, use shiki instead
|
||||
|
||||
side-channel-list@1.0.0:
|
||||
resolution: {integrity: sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==}
|
||||
@@ -5974,6 +5939,10 @@ packages:
|
||||
resolution: {integrity: sha512-KAkBqZl3c2GvNgNhcoyJae1aKldDW0LO279wF9bk1PnluRTETKBq0WyzRXxEhoQLk56yHaOY4JCBEKDuJIET5g==}
|
||||
engines: {node: '>=20.0.0'}
|
||||
|
||||
smol-toml@1.7.0:
|
||||
resolution: {integrity: sha512-aqVvWoyO21L23mb+drl4RmMXbf6N7FdHjAhTRA9ZBL7apWBgfWC16KjrASI+1p9GAroljyMHj6fK67i0UiTNvQ==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
snapdragon-node@2.1.1:
|
||||
resolution: {integrity: sha512-O27l4xaMYt/RSQ5TR3vpWCAB5Kb/czIcqUFOM/C4fYcLnbZUc1PkjTAMjof2pBWaSTwOUd6qUHcFGVGj7aIwnw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -6482,13 +6451,22 @@ packages:
|
||||
|
||||
uuid@3.4.0:
|
||||
resolution: {integrity: sha512-HjSDRw6gZE5JMggctHBcjVak08+KEVhSIiDzFnT9S9aegmp85S/bReBVTb4QTFaRNptJ9kuYaNhnbNEOkbKb/A==}
|
||||
deprecated: Please upgrade to version 7 or higher. Older versions may use Math.random() in certain circumstances, which is known to be problematic. See https://v8.dev/blog/math-random for details.
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
uuid@8.3.2:
|
||||
resolution: {integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==}
|
||||
deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).
|
||||
hasBin: true
|
||||
|
||||
valibot@1.4.2:
|
||||
resolution: {integrity: sha512-gjdCvJ6d3RyHAneqxMYMW9QMCwYMb3jpOO0IyHZV1bnRHFBHrX3VkIILt5XYR0WhwHiH7Mty8ovuPZ/O3gamrg==}
|
||||
peerDependencies:
|
||||
typescript: '>=5'
|
||||
peerDependenciesMeta:
|
||||
typescript:
|
||||
optional: true
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==}
|
||||
|
||||
@@ -6661,6 +6639,7 @@ packages:
|
||||
vue-i18n@11.1.11:
|
||||
resolution: {integrity: sha512-LvyteQoXeQiuILbzqv13LbyBna/TEv2Ha+4ZWK2AwGHUzZ8+IBaZS0TJkCgn5izSPLcgZwXy9yyTrewCb2u/MA==}
|
||||
engines: {node: '>= 16'}
|
||||
deprecated: This version is NOT deprecated. Previous deprecation was a mistake.
|
||||
peerDependencies:
|
||||
vue: ^3.0.0
|
||||
|
||||
@@ -8747,34 +8726,8 @@ snapshots:
|
||||
postcss-selector-parser: 6.0.10
|
||||
tailwindcss: 4.2.2
|
||||
|
||||
'@tanstack/match-sorter-utils@8.19.4':
|
||||
dependencies:
|
||||
remove-accents: 0.5.0
|
||||
|
||||
'@tanstack/query-core@5.90.20': {}
|
||||
|
||||
'@tanstack/vue-query@5.92.9(vue@3.5.18(typescript@5.9.3))':
|
||||
dependencies:
|
||||
'@tanstack/match-sorter-utils': 8.19.4
|
||||
'@tanstack/query-core': 5.90.20
|
||||
'@vue/devtools-api': 6.6.4
|
||||
vue: 3.5.18(typescript@5.9.3)
|
||||
vue-demi: 0.14.10(vue@3.5.18(typescript@5.9.3))
|
||||
|
||||
'@toycode/markdown-it-class@1.2.4': {}
|
||||
|
||||
'@ts-rest/core@3.52.1(@types/node@22.15.24)(zod@3.25.76)':
|
||||
optionalDependencies:
|
||||
'@types/node': 22.15.24
|
||||
zod: 3.25.76
|
||||
|
||||
'@ts-rest/vue-query@3.52.1(@tanstack/vue-query@5.92.9(vue@3.5.18(typescript@5.9.3)))(@ts-rest/core@3.52.1(@types/node@22.15.24)(zod@3.25.76))(zod@3.25.76)':
|
||||
dependencies:
|
||||
'@tanstack/vue-query': 5.92.9(vue@3.5.18(typescript@5.9.3))
|
||||
'@ts-rest/core': 3.52.1(@types/node@22.15.24)(zod@3.25.76)
|
||||
optionalDependencies:
|
||||
zod: 3.25.76
|
||||
|
||||
'@tybys/wasm-util@0.10.1':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
@@ -12151,6 +12104,8 @@ snapshots:
|
||||
node-addon-api@7.1.1:
|
||||
optional: true
|
||||
|
||||
node-diff3@3.2.1: {}
|
||||
|
||||
node-fetch@2.6.12:
|
||||
dependencies:
|
||||
whatwg-url: 5.0.0
|
||||
@@ -12704,8 +12659,6 @@ snapshots:
|
||||
dependencies:
|
||||
jsesc: 3.1.0
|
||||
|
||||
remove-accents@0.5.0: {}
|
||||
|
||||
remove-trailing-separator@1.1.0: {}
|
||||
|
||||
repeat-element@1.1.4: {}
|
||||
@@ -13070,6 +13023,8 @@ snapshots:
|
||||
|
||||
smob@1.6.1: {}
|
||||
|
||||
smol-toml@1.7.0: {}
|
||||
|
||||
snapdragon-node@2.1.1:
|
||||
dependencies:
|
||||
define-property: 1.0.0
|
||||
@@ -13598,6 +13553,10 @@ snapshots:
|
||||
|
||||
uuid@8.3.2: {}
|
||||
|
||||
valibot@1.4.2(typescript@5.9.3):
|
||||
optionalDependencies:
|
||||
typescript: 5.9.3
|
||||
|
||||
validate-npm-package-license@3.0.4:
|
||||
dependencies:
|
||||
spdx-correct: 3.2.0
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import ImageLightbox from "@/components/ImageLightbox.vue"
|
||||
import NewVersion from "@/components/NewVersion.vue"
|
||||
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
|
||||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||
@@ -12,6 +13,7 @@ const { isATProtoReady } = useATProtoLogin()
|
||||
<router-view v-if="isReady && isATProtoReady" />
|
||||
|
||||
<new-version />
|
||||
<image-lightbox />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
||||
import { useNoteView } from "@/hooks/useNoteView.hook"
|
||||
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
|
||||
import { useResolveLiveNotes } from "@/hooks/useResolveLiveNotes.hook"
|
||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||
import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook"
|
||||
import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
|
||||
@@ -43,7 +44,12 @@ useUserSettings()
|
||||
const { visitRepo } = useVisitRepo({ user: user, repo: repo })
|
||||
const { toHTML } = markdownBuilder(repo)
|
||||
const { listenToClick } = useLinks("note-display")
|
||||
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
|
||||
const { stackedNotes, scrollToFocusedNote, scrollToLastStackedNote } =
|
||||
useRouteQueryStackedNotes()
|
||||
|
||||
// A living link arrives with paths in `?liveNotes`; resolve them to the latest
|
||||
// shas, then focus the deepest note just like an ordinary shared link does.
|
||||
useResolveLiveNotes(() => scrollToLastStackedNote())
|
||||
|
||||
const { titles } = useNoteView()
|
||||
const { isLogged } = useGitHubLogin()
|
||||
@@ -62,7 +68,8 @@ const hasContent = computed(() => !!renderedContent.value)
|
||||
|
||||
useMarkdownPostRender(renderedContent, () => ".note-display", {
|
||||
onReady: () => listenToClick(),
|
||||
tikz: true
|
||||
tikz: true,
|
||||
macroplan: true
|
||||
})
|
||||
|
||||
watch(
|
||||
@@ -77,7 +84,10 @@ const retryLoad = () => {
|
||||
store.setUserRepo(props.user, props.repo)
|
||||
}
|
||||
|
||||
onMounted(() => visitRepo())
|
||||
onMounted(() => {
|
||||
visitRepo()
|
||||
scrollToLastStackedNote()
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
store.resetFiles()
|
||||
@@ -100,7 +110,7 @@ onUnmounted(() => {
|
||||
<h1 class="heading-1">
|
||||
{{ repo }}
|
||||
</h1>
|
||||
{{ user }}
|
||||
· {{ user }}
|
||||
</div>
|
||||
<cache-all-notes />
|
||||
</div>
|
||||
@@ -148,6 +158,10 @@ $header-height: 40px;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
.heading-1 {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
&.content {
|
||||
.title,
|
||||
h1,
|
||||
|
||||
@@ -25,16 +25,16 @@ const fontFamilies = computed(
|
||||
const sortedFontFamilies = computed(() => {
|
||||
const base = fontFamilies.value
|
||||
const extras = [
|
||||
store.userSettings?.chosenTitleFont,
|
||||
store.userSettings?.chosenHeadingFont,
|
||||
store.userSettings?.chosenBodyFont
|
||||
].filter((f): f is string => !!f && !base.includes(f))
|
||||
return [...base, ...extras].sort((a, b) => a.localeCompare(b))
|
||||
})
|
||||
const fontSizes = Array.from({ length: 7 }, (_, i) => `${9 + i * 2}pt`)
|
||||
|
||||
const titleFont = computed({
|
||||
get: () => store.userSettings?.chosenTitleFont,
|
||||
set: (value) => store.setTitleFont(value!)
|
||||
const headingFont = computed({
|
||||
get: () => store.userSettings?.chosenHeadingFont,
|
||||
set: (value) => store.setHeadingFont(value!)
|
||||
})
|
||||
const bodyFont = computed({
|
||||
get: () => store.userSettings?.chosenBodyFont,
|
||||
@@ -49,13 +49,35 @@ const fontSize = computed({
|
||||
<template>
|
||||
<div class="font-change">
|
||||
<div>
|
||||
<label for="title-font" class="font-label">t</label>
|
||||
<select id="title-font" class="select" v-model="titleFont">
|
||||
<label for="heading-font" class="font-label">h</label>
|
||||
<select id="heading-font" class="select" v-model="headingFont">
|
||||
<option v-for="font in sortedFontFamilies" :key="font" :value="font">
|
||||
{{ font }}
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm btn-circle"
|
||||
aria-label="Swap heading and paragraph fonts"
|
||||
@click="store.swapFonts()"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M7 10h14l-4 -4" />
|
||||
<path d="M17 14h-14l4 4" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<label for="body-font" class="font-label">p</label>
|
||||
<select id="body-font" class="select" v-model="bodyFont">
|
||||
<option v-for="font in sortedFontFamilies" :key="font" :value="font">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<script lang="ts" setup>
|
||||
import FontChange from "@/components/FontChange.vue"
|
||||
import HomeButton from "@/components/HomeButton.vue"
|
||||
import ShareNote from "@/components/ShareNote.vue"
|
||||
|
||||
defineProps<{ user: string; repo: string }>()
|
||||
</script>
|
||||
@@ -55,6 +56,7 @@ defineProps<{ user: string; repo: string }>()
|
||||
<path d="M5 20l6 -16l2 0l7 16" />
|
||||
</svg>
|
||||
</button>
|
||||
<share-note />
|
||||
<router-link
|
||||
class="btn btn-ghost btn-circle"
|
||||
:to="{ name: 'FluxNoteView', params: { user, repo } }"
|
||||
|
||||
54
src/components/ImageLightbox.vue
Normal file
54
src/components/ImageLightbox.vue
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts" setup>
|
||||
import { useEventListener } from "@vueuse/core"
|
||||
import { ref, watch } from "vue"
|
||||
|
||||
import { useImageLightbox } from "@/hooks/useImageLightbox.hook"
|
||||
import { svgToDataUrl } from "@/utils/svgDownload"
|
||||
|
||||
const { isOpen, src, alt, open, close } = useImageLightbox()
|
||||
|
||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||
|
||||
const onClick = (event: MouseEvent) => {
|
||||
const target = event.target as HTMLElement
|
||||
if (target.closest(".svg-download-buttons")) return
|
||||
|
||||
const svg = target.closest<SVGSVGElement>(".tikz svg, .mermaid svg")
|
||||
if (svg) {
|
||||
event.preventDefault()
|
||||
open(svgToDataUrl(svg), "diagram")
|
||||
return
|
||||
}
|
||||
|
||||
const img = target.closest("img")
|
||||
if (img && img.closest(".note-content, .note-display")) {
|
||||
event.preventDefault()
|
||||
open(img.currentSrc || img.src, img.alt)
|
||||
}
|
||||
}
|
||||
|
||||
useEventListener(document, "click", onClick)
|
||||
|
||||
watch(isOpen, (value) => {
|
||||
const el = dialogRef.value
|
||||
if (!el) return
|
||||
if (value && !el.open) el.showModal()
|
||||
else if (!value && el.open) el.close()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<dialog
|
||||
ref="dialogRef"
|
||||
class="modal image-lightbox not-prose"
|
||||
@close="close()"
|
||||
@click="close()"
|
||||
>
|
||||
<img
|
||||
v-if="src"
|
||||
:src="src"
|
||||
:alt="alt"
|
||||
class="max-h-[96vh] max-w-[96vw] rounded-lg bg-white p-3 cursor-zoom-out"
|
||||
/>
|
||||
</dialog>
|
||||
</template>
|
||||
29
src/components/NoteState.spec.ts
Normal file
29
src/components/NoteState.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { mount } from "@vue/test-utils"
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import NoteState from "./NoteState.vue"
|
||||
|
||||
describe("NoteState", () => {
|
||||
it("shows a spinner while loading and no retry", () => {
|
||||
const wrapper = mount(NoteState, { props: { status: "loading" } })
|
||||
|
||||
expect(wrapper.find(".loading-spinner").exists()).toBe(true)
|
||||
expect(wrapper.find(".note-state-retry").exists()).toBe(false)
|
||||
})
|
||||
|
||||
it("shows a message and retry button when failed", () => {
|
||||
const wrapper = mount(NoteState, { props: { status: "failed" } })
|
||||
|
||||
expect(wrapper.find(".loading-spinner").exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain("Couldn't load this note")
|
||||
expect(wrapper.find(".note-state-retry").exists()).toBe(true)
|
||||
})
|
||||
|
||||
it("emits retry when the retry button is clicked", async () => {
|
||||
const wrapper = mount(NoteState, { props: { status: "failed" } })
|
||||
|
||||
await wrapper.find(".note-state-retry").trigger("click")
|
||||
|
||||
expect(wrapper.emitted("retry")).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
48
src/components/NoteState.vue
Normal file
48
src/components/NoteState.vue
Normal file
@@ -0,0 +1,48 @@
|
||||
<script lang="ts" setup>
|
||||
defineProps<{ status: "loading" | "failed" }>()
|
||||
defineEmits<{ retry: [] }>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="note-state">
|
||||
<span
|
||||
v-if="status === 'loading'"
|
||||
class="loading loading-spinner loading-md"
|
||||
aria-label="Loading note"
|
||||
></span>
|
||||
<template v-else>
|
||||
<p class="note-state-message">Couldn't load this note.</p>
|
||||
<button type="button" class="note-state-retry" @click="$emit('retry')">
|
||||
Retry
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.note-state {
|
||||
display: flex;
|
||||
flex: 1;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.75rem;
|
||||
padding: 2rem 1rem;
|
||||
color: var(--color-base-content);
|
||||
opacity: 0.55;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.note-state-message {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.note-state-retry {
|
||||
padding: 0.25rem 0.85rem;
|
||||
border: 1px solid currentColor;
|
||||
border-radius: 0.375rem;
|
||||
background: transparent;
|
||||
color: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
@@ -45,8 +45,9 @@ defineSlots<{
|
||||
|
||||
<style scoped lang="scss">
|
||||
ul {
|
||||
padding-left: 1rem;
|
||||
padding-right: 1rem;
|
||||
width: 100%;
|
||||
max-width: 42rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
li {
|
||||
@@ -57,17 +58,19 @@ li {
|
||||
}
|
||||
|
||||
a {
|
||||
padding-left: 0;
|
||||
padding: 0;
|
||||
min-height: 0;
|
||||
height: auto;
|
||||
text-align: left;
|
||||
font-size: 1.2rem;
|
||||
line-height: 1.5rem;
|
||||
}
|
||||
|
||||
.alias {
|
||||
text-align: right;
|
||||
text-align: left;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
margin-top: 0.5rem;
|
||||
justify-content: flex-start;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
197
src/components/ShareNote.vue
Normal file
197
src/components/ShareNote.vue
Normal file
@@ -0,0 +1,197 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed } from "vue"
|
||||
import { LocationQueryRaw, useRoute, useRouter } from "vue-router"
|
||||
|
||||
import { stackToLivePaths } from "@/modules/note/liveNotes"
|
||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||
import { confirmMessage, errorMessage } from "@/utils/notif"
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useUserRepoStore()
|
||||
|
||||
const stackedShas = computed<string[]>(() => {
|
||||
const raw = route.query.stackedNotes
|
||||
if (!raw) return []
|
||||
return (Array.isArray(raw) ? raw : [raw]).filter(
|
||||
(sha): sha is string => typeof sha === "string"
|
||||
)
|
||||
})
|
||||
|
||||
const hasStack = computed(() => stackedShas.value.length > 0)
|
||||
|
||||
const absoluteUrl = (query: LocationQueryRaw): string => {
|
||||
const { href } = router.resolve({ path: route.path, query })
|
||||
return `${window.location.origin}${href}`
|
||||
}
|
||||
|
||||
// The exact snapshot: the current URL, with each note pinned to its blob sha.
|
||||
const snapshotUrl = (): string => absoluteUrl({ ...route.query })
|
||||
|
||||
// The living link: the same notes referenced by path, so the recipient always
|
||||
// lands on the latest version. See useResolveLiveNotes for the read side.
|
||||
const livingUrl = (): string => {
|
||||
const query = { ...route.query }
|
||||
delete query.stackedNotes
|
||||
return absoluteUrl({
|
||||
...query,
|
||||
liveNotes: stackToLivePaths(stackedShas.value, store.files)
|
||||
})
|
||||
}
|
||||
|
||||
const close = () =>
|
||||
(document.getElementById("share_modal") as HTMLDialogElement | null)?.close()
|
||||
|
||||
const copy = async (url: string, label: string) => {
|
||||
close()
|
||||
try {
|
||||
await navigator.clipboard.writeText(url)
|
||||
confirmMessage(`🔗 ${label} link copied`)
|
||||
} catch {
|
||||
errorMessage("❌ Couldn't copy the link")
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="btn btn-ghost btn-circle text-accent"
|
||||
title="Share this view"
|
||||
aria-label="Share this view"
|
||||
onclick="share_modal.showModal()"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-share"
|
||||
width="30"
|
||||
height="30"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<circle cx="6" cy="12" r="3" />
|
||||
<circle cx="18" cy="6" r="3" />
|
||||
<circle cx="18" cy="18" r="3" />
|
||||
<line x1="8.7" y1="10.7" x2="15.3" y2="7.3" />
|
||||
<line x1="8.7" y1="13.3" x2="15.3" y2="16.7" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<dialog id="share_modal" class="modal">
|
||||
<div class="modal-box max-w-md">
|
||||
<h3 class="text-lg font-bold">Share this view</h3>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="share-option"
|
||||
@click="copy(snapshotUrl(), 'Snapshot')"
|
||||
>
|
||||
<span class="share-option-icon">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-pin"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M15 4.5l-4 4l-4 1.5l-1.5 1.5l7 7l1.5 -1.5l1.5 -4l4 -4" />
|
||||
<path d="M9 15l-4.5 4.5" />
|
||||
<path d="M14.5 4l5.5 5.5" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="share-option-text">
|
||||
<span class="share-option-title">This exact version</span>
|
||||
<span class="share-option-desc">
|
||||
Pinned to what you see now — it never changes.
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="hasStack"
|
||||
type="button"
|
||||
class="share-option"
|
||||
@click="copy(livingUrl(), 'Always-latest')"
|
||||
>
|
||||
<span class="share-option-icon">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-repeat"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.5"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 12v-3a3 3 0 0 1 3 -3h13m-3 -3l3 3l-3 3" />
|
||||
<path d="M20 12v3a3 3 0 0 1 -3 3h-13m3 3l-3 -3l3 -3" />
|
||||
</svg>
|
||||
</span>
|
||||
<span class="share-option-text">
|
||||
<span class="share-option-title">Always latest</span>
|
||||
<span class="share-option-desc">
|
||||
Follows these notes, so the reader always sees the current version.
|
||||
</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.share-option {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
margin-top: 1rem;
|
||||
padding: 0.75rem 1rem;
|
||||
text-align: left;
|
||||
border: 1px solid var(--color-base-300);
|
||||
border-radius: var(--radius-box, 0.5rem);
|
||||
background-color: var(--color-base-100);
|
||||
color: var(--color-base-content);
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.share-option-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--color-accent);
|
||||
}
|
||||
|
||||
.share-option-text {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.15rem;
|
||||
}
|
||||
|
||||
.share-option-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.share-option-desc {
|
||||
font-size: 0.85em;
|
||||
opacity: 0.75;
|
||||
}
|
||||
</style>
|
||||
@@ -1,11 +1,5 @@
|
||||
<script lang="ts" setup>
|
||||
import {
|
||||
computed,
|
||||
defineAsyncComponent,
|
||||
onMounted,
|
||||
ref,
|
||||
watch
|
||||
} from "vue"
|
||||
import { computed, defineAsyncComponent, onMounted, ref, watch } from "vue"
|
||||
|
||||
import { useEditionMode } from "@/hooks/useEditionMode"
|
||||
import { useFile } from "@/hooks/useFile.hook"
|
||||
@@ -23,6 +17,7 @@ import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
||||
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
|
||||
import { filenameToNoteTitle } from "@/utils/noteTitle"
|
||||
import { errorMessage } from "@/utils/notif"
|
||||
import { threeWayMerge } from "@/utils/threeWayMerge"
|
||||
|
||||
const LinkedNotes = defineAsyncComponent(
|
||||
() => import("@/components/LinkedNotes.vue")
|
||||
@@ -40,6 +35,10 @@ const EditNote = defineAsyncComponent(
|
||||
() => import("@/modules/note/components/EditNote.vue")
|
||||
)
|
||||
|
||||
const NoteState = defineAsyncComponent(
|
||||
() => import("@/components/NoteState.vue")
|
||||
)
|
||||
|
||||
const props = defineProps<{
|
||||
user: string
|
||||
repo: string
|
||||
@@ -53,16 +52,28 @@ const repo = computed(() => props.repo)
|
||||
const sha = computed(() => props.sha)
|
||||
const index = computed(() => props.index)
|
||||
|
||||
const { scrollToFocusedNote } = useRouteQueryStackedNotes()
|
||||
const { scrollToFocusedNote, replaceStackedNote } = useRouteQueryStackedNotes()
|
||||
|
||||
// When this note's content changes (edit / pull) its sha changes too; advance
|
||||
// the stack handle so the live view follows it, leaving the old sha as an
|
||||
// immutable snapshot for any link already shared.
|
||||
const advanceStackTo = (newSha: string | null) => {
|
||||
if (newSha) replaceStackedNote(sha.value, newSha)
|
||||
}
|
||||
|
||||
const {
|
||||
path,
|
||||
newerSha,
|
||||
content,
|
||||
rawContent,
|
||||
getRawContent,
|
||||
saveCacheNote,
|
||||
getEditedSha
|
||||
} = useFile(sha)
|
||||
|
||||
// When this is an older snapshot of a still-existing note, jump the stack to
|
||||
// its current version (the exact snapshot is never swapped underneath you).
|
||||
const viewLatest = () => advanceStackTo(newerSha.value)
|
||||
const initialRawContent = ref<string | null>(null)
|
||||
const isMarkdown = computed(() =>
|
||||
path.value ? isMarkdownPath(path.value) : true
|
||||
@@ -144,7 +155,8 @@ const {
|
||||
lastCheckedAt,
|
||||
latestSha,
|
||||
check: checkFreshness,
|
||||
pullLatest
|
||||
pullLatest,
|
||||
resolveMergeSources
|
||||
} = useNoteFreshness({
|
||||
user: user.value,
|
||||
repo: repo.value,
|
||||
@@ -154,10 +166,34 @@ const {
|
||||
})
|
||||
|
||||
const conflictOpen = ref(false)
|
||||
const loadStatus = ref<"loading" | "ready" | "failed">("loading")
|
||||
|
||||
onMounted(async () => {
|
||||
initialRawContent.value = await getRawContent()
|
||||
})
|
||||
const loadNote = async () => {
|
||||
loadStatus.value = "loading"
|
||||
const raw = await getRawContent()
|
||||
if (raw === null) {
|
||||
loadStatus.value = "failed"
|
||||
return
|
||||
}
|
||||
rawContent.value = raw
|
||||
initialRawContent.value = raw
|
||||
loadStatus.value = "ready"
|
||||
}
|
||||
|
||||
onMounted(loadNote)
|
||||
|
||||
// A note only has a trustworthy baseline once it has finished loading. While
|
||||
// it is still loading (or after a failed load) rawContent holds the empty
|
||||
// placeholder and initialRawContent is null, so a naive
|
||||
// `rawContent !== initialRawContent` check reads as a huge edit — and saving
|
||||
// that would clobber the real file on GitHub with an empty commit. Gate every
|
||||
// "is this dirty?" decision on a loaded baseline.
|
||||
const isDirty = computed(
|
||||
() =>
|
||||
loadStatus.value === "ready" &&
|
||||
initialRawContent.value !== null &&
|
||||
rawContent.value !== initialRawContent.value
|
||||
)
|
||||
|
||||
watch(
|
||||
path,
|
||||
@@ -172,10 +208,10 @@ const { mode, toggleMode } = useEditionMode()
|
||||
useMarkdownPostRender(content, () => `.note-${sha.value}`, {
|
||||
onReady: () => listenToClick(),
|
||||
tikz: true,
|
||||
macroplan: true,
|
||||
mermaid: () => rawContent.value.includes("```mermaid"),
|
||||
shikiji: () => isMarkdown.value && rawContent.value.includes("```"),
|
||||
images: () =>
|
||||
/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null,
|
||||
images: () => (/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null),
|
||||
triggers: [mode]
|
||||
})
|
||||
|
||||
@@ -185,6 +221,13 @@ const performSave = async (overrideSha?: string) => {
|
||||
return
|
||||
}
|
||||
|
||||
// Defence in depth: never push content we didn't successfully load, or we'd
|
||||
// overwrite the file on GitHub with the empty placeholder.
|
||||
if (loadStatus.value !== "ready" || initialRawContent.value === null) {
|
||||
console.warn("refusing to save a note that hasn't finished loading")
|
||||
return
|
||||
}
|
||||
|
||||
const editedSha = overrideSha ?? (await getEditedSha()) ?? sha.value
|
||||
const { sha: newSha, conflict } = await updateFile({
|
||||
content: rawContent.value,
|
||||
@@ -193,9 +236,7 @@ const performSave = async (overrideSha?: string) => {
|
||||
})
|
||||
|
||||
if (conflict) {
|
||||
await checkFreshness()
|
||||
conflictOpen.value = true
|
||||
if (mode.value === "read") toggleMode()
|
||||
await handleConflict()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -205,9 +246,52 @@ const performSave = async (overrideSha?: string) => {
|
||||
}
|
||||
|
||||
await saveCacheNote(encodeUTF8ToBase64(rawContent.value), {
|
||||
editedSha: newSha
|
||||
editedSha: newSha,
|
||||
path: path.value
|
||||
})
|
||||
initialRawContent.value = rawContent.value
|
||||
advanceStackTo(newSha)
|
||||
}
|
||||
|
||||
// On a save/freshness conflict, try a 3-way merge first: if our edits and the
|
||||
// remote ones don't overlap, commit the merge silently (just a toast) so the
|
||||
// user is never interrupted. Only genuinely overlapping edits open the modal.
|
||||
const handleConflict = async () => {
|
||||
if (!path.value) return
|
||||
|
||||
const sources = await resolveMergeSources()
|
||||
if (sources) {
|
||||
const { clean, merged } = threeWayMerge(
|
||||
sources.base,
|
||||
rawContent.value,
|
||||
sources.theirs
|
||||
)
|
||||
if (clean) {
|
||||
const { sha: newSha, conflict } = await updateFile({
|
||||
content: merged,
|
||||
path: path.value,
|
||||
sha: sources.remoteSha,
|
||||
successMessage: "✅ Merged remote changes & saved"
|
||||
})
|
||||
if (!conflict && newSha) {
|
||||
rawContent.value = merged
|
||||
await saveCacheNote(encodeUTF8ToBase64(merged), {
|
||||
editedSha: newSha,
|
||||
path: path.value
|
||||
})
|
||||
initialRawContent.value = merged
|
||||
advanceStackTo(newSha)
|
||||
return
|
||||
}
|
||||
// Remote moved again between fetch and commit — fall back to the modal.
|
||||
}
|
||||
}
|
||||
|
||||
// Overlapping edits, or merge sources unavailable — let the user decide.
|
||||
// Ensure latestSha is set so the modal's "overwrite" has a target.
|
||||
if (!latestSha.value) await checkFreshness()
|
||||
conflictOpen.value = true
|
||||
if (mode.value === "read") toggleMode()
|
||||
}
|
||||
|
||||
watch(mode, async (newMode) => {
|
||||
@@ -216,8 +300,7 @@ watch(mode, async (newMode) => {
|
||||
return
|
||||
}
|
||||
|
||||
const hasUserFinishedToEdit =
|
||||
newMode === "read" && rawContent.value !== initialRawContent.value
|
||||
const hasUserFinishedToEdit = newMode === "read" && isDirty.value
|
||||
|
||||
if (!hasUserFinishedToEdit) {
|
||||
return
|
||||
@@ -229,7 +312,7 @@ watch(mode, async (newMode) => {
|
||||
|
||||
await checkFreshness()
|
||||
if (freshnessStatus.value === "outdated") {
|
||||
conflictOpen.value = true
|
||||
await handleConflict()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -241,6 +324,7 @@ const onConflictDiscard = async () => {
|
||||
if (raw !== null) {
|
||||
rawContent.value = raw
|
||||
initialRawContent.value = raw
|
||||
advanceStackTo(latestSha.value)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -264,9 +348,9 @@ const onBadgeClick = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
const hasUnsavedEdits = rawContent.value !== initialRawContent.value
|
||||
const hasUnsavedEdits = isDirty.value
|
||||
if (hasUnsavedEdits) {
|
||||
conflictOpen.value = true
|
||||
await handleConflict()
|
||||
return
|
||||
}
|
||||
|
||||
@@ -274,6 +358,7 @@ const onBadgeClick = async () => {
|
||||
if (raw !== null) {
|
||||
rawContent.value = raw
|
||||
initialRawContent.value = raw
|
||||
advanceStackTo(latestSha.value)
|
||||
return
|
||||
}
|
||||
if (failureStatus === "unauthorized") {
|
||||
@@ -304,7 +389,7 @@ const onBadgeClick = async () => {
|
||||
class="action"
|
||||
/>
|
||||
<button
|
||||
v-if="isMarkdown && canPush"
|
||||
v-if="isMarkdown && canPush && loadStatus === 'ready'"
|
||||
class="action button is-text is-light"
|
||||
:class="{ 'is-link': mode === 'edit' }"
|
||||
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
|
||||
@@ -411,11 +496,28 @@ const onBadgeClick = async () => {
|
||||
<div v-if="mode === 'edit' && isMarkdown" class="edit">
|
||||
<edit-note :key="editKey" v-model="rawContent" />
|
||||
</div>
|
||||
<div
|
||||
v-if="mode === 'read'"
|
||||
class="note-content"
|
||||
v-html="displayedContent"
|
||||
></div>
|
||||
<template v-else-if="mode === 'read'">
|
||||
<div v-if="newerSha" class="snapshot-banner">
|
||||
<span>You're viewing an older shared version.</span>
|
||||
<button
|
||||
type="button"
|
||||
class="btn bt-sm btn-soft btn-info"
|
||||
@click="viewLatest"
|
||||
>
|
||||
View latest
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
v-if="displayedContent"
|
||||
class="note-content"
|
||||
v-html="displayedContent"
|
||||
></div>
|
||||
<note-state
|
||||
v-else
|
||||
:status="loadStatus === 'failed' ? 'failed' : 'loading'"
|
||||
@retry="loadNote"
|
||||
/>
|
||||
</template>
|
||||
</section>
|
||||
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
|
||||
<note-conflict-modal
|
||||
@@ -486,6 +588,18 @@ $border-color: rgba(18, 19, 58, 0.2);
|
||||
display: none;
|
||||
}
|
||||
|
||||
.snapshot-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
margin: 0 0 1rem;
|
||||
padding: 0.4rem 0.75rem;
|
||||
border: 1px solid $border-color;
|
||||
border-radius: 0.375rem;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
|
||||
.action {
|
||||
margin: 0;
|
||||
|
||||
|
||||
@@ -79,7 +79,8 @@ const content = computed(() =>
|
||||
|
||||
useMarkdownPostRender(content, () => `.note-${classNameId.value}`, {
|
||||
onReady: () => listenToClick(),
|
||||
tikz: true
|
||||
tikz: true,
|
||||
macroplan: true
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
582
src/components/TodoTxtItem.vue
Normal file
582
src/components/TodoTxtItem.vue
Normal file
@@ -0,0 +1,582 @@
|
||||
<script setup lang="ts">
|
||||
import { onClickOutside } from "@vueuse/core"
|
||||
import { computed, nextTick, ref, useTemplateRef, watch } from "vue"
|
||||
|
||||
import {
|
||||
removeTagFromBody,
|
||||
removeTokenFromBody,
|
||||
segmentBody,
|
||||
Task,
|
||||
toggleCompleted
|
||||
} from "@/utils/todotxt"
|
||||
|
||||
const props = defineProps<{
|
||||
task: Task
|
||||
canEdit: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
update: [task: Task]
|
||||
delete: []
|
||||
}>()
|
||||
|
||||
const segments = computed(() => segmentBody(props.task.body))
|
||||
|
||||
const PRIORITIES = ["A", "B", "C", "D"] as const
|
||||
|
||||
const priorityBadgeClassFor = (priority?: string): string => {
|
||||
const preClass = "badge badge-soft"
|
||||
|
||||
switch (priority) {
|
||||
case "A":
|
||||
return `${preClass} badge-error`
|
||||
case "B":
|
||||
return `${preClass} badge-warning`
|
||||
case "C":
|
||||
return `${preClass} badge-info`
|
||||
default:
|
||||
return `${preClass} badge-ghost`
|
||||
}
|
||||
}
|
||||
|
||||
const priorityBadgeClass = computed(() =>
|
||||
priorityBadgeClassFor(props.task.priority)
|
||||
)
|
||||
|
||||
const toggle = () => {
|
||||
if (!props.canEdit) return
|
||||
emit("update", toggleCompleted(props.task))
|
||||
}
|
||||
|
||||
const priorityOpen = ref(false)
|
||||
const priorityDropdown = useTemplateRef<HTMLDivElement>("priorityDropdown")
|
||||
onClickOutside(priorityDropdown, () => {
|
||||
priorityOpen.value = false
|
||||
})
|
||||
|
||||
const togglePriorityMenu = () => {
|
||||
if (!props.canEdit) return
|
||||
priorityOpen.value = !priorityOpen.value
|
||||
}
|
||||
|
||||
const setPriority = (priority: string | undefined) => {
|
||||
if (!props.canEdit) return
|
||||
emit("update", { ...props.task, priority })
|
||||
priorityOpen.value = false
|
||||
}
|
||||
|
||||
const removeToken = (kind: "project" | "context", value: string) => {
|
||||
if (!props.canEdit) return
|
||||
const newBody = removeTokenFromBody(
|
||||
props.task.body,
|
||||
kind === "project" ? "+" : "@",
|
||||
value
|
||||
)
|
||||
emit("update", { ...props.task, body: newBody })
|
||||
}
|
||||
|
||||
const removeTag = (key: "due" | "rec", value: string) => {
|
||||
if (!props.canEdit) return
|
||||
const newBody = removeTagFromBody(props.task.body, key, value)
|
||||
emit("update", { ...props.task, body: newBody })
|
||||
}
|
||||
|
||||
const startOfToday = (): Date => {
|
||||
const now = new Date()
|
||||
return new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
}
|
||||
|
||||
const parseDueDate = (value: string): Date | null => {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) return null
|
||||
const [y, m, d] = value.split("-").map(Number)
|
||||
return new Date(y, m - 1, d)
|
||||
}
|
||||
|
||||
const dueClass = (value: string): string => {
|
||||
const due = parseDueDate(value)
|
||||
if (!due) return "badge badge-soft badge-accent"
|
||||
const today = startOfToday().getTime()
|
||||
if (due.getTime() < today) return "badge badge-soft badge-error"
|
||||
if (due.getTime() === today) return "badge badge-soft badge-warning"
|
||||
return "badge badge-soft badge-accent"
|
||||
}
|
||||
|
||||
const MS_PER_DAY = 86_400_000
|
||||
|
||||
const formatDueDate = (value: string): string => {
|
||||
const due = parseDueDate(value)
|
||||
if (!due) return value
|
||||
const diff = Math.round(
|
||||
(due.getTime() - startOfToday().getTime()) / MS_PER_DAY
|
||||
)
|
||||
if (diff === 0) return "Today"
|
||||
if (diff === 1) return "Tomorrow"
|
||||
if (diff === -1) return "Yesterday"
|
||||
return due.toLocaleDateString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric"
|
||||
})
|
||||
}
|
||||
|
||||
const dueTitle = (value: string): string => {
|
||||
const due = parseDueDate(value)
|
||||
if (!due) return `Due ${value}`
|
||||
return `Due ${due.toLocaleDateString(undefined, {
|
||||
weekday: "long",
|
||||
year: "numeric",
|
||||
month: "long",
|
||||
day: "numeric"
|
||||
})}`
|
||||
}
|
||||
|
||||
const REC_UNITS: Record<string, [string, string]> = {
|
||||
d: ["day", "days"],
|
||||
w: ["week", "weeks"],
|
||||
m: ["month", "months"],
|
||||
y: ["year", "years"]
|
||||
}
|
||||
const REC_ADVERBS: Record<string, string> = {
|
||||
d: "Daily",
|
||||
w: "Weekly",
|
||||
m: "Monthly",
|
||||
y: "Yearly"
|
||||
}
|
||||
|
||||
const formatRecurrence = (value: string): string => {
|
||||
const m = value.match(/^\+?(\d+)([dwmyDWMY])$/)
|
||||
if (!m) return value
|
||||
const n = parseInt(m[1], 10)
|
||||
const unit = m[2].toLowerCase()
|
||||
if (n === 1) return REC_ADVERBS[unit] ?? value
|
||||
const [, plural] = REC_UNITS[unit]
|
||||
return `Every ${n} ${plural}`
|
||||
}
|
||||
|
||||
const recTitle = (value: string): string => {
|
||||
const isStrict = value.startsWith("+")
|
||||
const formatted = formatRecurrence(value)
|
||||
return isStrict
|
||||
? `${formatted} (from due date)`
|
||||
: `${formatted} (from completion)`
|
||||
}
|
||||
|
||||
const editing = ref(false)
|
||||
const bodyDraft = ref("")
|
||||
const inputRef = useTemplateRef<HTMLInputElement>("bodyInput")
|
||||
|
||||
const startEdit = () => {
|
||||
if (!props.canEdit || props.task.completed) return
|
||||
bodyDraft.value = props.task.body
|
||||
editing.value = true
|
||||
nextTick(() => {
|
||||
inputRef.value?.focus()
|
||||
inputRef.value?.select()
|
||||
})
|
||||
}
|
||||
|
||||
const commitEdit = () => {
|
||||
if (!editing.value) return
|
||||
const next = bodyDraft.value.trim()
|
||||
editing.value = false
|
||||
if (next !== props.task.body) {
|
||||
emit("update", { ...props.task, body: next })
|
||||
}
|
||||
}
|
||||
|
||||
const cancelEdit = () => {
|
||||
editing.value = false
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.task.body,
|
||||
() => {
|
||||
if (editing.value) editing.value = false
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="todo-row group flex items-start gap-2 py-1 px-2 rounded hover:bg-base-200"
|
||||
:class="{ 'opacity-60': task.completed }"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="checkbox checkbox-success checkbox-sm mt-1"
|
||||
:checked="task.completed"
|
||||
:disabled="!canEdit"
|
||||
@change="toggle"
|
||||
/>
|
||||
|
||||
<div
|
||||
ref="priorityDropdown"
|
||||
class="todo-priority"
|
||||
:class="{ 'todo-priority--open': priorityOpen }"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:class="priorityBadgeClass"
|
||||
class="todo-priority-trigger cursor-pointer w-7 justify-center mt-0.5"
|
||||
:disabled="!canEdit"
|
||||
@click="togglePriorityMenu"
|
||||
>
|
||||
{{ task.priority ?? "—" }}
|
||||
</button>
|
||||
<div
|
||||
v-if="canEdit && priorityOpen"
|
||||
class="todo-priority-menu bg-base-200 rounded-box shadow p-2"
|
||||
>
|
||||
<button
|
||||
v-for="p in PRIORITIES"
|
||||
:key="p"
|
||||
type="button"
|
||||
:class="priorityBadgeClassFor(p)"
|
||||
class="todo-priority-option w-10 justify-center cursor-pointer"
|
||||
@click="setPriority(p)"
|
||||
>
|
||||
({{ p }})
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="priorityBadgeClassFor(undefined)"
|
||||
class="todo-priority-option w-10 justify-center cursor-pointer"
|
||||
@click="setPriority(undefined)"
|
||||
>
|
||||
—
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<input
|
||||
v-if="editing"
|
||||
ref="bodyInput"
|
||||
v-model="bodyDraft"
|
||||
type="text"
|
||||
class="input input-ghost input-sm w-full"
|
||||
@blur="commitEdit"
|
||||
@keydown.enter.prevent="commitEdit"
|
||||
@keydown.escape.prevent="cancelEdit"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="body-display cursor-text"
|
||||
:class="{ 'line-through': task.completed }"
|
||||
@click="startEdit"
|
||||
>
|
||||
<template v-for="(seg, i) in segments" :key="i">
|
||||
<span v-if="seg.kind === 'text'">{{ seg.value }}</span>
|
||||
<span
|
||||
v-else-if="seg.kind === 'project'"
|
||||
class="todo-chip badge badge-soft badge-primary mx-0.5"
|
||||
@click.stop
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-plus"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 5l0 14" />
|
||||
<path d="M5 12l14 0" />
|
||||
</svg>
|
||||
{{ seg.value }}
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="todo-chip-close"
|
||||
type="button"
|
||||
:aria-label="`Remove project ${seg.value}`"
|
||||
@click.stop="removeToken('project', seg.value)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-x"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M18 6l-12 12" />
|
||||
<path d="M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="seg.kind === 'context'"
|
||||
class="todo-chip badge badge-soft badge-secondary mx-0.5"
|
||||
@click.stop
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-at"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M12 12m-4 0a4 4 0 1 0 8 0a4 4 0 1 0 -8 0" />
|
||||
<path d="M16 12v1.5a2.5 2.5 0 0 0 5 0v-1.5a9 9 0 1 0 -5.5 8.28" />
|
||||
</svg>
|
||||
{{ seg.value }}
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="todo-chip-close"
|
||||
type="button"
|
||||
:aria-label="`Remove context ${seg.value}`"
|
||||
@click.stop="removeToken('context', seg.value)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-x"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M18 6l-12 12" />
|
||||
<path d="M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-else-if="seg.kind === 'due'"
|
||||
:class="dueClass(seg.value) + ' mx-0.5 todo-chip'"
|
||||
:title="dueTitle(seg.value)"
|
||||
@click.stop
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-calendar"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path
|
||||
d="M4 7a2 2 0 0 1 2 -2h12a2 2 0 0 1 2 2v12a2 2 0 0 1 -2 2h-12a2 2 0 0 1 -2 -2z"
|
||||
/>
|
||||
<path d="M16 3v4" />
|
||||
<path d="M8 3v4" />
|
||||
<path d="M4 11h16" />
|
||||
</svg>
|
||||
{{ formatDueDate(seg.value) }}
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="todo-chip-close"
|
||||
type="button"
|
||||
:aria-label="`Remove due date ${seg.value}`"
|
||||
@click.stop="removeTag('due', seg.value)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-x"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M18 6l-12 12" />
|
||||
<path d="M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="todo-chip badge badge-soft badge-info mx-0.5"
|
||||
:title="recTitle(seg.value)"
|
||||
@click.stop
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-repeat"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 12v-3a3 3 0 0 1 3 -3h13m-3 -3l3 3l-3 3" />
|
||||
<path d="M20 12v3a3 3 0 0 1 -3 3h-13m3 3l-3 -3l3 -3" />
|
||||
</svg>
|
||||
{{ formatRecurrence(seg.value) }}
|
||||
<button
|
||||
v-if="canEdit"
|
||||
class="todo-chip-close"
|
||||
type="button"
|
||||
:aria-label="`Remove recurrence ${seg.value}`"
|
||||
@click.stop="removeTag('rec', seg.value)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-x"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="2"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M18 6l-12 12" />
|
||||
<path d="M6 6l12 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</span>
|
||||
</template>
|
||||
<span v-if="task.body.length === 0" class="opacity-50 italic">
|
||||
(empty task)
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="canEdit"
|
||||
type="button"
|
||||
class="btn btn-ghost btn-xs opacity-0 group-hover:opacity-100 transition"
|
||||
aria-label="Delete task"
|
||||
@click="emit('delete')"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
class="icon icon-tabler icon-tabler-trash"
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
stroke-width="1.75"
|
||||
stroke="currentColor"
|
||||
fill="none"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path stroke="none" d="M0 0h24v24H0z" fill="none" />
|
||||
<path d="M4 7h16" />
|
||||
<path d="M10 11v6" />
|
||||
<path d="M14 11v6" />
|
||||
<path d="M5 7l1 12a2 2 0 0 0 2 2h8a2 2 0 0 0 2 -2l1 -12" />
|
||||
<path d="M9 7v-3a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped lang="scss">
|
||||
.body-display {
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.todo-priority {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
// Lift the whole priority widget above sibling rows while its menu is open,
|
||||
// otherwise the absolutely-positioned menu paints behind row N+1's content.
|
||||
// 1 is enough: no other element in this view sets a non-auto z-index.
|
||||
.todo-priority--open {
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.todo-priority-trigger {
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.todo-priority-menu {
|
||||
position: absolute;
|
||||
top: 100%;
|
||||
left: 0;
|
||||
margin-top: 0.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: auto;
|
||||
}
|
||||
|
||||
.todo-priority-option {
|
||||
border: 0;
|
||||
font: inherit;
|
||||
transition:
|
||||
transform 120ms ease,
|
||||
filter 120ms ease;
|
||||
|
||||
&:hover {
|
||||
filter: brightness(1.1);
|
||||
transform: scale(1.05);
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.todo-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.2rem;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
|
||||
.todo-chip-close {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
margin-left: 0.1rem;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 0;
|
||||
color: inherit;
|
||||
opacity: 0.7;
|
||||
|
||||
&:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -381,10 +381,10 @@ onMounted(() => {
|
||||
comes to rest.
|
||||
</h1>
|
||||
<p class="lede">
|
||||
Point it at any markdown — a GitHub repo of your own, or public
|
||||
notes from the open ATProto network — and Remanso turns it into a
|
||||
calm, stackable notebook with backlinks where your thinking
|
||||
finally runs clear.
|
||||
Point it at any markdown: a GitHub repo of your own, or public
|
||||
notes from the open ATProto network. Remanso turns it into a
|
||||
stackable notebook with backlinks where your thinking finally
|
||||
runs clear.
|
||||
</p>
|
||||
<div class="hero-ed-paths">
|
||||
<!-- CTA 01: GitHub repo -->
|
||||
@@ -472,7 +472,7 @@ onMounted(() => {
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="publicNotesLoadFailed" class="network-empty">
|
||||
The pool is quiet right now.
|
||||
Nothing has drifted in yet.
|
||||
</div>
|
||||
<router-link
|
||||
:to="{ name: 'PublicNoteListView' }"
|
||||
|
||||
@@ -2,6 +2,7 @@ import { computed, Ref, ref, toValue } from "vue"
|
||||
|
||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
||||
import { latestShaIfOlder } from "@/modules/note/snapshotStatus"
|
||||
import { queryFileContent } from "@/modules/repo/services/repo"
|
||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||
|
||||
@@ -14,6 +15,16 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
|
||||
return file?.path
|
||||
})
|
||||
|
||||
// Path recovered from the cached note doc — lets us locate the latest version
|
||||
// even when this (old) sha is no longer in store.files.
|
||||
const cachedNotePath = ref<string | undefined>()
|
||||
|
||||
// When viewing an older snapshot of a known note, the note's current sha
|
||||
// (null when already viewing the latest).
|
||||
const newerSha = computed(() =>
|
||||
latestShaIfOlder(shaValue, path.value ?? cachedNotePath.value, store.files)
|
||||
)
|
||||
|
||||
const {
|
||||
render,
|
||||
renderFromUTF8,
|
||||
@@ -47,6 +58,8 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
|
||||
fromCache.value = !!cachedNote
|
||||
|
||||
if (cachedNote) {
|
||||
cachedNotePath.value = cachedNote.path ?? cachedNotePath.value
|
||||
|
||||
if (from === "path") {
|
||||
queryFileContent(store.user, store.repo, shaValue).then(
|
||||
(fileContent) => {
|
||||
@@ -104,6 +117,7 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
|
||||
|
||||
return {
|
||||
path,
|
||||
newerSha,
|
||||
content,
|
||||
rawContent,
|
||||
getRawContent,
|
||||
|
||||
197
src/hooks/useGitHubContent.hook.spec.ts
Normal file
197
src/hooks/useGitHubContent.hook.spec.ts
Normal file
@@ -0,0 +1,197 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
vi.mock("@/modules/repo/services/octo", () => ({
|
||||
getOctokit: vi.fn(),
|
||||
runWithAuthRetry: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/notif", () => ({
|
||||
confirmMessage: vi.fn(),
|
||||
errorMessage: vi.fn()
|
||||
}))
|
||||
|
||||
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
|
||||
import { confirmMessage, errorMessage } from "@/utils/notif"
|
||||
|
||||
import { useGitHubContent } from "./useGitHubContent.hook"
|
||||
|
||||
const make = () => useGitHubContent({ user: "alice", repo: "notes" })
|
||||
|
||||
describe("useGitHubContent.fetchLatestSha", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(runWithAuthRetry).mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("returns the sha from a file response", async () => {
|
||||
vi.mocked(runWithAuthRetry).mockResolvedValue({
|
||||
data: { sha: "abc123" }
|
||||
} as never)
|
||||
|
||||
expect(await make().fetchLatestSha("a.md")).toEqual({
|
||||
kind: "ok",
|
||||
sha: "abc123"
|
||||
})
|
||||
})
|
||||
|
||||
it("returns sha=null when the path resolves to a directory (array response)", async () => {
|
||||
vi.mocked(runWithAuthRetry).mockResolvedValue({ data: [] } as never)
|
||||
|
||||
expect(await make().fetchLatestSha("dir/")).toEqual({
|
||||
kind: "ok",
|
||||
sha: null
|
||||
})
|
||||
})
|
||||
|
||||
it("returns sha=null when the data object has no sha field", async () => {
|
||||
vi.mocked(runWithAuthRetry).mockResolvedValue({
|
||||
data: { type: "submodule" }
|
||||
} as never)
|
||||
|
||||
expect(await make().fetchLatestSha("path")).toEqual({
|
||||
kind: "ok",
|
||||
sha: null
|
||||
})
|
||||
})
|
||||
|
||||
it("returns kind=unauthorized on 401", async () => {
|
||||
vi.mocked(runWithAuthRetry).mockRejectedValue(
|
||||
Object.assign(new Error("Unauthorized"), { status: 401 })
|
||||
)
|
||||
|
||||
expect(await make().fetchLatestSha("path")).toEqual({
|
||||
kind: "unauthorized"
|
||||
})
|
||||
})
|
||||
|
||||
it("returns kind=offline on non-401 errors (404, network, etc.)", async () => {
|
||||
vi.mocked(runWithAuthRetry).mockRejectedValue(
|
||||
Object.assign(new Error("Not found"), { status: 404 })
|
||||
)
|
||||
expect(await make().fetchLatestSha("path")).toEqual({ kind: "offline" })
|
||||
|
||||
vi.mocked(runWithAuthRetry).mockRejectedValue(new Error("network"))
|
||||
expect(await make().fetchLatestSha("path")).toEqual({ kind: "offline" })
|
||||
})
|
||||
})
|
||||
|
||||
describe("useGitHubContent.updateFile / createFile (putFile)", () => {
|
||||
const mockOctokitRequest = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
mockOctokitRequest.mockReset()
|
||||
vi.mocked(getOctokit).mockResolvedValue({
|
||||
request: mockOctokitRequest
|
||||
} as never)
|
||||
vi.mocked(confirmMessage).mockReset()
|
||||
vi.mocked(errorMessage).mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("returns the new sha and calls confirmMessage on success", async () => {
|
||||
mockOctokitRequest.mockResolvedValue({
|
||||
data: { content: { sha: "new-sha" } }
|
||||
})
|
||||
|
||||
const result = await make().updateFile({
|
||||
content: "# hello",
|
||||
path: "a.md",
|
||||
sha: "old-sha"
|
||||
})
|
||||
|
||||
expect(result).toEqual({ sha: "new-sha", conflict: false })
|
||||
expect(confirmMessage).toHaveBeenCalledWith("✅ Note saved")
|
||||
})
|
||||
|
||||
it("flags conflict=true on 409 and 422 responses", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
|
||||
for (const status of [409, 422]) {
|
||||
mockOctokitRequest.mockRejectedValueOnce(
|
||||
Object.assign(new Error("Conflict"), { status })
|
||||
)
|
||||
const result = await make().createFile({ content: "x", path: "a.md" })
|
||||
expect(result).toEqual({ sha: null, conflict: true })
|
||||
}
|
||||
expect(errorMessage).toHaveBeenCalledWith(
|
||||
"⚠ Conflict: this note changed on GitHub"
|
||||
)
|
||||
})
|
||||
|
||||
it("returns conflict=false and calls errorMessage on non-conflict errors", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
mockOctokitRequest.mockRejectedValue(
|
||||
Object.assign(new Error("Server error"), { status: 500 })
|
||||
)
|
||||
|
||||
const result = await make().updateFile({
|
||||
content: "x",
|
||||
path: "a.md",
|
||||
sha: "old"
|
||||
})
|
||||
|
||||
expect(result).toEqual({ sha: null, conflict: false })
|
||||
expect(errorMessage).toHaveBeenCalledWith("❌ Note could not be saved")
|
||||
})
|
||||
})
|
||||
|
||||
describe("useGitHubContent.uploadBinaryFile", () => {
|
||||
const mockOctokitRequest = vi.fn()
|
||||
|
||||
beforeEach(() => {
|
||||
mockOctokitRequest.mockReset()
|
||||
vi.mocked(getOctokit).mockResolvedValue({
|
||||
request: mockOctokitRequest
|
||||
} as never)
|
||||
vi.mocked(errorMessage).mockReset()
|
||||
vi.mocked(confirmMessage).mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("uploads pre-base64-encoded content without re-encoding", async () => {
|
||||
mockOctokitRequest.mockResolvedValue({
|
||||
data: { content: { sha: "img-sha" } }
|
||||
})
|
||||
|
||||
const result = await make().uploadBinaryFile({
|
||||
base64: "ZGF0YQ==",
|
||||
path: "img/cover.png"
|
||||
})
|
||||
|
||||
expect(result).toEqual({ sha: "img-sha", conflict: false })
|
||||
expect(mockOctokitRequest).toHaveBeenCalledWith(
|
||||
"PUT /repos/{owner}/{repo}/contents/{+path}",
|
||||
expect.objectContaining({
|
||||
path: "img/cover.png",
|
||||
content: "ZGF0YQ=="
|
||||
})
|
||||
)
|
||||
expect(confirmMessage).toHaveBeenCalledWith("✅ Image uploaded")
|
||||
})
|
||||
|
||||
it("surfaces a conflict on existing file (422)", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
mockOctokitRequest.mockRejectedValue(
|
||||
Object.assign(new Error("Exists"), { status: 422 })
|
||||
)
|
||||
|
||||
const result = await make().uploadBinaryFile({
|
||||
base64: "Zm9v",
|
||||
path: "img.png"
|
||||
})
|
||||
|
||||
expect(result).toEqual({ sha: null, conflict: true })
|
||||
expect(errorMessage).toHaveBeenCalledWith(
|
||||
"⚠ A file already exists at this path on GitHub"
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -88,18 +88,20 @@ export const useGitHubContent = ({
|
||||
const putFile = async ({
|
||||
content,
|
||||
path,
|
||||
sha
|
||||
sha,
|
||||
successMessage = "✅ Note saved"
|
||||
}: {
|
||||
content: string
|
||||
path: string
|
||||
sha?: string
|
||||
successMessage?: string
|
||||
}): Promise<{ sha: string | null; conflict: boolean }> =>
|
||||
putRaw({
|
||||
contentBase64: encodeUTF8ToBase64(content),
|
||||
path,
|
||||
sha,
|
||||
message: `Updating ${path} from Remanso`,
|
||||
successMessage: "✅ Note saved",
|
||||
successMessage,
|
||||
conflictMessage: "⚠ Conflict: this note changed on GitHub",
|
||||
failureMessage: "❌ Note could not be saved"
|
||||
})
|
||||
@@ -126,6 +128,7 @@ export const useGitHubContent = ({
|
||||
content: string
|
||||
path: string
|
||||
sha: string
|
||||
successMessage?: string
|
||||
}) => putFile(props),
|
||||
createFile: async (props: { content: string; path: string }) =>
|
||||
putFile(props),
|
||||
|
||||
19
src/hooks/useImageLightbox.hook.ts
Normal file
19
src/hooks/useImageLightbox.hook.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { ref } from "vue"
|
||||
|
||||
const isOpen = ref(false)
|
||||
const src = ref("")
|
||||
const alt = ref("")
|
||||
|
||||
export const useImageLightbox = () => {
|
||||
const open = (imageSrc: string, imageAlt = "") => {
|
||||
src.value = imageSrc
|
||||
alt.value = imageAlt
|
||||
isOpen.value = true
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
isOpen.value = false
|
||||
}
|
||||
|
||||
return { isOpen, src, alt, open, close }
|
||||
}
|
||||
164
src/hooks/useImageUpload.hook.spec.ts
Normal file
164
src/hooks/useImageUpload.hook.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
import { createPinia, setActivePinia } from "pinia"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
const uploadBinaryFile = vi.fn()
|
||||
const registerUploadedFile = vi.fn()
|
||||
|
||||
vi.mock("@/hooks/useGitHubContent.hook", () => ({
|
||||
useGitHubContent: () => ({ uploadBinaryFile })
|
||||
}))
|
||||
|
||||
vi.mock("@/modules/repo/store/userRepo.store", () => ({
|
||||
useUserRepoStore: () => ({
|
||||
files: [{ path: "alice/notes/already-here.png", sha: "x" }],
|
||||
registerUploadedFile
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock("@/utils/notif", () => ({
|
||||
errorMessage: vi.fn()
|
||||
}))
|
||||
|
||||
import { errorMessage } from "@/utils/notif"
|
||||
|
||||
import { useImageUpload } from "./useImageUpload.hook"
|
||||
|
||||
const makeFile = (name: string, body = "fake-image-bytes") =>
|
||||
new File([body], name, { type: "image/png" })
|
||||
|
||||
describe("useImageUpload", () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
uploadBinaryFile.mockReset()
|
||||
registerUploadedFile.mockReset()
|
||||
vi.mocked(errorMessage).mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("returns null and shows an error when notePath is missing", async () => {
|
||||
const { uploadImage } = useImageUpload({
|
||||
user: "alice",
|
||||
repo: "notes",
|
||||
notePath: undefined
|
||||
})
|
||||
|
||||
expect(await uploadImage(makeFile("img.png"))).toBeNull()
|
||||
expect(errorMessage).toHaveBeenCalledWith("❌ Image upload failed")
|
||||
expect(uploadBinaryFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("uploads to the note's directory using the note basename and the file extension", async () => {
|
||||
uploadBinaryFile.mockResolvedValue({ sha: "new-sha", conflict: false })
|
||||
|
||||
const { uploadImage } = useImageUpload({
|
||||
user: "alice",
|
||||
repo: "notes",
|
||||
notePath: "alice/notes/my-note.md"
|
||||
})
|
||||
|
||||
const result = await uploadImage(makeFile("photo.PNG"))
|
||||
|
||||
expect(result).toEqual({ filename: "my-note.png" })
|
||||
expect(uploadBinaryFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: "alice/notes/my-note.png"
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
it("deduplicates filenames against existing files in the store", async () => {
|
||||
uploadBinaryFile.mockResolvedValue({ sha: "new-sha", conflict: false })
|
||||
|
||||
const { uploadImage } = useImageUpload({
|
||||
user: "alice",
|
||||
repo: "notes",
|
||||
notePath: "alice/notes/already-here.md"
|
||||
})
|
||||
|
||||
const result = await uploadImage(makeFile("photo.png"))
|
||||
|
||||
expect(result?.filename).toBe("already-here-2.png")
|
||||
})
|
||||
|
||||
it("returns null on conflict and does NOT register the file", async () => {
|
||||
uploadBinaryFile.mockResolvedValue({ sha: null, conflict: true })
|
||||
|
||||
const { uploadImage } = useImageUpload({
|
||||
user: "alice",
|
||||
repo: "notes",
|
||||
notePath: "x/note.md"
|
||||
})
|
||||
|
||||
expect(await uploadImage(makeFile("img.png"))).toBeNull()
|
||||
expect(registerUploadedFile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("registers the uploaded file in the store on success", async () => {
|
||||
uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false })
|
||||
const file = makeFile("img.png", "data")
|
||||
|
||||
const { uploadImage } = useImageUpload({
|
||||
user: "alice",
|
||||
repo: "notes",
|
||||
notePath: "x/note.md"
|
||||
})
|
||||
|
||||
await uploadImage(file)
|
||||
|
||||
expect(registerUploadedFile).toHaveBeenCalledWith({
|
||||
path: "x/note.png",
|
||||
sha: "abc",
|
||||
type: "blob",
|
||||
size: file.size
|
||||
})
|
||||
})
|
||||
|
||||
it("falls back to .png when the file name has no extension", async () => {
|
||||
uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false })
|
||||
|
||||
const { uploadImage } = useImageUpload({
|
||||
user: "alice",
|
||||
repo: "notes",
|
||||
notePath: "x/note.md"
|
||||
})
|
||||
|
||||
await uploadImage(makeFile("noextension"))
|
||||
|
||||
expect(uploadBinaryFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: "x/note.png" })
|
||||
)
|
||||
})
|
||||
|
||||
it("uses the root path when the note is at the repo root", async () => {
|
||||
uploadBinaryFile.mockResolvedValue({ sha: "abc", conflict: false })
|
||||
|
||||
const { uploadImage } = useImageUpload({
|
||||
user: "alice",
|
||||
repo: "notes",
|
||||
notePath: "root.md"
|
||||
})
|
||||
|
||||
await uploadImage(makeFile("img.png"))
|
||||
|
||||
expect(uploadBinaryFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ path: "root.png" })
|
||||
)
|
||||
})
|
||||
|
||||
it("returns null and notifies on unexpected errors", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
uploadBinaryFile.mockRejectedValue(new Error("boom"))
|
||||
|
||||
const { uploadImage } = useImageUpload({
|
||||
user: "alice",
|
||||
repo: "notes",
|
||||
notePath: "x/note.md"
|
||||
})
|
||||
|
||||
expect(await uploadImage(makeFile("img.png"))).toBeNull()
|
||||
expect(errorMessage).toHaveBeenCalledWith("❌ Image upload failed")
|
||||
})
|
||||
})
|
||||
42
src/hooks/useMarkdown.hook.spec.ts
Normal file
42
src/hooks/useMarkdown.hook.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
|
||||
vi.mock("@/data/data", () => ({
|
||||
data: {},
|
||||
generateId: (type: string, id: string) => `${type}-${id}`
|
||||
}))
|
||||
|
||||
import { renderCodeFile } from "@/hooks/useMarkdown.hook"
|
||||
|
||||
describe("renderCodeFile", () => {
|
||||
it("wraps highlighted files in a code-file container with a sized gutter", async () => {
|
||||
const html = await renderCodeFile({
|
||||
rawContent: "const a = 1\nconst b = 2\n",
|
||||
lang: "typescript",
|
||||
filename: "example.ts"
|
||||
})
|
||||
|
||||
expect(html).toContain('<div class="code-file"')
|
||||
expect(html).toContain("--line-number-width:1ch")
|
||||
expect(html.match(/class="line"/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("wraps each line of unknown-language files in line spans", async () => {
|
||||
const html = await renderCodeFile({
|
||||
rawContent: "line one\nline <two>\n",
|
||||
lang: null
|
||||
})
|
||||
|
||||
expect(html).toContain('<span class="line">line one</span>')
|
||||
expect(html).toContain('<span class="line">line <two></span>')
|
||||
expect(html.match(/class="line"/g)).toHaveLength(2)
|
||||
})
|
||||
|
||||
it("sizes the gutter for files with many lines", async () => {
|
||||
const html = await renderCodeFile({
|
||||
rawContent: Array.from({ length: 120 }, (_, i) => `line ${i}`).join("\n"),
|
||||
lang: null
|
||||
})
|
||||
|
||||
expect(html).toContain("--line-number-width:3ch")
|
||||
})
|
||||
})
|
||||
@@ -13,7 +13,7 @@ import markdownItIframe from "markdown-it-iframe"
|
||||
import Shikiji from "markdown-it-shikiji"
|
||||
import mermaid from "mermaid"
|
||||
import type { LanguageRegistration } from "shikiji-core"
|
||||
import { Ref, toValue } from "vue"
|
||||
import { Ref, ref, toValue } from "vue"
|
||||
|
||||
import { data } from "@/data/data"
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
} from "@/utils/decodeBase64ToUTF8"
|
||||
import { html5Media } from "@/utils/markdown/markdown-html5-media"
|
||||
import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons"
|
||||
import { renderFallback } from "@/utils/markdown/renderFallback"
|
||||
|
||||
const TIKZ_BUNDLE_URL =
|
||||
"https://cdn.jsdelivr.net/gh/artisticat1/obsidian-tikzjax@0.5.2/tikzjax.js"
|
||||
@@ -94,6 +95,37 @@ const markdownItTikzExtractor = (md: MarkdownIt) => {
|
||||
}
|
||||
}
|
||||
|
||||
const markdownItMacroplanExtractor = (md: MarkdownIt) => {
|
||||
const defaultFence =
|
||||
md.renderer.rules.fence ||
|
||||
function (
|
||||
tokens: Array<Token>,
|
||||
index: number,
|
||||
options: Options,
|
||||
_: unknown,
|
||||
self: Renderer
|
||||
) {
|
||||
return self.renderToken(tokens, index, options)
|
||||
}
|
||||
|
||||
md.renderer.rules.fence = function (
|
||||
tokens: Array<Token>,
|
||||
index: number,
|
||||
options: Options,
|
||||
env: unknown,
|
||||
self: Renderer
|
||||
) {
|
||||
const token = tokens[index]
|
||||
|
||||
if (token.info.trim() === "macroplan") {
|
||||
const encoded = encodeUTF8ToBase64(token.content)
|
||||
return `<div class="macroplan-block" data-macroplan-source="${encoded}"><span class="macroplan-loading">Rendering macroplan…</span></div>\n`
|
||||
}
|
||||
|
||||
return defaultFence(tokens, index, options, env, self)
|
||||
}
|
||||
}
|
||||
|
||||
const slugger = new GithubSlugger()
|
||||
|
||||
let tabGroupCounter = 0
|
||||
@@ -106,6 +138,7 @@ const md = new MarkdownIt({
|
||||
})
|
||||
.use(markdownItMermaidExtractor)
|
||||
.use(markdownItTikzExtractor)
|
||||
.use(markdownItMacroplanExtractor)
|
||||
.use(html5Media)
|
||||
.use(blockEmbedPlugin, {
|
||||
youtube: {
|
||||
@@ -142,6 +175,7 @@ const md = new MarkdownIt({
|
||||
})
|
||||
|
||||
let shikijiPromise: Promise<void> | null = null
|
||||
const shikijiReady = ref(false)
|
||||
|
||||
export const useShikiji = (): Promise<void> => {
|
||||
if (!shikijiPromise) {
|
||||
@@ -154,11 +188,13 @@ export const useShikiji = (): Promise<void> => {
|
||||
"bash",
|
||||
"javascript",
|
||||
"typescript",
|
||||
"tsx",
|
||||
"markdown",
|
||||
"mermaid",
|
||||
"html",
|
||||
"css",
|
||||
"json",
|
||||
"rust",
|
||||
{
|
||||
...alloyGrammar,
|
||||
name: "alloy",
|
||||
@@ -167,6 +203,7 @@ export const useShikiji = (): Promise<void> => {
|
||||
]
|
||||
}).then((plugin) => {
|
||||
md.use(plugin)
|
||||
shikijiReady.value = true
|
||||
})
|
||||
}
|
||||
return shikijiPromise
|
||||
@@ -390,8 +427,20 @@ const stripFrontmatter = (content: string): string => {
|
||||
}
|
||||
|
||||
const renderMarkdown = (content: string, env?: Record<string, unknown>) => {
|
||||
// Track Shikiji readiness so reactive consumers (e.g. the `content`
|
||||
// computed in useFile) re-render once `useShikiji()` mutates the
|
||||
// singleton `md` — otherwise the first render on page load stays
|
||||
// unhighlighted because nothing else invalidates the computed.
|
||||
if (content.includes("```")) void shikijiReady.value
|
||||
slugger.reset()
|
||||
return env ? md.render(content, env) : md.render(content)
|
||||
try {
|
||||
return env ? md.render(content, env) : md.render(content)
|
||||
} catch (error) {
|
||||
// Never let a plugin failure blank the note or dump a raw error —
|
||||
// degrade to the note's raw text.
|
||||
console.error("markdown render failed", error)
|
||||
return renderFallback(content)
|
||||
}
|
||||
}
|
||||
|
||||
export const renderCodeFile = async ({
|
||||
@@ -405,10 +454,22 @@ export const renderCodeFile = async ({
|
||||
}): Promise<string> => {
|
||||
await useShikiji()
|
||||
const heading = filename ? `# ${filename}\n\n` : ""
|
||||
// Drop the trailing newline so the final empty line isn't numbered.
|
||||
const lines = rawContent.replace(/\n$/, "").split("\n")
|
||||
// Sized gutter so line numbers stay right-aligned past 3 digits.
|
||||
const wrapper = `<div class="code-file" style="--line-number-width:${String(lines.length).length}ch">`
|
||||
if (lang !== null) {
|
||||
return renderMarkdown(`${heading}\`\`\`\`${lang}\n${rawContent}\n\`\`\`\``)
|
||||
const rendered = renderMarkdown(
|
||||
`${heading}\`\`\`\`${lang}\n${lines.join("\n")}\n\`\`\`\``
|
||||
)
|
||||
// Shikiji always appends an empty line span; drop it so it isn't numbered.
|
||||
return `${wrapper}${rendered.replace('\n<span class="line"></span></code>', "</code>")}</div>`
|
||||
}
|
||||
return `${renderMarkdown(heading)}<pre><code>${md.utils.escapeHtml(rawContent)}</code></pre>`
|
||||
// Mirror Shikiji's per-line spans so the line-number CSS applies uniformly.
|
||||
const code = lines
|
||||
.map((line) => `<span class="line">${md.utils.escapeHtml(line)}</span>`)
|
||||
.join("\n")
|
||||
return `${wrapper}${renderMarkdown(heading)}<pre><code>${code}</code></pre></div>`
|
||||
}
|
||||
|
||||
export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {
|
||||
|
||||
@@ -6,11 +6,13 @@ import {
|
||||
runTikz,
|
||||
useShikiji
|
||||
} from "@/hooks/useMarkdown.hook"
|
||||
import { runMacroplan } from "@/modules/macroplan/runMacroplan"
|
||||
import { attachSvgDownloads } from "@/utils/svgDownload"
|
||||
|
||||
interface MarkdownPostRenderOptions {
|
||||
onReady?: () => void
|
||||
tikz?: boolean
|
||||
macroplan?: boolean
|
||||
mermaid?: () => boolean
|
||||
shikiji?: () => boolean
|
||||
images?: () => string | null | undefined
|
||||
@@ -44,6 +46,10 @@ export const useMarkdownPostRender = (
|
||||
renderJobs.push(runMermaid(`${scope} .mermaid`))
|
||||
}
|
||||
|
||||
if (options.macroplan) {
|
||||
renderJobs.push(runMacroplan(`${scope} .macroplan-block`))
|
||||
}
|
||||
|
||||
if (options.shikiji?.()) {
|
||||
void useShikiji()
|
||||
}
|
||||
|
||||
327
src/hooks/useNoteFreshness.hook.spec.ts
Normal file
327
src/hooks/useNoteFreshness.hook.spec.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
import { createPinia, setActivePinia } from "pinia"
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||
import { ref } from "vue"
|
||||
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
|
||||
const {
|
||||
fetchLatestSha,
|
||||
addFile,
|
||||
getRawContent,
|
||||
saveCacheNote,
|
||||
queryFileContent,
|
||||
dataGet
|
||||
} = vi.hoisted(() => ({
|
||||
fetchLatestSha: vi.fn(),
|
||||
addFile: vi.fn(),
|
||||
getRawContent: vi.fn((s: string) => s),
|
||||
saveCacheNote: vi.fn(),
|
||||
queryFileContent: vi.fn(),
|
||||
dataGet: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock("@/data/data", () => ({
|
||||
data: { get: dataGet },
|
||||
generateId: (type: string, id: string) => `${type}-${id}`
|
||||
}))
|
||||
|
||||
vi.mock("@/hooks/useGitHubContent.hook", () => ({
|
||||
useGitHubContent: () => ({ fetchLatestSha })
|
||||
}))
|
||||
|
||||
vi.mock("@/hooks/useMarkdown.hook", () => ({
|
||||
markdownBuilder: () => ({ getRawContent, render: (s: string) => s })
|
||||
}))
|
||||
|
||||
vi.mock("@/modules/note/cache/prepareNoteCache", () => ({
|
||||
prepareNoteCache: () => ({
|
||||
getCachedNote: async () => ({ note: null }),
|
||||
saveCacheNote
|
||||
})
|
||||
}))
|
||||
|
||||
vi.mock("@/modules/repo/services/repo", () => ({
|
||||
queryFileContent
|
||||
}))
|
||||
|
||||
vi.mock("@/modules/repo/store/userRepo.store", () => ({
|
||||
useUserRepoStore: () => ({ addFile })
|
||||
}))
|
||||
|
||||
import { useNoteFreshness } from "./useNoteFreshness.hook"
|
||||
|
||||
const setup = (overrides: { sha?: string; path?: string; edited?: string | null } = {}) => {
|
||||
setActivePinia(createPinia())
|
||||
const sha = ref(overrides.sha ?? "local-sha")
|
||||
const path = ref(overrides.path ?? "note.md")
|
||||
const getEditedSha = vi.fn().mockResolvedValue(overrides.edited ?? null)
|
||||
return useNoteFreshness({
|
||||
user: "alice",
|
||||
repo: "notes",
|
||||
sha,
|
||||
path,
|
||||
getEditedSha
|
||||
})
|
||||
}
|
||||
|
||||
// Make performance.now() return values where elapsed > MIN_SPINNER_MS so the
|
||||
// 400ms spinner-min-time wait is skipped.
|
||||
const skipSpinnerWait = () => {
|
||||
let call = 0
|
||||
vi.spyOn(performance, "now").mockImplementation(() =>
|
||||
call++ === 0 ? 0 : 10_000
|
||||
)
|
||||
}
|
||||
|
||||
describe("useNoteFreshness.check", () => {
|
||||
beforeEach(() => {
|
||||
fetchLatestSha.mockReset()
|
||||
addFile.mockReset()
|
||||
saveCacheNote.mockReset()
|
||||
queryFileContent.mockReset()
|
||||
skipSpinnerWait()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("does nothing when path is empty", async () => {
|
||||
const { check, status } = setup({ path: "" })
|
||||
|
||||
await check()
|
||||
|
||||
expect(status.value).toBe("unknown")
|
||||
expect(fetchLatestSha).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("sets status to 'verified' when remote sha matches local sha", async () => {
|
||||
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "local-sha" })
|
||||
|
||||
const { check, status, latestSha, lastCheckedAt } = setup()
|
||||
await check()
|
||||
|
||||
expect(status.value).toBe("verified")
|
||||
expect(latestSha.value).toBe("local-sha")
|
||||
expect(lastCheckedAt.value).toBeInstanceOf(Date)
|
||||
})
|
||||
|
||||
it("sets status to 'outdated' when remote sha differs from local sha", async () => {
|
||||
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
|
||||
|
||||
const { check, status } = setup()
|
||||
await check()
|
||||
|
||||
expect(status.value).toBe("outdated")
|
||||
})
|
||||
|
||||
it("prefers the edited sha over the live sha when comparing", async () => {
|
||||
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "edited-sha" })
|
||||
|
||||
const { check, status } = setup({ sha: "live-sha", edited: "edited-sha" })
|
||||
await check()
|
||||
|
||||
expect(status.value).toBe("verified")
|
||||
})
|
||||
|
||||
it("sets status to 'unauthorized' on auth failure", async () => {
|
||||
fetchLatestSha.mockResolvedValue({ kind: "unauthorized" })
|
||||
|
||||
const { check, status } = setup()
|
||||
await check()
|
||||
|
||||
expect(status.value).toBe("unauthorized")
|
||||
})
|
||||
|
||||
it("sets status to 'offline' on network failure", async () => {
|
||||
fetchLatestSha.mockResolvedValue({ kind: "offline" })
|
||||
|
||||
const { check, status } = setup()
|
||||
await check()
|
||||
|
||||
expect(status.value).toBe("offline")
|
||||
})
|
||||
|
||||
it("sets status to 'offline' when remote returns sha=null (e.g. directory)", async () => {
|
||||
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: null })
|
||||
|
||||
const { check, status } = setup()
|
||||
await check()
|
||||
|
||||
expect(status.value).toBe("offline")
|
||||
})
|
||||
})
|
||||
|
||||
describe("useNoteFreshness.pullLatest", () => {
|
||||
beforeEach(() => {
|
||||
fetchLatestSha.mockReset()
|
||||
addFile.mockReset()
|
||||
saveCacheNote.mockReset()
|
||||
queryFileContent.mockReset()
|
||||
getRawContent.mockClear()
|
||||
skipSpinnerWait()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("returns raw=null with no failureStatus when path is empty", async () => {
|
||||
const { pullLatest } = setup({ path: "" })
|
||||
|
||||
expect(await pullLatest()).toEqual({ raw: null, failureStatus: null })
|
||||
expect(fetchLatestSha).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("returns the fetched content and updates state on success", async () => {
|
||||
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
|
||||
queryFileContent.mockResolvedValue("BASE64")
|
||||
getRawContent.mockReturnValue("# raw")
|
||||
|
||||
const { pullLatest, status, latestSha, lastCheckedAt } = setup()
|
||||
const result = await pullLatest()
|
||||
|
||||
expect(result).toEqual({ raw: "# raw", failureStatus: null })
|
||||
expect(status.value).toBe("verified")
|
||||
expect(latestSha.value).toBe("remote-sha")
|
||||
expect(lastCheckedAt.value).toBeInstanceOf(Date)
|
||||
expect(addFile).toHaveBeenCalledWith({ path: "note.md", sha: "remote-sha" })
|
||||
expect(saveCacheNote).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("surfaces 'unauthorized' failureStatus when remote resolve is unauthorized", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
fetchLatestSha.mockResolvedValue({ kind: "unauthorized" })
|
||||
|
||||
const { pullLatest, status } = setup()
|
||||
expect(await pullLatest()).toEqual({
|
||||
raw: null,
|
||||
failureStatus: "unauthorized"
|
||||
})
|
||||
expect(status.value).toBe("unauthorized")
|
||||
})
|
||||
|
||||
it("surfaces 'offline' failureStatus when remote resolve is offline", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
fetchLatestSha.mockResolvedValue({ kind: "offline" })
|
||||
|
||||
const { pullLatest, status } = setup()
|
||||
expect(await pullLatest()).toEqual({
|
||||
raw: null,
|
||||
failureStatus: "offline"
|
||||
})
|
||||
expect(status.value).toBe("offline")
|
||||
})
|
||||
|
||||
it("surfaces 'offline' failureStatus when blob content fetch fails", async () => {
|
||||
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
|
||||
queryFileContent.mockResolvedValue(null)
|
||||
|
||||
const { pullLatest, status } = setup()
|
||||
expect(await pullLatest()).toEqual({
|
||||
raw: null,
|
||||
failureStatus: "offline"
|
||||
})
|
||||
expect(status.value).toBe("offline")
|
||||
})
|
||||
|
||||
it("uses the cached latestSha to skip a redundant fetchLatestSha call", async () => {
|
||||
fetchLatestSha.mockResolvedValueOnce({ kind: "ok", sha: "cached-sha" })
|
||||
queryFileContent.mockResolvedValue("BASE64")
|
||||
|
||||
const api = setup()
|
||||
await api.check() // populates latestSha
|
||||
expect(fetchLatestSha).toHaveBeenCalledTimes(1)
|
||||
|
||||
fetchLatestSha.mockClear()
|
||||
await api.pullLatest()
|
||||
|
||||
expect(fetchLatestSha).not.toHaveBeenCalled()
|
||||
expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "cached-sha")
|
||||
})
|
||||
})
|
||||
|
||||
describe("useNoteFreshness.resolveMergeSources", () => {
|
||||
beforeEach(() => {
|
||||
fetchLatestSha.mockReset()
|
||||
queryFileContent.mockReset()
|
||||
dataGet.mockReset()
|
||||
getRawContent.mockReset()
|
||||
getRawContent.mockImplementation((s: string) => s)
|
||||
skipSpinnerWait()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("returns null when path is empty", async () => {
|
||||
const { resolveMergeSources } = setup({ path: "" })
|
||||
expect(await resolveMergeSources()).toBeNull()
|
||||
})
|
||||
|
||||
it("resolves base (from cache snapshot) + theirs + remoteSha", async () => {
|
||||
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
|
||||
queryFileContent.mockResolvedValue("THEIRS_B64") // theirs blob
|
||||
dataGet.mockResolvedValue({ content: "BASE_B64" }) // base snapshot in cache
|
||||
|
||||
const { resolveMergeSources, latestSha } = setup({
|
||||
sha: "live",
|
||||
edited: "base-sha"
|
||||
})
|
||||
const result = await resolveMergeSources()
|
||||
|
||||
expect(result).toEqual({
|
||||
base: "BASE_B64",
|
||||
theirs: "THEIRS_B64",
|
||||
remoteSha: "remote-sha"
|
||||
})
|
||||
// base must be read from its own immutable snapshot, never the path pointer
|
||||
expect(dataGet).toHaveBeenCalledWith(`${DataType.Note}-base-sha`)
|
||||
expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "remote-sha")
|
||||
expect(latestSha.value).toBe("remote-sha")
|
||||
})
|
||||
|
||||
it("falls back to fetching the base blob when not cached", async () => {
|
||||
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
|
||||
dataGet.mockResolvedValue(null) // cache miss for base
|
||||
queryFileContent.mockImplementation((_u, _r, s: string) =>
|
||||
Promise.resolve(s === "remote-sha" ? "THEIRS_B64" : "BASE_FROM_BLOB")
|
||||
)
|
||||
|
||||
const { resolveMergeSources } = setup({ sha: "base-sha" })
|
||||
const result = await resolveMergeSources()
|
||||
|
||||
expect(result?.base).toBe("BASE_FROM_BLOB")
|
||||
expect(queryFileContent).toHaveBeenCalledWith("alice", "notes", "base-sha")
|
||||
})
|
||||
|
||||
it("returns null when the remote sha cannot be resolved", async () => {
|
||||
fetchLatestSha.mockResolvedValue({ kind: "offline" })
|
||||
|
||||
const { resolveMergeSources } = setup()
|
||||
expect(await resolveMergeSources()).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null when the remote (theirs) blob fetch fails", async () => {
|
||||
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
|
||||
queryFileContent.mockResolvedValue(null)
|
||||
|
||||
const { resolveMergeSources, latestSha } = setup()
|
||||
expect(await resolveMergeSources()).toBeNull()
|
||||
// latestSha is still surfaced so a modal fallback's overwrite has a target
|
||||
expect(latestSha.value).toBe("remote-sha")
|
||||
})
|
||||
|
||||
it("returns null when the base blob is unavailable (cache miss + blob null)", async () => {
|
||||
fetchLatestSha.mockResolvedValue({ kind: "ok", sha: "remote-sha" })
|
||||
dataGet.mockResolvedValue(null)
|
||||
queryFileContent.mockImplementation((_u, _r, s: string) =>
|
||||
Promise.resolve(s === "remote-sha" ? "THEIRS_B64" : null)
|
||||
)
|
||||
|
||||
const { resolveMergeSources } = setup({ sha: "base-sha" })
|
||||
expect(await resolveMergeSources()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,11 @@
|
||||
import { Ref, ref } from "vue"
|
||||
|
||||
import { data, generateId } from "@/data/data"
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
||||
import { Note } from "@/modules/note/models/Note"
|
||||
import { queryFileContent } from "@/modules/repo/services/repo"
|
||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||
|
||||
@@ -114,11 +117,52 @@ export const useNoteFreshness = ({
|
||||
return { raw: getRawContent(fileContent), failureStatus: null }
|
||||
}
|
||||
|
||||
// The base blob (common ancestor) must come from its own immutable snapshot,
|
||||
// never from the path pointer — that holds the latest, which is "theirs".
|
||||
const getCachedBlob = async (blobSha: string): Promise<string | null> => {
|
||||
const note = await data.get<DataType.Note, Note>(
|
||||
generateId(DataType.Note, blobSha)
|
||||
)
|
||||
return note?.content ?? null
|
||||
}
|
||||
|
||||
// Resolves the decoded raw text needed for a 3-way merge: `base` is the
|
||||
// version we edited from (the rejected sha), `theirs` is the current remote.
|
||||
// Returns null when any piece can't be resolved (offline, missing base).
|
||||
const resolveMergeSources = async (): Promise<{
|
||||
base: string
|
||||
theirs: string
|
||||
remoteSha: string
|
||||
} | null> => {
|
||||
if (!path.value) return null
|
||||
|
||||
const { sha: remoteSha } = await resolveRemoteSha(path.value)
|
||||
if (!remoteSha) return null
|
||||
// Surface the resolved sha so a modal fallback's Overwrite can use it.
|
||||
latestSha.value = remoteSha
|
||||
const theirsBlob = await queryFileContent(user, repo, remoteSha)
|
||||
if (!theirsBlob) return null
|
||||
|
||||
const baseSha = (await getEditedSha()) ?? sha.value
|
||||
const baseBlob =
|
||||
(await getCachedBlob(baseSha)) ??
|
||||
(await queryFileContent(user, repo, baseSha))
|
||||
if (!baseBlob) return null
|
||||
|
||||
const { getRawContent } = markdownBuilder(sha.value)
|
||||
return {
|
||||
base: getRawContent(baseBlob),
|
||||
theirs: getRawContent(theirsBlob),
|
||||
remoteSha
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
status,
|
||||
lastCheckedAt,
|
||||
latestSha,
|
||||
check,
|
||||
pullLatest
|
||||
pullLatest,
|
||||
resolveMergeSources
|
||||
}
|
||||
}
|
||||
|
||||
50
src/hooks/useResolveLiveNotes.hook.ts
Normal file
50
src/hooks/useResolveLiveNotes.hook.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { watch } from "vue"
|
||||
import { useRoute, useRouter } from "vue-router"
|
||||
|
||||
import { resolveLivePathsToShas } from "@/modules/note/liveNotes"
|
||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||
|
||||
// A living link (`?liveNotes=path&liveNotes=path`) carries file paths instead of
|
||||
// pinned blob shas. On open we resolve each path to the latest sha against the
|
||||
// HEAD file list and rewrite the URL to the ordinary pinned `stackedNotes` form
|
||||
// — so the recipient lands on the current version, and from there everything
|
||||
// (freshness, editing, the snapshot banner) behaves exactly as a shared snapshot
|
||||
// would. The rewrite is a `replace`, so no history entry and no view transition.
|
||||
export const useResolveLiveNotes = (onResolved?: () => void) => {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useUserRepoStore()
|
||||
|
||||
let resolved = false
|
||||
|
||||
const tryResolve = () => {
|
||||
if (resolved) return
|
||||
|
||||
const raw = route.query.liveNotes
|
||||
if (!raw) {
|
||||
resolved = true
|
||||
return
|
||||
}
|
||||
|
||||
// Wait for the HEAD file list before resolving paths to shas.
|
||||
if (store.files.length === 0) return
|
||||
resolved = true
|
||||
|
||||
const entries = (Array.isArray(raw) ? raw : [raw]).filter(
|
||||
(entry): entry is string => typeof entry === "string"
|
||||
)
|
||||
const shas = resolveLivePathsToShas(entries, store.files)
|
||||
|
||||
const query = { ...route.query }
|
||||
delete query.liveNotes
|
||||
if (shas.length) {
|
||||
query.stackedNotes = shas
|
||||
} else {
|
||||
delete query.stackedNotes
|
||||
}
|
||||
|
||||
router.replace({ path: route.path, query }).then(() => onResolved?.())
|
||||
}
|
||||
|
||||
watch(() => store.files.length, tryResolve, { immediate: true })
|
||||
}
|
||||
@@ -111,6 +111,16 @@ export const useRouteQueryStackedNotes = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Focus the deepest note when opening a shared link with stacked notes,
|
||||
// instead of landing on the readme and forcing a manual scroll (especially
|
||||
// long on mobile where notes stack vertically).
|
||||
const scrollToLastStackedNote = () => {
|
||||
const lastNote = stackedNotes.value.at(-1)
|
||||
if (lastNote) {
|
||||
scrollToFocusedNote({ noteId: lastNote })
|
||||
}
|
||||
}
|
||||
|
||||
const addStackedNote = (
|
||||
currentSha: string,
|
||||
sha: string,
|
||||
@@ -149,9 +159,26 @@ export const useRouteQueryStackedNotes = () => {
|
||||
scrollToFocusedNote({ noteId: selector ?? sha, hash, anchorTop })
|
||||
}
|
||||
|
||||
// Advance a note's handle in place when its content changes (edit / pull):
|
||||
// the live view follows to the new sha, while any previously-shared link
|
||||
// keeps pointing at the old, now-immutable snapshot.
|
||||
const replaceStackedNote = (oldSha: string, newSha: string) => {
|
||||
if (!oldSha || !newSha || oldSha === newSha) {
|
||||
return
|
||||
}
|
||||
if (!stackedNotes.value.includes(oldSha)) {
|
||||
return
|
||||
}
|
||||
stackedNotes.value = stackedNotes.value.map((note) =>
|
||||
note === oldSha ? newSha : note
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
stackedNotes: readonly(stackedNotes),
|
||||
addStackedNote,
|
||||
scrollToFocusedNote
|
||||
replaceStackedNote,
|
||||
scrollToFocusedNote,
|
||||
scrollToLastStackedNote
|
||||
}
|
||||
}
|
||||
|
||||
87
src/hooks/useTodoTxtCommit.hook.ts
Normal file
87
src/hooks/useTodoTxtCommit.hook.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import { useDebounceFn } from "@vueuse/core"
|
||||
import { onUnmounted, Ref, ref, toValue } from "vue"
|
||||
|
||||
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||
import { FileLine, parseFile, serializeFile } from "@/utils/todotxt"
|
||||
|
||||
export const useTodoTxtCommit = ({
|
||||
user,
|
||||
repo,
|
||||
path,
|
||||
initialContent,
|
||||
initialSha,
|
||||
debounceMs = 1000
|
||||
}: {
|
||||
user: string
|
||||
repo: string
|
||||
path: Ref<string | undefined> | string | undefined
|
||||
initialContent: Ref<string> | string
|
||||
initialSha: Ref<string> | string
|
||||
debounceMs?: number
|
||||
}) => {
|
||||
const { updateFile } = useGitHubContent({ user, repo })
|
||||
|
||||
const items = ref<FileLine[]>(parseFile(toValue(initialContent)))
|
||||
const currentSha = ref(toValue(initialSha))
|
||||
const isCommitting = ref(false)
|
||||
const hasPendingChanges = ref(false)
|
||||
|
||||
// Re-seed from a freshly fetched file. We must not blow away in-flight edits.
|
||||
const syncContent = (content: string, sha: string) => {
|
||||
if (!hasPendingChanges.value) {
|
||||
items.value = parseFile(content)
|
||||
currentSha.value = sha
|
||||
}
|
||||
}
|
||||
|
||||
const commitChanges = async () => {
|
||||
const pathValue = toValue(path)
|
||||
if (!pathValue || !hasPendingChanges.value) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isCommitting.value) {
|
||||
debouncedCommit()
|
||||
return
|
||||
}
|
||||
|
||||
isCommitting.value = true
|
||||
|
||||
const { sha: newSha } = await updateFile({
|
||||
content: serializeFile(items.value),
|
||||
path: pathValue,
|
||||
sha: currentSha.value
|
||||
})
|
||||
|
||||
if (newSha) {
|
||||
currentSha.value = newSha
|
||||
hasPendingChanges.value = false
|
||||
}
|
||||
|
||||
isCommitting.value = false
|
||||
}
|
||||
|
||||
const debouncedCommit = useDebounceFn(commitChanges, debounceMs)
|
||||
|
||||
const mutate = (mutator: (current: FileLine[]) => FileLine[]) => {
|
||||
items.value = mutator(items.value)
|
||||
hasPendingChanges.value = true
|
||||
debouncedCommit()
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
// Flush any pending edit on unmount so navigation doesn't lose work.
|
||||
if (hasPendingChanges.value) {
|
||||
commitChanges()
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
items,
|
||||
currentSha,
|
||||
isCommitting,
|
||||
hasPendingChanges,
|
||||
syncContent,
|
||||
mutate
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ import "notyf/notyf.min.css"
|
||||
import "./styles/app.css"
|
||||
import "@/analytics/openpanel"
|
||||
|
||||
import { VueQueryPlugin } from "@tanstack/vue-query"
|
||||
import { createPinia } from "pinia"
|
||||
import { createApp } from "vue"
|
||||
import { createI18n } from "vue-i18n"
|
||||
@@ -19,7 +18,6 @@ const i18n = createI18n({
|
||||
|
||||
createApp(App)
|
||||
.use(router)
|
||||
.use(VueQueryPlugin)
|
||||
.use(i18n)
|
||||
.use(createPinia())
|
||||
.mount("#app")
|
||||
|
||||
55
src/modules/atproto/withATProtoImages.spec.ts
Normal file
55
src/modules/atproto/withATProtoImages.spec.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { withATProtoImages } from "./withATProtoImages"
|
||||
|
||||
const PDS = "https://pds.example.com"
|
||||
const DID = "did:plc:abc123"
|
||||
|
||||
describe("withATProtoImages", () => {
|
||||
it("rewrites a bafkrei CID image to a getBlob URL", () => {
|
||||
const out = withATProtoImages(
|
||||
"",
|
||||
{ pds: PDS, did: DID }
|
||||
)
|
||||
|
||||
expect(out).toContain(
|
||||
`${PDS}/xrpc/com.atproto.sync.getBlob`
|
||||
)
|
||||
expect(out).toContain("did=did%3Aplc%3Aabc123")
|
||||
expect(out).toContain(
|
||||
"cid=bafkreigh2akiscaildc7r4apx2t6q4t6n6kxjpw3xhqxkfvvbprdaezz4i"
|
||||
)
|
||||
})
|
||||
|
||||
it("preserves the alt text", () => {
|
||||
const out = withATProtoImages("", {
|
||||
pds: PDS,
|
||||
did: DID
|
||||
})
|
||||
expect(out).toMatch(/!\[my cover\]/)
|
||||
})
|
||||
|
||||
it("rewrites multiple images in one pass", () => {
|
||||
const md = " and "
|
||||
const out = withATProtoImages(md, { pds: PDS, did: DID })
|
||||
|
||||
expect(out.match(/com\.atproto\.sync\.getBlob/g)).toHaveLength(2)
|
||||
expect(out).toContain("cid=bafkreiaaa")
|
||||
expect(out).toContain("cid=bafkreibbb")
|
||||
})
|
||||
|
||||
it("leaves regular image URLs untouched", () => {
|
||||
const md = ""
|
||||
expect(withATProtoImages(md, { pds: PDS, did: DID })).toBe(md)
|
||||
})
|
||||
|
||||
it("leaves CIDs that don't match the bafkrei prefix untouched", () => {
|
||||
const md = ""
|
||||
expect(withATProtoImages(md, { pds: PDS, did: DID })).toBe(md)
|
||||
})
|
||||
|
||||
it("preserves an empty alt text", () => {
|
||||
const out = withATProtoImages("", { pds: PDS, did: DID })
|
||||
expect(out).toMatch(/^!\[\]/)
|
||||
})
|
||||
})
|
||||
436
src/modules/macroplan/components/MacroplanGrid.vue
Normal file
436
src/modules/macroplan/components/MacroplanGrid.vue
Normal file
@@ -0,0 +1,436 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from "vue"
|
||||
|
||||
import type { FeatureRow, MarkerKind,Plan } from "../model/types"
|
||||
import { type WeekId,weekLabel } from "../model/week"
|
||||
|
||||
const props = defineProps<{ plan: Plan }>()
|
||||
|
||||
type Tone = "success" | "warning" | "error" | "neutral"
|
||||
|
||||
const GLYPH: Record<MarkerKind, string> = {
|
||||
original: "◯",
|
||||
reestimate: "△",
|
||||
"delivered-on-time": "◉",
|
||||
"delivered-late": "▲",
|
||||
}
|
||||
const MARKER_CLASS: Record<MarkerKind, string> = {
|
||||
original: "text-base-content/50",
|
||||
reestimate: "text-warning",
|
||||
"delivered-on-time": "text-success",
|
||||
"delivered-late": "text-error",
|
||||
}
|
||||
// When two markers land on the same week, the higher rank owns the cell.
|
||||
const RANK: Record<MarkerKind, number> = {
|
||||
"delivered-late": 3,
|
||||
"delivered-on-time": 3,
|
||||
reestimate: 2,
|
||||
original: 1,
|
||||
}
|
||||
const TONE_TEXT: Record<Tone, string> = {
|
||||
success: "text-success",
|
||||
warning: "text-warning",
|
||||
error: "text-error",
|
||||
neutral: "text-primary",
|
||||
}
|
||||
const TONE_BORDER: Record<Tone, string> = {
|
||||
success: "border-success",
|
||||
warning: "border-warning",
|
||||
error: "border-error",
|
||||
neutral: "border-primary",
|
||||
}
|
||||
const TONE_DOT: Record<Tone, string> = {
|
||||
success: "bg-success",
|
||||
warning: "bg-warning",
|
||||
error: "bg-error",
|
||||
neutral: "bg-base-300",
|
||||
}
|
||||
|
||||
// Layout constants (must match the CSS vars --name-w / --wk) for stacking math.
|
||||
const NAME_W = 9 * 16
|
||||
const WK = 3.5 * 16
|
||||
const BAND_CHAR = 6.6 // ≈ Fira Code advance (px) at the band font size
|
||||
|
||||
const weeks = computed(() => props.plan.weeks)
|
||||
|
||||
const gridStyle = computed(() => ({
|
||||
gridTemplateColumns: `var(--name-w) repeat(${weeks.value.length}, var(--wk)) minmax(9rem, 1fr)`,
|
||||
}))
|
||||
|
||||
function tone(row: FeatureRow): Tone {
|
||||
if (row.delivered) return row.onTime ? "success" : "error"
|
||||
if (row.status === "on-track") return "success"
|
||||
if (row.status === "at-risk") return "warning"
|
||||
if (row.status === "off-track") return "error"
|
||||
return "neutral"
|
||||
}
|
||||
|
||||
function statusWord(row: FeatureRow): string {
|
||||
return row.status ? row.status.replace("-", " ") : "in flight"
|
||||
}
|
||||
|
||||
function markerAt(row: FeatureRow, w: WeekId): MarkerKind | null {
|
||||
let best: MarkerKind | null = null
|
||||
for (const m of row.markers) {
|
||||
if (m.week === w && (best === null || RANK[m.kind] > RANK[best])) best = m.kind
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
interface Cell {
|
||||
// how far the bar line runs within this cell: none, center→right,
|
||||
// left→center, or full width
|
||||
line: "none" | "right" | "left" | "full"
|
||||
isStart: boolean
|
||||
glyph: string
|
||||
glyphCls: string
|
||||
}
|
||||
|
||||
// rows × weeks render matrix, computed once.
|
||||
const matrix = computed<Cell[][]>(() =>
|
||||
props.plan.rows.map((row) =>
|
||||
weeks.value.map((w) => {
|
||||
const m = markerAt(row, w)
|
||||
const inBar = w >= row.startWeek && w <= row.barEndWeek
|
||||
let line: Cell["line"] = "none"
|
||||
if (inBar) {
|
||||
const isStart = w === row.startWeek
|
||||
const isEnd = w === row.barEndWeek
|
||||
line = isStart && isEnd ? "none" : isStart ? "right" : isEnd ? "left" : "full"
|
||||
}
|
||||
return {
|
||||
line,
|
||||
isStart: inBar && w === row.startWeek,
|
||||
glyph: m ? GLYPH[m] : "",
|
||||
glyphCls: m ? MARKER_CLASS[m] : "",
|
||||
}
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
// Per-week column metadata for the header + the now / milestone column rules.
|
||||
const cols = computed(() =>
|
||||
weeks.value.map((w) => ({
|
||||
w,
|
||||
label: weekLabel(w),
|
||||
isNow: props.plan.nowInRange && w === props.plan.nowWeek,
|
||||
isMilestone: props.plan.milestones.some((m) => m.week === w),
|
||||
})),
|
||||
)
|
||||
|
||||
function colClass(i: number): string {
|
||||
const c = cols.value[i]
|
||||
if (c.isNow) return "col-now"
|
||||
if (c.isMilestone) return "col-ms"
|
||||
return ""
|
||||
}
|
||||
|
||||
// Milestone label flags above the axis, greedily stacked onto extra rows so
|
||||
// labels in nearby weeks never overlap. `col` is the 1-based grid column.
|
||||
const milestoneFlags = computed(() => {
|
||||
const items = props.plan.milestones
|
||||
.map((m) => ({ m, col: weeks.value.indexOf(m.week) + 2 }))
|
||||
.filter((x) => x.col >= 2)
|
||||
.sort((a, b) => a.col - b.col)
|
||||
const rowEnd: number[] = [] // px x-extent of the last flag placed in each row
|
||||
return items.map((x) => {
|
||||
const startX = NAME_W + (x.col - 2) * WK
|
||||
const text = `◆ ${x.m.name}` + (x.m.unmet.length ? ` · ${x.m.unmet.length} unmet` : "")
|
||||
const width = text.length * BAND_CHAR + 14
|
||||
let row = 0
|
||||
while (row < rowEnd.length && rowEnd[row] > startX - 6) row++
|
||||
rowEnd[row] = startX + width
|
||||
const title = x.m.unmet.length
|
||||
? `${x.m.name} — unmet: ${x.m.unmet.join(", ")}`
|
||||
: `${x.m.name} — all required features met`
|
||||
return { name: x.m.name, unmet: x.m.unmet, col: x.col, row: row + 1, title }
|
||||
})
|
||||
})
|
||||
const bandRows = computed(() => milestoneFlags.value.reduce((n, f) => Math.max(n, f.row), 0))
|
||||
const bandStyle = computed(() => ({
|
||||
gridTemplateColumns: `var(--name-w) repeat(${weeks.value.length}, var(--wk))`,
|
||||
gridTemplateRows: `repeat(${bandRows.value}, 1.15rem)`,
|
||||
}))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="macroplan overflow-auto rounded-box border border-base-300 bg-base-100">
|
||||
<!-- milestone band: stacked label flags with leader lines down to the axis -->
|
||||
<div v-if="milestoneFlags.length" class="ms-band" :style="bandStyle">
|
||||
<template v-for="f in milestoneFlags" :key="f.name + '-' + f.col">
|
||||
<i class="ms-lead" :style="{ gridColumn: f.col, gridRow: f.row + ' / -1' }" />
|
||||
<span class="ms-flag" :style="{ gridColumn: f.col, gridRow: f.row }" :title="f.title">
|
||||
<span class="ms-dia">◆</span> {{ f.name
|
||||
}}<span v-if="f.unmet.length" class="ms-unmet"> · {{ f.unmet.length }} unmet</span>
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="plan-grid" :style="gridStyle">
|
||||
<!-- header row -->
|
||||
<div class="hcell corner">Feature</div>
|
||||
<div
|
||||
v-for="(c, ci) in cols"
|
||||
:key="'h-' + c.w"
|
||||
class="hcell wkhead"
|
||||
:class="[colClass(ci), { 'now-head': c.isNow }]"
|
||||
>
|
||||
<span class="wklabel">{{ c.label }}</span>
|
||||
<span v-if="c.isNow" class="badge-now">now</span>
|
||||
</div>
|
||||
<div class="hcell learnhead">Learning / Status</div>
|
||||
|
||||
<!-- feature rows -->
|
||||
<template v-for="(row, ri) in plan.rows" :key="row.name">
|
||||
<div class="namecell border-l-4" :class="TONE_BORDER[tone(row)]">
|
||||
<span class="truncate" :title="row.name">{{ row.name }}</span>
|
||||
</div>
|
||||
<div
|
||||
v-for="(cell, ci) in matrix[ri]"
|
||||
:key="row.name + '-' + ci"
|
||||
class="cell"
|
||||
:class="colClass(ci)"
|
||||
>
|
||||
<i v-if="cell.line !== 'none'" class="bar" :class="[cell.line, TONE_TEXT[tone(row)]]"></i>
|
||||
<i v-if="cell.isStart" class="riser" :class="TONE_TEXT[tone(row)]"></i>
|
||||
<span v-if="cell.glyph" class="glyph" :class="cell.glyphCls">{{ cell.glyph }}</span>
|
||||
</div>
|
||||
<div class="learncell">
|
||||
<template v-if="row.delivered">
|
||||
<span v-if="row.learning" class="note">{{ row.learning }}</span>
|
||||
<span v-else class="muted">{{
|
||||
row.onTime ? "delivered on time" : "delivered late"
|
||||
}}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span class="dot" :class="TONE_DOT[tone(row)]"></span>
|
||||
<span class="note" :title="row.note || ''">{{ row.note || statusWord(row) }}</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- legend -->
|
||||
<div class="legend">
|
||||
<span><b class="text-base-content/50">◯</b> original estimate</span>
|
||||
<span><b class="text-warning">△</b> re-estimate</span>
|
||||
<span><b class="text-success">◉</b> on time</span>
|
||||
<span><b class="text-error">▲</b> late</span>
|
||||
<span><b>┣━</b> feature bar</span>
|
||||
<span><b class="ms-dia">◆</b> milestone</span>
|
||||
<span><b class="now-swatch"></b> today</span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.macroplan {
|
||||
--name-w: 9rem;
|
||||
--wk: 3.5rem;
|
||||
}
|
||||
.plan-grid {
|
||||
display: grid;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
/* Never let a too-tall container (e.g. the mobile 100svh note height that
|
||||
cascades in) stretch the auto row tracks — pack rows to their content
|
||||
height at the top. */
|
||||
align-content: start;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
|
||||
/* ── Milestone band ───────────────────────────────────────────────── */
|
||||
.ms-band {
|
||||
display: grid;
|
||||
width: max-content;
|
||||
min-width: 100%;
|
||||
padding-top: 0.35rem;
|
||||
font-variant-ligatures: none;
|
||||
}
|
||||
.ms-flag {
|
||||
align-self: center;
|
||||
white-space: nowrap;
|
||||
overflow: visible;
|
||||
font-size: 0.66rem;
|
||||
font-weight: 600;
|
||||
line-height: 1;
|
||||
color: var(--color-base-content);
|
||||
z-index: 1;
|
||||
}
|
||||
.ms-dia {
|
||||
color: color-mix(in oklab, var(--color-base-content) 55%, var(--color-base-100));
|
||||
}
|
||||
.ms-unmet {
|
||||
color: var(--color-error); /* unmet required features = a problem = red */
|
||||
font-weight: 700;
|
||||
}
|
||||
.ms-lead {
|
||||
justify-self: start;
|
||||
width: 0;
|
||||
border-left: 2px dashed color-mix(in oklab, var(--color-base-content) 30%, var(--color-base-100));
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.hcell {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 20;
|
||||
background: var(--color-base-200);
|
||||
border-bottom: 1px solid var(--color-base-300);
|
||||
padding: 0.4rem 0.5rem;
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.corner {
|
||||
left: 0;
|
||||
z-index: 30;
|
||||
text-align: left;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.wkhead {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
line-height: 1.15;
|
||||
padding-inline: 0.15rem;
|
||||
}
|
||||
.wklabel {
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.now-head {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.badge-now {
|
||||
font-size: 0.58rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.learnhead {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.namecell {
|
||||
position: sticky;
|
||||
left: 0;
|
||||
z-index: 10;
|
||||
background: var(--color-base-100);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 2rem;
|
||||
padding: 0 0.6rem;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
border-bottom: 1px solid var(--color-base-200);
|
||||
}
|
||||
.namecell .truncate {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cell {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2rem;
|
||||
border-bottom: 1px solid var(--color-base-200);
|
||||
}
|
||||
.bar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
height: 2px;
|
||||
transform: translateY(-50%);
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.bar.full {
|
||||
left: 0;
|
||||
right: 0;
|
||||
}
|
||||
.bar.left {
|
||||
left: 0;
|
||||
right: 50%;
|
||||
} /* enters from the left, stops at the glyph */
|
||||
.bar.right {
|
||||
left: 50%;
|
||||
right: 0;
|
||||
} /* starts at the first glyph, runs right */
|
||||
.riser {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 28%;
|
||||
bottom: 28%;
|
||||
width: 2px;
|
||||
transform: translateX(-50%);
|
||||
background: currentColor;
|
||||
opacity: 0.55;
|
||||
}
|
||||
.glyph {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
font-size: 0.95rem;
|
||||
line-height: 1;
|
||||
padding: 0 0.18rem;
|
||||
/* halo so the bar reads as passing behind, not through, the symbol */
|
||||
background: var(--color-base-100);
|
||||
border-radius: 0.3rem;
|
||||
}
|
||||
|
||||
.learncell {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-height: 2rem;
|
||||
padding: 0 0.6rem;
|
||||
font-size: 0.72rem;
|
||||
border-bottom: 1px solid var(--color-base-200);
|
||||
}
|
||||
.learncell .muted {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.learncell .note {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.dot {
|
||||
width: 0.6rem;
|
||||
height: 0.6rem;
|
||||
border-radius: 9999px;
|
||||
flex: none;
|
||||
}
|
||||
|
||||
/* "today" — a neutral grey column (red is reserved for problems) */
|
||||
.cell.col-now,
|
||||
.cell.col-now .glyph {
|
||||
background: color-mix(in oklab, var(--color-base-content) 7%, var(--color-base-100));
|
||||
}
|
||||
.col-ms {
|
||||
border-left: 2px dashed color-mix(in oklab, var(--color-base-content) 30%, var(--color-base-100));
|
||||
}
|
||||
|
||||
.legend {
|
||||
margin-top: 0.75rem;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.25rem 1.25rem;
|
||||
font-size: 0.72rem;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.legend b {
|
||||
font-weight: 700;
|
||||
}
|
||||
.now-swatch {
|
||||
display: inline-block;
|
||||
width: 0.8rem;
|
||||
height: 0.8rem;
|
||||
vertical-align: -0.1rem;
|
||||
border-radius: 0.15rem;
|
||||
background: color-mix(in oklab, var(--color-base-content) 7%, var(--color-base-100));
|
||||
border: 1px solid color-mix(in oklab, var(--color-base-content) 22%, var(--color-base-100));
|
||||
}
|
||||
</style>
|
||||
153
src/modules/macroplan/model/parse.ts
Normal file
153
src/modules/macroplan/model/parse.ts
Normal file
@@ -0,0 +1,153 @@
|
||||
import { parse as parseToml } from "smol-toml"
|
||||
import * as v from "valibot"
|
||||
|
||||
import type { RawPlan, StatusLevel } from "./types"
|
||||
import { toYmd } from "./week"
|
||||
|
||||
/** Thrown for any malformed source — message is safe to show the author. */
|
||||
export class PlanParseError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message)
|
||||
this.name = "PlanParseError"
|
||||
}
|
||||
}
|
||||
|
||||
const STATUSES = ["on-track", "at-risk", "off-track"] as const satisfies readonly StatusLevel[]
|
||||
|
||||
// ── Field schemas ──────────────────────────────────────────────────────────
|
||||
// A TOML date (smol-toml returns a Date subclass) or a yyyy-mm-dd string,
|
||||
// normalized to yyyy-mm-dd via `toYmd`.
|
||||
const Ymd = v.pipe(
|
||||
v.union([v.date(), v.string()], "must be a date (e.g. 2026-06-01)"),
|
||||
v.transform((value: string | Date) => toYmd(value)),
|
||||
)
|
||||
|
||||
const Status = v.picklist(STATUSES, `must be one of ${STATUSES.join(", ")}`)
|
||||
|
||||
const Name = v.pipe(v.string("is required"), v.nonEmpty("is required"))
|
||||
|
||||
const FeatureSchema = v.object({
|
||||
name: Name,
|
||||
start: Ymd,
|
||||
original: Ymd,
|
||||
reestimates: v.optional(v.array(Ymd, "must be a list of dates"), []),
|
||||
delivered: v.optional(Ymd),
|
||||
learning: v.optional(v.string()),
|
||||
status: v.optional(Status),
|
||||
note: v.optional(v.string()),
|
||||
})
|
||||
|
||||
const MilestoneSchema = v.object({
|
||||
name: Name,
|
||||
week: Ymd,
|
||||
requires: v.optional(
|
||||
v.array(v.string("must be a feature name"), "must be a list of feature names"),
|
||||
[],
|
||||
),
|
||||
})
|
||||
|
||||
/** The Macroplan format version this build understands (see docs/format.md).
|
||||
* Files may declare `macroplan_version`; a newer version is rejected rather
|
||||
* than silently mis-rendered against a schema we don't know. */
|
||||
export const FORMAT_VERSION = 1
|
||||
|
||||
/** Parse + validate a Macroplan TOML source into the raw model. */
|
||||
export function parseMacroplan(source: string): RawPlan {
|
||||
let data: Record<string, unknown>
|
||||
try {
|
||||
data = parseToml(source) as Record<string, unknown>
|
||||
} catch (e) {
|
||||
throw new PlanParseError(e instanceof Error ? e.message : String(e))
|
||||
}
|
||||
|
||||
checkVersion(data.macroplan_version)
|
||||
|
||||
const features = asBlocks(data.feature, "feature").map((f, i) =>
|
||||
check(FeatureSchema, f, blockWhere("feature", f, i)),
|
||||
)
|
||||
requireUniqueFeatureNames(features)
|
||||
|
||||
return {
|
||||
title: data.title != null ? String(data.title) : "Untitled Macroplan",
|
||||
start: data.start != null ? check(Ymd, data.start, "plan", "start") : undefined,
|
||||
end: data.end != null ? check(Ymd, data.end, "plan", "end") : undefined,
|
||||
features,
|
||||
milestones: asBlocks(data.milestone, "milestone").map((m, i) =>
|
||||
check(MilestoneSchema, m, blockWhere("milestone", m, i)),
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate the optional `macroplan_version` marker. Absent means the current
|
||||
* version; a future version we don't understand is rejected rather than
|
||||
* silently mis-rendered. */
|
||||
function checkVersion(value: unknown): void {
|
||||
if (value == null) return
|
||||
if (typeof value !== "number" || !Number.isInteger(value) || value < 1) {
|
||||
throw new PlanParseError("`macroplan_version` must be a positive integer")
|
||||
}
|
||||
if (value > FORMAT_VERSION) {
|
||||
throw new PlanParseError(
|
||||
`unsupported macroplan_version ${value} — this build understands up to ${FORMAT_VERSION}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Feature names are the join key for a Milestone's `requires`, so they must be
|
||||
* unique — otherwise a requirement resolves ambiguously to the first match. */
|
||||
function requireUniqueFeatureNames(features: readonly { name: string }[]): void {
|
||||
const seen = new Set<string>()
|
||||
for (const f of features) {
|
||||
if (seen.has(f.name)) {
|
||||
throw new PlanParseError(`duplicate feature name "${f.name}" — feature names must be unique`)
|
||||
}
|
||||
seen.add(f.name)
|
||||
}
|
||||
}
|
||||
|
||||
function asBlocks(value: unknown, key: string): unknown[] {
|
||||
if (value == null) return []
|
||||
if (!Array.isArray(value)) {
|
||||
throw new PlanParseError(`\`${key}\` must be written as [[${key}]] blocks`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
/** "feature \"Payments\"" when the block carries a name, else "feature #2". */
|
||||
function blockWhere(kind: string, block: unknown, i: number): string {
|
||||
const name =
|
||||
block != null && typeof block === "object" && "name" in block
|
||||
? (block as { name: unknown }).name
|
||||
: undefined
|
||||
return name != null && name !== "" ? `${kind} "${String(name)}"` : `${kind} #${i + 1}`
|
||||
}
|
||||
|
||||
/** Validate `value` against `schema`, raising a contextual PlanParseError. */
|
||||
function check<S extends v.GenericSchema>(
|
||||
schema: S,
|
||||
value: unknown,
|
||||
where: string,
|
||||
field?: string,
|
||||
): v.InferOutput<S> {
|
||||
const result = v.safeParse(schema, value)
|
||||
if (!result.success) {
|
||||
throw new PlanParseError(`${where}: ${friendly(result.issues[0], field)}`)
|
||||
}
|
||||
return result.output
|
||||
}
|
||||
|
||||
type Issue = {
|
||||
readonly message: string
|
||||
readonly received: string
|
||||
readonly path?: ReadonlyArray<{ readonly key: unknown }>
|
||||
}
|
||||
|
||||
/** Render an issue against the offending field, e.g. "`start` must be a date"
|
||||
* — or "missing `original`" when the key is absent (valibot reports its own
|
||||
* "Invalid key" wording for that, which isn't fit to show the author). */
|
||||
function friendly(issue: Issue, fallbackField?: string): string {
|
||||
const key = issue.path?.[0]?.key
|
||||
const field = typeof key === "string" ? key : fallbackField
|
||||
if (issue.received === "undefined") return field ? `missing \`${field}\`` : "missing value"
|
||||
return field ? `\`${field}\` ${issue.message}` : issue.message
|
||||
}
|
||||
62
src/modules/macroplan/model/plan.spec.ts
Normal file
62
src/modules/macroplan/model/plan.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { parseMacroplan, PlanParseError } from "./parse"
|
||||
import { buildPlan } from "./plan"
|
||||
|
||||
const TODAY = "2026-06-17" // a Wednesday → week of Mon 2026-06-15
|
||||
|
||||
const SOURCE = `
|
||||
title = "Q3 plan"
|
||||
|
||||
[[feature]]
|
||||
name = "Auth"
|
||||
start = 2026-06-01
|
||||
original = 2026-06-15
|
||||
delivered = 2026-06-15
|
||||
|
||||
[[feature]]
|
||||
name = "Payments"
|
||||
start = 2026-06-01
|
||||
original = 2026-06-08
|
||||
reestimates = [2026-06-22]
|
||||
status = "at-risk"
|
||||
|
||||
[[milestone]]
|
||||
name = "MVP"
|
||||
week = 2026-06-15
|
||||
requires = ["Auth", "Payments"]
|
||||
`
|
||||
|
||||
describe("macroplan model", () => {
|
||||
it("derives a render-ready plan from TOML", () => {
|
||||
const plan = buildPlan(parseMacroplan(SOURCE), TODAY)
|
||||
|
||||
expect(plan.title).toBe("Q3 plan")
|
||||
expect(plan.weeks).toEqual([
|
||||
"2026-06-01",
|
||||
"2026-06-08",
|
||||
"2026-06-15",
|
||||
"2026-06-22"
|
||||
])
|
||||
expect(plan.nowWeek).toBe("2026-06-15")
|
||||
|
||||
const auth = plan.rows.find((r) => r.name === "Auth")
|
||||
expect(auth?.onTime).toBe(true)
|
||||
expect(auth?.markers).toContainEqual({
|
||||
week: "2026-06-15",
|
||||
kind: "delivered-on-time"
|
||||
})
|
||||
|
||||
const payments = plan.rows.find((r) => r.name === "Payments")
|
||||
expect(payments?.delivered).toBe(false)
|
||||
expect(payments?.slipCount).toBe(1)
|
||||
|
||||
expect(plan.milestones[0].unmet).toEqual(["Payments"])
|
||||
})
|
||||
|
||||
it("rejects malformed sources with an author-safe error", () => {
|
||||
expect(() => parseMacroplan('[[feature]]\nname = "No dates"')).toThrow(
|
||||
PlanParseError
|
||||
)
|
||||
})
|
||||
})
|
||||
87
src/modules/macroplan/model/plan.ts
Normal file
87
src/modules/macroplan/model/plan.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
import type { FeatureRow, Marker, MilestoneLine,Plan, RawFeature, RawPlan } from "./types"
|
||||
import { mondayOf, type WeekId,weekRange } from "./week"
|
||||
|
||||
/**
|
||||
* Derive the render-ready Plan from the raw model (component C2).
|
||||
*
|
||||
* On-time vs. late is judged ONLY against the Original Estimate, never a
|
||||
* Re-estimate (ADR-0001). The full slip history (`◯` + every `△`) is preserved.
|
||||
*/
|
||||
export function buildPlan(raw: RawPlan, today: Date | string = new Date()): Plan {
|
||||
const nowWeek = mondayOf(today)
|
||||
const rows = raw.features.map((f) => buildRow(f, nowWeek))
|
||||
|
||||
const milestones = raw.milestones.map((m): MilestoneLine => {
|
||||
const week = mondayOf(m.week)
|
||||
const unmet = m.requires.filter((name) => {
|
||||
const row = rows.find((r) => r.name === name)
|
||||
if (!row) return true // references an unknown Feature → can't be met
|
||||
const delivery = row.markers.find(
|
||||
(x) => x.kind === "delivered-on-time" || x.kind === "delivered-late",
|
||||
)
|
||||
return !delivery || delivery.week > week // undelivered, or delivered after the milestone
|
||||
})
|
||||
return { name: m.name, week, requires: m.requires, unmet }
|
||||
})
|
||||
|
||||
// Range: earliest start to last marker/milestone (CONTEXT.md). Empty weeks drawn.
|
||||
// Optional authored `start`/`end` widen this span with lead-in / trailing weeks;
|
||||
// they only ever extend it — a Feature or marker outside them is never clipped.
|
||||
const allWeeks: WeekId[] = []
|
||||
if (raw.start != null) allWeeks.push(mondayOf(raw.start))
|
||||
if (raw.end != null) allWeeks.push(mondayOf(raw.end))
|
||||
for (const r of rows) {
|
||||
allWeeks.push(r.startWeek, r.barEndWeek)
|
||||
for (const mk of r.markers) allWeeks.push(mk.week)
|
||||
}
|
||||
for (const m of milestones) allWeeks.push(m.week)
|
||||
|
||||
let weeks: WeekId[] = []
|
||||
if (allWeeks.length) {
|
||||
const start = allWeeks.reduce((a, b) => (a < b ? a : b))
|
||||
const end = allWeeks.reduce((a, b) => (a > b ? a : b))
|
||||
weeks = weekRange(start, end)
|
||||
}
|
||||
const nowInRange = weeks.length > 0 && nowWeek >= weeks[0] && nowWeek <= weeks[weeks.length - 1]
|
||||
|
||||
return { title: raw.title, weeks, rows, milestones, nowWeek, nowInRange }
|
||||
}
|
||||
|
||||
function buildRow(f: RawFeature, nowWeek: WeekId): FeatureRow {
|
||||
const startWeek = mondayOf(f.start)
|
||||
const originalWeek = mondayOf(f.original)
|
||||
const deliveredWeek = f.delivered ? mondayOf(f.delivered) : undefined
|
||||
const delivered = deliveredWeek != null
|
||||
// ADR-0001: compare the Delivery week to the Original Estimate week only.
|
||||
const onTime = delivered ? deliveredWeek! <= originalWeek : null
|
||||
|
||||
const markers: Marker[] = []
|
||||
for (const re of f.reestimates) markers.push({ week: mondayOf(re), kind: "reestimate" })
|
||||
if (delivered) {
|
||||
markers.push({ week: deliveredWeek!, kind: onTime ? "delivered-on-time" : "delivered-late" })
|
||||
}
|
||||
// The Original Estimate `◯` stands unless an on-time/early delivery already
|
||||
// occupies (or precedes) it — then the delivery marker speaks for it.
|
||||
if (!(delivered && onTime)) {
|
||||
markers.push({ week: originalWeek, kind: "original" })
|
||||
}
|
||||
|
||||
const intrinsicEnd = [startWeek, ...markers.map((m) => m.week)].reduce((a, b) => (a > b ? a : b))
|
||||
// A delivered Feature's bar ends at its delivery. An undelivered Feature that
|
||||
// is already past its furthest estimate keeps "running" up to now (overdue),
|
||||
// so its bar extends to the now week; otherwise it ends at the last marker.
|
||||
const barEndWeek = delivered || intrinsicEnd > nowWeek ? intrinsicEnd : nowWeek
|
||||
|
||||
return {
|
||||
name: f.name,
|
||||
startWeek,
|
||||
barEndWeek,
|
||||
markers,
|
||||
delivered,
|
||||
onTime,
|
||||
status: f.status,
|
||||
note: f.note,
|
||||
learning: f.learning,
|
||||
slipCount: f.reestimates.length,
|
||||
}
|
||||
}
|
||||
72
src/modules/macroplan/model/types.ts
Normal file
72
src/modules/macroplan/model/types.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import type { WeekId } from "./week"
|
||||
|
||||
export type StatusLevel = "on-track" | "at-risk" | "off-track"
|
||||
|
||||
// ── Raw model: as authored, after TOML parse + validation, before derivation ──
|
||||
|
||||
export interface RawFeature {
|
||||
name: string
|
||||
start: string // yyyy-mm-dd
|
||||
original: string // yyyy-mm-dd — the Original Estimate
|
||||
reestimates: string[] // yyyy-mm-dd[]
|
||||
delivered?: string // yyyy-mm-dd
|
||||
learning?: string
|
||||
status?: StatusLevel
|
||||
note?: string
|
||||
}
|
||||
|
||||
export interface RawMilestone {
|
||||
name: string
|
||||
week: string // yyyy-mm-dd
|
||||
requires: string[] // Feature names
|
||||
}
|
||||
|
||||
export interface RawPlan {
|
||||
title: string
|
||||
start?: string // yyyy-mm-dd — optional authored left edge of the plan's span
|
||||
end?: string // yyyy-mm-dd — optional authored right edge of the plan's span
|
||||
features: RawFeature[]
|
||||
milestones: RawMilestone[]
|
||||
}
|
||||
|
||||
// ── Derived model: render-ready (C2 output) ──
|
||||
|
||||
export type MarkerKind =
|
||||
| "original" // ◯ Original Estimate, not yet delivered
|
||||
| "reestimate" // △ a slip to a later week
|
||||
| "delivered-on-time" // ◉ delivered on/before the Original Estimate
|
||||
| "delivered-late" // ▲ delivered after the Original Estimate
|
||||
|
||||
export interface Marker {
|
||||
week: WeekId
|
||||
kind: MarkerKind
|
||||
}
|
||||
|
||||
export interface FeatureRow {
|
||||
name: string
|
||||
startWeek: WeekId
|
||||
barEndWeek: WeekId
|
||||
markers: Marker[]
|
||||
delivered: boolean
|
||||
onTime: boolean | null // null while in-flight
|
||||
status?: StatusLevel
|
||||
note?: string
|
||||
learning?: string
|
||||
slipCount: number // number of Re-estimates
|
||||
}
|
||||
|
||||
export interface MilestoneLine {
|
||||
name: string
|
||||
week: WeekId
|
||||
requires: string[]
|
||||
unmet: string[] // required Features not delivered on/before this week
|
||||
}
|
||||
|
||||
export interface Plan {
|
||||
title: string
|
||||
weeks: WeekId[]
|
||||
rows: FeatureRow[]
|
||||
milestones: MilestoneLine[]
|
||||
nowWeek: WeekId
|
||||
nowInRange: boolean
|
||||
}
|
||||
59
src/modules/macroplan/model/week.ts
Normal file
59
src/modules/macroplan/model/week.ts
Normal file
@@ -0,0 +1,59 @@
|
||||
// Week math. A "Week" is identified by the ISO date (yyyy-mm-dd) of its Monday.
|
||||
// ISO yyyy-mm-dd strings sort lexicographically == chronologically, so plain
|
||||
// string comparison (`<`, `<=`) is valid week ordering — we lean on that.
|
||||
|
||||
export type WeekId = string // 'yyyy-mm-dd', always a Monday
|
||||
|
||||
/**
|
||||
* Extract a yyyy-mm-dd string from a TOML date (smol-toml's `TomlDate.toISOString()`
|
||||
* returns the authored local date, e.g. "2026-06-01") or a plain string.
|
||||
*/
|
||||
export function toYmd(value: unknown): string {
|
||||
if (value instanceof Date) return value.toISOString().slice(0, 10)
|
||||
if (typeof value === "string") return value.slice(0, 10)
|
||||
throw new Error(`expected a date, got ${JSON.stringify(value)}`)
|
||||
}
|
||||
|
||||
// Anchor at UTC noon so day-of-week / day arithmetic never crosses a DST or
|
||||
// timezone boundary.
|
||||
function utcNoon(ymd: string): Date {
|
||||
const [y, m, d] = ymd.split("-").map(Number)
|
||||
return new Date(Date.UTC(y, m - 1, d, 12))
|
||||
}
|
||||
|
||||
function fmt(dt: Date): string {
|
||||
return dt.toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
/** The Monday (yyyy-mm-dd) of the ISO week containing the given date. */
|
||||
export function mondayOf(value: unknown): WeekId {
|
||||
const dt = utcNoon(toYmd(value))
|
||||
const dow = dt.getUTCDay() // 0=Sun .. 6=Sat
|
||||
const shift = dow === 0 ? -6 : 1 - dow
|
||||
dt.setUTCDate(dt.getUTCDate() + shift)
|
||||
return fmt(dt)
|
||||
}
|
||||
|
||||
/** Add n weeks to a Monday WeekId (n may be negative). */
|
||||
export function addWeeks(week: WeekId, n: number): WeekId {
|
||||
const dt = utcNoon(week)
|
||||
dt.setUTCDate(dt.getUTCDate() + n * 7)
|
||||
return fmt(dt)
|
||||
}
|
||||
|
||||
/** Inclusive list of Monday WeekIds from start..end (both must already be Mondays). */
|
||||
export function weekRange(start: WeekId, end: WeekId): WeekId[] {
|
||||
if (start > end) return [start]
|
||||
const weeks: WeekId[] = []
|
||||
for (let w = start; w <= end; w = addWeeks(w, 1)) weeks.push(w)
|
||||
return weeks
|
||||
}
|
||||
|
||||
/** Short column label for a week, e.g. "Jun 15". */
|
||||
export function weekLabel(week: WeekId): string {
|
||||
return utcNoon(week).toLocaleDateString("en-US", {
|
||||
month: "short",
|
||||
day: "2-digit",
|
||||
timeZone: "UTC",
|
||||
})
|
||||
}
|
||||
40
src/modules/macroplan/runMacroplan.spec.ts
Normal file
40
src/modules/macroplan/runMacroplan.spec.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
||||
|
||||
import { runMacroplan } from "./runMacroplan"
|
||||
|
||||
const placeholder = (source: string): HTMLElement => {
|
||||
const el = document.createElement("div")
|
||||
el.className = "macroplan-block"
|
||||
el.dataset.macroplanSource = encodeUTF8ToBase64(source)
|
||||
document.body.appendChild(el)
|
||||
return el
|
||||
}
|
||||
|
||||
describe("runMacroplan", () => {
|
||||
it("mounts the grid onto a valid placeholder", async () => {
|
||||
const el = placeholder(
|
||||
'title = "Plan"\n\n[[feature]]\nname = "Auth"\nstart = 2026-06-01\noriginal = 2026-06-15\n'
|
||||
)
|
||||
|
||||
await runMacroplan(".macroplan-block")
|
||||
|
||||
expect(el.dataset.macroplanRendered).toBe("true")
|
||||
expect(el.querySelector(".macroplan")).not.toBeNull()
|
||||
expect(el.textContent).toContain("Auth")
|
||||
el.remove()
|
||||
})
|
||||
|
||||
it("renders the parse error inline for malformed sources", async () => {
|
||||
const el = placeholder('[[feature]]\nname = "No dates"')
|
||||
|
||||
await runMacroplan(".macroplan-block")
|
||||
|
||||
expect(el.dataset.macroplanRendered).toBe("error")
|
||||
expect(el.querySelector(".macroplan-error")?.textContent).toContain(
|
||||
"missing `start`"
|
||||
)
|
||||
el.remove()
|
||||
})
|
||||
})
|
||||
55
src/modules/macroplan/runMacroplan.ts
Normal file
55
src/modules/macroplan/runMacroplan.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import { createApp } from "vue"
|
||||
|
||||
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
|
||||
|
||||
const renderMacroplanError = (el: HTMLElement, err: unknown): void => {
|
||||
const msg = err instanceof Error ? err.message : String(err)
|
||||
el.textContent = ""
|
||||
const box = document.createElement("div")
|
||||
box.className = "macroplan-error"
|
||||
const title = document.createElement("strong")
|
||||
title.textContent = "Macroplan error"
|
||||
const detail = document.createElement("pre")
|
||||
detail.textContent = msg
|
||||
box.append(title, detail)
|
||||
el.appendChild(box)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount a MacroplanGrid onto every ```macroplan placeholder in scope.
|
||||
* The parser (smol-toml + valibot) and the grid component only load
|
||||
* when a placeholder actually exists, so notes without a plan pay nothing.
|
||||
*/
|
||||
export const runMacroplan = async (querySelector: string): Promise<void> => {
|
||||
const elements = Array.from(
|
||||
document.querySelectorAll<HTMLElement>(querySelector)
|
||||
).filter((el) => !el.dataset.macroplanRendered)
|
||||
if (elements.length === 0) return
|
||||
|
||||
const [{ parseMacroplan }, { buildPlan }, { default: MacroplanGrid }] =
|
||||
await Promise.all([
|
||||
import("./model/parse"),
|
||||
import("./model/plan"),
|
||||
import("./components/MacroplanGrid.vue")
|
||||
])
|
||||
|
||||
for (const el of elements) {
|
||||
el.dataset.macroplanRendered = "pending"
|
||||
|
||||
const encoded = el.dataset.macroplanSource
|
||||
if (!encoded) {
|
||||
el.dataset.macroplanRendered = "error"
|
||||
continue
|
||||
}
|
||||
|
||||
try {
|
||||
const plan = buildPlan(parseMacroplan(decodeBase64ToUTF8(encoded)))
|
||||
el.textContent = ""
|
||||
createApp(MacroplanGrid, { plan }).mount(el)
|
||||
el.dataset.macroplanRendered = "true"
|
||||
} catch (err) {
|
||||
renderMacroplanError(el, err)
|
||||
el.dataset.macroplanRendered = "error"
|
||||
}
|
||||
}
|
||||
}
|
||||
65
src/modules/note/cache/prepareNoteCache.spec.ts
vendored
Normal file
65
src/modules/note/cache/prepareNoteCache.spec.ts
vendored
Normal file
@@ -0,0 +1,65 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
vi.mock("@/data/data", () => ({
|
||||
data: {
|
||||
get: vi.fn().mockResolvedValue(null),
|
||||
update: vi.fn().mockResolvedValue(undefined)
|
||||
},
|
||||
generateId: (type: string, id: string) => `${type}-${id}`
|
||||
}))
|
||||
|
||||
const addFile = vi.fn()
|
||||
vi.mock("@/modules/repo/store/userRepo.store", () => ({
|
||||
useUserRepoStore: () => ({ addFile })
|
||||
}))
|
||||
|
||||
import { data, generateId } from "@/data/data"
|
||||
import { DataType } from "@/data/DataType.enum"
|
||||
|
||||
import { prepareNoteCache } from "./prepareNoteCache"
|
||||
|
||||
const writtenIds = () =>
|
||||
vi.mocked(data.update).mock.calls.map((c) => (c[0] as { _id: string })._id)
|
||||
|
||||
describe("prepareNoteCache.saveCacheNote", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(data.update).mockClear()
|
||||
addFile.mockClear()
|
||||
})
|
||||
|
||||
it("on edit, keys content by the NEW sha and leaves the viewed sha untouched", async () => {
|
||||
const { saveCacheNote } = prepareNoteCache("oldSha", "notes/a.md")
|
||||
|
||||
await saveCacheNote("new content", {
|
||||
editedSha: "newSha",
|
||||
path: "notes/a.md"
|
||||
})
|
||||
|
||||
const ids = writtenIds()
|
||||
// immutable snapshot under the content's own (new) sha
|
||||
expect(ids).toContain(generateId(DataType.Note, "newSha"))
|
||||
// latest pointer under the path
|
||||
expect(ids).toContain(generateId(DataType.Note, "notes/a.md"))
|
||||
// the previously-viewed sha stays immutable
|
||||
expect(ids).not.toContain(generateId(DataType.Note, "oldSha"))
|
||||
})
|
||||
|
||||
it("on a fresh load (no editedSha), keys content by the viewed sha", async () => {
|
||||
const { saveCacheNote } = prepareNoteCache("sha0", "notes/a.md")
|
||||
|
||||
await saveCacheNote("content")
|
||||
|
||||
expect(writtenIds()).toContain(generateId(DataType.Note, "sha0"))
|
||||
})
|
||||
|
||||
it("registers the new sha against the path in the store", async () => {
|
||||
const { saveCacheNote } = prepareNoteCache("oldSha", "notes/a.md")
|
||||
|
||||
await saveCacheNote("new content", {
|
||||
editedSha: "newSha",
|
||||
path: "notes/a.md"
|
||||
})
|
||||
|
||||
expect(addFile).toHaveBeenCalledWith({ path: "notes/a.md", sha: "newSha" })
|
||||
})
|
||||
})
|
||||
14
src/modules/note/cache/prepareNoteCache.ts
vendored
14
src/modules/note/cache/prepareNoteCache.ts
vendored
@@ -21,7 +21,8 @@ export const buildNoteDocs = (
|
||||
_id: generateId(DataType.Note, sha),
|
||||
$type: DataType.Note,
|
||||
content,
|
||||
editedSha
|
||||
editedSha,
|
||||
path
|
||||
}
|
||||
return path
|
||||
? [base, { ...base, _id: generateId(DataType.Note, path) }]
|
||||
@@ -61,11 +62,18 @@ export const prepareNoteCache = (sha: string, path?: string) => {
|
||||
content: string,
|
||||
params?: { editedSha?: string; path?: string }
|
||||
) => {
|
||||
// Content is addressed by its OWN sha so snapshots stay immutable: an edit
|
||||
// writes under the new sha and never overwrites the previously-viewed one.
|
||||
// The path key (notePath) always holds the latest content (live pointer).
|
||||
const contentId = params?.editedSha
|
||||
? generateId(DataType.Note, params.editedSha)
|
||||
: noteId
|
||||
const newNote: Note = {
|
||||
_id: noteId,
|
||||
_id: contentId,
|
||||
$type: DataType.Note,
|
||||
content,
|
||||
editedSha: params?.editedSha
|
||||
editedSha: params?.editedSha,
|
||||
path: params?.path ?? path
|
||||
}
|
||||
|
||||
if (params && params.path) {
|
||||
|
||||
62
src/modules/note/liveNotes.spec.ts
Normal file
62
src/modules/note/liveNotes.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { resolveLivePathsToShas, stackToLivePaths } from "./liveNotes"
|
||||
|
||||
const files = [
|
||||
{ path: "README.md", sha: "a".repeat(40) },
|
||||
{ path: "notes/one.md", sha: "b".repeat(40) },
|
||||
{ path: "notes/two.md", sha: "c".repeat(40) }
|
||||
]
|
||||
|
||||
describe("stackToLivePaths", () => {
|
||||
it("maps each stacked sha to its current file path", () => {
|
||||
expect(stackToLivePaths(["b".repeat(40), "c".repeat(40)], files)).toEqual([
|
||||
"notes/one.md",
|
||||
"notes/two.md"
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps a sha verbatim when no path resolves (drifted snapshot)", () => {
|
||||
const orphan = "d".repeat(40)
|
||||
expect(stackToLivePaths(["b".repeat(40), orphan], files)).toEqual([
|
||||
"notes/one.md",
|
||||
orphan
|
||||
])
|
||||
})
|
||||
|
||||
it("preserves order", () => {
|
||||
expect(stackToLivePaths(["c".repeat(40), "a".repeat(40)], files)).toEqual([
|
||||
"notes/two.md",
|
||||
"README.md"
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("resolveLivePathsToShas", () => {
|
||||
it("resolves each path to its latest sha", () => {
|
||||
expect(
|
||||
resolveLivePathsToShas(["notes/one.md", "notes/two.md"], files)
|
||||
).toEqual(["b".repeat(40), "c".repeat(40)])
|
||||
})
|
||||
|
||||
it("passes a sha-shaped entry through as a pinned fallback", () => {
|
||||
const orphan = "d".repeat(40)
|
||||
expect(resolveLivePathsToShas(["notes/one.md", orphan], files)).toEqual([
|
||||
"b".repeat(40),
|
||||
orphan
|
||||
])
|
||||
})
|
||||
|
||||
it("drops a renamed or deleted path so the view degrades gracefully", () => {
|
||||
expect(
|
||||
resolveLivePathsToShas(["notes/gone.md", "notes/two.md"], files)
|
||||
).toEqual(["c".repeat(40)])
|
||||
})
|
||||
|
||||
it("round-trips a live-shared stack back to the same shas", () => {
|
||||
const shas = ["a".repeat(40), "b".repeat(40)]
|
||||
expect(resolveLivePathsToShas(stackToLivePaths(shas, files), files)).toEqual(
|
||||
shas
|
||||
)
|
||||
})
|
||||
})
|
||||
38
src/modules/note/liveNotes.ts
Normal file
38
src/modules/note/liveNotes.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
// A shared link pins each stacked note to a blob sha, so the recipient sees the
|
||||
// exact snapshot forever. A "living" link trades that pin for freshness: notes
|
||||
// are referenced by their file path instead, and re-resolved to the latest blob
|
||||
// sha against the repo's HEAD file list every time the link is opened.
|
||||
|
||||
const SHA_PATTERN = /^[0-9a-f]{40}$/i
|
||||
|
||||
/**
|
||||
* Encode the current stack (blob shas) as a living reference list. Each note
|
||||
* becomes its file path when one resolves from the HEAD file list, so the link
|
||||
* re-resolves to the latest version on open. A sha with no known path (e.g. an
|
||||
* already-drifted snapshot the sharer never pulled) is kept verbatim, staying
|
||||
* pinned — order is preserved either way.
|
||||
*/
|
||||
export const stackToLivePaths = (
|
||||
shas: ReadonlyArray<string>,
|
||||
files: ReadonlyArray<{ path?: string; sha?: string }>
|
||||
): string[] =>
|
||||
shas.map((sha) => files.find((file) => file.sha === sha)?.path ?? sha)
|
||||
|
||||
/**
|
||||
* Resolve a living reference list back to current blob shas against the HEAD
|
||||
* file list: a path maps to its latest sha; an entry already shaped like a sha
|
||||
* is passed through (the pinned fallback above); anything else — a renamed or
|
||||
* deleted path — is dropped so the view degrades gracefully instead of trying
|
||||
* to fetch a blob that no longer exists.
|
||||
*/
|
||||
export const resolveLivePathsToShas = (
|
||||
entries: ReadonlyArray<string>,
|
||||
files: ReadonlyArray<{ path?: string; sha?: string }>
|
||||
): string[] =>
|
||||
entries
|
||||
.map((entry) => {
|
||||
const latestSha = files.find((file) => file.path === entry)?.sha
|
||||
if (latestSha) return latestSha
|
||||
return SHA_PATTERN.test(entry) ? entry : null
|
||||
})
|
||||
.filter((sha): sha is string => sha !== null)
|
||||
@@ -4,6 +4,9 @@ import { Model } from "@/data/models/Model"
|
||||
export interface Note extends Model<DataType.Note> {
|
||||
content: string
|
||||
editedSha?: string
|
||||
// The note's path, stored so a cached snapshot can find its latest version
|
||||
// even when its (old) sha is no longer in the repo file list.
|
||||
path?: string
|
||||
}
|
||||
|
||||
export interface PublicNoteListItem {
|
||||
|
||||
23
src/modules/note/snapshotStatus.spec.ts
Normal file
23
src/modules/note/snapshotStatus.spec.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { latestShaIfOlder } from "./snapshotStatus"
|
||||
|
||||
const files = [
|
||||
{ path: "notes/a.md", sha: "current" },
|
||||
{ path: "notes/b.md", sha: "other" }
|
||||
]
|
||||
|
||||
describe("latestShaIfOlder", () => {
|
||||
it("returns the current sha when viewing an older version of a known note", () => {
|
||||
expect(latestShaIfOlder("old", "notes/a.md", files)).toBe("current")
|
||||
})
|
||||
|
||||
it("returns null when viewing the current version", () => {
|
||||
expect(latestShaIfOlder("current", "notes/a.md", files)).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null when the path is unknown", () => {
|
||||
expect(latestShaIfOlder("old", undefined, files)).toBeNull()
|
||||
expect(latestShaIfOlder("old", "notes/missing.md", files)).toBeNull()
|
||||
})
|
||||
})
|
||||
25
src/modules/note/snapshotStatus.ts
Normal file
25
src/modules/note/snapshotStatus.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
/**
|
||||
* Return the note's current (latest) sha when `viewedSha` is NOT it — i.e. you
|
||||
* are viewing an older version — otherwise null. "Older" is not inferred from
|
||||
* the sha (a content hash has no order); it means "not the current sha for this
|
||||
* path" per the repo file list.
|
||||
*
|
||||
* The caller must supply `notePath`. For a current sha it comes from the file
|
||||
* list; for an older sha it comes from the cached snapshot's stored `path`
|
||||
* (notes viewed while current carry it). When the path can't be resolved
|
||||
* (foreign / evicted / pre-upgrade snapshot) this returns null and no banner is
|
||||
* shown — graceful, never a false claim. Content is never swapped.
|
||||
*/
|
||||
export const latestShaIfOlder = (
|
||||
viewedSha: string,
|
||||
notePath: string | undefined,
|
||||
files: ReadonlyArray<{ path?: string; sha?: string }>
|
||||
): string | null => {
|
||||
if (!notePath) {
|
||||
return null
|
||||
}
|
||||
|
||||
const currentSha = files.find((file) => file.path === notePath)?.sha
|
||||
|
||||
return currentSha && currentSha !== viewedSha ? currentSha : null
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
import { initContract } from "@ts-rest/core"
|
||||
import { initQueryClient } from "@ts-rest/vue-query"
|
||||
import { type } from "arktype"
|
||||
|
||||
const PublicNoteListItem = type({
|
||||
did: "string",
|
||||
rkey: "string",
|
||||
title: "string",
|
||||
publishedAt: "string",
|
||||
createdAt: "string"
|
||||
})
|
||||
|
||||
export type PublicNoteListItem = typeof PublicNoteListItem.infer
|
||||
|
||||
const PublicNote = type({
|
||||
did: "string",
|
||||
rkey: "string",
|
||||
title: "string",
|
||||
content: "string",
|
||||
publishedAt: "string",
|
||||
createdAt: "string"
|
||||
})
|
||||
|
||||
export type PublicNote = typeof PublicNote.infer
|
||||
|
||||
const contract = initContract()
|
||||
|
||||
export const noteRouter = contract.router({
|
||||
noteLists: {
|
||||
method: "GET",
|
||||
path: "/notes",
|
||||
query: contract.type<{ cursor?: string; limit?: number }>(),
|
||||
responses: {
|
||||
200: contract.type<{ notes: PublicNoteListItem[] }>()
|
||||
},
|
||||
summary: "List all notes"
|
||||
},
|
||||
noteListsByDid: {
|
||||
method: "GET",
|
||||
path: "/:did/notes",
|
||||
pathParams: contract.type<{ did: string }>(),
|
||||
query: contract.type<{ cursor?: string; limit?: number }>(),
|
||||
responses: {
|
||||
200: contract.type<{ notes: PublicNoteListItem[] }>()
|
||||
},
|
||||
summary: "List all notes"
|
||||
}
|
||||
})
|
||||
|
||||
export const client = initQueryClient(noteRouter, {
|
||||
baseUrl: "https://api.remanso.space"
|
||||
})
|
||||
@@ -8,7 +8,7 @@ export interface UserSettings extends Model<DataType.UserSettings> {
|
||||
fontSize?: string
|
||||
chosenFontSize?: string
|
||||
backlink?: boolean
|
||||
chosenTitleFont?: string
|
||||
chosenHeadingFont?: string
|
||||
chosenBodyFont?: string
|
||||
pageWidth?: string
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ import { getOctokit, runWithAuthRetry } from "./octo"
|
||||
import {
|
||||
getFiles,
|
||||
getRepoPermission,
|
||||
getUserSettingsContent,
|
||||
queryFileContent
|
||||
} from "./repo"
|
||||
|
||||
@@ -165,3 +166,58 @@ describe("queryFileContent", () => {
|
||||
expect(warn).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe("getUserSettingsContent", () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(runWithAuthRetry).mockReset()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const configFiles = [
|
||||
{ path: ".remanso.json", type: "blob", sha: "CFG" }
|
||||
] as never
|
||||
|
||||
const withConfig = (json: object) => {
|
||||
const base64 = btoa(JSON.stringify(json))
|
||||
vi.mocked(runWithAuthRetry).mockImplementation(async (call) => {
|
||||
const octokit = {
|
||||
request: vi.fn().mockResolvedValue({ data: { content: base64 } })
|
||||
}
|
||||
return call(octokit as never)
|
||||
})
|
||||
}
|
||||
|
||||
it("maps the `h` key to chosenHeadingFont and `p` to chosenBodyFont", async () => {
|
||||
withConfig({ h: "Lora", p: "Inter" })
|
||||
|
||||
const settings = await getUserSettingsContent("owner", "repo", configFiles)
|
||||
|
||||
expect(settings?.chosenHeadingFont).toBe("Lora")
|
||||
expect(settings?.chosenBodyFont).toBe("Inter")
|
||||
})
|
||||
|
||||
it("falls back to the legacy `t` key for chosenHeadingFont", async () => {
|
||||
withConfig({ t: "Merriweather", p: "Inter" })
|
||||
|
||||
const settings = await getUserSettingsContent("owner", "repo", configFiles)
|
||||
|
||||
expect(settings?.chosenHeadingFont).toBe("Merriweather")
|
||||
})
|
||||
|
||||
it("prefers `h` over the legacy `t` when both are present", async () => {
|
||||
withConfig({ h: "Lora", t: "Merriweather" })
|
||||
|
||||
const settings = await getUserSettingsContent("owner", "repo", configFiles)
|
||||
|
||||
expect(settings?.chosenHeadingFont).toBe("Lora")
|
||||
})
|
||||
|
||||
it("returns null when there is no .remanso.json", async () => {
|
||||
expect(
|
||||
await getUserSettingsContent("owner", "repo", [] as never)
|
||||
).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -123,12 +123,14 @@ export const getUserSettingsContent = async (
|
||||
|
||||
const raw = JSON.parse(atob(content)) as UserSettings & {
|
||||
t?: string
|
||||
h?: string
|
||||
p?: string
|
||||
}
|
||||
const { t, p, ...rest } = raw
|
||||
const { t, h, p, ...rest } = raw
|
||||
return {
|
||||
...rest,
|
||||
chosenTitleFont: t,
|
||||
// `h` (heading) is the current key; `t` (title) is kept for back-compat.
|
||||
chosenHeadingFont: h ?? t,
|
||||
chosenBodyFont: p
|
||||
}
|
||||
}
|
||||
|
||||
@@ -105,6 +105,18 @@ describe("userRepo store — synchronous mutations", () => {
|
||||
expect(vi.mocked(data.update)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("addFile replaces the entry at the same path when content (sha) changed", () => {
|
||||
const store = useUserRepoStore()
|
||||
store.user = "alice"
|
||||
store.repo = "notes"
|
||||
store.files = [{ sha: "old", path: "a.md", type: "blob" }] as never
|
||||
|
||||
store.addFile({ sha: "new", path: "a.md", type: "blob" } as never)
|
||||
|
||||
expect(store.files).toEqual([{ sha: "new", path: "a.md", type: "blob" }])
|
||||
expect(vi.mocked(data.update)).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("setFontFamily initializes userSettings when absent and persists to localStorage", () => {
|
||||
const store = useUserRepoStore()
|
||||
store.user = "alice"
|
||||
@@ -119,22 +131,40 @@ describe("userRepo store — synchronous mutations", () => {
|
||||
expect(persisted.chosenFontFamily).toBe("Inter")
|
||||
})
|
||||
|
||||
it("setFontSize, setTitleFont, setBodyFont each persist their respective field", () => {
|
||||
it("setFontSize, setHeadingFont, setBodyFont each persist their respective field", () => {
|
||||
const store = useUserRepoStore()
|
||||
store.user = "alice"
|
||||
store.repo = "notes"
|
||||
|
||||
store.setFontSize("18px")
|
||||
store.setTitleFont("Serif")
|
||||
store.setHeadingFont("Serif")
|
||||
store.setBodyFont("Sans")
|
||||
|
||||
const persisted = JSON.parse(
|
||||
localStorage.getItem("remanso:layout:alice:notes") as string
|
||||
)
|
||||
expect(persisted.chosenFontSize).toBe("18px")
|
||||
expect(persisted.chosenTitleFont).toBe("Serif")
|
||||
expect(persisted.chosenHeadingFont).toBe("Serif")
|
||||
expect(persisted.chosenBodyFont).toBe("Sans")
|
||||
})
|
||||
|
||||
it("swapFonts exchanges the heading and body fonts and persists", () => {
|
||||
const store = useUserRepoStore()
|
||||
store.user = "alice"
|
||||
store.repo = "notes"
|
||||
store.setHeadingFont("Lora")
|
||||
store.setBodyFont("Inter")
|
||||
|
||||
store.swapFonts()
|
||||
|
||||
expect(store.userSettings?.chosenHeadingFont).toBe("Inter")
|
||||
expect(store.userSettings?.chosenBodyFont).toBe("Lora")
|
||||
const persisted = JSON.parse(
|
||||
localStorage.getItem("remanso:layout:alice:notes") as string
|
||||
)
|
||||
expect(persisted.chosenHeadingFont).toBe("Inter")
|
||||
expect(persisted.chosenBodyFont).toBe("Lora")
|
||||
})
|
||||
})
|
||||
|
||||
describe("userRepo store — setUserRepo", () => {
|
||||
@@ -161,6 +191,25 @@ describe("userRepo store — setUserRepo", () => {
|
||||
expect(store.loadError).toBeNull()
|
||||
})
|
||||
|
||||
it("migrates the legacy chosenTitleFont localStorage key to chosenHeadingFont", async () => {
|
||||
localStorage.setItem(
|
||||
"remanso:layout:alice:notes",
|
||||
JSON.stringify({ chosenTitleFont: "Lora" })
|
||||
)
|
||||
|
||||
const store = useUserRepoStore()
|
||||
await store.setUserRepo("alice", "notes")
|
||||
await flushAsync()
|
||||
await flushAsync()
|
||||
|
||||
expect(store.userSettings?.chosenHeadingFont).toBe("Lora")
|
||||
const persisted = JSON.parse(
|
||||
localStorage.getItem("remanso:layout:alice:notes") as string
|
||||
)
|
||||
expect(persisted.chosenHeadingFont).toBe("Lora")
|
||||
expect(persisted.chosenTitleFont).toBeUndefined()
|
||||
})
|
||||
|
||||
it("populates files from getFiles on success", async () => {
|
||||
vi.mocked(getFiles).mockResolvedValue([
|
||||
{ sha: "a", path: "x.md", type: "blob" } as never
|
||||
|
||||
@@ -56,7 +56,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
||||
if (!this.userSettings) return
|
||||
try {
|
||||
const {
|
||||
chosenTitleFont,
|
||||
chosenHeadingFont,
|
||||
chosenBodyFont,
|
||||
chosenFontSize,
|
||||
chosenFontFamily,
|
||||
@@ -65,7 +65,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
||||
localStorage.setItem(
|
||||
`remanso:layout:${this.user}:${this.repo}`,
|
||||
JSON.stringify({
|
||||
chosenTitleFont,
|
||||
chosenHeadingFont,
|
||||
chosenBodyFont,
|
||||
chosenFontSize,
|
||||
chosenFontFamily,
|
||||
@@ -86,7 +86,17 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
||||
let lsLayout: Partial<UserSettings> = {}
|
||||
try {
|
||||
const lsRaw = localStorage.getItem(`remanso:layout:${user}:${repo}`)
|
||||
if (lsRaw) lsLayout = JSON.parse(lsRaw)
|
||||
if (lsRaw) {
|
||||
const parsed = JSON.parse(lsRaw) as Partial<UserSettings> & {
|
||||
chosenTitleFont?: string
|
||||
}
|
||||
// Migrate the pre-rename key so a saved heading font survives.
|
||||
if (parsed.chosenTitleFont && !parsed.chosenHeadingFont) {
|
||||
parsed.chosenHeadingFont = parsed.chosenTitleFont
|
||||
}
|
||||
delete parsed.chosenTitleFont
|
||||
lsLayout = parsed
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
@@ -157,9 +167,9 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
||||
: userSettings?.fontFamily
|
||||
const chosenFontSize =
|
||||
this.userSettings?.chosenFontSize ?? userSettings?.fontSize
|
||||
const chosenTitleFont =
|
||||
this.userSettings?.chosenTitleFont ??
|
||||
userSettings?.chosenTitleFont ??
|
||||
const chosenHeadingFont =
|
||||
this.userSettings?.chosenHeadingFont ??
|
||||
userSettings?.chosenHeadingFont ??
|
||||
chosenFontFamily
|
||||
const chosenBodyFont =
|
||||
this.userSettings?.chosenBodyFont ??
|
||||
@@ -175,14 +185,14 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
||||
chosenFontFamily ?? this.userSettings.fontFamily
|
||||
this.userSettings.chosenFontSize =
|
||||
chosenFontSize ?? this.userSettings.fontSize
|
||||
this.userSettings.chosenTitleFont = chosenTitleFont
|
||||
this.userSettings.chosenHeadingFont = chosenHeadingFont
|
||||
this.userSettings.chosenBodyFont = chosenBodyFont
|
||||
|
||||
this._persistLayout()
|
||||
|
||||
// Persist only repo config fields — chosen* are localStorage-only
|
||||
const {
|
||||
chosenTitleFont: _t,
|
||||
chosenHeadingFont: _h,
|
||||
chosenBodyFont: _b,
|
||||
chosenFontSize: _s,
|
||||
chosenFontFamily: _f,
|
||||
@@ -233,7 +243,9 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
||||
`${this.user}-${this.repo}`
|
||||
)
|
||||
const newFiles = [
|
||||
...toRaw(this.files).filter((f) => f.sha !== file.sha),
|
||||
...toRaw(this.files).filter(
|
||||
(f) => f.sha !== file.sha && f.path !== file.path
|
||||
),
|
||||
toRaw(file)
|
||||
]
|
||||
data.update<DataType.SavedRepo, SavedRepo>({
|
||||
@@ -292,11 +304,11 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
||||
this.userSettings.chosenFontSize = fontSize
|
||||
this._persistLayout()
|
||||
},
|
||||
setTitleFont(font: string) {
|
||||
setHeadingFont(font: string) {
|
||||
if (!this.userSettings) {
|
||||
this.userSettings = { $type: DataType.UserSettings }
|
||||
}
|
||||
this.userSettings.chosenTitleFont = font
|
||||
this.userSettings.chosenHeadingFont = font
|
||||
this._persistLayout()
|
||||
},
|
||||
setBodyFont(font: string) {
|
||||
@@ -305,6 +317,13 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
||||
}
|
||||
this.userSettings.chosenBodyFont = font
|
||||
this._persistLayout()
|
||||
},
|
||||
swapFonts() {
|
||||
if (!this.userSettings) return
|
||||
const { chosenHeadingFont, chosenBodyFont } = this.userSettings
|
||||
this.userSettings.chosenHeadingFont = chosenBodyFont
|
||||
this.userSettings.chosenBodyFont = chosenHeadingFont
|
||||
this._persistLayout()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
@@ -16,12 +16,12 @@ export const useUserSettings = () => {
|
||||
|
||||
const fontSize = store.userSettings?.chosenFontSize
|
||||
const bodyFont = store.userSettings?.chosenBodyFont
|
||||
const titleFont = store.userSettings?.chosenTitleFont
|
||||
const headingFont = store.userSettings?.chosenHeadingFont
|
||||
|
||||
downloadFont(bodyFont || DEFAULT_FONT_POLICY, "--font-family")
|
||||
downloadFont(
|
||||
titleFont || bodyFont || DEFAULT_FONT_POLICY,
|
||||
"--title-font-family"
|
||||
headingFont || bodyFont || DEFAULT_FONT_POLICY,
|
||||
"--heading-font-family"
|
||||
)
|
||||
root.style.setProperty("--font-size", fontSize || DEFAULT_FONT_SIZE)
|
||||
|
||||
|
||||
30
src/modules/user/service/oauthReturnPath.spec.ts
Normal file
30
src/modules/user/service/oauthReturnPath.spec.ts
Normal file
@@ -0,0 +1,30 @@
|
||||
import { beforeEach, describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
consumeGithubOAuthReturnPath,
|
||||
GITHUB_OAUTH_RETURN_PATH_KEY
|
||||
} from "./oauthReturnPath"
|
||||
|
||||
describe("consumeGithubOAuthReturnPath", () => {
|
||||
beforeEach(() => {
|
||||
sessionStorage.clear()
|
||||
})
|
||||
|
||||
it("returns the stored path and clears the key", () => {
|
||||
sessionStorage.setItem(GITHUB_OAUTH_RETURN_PATH_KEY, "/alice/notes")
|
||||
|
||||
expect(consumeGithubOAuthReturnPath()).toBe("/alice/notes")
|
||||
expect(sessionStorage.getItem(GITHUB_OAUTH_RETURN_PATH_KEY)).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null when nothing is stored", () => {
|
||||
expect(consumeGithubOAuthReturnPath()).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null on the second call (consume-once semantics)", () => {
|
||||
sessionStorage.setItem(GITHUB_OAUTH_RETURN_PATH_KEY, "/x")
|
||||
|
||||
expect(consumeGithubOAuthReturnPath()).toBe("/x")
|
||||
expect(consumeGithubOAuthReturnPath()).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -6,7 +6,7 @@
|
||||
:root {
|
||||
--primary-color: #ffa4c0;
|
||||
--font-family: "Libertinus Serif", serif;
|
||||
--title-font-family: "Libertinus Serif", serif;
|
||||
--heading-font-family: "Libertinus Serif", serif;
|
||||
--font-size: 13pt;
|
||||
--font-color: #4a4a4a;
|
||||
--link: #445fb9;
|
||||
@@ -182,6 +182,50 @@ pre {
|
||||
}
|
||||
}
|
||||
|
||||
:not(pre) > code {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
/* Line numbers for whole-file code views (non-markdown files).
|
||||
Scoped to .code-file so fenced blocks inside notes stay untouched. */
|
||||
.code-file pre code {
|
||||
counter-reset: line;
|
||||
}
|
||||
|
||||
.code-file pre code .line::before {
|
||||
counter-increment: line;
|
||||
content: counter(line);
|
||||
display: inline-block;
|
||||
width: var(--line-number-width, 3ch);
|
||||
margin-right: 1.5ch;
|
||||
text-align: right;
|
||||
color: var(--color-base-content);
|
||||
opacity: 0.35;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* Shikiji renders dual-theme tokens but only inlines the light colors;
|
||||
the dark palette lives in --shiki-dark* vars that we must apply ourselves.
|
||||
Without this, dark theme keeps the white code background and the
|
||||
base-content line numbers become white-on-white. */
|
||||
[data-theme="dracula"] :is(.shiki, .shiki span) {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:not([data-theme="light"]) :is(.shiki, .shiki span) {
|
||||
color: var(--shiki-dark) !important;
|
||||
background-color: var(--shiki-dark-bg) !important;
|
||||
font-style: var(--shiki-dark-font-style) !important;
|
||||
font-weight: var(--shiki-dark-font-weight) !important;
|
||||
text-decoration: var(--shiki-dark-text-decoration) !important;
|
||||
}
|
||||
}
|
||||
|
||||
pre.mermaid {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
@@ -214,11 +258,20 @@ pre.tikz svg {
|
||||
}
|
||||
}
|
||||
|
||||
.tikz-loading {
|
||||
.tikz-loading,
|
||||
.macroplan-loading {
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* The grid's milestone-flag stacking math assumes a monospace advance
|
||||
(BAND_CHAR in MacroplanGrid.vue), and the macroplan app renders in
|
||||
Fira Code — don't let the note's serif leak in. */
|
||||
.macroplan-block {
|
||||
font-family: "Fira Code", ui-monospace, SFMono-Regular, Menlo, Consolas,
|
||||
monospace;
|
||||
}
|
||||
|
||||
.svg-download-host {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
@@ -260,14 +313,16 @@ pre.tikz svg {
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.tikz-error {
|
||||
.tikz-error,
|
||||
.macroplan-error {
|
||||
border-left: 3px solid var(--color-error);
|
||||
padding: 0.5rem 0.75rem;
|
||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.tikz-error pre {
|
||||
.tikz-error pre,
|
||||
.macroplan-error pre {
|
||||
white-space: pre-wrap;
|
||||
font-size: 0.85em;
|
||||
}
|
||||
@@ -332,15 +387,6 @@ iframe {
|
||||
height: 400px;
|
||||
}
|
||||
|
||||
/* TODO page */
|
||||
.todo-notes input[type="checkbox"] {
|
||||
@apply checkbox checkbox-success;
|
||||
}
|
||||
|
||||
.todo-notes li:has(> input[type="checkbox"]) {
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.tabs :where(pre):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||
margin-top: 0;
|
||||
margin-bottom: 0;
|
||||
@@ -349,3 +395,72 @@ iframe {
|
||||
padding-bottom: 0.2rem;
|
||||
padding-inline-start: 0.2rem;
|
||||
}
|
||||
|
||||
/* Image lightbox — desktop click-to-fullscreen */
|
||||
|
||||
/* Affordance: zoomable content images and diagrams (desktop only). */
|
||||
@media (min-width: 769px) {
|
||||
.note-content img,
|
||||
.note-display img,
|
||||
.tikz svg,
|
||||
.mermaid svg {
|
||||
cursor: zoom-in;
|
||||
}
|
||||
}
|
||||
|
||||
/* DaisyUI hides the native ::backdrop and uses the .modal element's own
|
||||
background-color for the dim, so the blurred overlay lives on the element. */
|
||||
.image-lightbox {
|
||||
background-color: rgb(0 0 0 / 0);
|
||||
-webkit-backdrop-filter: blur(0);
|
||||
backdrop-filter: blur(0);
|
||||
transition:
|
||||
visibility 0.3s allow-discrete,
|
||||
background-color 0.3s ease-out,
|
||||
-webkit-backdrop-filter 0.3s ease-out,
|
||||
backdrop-filter 0.3s ease-out;
|
||||
}
|
||||
|
||||
.image-lightbox[open] {
|
||||
background-color: rgb(0 0 0 / 0.7);
|
||||
-webkit-backdrop-filter: blur(8px);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.image-lightbox[open] {
|
||||
background-color: rgb(0 0 0 / 0);
|
||||
-webkit-backdrop-filter: blur(0);
|
||||
backdrop-filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
.image-lightbox img {
|
||||
transition:
|
||||
opacity 0.3s ease-out,
|
||||
scale 0.3s ease-out;
|
||||
}
|
||||
|
||||
.image-lightbox[open] img {
|
||||
opacity: 1;
|
||||
scale: 1;
|
||||
}
|
||||
|
||||
.image-lightbox:not([open]) img {
|
||||
opacity: 0;
|
||||
scale: 0.95;
|
||||
}
|
||||
|
||||
@starting-style {
|
||||
.image-lightbox[open] img {
|
||||
opacity: 0;
|
||||
scale: 0.95;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.image-lightbox,
|
||||
.image-lightbox img {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
38
src/utils/displayLanguage.spec.ts
Normal file
38
src/utils/displayLanguage.spec.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
|
||||
import { displayLanguage } from "./displayLanguage"
|
||||
|
||||
describe("displayLanguage", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it("returns null when no language code is given", () => {
|
||||
expect(displayLanguage()).toBeNull()
|
||||
expect(displayLanguage("")).toBeNull()
|
||||
})
|
||||
|
||||
it("returns a human-readable name for a known language code", () => {
|
||||
expect(displayLanguage("en")).toMatch(/^English/i)
|
||||
})
|
||||
|
||||
it("works for French", () => {
|
||||
expect(displayLanguage("fr")).toBeTruthy()
|
||||
})
|
||||
|
||||
it("returns null and logs a warning when Intl.DisplayNames throws", () => {
|
||||
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||
const original = Intl.DisplayNames
|
||||
// Force the constructor to throw
|
||||
;(Intl as unknown as { DisplayNames: unknown }).DisplayNames = function () {
|
||||
throw new Error("boom")
|
||||
}
|
||||
|
||||
try {
|
||||
expect(displayLanguage("en")).toBeNull()
|
||||
expect(warn).toHaveBeenCalled()
|
||||
} finally {
|
||||
;(Intl as unknown as { DisplayNames: unknown }).DisplayNames = original
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -15,6 +15,7 @@ const EXT_TO_LANG: Record<string, string> = {
|
||||
scss: "css",
|
||||
json: "json",
|
||||
jsonc: "json",
|
||||
rs: "rust",
|
||||
als: "alloy"
|
||||
}
|
||||
|
||||
|
||||
33
src/utils/link.spec.ts
Normal file
33
src/utils/link.spec.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { isExternalLink } from "./link"
|
||||
|
||||
describe("isExternalLink", () => {
|
||||
const ORIGIN = window.location.origin
|
||||
|
||||
it("returns false for same-origin absolute URLs", () => {
|
||||
expect(isExternalLink(`${ORIGIN}/alice/notes`)).toBe(false)
|
||||
})
|
||||
|
||||
it("returns true for an https URL on a different origin", () => {
|
||||
expect(isExternalLink("https://github.com/anywhere")).toBe(true)
|
||||
})
|
||||
|
||||
it("returns true for an http URL on a different origin", () => {
|
||||
expect(isExternalLink("http://example.com")).toBe(true)
|
||||
})
|
||||
|
||||
it("returns false for relative paths (no http/https prefix)", () => {
|
||||
expect(isExternalLink("/alice/notes")).toBe(false)
|
||||
expect(isExternalLink("./neighbor.md")).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false for mailto and other non-http schemes", () => {
|
||||
expect(isExternalLink("mailto:user@example.com")).toBe(false)
|
||||
expect(isExternalLink("ftp://files.example.com")).toBe(false)
|
||||
})
|
||||
|
||||
it("returns false for an https URL that happens to start with the origin", () => {
|
||||
expect(isExternalLink(`${ORIGIN}/deep/path`)).toBe(false)
|
||||
})
|
||||
})
|
||||
26
src/utils/markdown/renderFallback.spec.ts
Normal file
26
src/utils/markdown/renderFallback.spec.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { renderFallback } from "./renderFallback"
|
||||
|
||||
describe("renderFallback", () => {
|
||||
it("shows the raw content inside a pre with a gentle notice", () => {
|
||||
const html = renderFallback("# hello")
|
||||
|
||||
expect(html).toContain("note-render-fallback")
|
||||
expect(html).toContain("couldn't be fully rendered")
|
||||
expect(html).toContain("<pre># hello</pre>")
|
||||
})
|
||||
|
||||
it("escapes HTML so raw content can't inject markup into v-html", () => {
|
||||
const html = renderFallback('<img src=x onerror="alert(1)"> & "q"')
|
||||
|
||||
expect(html).toContain(
|
||||
"<img src=x onerror="alert(1)"> & "q""
|
||||
)
|
||||
expect(html).not.toContain("<img src=x")
|
||||
})
|
||||
|
||||
it("escapes ampersands before other entities", () => {
|
||||
expect(renderFallback("<")).toContain("<pre>&lt;</pre>")
|
||||
})
|
||||
})
|
||||
14
src/utils/markdown/renderFallback.ts
Normal file
14
src/utils/markdown/renderFallback.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
const escapeHtml = (value: string): string =>
|
||||
value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll('"', """)
|
||||
|
||||
/**
|
||||
* Calm degradation for a note that failed to render: never a raw error dump
|
||||
* or a silent blank — show the note's raw text (escaped) with a gentle notice,
|
||||
* so the content is still readable.
|
||||
*/
|
||||
export const renderFallback = (content: string): string =>
|
||||
`<div class="note-render-fallback"><p><em>This note couldn't be fully rendered — showing its raw text.</em></p><pre>${escapeHtml(content)}</pre></div>`
|
||||
@@ -71,17 +71,22 @@ const buildExportableSvgString = (svg: SVGSVGElement): string => {
|
||||
clone.setAttribute("xmlns:xlink", "http://www.w3.org/1999/xlink")
|
||||
|
||||
const { width, height } = getSvgPixelSize(svg)
|
||||
const viewBox = svg.viewBox?.baseVal
|
||||
const originX = viewBox && viewBox.width > 0 ? viewBox.x : 0
|
||||
const originY = viewBox && viewBox.height > 0 ? viewBox.y : 0
|
||||
if (!clone.getAttribute("viewBox")) {
|
||||
clone.setAttribute("viewBox", `0 0 ${width} ${height}`)
|
||||
clone.setAttribute("viewBox", `${originX} ${originY} ${width} ${height}`)
|
||||
}
|
||||
clone.setAttribute("width", String(width))
|
||||
clone.setAttribute("height", String(height))
|
||||
|
||||
// Cover the full viewBox (its origin can be negative — e.g. a diagram whose
|
||||
// content extends above y=0), otherwise that content falls outside the fill.
|
||||
const bg = document.createElementNS(SVG_NS, "rect")
|
||||
bg.setAttribute("x", "0")
|
||||
bg.setAttribute("y", "0")
|
||||
bg.setAttribute("width", "100%")
|
||||
bg.setAttribute("height", "100%")
|
||||
bg.setAttribute("x", String(originX))
|
||||
bg.setAttribute("y", String(originY))
|
||||
bg.setAttribute("width", String(width))
|
||||
bg.setAttribute("height", String(height))
|
||||
bg.setAttribute("fill", "#ffffff")
|
||||
clone.insertBefore(bg, clone.firstChild)
|
||||
|
||||
@@ -96,6 +101,9 @@ const buildExportableSvgString = (svg: SVGSVGElement): string => {
|
||||
return new XMLSerializer().serializeToString(clone)
|
||||
}
|
||||
|
||||
export const svgToDataUrl = (svg: SVGSVGElement): string =>
|
||||
`data:image/svg+xml;charset=utf-8,${encodeURIComponent(buildExportableSvgString(svg))}`
|
||||
|
||||
const triggerBlobDownload = (blob: Blob, filename: string): void => {
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement("a")
|
||||
|
||||
94
src/utils/threeWayMerge.spec.ts
Normal file
94
src/utils/threeWayMerge.spec.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import { threeWayMerge } from "./threeWayMerge"
|
||||
|
||||
const base = "line1\nline2\nline3\nline4\nline5"
|
||||
|
||||
describe("threeWayMerge", () => {
|
||||
it("merges non-overlapping edits cleanly, keeping both changes", () => {
|
||||
const ours = "OURS1\nline2\nline3\nline4\nline5"
|
||||
const theirs = "line1\nline2\nline3\nline4\nTHEIRS5"
|
||||
|
||||
const { clean, merged } = threeWayMerge(base, ours, theirs)
|
||||
|
||||
expect(clean).toBe(true)
|
||||
expect(merged).toBe("OURS1\nline2\nline3\nline4\nTHEIRS5")
|
||||
})
|
||||
|
||||
it("treats identical edits on both sides as clean (false conflict)", () => {
|
||||
const edited = "line1\nCHANGED\nline3\nline4\nline5"
|
||||
|
||||
const { clean, merged } = threeWayMerge(base, edited, edited)
|
||||
|
||||
expect(clean).toBe(true)
|
||||
expect(merged).toBe(edited)
|
||||
})
|
||||
|
||||
it("flags overlapping edits on the same line as not clean", () => {
|
||||
const ours = "line1\nline2\nOURS3\nline4\nline5"
|
||||
const theirs = "line1\nline2\nTHEIRS3\nline4\nline5"
|
||||
|
||||
const { clean } = threeWayMerge(base, ours, theirs)
|
||||
|
||||
expect(clean).toBe(false)
|
||||
})
|
||||
|
||||
it("returns the base unchanged when neither side edited", () => {
|
||||
const { clean, merged } = threeWayMerge(base, base, base)
|
||||
|
||||
expect(clean).toBe(true)
|
||||
expect(merged).toBe(base)
|
||||
})
|
||||
|
||||
it("returns our edits when only we changed", () => {
|
||||
const ours = "OURS1\nline2\nline3\nline4\nline5"
|
||||
|
||||
const { clean, merged } = threeWayMerge(base, ours, base)
|
||||
|
||||
expect(clean).toBe(true)
|
||||
expect(merged).toBe(ours)
|
||||
})
|
||||
|
||||
it("returns their edits when only they changed", () => {
|
||||
const theirs = "line1\nline2\nline3\nline4\nTHEIRS5"
|
||||
|
||||
const { clean, merged } = threeWayMerge(base, base, theirs)
|
||||
|
||||
expect(clean).toBe(true)
|
||||
expect(merged).toBe(theirs)
|
||||
})
|
||||
|
||||
it("preserves a trailing newline", () => {
|
||||
const baseNl = "a\nb\nc\n"
|
||||
const ours = "A\nb\nc\n"
|
||||
const theirs = "a\nb\nC\n"
|
||||
|
||||
const { clean, merged } = threeWayMerge(baseNl, ours, theirs)
|
||||
|
||||
expect(clean).toBe(true)
|
||||
expect(merged).toBe("A\nb\nC\n")
|
||||
})
|
||||
|
||||
it("conservatively flags edits on immediately-adjacent lines as not clean", () => {
|
||||
// No unchanged line separates the two edits, so node-diff3 groups them
|
||||
// into one region and reports a conflict (git would merge this).
|
||||
const ours = "A\nline2\nline3\nline4\nline5"
|
||||
const theirs = "line1\nB\nline3\nline4\nline5"
|
||||
|
||||
const { clean } = threeWayMerge(base, ours, theirs)
|
||||
|
||||
expect(clean).toBe(false)
|
||||
})
|
||||
|
||||
it("merges added lines on different sides without conflict", () => {
|
||||
const ours = "line1\nOURS-NEW\nline2\nline3\nline4\nline5"
|
||||
const theirs = "line1\nline2\nline3\nline4\nline5\nTHEIRS-NEW"
|
||||
|
||||
const { clean, merged } = threeWayMerge(base, ours, theirs)
|
||||
|
||||
expect(clean).toBe(true)
|
||||
expect(merged).toBe(
|
||||
"line1\nOURS-NEW\nline2\nline3\nline4\nline5\nTHEIRS-NEW"
|
||||
)
|
||||
})
|
||||
})
|
||||
40
src/utils/threeWayMerge.ts
Normal file
40
src/utils/threeWayMerge.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
import { diff3Merge } from "node-diff3"
|
||||
|
||||
export interface ThreeWayMergeResult {
|
||||
clean: boolean
|
||||
merged: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Line-level 3-way merge. `ours` and `theirs` are both derived from `base`
|
||||
* (the common ancestor). When the two sets of edits are separated by at least
|
||||
* one unchanged line they merge automatically (`clean: true`); when they
|
||||
* overlap, `clean` is false and the caller should fall back to manual
|
||||
* resolution.
|
||||
*
|
||||
* Conservative by design: node-diff3 groups *consecutive* changed lines into a
|
||||
* single region, so edits to immediately-adjacent lines (no unchanged line
|
||||
* between them) are reported as a conflict even when they don't truly overlap.
|
||||
* This errs toward asking the user rather than risking a wrong auto-merge.
|
||||
*
|
||||
* `merged` is only meaningful when `clean` is true — conflicting regions are
|
||||
* dropped from it, so it must not be committed on a non-clean merge.
|
||||
*/
|
||||
export const threeWayMerge = (
|
||||
base: string,
|
||||
ours: string,
|
||||
theirs: string
|
||||
): ThreeWayMergeResult => {
|
||||
// diff3Merge(a, o, b): a = ours, o = ancestor, b = theirs. The default
|
||||
// excludeFalseConflicts folds identical edits made on both sides.
|
||||
const regions = diff3Merge(ours, base, theirs, { stringSeparator: /\r?\n/ })
|
||||
|
||||
const lines: string[] = []
|
||||
let clean = true
|
||||
for (const region of regions) {
|
||||
if (region.ok) lines.push(...region.ok)
|
||||
else clean = false
|
||||
}
|
||||
|
||||
return { clean, merged: lines.join("\n") }
|
||||
}
|
||||
312
src/utils/todotxt.spec.ts
Normal file
312
src/utils/todotxt.spec.ts
Normal file
@@ -0,0 +1,312 @@
|
||||
import { describe, expect, it } from "vitest"
|
||||
|
||||
import {
|
||||
applyRecurrence,
|
||||
contextsOf,
|
||||
isBlank,
|
||||
parseFile,
|
||||
parseLine,
|
||||
projectsOf,
|
||||
removeTagFromBody,
|
||||
removeTokenFromBody,
|
||||
segmentBody,
|
||||
serializeFile,
|
||||
serializeTask,
|
||||
tagsOf,
|
||||
toggleCompleted
|
||||
} from "./todotxt"
|
||||
|
||||
describe("todotxt parseLine", () => {
|
||||
it("parses a plain task with no metadata", () => {
|
||||
const task = parseLine("Pick up groceries")
|
||||
expect(task.completed).toBe(false)
|
||||
expect(task.priority).toBeUndefined()
|
||||
expect(task.completionDate).toBeUndefined()
|
||||
expect(task.creationDate).toBeUndefined()
|
||||
expect(task.body).toBe("Pick up groceries")
|
||||
})
|
||||
|
||||
it("parses priority + creation date + body", () => {
|
||||
const task = parseLine("(A) 2024-01-15 Call Mom +Family @phone")
|
||||
expect(task.priority).toBe("A")
|
||||
expect(task.creationDate).toBe("2024-01-15")
|
||||
expect(task.body).toBe("Call Mom +Family @phone")
|
||||
})
|
||||
|
||||
it("parses a completed task with completion + creation dates", () => {
|
||||
const task = parseLine("x 2024-01-20 (A) 2024-01-15 Finish report")
|
||||
expect(task.completed).toBe(true)
|
||||
expect(task.completionDate).toBe("2024-01-20")
|
||||
expect(task.priority).toBe("A")
|
||||
expect(task.creationDate).toBe("2024-01-15")
|
||||
expect(task.body).toBe("Finish report")
|
||||
})
|
||||
|
||||
it("parses a completed task with no completion date", () => {
|
||||
const task = parseLine("x Buy milk")
|
||||
expect(task.completed).toBe(true)
|
||||
expect(task.completionDate).toBeUndefined()
|
||||
expect(task.body).toBe("Buy milk")
|
||||
})
|
||||
|
||||
it("does not treat uppercase X as completion", () => {
|
||||
const task = parseLine("X-ray appointment")
|
||||
expect(task.completed).toBe(false)
|
||||
expect(task.body).toBe("X-ray appointment")
|
||||
})
|
||||
|
||||
it("does not match a lowercase priority", () => {
|
||||
const task = parseLine("(a) lowercase priority is body text")
|
||||
expect(task.priority).toBeUndefined()
|
||||
expect(task.body).toBe("(a) lowercase priority is body text")
|
||||
})
|
||||
})
|
||||
|
||||
describe("todotxt serializeTask", () => {
|
||||
it("round-trips a complex task", () => {
|
||||
const input = "x 2024-01-20 (A) 2024-01-15 Finish report +work @office"
|
||||
expect(serializeTask(parseLine(input))).toBe(input)
|
||||
})
|
||||
|
||||
it("round-trips a plain task", () => {
|
||||
const input = "Buy milk"
|
||||
expect(serializeTask(parseLine(input))).toBe(input)
|
||||
})
|
||||
|
||||
it("round-trips priority + body", () => {
|
||||
const input = "(B) Walk the dog"
|
||||
expect(serializeTask(parseLine(input))).toBe(input)
|
||||
})
|
||||
|
||||
it("drops completion date when task is not completed", () => {
|
||||
const task = parseLine("(A) 2024-01-15 Hello")
|
||||
const out = serializeTask({ ...task, completionDate: "2024-02-01" })
|
||||
expect(out).toBe("(A) 2024-01-15 Hello")
|
||||
})
|
||||
})
|
||||
|
||||
describe("todotxt projectsOf / contextsOf / tagsOf", () => {
|
||||
it("extracts projects", () => {
|
||||
const task = parseLine("Plan trip +Travel +Family @home")
|
||||
expect(projectsOf(task)).toEqual(["Travel", "Family"])
|
||||
})
|
||||
|
||||
it("extracts contexts", () => {
|
||||
const task = parseLine("Call +Family @phone @home")
|
||||
expect(contextsOf(task)).toEqual(["phone", "home"])
|
||||
})
|
||||
|
||||
it("extracts key:value tags", () => {
|
||||
const task = parseLine("Finish report due:2024-02-01 pri:A")
|
||||
expect(tagsOf(task)).toEqual({ due: "2024-02-01", pri: "A" })
|
||||
})
|
||||
|
||||
it("does not treat an email address as a context", () => {
|
||||
const task = parseLine("Email boss at boss@example.com")
|
||||
expect(contextsOf(task)).toEqual([])
|
||||
})
|
||||
|
||||
it("does not treat a URL scheme as a tag", () => {
|
||||
const task = parseLine("Read https://example.com later")
|
||||
expect(tagsOf(task)).toEqual({})
|
||||
})
|
||||
})
|
||||
|
||||
describe("todotxt segmentBody", () => {
|
||||
it("splits text + project + context", () => {
|
||||
expect(segmentBody("Call Mom +Family @phone")).toEqual([
|
||||
{ kind: "text", value: "Call Mom " },
|
||||
{ kind: "project", value: "Family" },
|
||||
{ kind: "text", value: " " },
|
||||
{ kind: "context", value: "phone" }
|
||||
])
|
||||
})
|
||||
|
||||
it("keeps mid-word @ as text", () => {
|
||||
expect(segmentBody("Email boss@example.com")).toEqual([
|
||||
{ kind: "text", value: "Email boss@example.com" }
|
||||
])
|
||||
})
|
||||
|
||||
it("handles a token at the start", () => {
|
||||
expect(segmentBody("@phone Call Mom")).toEqual([
|
||||
{ kind: "context", value: "phone" },
|
||||
{ kind: "text", value: " Call Mom" }
|
||||
])
|
||||
})
|
||||
|
||||
it("recognizes due: and rec: as their own segments", () => {
|
||||
expect(segmentBody("Pay rent due:2025-01-15 rec:1m")).toEqual([
|
||||
{ kind: "text", value: "Pay rent " },
|
||||
{ kind: "due", value: "2025-01-15" },
|
||||
{ kind: "text", value: " " },
|
||||
{ kind: "rec", value: "1m" }
|
||||
])
|
||||
})
|
||||
|
||||
it("does not match due: inside a word", () => {
|
||||
expect(segmentBody("xdue:foo")).toEqual([
|
||||
{ kind: "text", value: "xdue:foo" }
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe("todotxt removeTagFromBody", () => {
|
||||
it("removes a due tag", () => {
|
||||
expect(
|
||||
removeTagFromBody("Pay rent due:2025-01-15 rec:1m", "due", "2025-01-15")
|
||||
).toBe("Pay rent rec:1m")
|
||||
})
|
||||
|
||||
it("removes a rec tag at the end", () => {
|
||||
expect(removeTagFromBody("Pay rent rec:1m", "rec", "1m")).toBe("Pay rent")
|
||||
})
|
||||
})
|
||||
|
||||
describe("todotxt removeTokenFromBody", () => {
|
||||
it("removes a project token mid-body", () => {
|
||||
expect(
|
||||
removeTokenFromBody("Plan trip +Travel +Family @home", "+", "Travel")
|
||||
).toBe("Plan trip +Family @home")
|
||||
})
|
||||
|
||||
it("removes a context token at the start", () => {
|
||||
expect(removeTokenFromBody("@phone Call Mom", "@", "phone")).toBe(
|
||||
"Call Mom"
|
||||
)
|
||||
})
|
||||
|
||||
it("removes a token at the end", () => {
|
||||
expect(removeTokenFromBody("Call Mom @phone", "@", "phone")).toBe(
|
||||
"Call Mom"
|
||||
)
|
||||
})
|
||||
|
||||
it("leaves untouched a token-prefix substring inside another word", () => {
|
||||
expect(removeTokenFromBody("boss@example.com here", "@", "example")).toBe(
|
||||
"boss@example.com here"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("todotxt parseFile / serializeFile", () => {
|
||||
it("round-trips a file with trailing newline", () => {
|
||||
const input = `(A) 2024-01-15 First task +work
|
||||
x 2024-01-20 Second task
|
||||
Plain task
|
||||
`
|
||||
const parsed = parseFile(input)
|
||||
expect(parsed).toHaveLength(3)
|
||||
expect(serializeFile(parsed)).toBe(input)
|
||||
})
|
||||
|
||||
it("preserves blank lines", () => {
|
||||
const input = `Task one
|
||||
|
||||
Task two
|
||||
`
|
||||
const parsed = parseFile(input)
|
||||
expect(parsed).toHaveLength(3)
|
||||
expect(isBlank(parsed[1])).toBe(true)
|
||||
expect(serializeFile(parsed)).toBe(input)
|
||||
})
|
||||
|
||||
it("handles file with no trailing newline", () => {
|
||||
const parsed = parseFile("only line")
|
||||
expect(serializeFile(parsed, { trailingNewline: false })).toBe("only line")
|
||||
})
|
||||
})
|
||||
|
||||
describe("todotxt applyRecurrence", () => {
|
||||
it("returns null when task has no rec tag", () => {
|
||||
expect(applyRecurrence(parseLine("Buy milk"))).toBeNull()
|
||||
})
|
||||
|
||||
it("returns null for an invalid rec value", () => {
|
||||
expect(applyRecurrence(parseLine("Task rec:bad"))).toBeNull()
|
||||
})
|
||||
|
||||
it("non-strict: bases next due on today, replacing existing due", () => {
|
||||
const now = new Date(2026, 0, 15) // Jan 15
|
||||
const task = parseLine("Pay rent due:2025-12-15 rec:1m")
|
||||
const next = applyRecurrence(task, now)!
|
||||
expect(next.completed).toBe(false)
|
||||
expect(next.body).toContain("due:2026-02-15")
|
||||
expect(next.body).toContain("rec:1m")
|
||||
expect(next.body).not.toContain("due:2025-12-15")
|
||||
})
|
||||
|
||||
it("strict: bases next due on existing due date", () => {
|
||||
const now = new Date(2026, 0, 15) // Jan 15
|
||||
const task = parseLine("Pay rent due:2025-12-15 rec:+1m")
|
||||
const next = applyRecurrence(task, now)!
|
||||
expect(next.body).toContain("due:2026-01-15")
|
||||
expect(next.body).toContain("rec:+1m")
|
||||
})
|
||||
|
||||
it("strict with no due: falls back to today", () => {
|
||||
const now = new Date(2026, 0, 15)
|
||||
const task = parseLine("Daily standup rec:+1d")
|
||||
const next = applyRecurrence(task, now)!
|
||||
expect(next.body).toContain("due:2026-01-16")
|
||||
})
|
||||
|
||||
it("appends due tag when task has none", () => {
|
||||
const now = new Date(2026, 0, 15)
|
||||
const task = parseLine("Daily standup rec:1d")
|
||||
const next = applyRecurrence(task, now)!
|
||||
expect(next.body).toContain("due:2026-01-16")
|
||||
})
|
||||
|
||||
it("preserves projects, contexts, and rec tag in new body", () => {
|
||||
const now = new Date(2026, 0, 15)
|
||||
const task = parseLine("Pay rent +finance @home due:2026-01-01 rec:1m")
|
||||
const next = applyRecurrence(task, now)!
|
||||
expect(next.body).toContain("+finance")
|
||||
expect(next.body).toContain("@home")
|
||||
expect(next.body).toContain("rec:1m")
|
||||
})
|
||||
|
||||
it("preserves priority on the new task", () => {
|
||||
const now = new Date(2026, 0, 15)
|
||||
const task = parseLine("(A) Pay rent rec:1w")
|
||||
const next = applyRecurrence(task, now)!
|
||||
expect(next.priority).toBe("A")
|
||||
expect(next.body).toContain("due:2026-01-22")
|
||||
})
|
||||
|
||||
it("handles week interval", () => {
|
||||
const now = new Date(2026, 0, 15)
|
||||
expect(applyRecurrence(parseLine("Task rec:2w"), now)!.body).toContain(
|
||||
"due:2026-01-29"
|
||||
)
|
||||
})
|
||||
|
||||
it("handles year interval", () => {
|
||||
const now = new Date(2026, 0, 15)
|
||||
expect(applyRecurrence(parseLine("Task rec:1y"), now)!.body).toContain(
|
||||
"due:2027-01-15"
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe("todotxt toggleCompleted", () => {
|
||||
it("marks a task complete with today's date", () => {
|
||||
// Construct via local-time fields so the test doesn't depend on the
|
||||
// runner's timezone (todayIso uses getFullYear/getMonth/getDate).
|
||||
const now = new Date(2024, 2, 15, 12, 0)
|
||||
const task = parseLine("(A) 2024-01-15 Call Mom")
|
||||
const toggled = toggleCompleted(task, now)
|
||||
expect(toggled.completed).toBe(true)
|
||||
expect(toggled.completionDate).toBe("2024-03-15")
|
||||
expect(serializeTask(toggled)).toBe("x 2024-03-15 (A) 2024-01-15 Call Mom")
|
||||
})
|
||||
|
||||
it("uncompletes a task and drops the completion date", () => {
|
||||
const task = parseLine("x 2024-01-20 (A) 2024-01-15 Call Mom")
|
||||
const toggled = toggleCompleted(task)
|
||||
expect(toggled.completed).toBe(false)
|
||||
expect(toggled.completionDate).toBeUndefined()
|
||||
expect(serializeTask(toggled)).toBe("(A) 2024-01-15 Call Mom")
|
||||
})
|
||||
})
|
||||
299
src/utils/todotxt.ts
Normal file
299
src/utils/todotxt.ts
Normal file
@@ -0,0 +1,299 @@
|
||||
// Parser / serializer for the todo.txt format.
|
||||
// Spec reference: https://github.com/todotxt/todo.txt
|
||||
|
||||
export interface Task {
|
||||
raw: string
|
||||
completed: boolean
|
||||
priority?: string
|
||||
completionDate?: string
|
||||
creationDate?: string
|
||||
body: string
|
||||
}
|
||||
|
||||
export interface BlankLine {
|
||||
blank: true
|
||||
raw: string
|
||||
}
|
||||
|
||||
export type FileLine = Task | BlankLine
|
||||
|
||||
export const isBlank = (line: FileLine): line is BlankLine =>
|
||||
(line as BlankLine).blank === true
|
||||
|
||||
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
const PRIORITY_RE = /^\(([A-Z])\)$/
|
||||
const PROJECT_TOKEN_RE = /(?:^|\s)\+(\S+)/g
|
||||
const CONTEXT_TOKEN_RE = /(?:^|\s)@(\S+)/g
|
||||
// Match key:value tags but skip URL-like values (anything containing a slash)
|
||||
// to avoid grabbing the scheme of `https://example.com`.
|
||||
const TAG_TOKEN_RE = /(?:^|\s)([A-Za-z][A-Za-z0-9_-]*):([^\s/]+)/g
|
||||
|
||||
const tryConsume = (
|
||||
rest: string,
|
||||
predicate: (token: string) => boolean
|
||||
): { token: string; rest: string } | null => {
|
||||
const match = rest.match(/^(\S+)(\s+|$)/)
|
||||
if (!match || !predicate(match[1])) return null
|
||||
return { token: match[1], rest: rest.slice(match[0].length) }
|
||||
}
|
||||
|
||||
export const parseLine = (line: string): Task => {
|
||||
let rest = line
|
||||
let completed = false
|
||||
let priority: string | undefined
|
||||
let completionDate: string | undefined
|
||||
let creationDate: string | undefined
|
||||
|
||||
if (rest.startsWith("x ")) {
|
||||
completed = true
|
||||
rest = rest.slice(2)
|
||||
|
||||
const dateAttempt = tryConsume(rest, (t) => DATE_RE.test(t))
|
||||
if (dateAttempt) {
|
||||
completionDate = dateAttempt.token
|
||||
rest = dateAttempt.rest
|
||||
}
|
||||
}
|
||||
|
||||
const priorityAttempt = tryConsume(rest, (t) => PRIORITY_RE.test(t))
|
||||
if (priorityAttempt) {
|
||||
priority = priorityAttempt.token.slice(1, 2)
|
||||
rest = priorityAttempt.rest
|
||||
}
|
||||
|
||||
const creationAttempt = tryConsume(rest, (t) => DATE_RE.test(t))
|
||||
if (creationAttempt) {
|
||||
creationDate = creationAttempt.token
|
||||
rest = creationAttempt.rest
|
||||
}
|
||||
|
||||
return {
|
||||
raw: line,
|
||||
completed,
|
||||
priority,
|
||||
completionDate,
|
||||
creationDate,
|
||||
body: rest
|
||||
}
|
||||
}
|
||||
|
||||
export const serializeTask = (task: Task): string => {
|
||||
const parts: string[] = []
|
||||
if (task.completed) {
|
||||
parts.push("x")
|
||||
if (task.completionDate) parts.push(task.completionDate)
|
||||
}
|
||||
if (task.priority) parts.push(`(${task.priority})`)
|
||||
if (task.creationDate) parts.push(task.creationDate)
|
||||
if (task.body.length) parts.push(task.body)
|
||||
return parts.join(" ")
|
||||
}
|
||||
|
||||
export const parseFile = (text: string): FileLine[] => {
|
||||
const lines = text.split("\n")
|
||||
// Drop the trailing empty element produced by a final newline.
|
||||
// We re-add a trailing newline in serializeFile when there was one in the source.
|
||||
const trailing = lines.length > 0 && lines[lines.length - 1] === ""
|
||||
const meaningful = trailing ? lines.slice(0, -1) : lines
|
||||
|
||||
return meaningful.map((line) => {
|
||||
if (line.trim() === "") return { blank: true, raw: line }
|
||||
return parseLine(line)
|
||||
})
|
||||
}
|
||||
|
||||
export const serializeFile = (
|
||||
items: FileLine[],
|
||||
{ trailingNewline = true }: { trailingNewline?: boolean } = {}
|
||||
): string => {
|
||||
const lines = items.map((item) =>
|
||||
isBlank(item) ? item.raw : serializeTask(item)
|
||||
)
|
||||
return lines.join("\n") + (trailingNewline ? "\n" : "")
|
||||
}
|
||||
|
||||
const collectTokens = (body: string, re: RegExp): string[] => {
|
||||
const out: string[] = []
|
||||
for (const match of body.matchAll(re)) {
|
||||
out.push(match[1])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export const projectsOf = (task: Task): string[] =>
|
||||
collectTokens(task.body, PROJECT_TOKEN_RE)
|
||||
|
||||
export const contextsOf = (task: Task): string[] =>
|
||||
collectTokens(task.body, CONTEXT_TOKEN_RE)
|
||||
|
||||
export const tagsOf = (task: Task): Record<string, string> => {
|
||||
const out: Record<string, string> = {}
|
||||
for (const match of task.body.matchAll(TAG_TOKEN_RE)) {
|
||||
out[match[1]] = match[2]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
export type BodySegment =
|
||||
| { kind: "text"; value: string }
|
||||
| { kind: "project" | "context"; value: string }
|
||||
| { kind: "due" | "rec"; value: string }
|
||||
|
||||
// Split a task body into display segments. `due:VALUE` and `rec:VALUE` tags
|
||||
// get their own segment kinds so the UI can render them as themed chips;
|
||||
// `+project` and `@context` tokens become project/context segments.
|
||||
// All tokens only count when they sit at the start of the body or after
|
||||
// whitespace — so mid-word `@` (e.g. `boss@example.com`) stays as text.
|
||||
export const segmentBody = (body: string): BodySegment[] => {
|
||||
const segments: BodySegment[] = []
|
||||
const re = /(due|rec):(\S+)|([+@])(\S+)/g
|
||||
let last = 0
|
||||
for (const match of body.matchAll(re)) {
|
||||
const start = match.index ?? 0
|
||||
const prevChar = start > 0 ? body[start - 1] : " "
|
||||
if (!/\s/.test(prevChar)) continue
|
||||
if (start > last) {
|
||||
segments.push({ kind: "text", value: body.slice(last, start) })
|
||||
}
|
||||
if (match[1]) {
|
||||
segments.push({
|
||||
kind: match[1] as "due" | "rec",
|
||||
value: match[2]
|
||||
})
|
||||
} else {
|
||||
segments.push({
|
||||
kind: match[3] === "+" ? "project" : "context",
|
||||
value: match[4]
|
||||
})
|
||||
}
|
||||
last = start + match[0].length
|
||||
}
|
||||
if (last < body.length) {
|
||||
segments.push({ kind: "text", value: body.slice(last) })
|
||||
}
|
||||
return segments
|
||||
}
|
||||
|
||||
const escapeRegex = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
||||
|
||||
// Remove a `+project` or `@context` token from a task body.
|
||||
// Anchored on `(^|\s)` so mid-word matches (e.g. inside email addresses) are not touched.
|
||||
export const removeTokenFromBody = (
|
||||
body: string,
|
||||
prefix: "+" | "@",
|
||||
value: string
|
||||
): string => {
|
||||
const re = new RegExp(
|
||||
`(?:^|\\s)\\${prefix}${escapeRegex(value)}(?=\\s|$)`,
|
||||
"g"
|
||||
)
|
||||
return body.replace(re, "").replace(/[ \t]+/g, " ").trim()
|
||||
}
|
||||
|
||||
// Remove a `key:value` tag (e.g. `due:2025-01-15`) from a task body.
|
||||
export const removeTagFromBody = (
|
||||
body: string,
|
||||
key: string,
|
||||
value: string
|
||||
): string => {
|
||||
const re = new RegExp(
|
||||
`(?:^|\\s)${escapeRegex(key)}:${escapeRegex(value)}(?=\\s|$)`,
|
||||
"g"
|
||||
)
|
||||
return body.replace(re, "").replace(/[ \t]+/g, " ").trim()
|
||||
}
|
||||
|
||||
const todayIso = (now: Date): string => {
|
||||
const y = now.getFullYear()
|
||||
const m = String(now.getMonth() + 1).padStart(2, "0")
|
||||
const d = String(now.getDate()).padStart(2, "0")
|
||||
return `${y}-${m}-${d}`
|
||||
}
|
||||
|
||||
const parseDateStr = (s: string): Date | null => {
|
||||
if (!DATE_RE.test(s)) return null
|
||||
const [y, mo, d] = s.split("-").map(Number)
|
||||
return new Date(y, mo - 1, d)
|
||||
}
|
||||
|
||||
const addInterval = (base: Date, n: number, unit: string): Date => {
|
||||
const d = new Date(base)
|
||||
switch (unit.toLowerCase()) {
|
||||
case "d":
|
||||
d.setDate(d.getDate() + n)
|
||||
break
|
||||
case "w":
|
||||
d.setDate(d.getDate() + n * 7)
|
||||
break
|
||||
case "m":
|
||||
d.setMonth(d.getMonth() + n)
|
||||
break
|
||||
case "y":
|
||||
d.setFullYear(d.getFullYear() + n)
|
||||
break
|
||||
}
|
||||
return d
|
||||
}
|
||||
|
||||
const REC_RE = /^\+?(\d+)([dwmyDWMY])$/
|
||||
|
||||
// When a recurring task is completed, build a new open task with the next due date.
|
||||
// Strict mode (rec:+Nd): base date is the existing due: date (or today if absent).
|
||||
// Non-strict (rec:Nd): base date is today (completion date).
|
||||
// Returns null when the task has no valid rec: tag.
|
||||
export const applyRecurrence = (task: Task, now = new Date()): Task | null => {
|
||||
const tags = tagsOf(task)
|
||||
const rec = tags["rec"]
|
||||
if (!rec) return null
|
||||
const m = rec.match(REC_RE)
|
||||
if (!m) return null
|
||||
|
||||
const strict = rec.startsWith("+")
|
||||
const n = parseInt(m[1], 10)
|
||||
const unit = m[2]
|
||||
|
||||
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
let base = today
|
||||
if (strict && tags["due"]) {
|
||||
const parsed = parseDateStr(tags["due"])
|
||||
if (parsed) base = parsed
|
||||
}
|
||||
|
||||
const nextDate = addInterval(base, n, unit)
|
||||
const nextDue = todayIso(nextDate)
|
||||
|
||||
let newBody = task.body
|
||||
if (tags["due"]) {
|
||||
newBody = removeTagFromBody(newBody, "due", tags["due"])
|
||||
}
|
||||
newBody = (newBody.trim() + ` due:${nextDue}`).trim()
|
||||
|
||||
return {
|
||||
raw: "",
|
||||
completed: false,
|
||||
priority: task.priority,
|
||||
creationDate: todayIso(now),
|
||||
completionDate: undefined,
|
||||
body: newBody
|
||||
}
|
||||
}
|
||||
|
||||
// Toggle the completed state of a task.
|
||||
// On complete: stamp today's completion date. Priority stays in place per spec.
|
||||
// On uncomplete: drop completion date but leave the rest of the line as-is —
|
||||
// we deliberately do not try to recover any original priority that may have
|
||||
// been stashed in a `pri:X` tag.
|
||||
export const toggleCompleted = (task: Task, now = new Date()): Task => {
|
||||
if (task.completed) {
|
||||
return {
|
||||
...task,
|
||||
completed: false,
|
||||
completionDate: undefined
|
||||
}
|
||||
}
|
||||
return {
|
||||
...task,
|
||||
completed: true,
|
||||
completionDate: todayIso(now)
|
||||
}
|
||||
}
|
||||
@@ -130,6 +130,12 @@ const openProfile = () => {
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
width: 100%;
|
||||
max-width: 42rem;
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
h1 {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { computedAsync } from "@vueuse/core"
|
||||
import { useTitle } from "@vueuse/core"
|
||||
import { computed, ref, watch } from "vue"
|
||||
import { computed, onMounted, ref, watch } from "vue"
|
||||
import { useRouter } from "vue-router"
|
||||
|
||||
import HomeButton from "@/components/HomeButton.vue"
|
||||
@@ -112,13 +112,19 @@ const language = computed(() =>
|
||||
|
||||
const mainNoteId = computed(() => `${props.shortDid}-${props.rkey}`)
|
||||
|
||||
const { stackedNotes, scrollToFocusedNote } = useRouteQueryStackedNotes()
|
||||
const { stackedNotes, scrollToFocusedNote, scrollToLastStackedNote } =
|
||||
useRouteQueryStackedNotes()
|
||||
const { listenToClick } = useATProtoLinks("note-display", { mainNoteId })
|
||||
useResizeContainer("note-container", stackedNotes)
|
||||
|
||||
onMounted(() => {
|
||||
scrollToLastStackedNote()
|
||||
})
|
||||
|
||||
useMarkdownPostRender(content, () => ".public-note-view .note-display", {
|
||||
onReady: () => listenToClick(),
|
||||
tikz: true
|
||||
tikz: true,
|
||||
macroplan: true
|
||||
})
|
||||
</script>
|
||||
|
||||
|
||||
@@ -7,15 +7,13 @@ import { computed, ref } from "vue"
|
||||
import ProfileModal from "@/components/ProfileModal.vue"
|
||||
import SignInGithub from "@/components/SignInGithub.vue"
|
||||
import UserPill from "@/components/UserPill.vue"
|
||||
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
|
||||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||
import { useRepos } from "@/hooks/useRepos.hook"
|
||||
import { useRepoList } from "@/modules/repo/hooks/useRepoList.hook"
|
||||
import type { RepoBase } from "@/modules/repo/interfaces/RepoBase"
|
||||
|
||||
const { username, accessToken } = useGitHubLogin()
|
||||
const { isLoggedIn: isATProtoLoggedIn } = useATProtoLogin()
|
||||
const { isReady, hasCredentialError } = useRepos()
|
||||
const { isReady, repos, hasCredentialError } = useRepos()
|
||||
const {
|
||||
favoriteRepos,
|
||||
otherRepos,
|
||||
@@ -25,11 +23,6 @@ const {
|
||||
loadMore
|
||||
} = useRepoList()
|
||||
|
||||
const isGitHubLoggedIn = computed(() => !!accessToken.value)
|
||||
const isAnyUserLoggedIn = computed(
|
||||
() => isGitHubLoggedIn.value || isATProtoLoggedIn.value
|
||||
)
|
||||
|
||||
const openProfile = () => {
|
||||
;(document.getElementById("profile_modal") as HTMLDialogElement)?.showModal()
|
||||
}
|
||||
@@ -44,7 +37,7 @@ const filteredFavoriteRepos = computed<RepoBase[]>(() =>
|
||||
favoriteRepos.value.filter((r) => matchesQuery(r.name))
|
||||
)
|
||||
const filteredOtherRepos = computed<RepoBase[]>(() =>
|
||||
otherRepos.value.filter((r) => matchesQuery(r.name))
|
||||
repos.value.filter((r) => matchesQuery(r.name))
|
||||
)
|
||||
|
||||
const groupedOtherRepos = computed<
|
||||
|
||||
@@ -1,80 +1,311 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, defineAsyncComponent, nextTick, ref, watch } from "vue"
|
||||
import { computed, defineAsyncComponent, Ref, ref, watch } from "vue"
|
||||
|
||||
import { useCheckboxCommit } from "@/hooks/useCheckboxCommit.hook"
|
||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||
import TodoTxtItem from "@/components/TodoTxtItem.vue"
|
||||
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||
import { useTodoTxtCommit } from "@/hooks/useTodoTxtCommit.hook"
|
||||
import { queryFileContent } from "@/modules/repo/services/repo"
|
||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||
import { decodeBase64ToUTF8 } from "@/utils/decodeBase64ToUTF8"
|
||||
import { errorMessage } from "@/utils/notif"
|
||||
import {
|
||||
applyRecurrence,
|
||||
contextsOf,
|
||||
FileLine,
|
||||
isBlank,
|
||||
parseLine,
|
||||
projectsOf,
|
||||
Task
|
||||
} from "@/utils/todotxt"
|
||||
|
||||
type Prop = {
|
||||
user: string
|
||||
repo: string
|
||||
}
|
||||
|
||||
const FluxNote = defineAsyncComponent(() => import("@/components/FluxNote.vue"))
|
||||
const props = defineProps<Prop>()
|
||||
const user = computed(() => props.user)
|
||||
const repo = computed(() => props.repo)
|
||||
const TODO_PATH = "todo.txt"
|
||||
const DONE_PATH = "done.txt"
|
||||
|
||||
const FluxNote = defineAsyncComponent(() => import("@/components/FluxNote.vue"))
|
||||
|
||||
const props = defineProps<Prop>()
|
||||
const store = useUserRepoStore()
|
||||
|
||||
const todoNote = computed(() =>
|
||||
store.files.find((file) => file.path?.endsWith("_todo/todo.md"))
|
||||
)
|
||||
|
||||
const sha = computed(() => todoNote.value?.sha ?? "")
|
||||
const path = computed(() => todoNote.value?.path)
|
||||
|
||||
const todoFile = computed(() => store.files.find((f) => f.path === TODO_PATH))
|
||||
const sha = computed(() => todoFile.value?.sha ?? "")
|
||||
const canPush = computed(() => store.canPush)
|
||||
|
||||
const { toHTML } = markdownBuilder(repo)
|
||||
const path = computed(() =>
|
||||
todoFile.value?.path ? todoFile.value.path : TODO_PATH
|
||||
)
|
||||
|
||||
const { pendingContent, syncContent, listenToCheckboxes, hasPendingChanges } =
|
||||
useCheckboxCommit({
|
||||
user: props.user,
|
||||
repo: props.repo,
|
||||
path,
|
||||
initialContent: "",
|
||||
initialSha: sha,
|
||||
containerSelector: ".todo-notes .note-display",
|
||||
debounceMs: 1000,
|
||||
enabled: canPush
|
||||
})
|
||||
|
||||
// Render pending content to HTML for display
|
||||
const renderedContent = computed(() => {
|
||||
if (!pendingContent.value) {
|
||||
return ""
|
||||
}
|
||||
return toHTML(pendingContent.value)
|
||||
const { items, syncContent, mutate, hasPendingChanges } = useTodoTxtCommit({
|
||||
user: props.user,
|
||||
repo: props.repo,
|
||||
path,
|
||||
initialContent: "",
|
||||
initialSha: sha,
|
||||
debounceMs: 1000
|
||||
})
|
||||
|
||||
// Fetch raw content when sha changes
|
||||
watch(
|
||||
sha,
|
||||
async (newSha) => {
|
||||
if (!newSha || hasPendingChanges.value) {
|
||||
return
|
||||
}
|
||||
const base64Content = await queryFileContent(props.user, props.repo, newSha)
|
||||
if (base64Content) {
|
||||
const rawContent = decodeBase64ToUTF8(base64Content)
|
||||
syncContent(rawContent, newSha)
|
||||
if (!newSha || hasPendingChanges.value) return
|
||||
const base64 = await queryFileContent(props.user, props.repo, newSha)
|
||||
if (base64) {
|
||||
syncContent(decodeBase64ToUTF8(base64), newSha)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Setup checkbox listeners when content renders or canPush changes
|
||||
const doneFile = computed(() => store.files.find((f) => f.path === DONE_PATH))
|
||||
const doneSha = computed(() => doneFile.value?.sha ?? "")
|
||||
const donePath = computed(() => doneFile.value?.path ?? DONE_PATH)
|
||||
|
||||
// Reuse useTodoTxtCommit as a read-only loader. We never call its `mutate`,
|
||||
// so nothing is ever written to done.txt from this view — the file is
|
||||
// produced by an external tool.
|
||||
const { items: doneItems, syncContent: syncDoneContent } = useTodoTxtCommit({
|
||||
user: props.user,
|
||||
repo: props.repo,
|
||||
path: donePath,
|
||||
initialContent: "",
|
||||
initialSha: doneSha,
|
||||
debounceMs: 1000
|
||||
})
|
||||
|
||||
watch(
|
||||
[renderedContent, canPush],
|
||||
async () => {
|
||||
await nextTick()
|
||||
listenToCheckboxes()
|
||||
doneSha,
|
||||
async (newSha) => {
|
||||
if (!newSha) return
|
||||
const base64 = await queryFileContent(props.user, props.repo, newSha)
|
||||
if (base64) {
|
||||
syncDoneContent(decodeBase64ToUTF8(base64), newSha)
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// Rank `A` = 65 .. `Z` = 90; absent priority is Infinity so it sorts last
|
||||
// regardless of locale collation quirks.
|
||||
const priorityRank = (p?: string): number =>
|
||||
p ? p.charCodeAt(0) : Number.POSITIVE_INFINITY
|
||||
|
||||
const taskEntries = computed(() => {
|
||||
const out: { line: Task; index: number }[] = []
|
||||
items.value.forEach((line, index) => {
|
||||
if (!isBlank(line)) out.push({ line, index })
|
||||
})
|
||||
// Stable sort: equal priorities preserve file order.
|
||||
out.sort(
|
||||
(a, b) => priorityRank(a.line.priority) - priorityRank(b.line.priority)
|
||||
)
|
||||
return out
|
||||
})
|
||||
|
||||
const archivedTasks = computed(() =>
|
||||
doneItems.value.filter((line): line is Task => !isBlank(line))
|
||||
)
|
||||
|
||||
const allProjects = computed(() => {
|
||||
const set = new Set<string>()
|
||||
items.value.forEach((line) => {
|
||||
if (!isBlank(line)) projectsOf(line).forEach((p) => set.add(p))
|
||||
})
|
||||
return Array.from(set).sort()
|
||||
})
|
||||
|
||||
const allContexts = computed(() => {
|
||||
const set = new Set<string>()
|
||||
items.value.forEach((line) => {
|
||||
if (!isBlank(line)) contextsOf(line).forEach((c) => set.add(c))
|
||||
})
|
||||
return Array.from(set).sort()
|
||||
})
|
||||
|
||||
// "none" represents tasks without a priority.
|
||||
const allPriorities = computed<Array<string | "none">>(() => {
|
||||
const set = new Set<string | "none">()
|
||||
items.value.forEach((line) => {
|
||||
if (isBlank(line)) return
|
||||
set.add(line.priority ?? "none")
|
||||
})
|
||||
return Array.from(set).sort((a, b) => {
|
||||
if (a === "none") return 1
|
||||
if (b === "none") return -1
|
||||
return a.localeCompare(b)
|
||||
})
|
||||
})
|
||||
|
||||
const activeProjects = ref<Set<string>>(new Set())
|
||||
const activeContexts = ref<Set<string>>(new Set())
|
||||
const activePriorities = ref<Set<string | "none">>(new Set())
|
||||
|
||||
// XOR: either "open" only, "done" only, or "all" (no status filter).
|
||||
type StatusFilter = "all" | "open" | "done"
|
||||
const statusFilter = ref<StatusFilter>("open")
|
||||
const setStatusFilter = (next: Exclude<StatusFilter, "all">) => {
|
||||
statusFilter.value = statusFilter.value === next ? "all" : next
|
||||
}
|
||||
|
||||
// Drop entries from `active` that no longer appear in `available`. Without
|
||||
// this, editing tasks until a selected project/context/priority disappears
|
||||
// leaves the filter "stuck on" — hasFilters stays true with no visible chip
|
||||
// to toggle off.
|
||||
const pruneActive = <T>(active: Ref<Set<T>>, available: ReadonlyArray<T>) => {
|
||||
const valid = new Set(available)
|
||||
const pruned = new Set<T>()
|
||||
active.value.forEach((v) => {
|
||||
if (valid.has(v)) pruned.add(v)
|
||||
})
|
||||
if (pruned.size !== active.value.size) active.value = pruned
|
||||
}
|
||||
|
||||
watch(allProjects, (next) => pruneActive(activeProjects, next))
|
||||
watch(allContexts, (next) => pruneActive(activeContexts, next))
|
||||
watch(allPriorities, (next) => pruneActive(activePriorities, next))
|
||||
|
||||
const toggleProject = (p: string) => {
|
||||
const next = new Set(activeProjects.value)
|
||||
if (next.has(p)) next.delete(p)
|
||||
else next.add(p)
|
||||
activeProjects.value = next
|
||||
}
|
||||
|
||||
const toggleContext = (c: string) => {
|
||||
const next = new Set(activeContexts.value)
|
||||
if (next.has(c)) next.delete(c)
|
||||
else next.add(c)
|
||||
activeContexts.value = next
|
||||
}
|
||||
|
||||
const togglePriority = (p: string | "none") => {
|
||||
const next = new Set(activePriorities.value)
|
||||
if (next.has(p)) next.delete(p)
|
||||
else next.add(p)
|
||||
activePriorities.value = next
|
||||
}
|
||||
|
||||
const clearFilters = () => {
|
||||
activeProjects.value = new Set()
|
||||
activeContexts.value = new Set()
|
||||
activePriorities.value = new Set()
|
||||
statusFilter.value = "all"
|
||||
}
|
||||
|
||||
const hasFilters = computed(
|
||||
() =>
|
||||
activeProjects.value.size > 0 ||
|
||||
activeContexts.value.size > 0 ||
|
||||
activePriorities.value.size > 0 ||
|
||||
statusFilter.value !== "all"
|
||||
)
|
||||
|
||||
const priorityBadgeFilterClass = (p: string | "none"): string => {
|
||||
switch (p) {
|
||||
case "A":
|
||||
return "badge-error"
|
||||
case "B":
|
||||
return "badge-warning"
|
||||
case "C":
|
||||
return "badge-info"
|
||||
default:
|
||||
return "badge-ghost"
|
||||
}
|
||||
}
|
||||
|
||||
const filteredEntries = computed(() => {
|
||||
if (!hasFilters.value) return taskEntries.value
|
||||
return taskEntries.value.filter(({ line }) => {
|
||||
const taskProjects = projectsOf(line)
|
||||
const taskContexts = contextsOf(line)
|
||||
const projectsOk =
|
||||
activeProjects.value.size === 0 ||
|
||||
taskProjects.some((p) => activeProjects.value.has(p))
|
||||
const contextsOk =
|
||||
activeContexts.value.size === 0 ||
|
||||
taskContexts.some((c) => activeContexts.value.has(c))
|
||||
const prioritiesOk =
|
||||
activePriorities.value.size === 0 ||
|
||||
activePriorities.value.has(line.priority ?? "none")
|
||||
const statusOk =
|
||||
statusFilter.value === "all" ||
|
||||
(statusFilter.value === "done" ? line.completed : !line.completed)
|
||||
return projectsOk && contextsOk && prioritiesOk && statusOk
|
||||
})
|
||||
})
|
||||
|
||||
const updateTask = (index: number, next: Task) => {
|
||||
mutate((current) => {
|
||||
const prev = current[index]
|
||||
const updated = current.map((line, i) => (i === index ? next : line))
|
||||
if (!isBlank(prev) && !prev.completed && next.completed) {
|
||||
const recurring = applyRecurrence(next)
|
||||
if (recurring) return [...updated, recurring]
|
||||
}
|
||||
return updated
|
||||
})
|
||||
}
|
||||
|
||||
const deleteTask = (index: number) => {
|
||||
mutate((current) => current.filter((_, i) => i !== index))
|
||||
}
|
||||
|
||||
const newTaskInput = ref("")
|
||||
|
||||
const addTask = () => {
|
||||
const text = newTaskInput.value.trim()
|
||||
if (!text) return
|
||||
const task = parseLine(text)
|
||||
newTaskInput.value = ""
|
||||
mutate((current) => {
|
||||
const next: FileLine[] = [...current, task]
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
const { createFile } = useGitHubContent({ user: props.user, repo: props.repo })
|
||||
const isCreating = ref(false)
|
||||
|
||||
// Pin a leaving row's box to its current coordinates before Vue makes it
|
||||
// position: absolute. Without this, browsers (notably Firefox) compute the
|
||||
// static position of an absolutely-positioned former-flex child to (0,0),
|
||||
// so multiple leaving rows stack on top of each other and the remaining
|
||||
// rows snap into place once the DOM nodes are removed.
|
||||
const lockLeavingPosition = (el: Element) => {
|
||||
const li = el as HTMLElement
|
||||
const parent = li.parentElement
|
||||
if (!parent) return
|
||||
const liRect = li.getBoundingClientRect()
|
||||
const parentRect = parent.getBoundingClientRect()
|
||||
li.style.top = `${liRect.top - parentRect.top}px`
|
||||
li.style.left = `${liRect.left - parentRect.left}px`
|
||||
li.style.width = `${liRect.width}px`
|
||||
}
|
||||
|
||||
const createTodoFile = async () => {
|
||||
if (isCreating.value) return
|
||||
isCreating.value = true
|
||||
const { sha: newSha, conflict } = await createFile({
|
||||
content: "",
|
||||
path: TODO_PATH
|
||||
})
|
||||
isCreating.value = false
|
||||
|
||||
if (conflict) return
|
||||
if (!newSha) {
|
||||
errorMessage("Could not create todo.txt")
|
||||
return
|
||||
}
|
||||
|
||||
store.registerUploadedFile({
|
||||
path: TODO_PATH,
|
||||
sha: newSha,
|
||||
type: "blob"
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -83,22 +314,305 @@ watch(
|
||||
key="todo-notes"
|
||||
:user="user"
|
||||
:repo="repo"
|
||||
:content="renderedContent"
|
||||
content=""
|
||||
:parse-content="false"
|
||||
/>
|
||||
:with-content="false"
|
||||
>
|
||||
<h3 class="subtitle">Todo</h3>
|
||||
|
||||
<div v-if="!todoFile" class="card bg-base-200">
|
||||
<div class="card-body items-center text-center">
|
||||
<p>No <code>todo.txt</code> in this repo yet.</p>
|
||||
<div v-if="canPush" class="card-actions">
|
||||
<button
|
||||
class="btn btn-primary"
|
||||
:disabled="isCreating"
|
||||
@click="createTodoFile"
|
||||
>
|
||||
Create todo.txt
|
||||
</button>
|
||||
</div>
|
||||
<p v-else class="text-sm opacity-70">
|
||||
Sign in to a repo you can push to.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<form v-if="canPush" class="mb-4 join w-full" @submit.prevent="addTask">
|
||||
<input
|
||||
v-model="newTaskInput"
|
||||
type="text"
|
||||
class="input input-bordered input-sm join-item flex-1"
|
||||
placeholder="(A) task +project @context…"
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-sm btn-primary join-item"
|
||||
:disabled="!newTaskInput.trim()"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div
|
||||
v-if="
|
||||
allProjects.length || allContexts.length || allPriorities.length
|
||||
"
|
||||
class="todo-filters"
|
||||
>
|
||||
<div class="todo-filter-columns">
|
||||
<section class="todo-filter-column">
|
||||
<h4 class="todo-filter-label">Status</h4>
|
||||
<div class="todo-filter-chips">
|
||||
<button
|
||||
type="button"
|
||||
class="todo-filter-chip badge badge-success"
|
||||
:class="{ 'badge-soft': statusFilter !== 'open' }"
|
||||
@click="setStatusFilter('open')"
|
||||
>
|
||||
Open
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="todo-filter-chip badge badge-neutral"
|
||||
:class="{ 'badge-soft': statusFilter !== 'done' }"
|
||||
@click="setStatusFilter('done')"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="allPriorities.length" class="todo-filter-column">
|
||||
<h4 class="todo-filter-label">Priorities</h4>
|
||||
<div class="todo-filter-chips">
|
||||
<button
|
||||
v-for="p in allPriorities"
|
||||
:key="`pri:${p}`"
|
||||
type="button"
|
||||
class="todo-filter-chip badge"
|
||||
:class="[
|
||||
priorityBadgeFilterClass(p),
|
||||
{ 'badge-soft': !activePriorities.has(p) }
|
||||
]"
|
||||
@click="togglePriority(p)"
|
||||
>
|
||||
{{ p === "none" ? "—" : `(${p})` }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="allProjects.length" class="todo-filter-column">
|
||||
<h4 class="todo-filter-label">Projects</h4>
|
||||
<div class="todo-filter-chips">
|
||||
<button
|
||||
v-for="p in allProjects"
|
||||
:key="`p:${p}`"
|
||||
type="button"
|
||||
class="todo-filter-chip badge badge-primary"
|
||||
:class="{ 'badge-soft': !activeProjects.has(p) }"
|
||||
@click="toggleProject(p)"
|
||||
>
|
||||
+{{ p }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
<section v-if="allContexts.length" class="todo-filter-column">
|
||||
<h4 class="todo-filter-label">Contexts</h4>
|
||||
<div class="todo-filter-chips">
|
||||
<button
|
||||
v-for="c in allContexts"
|
||||
:key="`c:${c}`"
|
||||
type="button"
|
||||
class="todo-filter-chip badge badge-secondary"
|
||||
:class="{ 'badge-soft': !activeContexts.has(c) }"
|
||||
@click="toggleContext(c)"
|
||||
>
|
||||
@{{ c }}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-ghost btn-sm self-start"
|
||||
:class="{ invisible: !hasFilters }"
|
||||
:aria-hidden="!hasFilters"
|
||||
:tabindex="hasFilters ? 0 : -1"
|
||||
@click="clearFilters"
|
||||
>
|
||||
Clear filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<TransitionGroup
|
||||
name="todo"
|
||||
tag="ul"
|
||||
class="todo-list"
|
||||
@before-leave="lockLeavingPosition"
|
||||
>
|
||||
<li v-for="entry in filteredEntries" :key="entry.index">
|
||||
<todo-txt-item
|
||||
:task="entry.line"
|
||||
:can-edit="canPush"
|
||||
@update="(t) => updateTask(entry.index, t)"
|
||||
@delete="deleteTask(entry.index)"
|
||||
/>
|
||||
</li>
|
||||
</TransitionGroup>
|
||||
|
||||
<p
|
||||
v-if="filteredEntries.length === 0 && hasFilters"
|
||||
class="text-sm opacity-60 mt-3"
|
||||
>
|
||||
No tasks match the current filters.
|
||||
</p>
|
||||
|
||||
<p
|
||||
v-if="taskEntries.length === 0 && canPush"
|
||||
class="text-sm opacity-60 mt-3"
|
||||
>
|
||||
Your todo list is empty. Add a task above.
|
||||
</p>
|
||||
|
||||
<details v-if="doneFile" class="todo-archive">
|
||||
<summary>Archived ({{ archivedTasks.length }})</summary>
|
||||
<ul class="todo-archive-list">
|
||||
<li v-for="(task, i) in archivedTasks" :key="i">
|
||||
<todo-txt-item :task="task" :can-edit="false" />
|
||||
</li>
|
||||
</ul>
|
||||
</details>
|
||||
</template>
|
||||
</flux-note>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style lang="scss">
|
||||
<style scoped lang="scss">
|
||||
.todo-notes {
|
||||
.note-display {
|
||||
h1 {
|
||||
font-size: 1.8rem;
|
||||
display: flex;
|
||||
flex: 1;
|
||||
|
||||
.subtitle {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.todo-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
// No `gap` — gap can't transition, so it would snap closed when a row
|
||||
// is removed at the end of the leave animation. Spacing lives on
|
||||
// .todo-li-inner instead so it collapses with the row.
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
// Use grid-template-rows 1fr → 0fr so the row's height collapses
|
||||
// smoothly during leave instead of snapping when the DOM is removed.
|
||||
display: grid;
|
||||
grid-template-rows: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
margin-right: 0.5rem;
|
||||
.todo-li-inner {
|
||||
overflow: hidden;
|
||||
min-height: 0;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.todo-enter-active,
|
||||
.todo-leave-active {
|
||||
transition:
|
||||
opacity 180ms ease,
|
||||
transform 220ms ease,
|
||||
grid-template-rows 220ms ease;
|
||||
}
|
||||
|
||||
.todo-enter-from,
|
||||
.todo-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(-12px);
|
||||
grid-template-rows: 0fr;
|
||||
}
|
||||
|
||||
.todo-move {
|
||||
transition: transform 220ms ease;
|
||||
}
|
||||
|
||||
.todo-filters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.75rem;
|
||||
}
|
||||
|
||||
.todo-filter-columns {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.todo-filter-column {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.todo-filter-label {
|
||||
font-size: 0.8rem;
|
||||
opacity: 0.7;
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
flex: 0 0 auto;
|
||||
min-width: 5.5rem;
|
||||
}
|
||||
|
||||
.todo-filter-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.35rem;
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.todo-filter-chip {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.todo-archive {
|
||||
margin-top: 1rem;
|
||||
|
||||
> summary {
|
||||
cursor: pointer;
|
||||
opacity: 0.7;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
> summary::-webkit-details-marker {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.todo-archive-list {
|
||||
list-style: none;
|
||||
padding-left: 0;
|
||||
margin: 0.5rem 0 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
li {
|
||||
list-style: none;
|
||||
opacity: 0.7;
|
||||
padding-bottom: 0.25rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,7 +3,7 @@ const dotenv = require("dotenv")
|
||||
|
||||
dotenv.config()
|
||||
|
||||
const defaultTitleStyles = Array.from(
|
||||
const defaultHeadingStyles = Array.from(
|
||||
{ length: 6 },
|
||||
(_, k) => `h${k + 1}`
|
||||
).reduce(
|
||||
@@ -12,7 +12,7 @@ const defaultTitleStyles = Array.from(
|
||||
[heading]: {
|
||||
"margin-top": "0",
|
||||
"margin-bottom": "0.5em",
|
||||
"font-family": "var(--title-font-family)"
|
||||
"font-family": "var(--heading-font-family)"
|
||||
}
|
||||
}),
|
||||
{}
|
||||
@@ -27,7 +27,7 @@ module.exports = {
|
||||
typography: () => ({
|
||||
DEFAULT: {
|
||||
css: {
|
||||
...defaultTitleStyles,
|
||||
...defaultHeadingStyles,
|
||||
"font-size": "13pt",
|
||||
"font-family": '"Libertinus Serif", serif',
|
||||
p: {
|
||||
|
||||
Reference in New Issue
Block a user