diff --git a/b0esche_cloud/lib/blocs/document_viewer/document_viewer_bloc.dart b/b0esche_cloud/lib/blocs/document_viewer/document_viewer_bloc.dart index f03be14..8ff11da 100644 --- a/b0esche_cloud/lib/blocs/document_viewer/document_viewer_bloc.dart +++ b/b0esche_cloud/lib/blocs/document_viewer/document_viewer_bloc.dart @@ -35,6 +35,7 @@ class DocumentViewerBloc viewUrl: session.viewUrl, caps: session.capabilities, token: session.token, + fileInfo: session.fileInfo, ), ); _expiryTimer = Timer( diff --git a/b0esche_cloud/lib/blocs/document_viewer/document_viewer_state.dart b/b0esche_cloud/lib/blocs/document_viewer/document_viewer_state.dart index dee2602..6c6dbea 100644 --- a/b0esche_cloud/lib/blocs/document_viewer/document_viewer_state.dart +++ b/b0esche_cloud/lib/blocs/document_viewer/document_viewer_state.dart @@ -16,15 +16,17 @@ class DocumentViewerReady extends DocumentViewerState { final Uri viewUrl; final DocumentCapabilities caps; final String token; + final FileInfo? fileInfo; const DocumentViewerReady({ required this.viewUrl, required this.caps, required this.token, + this.fileInfo, }); @override - List get props => [viewUrl, caps, token]; + List get props => [viewUrl, caps, token, if (fileInfo != null) fileInfo!]; } class DocumentViewerError extends DocumentViewerState { diff --git a/b0esche_cloud/lib/models/document_capabilities.dart b/b0esche_cloud/lib/models/document_capabilities.dart index 1f722e6..4190c34 100644 --- a/b0esche_cloud/lib/models/document_capabilities.dart +++ b/b0esche_cloud/lib/models/document_capabilities.dart @@ -1,5 +1,33 @@ import 'package:equatable/equatable.dart'; +class FileInfo extends Equatable { + final String name; + final int size; + final DateTime? lastModified; + final String? modifiedByName; + + const FileInfo({ + required this.name, + required this.size, + this.lastModified, + this.modifiedByName, + }); + + @override + List get props => [name, size, lastModified, modifiedByName]; + + factory FileInfo.fromJson(Map json) { + return FileInfo( + name: json['name'] ?? '', + size: json['size'] ?? 0, + lastModified: json['lastModified'] != null + ? DateTime.tryParse(json['lastModified']) + : null, + modifiedByName: json['modifiedByName'], + ); + } +} + class DocumentCapabilities extends Equatable { final bool canEdit; final bool canAnnotate; diff --git a/b0esche_cloud/lib/models/viewer_session.dart b/b0esche_cloud/lib/models/viewer_session.dart index f916845..0b0a77a 100644 --- a/b0esche_cloud/lib/models/viewer_session.dart +++ b/b0esche_cloud/lib/models/viewer_session.dart @@ -6,16 +6,18 @@ class ViewerSession extends Equatable { final DocumentCapabilities capabilities; final String token; final DateTime expiresAt; + final FileInfo? fileInfo; const ViewerSession({ required this.viewUrl, required this.capabilities, required this.token, required this.expiresAt, + this.fileInfo, }); @override - List get props => [viewUrl, capabilities, token, expiresAt]; + List get props => [viewUrl, capabilities, token, expiresAt, fileInfo]; factory ViewerSession.fromJson(Map json) { return ViewerSession( @@ -23,6 +25,7 @@ class ViewerSession extends Equatable { capabilities: DocumentCapabilities.fromJson(json['capabilities']), token: json['token'], expiresAt: DateTime.parse(json['expiresAt']), + fileInfo: json['fileInfo'] != null ? FileInfo.fromJson(json['fileInfo']) : null, ); } } diff --git a/b0esche_cloud/lib/pages/document_viewer.dart b/b0esche_cloud/lib/pages/document_viewer.dart index bf04f2b..3a7a016 100644 --- a/b0esche_cloud/lib/pages/document_viewer.dart +++ b/b0esche_cloud/lib/pages/document_viewer.dart @@ -112,6 +112,20 @@ class _DocumentViewerModalState extends State { BlocBuilder( builder: (context, state) { if (state is DocumentViewerReady) { + final fileInfo = state.fileInfo; + String lastModifiedText = 'Last modified: Unknown'; + if (fileInfo != null) { + final modifiedDate = fileInfo.lastModified; + final modifiedBy = fileInfo.modifiedByName; + if (modifiedDate != null) { + final formattedDate = '${modifiedDate.day.toString().padLeft(2, '0')}.${modifiedDate.month.toString().padLeft(2, '0')}.${modifiedDate.year} ${modifiedDate.hour.toString().padLeft(2, '0')}:${modifiedDate.minute.toString().padLeft(2, '0')}'; + if (modifiedBy != null && modifiedBy.isNotEmpty) { + lastModifiedText = 'Last modified: $formattedDate by $modifiedBy'; + } else { + lastModifiedText = 'Last modified: $formattedDate'; + } + } + } return Container( height: 30, alignment: Alignment.centerLeft, @@ -119,9 +133,9 @@ class _DocumentViewerModalState extends State { decoration: BoxDecoration( color: AppTheme.primaryBackground.withValues(alpha: 0.3), ), - child: const Text( - 'Last modified: Unknown by Unknown (v1)', - style: TextStyle( + child: Text( + lastModifiedText, + style: const TextStyle( fontSize: 12, color: AppTheme.secondaryText, ), @@ -561,13 +575,26 @@ class _DocumentViewerState extends State { child: BlocBuilder( builder: (context, state) { if (state is DocumentViewerReady) { - // Placeholder for meta + final fileInfo = state.fileInfo; + String lastModifiedText = 'Last modified: Unknown'; + if (fileInfo != null) { + final modifiedDate = fileInfo.lastModified; + final modifiedBy = fileInfo.modifiedByName; + if (modifiedDate != null) { + final formattedDate = '${modifiedDate.day.toString().padLeft(2, '0')}.${modifiedDate.month.toString().padLeft(2, '0')}.${modifiedDate.year} ${modifiedDate.hour.toString().padLeft(2, '0')}:${modifiedDate.minute.toString().padLeft(2, '0')}'; + if (modifiedBy != null && modifiedBy.isNotEmpty) { + lastModifiedText = 'Last modified: $formattedDate by $modifiedBy'; + } else { + lastModifiedText = 'Last modified: $formattedDate'; + } + } + } return Container( height: 30, alignment: Alignment.centerLeft, padding: const EdgeInsets.symmetric(horizontal: 16), child: Text( - 'Last modified: Unknown by Unknown (v1)', + lastModifiedText, style: const TextStyle(fontSize: 12), ), ); diff --git a/b0esche_cloud/lib/repositories/mock_file_repository.dart b/b0esche_cloud/lib/repositories/mock_file_repository.dart deleted file mode 100644 index 7601883..0000000 --- a/b0esche_cloud/lib/repositories/mock_file_repository.dart +++ /dev/null @@ -1,293 +0,0 @@ -import '../models/file_item.dart'; -import '../models/viewer_session.dart'; -import '../models/editor_session.dart'; -import '../models/annotation.dart'; -import '../models/document_capabilities.dart'; -import '../models/api_error.dart'; -import '../repositories/file_repository.dart'; -import 'package:path/path.dart' as p; -import 'package:uuid/uuid.dart'; - -class MockFileRepository implements FileRepository { - final Map> _orgFiles = {}; - final _uuid = const Uuid(); - - List _getFilesForOrg(String orgId) { - if (!_orgFiles.containsKey(orgId)) { - // Initialize with different files per org - if (orgId == 'org1') { - _orgFiles[orgId] = [ - FileItem( - id: _uuid.v4(), - name: 'Personal Documents', - path: '/Personal Documents', - type: FileType.folder, - lastModified: DateTime.now(), - ), - FileItem( - id: _uuid.v4(), - name: 'Photos', - path: '/Photos', - type: FileType.folder, - lastModified: DateTime.now(), - ), - FileItem( - id: _uuid.v4(), - name: 'resume.pdf', - path: '/resume.pdf', - type: FileType.file, - size: 1024, - lastModified: DateTime.now(), - ), - FileItem( - id: _uuid.v4(), - name: 'notes.txt', - path: '/notes.txt', - type: FileType.file, - size: 256, - lastModified: DateTime.now(), - ), - ]; - } else if (orgId == 'org2') { - _orgFiles[orgId] = [ - FileItem( - id: _uuid.v4(), - name: 'Company Reports', - path: '/Company Reports', - type: FileType.folder, - lastModified: DateTime.now(), - ), - FileItem( - id: _uuid.v4(), - name: 'annual_report.pdf', - path: '/annual_report.pdf', - type: FileType.file, - size: 2048, - lastModified: DateTime.now(), - ), - FileItem( - id: _uuid.v4(), - name: 'presentation.pptx', - path: '/presentation.pptx', - type: FileType.file, - size: 4096, - lastModified: DateTime.now(), - ), - ]; - } else if (orgId == 'org3') { - _orgFiles[orgId] = [ - FileItem( - id: _uuid.v4(), - name: 'Project Code', - path: '/Project Code', - type: FileType.folder, - lastModified: DateTime.now(), - ), - FileItem( - id: _uuid.v4(), - name: 'side_project.dart', - path: '/side_project.dart', - type: FileType.file, - size: 512, - lastModified: DateTime.now(), - ), - ]; - } else { - // Default for new orgs - _orgFiles[orgId] = []; - } - } - return _orgFiles[orgId]!; - } - - @override - Future> getFiles(String orgId, String path) async { - await Future.delayed(const Duration(seconds: 1)); - final files = _getFilesForOrg(orgId); - if (path == '/') { - return files.where((f) => !f.path.substring(1).contains('/')).toList(); - } else { - return files - .where((f) => f.path.startsWith('$path/') && f.path != path) - .toList(); - } - } - - @override - Future getFile(String orgId, String path) async { - final files = _getFilesForOrg(orgId); - final index = files.indexWhere((f) => f.path == path); - return index != -1 ? files[index] : null; - } - - @override - Future requestEditorSession( - String orgId, - String fileId, - ) async { - await Future.delayed(const Duration(seconds: 1)); - // Mock: determine editability - final isEditable = - fileId.endsWith('.docx') || - fileId.endsWith('.xlsx') || - fileId.endsWith('.pptx'); - final editUrl = Uri.parse( - 'https://office.b0esche.cloud/editor/$orgId/$fileId?editable=$isEditable', - ); - final expiresAt = DateTime.now().add(const Duration(minutes: 30)); - return EditorSession( - editUrl: editUrl, - token: 'mock-editor-token', - readOnly: !isEditable, - expiresAt: expiresAt, - ); - } - - @override - Future deleteFile(String orgId, String path) async { - final files = _getFilesForOrg(orgId); - files.removeWhere((f) => f.path == path); - } - - @override - Future createFolder( - String orgId, - String parentPath, - String folderName, - ) async { - await Future.delayed(const Duration(seconds: 1)); - final normalizedName = folderName.startsWith('/') - ? folderName.substring(1) - : folderName; - final newPath = parentPath == '/' - ? '/$normalizedName' - : '$parentPath/$normalizedName'; - final files = _getFilesForOrg(orgId); - files.add( - FileItem( - id: _uuid.v4(), - name: normalizedName, - path: newPath, - type: FileType.folder, - lastModified: DateTime.now(), - ), - ); - } - - @override - Future moveFile( - String orgId, - String sourcePath, - String targetPath, - ) async { - await Future.delayed(const Duration(seconds: 1)); - final files = _getFilesForOrg(orgId); - final fileIndex = files.indexWhere((f) => f.path == sourcePath); - if (fileIndex != -1) { - final file = files[fileIndex]; - final newName = file.path.split('/').last; - final newPath = targetPath == '/' ? '/$newName' : '$targetPath/$newName'; - files[fileIndex] = FileItem( - id: file.id, - name: file.name, - path: newPath, - type: file.type, - size: file.size, - lastModified: DateTime.now(), - ); - } - } - - @override - Future renameFile(String orgId, String path, String newName) async { - await Future.delayed(const Duration(seconds: 1)); - final files = _getFilesForOrg(orgId); - final fileIndex = files.indexWhere((f) => f.path == path); - if (fileIndex != -1) { - final file = files[fileIndex]; - final parentPath = p.dirname(path); - final newPath = parentPath == '.' ? '/$newName' : '$parentPath/$newName'; - files[fileIndex] = FileItem( - id: file.id, - name: newName, - path: newPath, - type: file.type, - size: file.size, - lastModified: DateTime.now(), - ); - } - } - - @override - Future> searchFiles(String orgId, String query) async { - await Future.delayed(const Duration(seconds: 1)); - final files = _getFilesForOrg(orgId); - return files - .where((f) => f.name.toLowerCase().contains(query.toLowerCase())) - .toList(); - } - - @override - Future uploadFile(String orgId, FileItem file) async { - await Future.delayed(const Duration(seconds: 1)); - final files = _getFilesForOrg(orgId); - files.add( - FileItem( - id: _uuid.v4(), - name: file.name, - path: file.path, - type: file.type, - size: file.size, - lastModified: file.lastModified, - ), - ); - } - - @override - Future requestViewerSession( - String orgId, - String fileId, - ) async { - await Future.delayed(const Duration(seconds: 1)); - if (fileId.contains('forbidden')) { - throw ApiError( - code: 'permission_denied', - message: 'Access denied', - status: 403, - ); - } - if (fileId.contains('notfound')) { - throw ApiError(code: 'not_found', message: 'File not found', status: 404); - } - // Mock: assume fileId is path, determine if PDF - final isPdf = fileId.endsWith('.pdf'); - final caps = DocumentCapabilities( - canEdit: !isPdf && (fileId.endsWith('.docx') || fileId.endsWith('.xlsx')), - canAnnotate: isPdf, - isPdf: isPdf, - mimeType: isPdf ? 'application/pdf' : 'application/octet-stream', - ); - // Mock URL - final viewUrl = Uri.parse( - 'https://office.b0esche.cloud/viewer/$orgId/$fileId', - ); - final token = 'mock-viewer-token'; - final expiresAt = DateTime.now().add(const Duration(minutes: 30)); - return ViewerSession( - viewUrl: viewUrl, - capabilities: caps, - token: token, - expiresAt: expiresAt, - ); - } - - @override - Future saveAnnotations( - String orgId, - String fileId, - List annotations, - ) async { - await Future.delayed(const Duration(seconds: 2)); - // Mock: just delay, assume success - } -} diff --git a/go_cloud/internal/database/db.go b/go_cloud/internal/database/db.go index 2ed8330..35564e6 100644 --- a/go_cloud/internal/database/db.go +++ b/go_cloud/internal/database/db.go @@ -126,15 +126,17 @@ type Activity struct { } type File struct { - ID uuid.UUID - OrgID *uuid.UUID - UserID *uuid.UUID - Name string - Path string - Type string - Size int64 - LastModified time.Time - CreatedAt time.Time + ID uuid.UUID + OrgID *uuid.UUID + UserID *uuid.UUID + Name string + Path string + Type string + Size int64 + LastModified time.Time + CreatedAt time.Time + ModifiedBy *uuid.UUID + ModifiedByName string } func (db *DB) GetOrCreateUser(ctx context.Context, sub, email, name string) (*User, error) { @@ -465,12 +467,17 @@ func (db *DB) GetFileByID(ctx context.Context, fileID uuid.UUID) (*File, error) var f File var orgNull sql.NullString var userNull sql.NullString + var modifiedByNull sql.NullString + var modifiedByNameNull sql.NullString err := db.QueryRowContext(ctx, ` - SELECT id, org_id::text, user_id::text, name, path, type, size, last_modified, created_at - FROM files - WHERE id = $1 - `, fileID).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt) + SELECT f.id, f.org_id::text, f.user_id::text, f.name, f.path, f.type, f.size, f.last_modified, f.created_at, + f.modified_by::text, u.display_name + FROM files f + LEFT JOIN users u ON f.modified_by = u.id + WHERE f.id = $1 + `, fileID).Scan(&f.ID, &orgNull, &userNull, &f.Name, &f.Path, &f.Type, &f.Size, &f.LastModified, &f.CreatedAt, + &modifiedByNull, &modifiedByNameNull) if err != nil { return nil, err @@ -484,17 +491,24 @@ func (db *DB) GetFileByID(ctx context.Context, fileID uuid.UUID) (*File, error) uid, _ := uuid.Parse(userNull.String) f.UserID = &uid } + if modifiedByNull.Valid { + mid, _ := uuid.Parse(modifiedByNull.String) + f.ModifiedBy = &mid + } + if modifiedByNameNull.Valid { + f.ModifiedByName = modifiedByNameNull.String + } return &f, nil } -// UpdateFileSize updates the size and modification time of a file -func (db *DB) UpdateFileSize(ctx context.Context, fileID uuid.UUID, size int64) error { +// UpdateFileSize updates the size, modification time, and modifier of a file +func (db *DB) UpdateFileSize(ctx context.Context, fileID uuid.UUID, size int64, modifiedBy *uuid.UUID) error { _, err := db.ExecContext(ctx, ` UPDATE files - SET size = $1, last_modified = NOW() + SET size = $1, last_modified = NOW(), modified_by = $3 WHERE id = $2 - `, size, fileID) + `, size, fileID, modifiedBy) return err } diff --git a/go_cloud/internal/http/routes.go b/go_cloud/internal/http/routes.go index 0c1c1e0..885baa1 100644 --- a/go_cloud/internal/http/routes.go +++ b/go_cloud/internal/http/routes.go @@ -520,6 +520,12 @@ func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtM IsPdf bool `json:"isPdf"` MimeType string `json:"mimeType"` } `json:"capabilities"` + FileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + LastModified string `json:"lastModified"` + ModifiedByName string `json:"modifiedByName"` + } `json:"fileInfo"` ExpiresAt string `json:"expiresAt"` }{ ViewUrl: downloadPath, @@ -530,6 +536,17 @@ func viewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, jwtM IsPdf bool `json:"isPdf"` MimeType string `json:"mimeType"` }{CanEdit: false, CanAnnotate: isPdf, IsPdf: isPdf, MimeType: mimeType}, + FileInfo: struct { + Name string `json:"name"` + Size int64 `json:"size"` + LastModified string `json:"lastModified"` + ModifiedByName string `json:"modifiedByName"` + }{ + Name: file.Name, + Size: file.Size, + LastModified: file.LastModified.UTC().Format(time.RFC3339), + ModifiedByName: file.ModifiedByName, + }, ExpiresAt: time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), } @@ -606,6 +623,12 @@ func userViewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, IsPdf bool `json:"isPdf"` MimeType string `json:"mimeType"` } `json:"capabilities"` + FileInfo struct { + Name string `json:"name"` + Size int64 `json:"size"` + LastModified string `json:"lastModified"` + ModifiedByName string `json:"modifiedByName"` + } `json:"fileInfo"` ExpiresAt string `json:"expiresAt"` }{ ViewUrl: downloadPath, @@ -621,6 +644,17 @@ func userViewerHandler(w http.ResponseWriter, r *http.Request, db *database.DB, IsPdf: isPdf, MimeType: mimeType, }, + FileInfo: struct { + Name string `json:"name"` + Size int64 `json:"size"` + LastModified string `json:"lastModified"` + ModifiedByName string `json:"modifiedByName"` + }{ + Name: file.Name, + Size: file.Size, + LastModified: file.LastModified.UTC().Format(time.RFC3339), + ModifiedByName: file.ModifiedByName, + }, ExpiresAt: time.Now().Add(24 * time.Hour).UTC().Format(time.RFC3339), } diff --git a/go_cloud/internal/http/wopi_handlers.go b/go_cloud/internal/http/wopi_handlers.go index 6829f83..045267d 100644 --- a/go_cloud/internal/http/wopi_handlers.go +++ b/go_cloud/internal/http/wopi_handlers.go @@ -512,7 +512,7 @@ func wopiPutFileHandler(w http.ResponseWriter, r *http.Request, db *database.DB, // Update file size and modification time in database newSize := int64(len(content)) - err = db.UpdateFileSize(r.Context(), fileUUID, newSize) + err = db.UpdateFileSize(r.Context(), fileUUID, newSize, &userID) if err != nil { fmt.Printf("[WOPI-STORAGE] Failed to update file size: file=%s error=%v\n", fileID, err) // Don't fail the upload, just log the warning