Add documentation for type documents, the Type relationship, and the UI behavior changes (pinned type docs in section groups, Instances group in entity view, clickable Type chip in Inspector). Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
268 lines
10 KiB
Markdown
268 lines
10 KiB
Markdown
# Abstractions
|
|
|
|
Key abstractions and domain models in Laputa.
|
|
|
|
## Document Model
|
|
|
|
All data lives in markdown files with YAML frontmatter. There is no database — the filesystem is the source of truth.
|
|
|
|
### VaultEntry
|
|
|
|
The core data type representing a single note, defined identically in Rust (`src-tauri/src/vault.rs`) and TypeScript (`src/types.ts`):
|
|
|
|
```typescript
|
|
// src/types.ts
|
|
interface VaultEntry {
|
|
path: string // Absolute file path: /Users/luca/Laputa/project/my-project.md
|
|
filename: string // Just the filename: my-project.md
|
|
title: string // Extracted from first # heading, or filename as fallback
|
|
isA: string | null // Entity type: Project, Procedure, Person, etc.
|
|
aliases: string[] // Alternative names for wikilink resolution
|
|
belongsTo: string[] // Parent relationships (wikilinks)
|
|
relatedTo: string[] // Related entity links (wikilinks)
|
|
status: string | null // Active, Done, Paused, Archived, Dropped
|
|
owner: string | null // Person responsible
|
|
cadence: string | null // Update frequency: Weekly, Monthly, etc.
|
|
modifiedAt: number | null // Unix timestamp (seconds)
|
|
createdAt: number | null // Unix timestamp (seconds)
|
|
fileSize: number
|
|
}
|
|
```
|
|
|
|
### Entity Types (isA)
|
|
|
|
Entity type is inferred from the folder structure. The vault is organized by type:
|
|
|
|
```
|
|
~/Laputa/
|
|
├── type/ → "Type" ← type definition documents
|
|
├── project/ → "Project"
|
|
├── responsibility/ → "Responsibility"
|
|
├── procedure/ → "Procedure"
|
|
├── experiment/ → "Experiment"
|
|
├── person/ → "Person"
|
|
├── event/ → "Event"
|
|
├── topic/ → "Topic"
|
|
├── note/ → "Note"
|
|
├── quarter/ → "Quarter"
|
|
├── journal/ → "Journal"
|
|
├── essay/ → "Essay"
|
|
└── evergreen/ → "Evergreen"
|
|
```
|
|
|
|
Mapping logic lives in `vault.rs:parse_md_file()`. If a folder doesn't match any known type, the folder name is capitalized and used as-is.
|
|
|
|
### Types as Files
|
|
|
|
Each entity type can have a corresponding **type document** in the `type/` folder (e.g., `type/project.md`, `type/person.md`). Type documents:
|
|
|
|
- Have `Is A: Type` in their frontmatter
|
|
- Describe what the type means, its expected properties, and how it relates to other types
|
|
- Are navigable entities — they appear in the sidebar under "Types" and can be opened/edited like any other note
|
|
- Serve as the "definition" for their type category
|
|
|
|
**Type relationship**: When any entry has an `isA` value (e.g., "Project"), the Rust backend automatically adds a `"Type"` entry to its `relationships` map pointing to `[[type/project]]`. This makes the type navigable from the Inspector panel.
|
|
|
|
**UI behavior**:
|
|
- Clicking a section group header (e.g., "Projects") pins the type document at the top of the NoteList if it exists, with instances listed below
|
|
- Viewing a type document in entity view shows an "Instances" group listing all entries of that type
|
|
- The Type field in the Inspector properties panel is rendered as a clickable chip that navigates to the type document
|
|
|
|
### Frontmatter Format
|
|
|
|
Standard YAML frontmatter between `---` delimiters:
|
|
|
|
```yaml
|
|
---
|
|
title: Write Weekly Essays
|
|
is_a: Procedure
|
|
status: Active
|
|
owner: Luca Rossi
|
|
cadence: Weekly
|
|
belongs_to:
|
|
- "[[responsibility/grow-newsletter]]"
|
|
related_to:
|
|
- "[[topic/writing]]"
|
|
aliases:
|
|
- Weekly Writing
|
|
---
|
|
```
|
|
|
|
Supported value types (defined in `src-tauri/src/frontmatter.rs` as `FrontmatterValue`):
|
|
- **String**: `status: Active`
|
|
- **Number**: `priority: 5`
|
|
- **Bool**: `archived: true`
|
|
- **List**: Multi-line ` - item` or inline `[item1, item2]`
|
|
- **Null**: `owner:` (empty value)
|
|
|
|
### Title Extraction
|
|
|
|
Title comes from the first `# Heading` in the markdown body. If none is found, the filename (without `.md`) is used as fallback. This logic lives in `vault.rs:extract_title()`.
|
|
|
|
### Sidebar Selection
|
|
|
|
Navigation state is modeled as a discriminated union:
|
|
|
|
```typescript
|
|
type SidebarSelection =
|
|
| { kind: 'filter'; filter: 'all' | 'favorites' }
|
|
| { kind: 'sectionGroup'; type: string } // e.g. type: 'Project'
|
|
| { kind: 'entity'; entry: VaultEntry } // specific entity selected
|
|
| { kind: 'topic'; entry: VaultEntry } // topic selected
|
|
```
|
|
|
|
## File System Integration
|
|
|
|
### Vault Scanning (Rust)
|
|
|
|
`vault::scan_vault(path)` in `src-tauri/src/vault.rs`:
|
|
|
|
1. Validates the path exists and is a directory
|
|
2. Uses `walkdir` to recursively traverse the directory (follows symlinks)
|
|
3. Filters to `.md` files only
|
|
4. For each file, calls `parse_md_file()`:
|
|
- Reads file content with `fs::read_to_string()`
|
|
- Parses frontmatter with `gray_matter::Matter::<YAML>`
|
|
- Extracts title from first `#` heading
|
|
- Infers entity type from parent folder name
|
|
- Parses dates (`created_at`, `created_time`) as ISO 8601 to Unix timestamps
|
|
- Collects file metadata (size, modification time)
|
|
5. Sorts results by `modified_at` descending (newest first)
|
|
6. Skips unparseable files with a warning log
|
|
|
|
### Frontmatter Manipulation (Rust)
|
|
|
|
`frontmatter::update_frontmatter_content()` in `src-tauri/src/frontmatter.rs` performs line-by-line YAML editing:
|
|
|
|
1. Finds the frontmatter block between `---` delimiters
|
|
2. Iterates through lines looking for the target key (handles quoted keys like `"Is A"`)
|
|
3. If found: replaces the value (consuming multi-line list items if present)
|
|
4. If not found: appends the new key-value at the end of the frontmatter
|
|
5. If no frontmatter exists: creates a new `---` block with the key-value
|
|
|
|
The `with_frontmatter()` helper wraps this in a read-transform-write cycle on the actual file.
|
|
|
|
### Content Loading
|
|
|
|
- **Tauri mode**: Content is loaded on-demand when a tab is opened via `invoke('get_note_content', { path })`
|
|
- **Browser mode**: All content is loaded at startup from `MOCK_CONTENT` in `mock-tauri.ts`
|
|
- Content for backlink detection (`allContent`) is stored in memory as `Record<string, string>`
|
|
|
|
## Git Integration
|
|
|
|
Git operations live in `src-tauri/src/git.rs`. All operations shell out to the `git` CLI (not libgit2).
|
|
|
|
### Data Types
|
|
|
|
```typescript
|
|
interface GitCommit {
|
|
hash: string // Full SHA-1
|
|
shortHash: string // First 7 chars
|
|
message: string
|
|
author: string
|
|
date: number // Unix timestamp
|
|
}
|
|
|
|
interface ModifiedFile {
|
|
path: string // Absolute path
|
|
relativePath: string // Relative to vault root
|
|
status: 'modified' | 'added' | 'deleted' | 'untracked' | 'renamed'
|
|
}
|
|
```
|
|
|
|
### Operations
|
|
|
|
| Operation | Git command | Notes |
|
|
|-----------|------------|-------|
|
|
| File history | `git log --format=%H\|%h\|%an\|%aI\|%s -n 20 -- <file>` | Last 20 commits for a file |
|
|
| Modified files | `git status --porcelain` | Filtered to `.md` files only |
|
|
| File diff | `git diff -- <file>`, fallback to `--cached`, then synthetic diff for untracked | Unified diff format |
|
|
| Commit | `git add -A && git commit -m "<message>"` | Stages all changes |
|
|
| Push | `git push` | Pushes to upstream of current branch |
|
|
|
|
### Frontend Integration
|
|
|
|
- **Modified file badges**: Loaded at startup, shown in sidebar and breadcrumb bar
|
|
- **Diff view**: Loaded on-demand when user clicks the diff toggle in the breadcrumb bar
|
|
- **Git history**: Loaded when active tab changes, shown in Inspector panel
|
|
- **Commit dialog**: Triggered from sidebar, runs commit + push
|
|
|
|
## BlockNote Customization
|
|
|
|
The editor uses [BlockNote](https://www.blocknotejs.org/) (not CodeMirror 6) for rich text editing.
|
|
|
|
### Custom Wikilink Inline Content
|
|
|
|
Defined in `src/components/Editor.tsx`:
|
|
|
|
```typescript
|
|
const WikiLink = createReactInlineContentSpec(
|
|
{
|
|
type: "wikilink",
|
|
propSchema: { target: { default: "" } },
|
|
content: "none",
|
|
},
|
|
{
|
|
render: (props) => (
|
|
<span className="wikilink" data-target={props.inlineContent.props.target}>
|
|
{props.inlineContent.props.target}
|
|
</span>
|
|
),
|
|
}
|
|
)
|
|
|
|
const schema = BlockNoteSchema.create({
|
|
inlineContentSpecs: {
|
|
...defaultInlineContentSpecs,
|
|
wikilink: WikiLink,
|
|
},
|
|
})
|
|
```
|
|
|
|
### Markdown-to-BlockNote Pipeline
|
|
|
|
Since BlockNote doesn't natively understand `[[wikilinks]]`, content goes through a preprocessing pipeline in `src/utils/wikilinks.ts`:
|
|
|
|
```
|
|
Raw markdown
|
|
→ splitFrontmatter() → [yaml, body]
|
|
→ preProcessWikilinks(body) → replaces [[target]] with Unicode placeholder tokens
|
|
→ editor.tryParseMarkdownToBlocks() → BlockNote block tree
|
|
→ injectWikilinks(blocks) → walks tree, replaces placeholder text with wikilink inline content nodes
|
|
→ editor.replaceBlocks()
|
|
```
|
|
|
|
Placeholder tokens use `\u2039` (single left-pointing angle quotation mark) and `\u203A` (single right-pointing) to avoid colliding with markdown syntax.
|
|
|
|
### Wikilink Navigation
|
|
|
|
Two navigation mechanisms:
|
|
|
|
1. **Click handler**: A DOM event listener on `.editor__blocknote-container` catches clicks on `.wikilink` elements and calls `onNavigateWikilink(target)`.
|
|
|
|
2. **Suggestion menu**: Typing `[[` triggers BlockNote's `SuggestionMenuController`, which shows a filtered list of all vault entries. Selecting one inserts a wikilink inline content node.
|
|
|
|
Wikilink resolution in `useNoteActions.handleNavigateWikilink()` uses fuzzy matching:
|
|
- Exact title match
|
|
- Alias match
|
|
- Path stem match (e.g., `person/matteo-cellini`)
|
|
- Filename stem match
|
|
- Slug-to-words match (e.g., `matteo-cellini` → `matteo cellini`)
|
|
|
|
## Theme System
|
|
|
|
See [THEMING.md](./THEMING.md) for the full theme system documentation.
|
|
|
|
In brief: `src/theme.json` defines editor typography and styling as nested JSON. The `useEditorTheme` hook flattens it into CSS custom properties that are applied as inline styles on the BlockNote container.
|
|
|
|
## Inspector Abstraction
|
|
|
|
The Inspector panel (`src/components/Inspector.tsx`) is composed of four sub-panels:
|
|
|
|
1. **DynamicPropertiesPanel** (`src/components/DynamicPropertiesPanel.tsx`): Renders frontmatter as editable key-value pairs. Uses `EditableValue` for inline editing.
|
|
2. **Relationships**: Shows `belongs_to` and `related_to` wikilinks as clickable chips.
|
|
3. **Backlinks**: Scans `allContent` for notes that reference the current note via `[[title]]` or `[[path]]`.
|
|
4. **Git History**: Shows the last few commits from `gitHistory` state.
|
|
|
|
Frontmatter parsing on the TypeScript side is handled by `src/utils/frontmatter.ts:parseFrontmatter()`, a lightweight YAML parser that handles strings, booleans, inline arrays, and multi-line lists.
|