From c3bb0dd6abf1a0a8e6383bddf3d4d979e5d04cda Mon Sep 17 00:00:00 2001 From: m Date: Mon, 13 Jul 2026 00:36:08 +0200 Subject: [PATCH] endpoints to add tags --- backend/internal/handler/files.go | 31 ++++++++++++++-- backend/internal/model/file.go | 7 ++++ backend/internal/model/tag.go | 5 --- backend/internal/service/file.go | 60 +++++++++++++++++++++++++++++-- mobile/app/index.tsx | 50 +++++++++++++++++++++++--- mobile/components/SearchBar.tsx | 28 ++++++++++++++- 6 files changed, 165 insertions(+), 16 deletions(-) diff --git a/backend/internal/handler/files.go b/backend/internal/handler/files.go index 439d65a..bba9ca0 100644 --- a/backend/internal/handler/files.go +++ b/backend/internal/handler/files.go @@ -137,9 +137,36 @@ func (h *FileHandler) Delete(c *gin.Context) { } func (h *FileHandler) AddTags(c *gin.Context) { - api.Error(c, http.StatusNotImplemented, "NOT_IMPLEMENTED", "Tags not yet implemented") + id := c.Param("id") + + var body struct { + Tags []string `json:"tags" binding:"required"` + } + if err := c.ShouldBindJSON(&body); err != nil { + api.Error(c, http.StatusBadRequest, "INVALID_BODY", "Body must contain a 'tags' array") + return + } + + if err := h.files.AddTags(id, body.Tags); err != nil { + api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to add tags") + return + } + + tags, err := h.files.GetTagsByFileID(id) + if err != nil { + api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to fetch tags") + return + } + + api.Success(c, tags) } func (h *FileHandler) GetTags(c *gin.Context) { - api.Success(c, []interface{}{}) + id := c.Param("id") + tags, err := h.files.GetTagsByFileID(id) + if err != nil { + api.Error(c, http.StatusInternalServerError, "DB_ERROR", "Failed to fetch tags") + return + } + api.Success(c, tags) } diff --git a/backend/internal/model/file.go b/backend/internal/model/file.go index fcae85d..dccbbb1 100644 --- a/backend/internal/model/file.go +++ b/backend/internal/model/file.go @@ -1,5 +1,11 @@ package model +type Tag struct { + ID string `json:"id"` + Name string `json:"name"` + TagType string `json:"tagType"` +} + type File struct { ID string `json:"id"` Name string `json:"name"` @@ -8,6 +14,7 @@ type File struct { StorageKey string `json:"-"` Checksum string `json:"-"` OcrText string `json:"ocrText,omitempty"` + Tags []Tag `json:"tags"` CreatedAt string `json:"createdAt"` UpdatedAt string `json:"updatedAt"` } diff --git a/backend/internal/model/tag.go b/backend/internal/model/tag.go index 06b0c9a..8b53790 100644 --- a/backend/internal/model/tag.go +++ b/backend/internal/model/tag.go @@ -1,6 +1 @@ package model - -type Tag struct { - ID string `json:"id"` - Name string `json:"name"` -} diff --git a/backend/internal/service/file.go b/backend/internal/service/file.go index 0010733..2ec4846 100644 --- a/backend/internal/service/file.go +++ b/backend/internal/service/file.go @@ -2,6 +2,7 @@ package service import ( "context" + "database/sql" "encoding/hex" "fmt" "mime/multipart" @@ -74,7 +75,11 @@ func (s *FileService) List() ([]model.File, error) { files := make([]model.File, len(dbFiles)) for i, f := range dbFiles { - files[i] = dbToModel(f) + tags, err := s.queries.GetTagsByFileID(context.Background(), sql.NullString{String: f.ID, Valid: true}) + if err != nil { + return nil, fmt.Errorf("get tags for file %s: %w", f.ID, err) + } + files[i] = dbToModel(f, tags) } return files, nil } @@ -84,7 +89,11 @@ func (s *FileService) Get(id string) (*model.File, error) { if err != nil { return nil, fmt.Errorf("get file: %w", err) } - m := dbToModel(f) + tags, err := s.queries.GetTagsByFileID(context.Background(), sql.NullString{String: f.ID, Valid: true}) + if err != nil { + return nil, fmt.Errorf("get tags: %w", err) + } + m := dbToModel(f, tags) return &m, nil } @@ -113,7 +122,51 @@ func (s *FileService) UpdateOCRText(id, text string) error { }) } -func dbToModel(f db.File) model.File { +func (s *FileService) AddTags(fileID string, tagNames []string) error { + for _, name := range tagNames { + tag, err := s.queries.GetTagByName(context.Background(), name) + if err == sql.ErrNoRows { + tag, err = s.queries.CreateTag(context.Background(), db.CreateTagParams{ + ID: uuid.New().String(), + TagName: name, + TagType: "none", + }) + if err != nil { + return fmt.Errorf("create tag %q: %w", name, err) + } + } else if err != nil { + return fmt.Errorf("get tag %q: %w", name, err) + } + + err = s.queries.AddTagToFile(context.Background(), db.AddTagToFileParams{ + ID: uuid.New().String(), + TagID: sql.NullString{String: tag.ID, Valid: true}, + FileID: sql.NullString{String: fileID, Valid: true}, + }) + if err != nil { + return fmt.Errorf("link tag %q to file: %w", name, err) + } + } + return nil +} + +func (s *FileService) GetTagsByFileID(fileID string) ([]model.Tag, error) { + dbTags, err := s.queries.GetTagsByFileID(context.Background(), sql.NullString{String: fileID, Valid: true}) + if err != nil { + return nil, fmt.Errorf("get tags: %w", err) + } + tags := make([]model.Tag, len(dbTags)) + for i, t := range dbTags { + tags[i] = model.Tag{ID: t.ID, Name: t.TagName, TagType: t.TagType} + } + return tags, nil +} + +func dbToModel(f db.File, dbTags []db.Tag) model.File { + tags := make([]model.Tag, len(dbTags)) + for i, t := range dbTags { + tags[i] = model.Tag{ID: t.ID, Name: t.TagName, TagType: t.TagType} + } return model.File{ ID: f.ID, Name: f.Name, @@ -122,6 +175,7 @@ func dbToModel(f db.File) model.File { StorageKey: f.StorageKey, Checksum: f.Checksum, OcrText: f.OcrText, + Tags: tags, CreatedAt: f.CreatedAt.String(), UpdatedAt: f.UpdatedAt.String(), } diff --git a/mobile/app/index.tsx b/mobile/app/index.tsx index 9cd77c8..593a161 100644 --- a/mobile/app/index.tsx +++ b/mobile/app/index.tsx @@ -46,6 +46,25 @@ function groupByDate(files: FileItem[]): GroupedFiles { return groups; } +function groupByTag(files: FileItem[]): GroupedFiles { + const groups: GroupedFiles = {}; + for (const file of files) { + const tags = file.tags ?? []; + if (tags.length === 0) { + if (!groups['Sans tag']) groups['Sans tag'] = []; + groups['Sans tag'].push(file); + } else { + for (const tag of tags) { + const name = typeof tag === 'string' ? tag : tag.name; + if (!name) continue; + if (!groups[name]) groups[name] = []; + groups[name].push(file); + } + } + } + return groups; +} + function formatDateLabel(key: string): string { const d = new Date(); const today = d.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); @@ -115,6 +134,7 @@ export function HomeScreen() { const [filters, setFilters] = useState({ name: true, ocrText: true, tags: true }); const [keyboardOpen, setKeyboardOpen] = useState(false); const [selectedIds, setSelectedIds] = useState>(new Set()); + const [groupByTags, setGroupByTags] = useState(false); useEffect(() => { const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true)); @@ -131,7 +151,7 @@ export function HomeScreen() { [files, searchQuery, filters] ); - const groups = groupByDate(filteredFiles); + const groups = groupByTags ? groupByTag(filteredFiles) : groupByDate(filteredFiles); const groupKeys = Object.keys(groups); const fileIdToIndex = useMemo(() => { @@ -236,11 +256,16 @@ export function HomeScreen() { } - renderItem={({ item: dateKey }) => { - const groupFiles = groups[dateKey]; + renderItem={({ item: groupKey }) => { + const groupFiles = groups[groupKey]; + const label = groupByTags ? groupKey : formatDateLabel(groupKey); return ( - {formatDateLabel(dateKey)} + + {groupByTags && } + {label} + {groupFiles.length} + {groupFiles.map((file) => ( setSearchQuery('')} filters={filters} onFiltersChange={setFilters} + groupByTags={groupByTags} + onGroupToggle={() => setGroupByTags(!groupByTags)} bottomPadding={keyboardOpen ? insets.bottom+8 : 0} /> )} @@ -358,9 +385,22 @@ const styles = StyleSheet.create({ sectionTitle: { fontSize: 18, fontWeight: '700', - marginBottom: 12, color: '#333', }, + sectionHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 12, + gap: 6, + }, + sectionIcon: { + marginTop: 2, + }, + sectionCount: { + fontSize: 14, + color: '#999', + fontWeight: '400', + }, grid: { flexDirection: 'row', flexWrap: 'wrap', diff --git a/mobile/components/SearchBar.tsx b/mobile/components/SearchBar.tsx index 61131cf..6116f35 100644 --- a/mobile/components/SearchBar.tsx +++ b/mobile/components/SearchBar.tsx @@ -17,6 +17,8 @@ interface SearchBarProps { onClear: () => void; filters: SearchFilters; onFiltersChange: (f: SearchFilters) => void; + groupByTags: boolean; + onGroupToggle: () => void; bottomPadding?: number; } @@ -26,7 +28,7 @@ const FILTER_OPTIONS: { key: keyof SearchFilters; label: string; icon: IconName { key: 'tags', label: 'Tags', icon: 'label-outline' }, ]; -export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersChange, bottomPadding = 0 }: SearchBarProps) { +export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersChange, groupByTags, onGroupToggle, bottomPadding = 0 }: SearchBarProps) { const animatedHeight = useRef(new Animated.Value(0)).current; const [panelOpen, setPanelOpen] = React.useState(false); @@ -81,6 +83,17 @@ export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersCha color={hasActiveFilter ? '#fff' : '#1976D2'} /> + + + + @@ -151,6 +164,19 @@ const styles = StyleSheet.create({ filterBtnActive: { backgroundColor: '#1976D2', }, + groupBtn: { + width: 40, + height: 40, + borderRadius: 10, + borderWidth: 1, + borderColor: '#1976D2', + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#fff', + }, + groupBtnActive: { + backgroundColor: '#1976D2', + }, filterPanel: { flexDirection: 'row', paddingHorizontal: 12,