Compare commits
100 Commits
37c65142b7
...
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 | ||
|
|
9f21cb7882 | ||
|
|
1a2d8f4ebf | ||
|
|
d99672dbd8 | ||
|
|
a09e541fa8 | ||
|
|
455addf760 | ||
|
|
b87836782b | ||
|
|
299493854e | ||
|
|
54c52feeba | ||
|
|
60e3849c20 | ||
|
|
4ab0be75e7 | ||
|
|
6a0f0d08d2 | ||
|
|
0b3411626c | ||
|
|
01bb4b8c70 | ||
|
|
8a8509a0f3 | ||
|
|
151a4d9137 | ||
|
|
c412c75cfd | ||
|
|
e39ac32e43 | ||
|
|
a7ea2ce9cf | ||
|
|
1bf09a6629 | ||
|
|
7a2ccc1bd1 | ||
|
|
e11609990d | ||
|
|
e121cc2d05 | ||
|
|
f9e1495fc3 | ||
|
|
eeca9d071f | ||
|
|
e5dcdac65a | ||
|
|
2994839ee5 | ||
|
|
dd576ced90 | ||
|
|
0a2445a06d | ||
|
|
f3ed5e063f | ||
|
|
2002ac670a | ||
|
|
0ad747b69f | ||
|
|
c5236b2587 | ||
|
|
fc4ed188d7 | ||
|
|
de7ac8d096 | ||
|
|
0ef339d42e | ||
|
|
0e70d808ce | ||
|
|
952417621e | ||
|
|
9d1b6552cb | ||
|
|
f3708b2f9d | ||
|
|
a4d9774191 |
40
.gitea/workflows/ci.yml
Normal file
40
.gitea/workflows/ci.yml
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ gitea.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
verify:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Setup pnpm
|
||||||
|
uses: pnpm/action-setup@v4
|
||||||
|
with:
|
||||||
|
version: 11.5.2
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: pnpm
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Type-check
|
||||||
|
run: pnpm types
|
||||||
|
|
||||||
|
- name: Lint
|
||||||
|
run: pnpm lint
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: pnpm test --run
|
||||||
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**
|
- **Vue 3** (3.5) with Composition API, **Pinia** for state, **Vue Router**
|
||||||
- **Vite 7** build tool, **TypeScript** strict mode
|
- **Vite 7** build tool, **TypeScript** strict mode
|
||||||
- **Tailwind CSS v4** + **DaisyUI v5** for styling (PostCSS plugin approach, not tailwind.config.js)
|
- **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
|
- **PouchDB** (IndexedDB) for local persistence
|
||||||
- **Octokit** for GitHub API integration
|
- **Octokit** for GitHub API integration
|
||||||
- **markdown-it** with plugins (KaTeX, Shikiji, Mermaid, checkboxes, GitHub alerts)
|
- **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; }`
|
- DaisyUI configured via `@plugin 'daisyui' { themes: retro --default, coffee --prefersdark; }`
|
||||||
- `@tailwindcss/typography` for prose styling
|
- `@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
|
### Directory Structure
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
@@ -50,8 +54,7 @@ src/
|
|||||||
│ ├── user/ # Authentication, user settings
|
│ ├── user/ # Authentication, user settings
|
||||||
│ ├── card/ # Spaced repetition
|
│ ├── card/ # Spaced repetition
|
||||||
│ ├── history/ # Edit history tracking
|
│ ├── history/ # Edit history tracking
|
||||||
│ ├── atproto/ # ATProto/Bluesky integration (DID resolution, blob URLs)
|
│ └── atproto/ # ATProto/Bluesky integration (DID resolution, blob URLs)
|
||||||
│ └── post/ # ts-rest API client for public note publishing (api.remanso.space)
|
|
||||||
├── hooks/ # Composition hooks (useMarkdown, useBacklinks, useGitHubContent, etc.)
|
├── hooks/ # Composition hooks (useMarkdown, useBacklinks, useGitHubContent, etc.)
|
||||||
├── data/ # PouchDB wrapper and data models
|
├── data/ # PouchDB wrapper and data models
|
||||||
├── utils/ # Utilities including custom markdown-it plugins
|
├── 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/store/userRepo.store.ts` - Central Pinia store for repo/file state
|
||||||
- `src/modules/repo/services/octo.ts` - Octokit wrapper for GitHub API
|
- `src/modules/repo/services/octo.ts` - Octokit wrapper for GitHub API
|
||||||
- `src/hooks/useMarkdown.hook.ts` - Markdown rendering with all plugins
|
- `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
|
- `src/data/data.ts` - PouchDB database wrapper
|
||||||
|
|
||||||
### Patterns
|
### Patterns
|
||||||
@@ -81,5 +83,4 @@ src/
|
|||||||
- All components use Composition API (`<script setup lang="ts">`)
|
- All components use Composition API (`<script setup lang="ts">`)
|
||||||
- Custom hooks encapsulate feature logic (`use*.hook.ts` or `use*.ts`)
|
- Custom hooks encapsulate feature logic (`use*.hook.ts` or `use*.ts`)
|
||||||
- Event buses (`noteEventBus`, `backlinkEventBus`) for cross-component communication
|
- Event buses (`noteEventBus`, `backlinkEventBus`) for cross-component communication
|
||||||
- ts-rest contracts use arktype schemas for request/response validation
|
|
||||||
- Path alias: `@` maps to `src/`
|
- 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.)
|
||||||
12
package.json
12
package.json
@@ -2,7 +2,7 @@
|
|||||||
"name": "remanso",
|
"name": "remanso",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"packageManager": "pnpm@11.0.9",
|
"packageManager": "pnpm@11.5.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vite build",
|
"build": "vite build",
|
||||||
@@ -26,10 +26,7 @@
|
|||||||
"@octokit/rest": "^22.0.1",
|
"@octokit/rest": "^22.0.1",
|
||||||
"@openpanel/web": "^1.3.0",
|
"@openpanel/web": "^1.3.0",
|
||||||
"@tailwindcss/postcss": "^4.1.16",
|
"@tailwindcss/postcss": "^4.1.16",
|
||||||
"@tanstack/vue-query": "^5.92.9",
|
|
||||||
"@toycode/markdown-it-class": "^1.2.4",
|
"@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",
|
"@vscode/markdown-it-katex": "^1.1.2",
|
||||||
"@vueuse/components": "^14.2.1",
|
"@vueuse/components": "^14.2.1",
|
||||||
"@vueuse/core": "^13.6.0",
|
"@vueuse/core": "^13.6.0",
|
||||||
@@ -52,6 +49,7 @@
|
|||||||
"markdown-it-shikiji": "^0.10.2",
|
"markdown-it-shikiji": "^0.10.2",
|
||||||
"mermaid": "^11.12.1",
|
"mermaid": "^11.12.1",
|
||||||
"nanoid": "^5.1.6",
|
"nanoid": "^5.1.6",
|
||||||
|
"node-diff3": "^3.2.1",
|
||||||
"notyf": "^3.10.0",
|
"notyf": "^3.10.0",
|
||||||
"pastel-color": "^1.0.3",
|
"pastel-color": "^1.0.3",
|
||||||
"pinia": "^2.2.6",
|
"pinia": "^2.2.6",
|
||||||
@@ -61,6 +59,9 @@
|
|||||||
"register-service-worker": "^1.7.2",
|
"register-service-worker": "^1.7.2",
|
||||||
"retrobus": "^1.9.4",
|
"retrobus": "^1.9.4",
|
||||||
"sanitize-html": "^2.17.0",
|
"sanitize-html": "^2.17.0",
|
||||||
|
"shikiji-core": "0.10.2",
|
||||||
|
"smol-toml": "^1.7.0",
|
||||||
|
"valibot": "^1.4.2",
|
||||||
"vue": "^3.5.18",
|
"vue": "^3.5.18",
|
||||||
"vue-i18n": "^11.1.11",
|
"vue-i18n": "^11.1.11",
|
||||||
"vue-router": "^4.5.1",
|
"vue-router": "^4.5.1",
|
||||||
@@ -69,6 +70,7 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.28.5",
|
"@babel/core": "^7.28.5",
|
||||||
|
"@pinia/testing": "^1.0.3",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@tailwindcss/typography": "^0.5.19",
|
||||||
"@types/fontfaceobserver": "^2.1.3",
|
"@types/fontfaceobserver": "^2.1.3",
|
||||||
"@types/markdown-it": "^14.1.2",
|
"@types/markdown-it": "^14.1.2",
|
||||||
@@ -78,6 +80,7 @@
|
|||||||
"@vite-pwa/assets-generator": "^1.0.2",
|
"@vite-pwa/assets-generator": "^1.0.2",
|
||||||
"@vitejs/plugin-vue": "^5.2.4",
|
"@vitejs/plugin-vue": "^5.2.4",
|
||||||
"@vue/compiler-sfc": "^3.5.28",
|
"@vue/compiler-sfc": "^3.5.28",
|
||||||
|
"@vue/test-utils": "^2.4.11",
|
||||||
"autoprefixer": "^10.4.24",
|
"autoprefixer": "^10.4.24",
|
||||||
"daisyui": "^5.5.18",
|
"daisyui": "^5.5.18",
|
||||||
"dotenv": "^17.2.3",
|
"dotenv": "^17.2.3",
|
||||||
@@ -85,6 +88,7 @@
|
|||||||
"eslint-plugin-simple-import-sort": "^12.1.1",
|
"eslint-plugin-simple-import-sort": "^12.1.1",
|
||||||
"eslint-plugin-unused-imports": "^4.4.1",
|
"eslint-plugin-unused-imports": "^4.4.1",
|
||||||
"esno": "^4.8.0",
|
"esno": "^4.8.0",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"oxfmt": "^0.42.0",
|
"oxfmt": "^0.42.0",
|
||||||
"oxlint": "^1.57.0",
|
"oxlint": "^1.57.0",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
|
|||||||
687
pnpm-lock.yaml
generated
687
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,5 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import ImageLightbox from "@/components/ImageLightbox.vue"
|
||||||
import NewVersion from "@/components/NewVersion.vue"
|
import NewVersion from "@/components/NewVersion.vue"
|
||||||
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
|
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
|
||||||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||||
@@ -12,6 +13,7 @@ const { isATProtoReady } = useATProtoLogin()
|
|||||||
<router-view v-if="isReady && isATProtoReady" />
|
<router-view v-if="isReady && isATProtoReady" />
|
||||||
|
|
||||||
<new-version />
|
<new-version />
|
||||||
|
<image-lightbox />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { OpenPanel } from "@openpanel/web"
|
import { OpenPanel } from "@openpanel/web"
|
||||||
|
|
||||||
export const op = new OpenPanel({
|
export const op = new OpenPanel({
|
||||||
apiUrl: "https://api.panel.apoena.dev",
|
apiUrl: "https://api.stats.apoena.dev",
|
||||||
clientId: "038a6aac-19bb-4a7f-9aae-2d0201fead5b",
|
clientId: "038a6aac-19bb-4a7f-9aae-2d0201fead5b",
|
||||||
trackScreenViews: true,
|
trackScreenViews: true,
|
||||||
trackOutgoingLinks: true,
|
trackOutgoingLinks: true,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { onBeforeMount, ref } from "vue"
|
|||||||
import { useRoute, useRouter } from "vue-router"
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
|
||||||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||||
|
import { consumeGithubOAuthReturnPath } from "@/modules/user/service/oauthReturnPath"
|
||||||
import { signIn } from "@/modules/user/service/signIn"
|
import { signIn } from "@/modules/user/service/signIn"
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@@ -22,7 +23,13 @@ onBeforeMount(async () => {
|
|||||||
await saveCredentials(token)
|
await saveCredentials(token)
|
||||||
}
|
}
|
||||||
|
|
||||||
router.replace({ name: "Home" })
|
const returnPath = consumeGithubOAuthReturnPath()
|
||||||
|
|
||||||
|
if (!hasError.value && returnPath) {
|
||||||
|
router.replace(returnPath)
|
||||||
|
} else {
|
||||||
|
router.replace({ name: "Home" })
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
|||||||
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
||||||
import { useNoteView } from "@/hooks/useNoteView.hook"
|
import { useNoteView } from "@/hooks/useNoteView.hook"
|
||||||
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
|
import { useResizeContainer } from "@/hooks/useResizeContainer.hook"
|
||||||
|
import { useResolveLiveNotes } from "@/hooks/useResolveLiveNotes.hook"
|
||||||
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
import { useRouteQueryStackedNotes } from "@/hooks/useRouteQueryStackedNotes.hook"
|
||||||
import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook"
|
import { useVisitRepo } from "@/modules/history/hooks/useVisitRepo.hook"
|
||||||
import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
|
import CacheAllNotes from "@/modules/note/components/CacheAllNote.vue"
|
||||||
@@ -43,7 +44,12 @@ useUserSettings()
|
|||||||
const { visitRepo } = useVisitRepo({ user: user, repo: repo })
|
const { visitRepo } = useVisitRepo({ user: user, repo: repo })
|
||||||
const { toHTML } = markdownBuilder(repo)
|
const { toHTML } = markdownBuilder(repo)
|
||||||
const { listenToClick } = useLinks("note-display")
|
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 { titles } = useNoteView()
|
||||||
const { isLogged } = useGitHubLogin()
|
const { isLogged } = useGitHubLogin()
|
||||||
@@ -62,7 +68,8 @@ const hasContent = computed(() => !!renderedContent.value)
|
|||||||
|
|
||||||
useMarkdownPostRender(renderedContent, () => ".note-display", {
|
useMarkdownPostRender(renderedContent, () => ".note-display", {
|
||||||
onReady: () => listenToClick(),
|
onReady: () => listenToClick(),
|
||||||
tikz: true
|
tikz: true,
|
||||||
|
macroplan: true
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
@@ -73,7 +80,14 @@ watch(
|
|||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|
||||||
onMounted(() => visitRepo())
|
const retryLoad = () => {
|
||||||
|
store.setUserRepo(props.user, props.repo)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
visitRepo()
|
||||||
|
scrollToLastStackedNote()
|
||||||
|
})
|
||||||
|
|
||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
store.resetFiles()
|
store.resetFiles()
|
||||||
@@ -96,12 +110,19 @@ onUnmounted(() => {
|
|||||||
<h1 class="heading-1">
|
<h1 class="heading-1">
|
||||||
{{ repo }}
|
{{ repo }}
|
||||||
</h1>
|
</h1>
|
||||||
{{ user }}
|
· {{ user }}
|
||||||
</div>
|
</div>
|
||||||
<cache-all-notes />
|
<cache-all-notes />
|
||||||
</div>
|
</div>
|
||||||
<slot />
|
<slot />
|
||||||
<skeleton-loader v-if="isLoading" />
|
<skeleton-loader v-if="isLoading" />
|
||||||
|
<div
|
||||||
|
v-else-if="withContent && !hasContent && store.loadError === 'network'"
|
||||||
|
class="repo-network-error"
|
||||||
|
>
|
||||||
|
<p>Couldn't reach GitHub. Check your connection.</p>
|
||||||
|
<button class="btn btn-primary" @click="retryLoad">Retry</button>
|
||||||
|
</div>
|
||||||
<div v-else-if="withContent && !hasContent" class="repo-not-found">
|
<div v-else-if="withContent && !hasContent" class="repo-not-found">
|
||||||
<template v-if="isLogged">
|
<template v-if="isLogged">
|
||||||
<p>This repository is not accessible.</p>
|
<p>This repository is not accessible.</p>
|
||||||
@@ -137,6 +158,10 @@ $header-height: 40px;
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
||||||
|
.heading-1 {
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
&.content {
|
&.content {
|
||||||
.title,
|
.title,
|
||||||
h1,
|
h1,
|
||||||
@@ -198,7 +223,8 @@ $header-height: 40px;
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.repo-not-found {
|
.repo-not-found,
|
||||||
|
.repo-network-error {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -25,16 +25,16 @@ const fontFamilies = computed(
|
|||||||
const sortedFontFamilies = computed(() => {
|
const sortedFontFamilies = computed(() => {
|
||||||
const base = fontFamilies.value
|
const base = fontFamilies.value
|
||||||
const extras = [
|
const extras = [
|
||||||
store.userSettings?.chosenTitleFont,
|
store.userSettings?.chosenHeadingFont,
|
||||||
store.userSettings?.chosenBodyFont
|
store.userSettings?.chosenBodyFont
|
||||||
].filter((f): f is string => !!f && !base.includes(f))
|
].filter((f): f is string => !!f && !base.includes(f))
|
||||||
return [...base, ...extras].sort((a, b) => a.localeCompare(b))
|
return [...base, ...extras].sort((a, b) => a.localeCompare(b))
|
||||||
})
|
})
|
||||||
const fontSizes = Array.from({ length: 7 }, (_, i) => `${9 + i * 2}pt`)
|
const fontSizes = Array.from({ length: 7 }, (_, i) => `${9 + i * 2}pt`)
|
||||||
|
|
||||||
const titleFont = computed({
|
const headingFont = computed({
|
||||||
get: () => store.userSettings?.chosenTitleFont,
|
get: () => store.userSettings?.chosenHeadingFont,
|
||||||
set: (value) => store.setTitleFont(value!)
|
set: (value) => store.setHeadingFont(value!)
|
||||||
})
|
})
|
||||||
const bodyFont = computed({
|
const bodyFont = computed({
|
||||||
get: () => store.userSettings?.chosenBodyFont,
|
get: () => store.userSettings?.chosenBodyFont,
|
||||||
@@ -49,13 +49,35 @@ const fontSize = computed({
|
|||||||
<template>
|
<template>
|
||||||
<div class="font-change">
|
<div class="font-change">
|
||||||
<div>
|
<div>
|
||||||
<label for="title-font" class="font-label">t</label>
|
<label for="heading-font" class="font-label">h</label>
|
||||||
<select id="title-font" class="select" v-model="titleFont">
|
<select id="heading-font" class="select" v-model="headingFont">
|
||||||
<option v-for="font in sortedFontFamilies" :key="font" :value="font">
|
<option v-for="font in sortedFontFamilies" :key="font" :value="font">
|
||||||
{{ font }}
|
{{ font }}
|
||||||
</option>
|
</option>
|
||||||
</select>
|
</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>
|
<label for="body-font" class="font-label">p</label>
|
||||||
<select id="body-font" class="select" v-model="bodyFont">
|
<select id="body-font" class="select" v-model="bodyFont">
|
||||||
<option v-for="font in sortedFontFamilies" :key="font" :value="font">
|
<option v-for="font in sortedFontFamilies" :key="font" :value="font">
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import FontChange from "@/components/FontChange.vue"
|
import FontChange from "@/components/FontChange.vue"
|
||||||
import HomeButton from "@/components/HomeButton.vue"
|
import HomeButton from "@/components/HomeButton.vue"
|
||||||
|
import ShareNote from "@/components/ShareNote.vue"
|
||||||
|
|
||||||
defineProps<{ user: string; repo: string }>()
|
defineProps<{ user: string; repo: string }>()
|
||||||
</script>
|
</script>
|
||||||
@@ -55,6 +56,7 @@ defineProps<{ user: string; repo: string }>()
|
|||||||
<path d="M5 20l6 -16l2 0l7 16" />
|
<path d="M5 20l6 -16l2 0l7 16" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<share-note />
|
||||||
<router-link
|
<router-link
|
||||||
class="btn btn-ghost btn-circle"
|
class="btn btn-ghost btn-circle"
|
||||||
:to="{ name: 'FluxNoteView', params: { user, repo } }"
|
: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>
|
||||||
@@ -4,10 +4,10 @@ import { onMounted, ref, watch } from "vue"
|
|||||||
const props = defineProps<{ open: boolean }>()
|
const props = defineProps<{ open: boolean }>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: "discard"): void
|
discard: []
|
||||||
(e: "overwrite"): void
|
overwrite: []
|
||||||
(e: "cancel"): void
|
cancel: []
|
||||||
(e: "update:open", value: boolean): void
|
"update:open": [value: boolean]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const dialogRef = ref<HTMLDialogElement | null>(null)
|
const dialogRef = ref<HTMLDialogElement | null>(null)
|
||||||
@@ -17,8 +17,16 @@ const close = () => {
|
|||||||
emit("update:open", false)
|
emit("update:open", false)
|
||||||
}
|
}
|
||||||
|
|
||||||
const choose = (action: "discard" | "overwrite" | "cancel") => {
|
const onDiscard = () => {
|
||||||
emit(action)
|
emit("discard")
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
const onOverwrite = () => {
|
||||||
|
emit("overwrite")
|
||||||
|
close()
|
||||||
|
}
|
||||||
|
const onCancel = () => {
|
||||||
|
emit("cancel")
|
||||||
close()
|
close()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,7 +50,7 @@ onMounted(() => {
|
|||||||
ref="dialogRef"
|
ref="dialogRef"
|
||||||
class="modal"
|
class="modal"
|
||||||
@close="emit('update:open', false)"
|
@close="emit('update:open', false)"
|
||||||
@cancel.prevent="choose('cancel')"
|
@cancel.prevent="onCancel()"
|
||||||
>
|
>
|
||||||
<div class="modal-box">
|
<div class="modal-box">
|
||||||
<h3 class="text-lg font-bold">GitHub has a newer version of this note</h3>
|
<h3 class="text-lg font-bold">GitHub has a newer version of this note</h3>
|
||||||
@@ -55,28 +63,28 @@ onMounted(() => {
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-ghost"
|
class="btn btn-ghost"
|
||||||
@click="choose('cancel')"
|
@click="onCancel()"
|
||||||
>
|
>
|
||||||
Cancel
|
Cancel
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-warning"
|
class="btn btn-warning"
|
||||||
@click="choose('overwrite')"
|
@click="onOverwrite()"
|
||||||
>
|
>
|
||||||
Save anyway (overwrite)
|
Save anyway (overwrite)
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary"
|
class="btn btn-primary"
|
||||||
@click="choose('discard')"
|
@click="onDiscard()"
|
||||||
>
|
>
|
||||||
Discard my edits, pull latest
|
Discard my edits, pull latest
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form method="dialog" class="modal-backdrop">
|
<form method="dialog" class="modal-backdrop">
|
||||||
<button type="submit" @click="choose('cancel')">close</button>
|
<button type="submit" @click="onCancel()">close</button>
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
74
src/components/NoteFreshnessBadge.spec.ts
Normal file
74
src/components/NoteFreshnessBadge.spec.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { mount } from "@vue/test-utils"
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import NoteFreshnessBadge from "./NoteFreshnessBadge.vue"
|
||||||
|
|
||||||
|
const factory = (props: { status: string; lastCheckedAt?: Date | null }) =>
|
||||||
|
mount(NoteFreshnessBadge, {
|
||||||
|
props: { lastCheckedAt: null, ...props } as never
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("NoteFreshnessBadge", () => {
|
||||||
|
it("renders the verified state with the cloud-check icon", () => {
|
||||||
|
const wrapper = factory({ status: "verified" })
|
||||||
|
expect(wrapper.classes()).toContain("state-verified")
|
||||||
|
expect(wrapper.find(".icon-tabler-cloud-check").exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders the outdated state with the download icon", () => {
|
||||||
|
const wrapper = factory({ status: "outdated" })
|
||||||
|
expect(wrapper.classes()).toContain("state-outdated")
|
||||||
|
expect(wrapper.find(".icon-tabler-cloud-download").exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders the offline state with the cloud-off icon", () => {
|
||||||
|
const wrapper = factory({ status: "offline" })
|
||||||
|
expect(wrapper.classes()).toContain("state-offline")
|
||||||
|
expect(wrapper.find(".icon-tabler-cloud-off").exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders the unauthorized state with the lock icon", () => {
|
||||||
|
const wrapper = factory({ status: "unauthorized" })
|
||||||
|
expect(wrapper.classes()).toContain("state-unauthorized")
|
||||||
|
expect(wrapper.find(".icon-tabler-cloud-lock").exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("disables the button while checking", () => {
|
||||||
|
const wrapper = factory({ status: "checking" })
|
||||||
|
expect(wrapper.attributes("disabled")).toBeDefined()
|
||||||
|
expect(wrapper.find(".icon-tabler-loader-2").exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("emits click when not busy", async () => {
|
||||||
|
const wrapper = factory({ status: "verified" })
|
||||||
|
|
||||||
|
await wrapper.trigger("click")
|
||||||
|
|
||||||
|
expect(wrapper.emitted("click")).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("includes the last-checked time in the verified tooltip", () => {
|
||||||
|
const wrapper = factory({
|
||||||
|
status: "verified",
|
||||||
|
lastCheckedAt: new Date("2026-01-01T10:30:00")
|
||||||
|
})
|
||||||
|
const tooltip = wrapper.attributes("title") as string
|
||||||
|
expect(tooltip).toMatch(/Verified at/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders the unknown state as default", () => {
|
||||||
|
const wrapper = factory({ status: "unknown" })
|
||||||
|
expect(wrapper.classes()).toContain("state-unknown")
|
||||||
|
expect(wrapper.find(".icon-tabler-cloud-question").exists()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("offline tooltip prompts a retry", () => {
|
||||||
|
const wrapper = factory({ status: "offline" })
|
||||||
|
expect(wrapper.attributes("title")).toMatch(/retry/i)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("unauthorized tooltip prompts a sign-in", () => {
|
||||||
|
const wrapper = factory({ status: "unauthorized" })
|
||||||
|
expect(wrapper.attributes("title")).toMatch(/[Ss]ign in/)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -23,6 +23,8 @@ const label = computed(() => {
|
|||||||
return "Outdated"
|
return "Outdated"
|
||||||
case "offline":
|
case "offline":
|
||||||
return "Can’t reach GitHub"
|
return "Can’t reach GitHub"
|
||||||
|
case "unauthorized":
|
||||||
|
return "Sign in to GitHub"
|
||||||
case "unknown":
|
case "unknown":
|
||||||
default:
|
default:
|
||||||
return "Not checked"
|
return "Not checked"
|
||||||
@@ -39,6 +41,8 @@ const tooltip = computed(() => {
|
|||||||
return "GitHub has a newer version. Click to pull latest."
|
return "GitHub has a newer version. Click to pull latest."
|
||||||
case "offline":
|
case "offline":
|
||||||
return "Could not reach GitHub. Click to retry."
|
return "Could not reach GitHub. Click to retry."
|
||||||
|
case "unauthorized":
|
||||||
|
return "GitHub auth expired. Sign in again, then click to retry."
|
||||||
case "checking":
|
case "checking":
|
||||||
return "Checking against GitHub…"
|
return "Checking against GitHub…"
|
||||||
case "unknown":
|
case "unknown":
|
||||||
@@ -133,6 +137,25 @@ const isBusy = computed(() => props.status === "checking")
|
|||||||
>
|
>
|
||||||
<path d="M12 3a9 9 0 1 0 9 9" />
|
<path d="M12 3a9 9 0 1 0 9 9" />
|
||||||
</svg>
|
</svg>
|
||||||
|
<svg
|
||||||
|
v-else-if="status === 'unauthorized'"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-cloud-lock"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
stroke-width="1.75"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
d="M11 18.004h-4.343c-2.572 -.004 -4.657 -2.011 -4.657 -4.487c0 -2.475 2.085 -4.482 4.657 -4.482c.393 -1.762 1.794 -3.2 3.675 -3.773c1.88 -.572 3.956 -.193 5.444 1c1.488 1.19 2.162 3.007 1.77 4.769h.99c1.18 0 2.227 .593 2.85 1.5"
|
||||||
|
/>
|
||||||
|
<path d="M15 16m0 1a1 1 0 0 1 1 -1h4a1 1 0 0 1 1 1v3a1 1 0 0 1 -1 1h-4a1 1 0 0 1 -1 -1z" />
|
||||||
|
<path d="M18 14a2 2 0 0 1 2 2v0h-4v0a2 2 0 0 1 2 -2z" />
|
||||||
|
</svg>
|
||||||
<svg
|
<svg
|
||||||
v-else
|
v-else
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -191,6 +214,10 @@ const isBusy = computed(() => props.status === "checking")
|
|||||||
color: var(--color-error, hsl(0, 70%, 45%));
|
color: var(--color-error, hsl(0, 70%, 45%));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.state-unauthorized {
|
||||||
|
color: var(--color-warning, hsl(35, 90%, 45%));
|
||||||
|
}
|
||||||
|
|
||||||
.state-unknown,
|
.state-unknown,
|
||||||
.state-checking {
|
.state-checking {
|
||||||
color: var(--color-base-content);
|
color: var(--color-base-content);
|
||||||
|
|||||||
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>
|
||||||
153
src/components/ProfileModal.vue
Normal file
153
src/components/ProfileModal.vue
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
import SignInAtproto from "@/components/SignInAtproto.vue"
|
||||||
|
import SignInGithub from "@/components/SignInGithub.vue"
|
||||||
|
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||||
|
|
||||||
|
const { accessToken } = useGitHubLogin()
|
||||||
|
const isGitHubLoggedIn = computed(() => !!accessToken.value)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<dialog id="profile_modal" class="modal profile-modal">
|
||||||
|
<div class="modal-box profile-modal-box">
|
||||||
|
<div class="profile-modal-head">
|
||||||
|
<h3>Profile</h3>
|
||||||
|
<form method="dialog">
|
||||||
|
<button class="profile-modal-x" aria-label="close">×</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="profile-modal-section">
|
||||||
|
<div class="profile-modal-label">Bluesky / ATProto</div>
|
||||||
|
<sign-in-atproto :with-sign-out="true" />
|
||||||
|
</div>
|
||||||
|
<hr class="profile-modal-rule" />
|
||||||
|
<div class="profile-modal-section">
|
||||||
|
<div class="profile-modal-label">GitHub</div>
|
||||||
|
<sign-in-github />
|
||||||
|
<router-link
|
||||||
|
v-if="isGitHubLoggedIn"
|
||||||
|
:to="{ name: 'RepoList' }"
|
||||||
|
class="profile-modal-link"
|
||||||
|
>Manage your repos</router-link
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<form method="dialog" class="modal-backdrop">
|
||||||
|
<button></button>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.profile-modal {
|
||||||
|
--pm-rule: color-mix(
|
||||||
|
in oklch,
|
||||||
|
var(--color-base-content) 12%,
|
||||||
|
var(--color-base-200)
|
||||||
|
);
|
||||||
|
--pm-ink: var(--color-base-content);
|
||||||
|
--pm-ink-soft: color-mix(
|
||||||
|
in oklch,
|
||||||
|
var(--color-base-content) 65%,
|
||||||
|
var(--color-base-200)
|
||||||
|
);
|
||||||
|
--pm-ink-faint: color-mix(
|
||||||
|
in oklch,
|
||||||
|
var(--color-base-content) 38%,
|
||||||
|
var(--color-base-200)
|
||||||
|
);
|
||||||
|
--pm-accent: #e36598;
|
||||||
|
--pm-accent-wash: color-mix(in oklch, #e36598 12%, var(--color-base-200));
|
||||||
|
--pm-accent-deep: color-mix(
|
||||||
|
in oklch,
|
||||||
|
#e36598 75%,
|
||||||
|
var(--color-base-content)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-box {
|
||||||
|
background: var(--color-base-200);
|
||||||
|
border: 1px solid var(--pm-rule);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 30px 60px -20px rgba(0, 0, 0, 0.3);
|
||||||
|
color: var(--pm-ink);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-head {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-x {
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--pm-ink-soft);
|
||||||
|
line-height: 1;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--pm-accent-deep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-section {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-label {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
letter-spacing: 0.14em;
|
||||||
|
color: var(--pm-ink-faint);
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-rule {
|
||||||
|
border: 0;
|
||||||
|
border-top: 1px solid var(--pm-rule);
|
||||||
|
margin: 1.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-modal-link {
|
||||||
|
font-size: 16px;
|
||||||
|
padding: 0.55rem 1rem;
|
||||||
|
border-radius: 2px;
|
||||||
|
border: 1px solid var(--pm-rule);
|
||||||
|
background: transparent;
|
||||||
|
color: var(--pm-ink-soft);
|
||||||
|
cursor: pointer;
|
||||||
|
transition:
|
||||||
|
background 0.15s,
|
||||||
|
color 0.15s,
|
||||||
|
border-color 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--pm-accent-wash);
|
||||||
|
border-color: var(--pm-accent);
|
||||||
|
color: var(--pm-accent-deep);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -45,8 +45,9 @@ defineSlots<{
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
ul {
|
ul {
|
||||||
padding-left: 1rem;
|
width: 100%;
|
||||||
padding-right: 1rem;
|
max-width: 42rem;
|
||||||
|
margin-inline: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
li {
|
li {
|
||||||
@@ -57,17 +58,19 @@ li {
|
|||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
padding-left: 0;
|
padding: 0;
|
||||||
|
min-height: 0;
|
||||||
|
height: auto;
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
line-height: 1.5rem;
|
line-height: 1.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alias {
|
.alias {
|
||||||
text-align: right;
|
text-align: left;
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: flex-end;
|
justify-content: flex-start;
|
||||||
margin-top: 0.5rem;
|
margin-top: 0.125rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</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,20 +1,34 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { useRoute } from "vue-router"
|
||||||
|
|
||||||
|
import { GITHUB_OAUTH_RETURN_PATH_KEY } from "@/modules/user/service/oauthReturnPath"
|
||||||
|
|
||||||
const GITHUB_URL = "https://github.com/login/oauth/authorize"
|
const GITHUB_URL = "https://github.com/login/oauth/authorize"
|
||||||
|
|
||||||
const CLIENT_ID = "Iv1.12dc43d013ce3623"
|
const CLIENT_ID = "Iv1.12dc43d013ce3623"
|
||||||
const SCOPE = "repo%20workflow"
|
const SCOPE = "repo%20workflow"
|
||||||
const REDIRECT_URI = window.location.origin
|
const REDIRECT_URI = window.location.origin
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
const url = new URL(GITHUB_URL)
|
const url = new URL(GITHUB_URL)
|
||||||
url.searchParams.set("client_id", CLIENT_ID)
|
url.searchParams.set("client_id", CLIENT_ID)
|
||||||
url.searchParams.set("scope", SCOPE)
|
url.searchParams.set("scope", SCOPE)
|
||||||
url.searchParams.set("redirect_uri", REDIRECT_URI)
|
url.searchParams.set("redirect_uri", REDIRECT_URI)
|
||||||
|
|
||||||
const href = url.toString()
|
const href = url.toString()
|
||||||
|
|
||||||
|
const saveReturnPath = () => {
|
||||||
|
sessionStorage.setItem(GITHUB_OAUTH_RETURN_PATH_KEY, route.fullPath)
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<a :href="href" class="sign-in-github btn btn-sm btn-primary">
|
<a
|
||||||
|
:href="href"
|
||||||
|
class="sign-in-github btn btn-sm btn-primary"
|
||||||
|
@click="saveReturnPath"
|
||||||
|
>
|
||||||
Sign in with
|
Sign in with
|
||||||
<svg
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import {
|
import { computed, defineAsyncComponent, onMounted, ref, watch } from "vue"
|
||||||
computed,
|
|
||||||
defineAsyncComponent,
|
|
||||||
onMounted,
|
|
||||||
ref,
|
|
||||||
watch
|
|
||||||
} from "vue"
|
|
||||||
|
|
||||||
import { useEditionMode } from "@/hooks/useEditionMode"
|
import { useEditionMode } from "@/hooks/useEditionMode"
|
||||||
import { useFile } from "@/hooks/useFile.hook"
|
import { useFile } from "@/hooks/useFile.hook"
|
||||||
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||||
|
import { useImageUpload } from "@/hooks/useImageUpload.hook"
|
||||||
import { useLinks } from "@/hooks/useLinks.hook"
|
import { useLinks } from "@/hooks/useLinks.hook"
|
||||||
import { renderCodeFile } from "@/hooks/useMarkdown.hook"
|
import { renderCodeFile } from "@/hooks/useMarkdown.hook"
|
||||||
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
import { useMarkdownPostRender } from "@/hooks/useMarkdownPostRender.hook"
|
||||||
@@ -22,6 +17,7 @@ import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
|||||||
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
|
import { getFileLanguage, isMarkdownPath } from "@/utils/fileLanguage"
|
||||||
import { filenameToNoteTitle } from "@/utils/noteTitle"
|
import { filenameToNoteTitle } from "@/utils/noteTitle"
|
||||||
import { errorMessage } from "@/utils/notif"
|
import { errorMessage } from "@/utils/notif"
|
||||||
|
import { threeWayMerge } from "@/utils/threeWayMerge"
|
||||||
|
|
||||||
const LinkedNotes = defineAsyncComponent(
|
const LinkedNotes = defineAsyncComponent(
|
||||||
() => import("@/components/LinkedNotes.vue")
|
() => import("@/components/LinkedNotes.vue")
|
||||||
@@ -39,6 +35,10 @@ const EditNote = defineAsyncComponent(
|
|||||||
() => import("@/modules/note/components/EditNote.vue")
|
() => import("@/modules/note/components/EditNote.vue")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const NoteState = defineAsyncComponent(
|
||||||
|
() => import("@/components/NoteState.vue")
|
||||||
|
)
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
user: string
|
user: string
|
||||||
repo: string
|
repo: string
|
||||||
@@ -52,16 +52,28 @@ const repo = computed(() => props.repo)
|
|||||||
const sha = computed(() => props.sha)
|
const sha = computed(() => props.sha)
|
||||||
const index = computed(() => props.index)
|
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 {
|
const {
|
||||||
path,
|
path,
|
||||||
|
newerSha,
|
||||||
content,
|
content,
|
||||||
rawContent,
|
rawContent,
|
||||||
getRawContent,
|
getRawContent,
|
||||||
saveCacheNote,
|
saveCacheNote,
|
||||||
getEditedSha
|
getEditedSha
|
||||||
} = useFile(sha)
|
} = 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 initialRawContent = ref<string | null>(null)
|
||||||
const isMarkdown = computed(() =>
|
const isMarkdown = computed(() =>
|
||||||
path.value ? isMarkdownPath(path.value) : true
|
path.value ? isMarkdownPath(path.value) : true
|
||||||
@@ -99,6 +111,7 @@ useTitleNotes(repo)
|
|||||||
|
|
||||||
const store = useUserRepoStore()
|
const store = useUserRepoStore()
|
||||||
const hasBacklinks = computed(() => store.userSettings?.backlink)
|
const hasBacklinks = computed(() => store.userSettings?.backlink)
|
||||||
|
const canPush = computed(() => store.canPush)
|
||||||
|
|
||||||
const { displayNoteOverlay } = useNoteOverlay(className.value, index)
|
const { displayNoteOverlay } = useNoteOverlay(className.value, index)
|
||||||
const displayedTitle = computed(() => filenameToNoteTitle(props.title ?? ""))
|
const displayedTitle = computed(() => filenameToNoteTitle(props.title ?? ""))
|
||||||
@@ -109,12 +122,41 @@ const { updateFile } = useGitHubContent({
|
|||||||
repo: repo.value
|
repo: repo.value
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const { uploadImage } = useImageUpload({
|
||||||
|
user: user.value,
|
||||||
|
repo: repo.value,
|
||||||
|
notePath: path
|
||||||
|
})
|
||||||
|
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
const editKey = ref(0)
|
||||||
|
const isUploading = ref(false)
|
||||||
|
|
||||||
|
const onImagePicked = async (e: Event) => {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
const file = input.files?.[0]
|
||||||
|
input.value = ""
|
||||||
|
if (!file || !path.value) return
|
||||||
|
isUploading.value = true
|
||||||
|
try {
|
||||||
|
const result = await uploadImage(file)
|
||||||
|
if (!result) return
|
||||||
|
const trimmed = rawContent.value.replace(/\n+$/, "")
|
||||||
|
const prefix = trimmed ? `${trimmed}\n\n` : ""
|
||||||
|
rawContent.value = `${prefix}\n`
|
||||||
|
editKey.value++
|
||||||
|
} finally {
|
||||||
|
isUploading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
status: freshnessStatus,
|
status: freshnessStatus,
|
||||||
lastCheckedAt,
|
lastCheckedAt,
|
||||||
latestSha,
|
latestSha,
|
||||||
check: checkFreshness,
|
check: checkFreshness,
|
||||||
pullLatest
|
pullLatest,
|
||||||
|
resolveMergeSources
|
||||||
} = useNoteFreshness({
|
} = useNoteFreshness({
|
||||||
user: user.value,
|
user: user.value,
|
||||||
repo: repo.value,
|
repo: repo.value,
|
||||||
@@ -124,10 +166,34 @@ const {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const conflictOpen = ref(false)
|
const conflictOpen = ref(false)
|
||||||
|
const loadStatus = ref<"loading" | "ready" | "failed">("loading")
|
||||||
|
|
||||||
onMounted(async () => {
|
const loadNote = async () => {
|
||||||
initialRawContent.value = await getRawContent()
|
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(
|
watch(
|
||||||
path,
|
path,
|
||||||
@@ -142,10 +208,10 @@ const { mode, toggleMode } = useEditionMode()
|
|||||||
useMarkdownPostRender(content, () => `.note-${sha.value}`, {
|
useMarkdownPostRender(content, () => `.note-${sha.value}`, {
|
||||||
onReady: () => listenToClick(),
|
onReady: () => listenToClick(),
|
||||||
tikz: true,
|
tikz: true,
|
||||||
|
macroplan: true,
|
||||||
mermaid: () => rawContent.value.includes("```mermaid"),
|
mermaid: () => rawContent.value.includes("```mermaid"),
|
||||||
shikiji: () => isMarkdown.value && rawContent.value.includes("```"),
|
shikiji: () => isMarkdown.value && rawContent.value.includes("```"),
|
||||||
images: () =>
|
images: () => (/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null),
|
||||||
/\!\[.*?\]\(.*?\)/.test(rawContent.value) ? props.sha : null,
|
|
||||||
triggers: [mode]
|
triggers: [mode]
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -155,6 +221,13 @@ const performSave = async (overrideSha?: string) => {
|
|||||||
return
|
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 editedSha = overrideSha ?? (await getEditedSha()) ?? sha.value
|
||||||
const { sha: newSha, conflict } = await updateFile({
|
const { sha: newSha, conflict } = await updateFile({
|
||||||
content: rawContent.value,
|
content: rawContent.value,
|
||||||
@@ -163,9 +236,7 @@ const performSave = async (overrideSha?: string) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
if (conflict) {
|
if (conflict) {
|
||||||
await checkFreshness()
|
await handleConflict()
|
||||||
conflictOpen.value = true
|
|
||||||
if (mode.value === "read") toggleMode()
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,9 +246,52 @@ const performSave = async (overrideSha?: string) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
await saveCacheNote(encodeUTF8ToBase64(rawContent.value), {
|
await saveCacheNote(encodeUTF8ToBase64(rawContent.value), {
|
||||||
editedSha: newSha
|
editedSha: newSha,
|
||||||
|
path: path.value
|
||||||
})
|
})
|
||||||
initialRawContent.value = rawContent.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) => {
|
watch(mode, async (newMode) => {
|
||||||
@@ -186,8 +300,7 @@ watch(mode, async (newMode) => {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasUserFinishedToEdit =
|
const hasUserFinishedToEdit = newMode === "read" && isDirty.value
|
||||||
newMode === "read" && rawContent.value !== initialRawContent.value
|
|
||||||
|
|
||||||
if (!hasUserFinishedToEdit) {
|
if (!hasUserFinishedToEdit) {
|
||||||
return
|
return
|
||||||
@@ -199,7 +312,7 @@ watch(mode, async (newMode) => {
|
|||||||
|
|
||||||
await checkFreshness()
|
await checkFreshness()
|
||||||
if (freshnessStatus.value === "outdated") {
|
if (freshnessStatus.value === "outdated") {
|
||||||
conflictOpen.value = true
|
await handleConflict()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,10 +320,11 @@ watch(mode, async (newMode) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
const onConflictDiscard = async () => {
|
const onConflictDiscard = async () => {
|
||||||
const newRaw = await pullLatest()
|
const { raw } = await pullLatest()
|
||||||
if (newRaw !== null) {
|
if (raw !== null) {
|
||||||
rawContent.value = newRaw
|
rawContent.value = raw
|
||||||
initialRawContent.value = newRaw
|
initialRawContent.value = raw
|
||||||
|
advanceStackTo(latestSha.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -228,19 +342,27 @@ const onBadgeClick = async () => {
|
|||||||
try {
|
try {
|
||||||
if (freshnessStatus.value !== "outdated") {
|
if (freshnessStatus.value !== "outdated") {
|
||||||
await checkFreshness()
|
await checkFreshness()
|
||||||
|
if (freshnessStatus.value === "unauthorized") {
|
||||||
|
errorMessage("🔐 GitHub auth expired — please sign in again")
|
||||||
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasUnsavedEdits = rawContent.value !== initialRawContent.value
|
const hasUnsavedEdits = isDirty.value
|
||||||
if (hasUnsavedEdits) {
|
if (hasUnsavedEdits) {
|
||||||
conflictOpen.value = true
|
await handleConflict()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const newRaw = await pullLatest()
|
const { raw, failureStatus } = await pullLatest()
|
||||||
if (newRaw !== null) {
|
if (raw !== null) {
|
||||||
rawContent.value = newRaw
|
rawContent.value = raw
|
||||||
initialRawContent.value = newRaw
|
initialRawContent.value = raw
|
||||||
|
advanceStackTo(latestSha.value)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (failureStatus === "unauthorized") {
|
||||||
|
errorMessage("🔐 GitHub auth expired — please sign in again")
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("freshness badge click failed", error)
|
console.error("freshness badge click failed", error)
|
||||||
@@ -267,7 +389,7 @@ const onBadgeClick = async () => {
|
|||||||
class="action"
|
class="action"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
v-if="isMarkdown"
|
v-if="isMarkdown && canPush && loadStatus === 'ready'"
|
||||||
class="action button is-text is-light"
|
class="action button is-text is-light"
|
||||||
:class="{ 'is-link': mode === 'edit' }"
|
:class="{ 'is-link': mode === 'edit' }"
|
||||||
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
|
:style="mode === 'edit' ? 'color: var(--color-primary)' : ''"
|
||||||
@@ -316,6 +438,48 @@ const onBadgeClick = async () => {
|
|||||||
<path d="M14 4l0 4l-6 0l0 -4" />
|
<path d="M14 4l0 4l-6 0l0 -4" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
v-if="isMarkdown && mode === 'edit' && canPush"
|
||||||
|
class="action button is-text is-light"
|
||||||
|
:title="isUploading ? 'Uploading…' : 'Upload image'"
|
||||||
|
:disabled="isUploading"
|
||||||
|
@click="fileInput?.click()"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="isUploading"
|
||||||
|
class="loading loading-spinner loading-sm"
|
||||||
|
></span>
|
||||||
|
<svg
|
||||||
|
v-else
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
class="icon icon-tabler icon-tabler-photo-plus"
|
||||||
|
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 8h.01" />
|
||||||
|
<path
|
||||||
|
d="M12.5 21h-6.5a3 3 0 0 1 -3 -3v-12a3 3 0 0 1 3 -3h12a3 3 0 0 1 3 3v6.5"
|
||||||
|
/>
|
||||||
|
<path d="M3 16l5 -5c.928 -.893 2.072 -.893 3 0l4 4" />
|
||||||
|
<path d="M14 14l1 -1c.928 -.893 2.072 -.893 3 0l2 2" />
|
||||||
|
<path d="M16 19h6" />
|
||||||
|
<path d="M19 16v6" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
class="hidden-input"
|
||||||
|
@change="onImagePicked"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<a
|
<a
|
||||||
class="title-stacked-note-link"
|
class="title-stacked-note-link"
|
||||||
@@ -330,13 +494,30 @@ const onBadgeClick = async () => {
|
|||||||
</div>
|
</div>
|
||||||
<section class="text-content">
|
<section class="text-content">
|
||||||
<div v-if="mode === 'edit' && isMarkdown" class="edit">
|
<div v-if="mode === 'edit' && isMarkdown" class="edit">
|
||||||
<edit-note v-model="rawContent" />
|
<edit-note :key="editKey" v-model="rawContent" />
|
||||||
</div>
|
</div>
|
||||||
<div
|
<template v-else-if="mode === 'read'">
|
||||||
v-if="mode === 'read'"
|
<div v-if="newerSha" class="snapshot-banner">
|
||||||
class="note-content"
|
<span>You're viewing an older shared version.</span>
|
||||||
v-html="displayedContent"
|
<button
|
||||||
></div>
|
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>
|
</section>
|
||||||
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
|
<linked-notes v-if="hasBacklinks && content" :sha="sha" />
|
||||||
<note-conflict-modal
|
<note-conflict-modal
|
||||||
@@ -403,6 +584,22 @@ $border-color: rgba(18, 19, 58, 0.2);
|
|||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.hidden-input {
|
||||||
|
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 {
|
.action {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
||||||
|
|||||||
@@ -79,7 +79,8 @@ const content = computed(() =>
|
|||||||
|
|
||||||
useMarkdownPostRender(content, () => `.note-${classNameId.value}`, {
|
useMarkdownPostRender(content, () => `.note-${classNameId.value}`, {
|
||||||
onReady: () => listenToClick(),
|
onReady: () => listenToClick(),
|
||||||
tikz: true
|
tikz: true,
|
||||||
|
macroplan: true
|
||||||
})
|
})
|
||||||
</script>
|
</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>
|
||||||
98
src/components/UserPill.spec.ts
Normal file
98
src/components/UserPill.spec.ts
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { mount } from "@vue/test-utils"
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { ref } from "vue"
|
||||||
|
|
||||||
|
const ghAccessToken = ref<string | null>(null)
|
||||||
|
const ghUsername = ref<string | null>(null)
|
||||||
|
const atIsLoggedIn = ref(false)
|
||||||
|
const atHandle = ref<string | null>(null)
|
||||||
|
const atAvatarUrl = ref<string | null>(null)
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useGitHubLogin.hook", () => ({
|
||||||
|
useGitHubLogin: () => ({
|
||||||
|
username: ghUsername,
|
||||||
|
accessToken: ghAccessToken
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/hooks/useATProtoLogin.hook", () => ({
|
||||||
|
useATProtoLogin: () => ({
|
||||||
|
isLoggedIn: atIsLoggedIn,
|
||||||
|
handle: atHandle,
|
||||||
|
avatarUrl: atAvatarUrl
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
import UserPill from "./UserPill.vue"
|
||||||
|
|
||||||
|
describe("UserPill", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
ghAccessToken.value = null
|
||||||
|
ghUsername.value = null
|
||||||
|
atIsLoggedIn.value = false
|
||||||
|
atHandle.value = null
|
||||||
|
atAvatarUrl.value = null
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows the ghost 'Sign in' pill when nobody is logged in", () => {
|
||||||
|
const wrapper = mount(UserPill)
|
||||||
|
expect(wrapper.text()).toContain("Sign in")
|
||||||
|
expect(wrapper.classes()).toContain("profile-chip--ghost")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows the GitHub username and initial when logged in via GitHub only", () => {
|
||||||
|
ghAccessToken.value = "tok"
|
||||||
|
ghUsername.value = "alice"
|
||||||
|
|
||||||
|
const wrapper = mount(UserPill)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain("alice")
|
||||||
|
expect(wrapper.find(".profile-avatar-initial").text()).toBe("A")
|
||||||
|
expect(wrapper.find("img").exists()).toBe(false)
|
||||||
|
expect(wrapper.classes()).not.toContain("profile-chip--ghost")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("shows the ATProto handle and avatar when logged in via ATProto", () => {
|
||||||
|
atIsLoggedIn.value = true
|
||||||
|
atHandle.value = "alice.bsky.social"
|
||||||
|
atAvatarUrl.value = "https://cdn.example.com/avatar.jpg"
|
||||||
|
|
||||||
|
const wrapper = mount(UserPill)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain("alice.bsky.social")
|
||||||
|
const img = wrapper.find("img")
|
||||||
|
expect(img.exists()).toBe(true)
|
||||||
|
expect(img.attributes("src")).toBe("https://cdn.example.com/avatar.jpg")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("prefers the ATProto handle over the GitHub username when both are present", () => {
|
||||||
|
ghAccessToken.value = "tok"
|
||||||
|
ghUsername.value = "alice-gh"
|
||||||
|
atIsLoggedIn.value = true
|
||||||
|
atHandle.value = "alice.bsky.social"
|
||||||
|
|
||||||
|
const wrapper = mount(UserPill)
|
||||||
|
|
||||||
|
expect(wrapper.text()).toContain("alice.bsky.social")
|
||||||
|
expect(wrapper.text()).not.toContain("alice-gh")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("falls back to '?' initial when no name is available but a user is logged in", () => {
|
||||||
|
atIsLoggedIn.value = true
|
||||||
|
atHandle.value = ""
|
||||||
|
|
||||||
|
const wrapper = mount(UserPill)
|
||||||
|
|
||||||
|
expect(wrapper.find(".profile-avatar-initial").text()).toBe("?")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("emits click when clicked", async () => {
|
||||||
|
const wrapper = mount(UserPill)
|
||||||
|
await wrapper.trigger("click")
|
||||||
|
expect(wrapper.emitted("click")).toHaveLength(1)
|
||||||
|
})
|
||||||
|
})
|
||||||
111
src/components/UserPill.vue
Normal file
111
src/components/UserPill.vue
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
|
||||||
|
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||||
|
|
||||||
|
const { username, accessToken } = useGitHubLogin()
|
||||||
|
const { isLoggedIn: isATProtoLoggedIn, handle, avatarUrl } = useATProtoLogin()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
click: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const isGitHubLoggedIn = computed(() => !!accessToken.value)
|
||||||
|
const isAnyUserLoggedIn = computed(
|
||||||
|
() => isGitHubLoggedIn.value || isATProtoLoggedIn.value
|
||||||
|
)
|
||||||
|
const displayUsername = computed(() => handle.value || username.value || "")
|
||||||
|
const displayInitial = computed(() =>
|
||||||
|
(displayUsername.value[0] || "?").toUpperCase()
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<button
|
||||||
|
v-if="isAnyUserLoggedIn"
|
||||||
|
class="profile-chip"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('click')"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
v-if="isATProtoLoggedIn && avatarUrl"
|
||||||
|
:src="avatarUrl"
|
||||||
|
class="profile-avatar-small"
|
||||||
|
alt="Profile"
|
||||||
|
/>
|
||||||
|
<span v-else class="profile-avatar-small profile-avatar-initial">
|
||||||
|
{{ displayInitial }}
|
||||||
|
</span>
|
||||||
|
<span class="profile-name">{{ displayUsername }}</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
v-else
|
||||||
|
class="profile-chip profile-chip--ghost"
|
||||||
|
type="button"
|
||||||
|
@click="$emit('click')"
|
||||||
|
>
|
||||||
|
Sign in
|
||||||
|
</button>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.profile-chip {
|
||||||
|
--pill-rule: color-mix(
|
||||||
|
in oklch,
|
||||||
|
var(--color-base-content) 12%,
|
||||||
|
var(--color-base-200)
|
||||||
|
);
|
||||||
|
--pill-accent: #e36598;
|
||||||
|
--pill-accent-wash: color-mix(
|
||||||
|
in oklch,
|
||||||
|
#e36598 12%,
|
||||||
|
var(--color-base-200)
|
||||||
|
);
|
||||||
|
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 0.25rem 0.75rem 0.25rem 0.25rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
border: 1px solid var(--pill-rule);
|
||||||
|
background: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--color-base-content);
|
||||||
|
transition:
|
||||||
|
border-color 0.15s,
|
||||||
|
background 0.15s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--pill-accent);
|
||||||
|
background: var(--pill-accent-wash);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.profile-chip--ghost {
|
||||||
|
padding: 0.35rem 0.9rem;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-small {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-avatar-initial {
|
||||||
|
background: var(--pill-accent, #e36598);
|
||||||
|
color: white;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-name {
|
||||||
|
font-size: 0.95rem;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -4,9 +4,9 @@ import { getHex } from "pastel-color"
|
|||||||
import { computed, onMounted, ref } from "vue"
|
import { computed, onMounted, ref } from "vue"
|
||||||
import { useRouter } from "vue-router"
|
import { useRouter } from "vue-router"
|
||||||
|
|
||||||
import SignInAtproto from "@/components/SignInAtproto.vue"
|
import ProfileModal from "@/components/ProfileModal.vue"
|
||||||
import SignInGithub from "@/components/SignInGithub.vue"
|
|
||||||
import ThemeSwap from "@/components/ThemeSwap.vue"
|
import ThemeSwap from "@/components/ThemeSwap.vue"
|
||||||
|
import UserPill from "@/components/UserPill.vue"
|
||||||
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
|
import { useATProtoLogin } from "@/hooks/useATProtoLogin.hook"
|
||||||
import { useForm } from "@/hooks/useForm.hook"
|
import { useForm } from "@/hooks/useForm.hook"
|
||||||
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
import { useGitHubLogin } from "@/hooks/useGitHubLogin.hook"
|
||||||
@@ -18,7 +18,7 @@ import { useFavoriteRepos } from "@/modules/repo/hooks/useFavoriteRepos.hook"
|
|||||||
import { slugify } from "@/utils/slugify"
|
import { slugify } from "@/utils/slugify"
|
||||||
|
|
||||||
const { username, accessToken } = useGitHubLogin()
|
const { username, accessToken } = useGitHubLogin()
|
||||||
const { isLoggedIn: isATProtoLoggedIn, handle, avatarUrl } = useATProtoLogin()
|
const { isLoggedIn: isATProtoLoggedIn, handle } = useATProtoLogin()
|
||||||
const { userInput, repoInput, submit } = useForm()
|
const { userInput, repoInput, submit } = useForm()
|
||||||
const { savedFavoriteRepos } = useFavoriteRepos()
|
const { savedFavoriteRepos } = useFavoriteRepos()
|
||||||
const { lastVisitedRepos } = useLastVisitedRepos()
|
const { lastVisitedRepos } = useLastVisitedRepos()
|
||||||
@@ -29,10 +29,11 @@ const isGitHubLoggedIn = computed(() => !!accessToken.value)
|
|||||||
const isAnyUserLoggedIn = computed(
|
const isAnyUserLoggedIn = computed(
|
||||||
() => isGitHubLoggedIn.value || isATProtoLoggedIn.value
|
() => isGitHubLoggedIn.value || isATProtoLoggedIn.value
|
||||||
)
|
)
|
||||||
const displayUsername = computed(() => username.value || handle.value || "")
|
const displayUsername = computed(() => handle.value || username.value || "")
|
||||||
const displayInitial = computed(() =>
|
|
||||||
(displayUsername.value[0] || "?").toUpperCase()
|
const openProfile = () => {
|
||||||
)
|
;(document.getElementById("profile_modal") as HTMLDialogElement)?.showModal()
|
||||||
|
}
|
||||||
|
|
||||||
const tileStyle = (seed: string) => {
|
const tileStyle = (seed: string) => {
|
||||||
const bg = getHex(seed)
|
const bg = getHex(seed)
|
||||||
@@ -150,29 +151,7 @@ onMounted(() => {
|
|||||||
class="navlink"
|
class="navlink"
|
||||||
>Getting started</router-link
|
>Getting started</router-link
|
||||||
>
|
>
|
||||||
<button
|
<UserPill @click="openProfile" />
|
||||||
v-if="isAnyUserLoggedIn"
|
|
||||||
class="profile-chip"
|
|
||||||
onclick="profile_modal.showModal()"
|
|
||||||
>
|
|
||||||
<img
|
|
||||||
v-if="isATProtoLoggedIn && avatarUrl"
|
|
||||||
:src="avatarUrl"
|
|
||||||
class="profile-avatar-small"
|
|
||||||
alt="Profile"
|
|
||||||
/>
|
|
||||||
<span v-else class="profile-avatar-small profile-avatar-initial">
|
|
||||||
{{ displayInitial }}
|
|
||||||
</span>
|
|
||||||
<span class="profile-name">{{ displayUsername }}</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
v-else
|
|
||||||
class="hw-btn hw-btn-ghost"
|
|
||||||
onclick="profile_modal.showModal()"
|
|
||||||
>
|
|
||||||
Sign in
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
@@ -402,10 +381,10 @@ onMounted(() => {
|
|||||||
comes to rest.
|
comes to rest.
|
||||||
</h1>
|
</h1>
|
||||||
<p class="lede">
|
<p class="lede">
|
||||||
Point it at any markdown — a GitHub repo of your own, or public
|
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
|
notes from the open ATProto network. Remanso turns it into a
|
||||||
calm, stackable notebook with backlinks where your thinking
|
stackable notebook with backlinks where your thinking finally
|
||||||
finally runs clear.
|
runs clear.
|
||||||
</p>
|
</p>
|
||||||
<div class="hero-ed-paths">
|
<div class="hero-ed-paths">
|
||||||
<!-- CTA 01: GitHub repo -->
|
<!-- CTA 01: GitHub repo -->
|
||||||
@@ -493,7 +472,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div v-else-if="publicNotesLoadFailed" class="network-empty">
|
<div v-else-if="publicNotesLoadFailed" class="network-empty">
|
||||||
The pool is quiet right now.
|
Nothing has drifted in yet.
|
||||||
</div>
|
</div>
|
||||||
<router-link
|
<router-link
|
||||||
:to="{ name: 'PublicNoteListView' }"
|
:to="{ name: 'PublicNoteListView' }"
|
||||||
@@ -846,35 +825,7 @@ onMounted(() => {
|
|||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
<!-- ── Profile modal ──────────────────────────────────── -->
|
<ProfileModal />
|
||||||
<dialog id="profile_modal" class="modal hw-modal">
|
|
||||||
<div class="modal-box hw-modal-box">
|
|
||||||
<div class="hw-modal-head">
|
|
||||||
<h3>Profile</h3>
|
|
||||||
<form method="dialog">
|
|
||||||
<button class="hw-modal-x" aria-label="close">×</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
<div class="hw-modal-section">
|
|
||||||
<div class="hw-ms-label mono">Bluesky / ATProto</div>
|
|
||||||
<sign-in-atproto :with-sign-out="true" />
|
|
||||||
</div>
|
|
||||||
<hr class="hw-rule" />
|
|
||||||
<div class="hw-modal-section">
|
|
||||||
<div class="hw-ms-label mono">GitHub</div>
|
|
||||||
<sign-in-github />
|
|
||||||
<router-link
|
|
||||||
v-if="isGitHubLoggedIn"
|
|
||||||
:to="{ name: 'RepoList' }"
|
|
||||||
class="hw-btn hw-btn-ghost"
|
|
||||||
>Manage your repos</router-link
|
|
||||||
>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<form method="dialog" class="modal-backdrop">
|
|
||||||
<button></button>
|
|
||||||
</form>
|
|
||||||
</dialog>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -999,17 +950,6 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hw-btn-ghost {
|
|
||||||
border-color: var(--hw-rule);
|
|
||||||
color: var(--hw-ink-soft);
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background: var(--hw-pink-wash);
|
|
||||||
border-color: var(--hw-pink);
|
|
||||||
color: var(--hw-pink-deep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Utility classes ────────────────────────────────────────── */
|
/* ── Utility classes ────────────────────────────────────────── */
|
||||||
.mono {
|
.mono {
|
||||||
font-family: var(--hw-mono);
|
font-family: var(--hw-mono);
|
||||||
@@ -1029,12 +969,6 @@ main {
|
|||||||
color: var(--hw-pink-deep);
|
color: var(--hw-pink-deep);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hw-rule {
|
|
||||||
border: 0;
|
|
||||||
border-top: 1px solid var(--hw-rule);
|
|
||||||
margin: 1.25rem 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Top nav ────────────────────────────────────────────────── */
|
/* ── Top nav ────────────────────────────────────────────────── */
|
||||||
.topnav {
|
.topnav {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1086,50 +1020,6 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.profile-chip {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 0.5rem;
|
|
||||||
padding: 0.25rem 0.75rem 0.25rem 0.25rem;
|
|
||||||
border-radius: 999px;
|
|
||||||
border: 1px solid var(--hw-rule);
|
|
||||||
background: transparent;
|
|
||||||
cursor: pointer;
|
|
||||||
font-family: var(--hw-serif);
|
|
||||||
color: var(--hw-ink);
|
|
||||||
transition:
|
|
||||||
border-color 0.15s,
|
|
||||||
background 0.15s;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
border-color: var(--hw-pink);
|
|
||||||
background: var(--hw-pink-wash);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-avatar-small {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
border-radius: 50%;
|
|
||||||
object-fit: cover;
|
|
||||||
display: block;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-avatar-initial {
|
|
||||||
background: var(--hw-pink);
|
|
||||||
color: white;
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
font-family: var(--hw-serif);
|
|
||||||
font-weight: 600;
|
|
||||||
font-size: 0.9rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.profile-name {
|
|
||||||
font-size: 0.95rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── GitHub form ────────────────────────────────────────────── */
|
/* ── GitHub form ────────────────────────────────────────────── */
|
||||||
.gh-form {
|
.gh-form {
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2282,62 +2172,6 @@ img {
|
|||||||
color: var(--hw-ink-faint);
|
color: var(--hw-ink-faint);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Profile modal ──────────────────────────────────────────── */
|
|
||||||
.hw-modal .hw-modal-box {
|
|
||||||
background: var(--hw-paper);
|
|
||||||
border: 1px solid var(--hw-rule);
|
|
||||||
border-radius: 6px;
|
|
||||||
padding: 1.5rem;
|
|
||||||
box-shadow: 0 30px 60px -20px rgba(0, 0, 0, 0.3);
|
|
||||||
color: var(--hw-ink);
|
|
||||||
font-family: var(--hw-serif);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hw-modal-head {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
|
|
||||||
h3 {
|
|
||||||
font-family: var(--hw-serif);
|
|
||||||
font-size: 1.3rem;
|
|
||||||
margin: 0;
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hw-modal-x {
|
|
||||||
border: 0;
|
|
||||||
background: transparent;
|
|
||||||
font-size: 1.5rem;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--hw-ink-soft);
|
|
||||||
line-height: 1;
|
|
||||||
padding: 0.25rem 0.5rem;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
color: var(--hw-pink-deep);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.hw-modal-section {
|
|
||||||
display: flex;
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 0.75rem;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hw-ms-label {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
font-size: 0.72rem;
|
|
||||||
letter-spacing: 0.14em;
|
|
||||||
color: var(--hw-ink-faint);
|
|
||||||
margin-bottom: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* ── Responsive ─────────────────────────────────────────────── */
|
/* ── Responsive ─────────────────────────────────────────────── */
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.hero-ed-inner {
|
.hero-ed-inner {
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import { Model } from "./models/Model"
|
|||||||
export interface DataApi {
|
export interface DataApi {
|
||||||
add<DT extends DataType>(model: Model<DT>): Promise<boolean>
|
add<DT extends DataType>(model: Model<DT>): Promise<boolean>
|
||||||
update<DT extends DataType, T extends Model<DT>>(model: T): Promise<boolean>
|
update<DT extends DataType, T extends Model<DT>>(model: T): Promise<boolean>
|
||||||
|
bulkUpdate<DT extends DataType, T extends Model<DT>>(
|
||||||
|
models: T[]
|
||||||
|
): Promise<boolean>
|
||||||
remove(id: string): Promise<boolean>
|
remove(id: string): Promise<boolean>
|
||||||
get<DT extends DataType, T extends Model<DT>>(id: string): Promise<T | null>
|
get<DT extends DataType, T extends Model<DT>>(id: string): Promise<T | null>
|
||||||
getOrCreate<DT extends DataType, T extends Model<DT>>(
|
getOrCreate<DT extends DataType, T extends Model<DT>>(
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ class Data {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async bulkUpdate<DT extends DataType, T extends Model<DT>>(
|
||||||
|
models: T[]
|
||||||
|
): Promise<boolean> {
|
||||||
|
if (!this.locale || !models.length) return false
|
||||||
|
try {
|
||||||
|
const result = await this.locale.bulkDocs(models)
|
||||||
|
return result.every((r) => "ok" in r && r.ok)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public async remove(id: string): Promise<boolean> {
|
public async remove(id: string): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
const doc = await this.get(id)
|
const doc = await this.get(id)
|
||||||
|
|||||||
@@ -35,7 +35,8 @@ export const useCheckboxCommit = ({
|
|||||||
initialContent,
|
initialContent,
|
||||||
initialSha,
|
initialSha,
|
||||||
containerSelector,
|
containerSelector,
|
||||||
debounceMs = 1000
|
debounceMs = 1000,
|
||||||
|
enabled = true
|
||||||
}: {
|
}: {
|
||||||
user: string
|
user: string
|
||||||
repo: string
|
repo: string
|
||||||
@@ -44,6 +45,7 @@ export const useCheckboxCommit = ({
|
|||||||
initialSha: Ref<string> | string
|
initialSha: Ref<string> | string
|
||||||
containerSelector: string
|
containerSelector: string
|
||||||
debounceMs?: number
|
debounceMs?: number
|
||||||
|
enabled?: Ref<boolean> | boolean
|
||||||
}) => {
|
}) => {
|
||||||
const { updateFile } = useGitHubContent({ user, repo })
|
const { updateFile } = useGitHubContent({ user, repo })
|
||||||
|
|
||||||
@@ -91,6 +93,8 @@ export const useCheckboxCommit = ({
|
|||||||
const debouncedCommit = useDebounceFn(commitChanges, debounceMs)
|
const debouncedCommit = useDebounceFn(commitChanges, debounceMs)
|
||||||
|
|
||||||
const handleCheckboxChange = (event: Event) => {
|
const handleCheckboxChange = (event: Event) => {
|
||||||
|
if (!toValue(enabled)) return
|
||||||
|
|
||||||
const target = event.target as HTMLInputElement
|
const target = event.target as HTMLInputElement
|
||||||
|
|
||||||
if (target.tagName !== "INPUT" || target.type !== "checkbox") {
|
if (target.tagName !== "INPUT" || target.type !== "checkbox") {
|
||||||
@@ -128,8 +132,16 @@ export const useCheckboxCommit = ({
|
|||||||
const listenToCheckboxes = () => {
|
const listenToCheckboxes = () => {
|
||||||
removeListeners()
|
removeListeners()
|
||||||
const container = document.querySelector(containerSelector)
|
const container = document.querySelector(containerSelector)
|
||||||
if (container) {
|
if (!container) return
|
||||||
|
|
||||||
|
if (toValue(enabled)) {
|
||||||
container.addEventListener("change", handleCheckboxChange)
|
container.addEventListener("change", handleCheckboxChange)
|
||||||
|
} else {
|
||||||
|
container
|
||||||
|
.querySelectorAll<HTMLInputElement>('input[type="checkbox"]')
|
||||||
|
.forEach((checkbox) => {
|
||||||
|
checkbox.disabled = true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
76
src/hooks/useEditionMode.spec.ts
Normal file
76
src/hooks/useEditionMode.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import { mount } from "@vue/test-utils"
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { defineComponent, ref } from "vue"
|
||||||
|
|
||||||
|
const escape = ref(false)
|
||||||
|
vi.mock("@vueuse/core", () => ({
|
||||||
|
useMagicKeys: () => ({ escape })
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useEditionMode } from "./useEditionMode"
|
||||||
|
|
||||||
|
const host = (slot: (api: ReturnType<typeof useEditionMode>) => void) =>
|
||||||
|
mount(
|
||||||
|
defineComponent({
|
||||||
|
template: "<div/>",
|
||||||
|
setup() {
|
||||||
|
const api = useEditionMode()
|
||||||
|
slot(api)
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
describe("useEditionMode", () => {
|
||||||
|
afterEach(() => {
|
||||||
|
escape.value = false
|
||||||
|
})
|
||||||
|
|
||||||
|
it("starts in read mode", () => {
|
||||||
|
let mode: unknown
|
||||||
|
host((api) => {
|
||||||
|
mode = api.mode.value
|
||||||
|
})
|
||||||
|
expect(mode).toBe("read")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("toggleMode flips read ↔ edit", () => {
|
||||||
|
let api: ReturnType<typeof useEditionMode> | undefined
|
||||||
|
host((a) => {
|
||||||
|
api = a
|
||||||
|
})
|
||||||
|
|
||||||
|
api!.toggleMode()
|
||||||
|
expect(api!.mode.value).toBe("edit")
|
||||||
|
|
||||||
|
api!.toggleMode()
|
||||||
|
expect(api!.mode.value).toBe("read")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("escape key exits edit mode", async () => {
|
||||||
|
let api: ReturnType<typeof useEditionMode> | undefined
|
||||||
|
host((a) => {
|
||||||
|
a.toggleMode()
|
||||||
|
api = a
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(api!.mode.value).toBe("edit")
|
||||||
|
|
||||||
|
escape.value = true
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
|
||||||
|
expect(api!.mode.value).toBe("read")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("escape key is a no-op when already in read mode", async () => {
|
||||||
|
let api: ReturnType<typeof useEditionMode> | undefined
|
||||||
|
host((a) => {
|
||||||
|
api = a
|
||||||
|
})
|
||||||
|
|
||||||
|
escape.value = true
|
||||||
|
await new Promise((r) => setTimeout(r, 0))
|
||||||
|
|
||||||
|
expect(api!.mode.value).toBe("read")
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,6 +2,7 @@ import { computed, Ref, ref, toValue } from "vue"
|
|||||||
|
|
||||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||||
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
||||||
|
import { latestShaIfOlder } from "@/modules/note/snapshotStatus"
|
||||||
import { queryFileContent } from "@/modules/repo/services/repo"
|
import { queryFileContent } from "@/modules/repo/services/repo"
|
||||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
|
|
||||||
@@ -14,6 +15,16 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
|
|||||||
return file?.path
|
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 {
|
const {
|
||||||
render,
|
render,
|
||||||
renderFromUTF8,
|
renderFromUTF8,
|
||||||
@@ -47,6 +58,8 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
|
|||||||
fromCache.value = !!cachedNote
|
fromCache.value = !!cachedNote
|
||||||
|
|
||||||
if (cachedNote) {
|
if (cachedNote) {
|
||||||
|
cachedNotePath.value = cachedNote.path ?? cachedNotePath.value
|
||||||
|
|
||||||
if (from === "path") {
|
if (from === "path") {
|
||||||
queryFileContent(store.user, store.repo, shaValue).then(
|
queryFileContent(store.user, store.repo, shaValue).then(
|
||||||
(fileContent) => {
|
(fileContent) => {
|
||||||
@@ -104,6 +117,7 @@ export const useFile = (sha: Ref<string> | string, retrieveContent = true) => {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
path,
|
path,
|
||||||
|
newerSha,
|
||||||
content,
|
content,
|
||||||
rawContent,
|
rawContent,
|
||||||
getRawContent,
|
getRawContent,
|
||||||
|
|||||||
67
src/hooks/useForm.hook.spec.ts
Normal file
67
src/hooks/useForm.hook.spec.ts
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import { mount } from "@vue/test-utils"
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
import { defineComponent } from "vue"
|
||||||
|
|
||||||
|
const push = vi.fn()
|
||||||
|
vi.mock("vue-router", () => ({
|
||||||
|
useRouter: () => ({ push })
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { useForm } from "./useForm.hook"
|
||||||
|
|
||||||
|
const host = () => {
|
||||||
|
let api!: ReturnType<typeof useForm>
|
||||||
|
mount(
|
||||||
|
defineComponent({
|
||||||
|
template: "<div/>",
|
||||||
|
setup() {
|
||||||
|
api = useForm()
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
return api
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("useForm", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
push.mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("starts with empty user and repo inputs", () => {
|
||||||
|
const api = host()
|
||||||
|
expect(api.userInput.value).toBe("")
|
||||||
|
expect(api.repoInput.value).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("submit is a no-op when userInput is empty", () => {
|
||||||
|
const api = host()
|
||||||
|
api.repoInput.value = "notes"
|
||||||
|
|
||||||
|
api.submit()
|
||||||
|
|
||||||
|
expect(push).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("submit is a no-op when repoInput is empty", () => {
|
||||||
|
const api = host()
|
||||||
|
api.userInput.value = "alice"
|
||||||
|
|
||||||
|
api.submit()
|
||||||
|
|
||||||
|
expect(push).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("submit pushes the FluxNoteView route with user/repo params", () => {
|
||||||
|
const api = host()
|
||||||
|
api.userInput.value = "alice"
|
||||||
|
api.repoInput.value = "notes"
|
||||||
|
|
||||||
|
api.submit()
|
||||||
|
|
||||||
|
expect(push).toHaveBeenCalledWith({
|
||||||
|
name: "FluxNoteView",
|
||||||
|
params: { user: "alice", repo: "notes" }
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
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"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,8 +1,14 @@
|
|||||||
import { getOctokit } from "@/modules/repo/services/octo"
|
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
|
||||||
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
import { encodeUTF8ToBase64 } from "@/utils/decodeBase64ToUTF8"
|
||||||
import { confirmMessage, errorMessage } from "@/utils/notif"
|
import { confirmMessage, errorMessage } from "@/utils/notif"
|
||||||
|
|
||||||
const isConflictStatus = (status: number) => status === 409 || status === 422
|
const isConflictStatus = (status: number) => status === 409 || status === 422
|
||||||
|
const isUnauthorizedStatus = (status: number | undefined) => status === 401
|
||||||
|
|
||||||
|
export type FetchShaResult =
|
||||||
|
| { kind: "ok"; sha: string | null }
|
||||||
|
| { kind: "unauthorized" }
|
||||||
|
| { kind: "offline" }
|
||||||
|
|
||||||
export const useGitHubContent = ({
|
export const useGitHubContent = ({
|
||||||
user,
|
user,
|
||||||
@@ -11,65 +17,110 @@ export const useGitHubContent = ({
|
|||||||
user: string
|
user: string
|
||||||
repo: string
|
repo: string
|
||||||
}) => {
|
}) => {
|
||||||
const fetchLatestSha = async (path: string): Promise<string | null> => {
|
const fetchLatestSha = async (path: string): Promise<FetchShaResult> => {
|
||||||
|
try {
|
||||||
|
const response = await runWithAuthRetry((octokit) =>
|
||||||
|
octokit.request("GET /repos/{owner}/{repo}/contents/{+path}", {
|
||||||
|
owner: user,
|
||||||
|
repo,
|
||||||
|
path,
|
||||||
|
headers: { "X-GitHub-Api-Version": "2026-03-10" }
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const data = response?.data
|
||||||
|
if (Array.isArray(data) || !data) return { kind: "ok", sha: null }
|
||||||
|
return { kind: "ok", sha: "sha" in data ? data.sha : null }
|
||||||
|
} catch (error) {
|
||||||
|
const status = (error as { status?: number })?.status
|
||||||
|
if (isUnauthorizedStatus(status)) return { kind: "unauthorized" }
|
||||||
|
return { kind: "offline" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const putRaw = async ({
|
||||||
|
contentBase64,
|
||||||
|
path,
|
||||||
|
sha,
|
||||||
|
message,
|
||||||
|
successMessage,
|
||||||
|
conflictMessage,
|
||||||
|
failureMessage
|
||||||
|
}: {
|
||||||
|
contentBase64: string
|
||||||
|
path: string
|
||||||
|
sha?: string
|
||||||
|
message: string
|
||||||
|
successMessage: string
|
||||||
|
conflictMessage: string
|
||||||
|
failureMessage: string
|
||||||
|
}): Promise<{ sha: string | null; conflict: boolean }> => {
|
||||||
try {
|
try {
|
||||||
const octokit = await getOctokit()
|
const octokit = await getOctokit()
|
||||||
|
|
||||||
const response = await octokit.request(
|
const response = await octokit.request(
|
||||||
"GET /repos/{owner}/{repo}/contents/{path}",
|
"PUT /repos/{owner}/{repo}/contents/{+path}",
|
||||||
{
|
{
|
||||||
owner: user,
|
owner: user,
|
||||||
repo,
|
repo,
|
||||||
path,
|
path,
|
||||||
headers: { "X-GitHub-Api-Version": "2022-11-28" }
|
message,
|
||||||
|
content: contentBase64,
|
||||||
|
sha
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
const data = response?.data
|
|
||||||
if (Array.isArray(data) || !data) return null
|
confirmMessage(successMessage)
|
||||||
return "sha" in data ? data.sha : null
|
|
||||||
} catch {
|
return { sha: response?.data.content?.sha ?? null, conflict: false }
|
||||||
return null
|
} catch (error) {
|
||||||
|
const status = (error as { status?: number })?.status
|
||||||
|
if (status && isConflictStatus(status)) {
|
||||||
|
errorMessage(conflictMessage)
|
||||||
|
console.warn(error)
|
||||||
|
return { sha: null, conflict: true }
|
||||||
|
}
|
||||||
|
errorMessage(failureMessage)
|
||||||
|
console.warn(error)
|
||||||
|
return { sha: null, conflict: false }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const putFile = async ({
|
const putFile = async ({
|
||||||
content,
|
content,
|
||||||
path,
|
path,
|
||||||
sha
|
sha,
|
||||||
|
successMessage = "✅ Note saved"
|
||||||
}: {
|
}: {
|
||||||
content: string
|
content: string
|
||||||
path: string
|
path: string
|
||||||
sha?: string
|
sha?: string
|
||||||
}): Promise<{ sha: string | null; conflict: boolean }> => {
|
successMessage?: string
|
||||||
try {
|
}): Promise<{ sha: string | null; conflict: boolean }> =>
|
||||||
const octokit = await getOctokit()
|
putRaw({
|
||||||
|
contentBase64: encodeUTF8ToBase64(content),
|
||||||
|
path,
|
||||||
|
sha,
|
||||||
|
message: `Updating ${path} from Remanso`,
|
||||||
|
successMessage,
|
||||||
|
conflictMessage: "⚠ Conflict: this note changed on GitHub",
|
||||||
|
failureMessage: "❌ Note could not be saved"
|
||||||
|
})
|
||||||
|
|
||||||
const response = await octokit.request(
|
const uploadBinaryFile = async ({
|
||||||
`PUT /repos/{owner}/{repo}/contents/{path}`,
|
base64,
|
||||||
{
|
path
|
||||||
owner: user,
|
}: {
|
||||||
repo,
|
base64: string
|
||||||
path,
|
path: string
|
||||||
message: `Updating ${path} from Remanso`,
|
}): Promise<{ sha: string | null; conflict: boolean }> =>
|
||||||
content: encodeUTF8ToBase64(content),
|
putRaw({
|
||||||
sha
|
contentBase64: base64,
|
||||||
}
|
path,
|
||||||
)
|
message: `Uploading ${path} from Remanso`,
|
||||||
|
successMessage: "✅ Image uploaded",
|
||||||
confirmMessage("✅ Note saved")
|
conflictMessage: "⚠ A file already exists at this path on GitHub",
|
||||||
|
failureMessage: "❌ Image could not be uploaded"
|
||||||
return { sha: response?.data.content?.sha ?? null, conflict: false }
|
})
|
||||||
} catch (error) {
|
|
||||||
const status = (error as { status?: number })?.status
|
|
||||||
if (status && isConflictStatus(status)) {
|
|
||||||
errorMessage("⚠ Conflict: this note changed on GitHub")
|
|
||||||
console.warn(error)
|
|
||||||
return { sha: null, conflict: true }
|
|
||||||
}
|
|
||||||
errorMessage("❌ Note could not be saved")
|
|
||||||
console.warn(error)
|
|
||||||
return { sha: null, conflict: false }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
fetchLatestSha,
|
fetchLatestSha,
|
||||||
@@ -77,8 +128,10 @@ export const useGitHubContent = ({
|
|||||||
content: string
|
content: string
|
||||||
path: string
|
path: string
|
||||||
sha: string
|
sha: string
|
||||||
|
successMessage?: string
|
||||||
}) => putFile(props),
|
}) => putFile(props),
|
||||||
createFile: async (props: { content: string; path: string }) =>
|
createFile: async (props: { content: string; path: string }) =>
|
||||||
putFile(props)
|
putFile(props),
|
||||||
|
uploadBinaryFile
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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")
|
||||||
|
})
|
||||||
|
})
|
||||||
104
src/hooks/useImageUpload.hook.ts
Normal file
104
src/hooks/useImageUpload.hook.ts
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { Ref, toValue } from "vue"
|
||||||
|
|
||||||
|
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||||
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
|
import { errorMessage } from "@/utils/notif"
|
||||||
|
import { uniqueFilename } from "@/utils/uniqueFilename"
|
||||||
|
|
||||||
|
const arrayBufferToBase64 = (buffer: ArrayBuffer): string => {
|
||||||
|
const bytes = new Uint8Array(buffer)
|
||||||
|
const chunkSize = 0x8000
|
||||||
|
let binary = ""
|
||||||
|
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||||
|
const chunk = bytes.subarray(i, i + chunkSize)
|
||||||
|
binary += String.fromCharCode(...chunk)
|
||||||
|
}
|
||||||
|
return btoa(binary)
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitPath = (fullPath: string): { directory: string; filename: string } => {
|
||||||
|
const lastSlash = fullPath.lastIndexOf("/")
|
||||||
|
if (lastSlash === -1) return { directory: "", filename: fullPath }
|
||||||
|
return {
|
||||||
|
directory: fullPath.slice(0, lastSlash),
|
||||||
|
filename: fullPath.slice(lastSlash + 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripMarkdownExtension = (filename: string): string =>
|
||||||
|
filename.replace(/\.(md|markdown|mdx)$/i, "")
|
||||||
|
|
||||||
|
const extractExtension = (filename: string): string => {
|
||||||
|
const dot = filename.lastIndexOf(".")
|
||||||
|
if (dot <= 0) return ".png"
|
||||||
|
return filename.slice(dot).toLowerCase()
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useImageUpload = ({
|
||||||
|
user,
|
||||||
|
repo,
|
||||||
|
notePath
|
||||||
|
}: {
|
||||||
|
user: string
|
||||||
|
repo: string
|
||||||
|
notePath: Ref<string | undefined> | string | undefined
|
||||||
|
}) => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
const { uploadBinaryFile } = useGitHubContent({ user, repo })
|
||||||
|
|
||||||
|
const uploadImage = async (
|
||||||
|
file: File
|
||||||
|
): Promise<{ filename: string } | null> => {
|
||||||
|
const currentNotePath = toValue(notePath)
|
||||||
|
if (!currentNotePath) {
|
||||||
|
errorMessage("❌ Image upload failed")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { directory, filename: noteFilename } = splitPath(currentNotePath)
|
||||||
|
const basename = stripMarkdownExtension(noteFilename)
|
||||||
|
const extension = extractExtension(file.name)
|
||||||
|
|
||||||
|
const existingPaths = store.files
|
||||||
|
.map((f) => f.path)
|
||||||
|
.filter((p): p is string => typeof p === "string")
|
||||||
|
|
||||||
|
const filename = uniqueFilename({
|
||||||
|
basename,
|
||||||
|
extension,
|
||||||
|
existingPaths,
|
||||||
|
directory
|
||||||
|
})
|
||||||
|
|
||||||
|
const targetPath = directory ? `${directory}/${filename}` : filename
|
||||||
|
|
||||||
|
const buffer = await file.arrayBuffer()
|
||||||
|
const base64 = arrayBufferToBase64(buffer)
|
||||||
|
|
||||||
|
const { sha, conflict } = await uploadBinaryFile({
|
||||||
|
base64,
|
||||||
|
path: targetPath
|
||||||
|
})
|
||||||
|
|
||||||
|
if (conflict || !sha) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
store.registerUploadedFile({
|
||||||
|
path: targetPath,
|
||||||
|
sha,
|
||||||
|
type: "blob",
|
||||||
|
size: file.size
|
||||||
|
})
|
||||||
|
|
||||||
|
return { filename }
|
||||||
|
} catch (error) {
|
||||||
|
console.warn("image upload failed", error)
|
||||||
|
errorMessage("❌ Image upload failed")
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { uploadImage }
|
||||||
|
}
|
||||||
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 Shikiji from "markdown-it-shikiji"
|
||||||
import mermaid from "mermaid"
|
import mermaid from "mermaid"
|
||||||
import type { LanguageRegistration } from "shikiji-core"
|
import type { LanguageRegistration } from "shikiji-core"
|
||||||
import { Ref, toValue } from "vue"
|
import { Ref, ref, toValue } from "vue"
|
||||||
|
|
||||||
import { data } from "@/data/data"
|
import { data } from "@/data/data"
|
||||||
import { DataType } from "@/data/DataType.enum"
|
import { DataType } from "@/data/DataType.enum"
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
} from "@/utils/decodeBase64ToUTF8"
|
} from "@/utils/decodeBase64ToUTF8"
|
||||||
import { html5Media } from "@/utils/markdown/markdown-html5-media"
|
import { html5Media } from "@/utils/markdown/markdown-html5-media"
|
||||||
import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons"
|
import { markdownItTablerIcons } from "@/utils/markdown/markdown-it-tabler-icons"
|
||||||
|
import { renderFallback } from "@/utils/markdown/renderFallback"
|
||||||
|
|
||||||
const TIKZ_BUNDLE_URL =
|
const TIKZ_BUNDLE_URL =
|
||||||
"https://cdn.jsdelivr.net/gh/artisticat1/obsidian-tikzjax@0.5.2/tikzjax.js"
|
"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()
|
const slugger = new GithubSlugger()
|
||||||
|
|
||||||
let tabGroupCounter = 0
|
let tabGroupCounter = 0
|
||||||
@@ -106,6 +138,7 @@ const md = new MarkdownIt({
|
|||||||
})
|
})
|
||||||
.use(markdownItMermaidExtractor)
|
.use(markdownItMermaidExtractor)
|
||||||
.use(markdownItTikzExtractor)
|
.use(markdownItTikzExtractor)
|
||||||
|
.use(markdownItMacroplanExtractor)
|
||||||
.use(html5Media)
|
.use(html5Media)
|
||||||
.use(blockEmbedPlugin, {
|
.use(blockEmbedPlugin, {
|
||||||
youtube: {
|
youtube: {
|
||||||
@@ -142,6 +175,7 @@ const md = new MarkdownIt({
|
|||||||
})
|
})
|
||||||
|
|
||||||
let shikijiPromise: Promise<void> | null = null
|
let shikijiPromise: Promise<void> | null = null
|
||||||
|
const shikijiReady = ref(false)
|
||||||
|
|
||||||
export const useShikiji = (): Promise<void> => {
|
export const useShikiji = (): Promise<void> => {
|
||||||
if (!shikijiPromise) {
|
if (!shikijiPromise) {
|
||||||
@@ -154,11 +188,13 @@ export const useShikiji = (): Promise<void> => {
|
|||||||
"bash",
|
"bash",
|
||||||
"javascript",
|
"javascript",
|
||||||
"typescript",
|
"typescript",
|
||||||
|
"tsx",
|
||||||
"markdown",
|
"markdown",
|
||||||
"mermaid",
|
"mermaid",
|
||||||
"html",
|
"html",
|
||||||
"css",
|
"css",
|
||||||
"json",
|
"json",
|
||||||
|
"rust",
|
||||||
{
|
{
|
||||||
...alloyGrammar,
|
...alloyGrammar,
|
||||||
name: "alloy",
|
name: "alloy",
|
||||||
@@ -167,6 +203,7 @@ export const useShikiji = (): Promise<void> => {
|
|||||||
]
|
]
|
||||||
}).then((plugin) => {
|
}).then((plugin) => {
|
||||||
md.use(plugin)
|
md.use(plugin)
|
||||||
|
shikijiReady.value = true
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return shikijiPromise
|
return shikijiPromise
|
||||||
@@ -174,7 +211,7 @@ export const useShikiji = (): Promise<void> => {
|
|||||||
|
|
||||||
let mermaidInitialized = false
|
let mermaidInitialized = false
|
||||||
|
|
||||||
export const runMermaid = (querySelector: string) => {
|
export const runMermaid = (querySelector: string): Promise<void> => {
|
||||||
if (!mermaidInitialized) {
|
if (!mermaidInitialized) {
|
||||||
mermaidInitialized = true
|
mermaidInitialized = true
|
||||||
mermaid.initialize({
|
mermaid.initialize({
|
||||||
@@ -184,7 +221,7 @@ export const runMermaid = (querySelector: string) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
mermaid.run({
|
return mermaid.run({
|
||||||
querySelector
|
querySelector
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -390,8 +427,20 @@ const stripFrontmatter = (content: string): string => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const renderMarkdown = (content: string, env?: Record<string, unknown>) => {
|
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()
|
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 ({
|
export const renderCodeFile = async ({
|
||||||
@@ -405,10 +454,22 @@ export const renderCodeFile = async ({
|
|||||||
}): Promise<string> => {
|
}): Promise<string> => {
|
||||||
await useShikiji()
|
await useShikiji()
|
||||||
const heading = filename ? `# ${filename}\n\n` : ""
|
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) {
|
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) => {
|
export const markdownBuilder = (defaultPrefix?: Ref<string> | string) => {
|
||||||
|
|||||||
@@ -6,10 +6,13 @@ import {
|
|||||||
runTikz,
|
runTikz,
|
||||||
useShikiji
|
useShikiji
|
||||||
} from "@/hooks/useMarkdown.hook"
|
} from "@/hooks/useMarkdown.hook"
|
||||||
|
import { runMacroplan } from "@/modules/macroplan/runMacroplan"
|
||||||
|
import { attachSvgDownloads } from "@/utils/svgDownload"
|
||||||
|
|
||||||
interface MarkdownPostRenderOptions {
|
interface MarkdownPostRenderOptions {
|
||||||
onReady?: () => void
|
onReady?: () => void
|
||||||
tikz?: boolean
|
tikz?: boolean
|
||||||
|
macroplan?: boolean
|
||||||
mermaid?: () => boolean
|
mermaid?: () => boolean
|
||||||
shikiji?: () => boolean
|
shikiji?: () => boolean
|
||||||
images?: () => string | null | undefined
|
images?: () => string | null | undefined
|
||||||
@@ -31,13 +34,20 @@ export const useMarkdownPostRender = (
|
|||||||
options.onReady?.()
|
options.onReady?.()
|
||||||
|
|
||||||
const scope = scopeSelector()
|
const scope = scopeSelector()
|
||||||
|
const wantsTikz = !!options.tikz
|
||||||
|
const wantsMermaid = !!options.mermaid?.()
|
||||||
|
|
||||||
if (options.tikz) {
|
const renderJobs: Promise<unknown>[] = []
|
||||||
void runTikz(`${scope} .tikz`)
|
if (wantsTikz) {
|
||||||
|
renderJobs.push(runTikz(`${scope} .tikz`))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.mermaid?.()) {
|
if (wantsMermaid) {
|
||||||
runMermaid(`${scope} .mermaid`)
|
renderJobs.push(runMermaid(`${scope} .mermaid`))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.macroplan) {
|
||||||
|
renderJobs.push(runMacroplan(`${scope} .macroplan-block`))
|
||||||
}
|
}
|
||||||
|
|
||||||
if (options.shikiji?.()) {
|
if (options.shikiji?.()) {
|
||||||
@@ -48,6 +58,12 @@ export const useMarkdownPostRender = (
|
|||||||
if (imagesSha) {
|
if (imagesSha) {
|
||||||
useImages(imagesSha)
|
useImages(imagesSha)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (wantsTikz || wantsMermaid) {
|
||||||
|
await Promise.allSettled(renderJobs)
|
||||||
|
await nextTick()
|
||||||
|
attachSvgDownloads(document.querySelector(scope))
|
||||||
|
}
|
||||||
},
|
},
|
||||||
{ immediate: true }
|
{ immediate: true }
|
||||||
)
|
)
|
||||||
|
|||||||
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 { Ref, ref } from "vue"
|
||||||
|
|
||||||
|
import { data, generateId } from "@/data/data"
|
||||||
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
import { useGitHubContent } from "@/hooks/useGitHubContent.hook"
|
||||||
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
||||||
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
||||||
|
import { Note } from "@/modules/note/models/Note"
|
||||||
import { queryFileContent } from "@/modules/repo/services/repo"
|
import { queryFileContent } from "@/modules/repo/services/repo"
|
||||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
|
|
||||||
@@ -12,6 +15,9 @@ export type FreshnessStatus =
|
|||||||
| "verified"
|
| "verified"
|
||||||
| "outdated"
|
| "outdated"
|
||||||
| "offline"
|
| "offline"
|
||||||
|
| "unauthorized"
|
||||||
|
|
||||||
|
const MIN_SPINNER_MS = 400
|
||||||
|
|
||||||
export const useNoteFreshness = ({
|
export const useNoteFreshness = ({
|
||||||
user,
|
user,
|
||||||
@@ -38,25 +44,53 @@ export const useNoteFreshness = ({
|
|||||||
const check = async () => {
|
const check = async () => {
|
||||||
if (!path.value) return
|
if (!path.value) return
|
||||||
status.value = "checking"
|
status.value = "checking"
|
||||||
const remoteSha = await fetchLatestSha(path.value)
|
const startedAt = performance.now()
|
||||||
if (remoteSha === null) {
|
|
||||||
status.value = "offline"
|
let next: FreshnessStatus
|
||||||
return
|
const result = await fetchLatestSha(path.value)
|
||||||
|
if (result.kind === "unauthorized") {
|
||||||
|
next = "unauthorized"
|
||||||
|
} else if (result.kind === "offline" || result.sha === null) {
|
||||||
|
next = "offline"
|
||||||
|
} else {
|
||||||
|
latestSha.value = result.sha
|
||||||
|
lastCheckedAt.value = new Date()
|
||||||
|
const local = await expectedSha()
|
||||||
|
next = result.sha === local ? "verified" : "outdated"
|
||||||
}
|
}
|
||||||
latestSha.value = remoteSha
|
|
||||||
lastCheckedAt.value = new Date()
|
const elapsed = performance.now() - startedAt
|
||||||
const local = await expectedSha()
|
if (elapsed < MIN_SPINNER_MS) {
|
||||||
status.value = remoteSha === local ? "verified" : "outdated"
|
await new Promise((r) => setTimeout(r, MIN_SPINNER_MS - elapsed))
|
||||||
|
}
|
||||||
|
status.value = next
|
||||||
}
|
}
|
||||||
|
|
||||||
const pullLatest = async (): Promise<string | null> => {
|
const resolveRemoteSha = async (
|
||||||
if (!path.value) return null
|
path: string
|
||||||
|
): Promise<{ sha: string | null; failureStatus: FreshnessStatus | null }> => {
|
||||||
|
if (latestSha.value) return { sha: latestSha.value, failureStatus: null }
|
||||||
|
const result = await fetchLatestSha(path)
|
||||||
|
if (result.kind === "unauthorized") {
|
||||||
|
return { sha: null, failureStatus: "unauthorized" }
|
||||||
|
}
|
||||||
|
if (result.kind === "offline" || result.sha === null) {
|
||||||
|
return { sha: null, failureStatus: "offline" }
|
||||||
|
}
|
||||||
|
return { sha: result.sha, failureStatus: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
const pullLatest = async (): Promise<{
|
||||||
|
raw: string | null
|
||||||
|
failureStatus: FreshnessStatus | null
|
||||||
|
}> => {
|
||||||
|
if (!path.value) return { raw: null, failureStatus: null }
|
||||||
const usedCachedSha = latestSha.value !== null
|
const usedCachedSha = latestSha.value !== null
|
||||||
const remoteSha = latestSha.value ?? (await fetchLatestSha(path.value))
|
const { sha: remoteSha, failureStatus } = await resolveRemoteSha(path.value)
|
||||||
if (!remoteSha) {
|
if (!remoteSha) {
|
||||||
console.warn("pullLatest: could not resolve remote sha", { path: path.value })
|
console.warn("pullLatest: could not resolve remote sha", { path: path.value })
|
||||||
status.value = "offline"
|
if (failureStatus) status.value = failureStatus
|
||||||
return null
|
return { raw: null, failureStatus }
|
||||||
}
|
}
|
||||||
const fileContent = await queryFileContent(user, repo, remoteSha)
|
const fileContent = await queryFileContent(user, repo, remoteSha)
|
||||||
if (!fileContent) {
|
if (!fileContent) {
|
||||||
@@ -68,7 +102,7 @@ export const useNoteFreshness = ({
|
|||||||
// Cached SHA may be stale — clear so the next click re-resolves it.
|
// Cached SHA may be stale — clear so the next click re-resolves it.
|
||||||
if (usedCachedSha) latestSha.value = null
|
if (usedCachedSha) latestSha.value = null
|
||||||
status.value = "offline"
|
status.value = "offline"
|
||||||
return null
|
return { raw: null, failureStatus: "offline" }
|
||||||
}
|
}
|
||||||
const { saveCacheNote } = prepareNoteCache(sha.value, path.value)
|
const { saveCacheNote } = prepareNoteCache(sha.value, path.value)
|
||||||
await saveCacheNote(fileContent, {
|
await saveCacheNote(fileContent, {
|
||||||
@@ -80,7 +114,47 @@ export const useNoteFreshness = ({
|
|||||||
lastCheckedAt.value = new Date()
|
lastCheckedAt.value = new Date()
|
||||||
status.value = "verified"
|
status.value = "verified"
|
||||||
const { getRawContent } = markdownBuilder(sha.value)
|
const { getRawContent } = markdownBuilder(sha.value)
|
||||||
return getRawContent(fileContent)
|
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 {
|
return {
|
||||||
@@ -88,6 +162,7 @@ export const useNoteFreshness = ({
|
|||||||
lastCheckedAt,
|
lastCheckedAt,
|
||||||
latestSha,
|
latestSha,
|
||||||
check,
|
check,
|
||||||
pullLatest
|
pullLatest,
|
||||||
|
resolveMergeSources
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,16 +3,63 @@ import { computed, ref } from "vue"
|
|||||||
|
|
||||||
import { data, generateId } from "@/data/data"
|
import { data, generateId } from "@/data/data"
|
||||||
import { DataType } from "@/data/DataType.enum"
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
import { buildNoteDocs } from "@/modules/note/cache/prepareNoteCache"
|
||||||
import { Note } from "@/modules/note/models/Note"
|
import { Note } from "@/modules/note/models/Note"
|
||||||
import { queryFileContent } from "@/modules/repo/services/repo"
|
import { getMainReadme, queryFileContent } from "@/modules/repo/services/repo"
|
||||||
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
import { useUserRepoStore } from "@/modules/repo/store/userRepo.store"
|
||||||
|
|
||||||
|
const CONCURRENCY = 8
|
||||||
|
const BULK_FLUSH_SIZE = 50
|
||||||
|
|
||||||
|
export type CacheFailureKind =
|
||||||
|
| "fetch"
|
||||||
|
| "build"
|
||||||
|
| "save"
|
||||||
|
| "readme"
|
||||||
|
| "unknown"
|
||||||
|
|
||||||
|
export type CacheFailure = {
|
||||||
|
kind: CacheFailureKind
|
||||||
|
path?: string
|
||||||
|
sha?: string
|
||||||
|
message?: string
|
||||||
|
docIds?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const describeError = (error: unknown): string => {
|
||||||
|
if (error instanceof Error) return error.message
|
||||||
|
return String(error)
|
||||||
|
}
|
||||||
|
|
||||||
|
const runWithConcurrency = async <T>(
|
||||||
|
items: T[],
|
||||||
|
limit: number,
|
||||||
|
worker: (item: T) => Promise<void>
|
||||||
|
) => {
|
||||||
|
let cursor = 0
|
||||||
|
const next = async (): Promise<void> => {
|
||||||
|
const i = cursor++
|
||||||
|
if (i >= items.length) return
|
||||||
|
await worker(items[i])
|
||||||
|
return next()
|
||||||
|
}
|
||||||
|
await Promise.all(
|
||||||
|
Array.from({ length: Math.min(limit, items.length) }, next)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export const useOfflineNotes = () => {
|
export const useOfflineNotes = () => {
|
||||||
const store = useUserRepoStore()
|
const store = useUserRepoStore()
|
||||||
const totalOfNotes = computed(() => store.files.length)
|
const totalOfNotes = computed(() => store.files.length)
|
||||||
|
|
||||||
const noteCompleted = ref(0)
|
const noteCompleted = ref(0)
|
||||||
|
const failures = ref<CacheFailure[]>([])
|
||||||
|
const failedNotes = computed(() => failures.value.length)
|
||||||
|
|
||||||
|
const recordFailure = (failure: CacheFailure) => {
|
||||||
|
failures.value.push(failure)
|
||||||
|
console.warn("[offline-cache] failure", failure)
|
||||||
|
}
|
||||||
|
|
||||||
const cacheAllNotes = async () => {
|
const cacheAllNotes = async () => {
|
||||||
const isInitialized = store.user && store.repo && totalOfNotes.value > 0
|
const isInitialized = store.user && store.repo && totalOfNotes.value > 0
|
||||||
@@ -29,32 +76,84 @@ export const useOfflineNotes = () => {
|
|||||||
|
|
||||||
const cachedNotesSet = new Set(cachedNotesFromSha.map((note) => note._id))
|
const cachedNotesSet = new Set(cachedNotesFromSha.map((note) => note._id))
|
||||||
|
|
||||||
noteCompleted.value = 0
|
const filesToFetch = store.files.filter(
|
||||||
|
(file) =>
|
||||||
|
file.sha && !cachedNotesSet.has(generateId(DataType.Note, file.sha))
|
||||||
|
)
|
||||||
|
|
||||||
for (const file of store.files) {
|
noteCompleted.value = store.files.length - filesToFetch.length
|
||||||
noteCompleted.value++
|
failures.value = []
|
||||||
|
|
||||||
if (
|
const pendingDocs: Note[] = []
|
||||||
!file.sha ||
|
const flush = async () => {
|
||||||
cachedNotesSet.has(generateId(DataType.Note, file.sha))
|
if (!pendingDocs.length) return
|
||||||
) {
|
const batch = pendingDocs.splice(0, pendingDocs.length)
|
||||||
continue
|
try {
|
||||||
|
await data.bulkUpdate(batch)
|
||||||
|
} catch (error) {
|
||||||
|
recordFailure({
|
||||||
|
kind: "save",
|
||||||
|
message: describeError(error),
|
||||||
|
docIds: batch
|
||||||
|
.map((doc) => doc._id)
|
||||||
|
.filter((id): id is string => Boolean(id))
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const { saveCacheNote } = prepareNoteCache(file.sha, file.path)
|
|
||||||
|
|
||||||
const contentFile = await queryFileContent(
|
|
||||||
store.user,
|
|
||||||
store.repo,
|
|
||||||
file.sha
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!contentFile) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
saveCacheNote(contentFile)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const fetchWork = runWithConcurrency(
|
||||||
|
filesToFetch,
|
||||||
|
CONCURRENCY,
|
||||||
|
async (file) => {
|
||||||
|
try {
|
||||||
|
const content = await queryFileContent(
|
||||||
|
store.user,
|
||||||
|
store.repo,
|
||||||
|
file.sha!
|
||||||
|
)
|
||||||
|
if (content === null) {
|
||||||
|
recordFailure({
|
||||||
|
kind: "fetch",
|
||||||
|
path: file.path,
|
||||||
|
sha: file.sha!
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let docs: Note[]
|
||||||
|
try {
|
||||||
|
docs = buildNoteDocs(file.sha!, file.path, content)
|
||||||
|
} catch (error) {
|
||||||
|
recordFailure({
|
||||||
|
kind: "build",
|
||||||
|
path: file.path,
|
||||||
|
sha: file.sha!,
|
||||||
|
message: describeError(error)
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
pendingDocs.push(...docs)
|
||||||
|
if (pendingDocs.length >= BULK_FLUSH_SIZE) {
|
||||||
|
await flush()
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
recordFailure({
|
||||||
|
kind: "unknown",
|
||||||
|
path: file.path,
|
||||||
|
sha: file.sha,
|
||||||
|
message: describeError(error)
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
noteCompleted.value++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const readmeWork = getMainReadme(store.user, store.repo).catch((error) => {
|
||||||
|
recordFailure({ kind: "readme", message: describeError(error) })
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all([fetchWork, readmeWork])
|
||||||
|
await flush()
|
||||||
}
|
}
|
||||||
const { execute, isLoading } = useAsyncState(cacheAllNotes, null, {
|
const { execute, isLoading } = useAsyncState(cacheAllNotes, null, {
|
||||||
immediate: false
|
immediate: false
|
||||||
@@ -64,6 +163,8 @@ export const useOfflineNotes = () => {
|
|||||||
cacheAllNotes: execute,
|
cacheAllNotes: execute,
|
||||||
isLoading,
|
isLoading,
|
||||||
totalOfNotes,
|
totalOfNotes,
|
||||||
noteCompleted
|
noteCompleted,
|
||||||
|
failedNotes,
|
||||||
|
failures
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ const isReady = ref(false)
|
|||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const hasCredentialError = ref(false)
|
const hasCredentialError = ref(false)
|
||||||
const currentPage = ref(0)
|
const currentPage = ref(0)
|
||||||
const totalCount = ref(0)
|
const hasMore = ref(true)
|
||||||
let lastFetchedAt = 0
|
let lastFetchedAt = 0
|
||||||
|
|
||||||
const { username, accessToken } = useGitHubLogin()
|
const { username, accessToken } = useGitHubLogin()
|
||||||
@@ -20,7 +20,7 @@ const { username, accessToken } = useGitHubLogin()
|
|||||||
const resetState = () => {
|
const resetState = () => {
|
||||||
repos.value = []
|
repos.value = []
|
||||||
currentPage.value = 0
|
currentPage.value = 0
|
||||||
totalCount.value = 0
|
hasMore.value = true
|
||||||
isReady.value = false
|
isReady.value = false
|
||||||
isLoading.value = false
|
isLoading.value = false
|
||||||
hasCredentialError.value = false
|
hasCredentialError.value = false
|
||||||
@@ -32,26 +32,26 @@ const loadMore = async () => {
|
|||||||
isReady.value = true
|
isReady.value = true
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (isLoading.value) return
|
if (isLoading.value || !hasMore.value) return
|
||||||
isLoading.value = true
|
isLoading.value = true
|
||||||
try {
|
try {
|
||||||
const octokit = await getOctokit()
|
const octokit = await getOctokit()
|
||||||
const nextPage = currentPage.value + 1
|
const nextPage = currentPage.value + 1
|
||||||
const repoList = await octokit.request("GET /search/repositories", {
|
const repoList = await octokit.request("GET /user/repos", {
|
||||||
q: `user:${username.value}`,
|
sort: "full_name",
|
||||||
|
direction: "asc",
|
||||||
|
affiliation: "owner",
|
||||||
per_page: PER_PAGE,
|
per_page: PER_PAGE,
|
||||||
page: nextPage
|
page: nextPage
|
||||||
})
|
})
|
||||||
currentPage.value = nextPage
|
currentPage.value = nextPage
|
||||||
totalCount.value = repoList.data.total_count
|
hasMore.value = repoList.data.length === PER_PAGE
|
||||||
const newItems = repoList.data.items.map((item) => ({
|
const newItems = repoList.data.map((item) => ({
|
||||||
id: `${item.id}`,
|
id: `${item.id}`,
|
||||||
name: item.name,
|
name: item.name,
|
||||||
isPrivate: item.private
|
isPrivate: item.private ?? false
|
||||||
}))
|
}))
|
||||||
repos.value = [...repos.value, ...newItems].sort((a, b) =>
|
repos.value = [...repos.value, ...newItems]
|
||||||
a.name < b.name ? -1 : 1
|
|
||||||
)
|
|
||||||
} catch (err: unknown) {
|
} catch (err: unknown) {
|
||||||
if (
|
if (
|
||||||
typeof err === "object" &&
|
typeof err === "object" &&
|
||||||
@@ -79,9 +79,7 @@ watch(accessToken, (next, prev) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
export const useRepos = () => {
|
export const useRepos = () => {
|
||||||
const canLoadMore = computed(
|
const canLoadMore = computed(() => !isLoading.value && hasMore.value)
|
||||||
() => !isLoading.value && repos.value.length < totalCount.value
|
|
||||||
)
|
|
||||||
|
|
||||||
const isStale = Date.now() - lastFetchedAt > STALE_TIME_MS
|
const isStale = Date.now() - lastFetchedAt > STALE_TIME_MS
|
||||||
if (!isReady.value || isStale) {
|
if (!isReady.value || isStale) {
|
||||||
|
|||||||
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 = (
|
const addStackedNote = (
|
||||||
currentSha: string,
|
currentSha: string,
|
||||||
sha: string,
|
sha: string,
|
||||||
@@ -149,9 +159,26 @@ export const useRouteQueryStackedNotes = () => {
|
|||||||
scrollToFocusedNote({ noteId: selector ?? sha, hash, anchorTop })
|
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 {
|
return {
|
||||||
stackedNotes: readonly(stackedNotes),
|
stackedNotes: readonly(stackedNotes),
|
||||||
addStackedNote,
|
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 "./styles/app.css"
|
||||||
import "@/analytics/openpanel"
|
import "@/analytics/openpanel"
|
||||||
|
|
||||||
import { VueQueryPlugin } from "@tanstack/vue-query"
|
|
||||||
import { createPinia } from "pinia"
|
import { createPinia } from "pinia"
|
||||||
import { createApp } from "vue"
|
import { createApp } from "vue"
|
||||||
import { createI18n } from "vue-i18n"
|
import { createI18n } from "vue-i18n"
|
||||||
@@ -19,7 +18,6 @@ const i18n = createI18n({
|
|||||||
|
|
||||||
createApp(App)
|
createApp(App)
|
||||||
.use(router)
|
.use(router)
|
||||||
.use(VueQueryPlugin)
|
|
||||||
.use(i18n)
|
.use(i18n)
|
||||||
.use(createPinia())
|
.use(createPinia())
|
||||||
.mount("#app")
|
.mount("#app")
|
||||||
|
|||||||
53
src/modules/atproto/getUrl.spec.ts
Normal file
53
src/modules/atproto/getUrl.spec.ts
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
vi.mock("@/modules/atproto/getAuthor", () => ({
|
||||||
|
getAuthor: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { getAuthor } from "@/modules/atproto/getAuthor"
|
||||||
|
|
||||||
|
import { getUrl } from "./getUrl"
|
||||||
|
|
||||||
|
describe("getUrl", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getAuthor).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null when the author cannot be resolved", async () => {
|
||||||
|
vi.mocked(getAuthor).mockResolvedValue(null)
|
||||||
|
expect(
|
||||||
|
await getUrl({ did: "did:plc:abc", rkey: "r1" })
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("builds a getRecord URL with the right query params on the author's PDS", async () => {
|
||||||
|
vi.mocked(getAuthor).mockResolvedValue({
|
||||||
|
handle: "alice.bsky.social",
|
||||||
|
pds: "https://pds.example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
const url = await getUrl({ did: "did:plc:abc", rkey: "rkey1" })
|
||||||
|
const parsed = new URL(url as string)
|
||||||
|
|
||||||
|
expect(parsed.origin).toBe("https://pds.example.com")
|
||||||
|
expect(parsed.pathname).toBe("/xrpc/com.atproto.repo.getRecord")
|
||||||
|
expect(parsed.searchParams.get("repo")).toBe("did:plc:abc")
|
||||||
|
expect(parsed.searchParams.get("collection")).toBe("space.remanso.note")
|
||||||
|
expect(parsed.searchParams.get("rkey")).toBe("rkey1")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("passes the did to getAuthor", async () => {
|
||||||
|
vi.mocked(getAuthor).mockResolvedValue({
|
||||||
|
handle: "h",
|
||||||
|
pds: "https://pds.example.com"
|
||||||
|
})
|
||||||
|
|
||||||
|
await getUrl({ did: "did:web:example.com", rkey: "r2" })
|
||||||
|
|
||||||
|
expect(getAuthor).toHaveBeenCalledWith("did:web:example.com")
|
||||||
|
})
|
||||||
|
})
|
||||||
54
src/modules/atproto/parseAtUri.spec.ts
Normal file
54
src/modules/atproto/parseAtUri.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { parseAtUri } from "./parseAtUri"
|
||||||
|
|
||||||
|
describe("parseAtUri", () => {
|
||||||
|
it("parses a did:plc AT URI", () => {
|
||||||
|
expect(
|
||||||
|
parseAtUri("at://did:plc:abc123/app.bsky.feed.post/rkey-xyz")
|
||||||
|
).toEqual({
|
||||||
|
did: "did:plc:abc123",
|
||||||
|
rkey: "rkey-xyz"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("parses a did:web AT URI", () => {
|
||||||
|
expect(
|
||||||
|
parseAtUri("at://did:web:example.com/space.remanso.note/note-1")
|
||||||
|
).toEqual({
|
||||||
|
did: "did:web:example.com",
|
||||||
|
rkey: "note-1"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("treats rkeys with slashes as a single trailing segment", () => {
|
||||||
|
expect(
|
||||||
|
parseAtUri("at://did:plc:abc/space.remanso.note/multi/segment")
|
||||||
|
).toEqual({
|
||||||
|
did: "did:plc:abc",
|
||||||
|
rkey: "multi/segment"
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws when the URI does not start with at://", () => {
|
||||||
|
expect(() =>
|
||||||
|
parseAtUri("https://did:plc:abc/collection/rkey")
|
||||||
|
).toThrow(/Invalid AT URI/)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws when the DID prefix is missing", () => {
|
||||||
|
expect(() => parseAtUri("at://abc/collection/rkey")).toThrow(
|
||||||
|
/Invalid AT URI/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws when the collection or rkey is missing", () => {
|
||||||
|
expect(() => parseAtUri("at://did:plc:abc/onlycollection")).toThrow(
|
||||||
|
/Invalid AT URI/
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("throws on empty input", () => {
|
||||||
|
expect(() => parseAtUri("")).toThrow(/Invalid AT URI/)
|
||||||
|
})
|
||||||
|
})
|
||||||
41
src/modules/atproto/shortDid.spec.ts
Normal file
41
src/modules/atproto/shortDid.spec.ts
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { fromShortDid, toShortDid } from "./shortDid"
|
||||||
|
|
||||||
|
describe("toShortDid", () => {
|
||||||
|
it("strips did:plc: prefix", () => {
|
||||||
|
expect(toShortDid("did:plc:abc123")).toBe("abc123")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("strips did: prefix but keeps the method when non-plc", () => {
|
||||||
|
expect(toShortDid("did:web:example.com")).toBe("web:example.com")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns input unchanged when there is no did: prefix", () => {
|
||||||
|
expect(toShortDid("abc123")).toBe("abc123")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("fromShortDid", () => {
|
||||||
|
it("adds did:plc: prefix to bare identifiers", () => {
|
||||||
|
expect(fromShortDid("abc123")).toBe("did:plc:abc123")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("adds did: prefix when method is already present", () => {
|
||||||
|
expect(fromShortDid("web:example.com")).toBe("did:web:example.com")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("passes through fully-qualified DIDs unchanged", () => {
|
||||||
|
expect(fromShortDid("did:plc:abc123")).toBe("did:plc:abc123")
|
||||||
|
expect(fromShortDid("did:web:example.com")).toBe("did:web:example.com")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("round-trip toShortDid → fromShortDid", () => {
|
||||||
|
it.each(["did:plc:abc123", "did:web:example.com", "did:key:zXyZ"])(
|
||||||
|
"is identity for %s",
|
||||||
|
(did) => {
|
||||||
|
expect(fromShortDid(toShortDid(did))).toBe(did)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
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" })
|
||||||
|
})
|
||||||
|
})
|
||||||
29
src/modules/note/cache/prepareNoteCache.ts
vendored
29
src/modules/note/cache/prepareNoteCache.ts
vendored
@@ -11,6 +11,24 @@ type NoteCacheResult =
|
|||||||
| { note: Note; from: "path" }
|
| { note: Note; from: "path" }
|
||||||
| { note: null; from: null }
|
| { note: null; from: null }
|
||||||
|
|
||||||
|
export const buildNoteDocs = (
|
||||||
|
sha: string,
|
||||||
|
path: string | undefined,
|
||||||
|
content: string,
|
||||||
|
editedSha?: string
|
||||||
|
): Note[] => {
|
||||||
|
const base: Note = {
|
||||||
|
_id: generateId(DataType.Note, sha),
|
||||||
|
$type: DataType.Note,
|
||||||
|
content,
|
||||||
|
editedSha,
|
||||||
|
path
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
? [base, { ...base, _id: generateId(DataType.Note, path) }]
|
||||||
|
: [base]
|
||||||
|
}
|
||||||
|
|
||||||
export const prepareNoteCache = (sha: string, path?: string) => {
|
export const prepareNoteCache = (sha: string, path?: string) => {
|
||||||
const store = useUserRepoStore()
|
const store = useUserRepoStore()
|
||||||
|
|
||||||
@@ -44,11 +62,18 @@ export const prepareNoteCache = (sha: string, path?: string) => {
|
|||||||
content: string,
|
content: string,
|
||||||
params?: { editedSha?: string; path?: 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 = {
|
const newNote: Note = {
|
||||||
_id: noteId,
|
_id: contentId,
|
||||||
$type: DataType.Note,
|
$type: DataType.Note,
|
||||||
content,
|
content,
|
||||||
editedSha: params?.editedSha
|
editedSha: params?.editedSha,
|
||||||
|
path: params?.path ?? path
|
||||||
}
|
}
|
||||||
|
|
||||||
if (params && params.path) {
|
if (params && params.path) {
|
||||||
|
|||||||
@@ -1,14 +1,27 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useOfflineNotes } from "@/hooks/useOfflineNotes.hook"
|
import { useOfflineNotes } from "@/hooks/useOfflineNotes.hook"
|
||||||
import { confirmMessage } from "@/utils/notif"
|
import { confirmMessage, errorMessage } from "@/utils/notif"
|
||||||
|
|
||||||
const { cacheAllNotes, isLoading, totalOfNotes, noteCompleted } =
|
const {
|
||||||
useOfflineNotes()
|
cacheAllNotes,
|
||||||
|
isLoading,
|
||||||
|
totalOfNotes,
|
||||||
|
noteCompleted,
|
||||||
|
failedNotes,
|
||||||
|
failures
|
||||||
|
} = useOfflineNotes()
|
||||||
|
|
||||||
const confirmBeforeCachingAllNotes = async () => {
|
const confirmBeforeCachingAllNotes = async () => {
|
||||||
confirm("Do you want to cache all notes?")
|
confirm("Do you want to cache all notes?")
|
||||||
await cacheAllNotes()
|
await cacheAllNotes()
|
||||||
confirmMessage("✅ All notes have been locally saved")
|
if (failedNotes.value > 0) {
|
||||||
|
console.table(failures.value)
|
||||||
|
errorMessage(
|
||||||
|
`${failedNotes.value} of ${totalOfNotes.value} note(s) could not be cached — try again`
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
confirmMessage("✅ All notes have been locally saved")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
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> {
|
export interface Note extends Model<DataType.Note> {
|
||||||
content: string
|
content: string
|
||||||
editedSha?: 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 {
|
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"
|
|
||||||
})
|
|
||||||
@@ -2,24 +2,27 @@ import { computed, onMounted, ref } from "vue"
|
|||||||
|
|
||||||
import { data, generateId } from "@/data/data"
|
import { data, generateId } from "@/data/data"
|
||||||
import { DataType } from "@/data/DataType.enum"
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { useRepos } from "@/hooks/useRepos.hook"
|
|
||||||
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
|
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
|
||||||
import { FavoriteRepo } from "@/modules/repo/models/FavoriteRepo"
|
import { FavoriteRepo } from "@/modules/repo/models/FavoriteRepo"
|
||||||
|
|
||||||
export const useFavoriteRepos = () => {
|
export const useFavoriteRepos = () => {
|
||||||
const { repos } = useRepos()
|
|
||||||
const savedRepos = ref<FavoriteRepo[]>([])
|
const savedRepos = ref<FavoriteRepo[]>([])
|
||||||
|
|
||||||
const getFavorites = async () => {
|
const getFavorites = async () => {
|
||||||
savedRepos.value = await data.getAll<DataType.FavoriteRepo, FavoriteRepo>({
|
savedRepos.value = await data.getAll<DataType.FavoriteRepo, FavoriteRepo>({
|
||||||
prefix: DataType.FavoriteRepo,
|
prefix: DataType.FavoriteRepo
|
||||||
keys: repos.value.map((repo) => repo.id)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const savedFavoriteRepos = computed(() =>
|
const savedFavoriteRepos = computed(() => {
|
||||||
savedRepos.value.filter((repo) => repo.isFavorite)
|
const seen = new Map<string, FavoriteRepo>()
|
||||||
)
|
for (const repo of savedRepos.value) {
|
||||||
|
if (repo.isFavorite && !seen.has(repo.name)) {
|
||||||
|
seen.set(repo.name, repo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [...seen.values()]
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
getFavorites()
|
getFavorites()
|
||||||
|
|||||||
@@ -1,31 +1,38 @@
|
|||||||
import { computed } from "vue"
|
import { computed } from "vue"
|
||||||
|
|
||||||
|
import { DataType } from "@/data/DataType.enum"
|
||||||
import { useRepos } from "@/hooks/useRepos.hook"
|
import { useRepos } from "@/hooks/useRepos.hook"
|
||||||
import { useFavoriteRepos } from "@/modules/repo/hooks/useFavoriteRepos.hook"
|
import { useFavoriteRepos } from "@/modules/repo/hooks/useFavoriteRepos.hook"
|
||||||
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
|
import { RepoBase } from "@/modules/repo/interfaces/RepoBase"
|
||||||
|
|
||||||
|
const FAVORITE_ID_PREFIX = `${DataType.FavoriteRepo}-`
|
||||||
|
|
||||||
export const useRepoList = () => {
|
export const useRepoList = () => {
|
||||||
const { savedFavoriteRepos, addFavorite, removeFavorite } = useFavoriteRepos()
|
const { savedFavoriteRepos, addFavorite, removeFavorite } = useFavoriteRepos()
|
||||||
const { repos, canLoadMore, loadMore } = useRepos()
|
const { repos, canLoadMore, loadMore } = useRepos()
|
||||||
|
|
||||||
const favoriteRepos = computed(() => {
|
const favoriteRepos = computed<RepoBase[]>(() =>
|
||||||
return repos.value.filter((repo) =>
|
savedFavoriteRepos.value.map((fav) => ({
|
||||||
savedFavoriteRepos.value.find(
|
id: fav._id?.startsWith(FAVORITE_ID_PREFIX)
|
||||||
(fav) => fav._id?.includes(repo.id) ?? false
|
? fav._id.slice(FAVORITE_ID_PREFIX.length)
|
||||||
)
|
: (fav._id ?? ""),
|
||||||
)
|
name: fav.name,
|
||||||
})
|
isPrivate: fav.isPrivate
|
||||||
|
}))
|
||||||
const otherRepos = computed(() => {
|
)
|
||||||
return repos.value.filter(
|
|
||||||
(repo) => !favoriteRepos.value.find((favorite) => favorite.id === repo.id)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
const favoriteCheckboxes = computed(() =>
|
const favoriteCheckboxes = computed(() =>
|
||||||
favoriteRepos.value.map((favorite) => favorite.id)
|
favoriteRepos.value.map((favorite) => favorite.id)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const otherRepos = computed(() => {
|
||||||
|
const starredIds = new Set(favoriteCheckboxes.value)
|
||||||
|
const starredNames = new Set(favoriteRepos.value.map((r) => r.name))
|
||||||
|
return repos.value.filter(
|
||||||
|
(repo) => !starredIds.has(repo.id) && !starredNames.has(repo.name)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
const toggleCheckbox = async (repo: RepoBase) => {
|
const toggleCheckbox = async (repo: RepoBase) => {
|
||||||
if (favoriteCheckboxes.value.includes(repo.id)) {
|
if (favoriteCheckboxes.value.includes(repo.id)) {
|
||||||
await removeFavorite(repo)
|
await removeFavorite(repo)
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export interface UserSettings extends Model<DataType.UserSettings> {
|
|||||||
fontSize?: string
|
fontSize?: string
|
||||||
chosenFontSize?: string
|
chosenFontSize?: string
|
||||||
backlink?: boolean
|
backlink?: boolean
|
||||||
chosenTitleFont?: string
|
chosenHeadingFont?: string
|
||||||
chosenBodyFont?: string
|
chosenBodyFont?: string
|
||||||
pageWidth?: string
|
pageWidth?: string
|
||||||
}
|
}
|
||||||
|
|||||||
62
src/modules/repo/services/octo.spec.ts
Normal file
62
src/modules/repo/services/octo.spec.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
vi.mock("@/modules/user/service/signIn", () => ({
|
||||||
|
getAccessToken: vi.fn().mockResolvedValue({ token: "test-token" })
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { DEFAULT_OCTOKIT_TIMEOUT_MS, getOctokit } from "./octo"
|
||||||
|
|
||||||
|
const okResponse = () =>
|
||||||
|
new Response("{}", {
|
||||||
|
status: 200,
|
||||||
|
headers: { "content-type": "application/json" }
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getOctokit timeout hook", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
afterEach(() => {
|
||||||
|
vi.useRealTimers()
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("injects an AbortSignal.timeout into requests by default", async () => {
|
||||||
|
const timeoutSpy = vi.spyOn(AbortSignal, "timeout")
|
||||||
|
let receivedSignal: AbortSignal | undefined
|
||||||
|
const mockFetch = vi.fn(async (_url: string, init: RequestInit) => {
|
||||||
|
receivedSignal = init.signal ?? undefined
|
||||||
|
return okResponse()
|
||||||
|
})
|
||||||
|
|
||||||
|
const octokit = await getOctokit()
|
||||||
|
await octokit.request("GET /user", {
|
||||||
|
request: { fetch: mockFetch as unknown as typeof fetch }
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(timeoutSpy).toHaveBeenCalledWith(DEFAULT_OCTOKIT_TIMEOUT_MS)
|
||||||
|
expect(receivedSignal).toBeInstanceOf(AbortSignal)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("respects a caller-provided signal and does not overwrite it", async () => {
|
||||||
|
const timeoutSpy = vi.spyOn(AbortSignal, "timeout")
|
||||||
|
const controller = new AbortController()
|
||||||
|
let receivedSignal: AbortSignal | undefined
|
||||||
|
const mockFetch = vi.fn(async (_url: string, init: RequestInit) => {
|
||||||
|
receivedSignal = init.signal ?? undefined
|
||||||
|
return okResponse()
|
||||||
|
})
|
||||||
|
|
||||||
|
const octokit = await getOctokit()
|
||||||
|
await octokit.request("GET /user", {
|
||||||
|
request: {
|
||||||
|
fetch: mockFetch as unknown as typeof fetch,
|
||||||
|
signal: controller.signal
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(timeoutSpy).not.toHaveBeenCalled()
|
||||||
|
expect(receivedSignal).toBe(controller.signal)
|
||||||
|
})
|
||||||
|
|
||||||
|
})
|
||||||
@@ -1,11 +1,40 @@
|
|||||||
import { Octokit } from "@octokit/rest"
|
import { Octokit } from "@octokit/rest"
|
||||||
|
|
||||||
import { getAccessToken } from "@/modules/user/service/signIn"
|
import { getAccessToken, refreshToken } from "@/modules/user/service/signIn"
|
||||||
|
|
||||||
|
export const DEFAULT_OCTOKIT_TIMEOUT_MS = 8_000
|
||||||
|
|
||||||
export const getOctokit = async (): Promise<Octokit> => {
|
export const getOctokit = async (): Promise<Octokit> => {
|
||||||
const response = await getAccessToken()
|
const response = await getAccessToken()
|
||||||
|
|
||||||
return new Octokit({
|
const octokit = new Octokit({
|
||||||
auth: response?.token ?? ""
|
auth: response?.token ?? ""
|
||||||
})
|
})
|
||||||
|
|
||||||
|
octokit.hook.before("request", (options) => {
|
||||||
|
options.request ??= {}
|
||||||
|
if (!options.request.signal) {
|
||||||
|
options.request.signal = AbortSignal.timeout(DEFAULT_OCTOKIT_TIMEOUT_MS)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return octokit
|
||||||
|
}
|
||||||
|
|
||||||
|
const isUnauthorized = (error: unknown): boolean =>
|
||||||
|
(error as { status?: number })?.status === 401
|
||||||
|
|
||||||
|
// Runs an Octokit call; on 401, force-refreshes the token and retries once.
|
||||||
|
// Rethrows the original error if the refresh fails or the retry still 401s.
|
||||||
|
export const runWithAuthRetry = async <T>(
|
||||||
|
call: (octokit: Octokit) => Promise<T>
|
||||||
|
): Promise<T> => {
|
||||||
|
try {
|
||||||
|
return await call(await getOctokit())
|
||||||
|
} catch (error) {
|
||||||
|
if (!isUnauthorized(error)) throw error
|
||||||
|
const refreshed = await refreshToken({ force: true }).catch(() => null)
|
||||||
|
if (!refreshed) throw error
|
||||||
|
return await call(await getOctokit())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
223
src/modules/repo/services/repo.spec.ts
Normal file
223
src/modules/repo/services/repo.spec.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
const { reposGet } = vi.hoisted(() => ({ reposGet: vi.fn() }))
|
||||||
|
|
||||||
|
vi.mock("./octo", () => ({
|
||||||
|
getOctokit: vi.fn().mockResolvedValue({
|
||||||
|
repos: { get: reposGet }
|
||||||
|
}),
|
||||||
|
runWithAuthRetry: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
// Stub heavyweight transitive imports so loading repo.ts doesn't spin up the
|
||||||
|
// data Web Worker or the full markdown rendering stack.
|
||||||
|
vi.mock("@/hooks/useMarkdown.hook", () => ({
|
||||||
|
markdownBuilder: () => ({ render: (s: string) => s })
|
||||||
|
}))
|
||||||
|
vi.mock("@/modules/note/cache/prepareNoteCache", () => ({
|
||||||
|
prepareNoteCache: () => ({
|
||||||
|
getCachedNote: async () => ({ note: null }),
|
||||||
|
saveCacheNote: vi.fn()
|
||||||
|
})
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { getOctokit, runWithAuthRetry } from "./octo"
|
||||||
|
import {
|
||||||
|
getFiles,
|
||||||
|
getRepoPermission,
|
||||||
|
getUserSettingsContent,
|
||||||
|
queryFileContent
|
||||||
|
} from "./repo"
|
||||||
|
|
||||||
|
const makeOctokitWithRequest = (impl: (route: string, params: unknown) => unknown) => ({
|
||||||
|
request: vi.fn(impl)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getRepoPermission", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
reposGet.mockReset()
|
||||||
|
vi.mocked(getOctokit).mockResolvedValue({
|
||||||
|
repos: { get: reposGet }
|
||||||
|
} as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns true when permissions.push is true", async () => {
|
||||||
|
reposGet.mockResolvedValue({
|
||||||
|
data: { permissions: { push: true } }
|
||||||
|
})
|
||||||
|
await expect(getRepoPermission("owner", "repo")).resolves.toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false when permissions.push is false", async () => {
|
||||||
|
reposGet.mockResolvedValue({
|
||||||
|
data: { permissions: { push: false } }
|
||||||
|
})
|
||||||
|
await expect(getRepoPermission("owner", "repo")).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false when permissions is missing (anonymous request)", async () => {
|
||||||
|
reposGet.mockResolvedValue({
|
||||||
|
data: {}
|
||||||
|
})
|
||||||
|
await expect(getRepoPermission("owner", "repo")).resolves.toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns false when owner or repo is empty", async () => {
|
||||||
|
await expect(getRepoPermission("", "repo")).resolves.toBe(false)
|
||||||
|
await expect(getRepoPermission("owner", "")).resolves.toBe(false)
|
||||||
|
expect(reposGet).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getFiles", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getOctokit).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty array when owner is missing", async () => {
|
||||||
|
expect(await getFiles("", "repo")).toEqual([])
|
||||||
|
expect(getOctokit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty array when repo is missing", async () => {
|
||||||
|
expect(await getFiles("owner", "")).toEqual([])
|
||||||
|
expect(getOctokit).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty array when there are no commits", async () => {
|
||||||
|
const octokit = makeOctokitWithRequest((route) => {
|
||||||
|
if (route === "GET /repos/{owner}/{repo}/commits") {
|
||||||
|
return { data: [] }
|
||||||
|
}
|
||||||
|
throw new Error("unexpected route " + route)
|
||||||
|
})
|
||||||
|
vi.mocked(getOctokit).mockResolvedValue(octokit as never)
|
||||||
|
|
||||||
|
expect(await getFiles("owner", "repo")).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("fetches the tree from the latest commit and filters out non-blob entries", async () => {
|
||||||
|
const octokit = makeOctokitWithRequest((route, params) => {
|
||||||
|
if (route === "GET /repos/{owner}/{repo}/commits") {
|
||||||
|
return {
|
||||||
|
data: [{ commit: { tree: { sha: "TREE_SHA" } } }]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (route === "GET /repos/{owner}/{repo}/git/trees/{tree_sha}") {
|
||||||
|
expect((params as { tree_sha: string }).tree_sha).toBe("TREE_SHA")
|
||||||
|
expect((params as { recursive: string }).recursive).toBe("true")
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
tree: [
|
||||||
|
{ path: "README.md", type: "blob", sha: "a" },
|
||||||
|
{ path: "src", type: "tree", sha: "b" },
|
||||||
|
{ path: "src/note.md", type: "blob", sha: "c" }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error("unexpected route " + route)
|
||||||
|
})
|
||||||
|
vi.mocked(getOctokit).mockResolvedValue(octokit as never)
|
||||||
|
|
||||||
|
const files = await getFiles("owner", "repo")
|
||||||
|
|
||||||
|
expect(files.map((f) => f.path)).toEqual(["README.md", "src/note.md"])
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("queryFileContent", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(runWithAuthRetry).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null when owner or repo is missing", async () => {
|
||||||
|
expect(await queryFileContent("", "repo", "sha")).toBeNull()
|
||||||
|
expect(await queryFileContent("owner", "", "sha")).toBeNull()
|
||||||
|
expect(runWithAuthRetry).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the blob content via runWithAuthRetry", async () => {
|
||||||
|
vi.mocked(runWithAuthRetry).mockImplementation(async (call) => {
|
||||||
|
const octokit = {
|
||||||
|
request: vi.fn().mockResolvedValue({
|
||||||
|
data: { content: "BASE64" }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return call(octokit as never)
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(await queryFileContent("owner", "repo", "SHA")).toBe("BASE64")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null and swallows errors when the call fails", async () => {
|
||||||
|
const warn = vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
vi.mocked(runWithAuthRetry).mockRejectedValue(new Error("boom"))
|
||||||
|
|
||||||
|
expect(await queryFileContent("owner", "repo", "SHA")).toBeNull()
|
||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,7 +2,19 @@ import { markdownBuilder } from "@/hooks/useMarkdown.hook"
|
|||||||
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
import { prepareNoteCache } from "@/modules/note/cache/prepareNoteCache"
|
||||||
import { RepoFile } from "@/modules/repo/interfaces/RepoFile"
|
import { RepoFile } from "@/modules/repo/interfaces/RepoFile"
|
||||||
import { UserSettings } from "@/modules/repo/interfaces/UserSettings"
|
import { UserSettings } from "@/modules/repo/interfaces/UserSettings"
|
||||||
import { getOctokit } from "@/modules/repo/services/octo"
|
import { getOctokit, runWithAuthRetry } from "@/modules/repo/services/octo"
|
||||||
|
|
||||||
|
export const getRepoPermission = async (
|
||||||
|
owner: string,
|
||||||
|
repo: string
|
||||||
|
): Promise<boolean> => {
|
||||||
|
if (!owner || !repo) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
const octokit = await getOctokit()
|
||||||
|
const { data } = await octokit.repos.get({ owner, repo })
|
||||||
|
return data.permissions?.push ?? false
|
||||||
|
}
|
||||||
|
|
||||||
export const getFiles = async (
|
export const getFiles = async (
|
||||||
owner: string,
|
owner: string,
|
||||||
@@ -30,7 +42,8 @@ export const getFiles = async (
|
|||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
tree_sha: lastCommit.commit.tree.sha,
|
tree_sha: lastCommit.commit.tree.sha,
|
||||||
recursive: "true"
|
recursive: "true",
|
||||||
|
request: { signal: AbortSignal.timeout(20_000) }
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -82,6 +95,10 @@ export const getMainReadme = async (owner: string, repo: string) => {
|
|||||||
if (cachedReadme) {
|
if (cachedReadme) {
|
||||||
return render(cachedReadme.content)
|
return render(cachedReadme.content)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// No cached fallback — surface the error so the caller can classify
|
||||||
|
// network/timeout failures vs auth/404 and tailor the UI accordingly.
|
||||||
|
throw error
|
||||||
}
|
}
|
||||||
|
|
||||||
return null
|
return null
|
||||||
@@ -106,12 +123,14 @@ export const getUserSettingsContent = async (
|
|||||||
|
|
||||||
const raw = JSON.parse(atob(content)) as UserSettings & {
|
const raw = JSON.parse(atob(content)) as UserSettings & {
|
||||||
t?: string
|
t?: string
|
||||||
|
h?: string
|
||||||
p?: string
|
p?: string
|
||||||
}
|
}
|
||||||
const { t, p, ...rest } = raw
|
const { t, h, p, ...rest } = raw
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
chosenTitleFont: t,
|
// `h` (heading) is the current key; `t` (title) is kept for back-compat.
|
||||||
|
chosenHeadingFont: h ?? t,
|
||||||
chosenBodyFont: p
|
chosenBodyFont: p
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -126,14 +145,12 @@ export const queryFileContent = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const octokit = await getOctokit()
|
const file = await runWithAuthRetry((octokit) =>
|
||||||
const file = await octokit.request(
|
octokit.request("GET /repos/{owner}/{repo}/git/blobs/{file_sha}", {
|
||||||
"GET /repos/{owner}/{repo}/git/blobs/{file_sha}",
|
|
||||||
{
|
|
||||||
owner: user,
|
owner: user,
|
||||||
repo: repo,
|
repo: repo,
|
||||||
file_sha: sha
|
file_sha: sha
|
||||||
}
|
})
|
||||||
)
|
)
|
||||||
return file?.data.content ?? null
|
return file?.data.content ?? null
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
97
src/modules/repo/services/runWithAuthRetry.spec.ts
Normal file
97
src/modules/repo/services/runWithAuthRetry.spec.ts
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
vi.mock("@/modules/user/service/signIn", () => ({
|
||||||
|
getAccessToken: vi.fn(),
|
||||||
|
refreshToken: vi.fn()
|
||||||
|
}))
|
||||||
|
|
||||||
|
import {
|
||||||
|
getAccessToken,
|
||||||
|
refreshToken
|
||||||
|
} from "@/modules/user/service/signIn"
|
||||||
|
|
||||||
|
import { runWithAuthRetry } from "./octo"
|
||||||
|
|
||||||
|
const unauthorized = () => Object.assign(new Error("Bad credentials"), { status: 401 })
|
||||||
|
const notFound = () => Object.assign(new Error("Not found"), { status: 404 })
|
||||||
|
|
||||||
|
describe("runWithAuthRetry", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(getAccessToken).mockResolvedValue({
|
||||||
|
token: "t1"
|
||||||
|
} as Awaited<ReturnType<typeof getAccessToken>>)
|
||||||
|
vi.mocked(refreshToken).mockReset()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.restoreAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the call result on success without refreshing", async () => {
|
||||||
|
const call = vi.fn().mockResolvedValue("ok")
|
||||||
|
|
||||||
|
const result = await runWithAuthRetry(call)
|
||||||
|
|
||||||
|
expect(result).toBe("ok")
|
||||||
|
expect(call).toHaveBeenCalledTimes(1)
|
||||||
|
expect(refreshToken).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rethrows non-401 errors immediately without refreshing", async () => {
|
||||||
|
const err = notFound()
|
||||||
|
const call = vi.fn().mockRejectedValue(err)
|
||||||
|
|
||||||
|
await expect(runWithAuthRetry(call)).rejects.toBe(err)
|
||||||
|
expect(refreshToken).not.toHaveBeenCalled()
|
||||||
|
expect(call).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("refreshes and retries once on 401, returning the retry result", async () => {
|
||||||
|
vi.mocked(refreshToken).mockResolvedValue({
|
||||||
|
token: "t2"
|
||||||
|
} as Awaited<ReturnType<typeof refreshToken>>)
|
||||||
|
|
||||||
|
const call = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(unauthorized())
|
||||||
|
.mockResolvedValueOnce("after-refresh")
|
||||||
|
|
||||||
|
const result = await runWithAuthRetry(call)
|
||||||
|
|
||||||
|
expect(result).toBe("after-refresh")
|
||||||
|
expect(refreshToken).toHaveBeenCalledWith({ force: true })
|
||||||
|
expect(call).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rethrows the original 401 when refresh returns null", async () => {
|
||||||
|
vi.mocked(refreshToken).mockResolvedValue(null)
|
||||||
|
const err = unauthorized()
|
||||||
|
const call = vi.fn().mockRejectedValue(err)
|
||||||
|
|
||||||
|
await expect(runWithAuthRetry(call)).rejects.toBe(err)
|
||||||
|
expect(call).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("rethrows the original 401 when refresh itself throws", async () => {
|
||||||
|
vi.mocked(refreshToken).mockRejectedValue(new Error("network down"))
|
||||||
|
const err = unauthorized()
|
||||||
|
const call = vi.fn().mockRejectedValue(err)
|
||||||
|
|
||||||
|
await expect(runWithAuthRetry(call)).rejects.toBe(err)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("propagates a retry-time error after a successful refresh", async () => {
|
||||||
|
vi.mocked(refreshToken).mockResolvedValue({
|
||||||
|
token: "t2"
|
||||||
|
} as Awaited<ReturnType<typeof refreshToken>>)
|
||||||
|
|
||||||
|
const retryErr = unauthorized()
|
||||||
|
const call = vi
|
||||||
|
.fn()
|
||||||
|
.mockRejectedValueOnce(unauthorized())
|
||||||
|
.mockRejectedValueOnce(retryErr)
|
||||||
|
|
||||||
|
await expect(runWithAuthRetry(call)).rejects.toBe(retryErr)
|
||||||
|
expect(call).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
})
|
||||||
335
src/modules/repo/store/userRepo.store.spec.ts
Normal file
335
src/modules/repo/store/userRepo.store.spec.ts
Normal file
@@ -0,0 +1,335 @@
|
|||||||
|
import { createPinia, setActivePinia } from "pinia"
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
|
||||||
|
|
||||||
|
vi.mock("@/data/data", () => ({
|
||||||
|
data: {
|
||||||
|
get: vi.fn().mockResolvedValue(null),
|
||||||
|
update: vi.fn().mockResolvedValue(undefined),
|
||||||
|
add: vi.fn().mockResolvedValue(undefined)
|
||||||
|
},
|
||||||
|
generateId: (type: string, id: string) => `${type}-${id}`
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/modules/repo/services/repo", () => ({
|
||||||
|
getFiles: vi.fn().mockResolvedValue([]),
|
||||||
|
getMainReadme: vi.fn().mockResolvedValue(null),
|
||||||
|
getCachedMainReadme: vi.fn().mockResolvedValue(null),
|
||||||
|
getUserSettingsContent: vi.fn().mockResolvedValue(null),
|
||||||
|
getRepoPermission: vi.fn().mockResolvedValue(false)
|
||||||
|
}))
|
||||||
|
|
||||||
|
vi.mock("@/modules/user/service/signIn", () => ({
|
||||||
|
refreshToken: vi.fn().mockResolvedValue(null)
|
||||||
|
}))
|
||||||
|
|
||||||
|
import { data } from "@/data/data"
|
||||||
|
import {
|
||||||
|
getCachedMainReadme,
|
||||||
|
getFiles,
|
||||||
|
getMainReadme,
|
||||||
|
getRepoPermission,
|
||||||
|
getUserSettingsContent
|
||||||
|
} from "@/modules/repo/services/repo"
|
||||||
|
|
||||||
|
import { useUserRepoStore } from "./userRepo.store"
|
||||||
|
|
||||||
|
const flushAsync = () => new Promise((r) => setTimeout(r, 0))
|
||||||
|
|
||||||
|
describe("userRepo store — synchronous mutations", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
vi.clearAllMocks()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resetUserRepo clears user, repo, files, and settings", () => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
store.user = "alice"
|
||||||
|
store.repo = "notes"
|
||||||
|
store.files = [{ sha: "x", path: "a.md", type: "blob" }] as never
|
||||||
|
store.userSettings = { $type: 1 } as never
|
||||||
|
|
||||||
|
store.resetUserRepo()
|
||||||
|
|
||||||
|
expect(store.user).toBe("")
|
||||||
|
expect(store.repo).toBe("")
|
||||||
|
expect(store.files).toEqual([])
|
||||||
|
expect(store.userSettings).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("resetFiles clears files and sets readme to null", () => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
store.files = [{ sha: "x", path: "a.md", type: "blob" }] as never
|
||||||
|
store.readme = "<p>hi</p>"
|
||||||
|
|
||||||
|
store.resetFiles()
|
||||||
|
|
||||||
|
expect(store.files).toEqual([])
|
||||||
|
expect(store.readme).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("addFile appends a new file with a unique sha", () => {
|
||||||
|
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: "b.md", type: "blob" } as never)
|
||||||
|
|
||||||
|
expect(store.files.map((f) => f.sha)).toEqual(["old", "new"])
|
||||||
|
expect(vi.mocked(data.update)).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("addFile is a no-op when the sha already exists", () => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
store.user = "alice"
|
||||||
|
store.repo = "notes"
|
||||||
|
store.files = [{ sha: "x", path: "a.md", type: "blob" }] as never
|
||||||
|
|
||||||
|
store.addFile({ sha: "x", path: "duplicate.md", type: "blob" } as never)
|
||||||
|
|
||||||
|
expect(store.files).toHaveLength(1)
|
||||||
|
expect(vi.mocked(data.update)).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("addFile is a no-op when sha is missing", () => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
store.files = []
|
||||||
|
|
||||||
|
store.addFile({ path: "no-sha.md", type: "blob" } as never)
|
||||||
|
|
||||||
|
expect(store.files).toHaveLength(0)
|
||||||
|
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"
|
||||||
|
store.repo = "notes"
|
||||||
|
|
||||||
|
store.setFontFamily("Inter")
|
||||||
|
|
||||||
|
expect(store.userSettings?.chosenFontFamily).toBe("Inter")
|
||||||
|
const persisted = JSON.parse(
|
||||||
|
localStorage.getItem("remanso:layout:alice:notes") as string
|
||||||
|
)
|
||||||
|
expect(persisted.chosenFontFamily).toBe("Inter")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("setFontSize, setHeadingFont, setBodyFont each persist their respective field", () => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
store.user = "alice"
|
||||||
|
store.repo = "notes"
|
||||||
|
|
||||||
|
store.setFontSize("18px")
|
||||||
|
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.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", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
localStorage.clear()
|
||||||
|
vi.clearAllMocks()
|
||||||
|
vi.mocked(data.get).mockResolvedValue(null as never)
|
||||||
|
vi.mocked(getFiles).mockResolvedValue([])
|
||||||
|
vi.mocked(getMainReadme).mockResolvedValue(null)
|
||||||
|
vi.mocked(getCachedMainReadme).mockResolvedValue(null)
|
||||||
|
vi.mocked(getUserSettingsContent).mockResolvedValue(null)
|
||||||
|
vi.mocked(getRepoPermission).mockResolvedValue(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sets user and repo immediately and clears loadError", async () => {
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
store.loadError = "auth"
|
||||||
|
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
|
||||||
|
expect(store.user).toBe("alice")
|
||||||
|
expect(store.repo).toBe("notes")
|
||||||
|
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
|
||||||
|
])
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.files.map((f) => f.sha)).toEqual(["a"])
|
||||||
|
})
|
||||||
|
|
||||||
|
it("sets readme from getMainReadme on success", async () => {
|
||||||
|
vi.mocked(getMainReadme).mockResolvedValue("<p>hi</p>")
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.readme).toBe("<p>hi</p>")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("classifies 401 errors from getFiles as auth", async () => {
|
||||||
|
vi.mocked(getFiles).mockRejectedValue(
|
||||||
|
Object.assign(new Error("Unauthorized"), { status: 401 })
|
||||||
|
)
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.loadError).toBe("auth")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("classifies TimeoutError as network", async () => {
|
||||||
|
vi.mocked(getFiles).mockRejectedValue(
|
||||||
|
Object.assign(new Error("Timed out"), { name: "TimeoutError" })
|
||||||
|
)
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.loadError).toBe("network")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("classifies 500-range errors as network", async () => {
|
||||||
|
vi.mocked(getFiles).mockRejectedValue(
|
||||||
|
Object.assign(new Error("Server error"), { status: 503 })
|
||||||
|
)
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.loadError).toBe("network")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does NOT surface loadError from getMainReadme when a cached readme is present", async () => {
|
||||||
|
vi.mocked(getCachedMainReadme).mockResolvedValue("<p>cached</p>")
|
||||||
|
vi.mocked(getMainReadme).mockRejectedValue(
|
||||||
|
Object.assign(new Error("Server error"), { status: 503 })
|
||||||
|
)
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.readme).toBe("<p>cached</p>")
|
||||||
|
expect(store.loadError).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("surfaces loadError from getMainReadme when no cached readme is present", async () => {
|
||||||
|
vi.mocked(getCachedMainReadme).mockResolvedValue(null)
|
||||||
|
vi.mocked(getMainReadme).mockRejectedValue(
|
||||||
|
Object.assign(new Error("Unauthorized"), { status: 401 })
|
||||||
|
)
|
||||||
|
vi.spyOn(console, "warn").mockImplementation(() => {})
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
await store.setUserRepo("alice", "notes")
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.readme).toBeNull()
|
||||||
|
expect(store.loadError).toBe("auth")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("ignores stale getFiles results when a newer setUserRepo has been called (race guard)", async () => {
|
||||||
|
let resolveStale: (files: never[]) => void = () => {}
|
||||||
|
vi.mocked(getFiles).mockImplementationOnce(
|
||||||
|
() =>
|
||||||
|
new Promise<never[]>((r) => {
|
||||||
|
resolveStale = r
|
||||||
|
})
|
||||||
|
)
|
||||||
|
vi.mocked(getFiles).mockImplementationOnce(async () => [
|
||||||
|
{ sha: "fresh", path: "fresh.md", type: "blob" } as never
|
||||||
|
])
|
||||||
|
|
||||||
|
const store = useUserRepoStore()
|
||||||
|
|
||||||
|
await store.setUserRepo("alice", "stale-repo")
|
||||||
|
await store.setUserRepo("alice", "fresh-repo")
|
||||||
|
|
||||||
|
resolveStale([{ sha: "stale", path: "stale.md", type: "blob" } as never])
|
||||||
|
await flushAsync()
|
||||||
|
await flushAsync()
|
||||||
|
|
||||||
|
expect(store.repo).toBe("fresh-repo")
|
||||||
|
expect(store.files.map((f) => f.sha)).toEqual(["fresh"])
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -10,10 +10,13 @@ import {
|
|||||||
getCachedMainReadme,
|
getCachedMainReadme,
|
||||||
getFiles,
|
getFiles,
|
||||||
getMainReadme,
|
getMainReadme,
|
||||||
|
getRepoPermission,
|
||||||
getUserSettingsContent
|
getUserSettingsContent
|
||||||
} from "@/modules/repo/services/repo"
|
} from "@/modules/repo/services/repo"
|
||||||
import { refreshToken } from "@/modules/user/service/signIn"
|
import { refreshToken } from "@/modules/user/service/signIn"
|
||||||
|
|
||||||
|
export type RepoLoadError = "auth" | "network" | null
|
||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
user: string
|
user: string
|
||||||
repo: string
|
repo: string
|
||||||
@@ -21,9 +24,21 @@ interface State {
|
|||||||
readme?: string | null
|
readme?: string | null
|
||||||
userSettings?: UserSettings | null
|
userSettings?: UserSettings | null
|
||||||
needToLogin: boolean
|
needToLogin: boolean
|
||||||
|
loadError: RepoLoadError
|
||||||
|
canPush: boolean
|
||||||
_requestId: number
|
_requestId: number
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const classifyLoadError = (error: unknown): "auth" | "network" => {
|
||||||
|
if (error && typeof error === "object") {
|
||||||
|
const e = error as { name?: string; status?: number }
|
||||||
|
if (e.name === "TimeoutError" || e.name === "AbortError") return "network"
|
||||||
|
if (e.status === 401 || e.status === 403 || e.status === 404) return "auth"
|
||||||
|
if (typeof e.status === "number" && e.status >= 500) return "network"
|
||||||
|
}
|
||||||
|
return "network"
|
||||||
|
}
|
||||||
|
|
||||||
export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
||||||
state: (): State => ({
|
state: (): State => ({
|
||||||
user: "",
|
user: "",
|
||||||
@@ -32,6 +47,8 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
readme: undefined,
|
readme: undefined,
|
||||||
userSettings: undefined,
|
userSettings: undefined,
|
||||||
needToLogin: false,
|
needToLogin: false,
|
||||||
|
loadError: null,
|
||||||
|
canPush: false,
|
||||||
_requestId: 0
|
_requestId: 0
|
||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
@@ -39,7 +56,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
if (!this.userSettings) return
|
if (!this.userSettings) return
|
||||||
try {
|
try {
|
||||||
const {
|
const {
|
||||||
chosenTitleFont,
|
chosenHeadingFont,
|
||||||
chosenBodyFont,
|
chosenBodyFont,
|
||||||
chosenFontSize,
|
chosenFontSize,
|
||||||
chosenFontFamily,
|
chosenFontFamily,
|
||||||
@@ -48,7 +65,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
`remanso:layout:${this.user}:${this.repo}`,
|
`remanso:layout:${this.user}:${this.repo}`,
|
||||||
JSON.stringify({
|
JSON.stringify({
|
||||||
chosenTitleFont,
|
chosenHeadingFont,
|
||||||
chosenBodyFont,
|
chosenBodyFont,
|
||||||
chosenFontSize,
|
chosenFontSize,
|
||||||
chosenFontFamily,
|
chosenFontFamily,
|
||||||
@@ -63,11 +80,23 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
const requestId = ++this._requestId
|
const requestId = ++this._requestId
|
||||||
this.user = user
|
this.user = user
|
||||||
this.repo = repo
|
this.repo = repo
|
||||||
|
this.loadError = null
|
||||||
|
this.canPush = false
|
||||||
|
|
||||||
let lsLayout: Partial<UserSettings> = {}
|
let lsLayout: Partial<UserSettings> = {}
|
||||||
try {
|
try {
|
||||||
const lsRaw = localStorage.getItem(`remanso:layout:${user}:${repo}`)
|
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 {
|
} catch {
|
||||||
// ignore
|
// ignore
|
||||||
}
|
}
|
||||||
@@ -105,6 +134,17 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
|
|
||||||
if (requestId !== this._requestId) return
|
if (requestId !== this._requestId) return
|
||||||
|
|
||||||
|
getRepoPermission(user, repo)
|
||||||
|
.then((canPush) => {
|
||||||
|
if (requestId !== this._requestId) return
|
||||||
|
this.canPush = canPush
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (requestId !== this._requestId) return
|
||||||
|
console.warn("getRepoPermission failed", error)
|
||||||
|
this.canPush = false
|
||||||
|
})
|
||||||
|
|
||||||
getFiles(user, repo)
|
getFiles(user, repo)
|
||||||
.then(async (files) => {
|
.then(async (files) => {
|
||||||
if (requestId !== this._requestId) return
|
if (requestId !== this._requestId) return
|
||||||
@@ -127,9 +167,9 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
: userSettings?.fontFamily
|
: userSettings?.fontFamily
|
||||||
const chosenFontSize =
|
const chosenFontSize =
|
||||||
this.userSettings?.chosenFontSize ?? userSettings?.fontSize
|
this.userSettings?.chosenFontSize ?? userSettings?.fontSize
|
||||||
const chosenTitleFont =
|
const chosenHeadingFont =
|
||||||
this.userSettings?.chosenTitleFont ??
|
this.userSettings?.chosenHeadingFont ??
|
||||||
userSettings?.chosenTitleFont ??
|
userSettings?.chosenHeadingFont ??
|
||||||
chosenFontFamily
|
chosenFontFamily
|
||||||
const chosenBodyFont =
|
const chosenBodyFont =
|
||||||
this.userSettings?.chosenBodyFont ??
|
this.userSettings?.chosenBodyFont ??
|
||||||
@@ -145,14 +185,14 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
chosenFontFamily ?? this.userSettings.fontFamily
|
chosenFontFamily ?? this.userSettings.fontFamily
|
||||||
this.userSettings.chosenFontSize =
|
this.userSettings.chosenFontSize =
|
||||||
chosenFontSize ?? this.userSettings.fontSize
|
chosenFontSize ?? this.userSettings.fontSize
|
||||||
this.userSettings.chosenTitleFont = chosenTitleFont
|
this.userSettings.chosenHeadingFont = chosenHeadingFont
|
||||||
this.userSettings.chosenBodyFont = chosenBodyFont
|
this.userSettings.chosenBodyFont = chosenBodyFont
|
||||||
|
|
||||||
this._persistLayout()
|
this._persistLayout()
|
||||||
|
|
||||||
// Persist only repo config fields — chosen* are localStorage-only
|
// Persist only repo config fields — chosen* are localStorage-only
|
||||||
const {
|
const {
|
||||||
chosenTitleFont: _t,
|
chosenHeadingFont: _h,
|
||||||
chosenBodyFont: _b,
|
chosenBodyFont: _b,
|
||||||
chosenFontSize: _s,
|
chosenFontSize: _s,
|
||||||
chosenFontFamily: _f,
|
chosenFontFamily: _f,
|
||||||
@@ -163,14 +203,29 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
_id: userSettingsId
|
_id: userSettingsId
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (requestId !== this._requestId) return
|
||||||
|
console.warn("getFiles failed", error)
|
||||||
|
this.loadError = classifyLoadError(error)
|
||||||
|
})
|
||||||
|
|
||||||
getCachedMainReadme(user, repo).then(async (cachedReadme) => {
|
getCachedMainReadme(user, repo)
|
||||||
if (requestId !== this._requestId) return
|
.then(async (cachedReadme) => {
|
||||||
if (cachedReadme) this.readme = cachedReadme
|
if (requestId !== this._requestId) return
|
||||||
const fetched = await getMainReadme(user, repo)
|
if (cachedReadme) this.readme = cachedReadme
|
||||||
if (requestId !== this._requestId) return
|
const fetched = await getMainReadme(user, repo)
|
||||||
this.readme = fetched
|
if (requestId !== this._requestId) return
|
||||||
})
|
this.readme = fetched
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
if (requestId !== this._requestId) return
|
||||||
|
console.warn("getMainReadme failed", error)
|
||||||
|
// Only surface the error UI if we have nothing cached to display.
|
||||||
|
if (!this.readme) {
|
||||||
|
this.readme = null
|
||||||
|
this.loadError = classifyLoadError(error)
|
||||||
|
}
|
||||||
|
})
|
||||||
},
|
},
|
||||||
addFile(file: RepoFile) {
|
addFile(file: RepoFile) {
|
||||||
if (!file.sha) {
|
if (!file.sha) {
|
||||||
@@ -188,7 +243,31 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
`${this.user}-${this.repo}`
|
`${this.user}-${this.repo}`
|
||||||
)
|
)
|
||||||
const newFiles = [
|
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>({
|
||||||
|
_id: savedRepoId,
|
||||||
|
$type: DataType.SavedRepo,
|
||||||
|
repo: this.repo,
|
||||||
|
user: this.user,
|
||||||
|
files: newFiles
|
||||||
|
})
|
||||||
|
this.files = newFiles
|
||||||
|
},
|
||||||
|
registerUploadedFile(file: RepoFile) {
|
||||||
|
if (!file.path) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedRepoId = generateId(
|
||||||
|
DataType.SavedRepo,
|
||||||
|
`${this.user}-${this.repo}`
|
||||||
|
)
|
||||||
|
const newFiles = [
|
||||||
|
...toRaw(this.files).filter((f) => f.path !== file.path),
|
||||||
toRaw(file)
|
toRaw(file)
|
||||||
]
|
]
|
||||||
data.update<DataType.SavedRepo, SavedRepo>({
|
data.update<DataType.SavedRepo, SavedRepo>({
|
||||||
@@ -209,6 +288,7 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
resetFiles() {
|
resetFiles() {
|
||||||
this.files = []
|
this.files = []
|
||||||
this.readme = null
|
this.readme = null
|
||||||
|
this.canPush = false
|
||||||
},
|
},
|
||||||
setFontFamily(fontFamily: string) {
|
setFontFamily(fontFamily: string) {
|
||||||
if (!this.userSettings) {
|
if (!this.userSettings) {
|
||||||
@@ -224,11 +304,11 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
this.userSettings.chosenFontSize = fontSize
|
this.userSettings.chosenFontSize = fontSize
|
||||||
this._persistLayout()
|
this._persistLayout()
|
||||||
},
|
},
|
||||||
setTitleFont(font: string) {
|
setHeadingFont(font: string) {
|
||||||
if (!this.userSettings) {
|
if (!this.userSettings) {
|
||||||
this.userSettings = { $type: DataType.UserSettings }
|
this.userSettings = { $type: DataType.UserSettings }
|
||||||
}
|
}
|
||||||
this.userSettings.chosenTitleFont = font
|
this.userSettings.chosenHeadingFont = font
|
||||||
this._persistLayout()
|
this._persistLayout()
|
||||||
},
|
},
|
||||||
setBodyFont(font: string) {
|
setBodyFont(font: string) {
|
||||||
@@ -237,6 +317,13 @@ export const useUserRepoStore = defineStore("USER_REPO_STATE", {
|
|||||||
}
|
}
|
||||||
this.userSettings.chosenBodyFont = font
|
this.userSettings.chosenBodyFont = font
|
||||||
this._persistLayout()
|
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 fontSize = store.userSettings?.chosenFontSize
|
||||||
const bodyFont = store.userSettings?.chosenBodyFont
|
const bodyFont = store.userSettings?.chosenBodyFont
|
||||||
const titleFont = store.userSettings?.chosenTitleFont
|
const headingFont = store.userSettings?.chosenHeadingFont
|
||||||
|
|
||||||
downloadFont(bodyFont || DEFAULT_FONT_POLICY, "--font-family")
|
downloadFont(bodyFont || DEFAULT_FONT_POLICY, "--font-family")
|
||||||
downloadFont(
|
downloadFont(
|
||||||
titleFont || bodyFont || DEFAULT_FONT_POLICY,
|
headingFont || bodyFont || DEFAULT_FONT_POLICY,
|
||||||
"--title-font-family"
|
"--heading-font-family"
|
||||||
)
|
)
|
||||||
root.style.setProperty("--font-size", fontSize || DEFAULT_FONT_SIZE)
|
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()
|
||||||
|
})
|
||||||
|
})
|
||||||
7
src/modules/user/service/oauthReturnPath.ts
Normal file
7
src/modules/user/service/oauthReturnPath.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
export const GITHUB_OAUTH_RETURN_PATH_KEY = "github-oauth-return-path"
|
||||||
|
|
||||||
|
export const consumeGithubOAuthReturnPath = (): string | null => {
|
||||||
|
const path = sessionStorage.getItem(GITHUB_OAUTH_RETURN_PATH_KEY)
|
||||||
|
sessionStorage.removeItem(GITHUB_OAUTH_RETURN_PATH_KEY)
|
||||||
|
return path
|
||||||
|
}
|
||||||
@@ -38,7 +38,13 @@ export const needToRefreshToken = async () => {
|
|||||||
return isBefore(expirationDate, minimumViableDate)
|
return isBefore(expirationDate, minimumViableDate)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const refreshToken = async () => {
|
let inFlightRefresh: Promise<GithubAccessToken | null> | null = null
|
||||||
|
|
||||||
|
const performRefresh = async ({
|
||||||
|
force
|
||||||
|
}: {
|
||||||
|
force: boolean
|
||||||
|
}): Promise<GithubAccessToken | null> => {
|
||||||
const accessToken = await data.get<
|
const accessToken = await data.get<
|
||||||
DataType.GithubAccessToken,
|
DataType.GithubAccessToken,
|
||||||
GithubAccessToken
|
GithubAccessToken
|
||||||
@@ -48,7 +54,7 @@ export const refreshToken = async () => {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
const needRefresh = await needToRefreshToken()
|
const needRefresh = force || (await needToRefreshToken())
|
||||||
|
|
||||||
if (needRefresh) {
|
if (needRefresh) {
|
||||||
const authenticationServerURL = new URL(AUTHENTICATION_SERVER)
|
const authenticationServerURL = new URL(AUTHENTICATION_SERVER)
|
||||||
@@ -70,6 +76,18 @@ export const refreshToken = async () => {
|
|||||||
return accessToken
|
return accessToken
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GitHub refresh tokens are single-use, so concurrent refreshes race and the
|
||||||
|
// loser ends up with a revoked refresh token. Dedupe in-flight calls.
|
||||||
|
export const refreshToken = async ({
|
||||||
|
force = false
|
||||||
|
}: { force?: boolean } = {}): Promise<GithubAccessToken | null> => {
|
||||||
|
if (inFlightRefresh) return inFlightRefresh
|
||||||
|
inFlightRefresh = performRefresh({ force }).finally(() => {
|
||||||
|
inFlightRefresh = null
|
||||||
|
})
|
||||||
|
return inFlightRefresh
|
||||||
|
}
|
||||||
|
|
||||||
export const getAccessToken = async () => {
|
export const getAccessToken = async () => {
|
||||||
const response = await data.get<
|
const response = await data.get<
|
||||||
DataType.GithubAccessToken,
|
DataType.GithubAccessToken,
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
:root {
|
:root {
|
||||||
--primary-color: #ffa4c0;
|
--primary-color: #ffa4c0;
|
||||||
--font-family: "Libertinus Serif", serif;
|
--font-family: "Libertinus Serif", serif;
|
||||||
--title-font-family: "Libertinus Serif", serif;
|
--heading-font-family: "Libertinus Serif", serif;
|
||||||
--font-size: 13pt;
|
--font-size: 13pt;
|
||||||
--font-color: #4a4a4a;
|
--font-color: #4a4a4a;
|
||||||
--link: #445fb9;
|
--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 {
|
pre.mermaid {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -202,19 +246,83 @@ pre.tikz svg {
|
|||||||
height: auto;
|
height: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tikz-loading {
|
/* TikZ SVGs are monochrome black; invert them on dark themes so they
|
||||||
|
remain readable on the transparent background. */
|
||||||
|
[data-theme="dracula"] pre.tikz svg {
|
||||||
|
filter: invert(1) hue-rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root:not([data-theme="light"]) pre.tikz svg {
|
||||||
|
filter: invert(1) hue-rotate(180deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tikz-loading,
|
||||||
|
.macroplan-loading {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tikz-error {
|
/* 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;
|
||||||
|
max-width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-download-buttons {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.25rem;
|
||||||
|
right: 0.25rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 150ms ease-in-out;
|
||||||
|
pointer-events: none;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-download-host:hover .svg-download-buttons,
|
||||||
|
.svg-download-host:focus-within .svg-download-buttons {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-download-button {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
padding-inline: 0.4rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
min-height: 1.5rem;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.svg-download-button .icon-tabler-download {
|
||||||
|
width: 0.9rem;
|
||||||
|
height: 0.9rem;
|
||||||
|
max-width: none;
|
||||||
|
filter: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tikz-error,
|
||||||
|
.macroplan-error {
|
||||||
border-left: 3px solid var(--color-error);
|
border-left: 3px solid var(--color-error);
|
||||||
padding: 0.5rem 0.75rem;
|
padding: 0.5rem 0.75rem;
|
||||||
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
background: color-mix(in srgb, var(--color-error) 10%, transparent);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tikz-error pre {
|
.tikz-error pre,
|
||||||
|
.macroplan-error pre {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
font-size: 0.85em;
|
font-size: 0.85em;
|
||||||
}
|
}
|
||||||
@@ -279,15 +387,6 @@ iframe {
|
|||||||
height: 400px;
|
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"] *)) {
|
.tabs :where(pre):not(:where([class~="not-prose"], [class~="not-prose"] *)) {
|
||||||
margin-top: 0;
|
margin-top: 0;
|
||||||
margin-bottom: 0;
|
margin-bottom: 0;
|
||||||
@@ -296,3 +395,72 @@ iframe {
|
|||||||
padding-bottom: 0.2rem;
|
padding-bottom: 0.2rem;
|
||||||
padding-inline-start: 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
8
src/test/setup.ts
Normal file
8
src/test/setup.ts
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
import { config } from "@vue/test-utils"
|
||||||
|
|
||||||
|
config.global.stubs = {
|
||||||
|
"router-link": true,
|
||||||
|
"router-view": true,
|
||||||
|
transition: false,
|
||||||
|
"transition-group": false
|
||||||
|
}
|
||||||
25
src/utils/decodeBase64ToUTF8.spec.ts
Normal file
25
src/utils/decodeBase64ToUTF8.spec.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { decodeBase64ToUTF8, encodeUTF8ToBase64 } from "./decodeBase64ToUTF8"
|
||||||
|
|
||||||
|
describe("base64 ↔ UTF-8 round-trip", () => {
|
||||||
|
it.each([
|
||||||
|
["ASCII", "Hello, world!"],
|
||||||
|
["multi-byte UTF-8", "Café résumé naïve"],
|
||||||
|
["CJK characters", "こんにちは世界"],
|
||||||
|
["emoji", "👋 🌍 🎉"],
|
||||||
|
["empty string", ""],
|
||||||
|
["newlines and whitespace", "line1\nline2\tend"],
|
||||||
|
["markdown content", "# Title\n\n- [[link]]\n- **bold**"]
|
||||||
|
])("round-trips %s", (_label, input) => {
|
||||||
|
expect(decodeBase64ToUTF8(encodeUTF8ToBase64(input))).toBe(input)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("encodes ASCII to standard base64", () => {
|
||||||
|
expect(encodeUTF8ToBase64("hi")).toBe("aGk=")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("decodes standard base64 to ASCII", () => {
|
||||||
|
expect(decodeBase64ToUTF8("aGk=")).toBe("hi")
|
||||||
|
})
|
||||||
|
})
|
||||||
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
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
58
src/utils/fileLanguage.spec.ts
Normal file
58
src/utils/fileLanguage.spec.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { getFileLanguage, isMarkdownPath } from "./fileLanguage"
|
||||||
|
|
||||||
|
describe("isMarkdownPath", () => {
|
||||||
|
it.each(["note.md", "dir/note.md", "note.mdx", "DIR/NOTE.MD"])(
|
||||||
|
"returns true for %s",
|
||||||
|
(path) => {
|
||||||
|
expect(isMarkdownPath(path)).toBe(true)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it.each(["note.txt", "script.ts", "no-extension", "", "image.png"])(
|
||||||
|
"returns false for %s",
|
||||||
|
(path) => {
|
||||||
|
expect(isMarkdownPath(path)).toBe(false)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("getFileLanguage", () => {
|
||||||
|
it.each([
|
||||||
|
["sh", "bash"],
|
||||||
|
["bash", "bash"],
|
||||||
|
["js", "javascript"],
|
||||||
|
["mjs", "javascript"],
|
||||||
|
["cjs", "javascript"],
|
||||||
|
["ts", "typescript"],
|
||||||
|
["mts", "typescript"],
|
||||||
|
["md", "markdown"],
|
||||||
|
["mdx", "markdown"],
|
||||||
|
["html", "html"],
|
||||||
|
["htm", "html"],
|
||||||
|
["css", "css"],
|
||||||
|
["scss", "css"],
|
||||||
|
["json", "json"],
|
||||||
|
["jsonc", "json"],
|
||||||
|
["als", "alloy"]
|
||||||
|
])("maps .%s to %s", (ext, lang) => {
|
||||||
|
expect(getFileLanguage(`file.${ext}`)).toBe(lang)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("matches case-insensitively", () => {
|
||||||
|
expect(getFileLanguage("File.TS")).toBe("typescript")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null for unknown extensions", () => {
|
||||||
|
expect(getFileLanguage("file.xyz")).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null for files without an extension", () => {
|
||||||
|
expect(getFileLanguage("Makefile")).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns null for empty input", () => {
|
||||||
|
expect(getFileLanguage("")).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -15,6 +15,7 @@ const EXT_TO_LANG: Record<string, string> = {
|
|||||||
scss: "css",
|
scss: "css",
|
||||||
json: "json",
|
json: "json",
|
||||||
jsonc: "json",
|
jsonc: "json",
|
||||||
|
rs: "rust",
|
||||||
als: "alloy"
|
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)
|
||||||
|
})
|
||||||
|
})
|
||||||
54
src/utils/markdown/markdown-html5-media.spec.ts
Normal file
54
src/utils/markdown/markdown-html5-media.spec.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import MarkdownIt from "markdown-it"
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { html5Media } from "./markdown-html5-media"
|
||||||
|
|
||||||
|
const renderer = () => MarkdownIt().use(html5Media)
|
||||||
|
|
||||||
|
describe("html5Media plugin", () => {
|
||||||
|
it("renders <video> for .mp4 links using image syntax", () => {
|
||||||
|
const html = renderer().render("")
|
||||||
|
expect(html).toContain('<video src="movie.mp4"')
|
||||||
|
expect(html).toContain("</video>")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders <audio> for .mp3 links using image syntax", () => {
|
||||||
|
const html = renderer().render("")
|
||||||
|
expect(html).toContain('<audio src="song.mp3"')
|
||||||
|
expect(html).toContain("</audio>")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders <img> for unrecognized extensions", () => {
|
||||||
|
const html = renderer().render("")
|
||||||
|
expect(html).toContain('<img src="pic.png"')
|
||||||
|
expect(html).not.toContain("<video")
|
||||||
|
expect(html).not.toContain("<audio")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("recognizes all listed video extensions", () => {
|
||||||
|
for (const ext of ["mp4", "m4v", "ogv", "webm", "mpg", "mpeg"]) {
|
||||||
|
expect(renderer().render(``)).toContain(
|
||||||
|
`<video src="clip.${ext}"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("recognizes all listed audio extensions", () => {
|
||||||
|
for (const ext of ["aac", "m4a", "mp3", "oga", "ogg", "wav"]) {
|
||||||
|
expect(renderer().render(``)).toContain(
|
||||||
|
`<audio src="sound.${ext}"`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it("includes a title attribute when provided in image syntax", () => {
|
||||||
|
const html = renderer().render('')
|
||||||
|
expect(html).toContain('title="My title"')
|
||||||
|
})
|
||||||
|
|
||||||
|
it("matches extensions case-insensitively", () => {
|
||||||
|
expect(renderer().render("")).toContain(
|
||||||
|
'<video src="CLIP.MP4"'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
42
src/utils/markdown/markdown-it-regexp.spec.ts
Normal file
42
src/utils/markdown/markdown-it-regexp.spec.ts
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
import MarkdownIt from "markdown-it"
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { markdownItPlugin } from "./markdown-it-regexp"
|
||||||
|
|
||||||
|
describe("markdownItPlugin", () => {
|
||||||
|
it("calls the replacer when the pattern matches at start of inline content", () => {
|
||||||
|
const plugin = markdownItPlugin(/@(\w+)/, (match) => {
|
||||||
|
return `<span class="mention">${match[1]}</span>`
|
||||||
|
})
|
||||||
|
|
||||||
|
const md = MarkdownIt().use(plugin)
|
||||||
|
|
||||||
|
expect(md.render("@alice posted a note")).toContain(
|
||||||
|
'<span class="mention">alice</span>'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("leaves non-matching text untouched", () => {
|
||||||
|
const plugin = markdownItPlugin(/@(\w+)/, (match) => `MATCH:${match[1]}`)
|
||||||
|
const md = MarkdownIt().use(plugin)
|
||||||
|
|
||||||
|
expect(md.render("no mention here")).toBe("<p>no mention here</p>\n")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("supports case-insensitive matching when the input regex has the i flag", () => {
|
||||||
|
const plugin = markdownItPlugin(/@hello/i, () => "<b>hi</b>")
|
||||||
|
const md = MarkdownIt().use(plugin)
|
||||||
|
|
||||||
|
expect(md.render("@HELLO world")).toContain("<b>hi</b>")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("each plugin instance gets a unique rule id (no collisions)", () => {
|
||||||
|
const pluginA = markdownItPlugin(/@a/, () => "RULE_A")
|
||||||
|
const pluginB = markdownItPlugin(/@b/, () => "RULE_B")
|
||||||
|
const md = MarkdownIt().use(pluginA).use(pluginB)
|
||||||
|
|
||||||
|
const result = md.render("@a and @b")
|
||||||
|
expect(result).toContain("RULE_A")
|
||||||
|
expect(result).toContain("RULE_B")
|
||||||
|
})
|
||||||
|
})
|
||||||
31
src/utils/markdown/markdown-it-tabler-icons.spec.ts
Normal file
31
src/utils/markdown/markdown-it-tabler-icons.spec.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import MarkdownIt from "markdown-it"
|
||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import { markdownItTablerIcons } from "./markdown-it-tabler-icons"
|
||||||
|
|
||||||
|
describe("markdownItTablerIcons", () => {
|
||||||
|
const renderer = () => MarkdownIt().use(markdownItTablerIcons)
|
||||||
|
|
||||||
|
it("renders a tabler icon for :icon-name: syntax", () => {
|
||||||
|
expect(renderer().render(":home:")).toContain(
|
||||||
|
'<i class="ti ti-home"></i>'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("supports hyphenated icon names", () => {
|
||||||
|
expect(renderer().render(":arrow-right:")).toContain(
|
||||||
|
'<i class="ti ti-arrow-right"></i>'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("renders icons inline alongside surrounding text", () => {
|
||||||
|
const html = renderer().render("Click :check: to confirm")
|
||||||
|
expect(html).toContain("Click")
|
||||||
|
expect(html).toContain('<i class="ti ti-check"></i>')
|
||||||
|
expect(html).toContain("to confirm")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("does not fire on text without colon delimiters", () => {
|
||||||
|
expect(renderer().render("home")).toBe("<p>home</p>\n")
|
||||||
|
})
|
||||||
|
})
|
||||||
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>`
|
||||||
59
src/utils/noteTitle.spec.ts
Normal file
59
src/utils/noteTitle.spec.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, expect, it } from "vitest"
|
||||||
|
|
||||||
|
import {
|
||||||
|
filenameToNoteTitle,
|
||||||
|
pathToNotePathTitle,
|
||||||
|
pathToNoteTitle
|
||||||
|
} from "./noteTitle"
|
||||||
|
|
||||||
|
describe("filenameToNoteTitle", () => {
|
||||||
|
it("replaces hyphens with spaces", () => {
|
||||||
|
expect(filenameToNoteTitle("my-cool-note")).toBe("my cool note")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("wraps slashes with spaces", () => {
|
||||||
|
expect(filenameToNoteTitle("dir/sub/file")).toBe("dir / sub / file")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty input unchanged", () => {
|
||||||
|
expect(filenameToNoteTitle("")).toBe("")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pathToNotePathTitle", () => {
|
||||||
|
it("strips the file extension", () => {
|
||||||
|
expect(pathToNotePathTitle("note.md")).toBe("note")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("filters out README segments", () => {
|
||||||
|
expect(pathToNotePathTitle("folder/README.md")).toBe("folder")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("preserves multi-level paths and replaces hyphens", () => {
|
||||||
|
expect(pathToNotePathTitle("dir/my-sub-dir/my-note.md")).toBe(
|
||||||
|
"dir/my sub dir/my note"
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it("handles paths with multiple dots correctly", () => {
|
||||||
|
expect(pathToNotePathTitle("a/b.c.md")).toBe("a/b.c")
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
describe("pathToNoteTitle", () => {
|
||||||
|
it("returns the last segment of a multi-level path", () => {
|
||||||
|
expect(pathToNoteTitle("dir/sub/my-note.md")).toBe("my note")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the title for a root-level file", () => {
|
||||||
|
expect(pathToNoteTitle("my-note.md")).toBe("my note")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns empty string for README files at root", () => {
|
||||||
|
expect(pathToNoteTitle("README.md")).toBe("")
|
||||||
|
})
|
||||||
|
|
||||||
|
it("returns the parent dir name when the file is a README", () => {
|
||||||
|
expect(pathToNoteTitle("folder/README.md")).toBe("folder")
|
||||||
|
})
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user