feat: publish HoloLake model-native living system source
This commit is contained in:
parent
6ad10edde1
commit
c395dd3a99
2467 changed files with 615073 additions and 0 deletions
|
|
@ -0,0 +1,41 @@
|
|||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { isTauri } from './index'
|
||||
|
||||
const originalIsTauri = (globalThis as { isTauri?: unknown }).isTauri
|
||||
const windowWithLegacyMarkers = window as Window & {
|
||||
__TAURI__?: unknown
|
||||
__TAURI_INTERNALS__?: unknown
|
||||
}
|
||||
|
||||
describe('isTauri', () => {
|
||||
afterEach(() => {
|
||||
if (originalIsTauri === undefined) {
|
||||
delete (globalThis as { isTauri?: unknown }).isTauri
|
||||
} else {
|
||||
;(globalThis as { isTauri?: unknown }).isTauri = originalIsTauri
|
||||
}
|
||||
|
||||
delete windowWithLegacyMarkers.__TAURI__
|
||||
delete windowWithLegacyMarkers.__TAURI_INTERNALS__
|
||||
})
|
||||
|
||||
it('prefers the Tauri v2 global runtime flag', () => {
|
||||
;(globalThis as { isTauri?: unknown }).isTauri = true
|
||||
|
||||
expect(isTauri()).toBe(true)
|
||||
})
|
||||
|
||||
it('respects an explicit false runtime flag', () => {
|
||||
;(globalThis as { isTauri?: unknown }).isTauri = false
|
||||
windowWithLegacyMarkers.__TAURI__ = {}
|
||||
|
||||
expect(isTauri()).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the legacy window markers when the runtime flag is absent', () => {
|
||||
delete (globalThis as { isTauri?: unknown }).isTauri
|
||||
windowWithLegacyMarkers.__TAURI_INTERNALS__ = {}
|
||||
|
||||
expect(isTauri()).toBe(true)
|
||||
})
|
||||
})
|
||||
46
product-source/hololake-platform/src/mock-tauri/index.ts
Normal file
46
product-source/hololake-platform/src/mock-tauri/index.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
/**
|
||||
* Mock Tauri invoke for browser testing.
|
||||
* When running outside Tauri (e.g. in Chrome via localhost:5173),
|
||||
* this provides realistic test data so the UI can be verified visually.
|
||||
*/
|
||||
|
||||
import { MOCK_CONTENT } from './mock-content'
|
||||
import { mockHandlers, addMockEntry, updateMockContent, trackMockChange } from './mock-handlers'
|
||||
import { tryVaultApi } from './vault-api'
|
||||
|
||||
export { addMockEntry, updateMockContent, trackMockChange }
|
||||
|
||||
type MockHandler = (args: Record<string, unknown> | undefined) => unknown
|
||||
|
||||
export function isTauri(): boolean {
|
||||
if (typeof globalThis !== 'undefined' && typeof (globalThis as { isTauri?: unknown }).isTauri === 'boolean') {
|
||||
return Boolean((globalThis as { isTauri?: unknown }).isTauri)
|
||||
}
|
||||
|
||||
return typeof window !== 'undefined' && ('__TAURI__' in window || '__TAURI_INTERNALS__' in window)
|
||||
}
|
||||
|
||||
// Initialize window globals for browser testing and Playwright overrides
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__mockContent = MOCK_CONTENT
|
||||
window.__mockHandlers = mockHandlers
|
||||
}
|
||||
|
||||
function resolveMockHandler(command: string) {
|
||||
const windowHandler = typeof window === 'undefined' || !window.__mockHandlers
|
||||
? undefined
|
||||
: Reflect.get(window.__mockHandlers, command) as MockHandler | undefined
|
||||
return windowHandler ?? Reflect.get(mockHandlers, command) as MockHandler | undefined
|
||||
}
|
||||
|
||||
export async function mockInvoke<T>(cmd: string, args?: Record<string, unknown>): Promise<T> {
|
||||
const vaultResult = await tryVaultApi<T>(cmd, args)
|
||||
if (vaultResult !== undefined) return vaultResult
|
||||
|
||||
const handler = resolveMockHandler(cmd)
|
||||
if (handler) {
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
return handler(args) as T
|
||||
}
|
||||
throw new Error(`No mock handler for command: ${cmd}`)
|
||||
}
|
||||
698
product-source/hololake-platform/src/mock-tauri/mock-content.ts
Normal file
698
product-source/hololake-platform/src/mock-tauri/mock-content.ts
Normal file
|
|
@ -0,0 +1,698 @@
|
|||
/**
|
||||
* Mock markdown content keyed by file path.
|
||||
* Used by the mock Tauri layer when running in browser dev mode.
|
||||
*/
|
||||
|
||||
export const MOCK_CONTENT: Record<string, string> = {
|
||||
'/Users/luca/Laputa/26q1-laputa-app.md': `---
|
||||
title: Build Laputa App
|
||||
type: Project
|
||||
status: Active
|
||||
owner: Luca Rossi
|
||||
deadline: 2026-03-31
|
||||
published: true
|
||||
archived: false
|
||||
tags: [Tauri, React, TypeScript, CodeMirror]
|
||||
tools: [Vite, Vitest, Playwright]
|
||||
url: https://github.com/lucaong/laputa-app
|
||||
belongs_to:
|
||||
- "[[q1-2026]]"
|
||||
related_to:
|
||||
- "[[software-development]]"
|
||||
---
|
||||
|
||||
# Build Laputa App
|
||||
|
||||
## Text Formatting
|
||||
This paragraph has **bold text**, *italic text*, ***bold italic***, ~~strikethrough~~, and \`inline code\`. Here's a [regular link](https://example.com) and a wiki-link to [[Matteo Cellini]].
|
||||
|
||||
## Headings
|
||||
|
||||
### Third Level Heading
|
||||
Content under H3.
|
||||
|
||||
#### Fourth Level Heading
|
||||
Content under H4.
|
||||
|
||||
## Lists
|
||||
|
||||
### Bullet Lists (Nested)
|
||||
- First level item — this is a top-level bullet point
|
||||
- Second level item — indented one level
|
||||
- Third level item — indented two levels
|
||||
- Another third level item with longer text that wraps to multiple lines to test alignment
|
||||
- Back to second level
|
||||
- Another first level item
|
||||
- With a nested child
|
||||
- Final first level item
|
||||
|
||||
### Numbered Lists
|
||||
1. Step one — do this first
|
||||
2. Step two — then do this
|
||||
3. Step three — finally this
|
||||
1. Sub-step 3a
|
||||
2. Sub-step 3b
|
||||
|
||||
### Checkboxes
|
||||
- [x] Completed task with strikethrough
|
||||
- [x] Another done item
|
||||
- [ ] Pending task — needs attention
|
||||
- [ ] Future task with **bold** text inside
|
||||
|
||||
### Mixed Nesting
|
||||
- Top level bullet
|
||||
- Nested bullet
|
||||
- Deep nested bullet
|
||||
- Back to second
|
||||
- Another top level
|
||||
- With child
|
||||
|
||||
## Block Quotes
|
||||
> This is a blockquote. It should have a left border and distinct styling.
|
||||
> It can span multiple lines and contain **formatting**.
|
||||
|
||||
## Code Blocks
|
||||
\`\`\`typescript
|
||||
interface VaultEntry {
|
||||
path: string;
|
||||
title: string;
|
||||
isA: string;
|
||||
status: string | null;
|
||||
}
|
||||
|
||||
function loadVault(path: string): VaultEntry[] {
|
||||
// Load all markdown files from the vault
|
||||
return entries.filter(e => e.isA !== 'Note');
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
\`\`\`yaml
|
||||
title: Some Title
|
||||
type: Project
|
||||
status: Active
|
||||
\`\`\`
|
||||
|
||||
## Tables
|
||||
| Feature | Status | Priority |
|
||||
|---------|--------|----------|
|
||||
| Editor | Done | High |
|
||||
| Inspector | Done | High |
|
||||
| Git Integration | Done | Medium |
|
||||
| Mobile App | Planned | Low |
|
||||
|
||||
## Horizontal Rule
|
||||
|
||||
---
|
||||
|
||||
## Wiki-Links
|
||||
See [[Stock Screener — EMA200 Wick Bounce]] for the experiment approach.
|
||||
Contact [[Matteo Cellini]] for sponsorship data.
|
||||
Link to [[Grow Newsletter]] responsibility.
|
||||
Check [[Software Development]] for tech notes.
|
||||
See [[Laputa App Design Session]] event recap.
|
||||
Read [[Write Weekly Essays]] procedure.
|
||||
Also see [[Non-Existent Note]] which is a broken link.
|
||||
|
||||
## Paragraphs & Spacing
|
||||
This is a normal paragraph with enough text to test line wrapping and spacing between elements. The paragraph should have comfortable line height and spacing from the heading above.
|
||||
|
||||
And this is a second paragraph to verify inter-paragraph spacing is correct. Good typography requires consistent vertical rhythm throughout the document.
|
||||
`,
|
||||
'/Users/luca/Laputa/grow-newsletter.md': `---
|
||||
title: Grow Newsletter
|
||||
type: Responsibility
|
||||
status: Active
|
||||
owner: Luca Rossi
|
||||
---
|
||||
|
||||
# Grow Newsletter
|
||||
|
||||
## Purpose
|
||||
Build a sustainable audience through high-quality weekly essays on **engineering leadership**, **AI**, and **personal systems**.
|
||||
|
||||
## Key Metrics
|
||||
- Subscriber count (target: 100k by Q2 2026)
|
||||
- Open rate (target: > 50%)
|
||||
- Click-through rate
|
||||
|
||||
## Current Strategy
|
||||
1. Publish one essay per week — Tuesday morning
|
||||
2. Promote via Twitter/X threads
|
||||
3. Cross-post to LinkedIn with native formatting
|
||||
4. Guest posts on other newsletters monthly
|
||||
|
||||
## Procedures
|
||||
- [[Write Weekly Essays]] — the core writing workflow
|
||||
- Monthly audience analysis and topic planning
|
||||
|
||||
## Notes
|
||||
The newsletter is the *engine* that drives everything else — sponsorships, consulting leads, and brand building.
|
||||
`,
|
||||
'/Users/luca/Laputa/manage-sponsorships.md': `---
|
||||
title: Manage Sponsorships
|
||||
type: Responsibility
|
||||
status: Active
|
||||
owner: Matteo Cellini
|
||||
---
|
||||
|
||||
# Manage Sponsorships
|
||||
|
||||
## Overview
|
||||
Revenue stream from newsletter sponsorships. [[Matteo Cellini]] handles day-to-day operations.
|
||||
|
||||
## Process
|
||||
1. Inbound leads via sponsorship page
|
||||
2. Qualification call
|
||||
3. Proposal and negotiation
|
||||
4. Schedule and deliver
|
||||
5. Report results to sponsor
|
||||
|
||||
## Metrics
|
||||
- Monthly revenue
|
||||
- Close rate
|
||||
- Repeat sponsor rate
|
||||
`,
|
||||
'/Users/luca/Laputa/write-weekly-essays.md': `---
|
||||
title: Write Weekly Essays
|
||||
type: Procedure
|
||||
status: Active
|
||||
owner: Luca Rossi
|
||||
cadence: Weekly
|
||||
belongs_to:
|
||||
- "[[grow-newsletter]]"
|
||||
---
|
||||
|
||||
# Write Weekly Essays
|
||||
|
||||
## Schedule
|
||||
- **Monday**: Pick topic, outline
|
||||
- **Tuesday**: First draft
|
||||
- **Wednesday**: Edit and polish
|
||||
- **Thursday**: Schedule for Tuesday send
|
||||
|
||||
## Writing Guidelines
|
||||
- 1500-2500 words
|
||||
- One clear takeaway
|
||||
- Use *real examples* from personal experience
|
||||
- Include actionable advice, not just theory
|
||||
|
||||
### Checklist
|
||||
- [ ] Pick a topic from the backlog
|
||||
- [ ] Write outline with 3-5 sections
|
||||
- [x] Set up newsletter template
|
||||
- [x] Configure email scheduling
|
||||
- [ ] Review analytics from last issue
|
||||
|
||||
### Nested Topics
|
||||
- Content strategy for growing the newsletter audience through organic channels, referrals, and high-quality evergreen content that people want to share with their engineering teams
|
||||
- Newsletter growth and subscriber acquisition including all the different channels we use to attract new readers to the publication
|
||||
- Organic subscribers from search, Twitter, and word of mouth — these are the highest quality subscribers with the best retention rates over time
|
||||
- Paid acquisition through Facebook ads and newsletter cross-promotions with other engineering publications in the space
|
||||
- Social media cross-posting
|
||||
- Technical writing
|
||||
- Code examples
|
||||
- Architecture diagrams
|
||||
1. First ordered item with a really long description that should definitely wrap to the next line when displayed in the editor, testing the hanging indent behavior for numbered lists
|
||||
2. Second ordered item — shorter
|
||||
1. Nested ordered item that also has quite a long description to verify that the indentation works correctly for nested numbered lists too
|
||||
`,
|
||||
'/Users/luca/Laputa/run-sponsorships.md': `---
|
||||
title: Run Sponsorships
|
||||
type: Procedure
|
||||
status: Active
|
||||
owner: Matteo Cellini
|
||||
cadence: Weekly
|
||||
belongs_to:
|
||||
- "[[manage-sponsorships]]"
|
||||
---
|
||||
|
||||
# Run Sponsorships
|
||||
|
||||
## Weekly Tasks
|
||||
- Review pipeline in CRM
|
||||
- Follow up with pending proposals
|
||||
- Schedule confirmed sponsors
|
||||
- Send performance reports to completed sponsors
|
||||
|
||||
## Templates
|
||||
- Proposal template: \`/templates/sponsorship-proposal.md\`
|
||||
- Report template: \`/templates/sponsorship-report.md\`
|
||||
`,
|
||||
'/Users/luca/Laputa/stock-screener.md': `---
|
||||
title: Stock Screener — EMA200 Wick Bounce
|
||||
type: Experiment
|
||||
status: Active
|
||||
owner: Luca Rossi
|
||||
domains: [Finance, Quantitative Analysis]
|
||||
tools: [Python, pandas, TradingView]
|
||||
related_to:
|
||||
- "[[trading]]"
|
||||
- "[[algorithmic-trading]]"
|
||||
---
|
||||
|
||||
# Stock Screener — EMA200 Wick Bounce
|
||||
|
||||
## Hypothesis
|
||||
Stocks that wick below the 200-day EMA and close above it show a **statistically significant bounce** in the following 5-10 days.
|
||||
|
||||
## Setup
|
||||
- Scan for daily candles where:
|
||||
- Low < EMA200
|
||||
- Close > EMA200
|
||||
- Volume > 1.5x average
|
||||
- Filter for mid-cap stocks ($2B-$20B)
|
||||
|
||||
## Results So Far
|
||||
| Date | Ticker | Entry | Exit | Return |
|
||||
|------|--------|-------|------|--------|
|
||||
| 2026-01-15 | AAPL | 182.30 | 189.50 | +3.9% |
|
||||
| 2026-01-22 | MSFT | 410.20 | 418.80 | +2.1% |
|
||||
|
||||
## Next Steps
|
||||
- [ ] Backtest on 10 years of data
|
||||
- [ ] Add RSI filter for oversold confirmation
|
||||
- [ ] Build automated alerts via Python script
|
||||
`,
|
||||
'/Users/luca/Laputa/facebook-ads-strategy.md': `---
|
||||
title: Facebook Ads Strategy
|
||||
type: Note
|
||||
belongs_to:
|
||||
- "[[26q1-laputa-app]]"
|
||||
related_to:
|
||||
- "[[growth]]"
|
||||
- "[[ads]]"
|
||||
---
|
||||
|
||||
# Facebook Ads Strategy
|
||||
|
||||
## Key Learnings
|
||||
- **Lookalike audiences** from newsletter subscribers convert 3x better than interest-based targeting
|
||||
- Video ads outperform static images by 40% on engagement
|
||||
- Best performing CTA: "Join 50,000 engineers" (social proof)
|
||||
|
||||
## Budget
|
||||
- Monthly budget: $2,000
|
||||
- Cost per subscriber: ~$1.50 (down from $3.20 in Q3 2025)
|
||||
|
||||
## A/B Tests Running
|
||||
1. Long-form vs short-form ad copy
|
||||
2. Testimonial vs data-driven creative
|
||||
`,
|
||||
'/Users/luca/Laputa/budget-allocation.md': `---
|
||||
title: Budget Allocation
|
||||
type: Note
|
||||
belongs_to:
|
||||
- "[[26q1-laputa-app]]"
|
||||
---
|
||||
|
||||
# Budget Allocation
|
||||
|
||||
## Q1 2026
|
||||
| Category | Budget | Actual | Delta |
|
||||
|----------|--------|--------|-------|
|
||||
| Ads | $6,000 | $5,400 | -$600 |
|
||||
| Tools | $500 | $480 | -$20 |
|
||||
| Freelancers | $2,000 | $1,800 | -$200 |
|
||||
|
||||
## Notes
|
||||
- Under budget on ads due to improved targeting efficiency
|
||||
- Consider reallocating savings to content production
|
||||
`,
|
||||
'/Users/luca/Laputa/matteo-cellini.md': `---
|
||||
title: Matteo Cellini
|
||||
type: Person
|
||||
aliases:
|
||||
- Matteo
|
||||
---
|
||||
|
||||
# Matteo Cellini
|
||||
|
||||
## Role
|
||||
Sponsorship manager — handles all sponsor relationships, proposals, and reporting.
|
||||
|
||||
## Contact
|
||||
- Email: matteo@example.com
|
||||
- Slack: @matteo
|
||||
|
||||
## Responsibilities
|
||||
- [[Manage Sponsorships]]
|
||||
- [[Run Sponsorships]]
|
||||
`,
|
||||
'/Users/luca/Laputa/2026-02-14-laputa-app-kickoff.md': `---
|
||||
title: Laputa App Design Session
|
||||
type: Event
|
||||
related_to:
|
||||
- "[[26q1-laputa-app]]"
|
||||
- "[[matteo-cellini]]"
|
||||
---
|
||||
|
||||
# Laputa App Design Session
|
||||
|
||||
## Date
|
||||
2026-02-14
|
||||
|
||||
## Attendees
|
||||
- Luca Rossi
|
||||
- [[Matteo Cellini]]
|
||||
|
||||
## Notes
|
||||
- Agreed on four-panel layout inspired by Bear Notes
|
||||
- CodeMirror 6 for the editor — live preview is critical
|
||||
- MVP by end of Q1: sidebar + note list + editor working
|
||||
- Inspector panel can wait for M4
|
||||
|
||||
## Action Items
|
||||
- [ ] Luca: finalize ontology mapping
|
||||
- [x] Luca: set up Tauri v2 project scaffold
|
||||
- [ ] Matteo: test with real vault data
|
||||
`,
|
||||
'/Users/luca/Laputa/software-development.md': `---
|
||||
title: Software Development
|
||||
type: Topic
|
||||
aliases:
|
||||
- Dev
|
||||
- Coding
|
||||
---
|
||||
|
||||
# Software Development
|
||||
|
||||
A broad topic covering everything from frontend to systems programming.
|
||||
|
||||
## Subtopics of Interest
|
||||
- **Frontend**: React, TypeScript, CSS
|
||||
- **Desktop**: Tauri, Electron alternatives
|
||||
- **AI/ML**: LLMs, agents, code generation
|
||||
- **Systems**: Rust, performance optimization
|
||||
`,
|
||||
'/Users/luca/Laputa/trading.md': `---
|
||||
title: Trading
|
||||
type: Topic
|
||||
aliases:
|
||||
- Algorithmic Trading
|
||||
---
|
||||
|
||||
# Trading
|
||||
|
||||
## Focus Areas
|
||||
- Technical analysis (EMA, RSI, volume patterns)
|
||||
- Algorithmic screening and alerts
|
||||
- Risk management and position sizing
|
||||
|
||||
## Active Experiments
|
||||
- [[Stock Screener — EMA200 Wick Bounce]]
|
||||
`,
|
||||
'/Users/luca/Laputa/on-writing-well.md': `---
|
||||
title: On Writing Well
|
||||
type: Essay
|
||||
Belongs to:
|
||||
- "[[grow-newsletter]]"
|
||||
---
|
||||
|
||||
# On Writing Well
|
||||
|
||||
Good writing is lean and confident. Every sentence should serve a purpose.
|
||||
`,
|
||||
'/Users/luca/Laputa/engineering-leadership-101.md': `---
|
||||
title: Engineering Leadership 101
|
||||
type: Essay
|
||||
Belongs to:
|
||||
- "[[grow-newsletter]]"
|
||||
Related to:
|
||||
- "[[software-development]]"
|
||||
---
|
||||
|
||||
# Engineering Leadership 101
|
||||
|
||||
The transition from IC to manager is the hardest career shift in engineering.
|
||||
`,
|
||||
'/Users/luca/Laputa/ai-agents-primer.md': `---
|
||||
title: AI Agents Primer
|
||||
type: Essay
|
||||
Belongs to:
|
||||
- "[[grow-newsletter]]"
|
||||
---
|
||||
|
||||
# AI Agents Primer
|
||||
|
||||
AI agents are autonomous systems that can plan, execute, and adapt to achieve goals.
|
||||
`,
|
||||
'/Users/luca/Laputa/maria-bianchi.md': `---
|
||||
title: Maria Bianchi
|
||||
type: Person
|
||||
aliases:
|
||||
- Maria
|
||||
---
|
||||
|
||||
# Maria Bianchi
|
||||
|
||||
## Role
|
||||
Product designer — leads UX research and design sprints for the app.
|
||||
|
||||
## Contact
|
||||
- Email: maria@example.com
|
||||
- Slack: @maria
|
||||
`,
|
||||
'/Users/luca/Laputa/marco-verdi.md': `---
|
||||
title: Marco Verdi
|
||||
type: Person
|
||||
aliases:
|
||||
- Marco
|
||||
---
|
||||
|
||||
# Marco Verdi
|
||||
|
||||
## Role
|
||||
Frontend engineer — focuses on React performance and accessibility.
|
||||
|
||||
## Contact
|
||||
- Email: marco@example.com
|
||||
`,
|
||||
'/Users/luca/Laputa/elena-russo.md': `---
|
||||
title: Elena Russo
|
||||
type: Person
|
||||
aliases:
|
||||
- Elena
|
||||
---
|
||||
|
||||
# Elena Russo
|
||||
|
||||
## Role
|
||||
Content strategist — plans newsletter topics and manages the editorial calendar.
|
||||
`,
|
||||
'/Users/luca/Laputa/project.md': `---
|
||||
type: Type
|
||||
order: 0
|
||||
---
|
||||
|
||||
# Project
|
||||
|
||||
A **time-bound initiative** that advances a [[responsibility|Responsibility]]. Projects have a clear start, end, and deliverables.
|
||||
|
||||
## Properties
|
||||
- **Status**: Active, Paused, Done, Dropped
|
||||
- **Owner**: The person accountable
|
||||
- **Belongs to**: Usually a Quarter or Responsibility
|
||||
`,
|
||||
'/Users/luca/Laputa/responsibility.md': `---
|
||||
type: Type
|
||||
order: 1
|
||||
---
|
||||
|
||||
# Responsibility
|
||||
|
||||
An **ongoing area of ownership** — something you're accountable for indefinitely. Responsibilities don't end; they have procedures, projects, and measures attached.
|
||||
|
||||
## Properties
|
||||
- **Status**: Active, Paused, Archived
|
||||
- **Owner**: The person accountable
|
||||
`,
|
||||
'/Users/luca/Laputa/procedure.md': `---
|
||||
type: Type
|
||||
order: 2
|
||||
---
|
||||
|
||||
# Procedure
|
||||
|
||||
A **recurring process** tied to a [[responsibility|Responsibility]]. Procedures have a cadence (weekly, monthly) and describe how to do something.
|
||||
|
||||
## Properties
|
||||
- **Status**: Active, Paused
|
||||
- **Owner**: The person responsible
|
||||
- **Cadence**: Weekly, Monthly, Quarterly
|
||||
- **Belongs to**: A Responsibility
|
||||
`,
|
||||
'/Users/luca/Laputa/experiment.md': `---
|
||||
type: Type
|
||||
order: 3
|
||||
---
|
||||
|
||||
# Experiment
|
||||
|
||||
A **hypothesis-driven investigation** with a clear test and measurable outcome. Experiments are time-bound and have explicit success criteria.
|
||||
|
||||
## Properties
|
||||
- **Status**: Active, Done, Dropped
|
||||
- **Owner**: The person running the experiment
|
||||
`,
|
||||
'/Users/luca/Laputa/person.md': `---
|
||||
type: Type
|
||||
order: 4
|
||||
---
|
||||
|
||||
# Person
|
||||
|
||||
A **person** you interact with — team members, collaborators, contacts. People can own projects, responsibilities, and procedures.
|
||||
|
||||
## Properties
|
||||
- **Aliases**: Alternative names for wikilink resolution
|
||||
`,
|
||||
'/Users/luca/Laputa/event.md': `---
|
||||
type: Type
|
||||
order: 5
|
||||
---
|
||||
|
||||
# Event
|
||||
|
||||
A **point-in-time occurrence** — meetings, launches, milestones. Events are linked to the entities they relate to.
|
||||
|
||||
## Properties
|
||||
- **Related to**: Entities this event is about
|
||||
`,
|
||||
'/Users/luca/Laputa/topic.md': `---
|
||||
type: Type
|
||||
order: 6
|
||||
---
|
||||
|
||||
# Topic
|
||||
|
||||
A **subject area** for categorization. Topics group related notes, projects, and resources by theme.
|
||||
|
||||
## Properties
|
||||
- **Aliases**: Alternative names
|
||||
`,
|
||||
'/Users/luca/Laputa/essay.md': `---
|
||||
type: Type
|
||||
order: 7
|
||||
---
|
||||
|
||||
# Essay
|
||||
|
||||
A **published piece of writing** — newsletter essays, blog posts, articles. Essays belong to a responsibility and may relate to topics.
|
||||
|
||||
## Properties
|
||||
- **Belongs to**: Usually a Responsibility
|
||||
`,
|
||||
'/Users/luca/Laputa/note.md': `---
|
||||
type: Type
|
||||
order: 8
|
||||
---
|
||||
|
||||
# Note
|
||||
|
||||
A **general-purpose document** — research notes, meeting notes, strategy docs. Notes belong to projects or responsibilities.
|
||||
|
||||
## Properties
|
||||
- **Belongs to**: A Project, Responsibility, or other parent
|
||||
`,
|
||||
'/Users/luca/Laputa/recipe.md': `---
|
||||
type: Type
|
||||
icon: cooking-pot
|
||||
color: orange
|
||||
---
|
||||
|
||||
# Recipe
|
||||
|
||||
A **recipe** for cooking or baking. Recipes have ingredients, steps, and serving info.
|
||||
|
||||
## Default Properties
|
||||
- **Servings**: Number of servings
|
||||
- **Prep Time**: Time to prepare
|
||||
- **Cook Time**: Time to cook
|
||||
`,
|
||||
'/Users/luca/Laputa/book.md': `---
|
||||
type: Type
|
||||
icon: book-open
|
||||
color: green
|
||||
---
|
||||
|
||||
# Book
|
||||
|
||||
A **book** you're reading or have read. Track reading progress, notes, and key takeaways.
|
||||
|
||||
## Default Properties
|
||||
- **Author**: The book's author
|
||||
- **Status**: Reading, Finished, Abandoned
|
||||
- **Rating**: 1-5 stars
|
||||
`,
|
||||
'/Users/luca/Laputa/25q3-website-redesign.md': `---
|
||||
title: Website Redesign
|
||||
type: Project
|
||||
status: Done
|
||||
archived: true
|
||||
owner: Luca Rossi
|
||||
belongs_to:
|
||||
- "[[q3-2025]]"
|
||||
---
|
||||
|
||||
# Website Redesign
|
||||
|
||||
Completed redesign of the company website. Migrated from WordPress to Next.js with improved performance and SEO.
|
||||
|
||||
## Results
|
||||
- Page load time: 4.2s → 1.1s
|
||||
- Organic traffic: +35% in 3 months
|
||||
- Bounce rate: 58% → 42%
|
||||
`,
|
||||
'/Users/luca/Laputa/twitter-thread-experiment.md': `---
|
||||
title: Twitter Thread Growth Experiment
|
||||
type: Experiment
|
||||
status: Done
|
||||
archived: true
|
||||
owner: Luca Rossi
|
||||
related_to:
|
||||
- "[[grow-newsletter]]"
|
||||
---
|
||||
|
||||
# Twitter Thread Growth Experiment
|
||||
|
||||
## Hypothesis
|
||||
Publishing 3 Twitter threads per week (instead of 1) will increase newsletter signups by 50%.
|
||||
|
||||
## Result
|
||||
After 6 weeks, signups increased by only 12%. The additional threads had diminishing returns — quality matters more than quantity.
|
||||
|
||||
## Decision
|
||||
Reverted to 1 high-quality thread per week. Archived this experiment.
|
||||
`,
|
||||
'/Users/luca/Laputa/pasta-carbonara.md': `---
|
||||
title: Pasta Carbonara
|
||||
type: Recipe
|
||||
servings: 4
|
||||
prep_time: 10 min
|
||||
cook_time: 20 min
|
||||
---
|
||||
|
||||
# Pasta Carbonara
|
||||
|
||||
Classic Roman pasta dish with eggs, pecorino, guanciale, and black pepper.
|
||||
|
||||
## Ingredients
|
||||
- 400g spaghetti
|
||||
- 200g guanciale
|
||||
- 4 egg yolks + 2 whole eggs
|
||||
- 100g Pecorino Romano
|
||||
- Black pepper
|
||||
`,
|
||||
'/Users/luca/Laputa/designing-data-intensive-applications.md': `---
|
||||
title: Designing Data-Intensive Applications
|
||||
type: Book
|
||||
author: Martin Kleppmann
|
||||
status: Finished
|
||||
rating: 5
|
||||
---
|
||||
|
||||
# Designing Data-Intensive Applications
|
||||
|
||||
Essential reading for anyone building distributed systems. Covers replication, partitioning, transactions, and stream processing.
|
||||
`,
|
||||
}
|
||||
1119
product-source/hololake-platform/src/mock-tauri/mock-entries.ts
Normal file
1119
product-source/hololake-platform/src/mock-tauri/mock-entries.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,224 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
async function loadHandlers() {
|
||||
vi.resetModules()
|
||||
return import('./mock-handlers')
|
||||
}
|
||||
|
||||
describe('mockHandlers coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('renames a note, updates its frontmatter title, and rewrites backlinks', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
const vaultPath = '/Users/mock/Test Vault'
|
||||
const oldPath = `${vaultPath}/old-note.md`
|
||||
const referencePath = `${vaultPath}/reference.md`
|
||||
|
||||
mockHandlers.save_note_content({
|
||||
path: oldPath,
|
||||
content: '---\ntitle: Old Note\n---\n\n# Old Note',
|
||||
})
|
||||
mockHandlers.save_note_content({
|
||||
path: referencePath,
|
||||
content: 'See [[Old Note]] and [[old-note]].',
|
||||
})
|
||||
|
||||
const result = mockHandlers.rename_note({
|
||||
vault_path: vaultPath,
|
||||
old_path: oldPath,
|
||||
new_title: 'New Title',
|
||||
old_title: 'Old Note',
|
||||
})
|
||||
|
||||
const updatedContent = mockHandlers.get_all_content() as Record<string, string>
|
||||
|
||||
expect(result).toEqual({
|
||||
new_path: `${vaultPath}/new-title.md`,
|
||||
updated_files: 1,
|
||||
failed_updates: 0,
|
||||
})
|
||||
expect(updatedContent[`${vaultPath}/new-title.md`]).toContain('title: New Title')
|
||||
expect(updatedContent[referencePath]).toBe('See [[new-title]] and [[new-title]].')
|
||||
})
|
||||
|
||||
it('treats an unchanged title as a no-op rename', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
const vaultPath = '/Users/mock/Test Vault'
|
||||
const notePath = `${vaultPath}/same-title.md`
|
||||
|
||||
mockHandlers.save_note_content({
|
||||
path: notePath,
|
||||
content: '---\ntitle: Same Title\n---\n',
|
||||
})
|
||||
|
||||
expect(mockHandlers.rename_note({
|
||||
vault_path: vaultPath,
|
||||
old_path: notePath,
|
||||
new_title: 'Same Title',
|
||||
old_title: 'Same Title',
|
||||
})).toEqual({
|
||||
new_path: notePath,
|
||||
updated_files: 0,
|
||||
failed_updates: 0,
|
||||
})
|
||||
})
|
||||
|
||||
it('validates filename-only renames and blocks collisions', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
const vaultPath = '/Users/mock/Test Vault'
|
||||
const sourcePath = `${vaultPath}/draft.md`
|
||||
|
||||
mockHandlers.save_note_content({
|
||||
path: sourcePath,
|
||||
content: '# Draft',
|
||||
})
|
||||
mockHandlers.save_note_content({
|
||||
path: `${vaultPath}/duplicate.md`,
|
||||
content: '# Existing',
|
||||
})
|
||||
|
||||
expect(() => mockHandlers.rename_note_filename({
|
||||
vault_path: vaultPath,
|
||||
old_path: sourcePath,
|
||||
new_filename_stem: ' ',
|
||||
})).toThrow('Invalid filename')
|
||||
|
||||
expect(() => mockHandlers.rename_note_filename({
|
||||
vault_path: vaultPath,
|
||||
old_path: sourcePath,
|
||||
new_filename_stem: 'duplicate',
|
||||
})).toThrow('A note with that name already exists')
|
||||
})
|
||||
|
||||
it('tracks saved files, deduplicates modified-file listings, and clears them on commit', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
|
||||
mockHandlers.save_note_content({
|
||||
path: '/Users/luca/Laputa/26q1-laputa-app.md',
|
||||
content: '# Updated project note',
|
||||
})
|
||||
mockHandlers.save_note_content({
|
||||
path: '/Users/luca/Laputa/new-note.md',
|
||||
content: '# New note',
|
||||
})
|
||||
|
||||
const modifiedBeforeCommit = mockHandlers.get_modified_files()
|
||||
const basePathCount = modifiedBeforeCommit.filter((entry) => entry.path === '/Users/luca/Laputa/26q1-laputa-app.md').length
|
||||
|
||||
expect(basePathCount).toBe(1)
|
||||
expect(modifiedBeforeCommit.some((entry) => entry.path === '/Users/luca/Laputa/new-note.md')).toBe(true)
|
||||
|
||||
expect(mockHandlers.git_commit({ message: 'Save everything' })).toContain('6 files changed')
|
||||
expect(mockHandlers.get_modified_files()).toEqual([])
|
||||
})
|
||||
|
||||
it('searches mock content and slices pulse results to the requested limit', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
const projectPath = '/Users/luca/Laputa/26q1-laputa-app.md'
|
||||
|
||||
mockHandlers.save_note_content({
|
||||
path: projectPath,
|
||||
content: '# Project Plan\n\nStrategic coverage improvements',
|
||||
})
|
||||
|
||||
const search = mockHandlers.search_vault({ query: 'strategic', mode: 'content' })
|
||||
const pulse = mockHandlers.get_vault_pulse({ limit: 2 })
|
||||
|
||||
expect(search.query).toBe('strategic')
|
||||
expect(search.results).toEqual([
|
||||
expect.objectContaining({
|
||||
path: projectPath,
|
||||
title: 'Build Laputa App',
|
||||
}),
|
||||
])
|
||||
expect(pulse).toHaveLength(2)
|
||||
expect(pulse[0]?.shortHash).toBe('a1b2c3d')
|
||||
})
|
||||
|
||||
it('applies setting defaults and keeps saved vault lists isolated from caller mutations', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
|
||||
mockHandlers.save_settings({
|
||||
settings: {
|
||||
auto_pull_interval_minutes: undefined,
|
||||
autogit_enabled: true,
|
||||
autogit_idle_threshold_seconds: undefined,
|
||||
autogit_inactive_threshold_seconds: undefined,
|
||||
auto_advance_inbox_after_organize: true,
|
||||
telemetry_consent: true,
|
||||
crash_reporting_enabled: false,
|
||||
analytics_enabled: true,
|
||||
anonymous_id: 'anon-1',
|
||||
release_channel: 'alpha',
|
||||
ui_language: 'zh-CN',
|
||||
default_ai_agent: 'codex',
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockHandlers.get_settings()).toEqual({
|
||||
auto_pull_interval_minutes: 5,
|
||||
git_enabled: null,
|
||||
git_path: null,
|
||||
git_provider: null,
|
||||
git_wsl_distro: null,
|
||||
autogit_enabled: true,
|
||||
autogit_idle_threshold_seconds: 90,
|
||||
autogit_inactive_threshold_seconds: 30,
|
||||
auto_advance_inbox_after_organize: true,
|
||||
telemetry_consent: true,
|
||||
crash_reporting_enabled: false,
|
||||
analytics_enabled: true,
|
||||
anonymous_id: 'anon-1',
|
||||
release_channel: 'alpha',
|
||||
automatic_update_checks_enabled: null,
|
||||
theme_mode: null,
|
||||
date_display_format: null,
|
||||
note_width_mode: null,
|
||||
sidebar_type_pluralization_enabled: null,
|
||||
initial_h1_auto_rename_enabled: null,
|
||||
ai_features_enabled: null,
|
||||
ui_language: 'zh-CN',
|
||||
default_ai_agent: 'codex',
|
||||
default_ai_target: null,
|
||||
ai_model_providers: null,
|
||||
ai_workspace_conversations: null,
|
||||
hide_gitignored_files: null,
|
||||
all_notes_show_pdfs: null,
|
||||
all_notes_show_images: null,
|
||||
all_notes_show_unsupported: null,
|
||||
multi_workspace_enabled: null,
|
||||
})
|
||||
|
||||
const list = {
|
||||
vaults: [{ label: 'Work', path: '/work' }],
|
||||
active_vault: '/work',
|
||||
}
|
||||
mockHandlers.save_vault_list({ list })
|
||||
|
||||
const savedList = mockHandlers.load_vault_list()
|
||||
savedList.vaults.push({ label: 'Leak', path: '/leak' })
|
||||
|
||||
expect(mockHandlers.load_vault_list()).toEqual({
|
||||
vaults: [{ label: 'Work', path: '/work' }],
|
||||
active_vault: '/work',
|
||||
})
|
||||
})
|
||||
|
||||
it('builds attachment paths for saved and copied images', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
vi.spyOn(Date, 'now').mockReturnValue(12345)
|
||||
|
||||
expect(mockHandlers.save_image({
|
||||
vault_path: '/vault',
|
||||
filename: 'diagram.png',
|
||||
data: 'base64',
|
||||
})).toBe('/vault/attachments/12345-diagram.png')
|
||||
|
||||
expect(mockHandlers.copy_image_to_vault({
|
||||
vault_path: '/vault',
|
||||
source_path: '/tmp/screenshot.jpg',
|
||||
})).toBe('/vault/attachments/12345-screenshot.jpg')
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,234 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
async function loadHandlers() {
|
||||
vi.resetModules()
|
||||
return import('./mock-handlers')
|
||||
}
|
||||
|
||||
describe('mockHandlers additional coverage', () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('returns entry fallbacks, file history, diffs, and empty search results for empty queries', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
|
||||
expect(mockHandlers.reload_vault_entry({ path: '/missing.md' })).toEqual(
|
||||
expect.objectContaining({
|
||||
path: '/missing.md',
|
||||
title: 'Unknown',
|
||||
filename: 'unknown.md',
|
||||
}),
|
||||
)
|
||||
|
||||
expect(mockHandlers.get_file_history({ path: '/vault/notes/strategy.md' })).toEqual(expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
shortHash: 'a1b2c3d',
|
||||
message: 'Update strategy with latest changes',
|
||||
}),
|
||||
expect.objectContaining({
|
||||
shortHash: 'm0n1o2p',
|
||||
message: 'Create strategy',
|
||||
}),
|
||||
]))
|
||||
|
||||
expect(mockHandlers.get_file_diff({ path: '/vault/old-draft.md' })).toContain('deleted file mode 100644')
|
||||
expect(mockHandlers.get_file_diff_at_commit({
|
||||
path: '/vault/notes/strategy.md',
|
||||
commitHash: 'abcdef1234567890',
|
||||
})).toContain('Updated paragraph at commit abcdef1.')
|
||||
|
||||
expect(mockHandlers.search_vault({ query: '', mode: 'title' })).toEqual({
|
||||
results: [],
|
||||
elapsed_ms: 0,
|
||||
query: '',
|
||||
mode: 'title',
|
||||
})
|
||||
})
|
||||
|
||||
it('renames a filename successfully and rewrites wikilinks that target the old path stem', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
const vaultPath = '/Users/mock/Test Vault'
|
||||
const sourcePath = `${vaultPath}/meeting-notes.md`
|
||||
const backlinkPath = `${vaultPath}/backlinks.md`
|
||||
|
||||
mockHandlers.save_note_content({
|
||||
path: sourcePath,
|
||||
content: '# Meeting Notes',
|
||||
})
|
||||
mockHandlers.save_note_content({
|
||||
path: backlinkPath,
|
||||
content: 'Links: [[meeting-notes]] and [[Meeting Notes|alias]].',
|
||||
})
|
||||
|
||||
expect(mockHandlers.rename_note_filename({
|
||||
vault_path: vaultPath,
|
||||
old_path: sourcePath,
|
||||
new_filename_stem: 'weekly-notes',
|
||||
})).toEqual({
|
||||
new_path: `${vaultPath}/weekly-notes.md`,
|
||||
updated_files: 1,
|
||||
failed_updates: 0,
|
||||
})
|
||||
|
||||
const content = mockHandlers.get_all_content() as Record<string, string>
|
||||
expect(content[`${vaultPath}/weekly-notes.md`]).toBe('# Meeting Notes')
|
||||
expect(content[backlinkPath]).toBe('Links: [[weekly-notes]] and [[Meeting Notes|alias]].')
|
||||
})
|
||||
|
||||
it('moves a note into another workspace without rewriting links that still use the same relative path', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
const sourceVaultPath = '/Users/mock/Personal Vault'
|
||||
const destinationVaultPath = '/Users/mock/Team Vault'
|
||||
const sourcePath = `${sourceVaultPath}/areas/weekly-review.md`
|
||||
const backlinkPath = `${sourceVaultPath}/backlinks.md`
|
||||
|
||||
mockHandlers.save_note_content({
|
||||
path: sourcePath,
|
||||
content: '# Weekly Review',
|
||||
})
|
||||
mockHandlers.save_note_content({
|
||||
path: backlinkPath,
|
||||
content: 'Links: [[areas/weekly-review]] and [[Weekly Review|alias]].',
|
||||
})
|
||||
|
||||
expect(mockHandlers.move_note_to_workspace({
|
||||
source_vault_path: sourceVaultPath,
|
||||
destination_vault_path: destinationVaultPath,
|
||||
old_path: sourcePath,
|
||||
})).toEqual({
|
||||
new_path: `${destinationVaultPath}/areas/weekly-review.md`,
|
||||
updated_files: 0,
|
||||
failed_updates: 0,
|
||||
})
|
||||
|
||||
const content = mockHandlers.get_all_content() as Record<string, string>
|
||||
expect(content[`${destinationVaultPath}/areas/weekly-review.md`]).toBe('# Weekly Review')
|
||||
expect(content[backlinkPath]).toBe('Links: [[areas/weekly-review]] and [[Weekly Review|alias]].')
|
||||
})
|
||||
|
||||
it('tracks remote state through create, clone, and add-remote flows', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
const emptyVaultPath = '/Users/mock/Documents/Brand New Vault'
|
||||
const clonedVaultPath = '/Users/mock/Documents/Cloned Vault'
|
||||
|
||||
expect(mockHandlers.git_remote_status({ vaultPath: emptyVaultPath })).toEqual({
|
||||
branch: 'main',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote: true,
|
||||
})
|
||||
|
||||
expect(mockHandlers.create_empty_vault({ targetPath: emptyVaultPath })).toBe(emptyVaultPath)
|
||||
expect(mockHandlers.git_remote_status({ vaultPath: emptyVaultPath })).toEqual({
|
||||
branch: 'main',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote: false,
|
||||
})
|
||||
|
||||
expect(mockHandlers.git_add_remote({
|
||||
request: { vault_path: emptyVaultPath, remoteUrl: 'https://example.test/repo.git' },
|
||||
})).toEqual({
|
||||
status: 'connected',
|
||||
message: 'Remote connected. This vault now tracks origin/main.',
|
||||
})
|
||||
expect(mockHandlers.git_remote_status({ vault_path: emptyVaultPath })).toEqual({
|
||||
branch: 'main',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote: true,
|
||||
})
|
||||
|
||||
expect(mockHandlers.create_getting_started_vault({ targetPath: clonedVaultPath })).toBe(clonedVaultPath)
|
||||
expect(mockHandlers.git_remote_status({ vaultPath: clonedVaultPath })).toEqual({
|
||||
branch: 'main',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote: false,
|
||||
})
|
||||
|
||||
expect(mockHandlers.clone_repo({
|
||||
url: 'https://example.test/repo.git',
|
||||
local_path: clonedVaultPath,
|
||||
})).toBe(`Cloned to ${clonedVaultPath}`)
|
||||
expect(mockHandlers.git_remote_status({ vaultPath: clonedVaultPath })).toEqual({
|
||||
branch: 'main',
|
||||
ahead: 0,
|
||||
behind: 0,
|
||||
hasRemote: true,
|
||||
})
|
||||
})
|
||||
|
||||
it('persists last-vault state, reports vault existence, and restores AI guidance state', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
|
||||
expect(mockHandlers.get_last_vault_path()).toBe('/Users/mock/demo-vault-v2')
|
||||
expect(mockHandlers.set_last_vault_path({ path: '/Users/mock/Documents/Work' })).toBeNull()
|
||||
expect(mockHandlers.get_last_vault_path()).toBe('/Users/mock/Documents/Work')
|
||||
|
||||
expect(mockHandlers.check_vault_exists({ path: '/tmp/demo-vault-v2-copy' })).toBe(true)
|
||||
expect(mockHandlers.check_vault_exists({ path: '/tmp/random-vault' })).toBe(false)
|
||||
|
||||
expect(mockHandlers.get_vault_ai_guidance_status()).toEqual({
|
||||
agents_state: 'managed',
|
||||
claude_state: 'managed',
|
||||
gemini_state: 'managed',
|
||||
can_restore: false,
|
||||
})
|
||||
expect(mockHandlers.restore_vault_ai_guidance()).toEqual({
|
||||
agents_state: 'managed',
|
||||
claude_state: 'managed',
|
||||
gemini_state: 'managed',
|
||||
can_restore: false,
|
||||
})
|
||||
expect(mockHandlers.repair_vault()).toBe('Vault repaired')
|
||||
})
|
||||
|
||||
it('persists theme mode through the mock settings backend', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
const settings = mockHandlers.get_settings()
|
||||
|
||||
mockHandlers.save_settings({
|
||||
settings: {
|
||||
...settings,
|
||||
theme_mode: 'dark',
|
||||
},
|
||||
})
|
||||
|
||||
expect(mockHandlers.get_settings()).toEqual(expect.objectContaining({
|
||||
theme_mode: 'dark',
|
||||
}))
|
||||
})
|
||||
|
||||
it('surfaces the simple command handlers for git, conflicts, trash, and telemetry', async () => {
|
||||
const { mockHandlers } = await loadHandlers()
|
||||
|
||||
expect(mockHandlers.git_pull()).toEqual({
|
||||
status: 'up_to_date',
|
||||
message: 'Already up to date',
|
||||
updatedFiles: [],
|
||||
conflictFiles: [],
|
||||
})
|
||||
expect(mockHandlers.git_push()).toEqual({
|
||||
status: 'ok',
|
||||
message: 'Pushed to remote',
|
||||
})
|
||||
expect(mockHandlers.get_conflict_files()).toEqual([])
|
||||
expect(mockHandlers.get_conflict_mode()).toBe('none')
|
||||
expect(mockHandlers.purge_trash()).toEqual([])
|
||||
expect(mockHandlers.empty_trash()).toEqual([])
|
||||
expect(mockHandlers.delete_note({ path: '/vault/trash/me.md' })).toBe('/vault/trash/me.md')
|
||||
expect(mockHandlers.batch_delete_notes({ paths: ['/a.md', '/b.md'] })).toEqual(['/a.md', '/b.md'])
|
||||
expect(mockHandlers.batch_archive_notes({ paths: ['/a.md', '/b.md', '/c.md'] })).toBe(3)
|
||||
expect(mockHandlers.batch_trash_notes({ paths: ['/a.md', '/b.md'] })).toBe(2)
|
||||
expect(mockHandlers.migrate_is_a_to_type()).toBe(0)
|
||||
expect(mockHandlers.register_mcp_tools()).toBe('registered')
|
||||
expect(mockHandlers.check_mcp_status()).toBe('installed')
|
||||
expect(mockHandlers.copy_text_to_clipboard()).toBeNull()
|
||||
expect(mockHandlers.read_text_from_clipboard()).toBe('')
|
||||
expect(mockHandlers.reinit_telemetry()).toBeNull()
|
||||
expect(mockHandlers.stream_claude_chat()).toBe('mock-session')
|
||||
expect(mockHandlers.stream_ai_agent()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
import { describe, expect, it } from 'vitest'
|
||||
import { mockHandlers } from './mock-handlers'
|
||||
|
||||
describe('mockHandlers git remote state', () => {
|
||||
it('keeps starter vaults local-only until a remote is added', () => {
|
||||
const vaultPath = '/Users/mock/Documents/Getting Started Test'
|
||||
|
||||
expect(mockHandlers.create_getting_started_vault({ targetPath: vaultPath })).toBe(vaultPath)
|
||||
expect(mockHandlers.git_remote_status({ vaultPath }).hasRemote).toBe(false)
|
||||
|
||||
expect(
|
||||
mockHandlers.git_add_remote({
|
||||
request: {
|
||||
vaultPath,
|
||||
remoteUrl: 'https://example.com/starter.git',
|
||||
},
|
||||
}).status,
|
||||
).toBe('connected')
|
||||
|
||||
expect(mockHandlers.git_remote_status({ vaultPath }).hasRemote).toBe(true)
|
||||
})
|
||||
|
||||
it('starts empty vaults without a remote and keeps cloned vaults remote-backed', () => {
|
||||
const emptyVaultPath = '/Users/mock/Documents/Local Vault'
|
||||
const clonedVaultPath = '/Users/mock/Documents/Cloned Vault'
|
||||
|
||||
expect(mockHandlers.create_empty_vault({ targetPath: emptyVaultPath })).toBe(emptyVaultPath)
|
||||
expect(mockHandlers.git_remote_status({ vaultPath: emptyVaultPath }).hasRemote).toBe(false)
|
||||
|
||||
expect(mockHandlers.clone_repo({ url: 'https://example.com/repo.git', localPath: clonedVaultPath })).toContain(clonedVaultPath)
|
||||
expect(mockHandlers.git_remote_status({ vaultPath: clonedVaultPath }).hasRemote).toBe(true)
|
||||
})
|
||||
})
|
||||
741
product-source/hololake-platform/src/mock-tauri/mock-handlers.ts
Normal file
741
product-source/hololake-platform/src/mock-tauri/mock-handlers.ts
Normal file
|
|
@ -0,0 +1,741 @@
|
|||
/**
|
||||
* Mock command handlers for Tauri invoke calls.
|
||||
* Each handler simulates a Tauri backend command.
|
||||
*/
|
||||
|
||||
import type {
|
||||
VaultEntry,
|
||||
ModifiedFile,
|
||||
Settings,
|
||||
GitProviderProbe,
|
||||
GitProviderStatus,
|
||||
GitAddRemoteResult,
|
||||
GitPullResult,
|
||||
GitPushResult,
|
||||
GitRemoteStatus,
|
||||
LastCommitInfo,
|
||||
PulseCommit,
|
||||
} from '../types'
|
||||
import { MOCK_CONTENT } from './mock-content'
|
||||
import { MOCK_ENTRIES } from './mock-entries'
|
||||
|
||||
function syncWindowContent(): void {
|
||||
if (typeof window !== 'undefined') {
|
||||
window.__mockContent = MOCK_CONTENT
|
||||
}
|
||||
}
|
||||
|
||||
function mockFileHistory(path: string) {
|
||||
const filename = path.split('/').pop()?.replace('.md', '') ?? 'unknown'
|
||||
const ts = Math.floor(Date.now() / 1000)
|
||||
return [
|
||||
{ hash: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', shortHash: 'a1b2c3d', message: `Update ${filename} with latest changes`, author: 'Luca Rossi', date: ts - 86400 * 2 },
|
||||
{ hash: 'e4f5g6h7i8j9k0l1m2n3o4p5q6r7s8t9u0v1w2x3', shortHash: 'e4f5g6h', message: `Add new section to ${filename}`, author: 'Luca Rossi', date: ts - 86400 * 5 },
|
||||
{ hash: 'i7j8k9l0m1n2o3p4q5r6s7t8u9v0w1x2y3z4a5b6', shortHash: 'i7j8k9l', message: `Fix formatting in ${filename}`, author: 'Luca Rossi', date: ts - 86400 * 12 },
|
||||
{ hash: 'm0n1o2p3q4r5s6t7u8v9w0x1y2z3a4b5c6d7e8f9', shortHash: 'm0n1o2p', message: `Create ${filename}`, author: 'Luca Rossi', date: ts - 86400 * 30 },
|
||||
]
|
||||
}
|
||||
|
||||
function stripMockFrontmatter(content: string): string {
|
||||
const lineEnding = content.startsWith('---\r\n')
|
||||
? '\r\n'
|
||||
: content.startsWith('---\n') ? '\n' : null
|
||||
if (!lineEnding) return content
|
||||
|
||||
const afterOpen = content.slice(3 + lineEnding.length)
|
||||
const closeIndex = afterOpen.indexOf(`${lineEnding}---`)
|
||||
if (closeIndex === -1) return content
|
||||
|
||||
return afterOpen.slice(closeIndex + lineEnding.length + 3).trimStart()
|
||||
}
|
||||
|
||||
function mockSearchContent(content: string, excludeFrontmatter?: boolean): string {
|
||||
return excludeFrontmatter ? stripMockFrontmatter(content) : content
|
||||
}
|
||||
|
||||
function mockModifiedFiles(): ModifiedFile[] {
|
||||
return [
|
||||
{ path: '/Users/luca/Laputa/26q1-laputa-app.md', relativePath: '26q1-laputa-app.md', status: 'modified' },
|
||||
{ path: '/Users/luca/Laputa/facebook-ads-strategy.md', relativePath: 'facebook-ads-strategy.md', status: 'modified' },
|
||||
{ path: '/Users/luca/Laputa/ai-agents-primer.md', relativePath: 'ai-agents-primer.md', status: 'added' },
|
||||
{ path: '/Users/luca/Laputa/old-draft.md', relativePath: 'old-draft.md', status: 'deleted' },
|
||||
]
|
||||
}
|
||||
|
||||
function mockFileDiff(path: string): string {
|
||||
const filename = path.split('/').pop() ?? 'unknown'
|
||||
if (filename === 'old-draft.md') {
|
||||
return `diff --git a/${filename} b/${filename}
|
||||
deleted file mode 100644
|
||||
index abc1234..0000000
|
||||
--- a/${filename}
|
||||
+++ /dev/null
|
||||
@@ -1,7 +0,0 @@
|
||||
----
|
||||
-title: Old Draft
|
||||
-type: Note
|
||||
----
|
||||
-
|
||||
-# Old Draft
|
||||
-
|
||||
-This note was deleted.`
|
||||
}
|
||||
return `diff --git a/${filename} b/${filename}
|
||||
index abc1234..def5678 100644
|
||||
--- a/${filename}
|
||||
+++ b/${filename}
|
||||
@@ -1,8 +1,10 @@
|
||||
---
|
||||
title: Example Note
|
||||
type: Note
|
||||
+status: Active
|
||||
---
|
||||
|
||||
# Example Note
|
||||
|
||||
-This is the original content.
|
||||
+This is the updated content.
|
||||
+
|
||||
+A new paragraph has been added.`
|
||||
}
|
||||
|
||||
function mockFileDiffAtCommit(path: string, commitHash: string): string {
|
||||
const filename = path.split('/').pop() ?? 'unknown'
|
||||
const shortHash = commitHash.slice(0, 7)
|
||||
return `diff --git a/${filename} b/${filename}
|
||||
index abc1234..${shortHash} 100644
|
||||
--- a/${filename}
|
||||
+++ b/${filename}
|
||||
@@ -5,3 +5,5 @@
|
||||
---
|
||||
|
||||
# Example Note
|
||||
-Old paragraph from before ${shortHash}.
|
||||
+Updated paragraph at commit ${shortHash}.
|
||||
+
|
||||
+New content added in this commit.`
|
||||
}
|
||||
|
||||
let mockHasChanges = true
|
||||
const mockSavedSinceCommit = new Set<string>()
|
||||
|
||||
let mockSettings: Settings = {
|
||||
auto_pull_interval_minutes: 5,
|
||||
git_enabled: null,
|
||||
git_path: null,
|
||||
git_provider: null,
|
||||
git_wsl_distro: null,
|
||||
autogit_enabled: false,
|
||||
autogit_idle_threshold_seconds: 90,
|
||||
autogit_inactive_threshold_seconds: 30,
|
||||
auto_advance_inbox_after_organize: false,
|
||||
telemetry_consent: false,
|
||||
crash_reporting_enabled: null,
|
||||
analytics_enabled: null,
|
||||
anonymous_id: null,
|
||||
release_channel: null,
|
||||
automatic_update_checks_enabled: null,
|
||||
theme_mode: null,
|
||||
ui_language: null,
|
||||
date_display_format: null,
|
||||
note_width_mode: null,
|
||||
sidebar_type_pluralization_enabled: null,
|
||||
initial_h1_auto_rename_enabled: null,
|
||||
ai_features_enabled: null,
|
||||
default_ai_agent: 'claude_code',
|
||||
default_ai_target: null,
|
||||
ai_model_providers: null,
|
||||
ai_workspace_conversations: null,
|
||||
hide_gitignored_files: null,
|
||||
all_notes_show_pdfs: null,
|
||||
all_notes_show_images: null,
|
||||
all_notes_show_unsupported: null,
|
||||
multi_workspace_enabled: null,
|
||||
}
|
||||
|
||||
const DEFAULT_MOCK_VAULT_PATH = '/Users/mock/demo-vault-v2'
|
||||
const DEFAULT_MOCK_VAULT = {
|
||||
label: 'demo-vault-v2',
|
||||
path: DEFAULT_MOCK_VAULT_PATH,
|
||||
}
|
||||
|
||||
let mockLastVaultPath: string | null = DEFAULT_MOCK_VAULT_PATH
|
||||
const mockRemoteStateByVault = new Map<string, boolean>([[DEFAULT_MOCK_VAULT_PATH, true]])
|
||||
|
||||
let mockVaultList: { vaults: Array<{ label: string; path: string }>; active_vault: string | null } = {
|
||||
vaults: [DEFAULT_MOCK_VAULT],
|
||||
active_vault: DEFAULT_MOCK_VAULT_PATH,
|
||||
}
|
||||
|
||||
let mockWorkspaceAiGuidanceStatus = {
|
||||
agents_state: 'managed',
|
||||
claude_state: 'managed',
|
||||
gemini_state: 'managed',
|
||||
can_restore: false,
|
||||
} as const
|
||||
|
||||
function normalizeMockVaultPath(path: string | null | undefined): string | null {
|
||||
const trimmed = path?.trim()
|
||||
return trimmed ? trimmed : null
|
||||
}
|
||||
|
||||
function setMockRemoteState(path: string | null | undefined, hasRemote: boolean): void {
|
||||
const normalizedPath = normalizeMockVaultPath(path)
|
||||
if (!normalizedPath) return
|
||||
mockRemoteStateByVault.set(normalizedPath, hasRemote)
|
||||
}
|
||||
|
||||
function getMockRemoteState(path: string | null | undefined): boolean {
|
||||
const normalizedPath = normalizeMockVaultPath(path)
|
||||
if (!normalizedPath) return true
|
||||
return mockRemoteStateByVault.get(normalizedPath) ?? true
|
||||
}
|
||||
|
||||
type MockContentPath = { path: string }
|
||||
type MockContentWrite = MockContentPath & { content: string }
|
||||
|
||||
function readMockContent({ path }: MockContentPath): string {
|
||||
const content = Reflect.get(MOCK_CONTENT, path)
|
||||
return typeof content === 'string' ? content : ''
|
||||
}
|
||||
|
||||
function writeMockContent({ path, content }: MockContentWrite): void {
|
||||
Reflect.set(MOCK_CONTENT, path, content)
|
||||
}
|
||||
|
||||
function deleteMockContent({ path }: MockContentPath): void {
|
||||
Reflect.deleteProperty(MOCK_CONTENT, path)
|
||||
}
|
||||
|
||||
function relativePathStem({ path, vaultPath }: { path: string; vaultPath: string }) {
|
||||
const prefix = vaultPath.endsWith('/') ? vaultPath : `${vaultPath}/`
|
||||
if (path.startsWith(prefix)) return path.slice(prefix.length).replace(/\.md$/, '')
|
||||
return (path.split('/').pop() ?? path).replace(/\.md$/, '')
|
||||
}
|
||||
|
||||
function canonicalRenameTargets({ oldTitle, oldPathStem }: { oldTitle: string; oldPathStem: string }) {
|
||||
const oldFilenameStem = oldPathStem.split('/').pop() ?? oldPathStem
|
||||
return [...new Set([oldTitle, oldPathStem, oldFilenameStem].filter(Boolean))]
|
||||
}
|
||||
|
||||
function slugifyMockTitle({ title }: { title: string }) {
|
||||
return title.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/(^-|-$)/g, '')
|
||||
}
|
||||
|
||||
function buildRenamedMockPath({ oldPath, newTitle }: { oldPath: string; newTitle: string }) {
|
||||
const parentDir = oldPath.replace(/\/[^/]+$/, '')
|
||||
return `${parentDir}/${slugifyMockTitle({ title: newTitle })}.md`
|
||||
}
|
||||
|
||||
function replaceMockTitleFrontmatter({ content, newTitle }: { content: string; newTitle: string }) {
|
||||
return /^title:\s*/m.test(content)
|
||||
? content.replace(/^title:\s*.*$/m, `title: ${newTitle}`)
|
||||
: content
|
||||
}
|
||||
|
||||
function replaceRenamedWikilinks({ content, oldTargets, newPathStem }: {
|
||||
content: string
|
||||
oldTargets: string[]
|
||||
newPathStem: string
|
||||
}) {
|
||||
if (oldTargets.length === 0) return content
|
||||
const targets = new Set(oldTargets)
|
||||
let rewritten = ''
|
||||
let cursor = 0
|
||||
|
||||
while (cursor < content.length) {
|
||||
const start = content.indexOf('[[', cursor)
|
||||
if (start === -1) break
|
||||
|
||||
const end = content.indexOf(']]', start + 2)
|
||||
if (end === -1) break
|
||||
|
||||
rewritten += content.slice(cursor, start)
|
||||
rewritten += renamedWikilinkToken({
|
||||
newPathStem,
|
||||
targets,
|
||||
token: content.slice(start, end + 2),
|
||||
})
|
||||
cursor = end + 2
|
||||
}
|
||||
|
||||
return rewritten + content.slice(cursor)
|
||||
}
|
||||
|
||||
function renamedWikilinkToken({ newPathStem, targets, token }: {
|
||||
newPathStem: string
|
||||
targets: Set<string>
|
||||
token: string
|
||||
}) {
|
||||
const body = token.slice(2, -2)
|
||||
const pipeIndex = body.indexOf('|')
|
||||
const target = pipeIndex === -1 ? body : body.slice(0, pipeIndex)
|
||||
if (!targets.has(target)) return token
|
||||
|
||||
const pipe = pipeIndex === -1 ? '' : body.slice(pipeIndex)
|
||||
return `[[${newPathStem}${pipe}]]`
|
||||
}
|
||||
|
||||
function updateMockRenameReferences({ newPath, newPathStem, oldTargets }: {
|
||||
newPath: string
|
||||
newPathStem: string
|
||||
oldTargets: string[]
|
||||
}) {
|
||||
let updatedFiles = 0
|
||||
for (const [path, content] of Object.entries(MOCK_CONTENT)) {
|
||||
if (path === newPath) continue
|
||||
const replaced = replaceRenamedWikilinks({ content, oldTargets, newPathStem })
|
||||
if (replaced === content) continue
|
||||
writeMockContent({ path, content: replaced })
|
||||
updatedFiles += 1
|
||||
}
|
||||
return updatedFiles
|
||||
}
|
||||
|
||||
function handleRenameNote(args: { vault_path: string; old_path: string; new_title: string; old_title?: string | null }) {
|
||||
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
|
||||
const oldTitle = args.old_title ?? oldEntry?.title ?? ''
|
||||
const oldContent = readMockContent({ path: args.old_path })
|
||||
const newPath = buildRenamedMockPath({ oldPath: args.old_path, newTitle: args.new_title })
|
||||
const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path })
|
||||
const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path })
|
||||
|
||||
if (oldTitle === args.new_title && newPath === args.old_path) {
|
||||
return { new_path: args.old_path, updated_files: 0, failed_updates: 0 }
|
||||
}
|
||||
|
||||
const newContent = replaceMockTitleFrontmatter({ content: oldContent, newTitle: args.new_title })
|
||||
deleteMockContent({ path: args.old_path })
|
||||
writeMockContent({ path: newPath, content: newContent })
|
||||
const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem })
|
||||
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
|
||||
|
||||
syncWindowContent()
|
||||
return { new_path: newPath, updated_files: updatedFiles, failed_updates: 0 }
|
||||
}
|
||||
|
||||
function handleRenameNoteFilename(args: {
|
||||
vault_path: string
|
||||
old_path: string
|
||||
new_filename_stem: string
|
||||
}) {
|
||||
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
|
||||
const oldContent = readMockContent({ path: args.old_path })
|
||||
const oldTitle = oldEntry?.title ?? ''
|
||||
const normalizedStem = args.new_filename_stem.trim().replace(/\.md$/, '')
|
||||
const oldFilename = args.old_path.split('/').pop() ?? ''
|
||||
const newFilename = `${normalizedStem}.md`
|
||||
|
||||
if (!normalizedStem) {
|
||||
throw new Error('Invalid filename')
|
||||
}
|
||||
if (oldFilename === newFilename) {
|
||||
return { new_path: args.old_path, updated_files: 0, failed_updates: 0 }
|
||||
}
|
||||
|
||||
const parentDir = args.old_path.replace(/\/[^/]+$/, '')
|
||||
const newPath = `${parentDir}/${newFilename}`
|
||||
if (newPath !== args.old_path && Object.hasOwn(MOCK_CONTENT, newPath)) {
|
||||
throw new Error('A note with that name already exists')
|
||||
}
|
||||
|
||||
deleteMockContent({ path: args.old_path })
|
||||
writeMockContent({ path: newPath, content: oldContent })
|
||||
|
||||
const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path })
|
||||
const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path })
|
||||
const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem })
|
||||
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
|
||||
|
||||
syncWindowContent()
|
||||
return { new_path: newPath, updated_files: updatedFiles, failed_updates: 0 }
|
||||
}
|
||||
|
||||
function handleMoveNoteToFolder(args: {
|
||||
vault_path: string
|
||||
old_path: string
|
||||
folder_path: string
|
||||
}) {
|
||||
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
|
||||
const oldContent = readMockContent({ path: args.old_path })
|
||||
const oldTitle = oldEntry?.title ?? ''
|
||||
const oldFilename = args.old_path.split('/').pop() ?? ''
|
||||
const normalizedFolderPath = args.folder_path.trim().replace(/^\/+|\/+$/g, '')
|
||||
|
||||
if (!normalizedFolderPath) {
|
||||
throw new Error('Folder path cannot be empty')
|
||||
}
|
||||
|
||||
const vaultRoot = args.vault_path.replace(/\/+$/, '')
|
||||
const newPath = `${vaultRoot}/${normalizedFolderPath}/${oldFilename}`
|
||||
if (newPath === args.old_path) {
|
||||
return { new_path: args.old_path, updated_files: 0, failed_updates: 0 }
|
||||
}
|
||||
if (Object.hasOwn(MOCK_CONTENT, newPath)) {
|
||||
throw new Error('A note with that name already exists')
|
||||
}
|
||||
|
||||
deleteMockContent({ path: args.old_path })
|
||||
writeMockContent({ path: newPath, content: oldContent })
|
||||
|
||||
const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.vault_path })
|
||||
const newPathStem = relativePathStem({ path: newPath, vaultPath: args.vault_path })
|
||||
const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem })
|
||||
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
|
||||
|
||||
syncWindowContent()
|
||||
return { new_path: newPath, updated_files: updatedFiles, failed_updates: 0 }
|
||||
}
|
||||
|
||||
function handleMoveNoteToWorkspace(args: {
|
||||
source_vault_path: string
|
||||
destination_vault_path: string
|
||||
old_path: string
|
||||
replacement_target?: string | null
|
||||
}) {
|
||||
const oldEntry = MOCK_ENTRIES.find(e => e.path === args.old_path)
|
||||
const oldContent = readMockContent({ path: args.old_path })
|
||||
const oldTitle = oldEntry?.title ?? ''
|
||||
const oldFilename = args.old_path.split('/').pop() ?? ''
|
||||
const sourceRoot = args.source_vault_path.replace(/\/+$/, '')
|
||||
const destinationRoot = args.destination_vault_path.replace(/\/+$/, '')
|
||||
const relativePath = args.old_path.startsWith(`${sourceRoot}/`)
|
||||
? args.old_path.slice(sourceRoot.length + 1)
|
||||
: oldFilename
|
||||
const newPath = `${destinationRoot}/${relativePath}`
|
||||
|
||||
if (newPath === args.old_path) {
|
||||
return { new_path: args.old_path, updated_files: 0, failed_updates: 0 }
|
||||
}
|
||||
if (Object.hasOwn(MOCK_CONTENT, newPath)) {
|
||||
throw new Error('A note with that name already exists')
|
||||
}
|
||||
|
||||
deleteMockContent({ path: args.old_path })
|
||||
writeMockContent({ path: newPath, content: oldContent })
|
||||
|
||||
const oldPathStem = relativePathStem({ path: args.old_path, vaultPath: args.source_vault_path })
|
||||
const newPathStem = args.replacement_target
|
||||
?? relativePathStem({ path: newPath, vaultPath: args.destination_vault_path })
|
||||
const oldTargets = canonicalRenameTargets({ oldTitle, oldPathStem })
|
||||
const updatedFiles = updateMockRenameReferences({ newPath, newPathStem, oldTargets })
|
||||
|
||||
syncWindowContent()
|
||||
return { new_path: newPath, updated_files: updatedFiles, failed_updates: 0 }
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- mock handler map accepts heterogeneous arg types
|
||||
export const mockHandlers: Record<string, (args: any) => any> = {
|
||||
list_vault: () => MOCK_ENTRIES,
|
||||
list_vault_folders: () => [],
|
||||
list_views: () => [],
|
||||
save_view_cmd: () => {},
|
||||
delete_view_cmd: () => {},
|
||||
reload_vault: () => MOCK_ENTRIES,
|
||||
reload_vault_entry: (args: { path: string }) => MOCK_ENTRIES.find(e => e.path === args.path) ?? { path: args.path, title: 'Unknown', filename: 'unknown.md', aliases: [], belongsTo: [], relatedTo: [], archived: false, snippet: '', wordCount: 0, fileSize: 0, relationships: {}, outgoingLinks: [], properties: {} },
|
||||
sync_note_title: () => false,
|
||||
get_note_content: (args: { path: string }) => MOCK_CONTENT[args.path] ?? '',
|
||||
validate_note_content: (args: { path: string; content: string }) => (MOCK_CONTENT[args.path] ?? '') === args.content,
|
||||
get_all_content: () => MOCK_CONTENT,
|
||||
get_file_history: (args: { path: string }) => mockFileHistory(args.path),
|
||||
get_modified_files: () => {
|
||||
const base = mockHasChanges ? mockModifiedFiles() : []
|
||||
const basePaths = new Set(base.map(f => f.path))
|
||||
const extra: ModifiedFile[] = [...mockSavedSinceCommit]
|
||||
.filter(p => !basePaths.has(p))
|
||||
.map(p => ({ path: p, relativePath: p.replace(/^.*?\/Laputa\//, ''), status: 'modified' as const }))
|
||||
return [...base, ...extra]
|
||||
},
|
||||
get_file_diff: (args: { path: string }) => mockFileDiff(args.path),
|
||||
get_file_diff_at_commit: (args: { path: string; commitHash: string }) => mockFileDiffAtCommit(args.path, args.commitHash),
|
||||
git_discard_file: () => {},
|
||||
git_commit: (args: { message: string }) => {
|
||||
const count = (mockHasChanges ? mockModifiedFiles().length : 0) + mockSavedSinceCommit.size
|
||||
mockHasChanges = false
|
||||
mockSavedSinceCommit.clear()
|
||||
return `[main abc1234] ${args.message}\n ${count} files changed`
|
||||
},
|
||||
git_author_identity: () => ({
|
||||
name: 'Demo User',
|
||||
email: 'demo@example.com',
|
||||
source: 'global',
|
||||
warning: null,
|
||||
}),
|
||||
get_build_number: () => 'bDEV',
|
||||
should_use_external_media_preview: () => false,
|
||||
get_last_commit_info: (): LastCommitInfo => ({ shortHash: 'a1b2c3d', commitUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0' }),
|
||||
is_git_repo: () => true,
|
||||
init_git_repo: () => null,
|
||||
git_pull: (): GitPullResult => ({ status: 'up_to_date', message: 'Already up to date', updatedFiles: [], conflictFiles: [] }),
|
||||
git_push: (): GitPushResult => ({ status: 'ok', message: 'Pushed to remote' }),
|
||||
git_remote_status: (args?: { vaultPath?: string; vault_path?: string }): GitRemoteStatus => {
|
||||
const vaultPath = args?.vaultPath ?? args?.vault_path ?? mockLastVaultPath ?? DEFAULT_MOCK_VAULT_PATH
|
||||
return { branch: 'main', ahead: 0, behind: 0, hasRemote: getMockRemoteState(vaultPath) }
|
||||
},
|
||||
git_file_url: (args?: { vaultPath?: string; vault_path?: string; path?: string }): string | null => {
|
||||
const vaultPath = args?.vaultPath ?? args?.vault_path ?? mockLastVaultPath ?? DEFAULT_MOCK_VAULT_PATH
|
||||
if (!getMockRemoteState(vaultPath)) return null
|
||||
const path = args?.path?.replace(/^.*?\/Laputa\//, '') ?? 'note.md'
|
||||
return `https://github.com/lucaong/laputa-vault/blob/main/${encodeURI(path)}`
|
||||
},
|
||||
git_provider_status: (): GitProviderStatus => ({
|
||||
selected_provider: mockSettings.git_provider ?? 'native',
|
||||
selected_wsl_distro: mockSettings.git_wsl_distro ?? null,
|
||||
native: {
|
||||
provider: 'native',
|
||||
label: 'Native Git',
|
||||
available: true,
|
||||
version: 'git version 2.45.0',
|
||||
distro: null,
|
||||
path: null,
|
||||
message: 'Native Git is available: git version 2.45.0',
|
||||
},
|
||||
wsl_distributions: [{
|
||||
provider: 'wsl',
|
||||
label: 'WSL2 Git',
|
||||
available: true,
|
||||
version: 'git version 2.43.0',
|
||||
distro: 'Ubuntu',
|
||||
path: null,
|
||||
message: 'WSL2 Git is available: git version 2.43.0',
|
||||
}],
|
||||
}),
|
||||
test_git_provider: (args?: { provider?: string; distro?: string | null }): GitProviderProbe => {
|
||||
const provider = args?.provider === 'wsl' ? 'wsl' : 'native'
|
||||
return provider === 'wsl'
|
||||
? {
|
||||
provider,
|
||||
label: 'WSL2 Git',
|
||||
available: true,
|
||||
version: 'git version 2.43.0',
|
||||
distro: args?.distro ?? 'Ubuntu',
|
||||
path: null,
|
||||
message: 'WSL2 Git is available: git version 2.43.0',
|
||||
}
|
||||
: {
|
||||
provider,
|
||||
label: 'Native Git',
|
||||
available: true,
|
||||
version: 'git version 2.45.0',
|
||||
distro: null,
|
||||
path: null,
|
||||
message: 'Native Git is available: git version 2.45.0',
|
||||
}
|
||||
},
|
||||
git_add_remote: (args?: {
|
||||
request?: { vaultPath?: string; vault_path?: string; remoteUrl?: string }
|
||||
vaultPath?: string
|
||||
vault_path?: string
|
||||
remoteUrl?: string
|
||||
}): GitAddRemoteResult => {
|
||||
const request = args?.request ?? args ?? {}
|
||||
const vaultPath = request.vaultPath ?? request.vault_path ?? mockLastVaultPath ?? DEFAULT_MOCK_VAULT_PATH
|
||||
setMockRemoteState(vaultPath, true)
|
||||
return {
|
||||
status: 'connected',
|
||||
message: 'Remote connected. This vault now tracks origin/main.',
|
||||
}
|
||||
},
|
||||
get_vault_pulse: (args: { limit?: number }): PulseCommit[] => {
|
||||
const limit = args.limit ?? 30
|
||||
const ts = Math.floor(Date.now() / 1000)
|
||||
const commits: PulseCommit[] = [
|
||||
{ hash: 'a1b2c3d4e5f6', shortHash: 'a1b2c3d', message: 'Update project notes and add new experiment', date: ts - 3600, githubUrl: 'https://github.com/lucaong/laputa-vault/commit/a1b2c3d4e5f6', files: [{ path: '26q1-laputa-app.md', status: 'modified', title: '26q1 laputa app' }, { path: 'ai-search.md', status: 'added', title: 'ai search' }], added: 1, modified: 1, deleted: 0 },
|
||||
{ hash: 'b2c3d4e5f6g7', shortHash: 'b2c3d4e', message: 'Reorganize people notes', date: ts - 86400, githubUrl: 'https://github.com/lucaong/laputa-vault/commit/b2c3d4e5f6g7', files: [{ path: 'alice-johnson.md', status: 'modified', title: 'alice johnson' }, { path: 'bob-smith.md', status: 'modified', title: 'bob smith' }, { path: 'old-contact.md', status: 'deleted', title: 'old contact' }], added: 0, modified: 2, deleted: 1 },
|
||||
{ hash: 'c3d4e5f6g7h8', shortHash: 'c3d4e5f', message: 'Add daily journal entry', date: ts - 172800, githubUrl: null, files: [{ path: '2026-03-03.md', status: 'added', title: '2026 03 03' }], added: 1, modified: 0, deleted: 0 },
|
||||
]
|
||||
return commits.slice(0, limit)
|
||||
},
|
||||
get_conflict_files: (): string[] => [],
|
||||
get_conflict_mode: () => 'none',
|
||||
check_claude_cli: () => ({ installed: false, version: null }),
|
||||
get_ai_agents_status: () => ({
|
||||
claude_code: { installed: false, version: null },
|
||||
codex: { installed: false, version: null },
|
||||
copilot: { installed: false, version: null },
|
||||
opencode: { installed: false, version: null },
|
||||
pi: { installed: false, version: null },
|
||||
antigravity: { installed: false, version: null },
|
||||
kiro: { installed: false, version: null },
|
||||
hermes: { installed: false, version: null },
|
||||
}),
|
||||
get_agent_docs_path: () => '/mock/Tolaria/resources/agent-docs',
|
||||
get_vault_ai_guidance_status: () => ({ ...mockWorkspaceAiGuidanceStatus }),
|
||||
restore_vault_ai_guidance: () => {
|
||||
mockWorkspaceAiGuidanceStatus = {
|
||||
agents_state: 'managed',
|
||||
claude_state: 'managed',
|
||||
gemini_state: 'managed',
|
||||
can_restore: false,
|
||||
}
|
||||
return { ...mockWorkspaceAiGuidanceStatus }
|
||||
},
|
||||
stream_claude_chat: () => 'mock-session',
|
||||
stream_ai_agent: () => null,
|
||||
abort_ai_agent_stream: () => false,
|
||||
save_note_content: (args: { path: string; content: string }) => {
|
||||
MOCK_CONTENT[args.path] = args.content
|
||||
mockSavedSinceCommit.add(args.path)
|
||||
syncWindowContent()
|
||||
return null
|
||||
},
|
||||
save_image: (args: { vault_path?: string; filename: string; data: string }) => {
|
||||
const vault = args.vault_path ?? '/Users/luca/Laputa'
|
||||
return `${vault}/attachments/${Date.now()}-${args.filename}`
|
||||
},
|
||||
copy_image_to_vault: (args: { vault_path?: string; source_path: string }) => {
|
||||
const vault = args.vault_path ?? '/Users/luca/Laputa'
|
||||
const filename = args.source_path.split('/').pop() ?? 'image.png'
|
||||
return `${vault}/attachments/${Date.now()}-${filename}`
|
||||
},
|
||||
get_settings: () => ({ ...mockSettings }),
|
||||
save_settings: (args: { settings: Settings }) => {
|
||||
const s = args.settings
|
||||
mockSettings = {
|
||||
auto_pull_interval_minutes: s.auto_pull_interval_minutes ?? 5,
|
||||
git_enabled: s.git_enabled ?? null,
|
||||
git_path: s.git_path ?? null,
|
||||
git_provider: s.git_provider ?? null,
|
||||
git_wsl_distro: s.git_wsl_distro ?? null,
|
||||
autogit_enabled: s.autogit_enabled ?? false,
|
||||
autogit_idle_threshold_seconds: s.autogit_idle_threshold_seconds ?? 90,
|
||||
autogit_inactive_threshold_seconds: s.autogit_inactive_threshold_seconds ?? 30,
|
||||
auto_advance_inbox_after_organize: s.auto_advance_inbox_after_organize ?? false,
|
||||
telemetry_consent: s.telemetry_consent,
|
||||
crash_reporting_enabled: s.crash_reporting_enabled,
|
||||
analytics_enabled: s.analytics_enabled,
|
||||
anonymous_id: s.anonymous_id,
|
||||
release_channel: s.release_channel,
|
||||
automatic_update_checks_enabled: s.automatic_update_checks_enabled ?? null,
|
||||
theme_mode: s.theme_mode ?? null,
|
||||
ui_language: s.ui_language ?? null,
|
||||
date_display_format: s.date_display_format ?? null,
|
||||
note_width_mode: s.note_width_mode ?? null,
|
||||
sidebar_type_pluralization_enabled: s.sidebar_type_pluralization_enabled ?? null,
|
||||
initial_h1_auto_rename_enabled: s.initial_h1_auto_rename_enabled ?? null,
|
||||
ai_features_enabled: s.ai_features_enabled ?? null,
|
||||
default_ai_agent: s.default_ai_agent ?? null,
|
||||
default_ai_target: s.default_ai_target ?? null,
|
||||
ai_model_providers: s.ai_model_providers ?? null,
|
||||
ai_workspace_conversations: s.ai_workspace_conversations ?? null,
|
||||
hide_gitignored_files: s.hide_gitignored_files ?? null,
|
||||
all_notes_show_pdfs: s.all_notes_show_pdfs ?? null,
|
||||
all_notes_show_images: s.all_notes_show_images ?? null,
|
||||
all_notes_show_unsupported: s.all_notes_show_unsupported ?? null,
|
||||
multi_workspace_enabled: s.multi_workspace_enabled ?? null,
|
||||
}
|
||||
return null
|
||||
},
|
||||
load_vault_list: () => ({ ...mockVaultList, vaults: [...mockVaultList.vaults] }),
|
||||
save_vault_list: (args: { list: typeof mockVaultList }) => { mockVaultList = { ...args.list }; return null },
|
||||
rename_note: handleRenameNote,
|
||||
rename_note_filename: handleRenameNoteFilename,
|
||||
move_note_to_folder: handleMoveNoteToFolder,
|
||||
move_note_to_workspace: handleMoveNoteToWorkspace,
|
||||
clone_repo: (args: { url: string; localPath?: string; local_path?: string }) => {
|
||||
const localPath = args.localPath ?? args.local_path ?? ''
|
||||
setMockRemoteState(localPath, true)
|
||||
return `Cloned to ${localPath}`
|
||||
},
|
||||
clone_git_repo: (args: { url: string; localPath?: string; local_path?: string }) => {
|
||||
const localPath = args.localPath ?? args.local_path ?? ''
|
||||
setMockRemoteState(localPath, true)
|
||||
return `Cloned to ${localPath}`
|
||||
},
|
||||
purge_trash: () => [],
|
||||
delete_note: (args: { path: string }) => args.path,
|
||||
batch_delete_notes: (args: { paths: string[] }) => args.paths,
|
||||
empty_trash: () => [],
|
||||
migrate_is_a_to_type: () => 0,
|
||||
batch_archive_notes: (args: { paths: string[] }) => args.paths.length,
|
||||
batch_trash_notes: (args: { paths: string[] }) => args.paths.length,
|
||||
search_vault: (args: { query: string; mode: string; excludeFrontmatter?: boolean }) => {
|
||||
const q = (args.query ?? '').toLowerCase()
|
||||
if (!q) return { results: [], elapsed_ms: 0, query: q, mode: args.mode }
|
||||
const matches = MOCK_ENTRIES
|
||||
.filter(e => {
|
||||
const content = mockSearchContent(MOCK_CONTENT[e.path] ?? '', args.excludeFrontmatter)
|
||||
return e.title.toLowerCase().includes(q) || content.toLowerCase().includes(q)
|
||||
})
|
||||
.slice(0, 20)
|
||||
.map((e, i) => ({
|
||||
title: e.title,
|
||||
path: e.path,
|
||||
snippet: e.snippet || '',
|
||||
score: 1.0 - i * 0.05,
|
||||
note_type: e.isA,
|
||||
}))
|
||||
return { results: matches, elapsed_ms: 42, query: q, mode: args.mode }
|
||||
},
|
||||
get_last_vault_path: () => mockLastVaultPath,
|
||||
set_last_vault_path: (args: { path: string }) => { mockLastVaultPath = args.path; return null },
|
||||
get_default_vault_path: () => '/Users/mock/Documents/Getting Started',
|
||||
check_vault_exists: (args: { path: string }) => {
|
||||
// In mock mode, the demo-vault-v2 path always "exists"
|
||||
return args.path.includes('demo-vault-v2')
|
||||
},
|
||||
create_empty_vault: (args: { targetPath?: string; target_path?: string }) => {
|
||||
const targetPath = args.targetPath || args.target_path || '/Users/mock/Documents/My Vault'
|
||||
setMockRemoteState(targetPath, false)
|
||||
return targetPath
|
||||
},
|
||||
create_getting_started_vault: (args: { targetPath?: string | null }) => {
|
||||
const targetPath = args.targetPath || '/Users/mock/Documents/Getting Started'
|
||||
setMockRemoteState(targetPath, false)
|
||||
return targetPath
|
||||
},
|
||||
register_mcp_tools: () => 'registered',
|
||||
check_mcp_status: () => 'installed',
|
||||
get_mcp_config_snippet: () => JSON.stringify({
|
||||
mcpServers: {
|
||||
tolaria: {
|
||||
type: 'stdio',
|
||||
command: 'node',
|
||||
args: ['/mock/Tolaria/mcp-server/index.js'],
|
||||
env: {
|
||||
WS_UI_PORT: '9711',
|
||||
},
|
||||
},
|
||||
},
|
||||
}, null, 2),
|
||||
get_opencode_mcp_config_snippet: () => JSON.stringify({
|
||||
$schema: 'https://opencode.ai/config.json',
|
||||
mcp: {
|
||||
tolaria: {
|
||||
type: 'local',
|
||||
command: ['node', '/mock/Tolaria/mcp-server/index.js'],
|
||||
enabled: true,
|
||||
environment: {
|
||||
WS_UI_PORT: '9711',
|
||||
},
|
||||
},
|
||||
},
|
||||
}, null, 2),
|
||||
copy_text_to_clipboard: () => null,
|
||||
read_text_from_clipboard: () => '',
|
||||
sync_mcp_bridge_vault: (args: { vaultPath?: string | null }) => args.vaultPath ? 'started' : 'stopped',
|
||||
repair_vault: (): string => {
|
||||
mockWorkspaceAiGuidanceStatus = {
|
||||
agents_state: 'managed',
|
||||
claude_state: 'managed',
|
||||
gemini_state: 'managed',
|
||||
can_restore: false,
|
||||
}
|
||||
return 'Vault repaired'
|
||||
},
|
||||
reinit_telemetry: (): null => null,
|
||||
}
|
||||
|
||||
export function addMockEntry(_entry: VaultEntry, content: string): void {
|
||||
writeMockContent({ path: _entry.path, content })
|
||||
syncWindowContent()
|
||||
}
|
||||
|
||||
export function updateMockContent(path: string, content: string): void {
|
||||
writeMockContent({ path, content })
|
||||
syncWindowContent()
|
||||
}
|
||||
|
||||
export function trackMockChange(path: string): void {
|
||||
mockSavedSinceCommit.add(path)
|
||||
}
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const originalFetch = globalThis.fetch
|
||||
|
||||
function jsonResponse(body: unknown, status = 200) {
|
||||
return new Response(JSON.stringify(body), {
|
||||
status,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
})
|
||||
}
|
||||
|
||||
function requestUrl(input: RequestInfo | URL) {
|
||||
return input instanceof Request ? input.url : String(input)
|
||||
}
|
||||
|
||||
function requestBody(init?: RequestInit) {
|
||||
return JSON.parse(String(init?.body)) as Record<string, unknown>
|
||||
}
|
||||
|
||||
function mockNoteContentFetch(content: string) {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = requestUrl(input)
|
||||
if (url === '/api/vault/ping') {
|
||||
return jsonResponse({ ok: true })
|
||||
}
|
||||
if (url === '/api/vault/content') {
|
||||
expect(requestBody(init)).toEqual({ path: '/fixture/alpha.md' })
|
||||
return jsonResponse({ content })
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`)
|
||||
})
|
||||
globalThis.fetch = fetchMock as typeof fetch
|
||||
return fetchMock
|
||||
}
|
||||
|
||||
describe('tryVaultApi', () => {
|
||||
afterEach(() => {
|
||||
vi.resetModules()
|
||||
vi.restoreAllMocks()
|
||||
globalThis.fetch = originalFetch
|
||||
})
|
||||
|
||||
it('retries vault API discovery after an unavailable response', async () => {
|
||||
let vaultApiOnline = false
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = requestUrl(input)
|
||||
if (url === '/api/vault/ping') {
|
||||
return jsonResponse({ ok: vaultApiOnline }, vaultApiOnline ? 200 : 503)
|
||||
}
|
||||
if (url === '/api/vault/list') {
|
||||
expect(requestBody(init)).toEqual({ path: '/fixture', reload: false })
|
||||
return jsonResponse([{ title: 'Alpha Project' }])
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`)
|
||||
})
|
||||
globalThis.fetch = fetchMock as typeof fetch
|
||||
|
||||
const { tryVaultApi } = await import('./vault-api')
|
||||
|
||||
await expect(tryVaultApi('list_vault', { path: '/fixture' })).resolves.toBeUndefined()
|
||||
|
||||
vaultApiOnline = true
|
||||
|
||||
await expect(tryVaultApi('list_vault', { path: '/fixture' })).resolves.toEqual([{ title: 'Alpha Project' }])
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/api/vault/ping')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('unwraps note content responses from the vault API', async () => {
|
||||
const fetchMock = mockNoteContentFetch('# Alpha Project')
|
||||
const { tryVaultApi } = await import('./vault-api')
|
||||
|
||||
await expect(tryVaultApi('get_note_content', { path: '/fixture/alpha.md' })).resolves.toBe('# Alpha Project')
|
||||
expect(fetchMock.mock.calls.filter(([url]) => String(url) === '/api/vault/ping')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('validates cached note content through the vault API', async () => {
|
||||
mockNoteContentFetch('# Alpha Project')
|
||||
const { tryVaultApi } = await import('./vault-api')
|
||||
|
||||
await expect(tryVaultApi('validate_note_content', {
|
||||
path: '/fixture/alpha.md',
|
||||
content: '# Alpha Project',
|
||||
})).resolves.toBe(true)
|
||||
await expect(tryVaultApi('validate_note_content', {
|
||||
path: '/fixture/alpha.md',
|
||||
content: '# Stale',
|
||||
})).resolves.toBe(false)
|
||||
})
|
||||
|
||||
it('accepts nested Tauri command args when routing browser vault API writes', async () => {
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = requestUrl(input)
|
||||
if (url === '/api/vault/ping') {
|
||||
return jsonResponse({ ok: true })
|
||||
}
|
||||
if (url === '/api/vault/rename') {
|
||||
expect(requestBody(init)).toEqual({
|
||||
old_path: '/fixture/untitled-note-123.md',
|
||||
new_title: 'Fresh Title',
|
||||
vault_path: '/fixture',
|
||||
})
|
||||
return jsonResponse({ new_path: '/fixture/fresh-title.md', updated_files: 0 })
|
||||
}
|
||||
throw new Error(`Unexpected fetch: ${url}`)
|
||||
})
|
||||
globalThis.fetch = fetchMock as typeof fetch
|
||||
|
||||
const { tryVaultApi } = await import('./vault-api')
|
||||
|
||||
await expect(tryVaultApi('rename_note', {
|
||||
args: {
|
||||
old_path: '/fixture/untitled-note-123.md',
|
||||
new_title: 'Fresh Title',
|
||||
vault_path: '/fixture',
|
||||
},
|
||||
})).resolves.toEqual({ new_path: '/fixture/fresh-title.md', updated_files: 0 })
|
||||
})
|
||||
})
|
||||
217
product-source/hololake-platform/src/mock-tauri/vault-api.ts
Normal file
217
product-source/hololake-platform/src/mock-tauri/vault-api.ts
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
/**
|
||||
* Vault API detection and proxy for browser dev mode.
|
||||
* When a local vault API server is running, routes read and write commands
|
||||
* through it instead of returning hardcoded mock data.
|
||||
*/
|
||||
|
||||
let vaultApiAvailable: boolean | null = null
|
||||
|
||||
async function detectVaultApiAvailability(): Promise<boolean> {
|
||||
try {
|
||||
const res = await fetch('/api/vault/ping', { signal: AbortSignal.timeout(500) })
|
||||
return res.ok
|
||||
} catch (error) {
|
||||
void error
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
async function checkVaultApi(): Promise<boolean> {
|
||||
if (vaultApiAvailable === true) return true
|
||||
|
||||
const available = await detectVaultApiAvailability()
|
||||
vaultApiAvailable = available
|
||||
console.info(`[mock-tauri] Vault API available: ${vaultApiAvailable}`)
|
||||
return available
|
||||
}
|
||||
|
||||
interface VaultApiGetRequest {
|
||||
body: Record<string, unknown>
|
||||
kind: 'all-content' | 'content' | 'entry' | 'list' | 'search'
|
||||
}
|
||||
|
||||
interface VaultApiPostRequest {
|
||||
body: Record<string, unknown>
|
||||
kind: 'delete' | 'rename' | 'rename-filename' | 'save'
|
||||
}
|
||||
|
||||
type VaultApiRequest = VaultApiGetRequest | VaultApiPostRequest
|
||||
|
||||
/** Tracks last vault path for commands that don't receive it as an argument. */
|
||||
let lastVaultPath: string | null = null
|
||||
|
||||
type PathQueryCommand =
|
||||
| 'reload_vault_entry'
|
||||
| 'get_note_content'
|
||||
| 'validate_note_content'
|
||||
| 'get_all_content'
|
||||
|
||||
function argText(args: Record<string, unknown>, key: string): string | null {
|
||||
const value = Reflect.get(args, key)
|
||||
return value ? String(value) : null
|
||||
}
|
||||
|
||||
function commandArgs(args: Record<string, unknown>): Record<string, unknown> {
|
||||
const nestedArgs = Reflect.get(args, 'args')
|
||||
if (!nestedArgs || typeof nestedArgs !== 'object') return args
|
||||
return nestedArgs as Record<string, unknown>
|
||||
}
|
||||
|
||||
function buildListRequest(args: Record<string, unknown>, reload: boolean): VaultApiRequest | null {
|
||||
const payload = commandArgs(args)
|
||||
const path = argText(payload, 'path')
|
||||
if (!path) return null
|
||||
|
||||
lastVaultPath = path
|
||||
return { kind: 'list', body: { path, reload } }
|
||||
}
|
||||
|
||||
function buildPathQueryRequest(cmd: PathQueryCommand, args: Record<string, unknown>): VaultApiRequest | null {
|
||||
const payload = commandArgs(args)
|
||||
const path = argText(payload, 'path')
|
||||
if (!path) return null
|
||||
return { kind: pathQueryKind(cmd), body: { path } }
|
||||
}
|
||||
|
||||
function buildRequiredPostRequest(
|
||||
kind: VaultApiPostRequest['kind'],
|
||||
required: unknown,
|
||||
body: Record<string, unknown>,
|
||||
): VaultApiRequest | null {
|
||||
return required ? { kind, body } : null
|
||||
}
|
||||
|
||||
function buildRequiredPathPostRequest(
|
||||
kind: VaultApiPostRequest['kind'],
|
||||
args: Record<string, unknown>,
|
||||
body: Record<string, unknown>,
|
||||
): VaultApiRequest | null {
|
||||
return buildRequiredPostRequest(kind, args.path, body)
|
||||
}
|
||||
|
||||
function buildSearchRequest(args: Record<string, unknown>): VaultApiRequest | null {
|
||||
const payload = commandArgs(args)
|
||||
const query = argText(payload, 'query')
|
||||
if (!query || !lastVaultPath) return null
|
||||
|
||||
const mode = argText(payload, 'mode') ?? 'all'
|
||||
const body: Record<string, unknown> = { mode, query, vault_path: lastVaultPath }
|
||||
if (Reflect.get(payload, 'excludeFrontmatter') === true) body.exclude_frontmatter = true
|
||||
return { kind: 'search', body }
|
||||
}
|
||||
|
||||
function isPathQueryCommand(cmd: string): cmd is PathQueryCommand {
|
||||
return cmd === 'reload_vault_entry'
|
||||
|| cmd === 'get_note_content'
|
||||
|| cmd === 'validate_note_content'
|
||||
|| cmd === 'get_all_content'
|
||||
}
|
||||
|
||||
function pathQueryKind(command: PathQueryCommand): VaultApiGetRequest['kind'] {
|
||||
if (command === 'reload_vault_entry') return 'entry'
|
||||
if (command === 'get_all_content') return 'all-content'
|
||||
return 'content'
|
||||
}
|
||||
|
||||
function buildPostRequest(cmd: string, args: Record<string, unknown>): VaultApiRequest | null {
|
||||
const payload = commandArgs(args)
|
||||
if (cmd === 'save_note_content') {
|
||||
return buildRequiredPathPostRequest('save', payload, {
|
||||
content: payload.content,
|
||||
path: payload.path,
|
||||
})
|
||||
}
|
||||
if (cmd === 'rename_note') {
|
||||
return buildRequiredPostRequest('rename', payload.old_path, {
|
||||
new_title: payload.new_title,
|
||||
old_path: payload.old_path,
|
||||
vault_path: payload.vault_path,
|
||||
})
|
||||
}
|
||||
if (cmd === 'rename_note_filename') {
|
||||
return buildRequiredPostRequest('rename-filename', payload.old_path, {
|
||||
new_filename_stem: payload.new_filename_stem,
|
||||
old_path: payload.old_path,
|
||||
vault_path: payload.vault_path,
|
||||
})
|
||||
}
|
||||
if (cmd === 'delete_note') return buildRequiredPathPostRequest('delete', payload, { path: payload.path })
|
||||
return null
|
||||
}
|
||||
|
||||
function buildVaultApiRequest(cmd: string, args?: Record<string, unknown>): VaultApiRequest | null {
|
||||
if (!args) return null
|
||||
if (cmd === 'list_vault') return buildListRequest(args, false)
|
||||
if (cmd === 'reload_vault') return buildListRequest(args, true)
|
||||
if (cmd === 'search_vault') return buildSearchRequest(args)
|
||||
if (isPathQueryCommand(cmd)) return buildPathQueryRequest(cmd, args)
|
||||
return buildPostRequest(cmd, args)
|
||||
}
|
||||
|
||||
function buildFetchOptions(request: { body: Record<string, unknown> }): RequestInit {
|
||||
return {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(request.body),
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchVaultApiResponse(request: VaultApiRequest) {
|
||||
const res = await fetchVaultApiRequest(request)
|
||||
if (!res.ok) return undefined
|
||||
return res.json()
|
||||
}
|
||||
|
||||
function isGetRequest(request: VaultApiRequest): request is VaultApiGetRequest {
|
||||
return request.kind === 'all-content'
|
||||
|| request.kind === 'content'
|
||||
|| request.kind === 'entry'
|
||||
|| request.kind === 'list'
|
||||
|| request.kind === 'search'
|
||||
}
|
||||
|
||||
function fetchVaultApiGetRequest(request: VaultApiGetRequest): Promise<Response> {
|
||||
if (request.kind === 'all-content') {
|
||||
return fetch('/api/vault/all-content', buildFetchOptions(request))
|
||||
}
|
||||
if (request.kind === 'content') {
|
||||
return fetch('/api/vault/content', buildFetchOptions(request))
|
||||
}
|
||||
if (request.kind === 'entry') {
|
||||
return fetch('/api/vault/entry', buildFetchOptions(request))
|
||||
}
|
||||
if (request.kind === 'list') {
|
||||
return fetch('/api/vault/list', buildFetchOptions(request))
|
||||
}
|
||||
return fetch('/api/vault/search', buildFetchOptions(request))
|
||||
}
|
||||
|
||||
function fetchVaultApiPostRequest(request: VaultApiPostRequest): Promise<Response> {
|
||||
if (request.kind === 'delete') return fetch('/api/vault/delete', buildFetchOptions(request))
|
||||
if (request.kind === 'rename') return fetch('/api/vault/rename', buildFetchOptions(request))
|
||||
if (request.kind === 'rename-filename') return fetch('/api/vault/rename-filename', buildFetchOptions(request))
|
||||
return fetch('/api/vault/save', buildFetchOptions(request))
|
||||
}
|
||||
|
||||
function fetchVaultApiRequest(request: VaultApiRequest): Promise<Response> {
|
||||
return isGetRequest(request)
|
||||
? fetchVaultApiGetRequest(request)
|
||||
: fetchVaultApiPostRequest(request)
|
||||
}
|
||||
|
||||
export async function tryVaultApi<T>(cmd: string, args?: Record<string, unknown>): Promise<T | undefined> {
|
||||
const request = buildVaultApiRequest(cmd, args)
|
||||
if (!request) return undefined
|
||||
if (!await checkVaultApi()) return undefined
|
||||
|
||||
try {
|
||||
const data = await fetchVaultApiResponse(request)
|
||||
if (data === undefined) return undefined
|
||||
if (cmd === 'get_note_content') return data.content as T
|
||||
if (cmd === 'validate_note_content') return (data.content === args?.content) as T
|
||||
return data as T
|
||||
} catch (err) {
|
||||
console.warn(`[mock-tauri] Vault API call failed for ${cmd}, falling back to mock:`, err)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue