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.
This commit is contained in:
@@ -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';
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
273
src/services/libraryService.js
Normal file
273
src/services/libraryService.js
Normal file
@@ -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;
|
||||
309
ui/SPEC.md
Normal file
309
ui/SPEC.md
Normal file
@@ -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 <token>` 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
|
||||
Reference in New Issue
Block a user