From 8b0b60db335dbf23391066941097503ccd6c0a6a Mon Sep 17 00:00:00 2001 From: Erwin Date: Sat, 28 Mar 2026 03:31:14 +0000 Subject: [PATCH] fix: add missing libraryService.js and fix two bugs - Create missing src/services/libraryService.js (was imported but never created) - Fix readJSONFile import in tagIndexer.js (unused import) - Fix found.found?.libId typo in deleteDocument (double .found lookup) These were blocking the application from starting. --- src/indexers/tagIndexer.js | 2 +- src/services/documentService.js | 1 - src/services/libraryService.js | 273 ++++++++++++++++++++++++++++ ui/SPEC.md | 309 ++++++++++++++++++++++++++++++++ 4 files changed, 583 insertions(+), 2 deletions(-) create mode 100644 src/services/libraryService.js create mode 100644 ui/SPEC.md diff --git a/src/indexers/tagIndexer.js b/src/indexers/tagIndexer.js index 344bb52..3107b74 100644 --- a/src/indexers/tagIndexer.js +++ b/src/indexers/tagIndexer.js @@ -3,7 +3,7 @@ * Rebuild and query the global tag index */ -import { readJSON, writeJSON, pathExists, listDir, readJSONFile } from '../utils/fsHelper.js'; +import { readJSON, writeJSON, pathExists, listDir } from '../utils/fsHelper.js'; import { join } from 'path'; import config from '../config/index.js'; diff --git a/src/services/documentService.js b/src/services/documentService.js index 5e41d12..1f1d48c 100644 --- a/src/services/documentService.js +++ b/src/services/documentService.js @@ -276,7 +276,6 @@ export class DocumentService { } const meta = readJSON(found.metaPath); - deletePath(found.found?.libId ? join(this.librariesPath, found.libId, 'documents', docId) : null); deletePath(this._docPath(found.libId, docId)); this.tagIndexer.removeDocument(docId); diff --git a/src/services/libraryService.js b/src/services/libraryService.js new file mode 100644 index 0000000..a754177 --- /dev/null +++ b/src/services/libraryService.js @@ -0,0 +1,273 @@ +/** + * SimpleNote Web - Library Service + * Library CRUD with filesystem storage + */ + +import { join } from 'path'; +import config from '../config/index.js'; +import { ensureDir, readJSON, writeJSON, pathExists, deletePath, listDir, isDirectory } from '../utils/fsHelper.js'; +import { generateId } from '../utils/uuid.js'; +import { NotFoundError, ValidationError } from '../utils/errors.js'; + +const LIBRARIES_DIR = 'libraries'; +const LIBRARY_META_FILE = '.library.json'; + +export class LibraryService { + constructor(dataRoot = config.dataRoot) { + this.dataRoot = dataRoot; + this.librariesPath = join(dataRoot, LIBRARIES_DIR); + } + + _libPath(libId) { + return join(this.librariesPath, libId); + } + + _libMetaPath(libId) { + return join(this._libPath(libId), LIBRARY_META_FILE); + } + + _libDocumentsPath(libId) { + return join(this._libPath(libId), 'documents'); + } + + _libSubLibrariesPath(libId) { + return join(this._libPath(libId), 'sub-libraries'); + } + + _readLibMeta(libId) { + const meta = readJSON(this._libMetaPath(libId)); + if (!meta) throw new NotFoundError('Library'); + return meta; + } + + _findLibraryById(libId) { + const metaPath = this._libMetaPath(libId); + if (pathExists(metaPath)) { + return { id: libId, metaPath }; + } + return null; + } + + async createLibrary({ name, parentId = null }) { + if (!name || !name.trim()) { + throw new ValidationError('Library name is required'); + } + + const libId = generateId(); + const now = new Date().toISOString(); + + if (parentId) { + // Verify parent exists + const parentMeta = this._readLibMeta(parentId); + const parentSubLibsPath = this._libSubLibrariesPath(parentId); + ensureDir(parentSubLibsPath); + + const libMeta = { + id: libId, + name: name.trim(), + parentId, + createdAt: now, + updatedAt: now, + }; + + const libPath = join(parentSubLibsPath, libId); + ensureDir(libPath); + ensureDir(join(libPath, 'documents')); + writeJSON(this._libMetaPath(libId), libMeta); + return libMeta; + } else { + ensureDir(this.librariesPath); + + const libMeta = { + id: libId, + name: name.trim(), + parentId: null, + createdAt: now, + updatedAt: now, + }; + + const libPath = this._libPath(libId); + ensureDir(libPath); + ensureDir(join(libPath, 'documents')); + writeJSON(this._libMetaPath(libId), libMeta); + return libMeta; + } + } + + async listRootLibraries() { + ensureDir(this.librariesPath); + + const entries = listDir(this.librariesPath); + const libraries = []; + + for (const entry of entries) { + const libPath = join(this.librariesPath, entry); + if (!isDirectory(libPath)) continue; + + const meta = readJSON(this._libMetaPath(entry)); + if (!meta) continue; + + const docCount = this._countDocuments(this._libDocumentsPath(entry)); + libraries.push({ + ...meta, + documentCount: docCount, + }); + } + + return libraries; + } + + _countDocuments(docsPath) { + if (!pathExists(docsPath)) return 0; + const entries = listDir(docsPath); + return entries.filter(e => { + const metaPath = join(docsPath, e, '.meta.json'); + return pathExists(metaPath); + }).length; + } + + async getLibrary(libId) { + const found = this._findLibraryById(libId); + if (!found) { + throw new NotFoundError('Library'); + } + + const meta = readJSON(this._libMetaPath(libId)); + const docsPath = this._libDocumentsPath(libId); + const subLibsPath = this._libSubLibrariesPath(libId); + + // List documents + const documents = []; + if (pathExists(docsPath)) { + const docEntries = listDir(docsPath); + for (const docEntry of docEntries) { + const docMetaPath = join(docsPath, docEntry, '.meta.json'); + if (pathExists(docMetaPath)) { + const docMeta = readJSON(docMetaPath); + if (docMeta?.id) { + documents.push({ + id: docMeta.id, + title: docMeta.title, + type: docMeta.type, + status: docMeta.status, + tags: docMeta.tags || [], + updatedAt: docMeta.updatedAt, + }); + } + } + } + } + + // List sub-libraries + const subLibraries = []; + if (pathExists(subLibsPath)) { + const subEntries = listDir(subLibsPath); + for (const subEntry of subEntries) { + const subMetaPath = join(subLibsPath, subEntry, LIBRARY_META_FILE); + if (pathExists(subMetaPath)) { + const subMeta = readJSON(subMetaPath); + if (subMeta?.id) { + const subDocCount = this._countDocuments(join(subLibsPath, subEntry, 'documents')); + subLibraries.push({ + id: subMeta.id, + name: subMeta.name, + documentCount: subDocCount, + }); + } + } + } + } + + return { + library: meta, + documents, + subLibraries, + }; + } + + async getLibraryTree(libId) { + const found = this._findLibraryById(libId); + if (!found) { + throw new NotFoundError('Library'); + } + + const meta = readJSON(this._libMetaPath(libId)); + + const buildTree = (id) => { + const libMeta = readJSON(this._libMetaPath(id)); + const docsPath = this._libDocumentsPath(id); + const subLibsPath = this._libSubLibrariesPath(id); + + const documents = []; + if (pathExists(docsPath)) { + const docEntries = listDir(docsPath); + for (const docEntry of docEntries) { + const docMetaPath = join(docsPath, docEntry, '.meta.json'); + if (pathExists(docMetaPath)) { + const docMeta = readJSON(docMetaPath); + if (docMeta?.id) { + documents.push({ + id: docMeta.id, + title: docMeta.title, + type: docMeta.type, + }); + } + } + } + } + + const subLibraries = []; + if (pathExists(subLibsPath)) { + const subEntries = listDir(subLibsPath); + for (const subEntry of subEntries) { + const subMetaPath = join(subLibsPath, subEntry, LIBRARY_META_FILE); + if (pathExists(subMetaPath)) { + subLibraries.push(buildTree(subEntry)); + } + } + } + + return { + id: libMeta.id, + name: libMeta.name, + documents, + subLibraries, + }; + }; + + return buildTree(libId); + } + + async listDocumentsInLibrary(libId) { + const found = this._findLibraryById(libId); + if (!found) { + throw new NotFoundError('Library'); + } + + const result = await this.getLibrary(libId); + return { documents: result.documents, total: result.documents.length }; + } + + async deleteLibrary(libId) { + const found = this._findLibraryById(libId); + if (!found) { + throw new NotFoundError('Library'); + } + + const libPath = this._libPath(libId); + deletePath(libPath); + + return { deleted: true, id: libId }; + } +} + +let globalLibraryService = null; + +export function getLibraryService(dataRoot = config.dataRoot) { + if (!globalLibraryService) { + globalLibraryService = new LibraryService(dataRoot); + } + return globalLibraryService; +} + +export default LibraryService; diff --git a/ui/SPEC.md b/ui/SPEC.md new file mode 100644 index 0000000..5b6b1c1 --- /dev/null +++ b/ui/SPEC.md @@ -0,0 +1,309 @@ +# SimpleNote Web - UI/UX Specification + +## 1. Overview + +### 1.1 Purpose +SimpleNote Web is the frontend interface for the SimpleNote document management system. It provides a clean, efficient way to browse, create, edit, and manage documents organized in nested libraries with tag-based filtering. + +### 1.2 Target Users +- Technical users and agents creating/managing documentation +- Teams using CLI tools to push documents that need visual review +- Anyone preferring a Joplin-like experience with a modern dark-mode interface + +### 1.3 Tech Stack +- **Framework**: Vanilla JS with modern ES modules (no framework dependency for simplicity) +- **Styling**: CSS custom properties for theming (dark/light modes) +- **Markdown**: `marked` library for rendering +- **Icons**: Lucide Icons (SVG-based, MIT licensed) +- **HTTP Client**: Native Fetch API +- **State**: Simple pub/sub pattern with localStorage for preferences + +--- + +## 2. Design Principles + +### 2.1 Core Principles +1. **Content-First**: Documents are the hero; UI stays out of the way +2. **Keyboard-Friendly**: Common actions accessible via shortcuts +3. **Information Density**: Compact but readable; optimized for power users +4. **Progressive Disclosure**: Show details on demand, not all at once +5. **Dark Mode Primary**: Inspired by Mission Control dashboard aesthetics + +### 2.2 Layout Architecture +``` +┌─────────────────────────────────────────────────────────────┐ +│ Header (Logo + Search + User Actions) │ +├──────────────┬──────────────────────────────────────────────┤ +│ │ │ +│ Sidebar │ Main Content Area │ +│ (Library │ │ +│ Tree + │ - Document List (Dashboard) │ +│ Tags) │ - Document Viewer │ +│ │ - Document Editor │ +│ │ - Library Browser │ +│ │ │ +├──────────────┴──────────────────────────────────────────────┤ +│ Status Bar (optional: sync status, doc count) │ +└─────────────────────────────────────────────────────────────┘ +``` + +### 2.3 Responsive Strategy +- **Desktop (>1024px)**: Full sidebar + content layout +- **Tablet (768-1024px)**: Collapsible sidebar, full content +- **Mobile (<768px)**: Bottom navigation, stacked layout, slide-over panels + +--- + +## 3. Views Specification + +### 3.1 Dashboard / Main View + +**Purpose**: Central hub showing documents with filtering and navigation. + +**Components**: +- **Header Bar**: Logo, global search input, theme toggle, settings +- **Sidebar (left)**: + - Library tree (collapsible, nested) + - Quick filters: All Documents, Recent, Favorites + - Tag cloud/list with counts +- **Main Content**: + - Toolbar: View toggle (list/grid), sort options, bulk actions + - Document cards/list with title, tags, type badge, date, status + - Quick actions: New Document, New Library buttons + +**URL**: `/` or `/documents` + +**Keyboard Shortcuts**: +- `Ctrl/Cmd + K`: Focus search +- `Ctrl/Cmd + N`: New document +- `Ctrl/Cmd + Shift + N`: New library +- `Escape`: Clear filters/search + +### 3.2 Document Viewer + +**Purpose**: Read-only view of a document with rendered markdown. + +**Components**: +- **Header**: Back button, document title, action buttons (Edit, Export, Delete) +- **Metadata Panel** (collapsible sidebar or dropdown): + - Type badge (requirement/note/spec/general) + - Status badge (draft/approved/implemented) + - Priority indicator (high/medium/low with color) + - Tags as clickable pills + - Author + - Created/Updated timestamps +- **Content Area**: Rendered markdown with: + - Syntax highlighting for code blocks + - Styled tables + - Checkbox rendering for criteria lists + - Anchor links for headings + +**URL**: `/documents/:id` + +**Keyboard Shortcuts**: +- `E`: Edit document +- `X`: Export markdown +- `Delete/Backspace`: Delete (with confirmation) +- `Escape`: Back to list + +### 3.3 Document Editor + +**Purpose**: Create or edit documents with live preview. + +**Components**: +- **Header**: Cancel/Save buttons, document title (editable) +- **Editor Toolbar**: + - Markdown formatting buttons (bold, italic, headers, lists, code, link) + - Insert template dropdown + - Preview toggle (split view or tabbed) +- **Frontmatter Panel**: + - Title input + - Type selector (dropdown) + - Status selector (dropdown) + - Priority selector (dropdown) + - Tags input (chip-style, autocomplete) + - Library selector (dropdown with tree view) +- **Editor Area**: + - Monaco-style textarea with markdown syntax highlighting + - OR CodeMirror/MarkText integration (future) +- **Preview Pane**: Live rendered markdown (toggleable) +- **Auto-save Indicator**: "Saved" / "Saving..." / "Unsaved changes" + +**URL**: `/documents/:id/edit` or `/documents/new` + +**Keyboard Shortcuts**: +- `Ctrl/Cmd + S`: Save +- `Ctrl/Cmd + B`: Bold +- `Ctrl/Cmd + I`: Italic +- `Ctrl/Cmd + K`: Insert link +- `Ctrl/Cmd + Shift + P`: Toggle preview +- `Escape`: Discard changes (with confirmation) + +### 3.4 Library Browser + +**Purpose**: Navigate and manage library hierarchy. + +**Components**: +- **Breadcrumbs**: Path from root to current library +- **Library Tree** (sidebar): Expandable/collapsible nested view +- **Main Content**: + - Header: Current library name, document count + - Actions: New Document, New Sub-library, Rename, Delete + - Content list: Documents and sub-libraries mixed + - Drag-and-drop reordering (future) + +**URL**: `/libraries/:id` + +**Actions**: +- Click library → navigate to library view +- Click document → open viewer +- Right-click/long-press → context menu (Rename, Delete, Move) +- "+" buttons for creating new items + +**Keyboard Shortcuts**: +- `Ctrl/Cmd + Shift + N`: New sub-library +- `R`: Rename selected +- `Delete`: Delete (with confirmation) +- `Escape`: Go to parent library + +--- + +## 4. Interaction Patterns + +### 4.1 Navigation +- Sidebar persists across views (single-page app behavior) +- Breadcrumbs for deep library navigation +- Browser back/forward support via History API + +### 4.2 Search +- Real-time filtering as user types (debounced 300ms) +- Search scope: title, content, tags +- Highlight matching terms in results +- Empty state: "No documents found" + +### 4.3 Tag Filtering +- Click tag in sidebar → filter documents +- Click tag pill in document → filter by that tag +- Multiple tags = AND filter +- Active filters shown as removable chips + +### 4.4 Confirmation Dialogs +- Delete operations: Modal with confirmation +- Unsaved changes: Modal asking to save/discard +- All modals: Escape to cancel, Enter to confirm (when safe) + +### 4.5 Notifications/Toasts +- Success: Green, auto-dismiss 3s +- Error: Red, persistent until dismissed +- Info: Blue, auto-dismiss 5s +- Position: Bottom-right + +--- + +## 5. Data Flow + +### 5.1 API Integration +All UI data comes from the REST API at `/api/v1`. + +**Auth Flow**: +1. User enters token in settings or login page +2. Token stored in localStorage +3. All API requests include `Authorization: Bearer ` header +4. 401 response → clear token, show login + +**Endpoints Used by UI**: +``` +GET /api/v1/documents → Dashboard document list +GET /api/v1/documents/:id → Document viewer +POST /api/v1/documents → Create document +PUT /api/v1/documents/:id → Update document +DELETE /api/v1/documents/:id → Delete document +GET /api/v1/documents/:id/export → Export markdown +GET /api/v1/libraries → Library tree (root) +GET /api/v1/libraries/:id → Library contents +GET /api/v1/libraries/:id/tree → Full library tree +POST /api/v1/libraries → Create library +DELETE /api/v1/libraries/:id → Delete library +GET /api/v1/tags → Tag list with counts +GET /api/v1/tags/:tag/documents → Filtered documents +``` + +### 5.2 State Management +```javascript +// App state (simple pub/sub) +const state = { + documents: [], + libraries: [], + tags: [], + currentView: 'dashboard', // dashboard|viewer|editor|library + currentDocument: null, + currentLibrary: null, + filters: { tag: null, library: null, search: '' }, + preferences: { theme: 'dark' } +}; +``` + +### 5.3 Auto-save Implementation +```javascript +// Debounced auto-save during editing +let saveTimeout; +function onEditorChange(content) { + clearTimeout(saveTimeout); + ui.setSaving(); + saveTimeout = setTimeout(() => { + api.updateDocument(id, { content }).then(() => { + ui.setSaved(); + }).catch(() => { + ui.setError('Auto-save failed'); + }); + }, 2000); // 2 second debounce +} +``` + +--- + +## 6. Accessibility + +### 6.1 Requirements +- All interactive elements keyboard accessible +- Focus visible indicators (custom styled) +- ARIA labels on icons and non-text elements +- Color contrast ratio ≥ 4.5:1 (WCAG AA) +- Screen reader announcements for dynamic content + +### 6.2 Keyboard Navigation +- Tab order follows visual layout +- Focus trapped in modals +- Skip-to-content link +- Arrow keys for tree/list navigation + +--- + +## 7. Error Handling + +### 7.1 Network Errors +- Show toast notification +- Retry button for failed requests +- Offline indicator in status bar + +### 7.2 Validation Errors +- Inline field errors in forms +- Red border on invalid fields +- Error message below field + +### 7.3 Empty States +- No documents: Illustration + "Create your first document" CTA +- No search results: "No matches found" + clear filters button +- No libraries: "Create a library to organize your documents" + +--- + +## 8. Future Enhancements (Out of Scope for v1) + +- Drag-and-drop document organization +- Document versioning/history +- Collaborative editing +- Full-text search +- Document templates gallery +- Export to PDF/HTML +- Mobile native app