Full document, spreadsheet, slideshow, and diagram tooling
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Merge pull request 'feat: docker-compose deployment, workspace/landing page model (#138, #58)' (#147) from feat/docker-workspace into main

scott 044332b9 e7526791

+1041
+217
src/lib/docker-config.ts
··· 1 + /** 2 + * Docker / Self-Hosting Configuration — container config and compose generation. 3 + * 4 + * Pure logic module: config validation, compose file generation, env handling. 5 + * Docker CLI and file I/O handled by the deployment layer. 6 + */ 7 + 8 + export interface DockerConfig { 9 + /** Container image name */ 10 + image: string; 11 + /** Image tag */ 12 + tag: string; 13 + /** Port to expose (host:container) */ 14 + hostPort: number; 15 + containerPort: number; 16 + /** Data directory volume mount path */ 17 + dataDir: string; 18 + /** Enable HTTPS with self-signed cert */ 19 + enableHttps: boolean; 20 + /** HTTPS port */ 21 + httpsPort: number; 22 + /** SQLite database file name */ 23 + dbFile: string; 24 + /** Node environment */ 25 + nodeEnv: 'production' | 'development'; 26 + /** Additional environment variables */ 27 + envVars: Record<string, string>; 28 + } 29 + 30 + /** 31 + * Create default Docker config. 32 + */ 33 + export function createDockerConfig(options: Partial<DockerConfig> = {}): DockerConfig { 34 + return { 35 + image: options.image ?? 'crypt-tools', 36 + tag: options.tag ?? 'latest', 37 + hostPort: options.hostPort ?? 3000, 38 + containerPort: options.containerPort ?? 3000, 39 + dataDir: options.dataDir ?? './data', 40 + enableHttps: options.enableHttps ?? false, 41 + httpsPort: options.httpsPort ?? 443, 42 + dbFile: options.dbFile ?? 'crypt.db', 43 + nodeEnv: options.nodeEnv ?? 'production', 44 + envVars: options.envVars ?? {}, 45 + }; 46 + } 47 + 48 + /** 49 + * Validate Docker config. 50 + */ 51 + export function validateDockerConfig(config: DockerConfig): { valid: boolean; errors: string[] } { 52 + const errors: string[] = []; 53 + if (!config.image) errors.push('Image name is required'); 54 + if (config.hostPort < 1 || config.hostPort > 65535) errors.push('Host port must be 1-65535'); 55 + if (config.containerPort < 1 || config.containerPort > 65535) errors.push('Container port must be 1-65535'); 56 + if (!config.dataDir) errors.push('Data directory is required'); 57 + if (config.enableHttps && (config.httpsPort < 1 || config.httpsPort > 65535)) { 58 + errors.push('HTTPS port must be 1-65535'); 59 + } 60 + return { valid: errors.length === 0, errors }; 61 + } 62 + 63 + /** 64 + * Generate docker-compose.yml content. 65 + */ 66 + export function generateComposeFile(config: DockerConfig): string { 67 + const env = buildEnvBlock(config); 68 + const ports = [` - "${config.hostPort}:${config.containerPort}"`]; 69 + if (config.enableHttps) { 70 + ports.push(` - "${config.httpsPort}:443"`); 71 + } 72 + 73 + return `version: "3.8" 74 + 75 + services: 76 + crypt: 77 + image: ${config.image}:${config.tag} 78 + restart: unless-stopped 79 + ports: 80 + ${ports.join('\n')} 81 + volumes: 82 + - ${config.dataDir}:/app/data 83 + environment: 84 + ${env} 85 + healthcheck: 86 + test: ["CMD", "wget", "--spider", "-q", "http://localhost:${config.containerPort}/api/v1/health"] 87 + interval: 30s 88 + timeout: 10s 89 + retries: 3 90 + `; 91 + } 92 + 93 + /** 94 + * Generate a Dockerfile content. 95 + */ 96 + export function generateDockerfile(): string { 97 + return `FROM node:20-alpine 98 + 99 + WORKDIR /app 100 + 101 + COPY package*.json ./ 102 + RUN npm ci --production 103 + 104 + COPY dist/ ./dist/ 105 + COPY server/ ./server/ 106 + 107 + RUN mkdir -p /app/data 108 + 109 + ENV NODE_ENV=production 110 + ENV PORT=3000 111 + ENV DATA_DIR=/app/data 112 + 113 + EXPOSE 3000 114 + 115 + HEALTHCHECK --interval=30s --timeout=10s --retries=3 \\ 116 + CMD wget --spider -q http://localhost:3000/api/v1/health || exit 1 117 + 118 + CMD ["node", "server/index.js"] 119 + `; 120 + } 121 + 122 + /** 123 + * Generate .env.example content. 124 + */ 125 + export function generateEnvExample(): string { 126 + return `# Crypt Tools - Environment Configuration 127 + # Copy to .env and modify as needed 128 + 129 + # Port to serve on 130 + PORT=3000 131 + 132 + # Data directory for SQLite database and uploads 133 + DATA_DIR=./data 134 + 135 + # Node environment 136 + NODE_ENV=production 137 + 138 + # Enable HTTPS with self-signed certificate (optional) 139 + # ENABLE_HTTPS=true 140 + # HTTPS_PORT=443 141 + 142 + # Custom SQLite database filename (optional) 143 + # DB_FILE=crypt.db 144 + `; 145 + } 146 + 147 + /** 148 + * Generate deployment instructions. 149 + */ 150 + export function generateQuickStart(): string { 151 + return `# Quick Start 152 + 153 + \`\`\`bash 154 + # Clone and start 155 + git clone <repo-url> crypt-tools 156 + cd crypt-tools 157 + docker compose up -d 158 + 159 + # Access at http://localhost:3000 160 + \`\`\` 161 + 162 + # With custom port 163 + \`\`\`bash 164 + PORT=8080 docker compose up -d 165 + \`\`\` 166 + 167 + # With persistent data 168 + \`\`\`bash 169 + mkdir -p ./my-data 170 + DATA_DIR=./my-data docker compose up -d 171 + \`\`\` 172 + `; 173 + } 174 + 175 + /** 176 + * Parse a port number from string. 177 + */ 178 + export function parsePort(value: string): number | null { 179 + const num = parseInt(value, 10); 180 + if (isNaN(num) || num < 1 || num > 65535) return null; 181 + return num; 182 + } 183 + 184 + /** 185 + * Check if a data directory path is safe (no traversal). 186 + */ 187 + export function isSafeDataDir(dir: string): boolean { 188 + if (dir.includes('..')) return false; 189 + if (dir.startsWith('/etc') || dir.startsWith('/sys') || dir.startsWith('/proc')) return false; 190 + return dir.length > 0; 191 + } 192 + 193 + /** 194 + * Get the full image reference. 195 + */ 196 + export function imageRef(config: DockerConfig): string { 197 + return `${config.image}:${config.tag}`; 198 + } 199 + 200 + /** Build environment block for compose file */ 201 + function buildEnvBlock(config: DockerConfig): string { 202 + const vars: Record<string, string> = { 203 + NODE_ENV: config.nodeEnv, 204 + PORT: String(config.containerPort), 205 + DATA_DIR: '/app/data', 206 + DB_FILE: config.dbFile, 207 + ...config.envVars, 208 + }; 209 + 210 + if (config.enableHttps) { 211 + vars.ENABLE_HTTPS = 'true'; 212 + } 213 + 214 + return Object.entries(vars) 215 + .map(([k, v]) => ` ${k}: "${v}"`) 216 + .join('\n'); 217 + }
+319
src/lib/workspace.ts
··· 1 + /** 2 + * Workspace — document grouping, activity feed, quick access, tags, view modes. 3 + * 4 + * Pure logic module: workspace model, filtering, sorting, activity tracking. 5 + * DOM rendering handled by the UI layer. 6 + */ 7 + 8 + export type ViewMode = 'list' | 'grid' | 'board'; 9 + export type DocType = 'docs' | 'sheets' | 'slides'; 10 + 11 + export interface WorkspaceDoc { 12 + id: string; 13 + title: string; 14 + type: DocType; 15 + /** Workspace/folder ID */ 16 + workspaceId: string; 17 + /** Tag IDs */ 18 + tags: string[]; 19 + /** Pinned/starred */ 20 + pinned: boolean; 21 + createdAt: number; 22 + updatedAt: number; 23 + /** Last accessed by current user */ 24 + lastAccessed: number; 25 + } 26 + 27 + export interface Workspace { 28 + id: string; 29 + name: string; 30 + color: string; 31 + /** Sort order */ 32 + order: number; 33 + } 34 + 35 + export interface Tag { 36 + id: string; 37 + name: string; 38 + color: string; 39 + } 40 + 41 + export interface ActivityEntry { 42 + id: string; 43 + docId: string; 44 + docTitle: string; 45 + userName: string; 46 + action: string; 47 + timestamp: number; 48 + } 49 + 50 + export interface WorkspaceState { 51 + docs: Map<string, WorkspaceDoc>; 52 + workspaces: Map<string, Workspace>; 53 + tags: Map<string, Tag>; 54 + activities: ActivityEntry[]; 55 + viewMode: ViewMode; 56 + /** Active workspace filter (null = all) */ 57 + activeWorkspace: string | null; 58 + /** Active tag filters */ 59 + activeTags: string[]; 60 + /** Search query */ 61 + searchQuery: string; 62 + } 63 + 64 + let _counter = 0; 65 + 66 + /** 67 + * Create initial workspace state. 68 + */ 69 + export function createWorkspaceState(): WorkspaceState { 70 + return { 71 + docs: new Map(), 72 + workspaces: new Map(), 73 + tags: new Map(), 74 + activities: [], 75 + viewMode: 'grid', 76 + activeWorkspace: null, 77 + activeTags: [], 78 + searchQuery: '', 79 + }; 80 + } 81 + 82 + /** 83 + * Add a workspace. 84 + */ 85 + export function addWorkspace(state: WorkspaceState, name: string, color = '#6366f1'): WorkspaceState { 86 + const ws: Workspace = { id: `ws-${Date.now()}-${++_counter}`, name, color, order: state.workspaces.size }; 87 + const workspaces = new Map(state.workspaces); 88 + workspaces.set(ws.id, ws); 89 + return { ...state, workspaces }; 90 + } 91 + 92 + /** 93 + * Remove a workspace (moves docs to unassigned). 94 + */ 95 + export function removeWorkspace(state: WorkspaceState, wsId: string): WorkspaceState { 96 + const workspaces = new Map(state.workspaces); 97 + workspaces.delete(wsId); 98 + const docs = new Map(state.docs); 99 + for (const [id, doc] of docs) { 100 + if (doc.workspaceId === wsId) { 101 + docs.set(id, { ...doc, workspaceId: '' }); 102 + } 103 + } 104 + return { ...state, workspaces, docs }; 105 + } 106 + 107 + /** 108 + * Add a tag. 109 + */ 110 + export function addTag(state: WorkspaceState, name: string, color = '#8b5cf6'): WorkspaceState { 111 + const tag: Tag = { id: `tag-${Date.now()}-${++_counter}`, name, color }; 112 + const tags = new Map(state.tags); 113 + tags.set(tag.id, tag); 114 + return { ...state, tags }; 115 + } 116 + 117 + /** 118 + * Remove a tag (removes from all docs). 119 + */ 120 + export function removeTag(state: WorkspaceState, tagId: string): WorkspaceState { 121 + const tags = new Map(state.tags); 122 + tags.delete(tagId); 123 + const docs = new Map(state.docs); 124 + for (const [id, doc] of docs) { 125 + if (doc.tags.includes(tagId)) { 126 + docs.set(id, { ...doc, tags: doc.tags.filter(t => t !== tagId) }); 127 + } 128 + } 129 + return { ...state, tags, docs }; 130 + } 131 + 132 + /** 133 + * Register a document. 134 + */ 135 + export function registerDoc( 136 + state: WorkspaceState, 137 + id: string, 138 + title: string, 139 + type: DocType, 140 + workspaceId = '', 141 + ): WorkspaceState { 142 + const now = Date.now(); 143 + const doc: WorkspaceDoc = { 144 + id, title, type, workspaceId, 145 + tags: [], pinned: false, 146 + createdAt: now, updatedAt: now, lastAccessed: now, 147 + }; 148 + const docs = new Map(state.docs); 149 + docs.set(id, doc); 150 + return { ...state, docs }; 151 + } 152 + 153 + /** 154 + * Update a doc's metadata. 155 + */ 156 + export function updateDoc(state: WorkspaceState, docId: string, updates: Partial<WorkspaceDoc>): WorkspaceState { 157 + const doc = state.docs.get(docId); 158 + if (!doc) return state; 159 + const docs = new Map(state.docs); 160 + docs.set(docId, { ...doc, ...updates, updatedAt: Date.now() }); 161 + return { ...state, docs }; 162 + } 163 + 164 + /** 165 + * Toggle pin/star on a document. 166 + */ 167 + export function togglePin(state: WorkspaceState, docId: string): WorkspaceState { 168 + const doc = state.docs.get(docId); 169 + if (!doc) return state; 170 + return updateDoc(state, docId, { pinned: !doc.pinned }); 171 + } 172 + 173 + /** 174 + * Add a tag to a document. 175 + */ 176 + export function tagDoc(state: WorkspaceState, docId: string, tagId: string): WorkspaceState { 177 + const doc = state.docs.get(docId); 178 + if (!doc || doc.tags.includes(tagId)) return state; 179 + return updateDoc(state, docId, { tags: [...doc.tags, tagId] }); 180 + } 181 + 182 + /** 183 + * Remove a tag from a document. 184 + */ 185 + export function untagDoc(state: WorkspaceState, docId: string, tagId: string): WorkspaceState { 186 + const doc = state.docs.get(docId); 187 + if (!doc) return state; 188 + return updateDoc(state, docId, { tags: doc.tags.filter(t => t !== tagId) }); 189 + } 190 + 191 + /** 192 + * Move a doc to a workspace. 193 + */ 194 + export function moveToWorkspace(state: WorkspaceState, docId: string, wsId: string): WorkspaceState { 195 + return updateDoc(state, docId, { workspaceId: wsId }); 196 + } 197 + 198 + /** 199 + * Record an activity. 200 + */ 201 + export function recordActivity( 202 + state: WorkspaceState, 203 + docId: string, 204 + docTitle: string, 205 + userName: string, 206 + action: string, 207 + ): WorkspaceState { 208 + const entry: ActivityEntry = { 209 + id: `act-${Date.now()}-${++_counter}`, 210 + docId, docTitle, userName, action, 211 + timestamp: Date.now(), 212 + }; 213 + return { ...state, activities: [entry, ...state.activities].slice(0, 100) }; 214 + } 215 + 216 + /** 217 + * Set the view mode. 218 + */ 219 + export function setViewMode(state: WorkspaceState, mode: ViewMode): WorkspaceState { 220 + return { ...state, viewMode: mode }; 221 + } 222 + 223 + /** 224 + * Set the active workspace filter. 225 + */ 226 + export function setActiveWorkspace(state: WorkspaceState, wsId: string | null): WorkspaceState { 227 + return { ...state, activeWorkspace: wsId }; 228 + } 229 + 230 + /** 231 + * Toggle a tag filter. 232 + */ 233 + export function toggleTagFilter(state: WorkspaceState, tagId: string): WorkspaceState { 234 + const activeTags = state.activeTags.includes(tagId) 235 + ? state.activeTags.filter(t => t !== tagId) 236 + : [...state.activeTags, tagId]; 237 + return { ...state, activeTags }; 238 + } 239 + 240 + /** 241 + * Set the search query. 242 + */ 243 + export function setSearch(state: WorkspaceState, query: string): WorkspaceState { 244 + return { ...state, searchQuery: query }; 245 + } 246 + 247 + /** 248 + * Get filtered and sorted documents. 249 + */ 250 + export function getFilteredDocs(state: WorkspaceState): WorkspaceDoc[] { 251 + let docs = Array.from(state.docs.values()); 252 + 253 + // Filter by workspace 254 + if (state.activeWorkspace) { 255 + docs = docs.filter(d => d.workspaceId === state.activeWorkspace); 256 + } 257 + 258 + // Filter by tags (AND logic) 259 + if (state.activeTags.length > 0) { 260 + docs = docs.filter(d => state.activeTags.every(t => d.tags.includes(t))); 261 + } 262 + 263 + // Filter by search 264 + if (state.searchQuery) { 265 + const q = state.searchQuery.toLowerCase(); 266 + docs = docs.filter(d => d.title.toLowerCase().includes(q)); 267 + } 268 + 269 + // Sort: pinned first, then by lastAccessed 270 + docs.sort((a, b) => { 271 + if (a.pinned !== b.pinned) return a.pinned ? -1 : 1; 272 + return b.lastAccessed - a.lastAccessed; 273 + }); 274 + 275 + return docs; 276 + } 277 + 278 + /** 279 + * Get pinned documents. 280 + */ 281 + export function getPinnedDocs(state: WorkspaceState): WorkspaceDoc[] { 282 + return Array.from(state.docs.values()) 283 + .filter(d => d.pinned) 284 + .sort((a, b) => b.lastAccessed - a.lastAccessed); 285 + } 286 + 287 + /** 288 + * Get recent activities. 289 + */ 290 + export function getRecentActivities(state: WorkspaceState, limit = 20): ActivityEntry[] { 291 + return state.activities.slice(0, limit); 292 + } 293 + 294 + /** 295 + * Format a relative time string. 296 + */ 297 + export function formatRelativeTime(timestamp: number, now = Date.now()): string { 298 + const diff = now - timestamp; 299 + const seconds = Math.floor(diff / 1000); 300 + if (seconds < 60) return 'just now'; 301 + const minutes = Math.floor(seconds / 60); 302 + if (minutes < 60) return `${minutes}m ago`; 303 + const hours = Math.floor(minutes / 60); 304 + if (hours < 24) return `${hours}h ago`; 305 + const days = Math.floor(hours / 24); 306 + if (days < 7) return `${days}d ago`; 307 + return `${Math.floor(days / 7)}w ago`; 308 + } 309 + 310 + /** 311 + * Get document count by type. 312 + */ 313 + export function countByType(state: WorkspaceState): Record<DocType, number> { 314 + const counts: Record<DocType, number> = { docs: 0, sheets: 0, slides: 0 }; 315 + for (const doc of state.docs.values()) { 316 + counts[doc.type]++; 317 + } 318 + return counts; 319 + }
+191
tests/docker-config.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createDockerConfig, 4 + validateDockerConfig, 5 + generateComposeFile, 6 + generateDockerfile, 7 + generateEnvExample, 8 + generateQuickStart, 9 + parsePort, 10 + isSafeDataDir, 11 + imageRef, 12 + } from '../src/lib/docker-config'; 13 + 14 + describe('docker-config', () => { 15 + describe('createDockerConfig', () => { 16 + it('creates default config', () => { 17 + const config = createDockerConfig(); 18 + expect(config.image).toBe('crypt-tools'); 19 + expect(config.tag).toBe('latest'); 20 + expect(config.hostPort).toBe(3000); 21 + expect(config.containerPort).toBe(3000); 22 + expect(config.dataDir).toBe('./data'); 23 + expect(config.enableHttps).toBe(false); 24 + expect(config.nodeEnv).toBe('production'); 25 + }); 26 + 27 + it('accepts partial overrides', () => { 28 + const config = createDockerConfig({ hostPort: 8080, enableHttps: true }); 29 + expect(config.hostPort).toBe(8080); 30 + expect(config.enableHttps).toBe(true); 31 + expect(config.image).toBe('crypt-tools'); // still default 32 + }); 33 + }); 34 + 35 + describe('validateDockerConfig', () => { 36 + it('validates default config', () => { 37 + expect(validateDockerConfig(createDockerConfig()).valid).toBe(true); 38 + }); 39 + 40 + it('rejects empty image', () => { 41 + const config = createDockerConfig({ image: '' }); 42 + expect(validateDockerConfig(config).valid).toBe(false); 43 + }); 44 + 45 + it('rejects invalid host port', () => { 46 + expect(validateDockerConfig(createDockerConfig({ hostPort: 0 })).valid).toBe(false); 47 + expect(validateDockerConfig(createDockerConfig({ hostPort: 70000 })).valid).toBe(false); 48 + }); 49 + 50 + it('rejects invalid container port', () => { 51 + expect(validateDockerConfig(createDockerConfig({ containerPort: -1 })).valid).toBe(false); 52 + }); 53 + 54 + it('rejects empty data dir', () => { 55 + expect(validateDockerConfig(createDockerConfig({ dataDir: '' })).valid).toBe(false); 56 + }); 57 + 58 + it('rejects invalid HTTPS port when enabled', () => { 59 + const config = createDockerConfig({ enableHttps: true, httpsPort: 0 }); 60 + expect(validateDockerConfig(config).valid).toBe(false); 61 + }); 62 + }); 63 + 64 + describe('generateComposeFile', () => { 65 + it('generates valid YAML structure', () => { 66 + const config = createDockerConfig(); 67 + const yaml = generateComposeFile(config); 68 + expect(yaml).toContain('version: "3.8"'); 69 + expect(yaml).toContain('services:'); 70 + expect(yaml).toContain('crypt:'); 71 + expect(yaml).toContain('image: crypt-tools:latest'); 72 + expect(yaml).toContain('"3000:3000"'); 73 + expect(yaml).toContain('./data:/app/data'); 74 + expect(yaml).toContain('healthcheck:'); 75 + }); 76 + 77 + it('includes HTTPS port when enabled', () => { 78 + const config = createDockerConfig({ enableHttps: true, httpsPort: 8443 }); 79 + const yaml = generateComposeFile(config); 80 + expect(yaml).toContain('"8443:443"'); 81 + expect(yaml).toContain('ENABLE_HTTPS: "true"'); 82 + }); 83 + 84 + it('does not include HTTPS when disabled', () => { 85 + const config = createDockerConfig(); 86 + const yaml = generateComposeFile(config); 87 + expect(yaml).not.toContain('443'); 88 + expect(yaml).not.toContain('ENABLE_HTTPS'); 89 + }); 90 + 91 + it('includes custom env vars', () => { 92 + const config = createDockerConfig({ envVars: { LOG_LEVEL: 'debug' } }); 93 + const yaml = generateComposeFile(config); 94 + expect(yaml).toContain('LOG_LEVEL: "debug"'); 95 + }); 96 + 97 + it('sets restart policy', () => { 98 + expect(generateComposeFile(createDockerConfig())).toContain('restart: unless-stopped'); 99 + }); 100 + }); 101 + 102 + describe('generateDockerfile', () => { 103 + it('uses node 20 alpine', () => { 104 + const df = generateDockerfile(); 105 + expect(df).toContain('FROM node:20-alpine'); 106 + }); 107 + 108 + it('exposes port 3000', () => { 109 + expect(generateDockerfile()).toContain('EXPOSE 3000'); 110 + }); 111 + 112 + it('sets production env', () => { 113 + expect(generateDockerfile()).toContain('ENV NODE_ENV=production'); 114 + }); 115 + 116 + it('includes healthcheck', () => { 117 + expect(generateDockerfile()).toContain('HEALTHCHECK'); 118 + }); 119 + 120 + it('creates data directory', () => { 121 + expect(generateDockerfile()).toContain('mkdir -p /app/data'); 122 + }); 123 + }); 124 + 125 + describe('generateEnvExample', () => { 126 + it('includes PORT', () => { 127 + expect(generateEnvExample()).toContain('PORT=3000'); 128 + }); 129 + 130 + it('includes DATA_DIR', () => { 131 + expect(generateEnvExample()).toContain('DATA_DIR=./data'); 132 + }); 133 + 134 + it('includes commented HTTPS options', () => { 135 + const env = generateEnvExample(); 136 + expect(env).toContain('# ENABLE_HTTPS'); 137 + expect(env).toContain('# HTTPS_PORT'); 138 + }); 139 + }); 140 + 141 + describe('generateQuickStart', () => { 142 + it('includes docker compose command', () => { 143 + expect(generateQuickStart()).toContain('docker compose up'); 144 + }); 145 + }); 146 + 147 + describe('parsePort', () => { 148 + it('parses valid port', () => { 149 + expect(parsePort('3000')).toBe(3000); 150 + expect(parsePort('80')).toBe(80); 151 + expect(parsePort('65535')).toBe(65535); 152 + }); 153 + 154 + it('returns null for invalid port', () => { 155 + expect(parsePort('0')).toBeNull(); 156 + expect(parsePort('70000')).toBeNull(); 157 + expect(parsePort('abc')).toBeNull(); 158 + expect(parsePort('')).toBeNull(); 159 + }); 160 + }); 161 + 162 + describe('isSafeDataDir', () => { 163 + it('allows normal paths', () => { 164 + expect(isSafeDataDir('./data')).toBe(true); 165 + expect(isSafeDataDir('/home/user/data')).toBe(true); 166 + expect(isSafeDataDir('data')).toBe(true); 167 + }); 168 + 169 + it('rejects path traversal', () => { 170 + expect(isSafeDataDir('../secrets')).toBe(false); 171 + expect(isSafeDataDir('/foo/../etc/passwd')).toBe(false); 172 + }); 173 + 174 + it('rejects system paths', () => { 175 + expect(isSafeDataDir('/etc/shadow')).toBe(false); 176 + expect(isSafeDataDir('/sys/kernel')).toBe(false); 177 + expect(isSafeDataDir('/proc/1')).toBe(false); 178 + }); 179 + 180 + it('rejects empty path', () => { 181 + expect(isSafeDataDir('')).toBe(false); 182 + }); 183 + }); 184 + 185 + describe('imageRef', () => { 186 + it('combines image and tag', () => { 187 + const config = createDockerConfig({ image: 'myregistry/crypt', tag: 'v1.2.3' }); 188 + expect(imageRef(config)).toBe('myregistry/crypt:v1.2.3'); 189 + }); 190 + }); 191 + });
+314
tests/workspace.test.ts
··· 1 + import { describe, it, expect } from 'vitest'; 2 + import { 3 + createWorkspaceState, 4 + addWorkspace, 5 + removeWorkspace, 6 + addTag, 7 + removeTag, 8 + registerDoc, 9 + updateDoc, 10 + togglePin, 11 + tagDoc, 12 + untagDoc, 13 + moveToWorkspace, 14 + recordActivity, 15 + setViewMode, 16 + setActiveWorkspace, 17 + toggleTagFilter, 18 + setSearch, 19 + getFilteredDocs, 20 + getPinnedDocs, 21 + getRecentActivities, 22 + formatRelativeTime, 23 + countByType, 24 + } from '../src/lib/workspace'; 25 + 26 + describe('workspace', () => { 27 + describe('createWorkspaceState', () => { 28 + it('creates empty state', () => { 29 + const state = createWorkspaceState(); 30 + expect(state.docs.size).toBe(0); 31 + expect(state.workspaces.size).toBe(0); 32 + expect(state.tags.size).toBe(0); 33 + expect(state.activities).toHaveLength(0); 34 + expect(state.viewMode).toBe('grid'); 35 + expect(state.activeWorkspace).toBeNull(); 36 + }); 37 + }); 38 + 39 + describe('addWorkspace / removeWorkspace', () => { 40 + it('adds a workspace', () => { 41 + let state = createWorkspaceState(); 42 + state = addWorkspace(state, 'Project Alpha', '#ff0000'); 43 + expect(state.workspaces.size).toBe(1); 44 + const ws = [...state.workspaces.values()][0]; 45 + expect(ws.name).toBe('Project Alpha'); 46 + expect(ws.color).toBe('#ff0000'); 47 + }); 48 + 49 + it('removes a workspace and unassigns docs', () => { 50 + let state = createWorkspaceState(); 51 + state = addWorkspace(state, 'WS1'); 52 + const wsId = [...state.workspaces.keys()][0]; 53 + state = registerDoc(state, 'doc-1', 'Notes', 'docs', wsId); 54 + state = removeWorkspace(state, wsId); 55 + expect(state.workspaces.size).toBe(0); 56 + expect(state.docs.get('doc-1')!.workspaceId).toBe(''); 57 + }); 58 + }); 59 + 60 + describe('addTag / removeTag', () => { 61 + it('adds a tag', () => { 62 + let state = createWorkspaceState(); 63 + state = addTag(state, 'Important', '#ff0000'); 64 + expect(state.tags.size).toBe(1); 65 + }); 66 + 67 + it('removes a tag and cleans docs', () => { 68 + let state = createWorkspaceState(); 69 + state = addTag(state, 'Urgent'); 70 + const tagId = [...state.tags.keys()][0]; 71 + state = registerDoc(state, 'doc-1', 'Notes', 'docs'); 72 + state = tagDoc(state, 'doc-1', tagId); 73 + state = removeTag(state, tagId); 74 + expect(state.tags.size).toBe(0); 75 + expect(state.docs.get('doc-1')!.tags).toHaveLength(0); 76 + }); 77 + }); 78 + 79 + describe('registerDoc / updateDoc', () => { 80 + it('registers a document', () => { 81 + let state = createWorkspaceState(); 82 + state = registerDoc(state, 'doc-1', 'Budget', 'sheets'); 83 + expect(state.docs.size).toBe(1); 84 + const doc = state.docs.get('doc-1')!; 85 + expect(doc.title).toBe('Budget'); 86 + expect(doc.type).toBe('sheets'); 87 + expect(doc.pinned).toBe(false); 88 + }); 89 + 90 + it('updates a document', () => { 91 + let state = createWorkspaceState(); 92 + state = registerDoc(state, 'doc-1', 'Old Title', 'docs'); 93 + state = updateDoc(state, 'doc-1', { title: 'New Title' }); 94 + expect(state.docs.get('doc-1')!.title).toBe('New Title'); 95 + }); 96 + 97 + it('updateDoc returns unchanged for missing doc', () => { 98 + const state = createWorkspaceState(); 99 + expect(updateDoc(state, 'fake', { title: 'X' })).toBe(state); 100 + }); 101 + }); 102 + 103 + describe('togglePin', () => { 104 + it('pins and unpins a doc', () => { 105 + let state = createWorkspaceState(); 106 + state = registerDoc(state, 'doc-1', 'Notes', 'docs'); 107 + state = togglePin(state, 'doc-1'); 108 + expect(state.docs.get('doc-1')!.pinned).toBe(true); 109 + state = togglePin(state, 'doc-1'); 110 + expect(state.docs.get('doc-1')!.pinned).toBe(false); 111 + }); 112 + }); 113 + 114 + describe('tagDoc / untagDoc', () => { 115 + it('adds a tag to a doc', () => { 116 + let state = createWorkspaceState(); 117 + state = addTag(state, 'Urgent'); 118 + const tagId = [...state.tags.keys()][0]; 119 + state = registerDoc(state, 'doc-1', 'Notes', 'docs'); 120 + state = tagDoc(state, 'doc-1', tagId); 121 + expect(state.docs.get('doc-1')!.tags).toContain(tagId); 122 + }); 123 + 124 + it('does not duplicate tags', () => { 125 + let state = createWorkspaceState(); 126 + state = addTag(state, 'Urgent'); 127 + const tagId = [...state.tags.keys()][0]; 128 + state = registerDoc(state, 'doc-1', 'Notes', 'docs'); 129 + state = tagDoc(state, 'doc-1', tagId); 130 + state = tagDoc(state, 'doc-1', tagId); 131 + expect(state.docs.get('doc-1')!.tags).toHaveLength(1); 132 + }); 133 + 134 + it('removes a tag from a doc', () => { 135 + let state = createWorkspaceState(); 136 + state = addTag(state, 'Urgent'); 137 + const tagId = [...state.tags.keys()][0]; 138 + state = registerDoc(state, 'doc-1', 'Notes', 'docs'); 139 + state = tagDoc(state, 'doc-1', tagId); 140 + state = untagDoc(state, 'doc-1', tagId); 141 + expect(state.docs.get('doc-1')!.tags).toHaveLength(0); 142 + }); 143 + }); 144 + 145 + describe('moveToWorkspace', () => { 146 + it('moves doc to a workspace', () => { 147 + let state = createWorkspaceState(); 148 + state = addWorkspace(state, 'WS1'); 149 + const wsId = [...state.workspaces.keys()][0]; 150 + state = registerDoc(state, 'doc-1', 'Notes', 'docs'); 151 + state = moveToWorkspace(state, 'doc-1', wsId); 152 + expect(state.docs.get('doc-1')!.workspaceId).toBe(wsId); 153 + }); 154 + }); 155 + 156 + describe('recordActivity', () => { 157 + it('records an activity entry', () => { 158 + let state = createWorkspaceState(); 159 + state = recordActivity(state, 'doc-1', 'Budget', 'Alice', 'edited'); 160 + expect(state.activities).toHaveLength(1); 161 + expect(state.activities[0].action).toBe('edited'); 162 + }); 163 + 164 + it('prepends new activities (most recent first)', () => { 165 + let state = createWorkspaceState(); 166 + state = recordActivity(state, 'doc-1', 'A', 'Alice', 'created'); 167 + state = recordActivity(state, 'doc-2', 'B', 'Bob', 'edited'); 168 + expect(state.activities[0].docTitle).toBe('B'); 169 + }); 170 + 171 + it('caps at 100 entries', () => { 172 + let state = createWorkspaceState(); 173 + for (let i = 0; i < 110; i++) { 174 + state = recordActivity(state, `doc-${i}`, `Doc ${i}`, 'User', 'edited'); 175 + } 176 + expect(state.activities).toHaveLength(100); 177 + }); 178 + }); 179 + 180 + describe('view and filter controls', () => { 181 + it('sets view mode', () => { 182 + let state = createWorkspaceState(); 183 + state = setViewMode(state, 'list'); 184 + expect(state.viewMode).toBe('list'); 185 + }); 186 + 187 + it('sets active workspace', () => { 188 + let state = createWorkspaceState(); 189 + state = setActiveWorkspace(state, 'ws-1'); 190 + expect(state.activeWorkspace).toBe('ws-1'); 191 + }); 192 + 193 + it('toggles tag filter', () => { 194 + let state = createWorkspaceState(); 195 + state = toggleTagFilter(state, 'tag-1'); 196 + expect(state.activeTags).toContain('tag-1'); 197 + state = toggleTagFilter(state, 'tag-1'); 198 + expect(state.activeTags).not.toContain('tag-1'); 199 + }); 200 + 201 + it('sets search query', () => { 202 + let state = createWorkspaceState(); 203 + state = setSearch(state, 'budget'); 204 + expect(state.searchQuery).toBe('budget'); 205 + }); 206 + }); 207 + 208 + describe('getFilteredDocs', () => { 209 + function setupState() { 210 + let state = createWorkspaceState(); 211 + state = addWorkspace(state, 'WS1'); 212 + state = addTag(state, 'Urgent'); 213 + const wsId = [...state.workspaces.keys()][0]; 214 + const tagId = [...state.tags.keys()][0]; 215 + state = registerDoc(state, 'd1', 'Budget Report', 'sheets', wsId); 216 + state = registerDoc(state, 'd2', 'Meeting Notes', 'docs', wsId); 217 + state = registerDoc(state, 'd3', 'Pitch Deck', 'slides', ''); 218 + state = tagDoc(state, 'd1', tagId); 219 + state = togglePin(state, 'd3'); 220 + return { state, wsId, tagId }; 221 + } 222 + 223 + it('returns all docs by default', () => { 224 + const { state } = setupState(); 225 + expect(getFilteredDocs(state)).toHaveLength(3); 226 + }); 227 + 228 + it('filters by workspace', () => { 229 + const { state, wsId } = setupState(); 230 + const filtered = getFilteredDocs(setActiveWorkspace(state, wsId)); 231 + expect(filtered).toHaveLength(2); 232 + }); 233 + 234 + it('filters by tag', () => { 235 + const { state, tagId } = setupState(); 236 + const filtered = getFilteredDocs(toggleTagFilter(state, tagId)); 237 + expect(filtered).toHaveLength(1); 238 + expect(filtered[0].id).toBe('d1'); 239 + }); 240 + 241 + it('filters by search', () => { 242 + const { state } = setupState(); 243 + const filtered = getFilteredDocs(setSearch(state, 'budget')); 244 + expect(filtered).toHaveLength(1); 245 + expect(filtered[0].id).toBe('d1'); 246 + }); 247 + 248 + it('pinned docs come first', () => { 249 + const { state } = setupState(); 250 + const docs = getFilteredDocs(state); 251 + expect(docs[0].id).toBe('d3'); // pinned 252 + }); 253 + }); 254 + 255 + describe('getPinnedDocs', () => { 256 + it('returns only pinned docs', () => { 257 + let state = createWorkspaceState(); 258 + state = registerDoc(state, 'd1', 'A', 'docs'); 259 + state = registerDoc(state, 'd2', 'B', 'docs'); 260 + state = togglePin(state, 'd1'); 261 + const pinned = getPinnedDocs(state); 262 + expect(pinned).toHaveLength(1); 263 + expect(pinned[0].id).toBe('d1'); 264 + }); 265 + }); 266 + 267 + describe('getRecentActivities', () => { 268 + it('returns limited activities', () => { 269 + let state = createWorkspaceState(); 270 + for (let i = 0; i < 30; i++) { 271 + state = recordActivity(state, `d${i}`, `Doc ${i}`, 'User', 'edited'); 272 + } 273 + expect(getRecentActivities(state, 10)).toHaveLength(10); 274 + }); 275 + }); 276 + 277 + describe('formatRelativeTime', () => { 278 + const now = 1700000000000; 279 + 280 + it('formats just now', () => { 281 + expect(formatRelativeTime(now - 30000, now)).toBe('just now'); 282 + }); 283 + 284 + it('formats minutes', () => { 285 + expect(formatRelativeTime(now - 300000, now)).toBe('5m ago'); 286 + }); 287 + 288 + it('formats hours', () => { 289 + expect(formatRelativeTime(now - 7200000, now)).toBe('2h ago'); 290 + }); 291 + 292 + it('formats days', () => { 293 + expect(formatRelativeTime(now - 86400000 * 3, now)).toBe('3d ago'); 294 + }); 295 + 296 + it('formats weeks', () => { 297 + expect(formatRelativeTime(now - 86400000 * 14, now)).toBe('2w ago'); 298 + }); 299 + }); 300 + 301 + describe('countByType', () => { 302 + it('counts docs by type', () => { 303 + let state = createWorkspaceState(); 304 + state = registerDoc(state, 'd1', 'A', 'docs'); 305 + state = registerDoc(state, 'd2', 'B', 'sheets'); 306 + state = registerDoc(state, 'd3', 'C', 'sheets'); 307 + state = registerDoc(state, 'd4', 'D', 'slides'); 308 + const counts = countByType(state); 309 + expect(counts.docs).toBe(1); 310 + expect(counts.sheets).toBe(2); 311 + expect(counts.slides).toBe(1); 312 + }); 313 + }); 314 + });