feat: trash notes — sidebar filter, breadcrumb buttons, cmd+del shortcut, demo vault data
This commit is contained in:
21
demo-vault-v2/experiment/experiment-failed-podcast.md
Normal file
21
demo-vault-v2/experiment/experiment-failed-podcast.md
Normal file
@@ -0,0 +1,21 @@
|
||||
---
|
||||
Is A: Experiment
|
||||
Status: Dropped
|
||||
trashed: true
|
||||
trashed_at: "2026-02-05"
|
||||
---
|
||||
# Failed Podcast Experiment
|
||||
|
||||
Tried launching a weekly podcast alongside the newsletter. The production overhead was too high for the audience growth it generated.
|
||||
|
||||
## Hypothesis
|
||||
A companion podcast would increase newsletter subscribers by 30% within 3 months.
|
||||
|
||||
## Results
|
||||
- Produced 8 episodes over 2 months
|
||||
- Gained only ~200 new subscribers (vs 2000 target)
|
||||
- Each episode took 4-6 hours to produce
|
||||
- ROI was negative compared to writing time
|
||||
|
||||
## Decision
|
||||
Dropped the podcast and reallocated time back to writing. The newsletter remains the primary channel.
|
||||
18
demo-vault-v2/note/note-deprecated-workflow.md
Normal file
18
demo-vault-v2/note/note-deprecated-workflow.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
Is A: Note
|
||||
trashed: true
|
||||
trashed_at: "2026-01-15"
|
||||
Topics: ["[[topic-productivity-systems]]"]
|
||||
---
|
||||
# Deprecated Workflow
|
||||
|
||||
An old workflow for managing sponsorship outreach that was replaced by the new CRM-based process. Keeping in trash in case any of the templates are useful later.
|
||||
|
||||
## Old Process
|
||||
1. Find potential sponsors via LinkedIn
|
||||
2. Send cold email using template A
|
||||
3. Follow up after 3 days
|
||||
4. Schedule call if interested
|
||||
|
||||
## Why Deprecated
|
||||
The manual process was too slow and error-prone. Switched to automated pipeline with CRM integration.
|
||||
18
demo-vault-v2/note/note-old-meeting-notes.md
Normal file
18
demo-vault-v2/note/note-old-meeting-notes.md
Normal file
@@ -0,0 +1,18 @@
|
||||
---
|
||||
Is A: Note
|
||||
trashed: true
|
||||
trashed_at: "2026-02-10"
|
||||
Topics: ["[[topic-productivity-systems]]"]
|
||||
---
|
||||
# Old Meeting Notes
|
||||
|
||||
These are meeting notes from a brainstorming session that didn't lead anywhere. The project was shelved and these notes are no longer relevant.
|
||||
|
||||
## Attendees
|
||||
- Luca
|
||||
- Matteo
|
||||
|
||||
## Discussion
|
||||
- Explored potential partnership with a media company
|
||||
- Decided not to pursue after evaluating the terms
|
||||
- Follow-up was scheduled but never happened
|
||||
6084
design/trash-notes.pen
Normal file
6084
design/trash-notes.pen
Normal file
File diff suppressed because it is too large
Load Diff
@@ -25,6 +25,9 @@ pub struct VaultEntry {
|
||||
pub owner: Option<String>,
|
||||
pub cadence: Option<String>,
|
||||
pub archived: bool,
|
||||
pub trashed: bool,
|
||||
#[serde(rename = "trashedAt")]
|
||||
pub trashed_at: Option<u64>,
|
||||
#[serde(rename = "modifiedAt")]
|
||||
pub modified_at: Option<u64>,
|
||||
#[serde(rename = "createdAt")]
|
||||
@@ -60,6 +63,10 @@ struct Frontmatter {
|
||||
cadence: Option<String>,
|
||||
#[serde(rename = "Archived")]
|
||||
archived: Option<bool>,
|
||||
#[serde(rename = "Trashed")]
|
||||
trashed: Option<bool>,
|
||||
#[serde(rename = "Trashed at")]
|
||||
trashed_at: Option<String>,
|
||||
#[serde(rename = "Created at")]
|
||||
created_at: Option<String>,
|
||||
#[serde(rename = "Created time")]
|
||||
@@ -213,8 +220,8 @@ fn parse_frontmatter(data: &HashMap<String, serde_json::Value>) -> Frontmatter {
|
||||
/// Known non-relationship frontmatter keys to skip (case-insensitive comparison).
|
||||
/// Only skip keys that can never contain wikilinks.
|
||||
const SKIP_KEYS: &[&str] = &[
|
||||
"is a", "aliases", "status", "cadence", "archived", "created at", "created time",
|
||||
"icon", "color",
|
||||
"is a", "aliases", "status", "cadence", "archived", "trashed", "trashed at",
|
||||
"created at", "created time", "icon", "color",
|
||||
];
|
||||
|
||||
/// Check if a string contains a wikilink pattern `[[...]]`.
|
||||
@@ -364,6 +371,8 @@ pub fn parse_md_file(path: &Path) -> Result<VaultEntry, String> {
|
||||
owner: frontmatter.owner,
|
||||
cadence: frontmatter.cadence,
|
||||
archived: frontmatter.archived.unwrap_or(false),
|
||||
trashed: frontmatter.trashed.unwrap_or(false),
|
||||
trashed_at: frontmatter.trashed_at.as_deref().and_then(parse_iso_date),
|
||||
modified_at, created_at, file_size,
|
||||
icon: frontmatter.icon,
|
||||
color: frontmatter.color,
|
||||
|
||||
18
src/App.tsx
18
src/App.tsx
@@ -112,10 +112,26 @@ function App() {
|
||||
vault.updateEntry(typeEntry.path, { icon, color })
|
||||
}, [vault, notes])
|
||||
|
||||
const handleTrashNote = useCallback(async (path: string) => {
|
||||
const now = new Date().toISOString().slice(0, 10)
|
||||
await notes.handleUpdateFrontmatter(path, 'trashed', true)
|
||||
await notes.handleUpdateFrontmatter(path, 'trashed_at', now)
|
||||
vault.updateEntry(path, { trashed: true, trashedAt: Date.now() / 1000 })
|
||||
setToastMessage('Note moved to trash')
|
||||
}, [notes, vault, setToastMessage])
|
||||
|
||||
const handleRestoreNote = useCallback(async (path: string) => {
|
||||
await notes.handleUpdateFrontmatter(path, 'trashed', false)
|
||||
await notes.handleDeleteProperty(path, 'trashed_at')
|
||||
vault.updateEntry(path, { trashed: false, trashedAt: null })
|
||||
setToastMessage('Note restored from trash')
|
||||
}, [notes, vault, setToastMessage])
|
||||
|
||||
useAppKeyboard({
|
||||
onQuickOpen: () => setShowQuickOpen(true),
|
||||
onCreateNote: openCreateDialog,
|
||||
onSave: () => setToastMessage('Saved'),
|
||||
onTrashNote: handleTrashNote,
|
||||
activeTabPathRef: notes.activeTabPathRef,
|
||||
handleCloseTabRef: notes.handleCloseTabRef,
|
||||
})
|
||||
@@ -193,6 +209,8 @@ function App() {
|
||||
showAIChat={showAIChat}
|
||||
onToggleAIChat={() => setShowAIChat(c => !c)}
|
||||
vaultPath={vaultPath}
|
||||
onTrashNote={handleTrashNote}
|
||||
onRestoreNote={handleRestoreNote}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
70
src/components/BreadcrumbBar.test.tsx
Normal file
70
src/components/BreadcrumbBar.test.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import { BreadcrumbBar } from './BreadcrumbBar'
|
||||
import type { VaultEntry } from '../types'
|
||||
|
||||
const baseEntry: VaultEntry = {
|
||||
path: '/vault/note/test.md',
|
||||
filename: 'test.md',
|
||||
title: 'Test Note',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
snippet: '',
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
}
|
||||
|
||||
const trashedEntry: VaultEntry = {
|
||||
...baseEntry,
|
||||
trashed: true,
|
||||
trashedAt: Date.now() / 1000 - 86400 * 5,
|
||||
}
|
||||
|
||||
const defaultProps = {
|
||||
wordCount: 100,
|
||||
isModified: false,
|
||||
showDiffToggle: false,
|
||||
diffMode: false,
|
||||
diffLoading: false,
|
||||
onToggleDiff: vi.fn(),
|
||||
}
|
||||
|
||||
describe('BreadcrumbBar — trash/restore', () => {
|
||||
it('shows trash button for non-trashed note', () => {
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onTrash={vi.fn()} onRestore={vi.fn()} />)
|
||||
expect(screen.getByTitle('Move to trash (Cmd+Delete)')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Restore from trash')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows restore button for trashed note', () => {
|
||||
render(<BreadcrumbBar entry={trashedEntry} {...defaultProps} onTrash={vi.fn()} onRestore={vi.fn()} />)
|
||||
expect(screen.getByTitle('Restore from trash')).toBeInTheDocument()
|
||||
expect(screen.queryByTitle('Move to trash (Cmd+Delete)')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('calls onTrash when trash button is clicked', () => {
|
||||
const onTrash = vi.fn()
|
||||
render(<BreadcrumbBar entry={baseEntry} {...defaultProps} onTrash={onTrash} onRestore={vi.fn()} />)
|
||||
fireEvent.click(screen.getByTitle('Move to trash (Cmd+Delete)'))
|
||||
expect(onTrash).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('calls onRestore when restore button is clicked', () => {
|
||||
const onRestore = vi.fn()
|
||||
render(<BreadcrumbBar entry={trashedEntry} {...defaultProps} onTrash={vi.fn()} onRestore={onRestore} />)
|
||||
fireEvent.click(screen.getByTitle('Restore from trash'))
|
||||
expect(onRestore).toHaveBeenCalledOnce()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
Sparkle,
|
||||
SlidersHorizontal,
|
||||
DotsThree,
|
||||
Trash,
|
||||
ArrowCounterClockwise,
|
||||
} from '@phosphor-icons/react'
|
||||
|
||||
interface BreadcrumbBarProps {
|
||||
@@ -22,6 +24,8 @@ interface BreadcrumbBarProps {
|
||||
onToggleAIChat?: () => void
|
||||
inspectorCollapsed?: boolean
|
||||
onToggleInspector?: () => void
|
||||
onTrash?: () => void
|
||||
onRestore?: () => void
|
||||
}
|
||||
|
||||
const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
@@ -29,6 +33,7 @@ const DISABLED_ICON_STYLE = { opacity: 0.4, cursor: 'not-allowed' } as const
|
||||
export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
entry, wordCount, isModified, showDiffToggle, diffMode, diffLoading,
|
||||
onToggleDiff, showAIChat, onToggleAIChat, inspectorCollapsed, onToggleInspector,
|
||||
onTrash, onRestore,
|
||||
}: BreadcrumbBarProps) {
|
||||
return (
|
||||
<div
|
||||
@@ -104,6 +109,23 @@ export const BreadcrumbBar = memo(function BreadcrumbBar({
|
||||
>
|
||||
<Sparkle size={16} weight={showAIChat ? 'fill' : 'regular'} />
|
||||
</button>
|
||||
{entry.trashed ? (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-foreground"
|
||||
onClick={onRestore}
|
||||
title="Restore from trash"
|
||||
>
|
||||
<ArrowCounterClockwise size={16} />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 cursor-pointer transition-colors text-muted-foreground hover:text-destructive"
|
||||
onClick={onTrash}
|
||||
title="Move to trash (Cmd+Delete)"
|
||||
>
|
||||
<Trash size={16} />
|
||||
</button>
|
||||
)}
|
||||
{inspectorCollapsed && (
|
||||
<button
|
||||
className="flex items-center justify-center border-none bg-transparent p-0 text-muted-foreground cursor-pointer hover:text-foreground transition-colors"
|
||||
|
||||
@@ -53,6 +53,8 @@ const mockEntry: VaultEntry = {
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
|
||||
@@ -48,6 +48,8 @@ interface EditorProps {
|
||||
showAIChat?: boolean
|
||||
onToggleAIChat?: () => void
|
||||
vaultPath?: string
|
||||
onTrashNote?: (path: string) => void
|
||||
onRestoreNote?: (path: string) => void
|
||||
}
|
||||
|
||||
// --- Custom Inline Content: WikiLink ---
|
||||
@@ -151,6 +153,7 @@ export const Editor = memo(function Editor({
|
||||
onUpdateFrontmatter, onDeleteProperty, onAddProperty,
|
||||
showAIChat, onToggleAIChat,
|
||||
vaultPath,
|
||||
onTrashNote, onRestoreNote,
|
||||
}: EditorProps) {
|
||||
const [diffMode, setDiffMode] = useState(false)
|
||||
const [diffContent, setDiffContent] = useState<string | null>(null)
|
||||
@@ -360,6 +363,8 @@ export const Editor = memo(function Editor({
|
||||
onToggleAIChat={onToggleAIChat}
|
||||
inspectorCollapsed={inspectorCollapsed}
|
||||
onToggleInspector={onToggleInspector}
|
||||
onTrash={onTrashNote ? () => onTrashNote(activeTab.entry.path) : undefined}
|
||||
onRestore={onRestoreNote ? () => onRestoreNote(activeTab.entry.path) : undefined}
|
||||
/>
|
||||
) : null
|
||||
|
||||
|
||||
@@ -15,6 +15,8 @@ const mockEntry: VaultEntry = {
|
||||
owner: 'Luca Rossi',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1707900000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
@@ -54,6 +56,8 @@ const referrerEntry: VaultEntry = {
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1707900000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
|
||||
@@ -4,7 +4,7 @@ import type { VaultEntry, GitCommit } from '../types'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
SlidersHorizontal, X, Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, FileText, StackSimple,
|
||||
Users, CalendarBlank, Tag, FileText, StackSimple, Trash,
|
||||
} from '@phosphor-icons/react'
|
||||
import { parseFrontmatter, type ParsedFrontmatter } from '../utils/frontmatter'
|
||||
import { DynamicPropertiesPanel, RELATIONSHIP_KEYS, containsWikilinks } from './DynamicPropertiesPanel'
|
||||
@@ -84,6 +84,8 @@ function RelationshipGroup({ label, refs, entries, onNavigate }: { label: string
|
||||
const resolved = resolveRef(ref, entries)
|
||||
const refType = resolved?.isA ?? undefined
|
||||
const isArchived = resolved?.archived ?? false
|
||||
const isTrashed = resolved?.trashed ?? false
|
||||
const isDimmed = isArchived || isTrashed
|
||||
const color = refType ? getTypeColor(refType) : 'var(--accent-blue)'
|
||||
const bgColor = refType ? getTypeLightColor(refType) : 'var(--accent-blue-light)'
|
||||
const TypeIcon = getTypeIcon(refType)
|
||||
@@ -92,19 +94,21 @@ function RelationshipGroup({ label, refs, entries, onNavigate }: { label: string
|
||||
key={`${ref}-${idx}`}
|
||||
className="flex items-center justify-between gap-2 border-none text-left cursor-pointer hover:opacity-80"
|
||||
style={{
|
||||
background: isArchived ? 'var(--muted)' : bgColor,
|
||||
color: isArchived ? 'var(--muted-foreground)' : color,
|
||||
background: isDimmed ? 'var(--muted)' : bgColor,
|
||||
color: isDimmed ? 'var(--muted-foreground)' : color,
|
||||
borderRadius: 6, padding: '6px 10px', fontSize: 12, fontWeight: 500,
|
||||
opacity: isArchived ? 0.7 : 1,
|
||||
opacity: isDimmed ? 0.7 : 1,
|
||||
}}
|
||||
onClick={() => onNavigate(wikilinkTarget(ref))}
|
||||
title={isArchived ? 'Archived' : undefined}
|
||||
title={isTrashed ? 'Trashed' : isArchived ? 'Archived' : undefined}
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
<span className="flex items-center gap-1 flex-1 truncate">
|
||||
{isTrashed && <Trash size={12} className="shrink-0" />}
|
||||
{wikilinkDisplay(ref)}
|
||||
{isArchived && <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>}
|
||||
{isTrashed && <span style={{ fontSize: 10, opacity: 0.8 }}>(trashed)</span>}
|
||||
{isArchived && !isTrashed && <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>}
|
||||
</span>
|
||||
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: isArchived ? 'var(--muted-foreground)' : color }} />
|
||||
<TypeIcon width={14} height={14} className="shrink-0" style={{ color: isDimmed ? 'var(--muted-foreground)' : color }} />
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
@@ -182,19 +186,22 @@ function BacklinksPanel({ backlinks, onNavigate }: { backlinks: VaultEntry[]; on
|
||||
{backlinks.map((e) => {
|
||||
const color = e.isA ? getTypeColor(e.isA) : 'var(--accent-blue)'
|
||||
const TypeIcon = getTypeIcon(e.isA ?? undefined)
|
||||
const isDimmed = e.archived || e.trashed
|
||||
return (
|
||||
<button
|
||||
key={e.path}
|
||||
className="flex items-center justify-between gap-2 border-none bg-transparent p-0 py-1 text-left text-[13px] cursor-pointer hover:opacity-80"
|
||||
style={{ color: e.archived ? 'var(--muted-foreground)' : color, opacity: e.archived ? 0.7 : 1 }}
|
||||
style={{ color: isDimmed ? 'var(--muted-foreground)' : color, opacity: isDimmed ? 0.7 : 1 }}
|
||||
onClick={() => onNavigate(e.title)}
|
||||
title={e.archived ? 'Archived' : undefined}
|
||||
title={e.trashed ? 'Trashed' : e.archived ? 'Archived' : undefined}
|
||||
>
|
||||
<span className="flex-1 truncate">
|
||||
<span className="flex items-center gap-1 flex-1 truncate">
|
||||
{e.trashed && <Trash size={12} className="shrink-0" />}
|
||||
{e.title}
|
||||
{e.archived && <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>}
|
||||
{e.trashed && <span style={{ fontSize: 10, opacity: 0.8 }}>(trashed)</span>}
|
||||
{e.archived && !e.trashed && <span style={{ marginLeft: 4, fontSize: 10, opacity: 0.8 }}>(archived)</span>}
|
||||
</span>
|
||||
{e.isA && <TypeIcon width={14} height={14} className="shrink-0" style={{ color: e.archived ? 'var(--muted-foreground)' : color }} />}
|
||||
{e.isA && <TypeIcon width={14} height={14} className="shrink-0" style={{ color: isDimmed ? 'var(--muted-foreground)' : color }} />}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { render, screen, fireEvent } from '@testing-library/react'
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { NoteList, getSortComparator } from './NoteList'
|
||||
import { NoteList, getSortComparator, filterEntries } from './NoteList'
|
||||
import type { VaultEntry, SidebarSelection } from '../types'
|
||||
|
||||
const allSelection: SidebarSelection = { kind: 'filter', filter: 'all' }
|
||||
@@ -19,6 +19,8 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
@@ -41,6 +43,8 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 847,
|
||||
@@ -64,6 +68,8 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 320,
|
||||
@@ -84,6 +90,8 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 512,
|
||||
@@ -104,6 +112,8 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
@@ -445,3 +455,129 @@ describe('NoteList sort controls', () => {
|
||||
expect(titles).toEqual(['Alpha Note', 'Zebra Note'])
|
||||
})
|
||||
})
|
||||
|
||||
// --- Trash feature tests ---
|
||||
|
||||
const trashedEntry: VaultEntry = {
|
||||
path: '/vault/note/old-draft.md',
|
||||
filename: 'old-draft.md',
|
||||
title: 'Old Draft Notes',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: true,
|
||||
trashedAt: Date.now() / 1000 - 86400 * 5,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 280,
|
||||
snippet: 'Some draft content that is no longer needed.',
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
}
|
||||
|
||||
const expiredTrashedEntry: VaultEntry = {
|
||||
path: '/vault/note/deprecated-api.md',
|
||||
filename: 'deprecated-api.md',
|
||||
title: 'Deprecated API Notes',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: true,
|
||||
trashedAt: Date.now() / 1000 - 86400 * 35,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 190,
|
||||
snippet: 'Old API docs replaced by v2.',
|
||||
relationships: {},
|
||||
icon: null,
|
||||
color: null,
|
||||
}
|
||||
|
||||
const entriesWithTrashed = [...mockEntries, trashedEntry, expiredTrashedEntry]
|
||||
|
||||
describe('filterEntries — trash', () => {
|
||||
it('excludes trashed entries from "all" filter', () => {
|
||||
const result = filterEntries(entriesWithTrashed, { kind: 'filter', filter: 'all' })
|
||||
expect(result.find((e) => e.title === 'Old Draft Notes')).toBeUndefined()
|
||||
expect(result.find((e) => e.title === 'Build Laputa App')).toBeDefined()
|
||||
})
|
||||
|
||||
it('excludes trashed entries from section group', () => {
|
||||
const result = filterEntries(entriesWithTrashed, { kind: 'sectionGroup', type: 'Note' })
|
||||
expect(result.find((e) => e.title === 'Old Draft Notes')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('trash filter returns only trashed entries', () => {
|
||||
const result = filterEntries(entriesWithTrashed, { kind: 'filter', filter: 'trash' })
|
||||
expect(result).toHaveLength(2)
|
||||
expect(result.every((e) => e.trashed)).toBe(true)
|
||||
})
|
||||
|
||||
it('archived filter excludes trashed entries', () => {
|
||||
const archivedAndTrashed = [
|
||||
...mockEntries,
|
||||
{ ...mockEntries[0], path: '/archived.md', archived: true, trashed: false, trashedAt: null, title: 'Archived Note' },
|
||||
trashedEntry,
|
||||
]
|
||||
const result = filterEntries(archivedAndTrashed, { kind: 'filter', filter: 'archived' })
|
||||
expect(result.find((e) => e.title === 'Archived Note')).toBeDefined()
|
||||
expect(result.find((e) => e.title === 'Old Draft Notes')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('topic filter excludes trashed entries', () => {
|
||||
const topicEntry: VaultEntry = { ...mockEntries[4] } // Software Development topic
|
||||
const trashedWithTopic: VaultEntry = {
|
||||
...trashedEntry,
|
||||
relatedTo: ['[[topic/software-development]]'],
|
||||
}
|
||||
const all = [...mockEntries, trashedWithTopic]
|
||||
const result = filterEntries(all, { kind: 'topic', entry: topicEntry })
|
||||
expect(result.find((e) => e.title === 'Old Draft Notes')).toBeUndefined()
|
||||
// Normal entry with that topic should still appear
|
||||
expect(result.find((e) => e.title === 'Build Laputa App')).toBeDefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('NoteList — trash view', () => {
|
||||
const trashSelection: SidebarSelection = { kind: 'filter', filter: 'trash' }
|
||||
|
||||
it('shows "Trash" header when trash filter is active', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Trash')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows only trashed entries in trash view', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Old Draft Notes')).toBeInTheDocument()
|
||||
expect(screen.getByText('Deprecated API Notes')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Build Laputa App')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows TRASHED badge on trashed entries', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
const badges = screen.getAllByText('TRASHED')
|
||||
expect(badges.length).toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('shows 30-day warning banner when expired notes exist', () => {
|
||||
render(<NoteList entries={entriesWithTrashed} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Notes in trash for 30+ days will be permanently deleted')).toBeInTheDocument()
|
||||
expect(screen.getByText(/1 note is past the 30-day retention period/)).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows "Trash is empty" when no trashed entries', () => {
|
||||
render(<NoteList entries={mockEntries} selection={trashSelection} selectedNote={null} onSelectNote={noopSelect} allContent={{}} onCreateNote={vi.fn()} />)
|
||||
expect(screen.getByText('Trash is empty')).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@ import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
MagnifyingGlass, Plus, Wrench, Flask, Target, ArrowsClockwise,
|
||||
Users, CalendarBlank, Tag, FileText, CaretDown, CaretRight, StackSimple,
|
||||
ArrowsDownUp, Check,
|
||||
ArrowsDownUp, Check, Warning,
|
||||
} from '@phosphor-icons/react'
|
||||
import type { ComponentType, SVGAttributes } from 'react'
|
||||
import { getTypeColor, getTypeLightColor } from '../utils/typeColors'
|
||||
@@ -255,20 +255,22 @@ export function filterEntries(entries: VaultEntry[], selection: SidebarSelection
|
||||
case 'filter':
|
||||
switch (selection.filter) {
|
||||
case 'all':
|
||||
return entries.filter((e) => !e.archived)
|
||||
return entries.filter((e) => !e.archived && !e.trashed)
|
||||
case 'favorites':
|
||||
return []
|
||||
case 'archived':
|
||||
return entries.filter((e) => e.archived)
|
||||
return entries.filter((e) => e.archived && !e.trashed)
|
||||
case 'trash':
|
||||
return entries.filter((e) => e.trashed)
|
||||
}
|
||||
break
|
||||
case 'sectionGroup':
|
||||
return entries.filter((e) => e.isA === selection.type && !e.archived)
|
||||
return entries.filter((e) => e.isA === selection.type && !e.archived && !e.trashed)
|
||||
case 'entity':
|
||||
return []
|
||||
case 'topic': {
|
||||
const topic = selection.entry
|
||||
return entries.filter((e) => refsMatch(e.relatedTo, topic) && !e.archived)
|
||||
return entries.filter((e) => refsMatch(e.relatedTo, topic) && !e.archived && !e.trashed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -350,6 +352,7 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
|
||||
const isEntityView = selection.kind === 'entity'
|
||||
const isSectionGroup = selection.kind === 'sectionGroup'
|
||||
const isTrashView = selection.kind === 'filter' && selection.filter === 'trash'
|
||||
|
||||
const handleSortChange = useCallback((groupLabel: string, option: SortOption) => {
|
||||
setSortPrefs((prev) => {
|
||||
@@ -421,6 +424,12 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
)
|
||||
|
||||
|
||||
const expiredTrashCount = useMemo(() => {
|
||||
if (!isTrashView) return 0
|
||||
const now = Date.now() / 1000
|
||||
return searched.filter((e) => e.trashedAt && (now - e.trashedAt) >= 86400 * 30).length
|
||||
}, [isTrashView, searched])
|
||||
|
||||
const renderItem = useCallback((entry: VaultEntry, isPinned = false) => {
|
||||
const isSelected = selectedNote?.path === entry.path && !isPinned
|
||||
const te = typeEntryMap[entry.isA ?? '']
|
||||
@@ -466,13 +475,23 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
ARCHIVED
|
||||
</span>
|
||||
)}
|
||||
{entry.trashed && (
|
||||
<span
|
||||
className="ml-1.5 inline-block align-middle"
|
||||
style={{ fontSize: 9, fontWeight: 500, background: 'var(--destructive-light, #ef44441a)', color: 'var(--destructive)', borderRadius: 4, padding: '1px 4px', verticalAlign: 'middle' }}
|
||||
>
|
||||
TRASHED
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[12px] leading-[1.5] text-muted-foreground" style={{ display: '-webkit-box', WebkitLineClamp: 2, WebkitBoxOrient: 'vertical', overflow: 'hidden' }}>
|
||||
{entry.snippet}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground">
|
||||
{relativeDate(getDisplayDate(entry))}
|
||||
<div className="mt-0.5 text-[10px] text-muted-foreground" style={entry.trashed && entry.trashedAt && (Date.now() / 1000 - entry.trashedAt) >= 86400 * 30 ? { color: 'var(--destructive)', fontWeight: 500 } : undefined}>
|
||||
{entry.trashed && entry.trashedAt
|
||||
? `Trashed ${relativeDate(entry.trashedAt)}${(Date.now() / 1000 - entry.trashedAt) >= 86400 * 30 ? ' — will be permanently deleted' : ''}`
|
||||
: relativeDate(getDisplayDate(entry))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
@@ -571,7 +590,9 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
? typeDocument.title
|
||||
: selection.kind === 'filter' && (selection as { filter: string }).filter === 'archived'
|
||||
? 'Archive'
|
||||
: 'Notes'}
|
||||
: selection.kind === 'filter' && (selection as { filter: string }).filter === 'trash'
|
||||
? 'Trash'
|
||||
: 'Notes'}
|
||||
</h3>
|
||||
<div className="flex items-center gap-3" style={{ WebkitAppRegion: 'no-drag' } as React.CSSProperties}>
|
||||
{!isEntityView && (
|
||||
@@ -646,8 +667,27 @@ function NoteListInner({ entries, selection, selectedNote, allContent, modifiedF
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
{/* 30-day warning banner for trash view */}
|
||||
{isTrashView && expiredTrashCount > 0 && (
|
||||
<div
|
||||
className="flex items-start gap-2 border-b border-[var(--border)]"
|
||||
style={{ padding: '10px 12px', background: 'color-mix(in srgb, var(--destructive) 6%, transparent)' }}
|
||||
>
|
||||
<Warning size={16} className="shrink-0" style={{ color: 'var(--destructive)', marginTop: 1 }} />
|
||||
<div>
|
||||
<div style={{ fontSize: 12, fontWeight: 600, color: 'var(--destructive)' }}>
|
||||
Notes in trash for 30+ days will be permanently deleted
|
||||
</div>
|
||||
<div className="text-muted-foreground" style={{ fontSize: 11 }}>
|
||||
{expiredTrashCount} {expiredTrashCount === 1 ? 'note is' : 'notes are'} past the 30-day retention period
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{searched.length === 0 ? (
|
||||
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">No notes found</div>
|
||||
<div className="px-4 py-8 text-center text-[13px] text-muted-foreground">
|
||||
{isTrashView ? 'Trash is empty' : 'No notes found'}
|
||||
</div>
|
||||
) : (
|
||||
searched.map((entry) => renderItem(entry))
|
||||
)}
|
||||
|
||||
@@ -28,6 +28,8 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 1024,
|
||||
@@ -48,6 +50,8 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 512,
|
||||
@@ -68,6 +72,8 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: 'Luca',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
@@ -88,6 +94,8 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: 'Luca',
|
||||
cadence: 'Weekly',
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 128,
|
||||
@@ -108,6 +116,8 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 256,
|
||||
@@ -128,6 +138,8 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 180,
|
||||
@@ -148,6 +160,8 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 100,
|
||||
@@ -168,6 +182,8 @@ const mockEntries: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
@@ -315,6 +331,8 @@ describe('Sidebar', () => {
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
@@ -335,6 +353,8 @@ describe('Sidebar', () => {
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
@@ -355,6 +375,8 @@ describe('Sidebar', () => {
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 300,
|
||||
@@ -403,6 +425,8 @@ describe('Sidebar', () => {
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: 1700000000,
|
||||
createdAt: null,
|
||||
fileSize: 200,
|
||||
|
||||
@@ -136,6 +136,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
|
||||
)
|
||||
|
||||
const archivedCount = useMemo(() => entries.filter((e) => e.archived).length, [entries])
|
||||
const trashedCount = useMemo(() => entries.filter((e) => e.trashed).length, [entries])
|
||||
|
||||
// Close context menu on outside click
|
||||
useEffect(() => {
|
||||
@@ -192,7 +193,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
|
||||
}, [customizeTarget, typeEntryMap, onCustomizeType])
|
||||
|
||||
const renderSection = ({ label, type, Icon, customColor }: SectionGroup) => {
|
||||
const items = entries.filter((e) => e.isA === type && !e.archived)
|
||||
const items = entries.filter((e) => e.isA === type && !e.archived && !e.trashed)
|
||||
const isCollapsed = collapsed[type] ?? true
|
||||
const isTopic = type === 'Topic'
|
||||
const isTypeSection = type === 'Type'
|
||||
@@ -293,7 +294,7 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
|
||||
{/* Top nav — All Notes + Favorites */}
|
||||
<div className="border-b border-border" style={{ padding: '4px 6px' }}>
|
||||
{TOP_NAV.map(({ label, filter, Icon }) => {
|
||||
const count = filter === 'all' ? entries.filter((e) => !e.archived).length : 0
|
||||
const count = filter === 'all' ? entries.filter((e) => !e.archived && !e.trashed).length : 0
|
||||
return (
|
||||
<div
|
||||
key={filter}
|
||||
@@ -350,13 +351,27 @@ export const Sidebar = memo(function Sidebar({ entries, selection, onSelect, onS
|
||||
<TagSimple size={16} />
|
||||
<span className="flex-1 text-[13px] font-medium">Untagged</span>
|
||||
</div>
|
||||
{/* Trash filter */}
|
||||
<div
|
||||
className="flex select-none items-center gap-2 rounded text-foreground"
|
||||
style={{ padding: '6px 16px', borderRadius: 4, opacity: 0.4, cursor: 'not-allowed' }}
|
||||
title="Coming soon"
|
||||
className={cn(
|
||||
"flex cursor-pointer select-none items-center gap-2 rounded transition-colors",
|
||||
isActive({ kind: 'filter', filter: 'trash' })
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "text-foreground hover:bg-accent"
|
||||
)}
|
||||
style={{ padding: '6px 16px', borderRadius: 4 }}
|
||||
onClick={() => onSelect({ kind: 'filter', filter: 'trash' })}
|
||||
>
|
||||
<Trash size={16} />
|
||||
<span className="flex-1 text-[13px] font-medium">Trash</span>
|
||||
{trashedCount > 0 && (
|
||||
<span
|
||||
className="flex items-center justify-center text-muted-foreground"
|
||||
style={{ height: 20, borderRadius: 9999, padding: '0 6px', fontSize: 10, background: 'var(--muted)' }}
|
||||
>
|
||||
{trashedCount}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -4,12 +4,13 @@ interface KeyboardActions {
|
||||
onQuickOpen: () => void
|
||||
onCreateNote: () => void
|
||||
onSave: () => void
|
||||
onTrashNote: (path: string) => void
|
||||
activeTabPathRef: React.MutableRefObject<string | null>
|
||||
handleCloseTabRef: React.MutableRefObject<(path: string) => void>
|
||||
}
|
||||
|
||||
export function useAppKeyboard({
|
||||
onQuickOpen, onCreateNote, onSave,
|
||||
onQuickOpen, onCreateNote, onSave, onTrashNote,
|
||||
activeTabPathRef, handleCloseTabRef,
|
||||
}: KeyboardActions) {
|
||||
useEffect(() => {
|
||||
@@ -36,9 +37,16 @@ export function useAppKeyboard({
|
||||
if (path) handleCloseTabRef.current(path)
|
||||
break
|
||||
}
|
||||
case 'Backspace':
|
||||
case 'Delete': {
|
||||
e.preventDefault()
|
||||
const path = activeTabPathRef.current
|
||||
if (path) onTrashNote(path)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', handleKeyDown)
|
||||
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||
}, [onQuickOpen, onCreateNote, onSave, activeTabPathRef, handleCloseTabRef])
|
||||
}, [onQuickOpen, onCreateNote, onSave, onTrashNote, activeTabPathRef, handleCloseTabRef])
|
||||
}
|
||||
|
||||
@@ -213,7 +213,8 @@ export function useNoteActions(
|
||||
path, filename: `${slug}.md`, title, isA: type,
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: noStatusTypes.has(type) ? null : 'Active',
|
||||
owner: null, cadence: null, archived: false, modifiedAt: now, createdAt: now, fileSize: 0,
|
||||
owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: now, createdAt: now, fileSize: 0,
|
||||
snippet: '', relationships: {}, icon: null, color: null,
|
||||
}
|
||||
|
||||
@@ -240,7 +241,7 @@ export function useNoteActions(
|
||||
const newEntry: VaultEntry = {
|
||||
path, filename: `${slug}.md`, title: typeName, isA: 'Type',
|
||||
aliases: [], belongsTo: [], relatedTo: [],
|
||||
status: null, owner: null, cadence: null, archived: false,
|
||||
status: null, owner: null, cadence: null, archived: false, trashed: false, trashedAt: null,
|
||||
modifiedAt: now, createdAt: now, fileSize: 0,
|
||||
snippet: '', relationships: {}, icon: null, color: null,
|
||||
}
|
||||
|
||||
@@ -583,6 +583,45 @@ A **book** you're reading or have read. Track reading progress, notes, and key t
|
||||
- **Author**: The book's author
|
||||
- **Status**: Reading, Finished, Abandoned
|
||||
- **Rating**: 1-5 stars
|
||||
`,
|
||||
// --- Trashed entries ---
|
||||
'/Users/luca/Laputa/note/old-draft-notes.md': `---
|
||||
title: Old Draft Notes
|
||||
is_a: Note
|
||||
trashed: true
|
||||
trashed_at: ${new Date(Date.now() - 86400000 * 5).toISOString().slice(0, 10)}
|
||||
belongs_to:
|
||||
- "[[project/26q1-laputa-app]]"
|
||||
---
|
||||
|
||||
# Old Draft Notes
|
||||
|
||||
Some rough draft content that is no longer relevant. Moving to trash.
|
||||
`,
|
||||
'/Users/luca/Laputa/note/deprecated-api-notes.md': `---
|
||||
title: Deprecated API Notes
|
||||
is_a: Note
|
||||
trashed: true
|
||||
trashed_at: ${new Date(Date.now() - 86400000 * 35).toISOString().slice(0, 10)}
|
||||
---
|
||||
|
||||
# Deprecated API Notes
|
||||
|
||||
Old API documentation for the v1 endpoint. Replaced by v2 docs.
|
||||
`,
|
||||
'/Users/luca/Laputa/experiment/failed-seo-experiment.md': `---
|
||||
title: Failed SEO Experiment
|
||||
is_a: Experiment
|
||||
status: Dropped
|
||||
trashed: true
|
||||
trashed_at: ${new Date(Date.now() - 86400000 * 10).toISOString().slice(0, 10)}
|
||||
related_to:
|
||||
- "[[responsibility/grow-newsletter]]"
|
||||
---
|
||||
|
||||
# Failed SEO Experiment
|
||||
|
||||
Tried programmatic SEO pages. Results were negligible — trashing this.
|
||||
`,
|
||||
// --- Archived entries ---
|
||||
'/Users/luca/Laputa/project/25q3-website-redesign.md': `---
|
||||
@@ -672,6 +711,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: 'Luca Rossi',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000,
|
||||
createdAt: Date.now() / 1000 - 86400 * 60,
|
||||
fileSize: 2048,
|
||||
@@ -696,6 +737,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: 'Luca Rossi',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 3600,
|
||||
createdAt: Date.now() / 1000 - 86400 * 180,
|
||||
fileSize: 1024,
|
||||
@@ -725,6 +768,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: 'Matteo Cellini',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 7200,
|
||||
createdAt: Date.now() / 1000 - 86400 * 150,
|
||||
fileSize: 890,
|
||||
@@ -748,6 +793,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: 'Luca Rossi',
|
||||
cadence: 'Weekly',
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400,
|
||||
createdAt: Date.now() / 1000 - 86400 * 120,
|
||||
fileSize: 512,
|
||||
@@ -771,6 +818,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: 'Matteo Cellini',
|
||||
cadence: 'Weekly',
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 2,
|
||||
createdAt: Date.now() / 1000 - 86400 * 100,
|
||||
fileSize: 640,
|
||||
@@ -794,6 +843,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: 'Luca Rossi',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400,
|
||||
createdAt: Date.now() / 1000 - 86400 * 45,
|
||||
fileSize: 3200,
|
||||
@@ -818,6 +869,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 3600 * 5,
|
||||
createdAt: Date.now() / 1000 - 86400 * 30,
|
||||
fileSize: 847,
|
||||
@@ -842,6 +895,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400,
|
||||
createdAt: Date.now() / 1000 - 86400 * 20,
|
||||
fileSize: 560,
|
||||
@@ -865,6 +920,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 7,
|
||||
createdAt: Date.now() / 1000 - 86400 * 200,
|
||||
fileSize: 320,
|
||||
@@ -887,6 +944,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 3600 * 2,
|
||||
createdAt: Date.now() / 1000 - 86400 * 7,
|
||||
fileSize: 1200,
|
||||
@@ -910,6 +969,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 30,
|
||||
createdAt: Date.now() / 1000 - 86400 * 365,
|
||||
fileSize: 256,
|
||||
@@ -933,6 +994,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 14,
|
||||
createdAt: Date.now() / 1000 - 86400 * 300,
|
||||
fileSize: 180,
|
||||
@@ -956,6 +1019,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 3,
|
||||
createdAt: Date.now() / 1000 - 86400 * 14,
|
||||
fileSize: 4200,
|
||||
@@ -979,6 +1044,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 7,
|
||||
createdAt: Date.now() / 1000 - 86400 * 30,
|
||||
fileSize: 3800,
|
||||
@@ -1003,6 +1070,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 10,
|
||||
createdAt: Date.now() / 1000 - 86400 * 21,
|
||||
fileSize: 5100,
|
||||
@@ -1027,6 +1096,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 90,
|
||||
createdAt: Date.now() / 1000 - 86400 * 365,
|
||||
fileSize: 320,
|
||||
@@ -1047,6 +1118,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 90,
|
||||
createdAt: Date.now() / 1000 - 86400 * 365,
|
||||
fileSize: 280,
|
||||
@@ -1067,6 +1140,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 90,
|
||||
createdAt: Date.now() / 1000 - 86400 * 365,
|
||||
fileSize: 310,
|
||||
@@ -1087,6 +1162,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 90,
|
||||
createdAt: Date.now() / 1000 - 86400 * 365,
|
||||
fileSize: 290,
|
||||
@@ -1107,6 +1184,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 90,
|
||||
createdAt: Date.now() / 1000 - 86400 * 365,
|
||||
fileSize: 200,
|
||||
@@ -1127,6 +1206,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 90,
|
||||
createdAt: Date.now() / 1000 - 86400 * 365,
|
||||
fileSize: 180,
|
||||
@@ -1147,6 +1228,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 90,
|
||||
createdAt: Date.now() / 1000 - 86400 * 365,
|
||||
fileSize: 170,
|
||||
@@ -1167,6 +1250,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 90,
|
||||
createdAt: Date.now() / 1000 - 86400 * 365,
|
||||
fileSize: 200,
|
||||
@@ -1187,6 +1272,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 90,
|
||||
createdAt: Date.now() / 1000 - 86400 * 365,
|
||||
fileSize: 190,
|
||||
@@ -1208,6 +1295,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 30,
|
||||
createdAt: Date.now() / 1000 - 86400 * 365,
|
||||
fileSize: 250,
|
||||
@@ -1228,6 +1317,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 30,
|
||||
createdAt: Date.now() / 1000 - 86400 * 365,
|
||||
fileSize: 220,
|
||||
@@ -1249,6 +1340,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 5,
|
||||
createdAt: Date.now() / 1000 - 86400 * 10,
|
||||
fileSize: 420,
|
||||
@@ -1271,6 +1364,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 14,
|
||||
createdAt: Date.now() / 1000 - 86400 * 60,
|
||||
fileSize: 380,
|
||||
@@ -1281,6 +1376,81 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
// --- Trashed entries ---
|
||||
{
|
||||
path: '/Users/luca/Laputa/note/old-draft-notes.md',
|
||||
filename: 'old-draft-notes.md',
|
||||
title: 'Old Draft Notes',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: ['[[project/26q1-laputa-app]]'],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: true,
|
||||
trashedAt: Date.now() / 1000 - 86400 * 5,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 10,
|
||||
createdAt: null,
|
||||
fileSize: 280,
|
||||
snippet: 'Some rough draft content that is no longer relevant. Moving to trash.',
|
||||
relationships: {
|
||||
'Belongs to': ['[[project/26q1-laputa-app]]'],
|
||||
'Type': ['[[type/note]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/note/deprecated-api-notes.md',
|
||||
filename: 'deprecated-api-notes.md',
|
||||
title: 'Deprecated API Notes',
|
||||
isA: 'Note',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: [],
|
||||
status: null,
|
||||
owner: null,
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: true,
|
||||
trashedAt: Date.now() / 1000 - 86400 * 35,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 40,
|
||||
createdAt: null,
|
||||
fileSize: 190,
|
||||
snippet: 'Old API documentation for the v1 endpoint. Replaced by v2 docs.',
|
||||
relationships: {
|
||||
'Type': ['[[type/note]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
{
|
||||
path: '/Users/luca/Laputa/experiment/failed-seo-experiment.md',
|
||||
filename: 'failed-seo-experiment.md',
|
||||
title: 'Failed SEO Experiment',
|
||||
isA: 'Experiment',
|
||||
aliases: [],
|
||||
belongsTo: [],
|
||||
relatedTo: ['[[responsibility/grow-newsletter]]'],
|
||||
status: 'Dropped',
|
||||
owner: 'Luca Rossi',
|
||||
cadence: null,
|
||||
archived: false,
|
||||
trashed: true,
|
||||
trashedAt: Date.now() / 1000 - 86400 * 10,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 15,
|
||||
createdAt: null,
|
||||
fileSize: 340,
|
||||
snippet: 'Tried programmatic SEO pages. Results were negligible — trashing this.',
|
||||
relationships: {
|
||||
'Related to': ['[[responsibility/grow-newsletter]]'],
|
||||
'Type': ['[[type/experiment]]'],
|
||||
},
|
||||
icon: null,
|
||||
color: null,
|
||||
},
|
||||
// --- Archived entries ---
|
||||
{
|
||||
path: '/Users/luca/Laputa/project/25q3-website-redesign.md',
|
||||
@@ -1294,6 +1464,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: 'Luca Rossi',
|
||||
cadence: null,
|
||||
archived: true,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
icon: null,
|
||||
color: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 120,
|
||||
@@ -1317,6 +1489,8 @@ const MOCK_ENTRIES: VaultEntry[] = [
|
||||
owner: 'Luca Rossi',
|
||||
cadence: null,
|
||||
archived: true,
|
||||
trashed: false,
|
||||
trashedAt: null,
|
||||
icon: null,
|
||||
color: null,
|
||||
modifiedAt: Date.now() / 1000 - 86400 * 90,
|
||||
|
||||
@@ -10,6 +10,8 @@ export interface VaultEntry {
|
||||
owner: string | null
|
||||
cadence: string | null
|
||||
archived: boolean
|
||||
trashed: boolean
|
||||
trashedAt: number | null
|
||||
modifiedAt: number | null
|
||||
createdAt: number | null
|
||||
fileSize: number
|
||||
@@ -37,7 +39,7 @@ export interface ModifiedFile {
|
||||
}
|
||||
|
||||
export type SidebarSelection =
|
||||
| { kind: 'filter'; filter: 'all' | 'favorites' | 'archived' }
|
||||
| { kind: 'filter'; filter: 'all' | 'favorites' | 'archived' | 'trash' }
|
||||
| { kind: 'sectionGroup'; type: string }
|
||||
| { kind: 'entity'; entry: VaultEntry }
|
||||
| { kind: 'topic'; entry: VaultEntry }
|
||||
|
||||
Reference in New Issue
Block a user