endpoints to add tags

This commit is contained in:
m
2026-07-13 00:36:08 +02:00
parent 7dc53ea99f
commit c3bb0dd6ab
6 changed files with 165 additions and 16 deletions
+29 -2
View File
@@ -137,9 +137,36 @@ func (h *FileHandler) Delete(c *gin.Context) {
} }
func (h *FileHandler) AddTags(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) { 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)
} }
+7
View File
@@ -1,5 +1,11 @@
package model package model
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
TagType string `json:"tagType"`
}
type File struct { type File struct {
ID string `json:"id"` ID string `json:"id"`
Name string `json:"name"` Name string `json:"name"`
@@ -8,6 +14,7 @@ type File struct {
StorageKey string `json:"-"` StorageKey string `json:"-"`
Checksum string `json:"-"` Checksum string `json:"-"`
OcrText string `json:"ocrText,omitempty"` OcrText string `json:"ocrText,omitempty"`
Tags []Tag `json:"tags"`
CreatedAt string `json:"createdAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"` UpdatedAt string `json:"updatedAt"`
} }
-5
View File
@@ -1,6 +1 @@
package model package model
type Tag struct {
ID string `json:"id"`
Name string `json:"name"`
}
+57 -3
View File
@@ -2,6 +2,7 @@ package service
import ( import (
"context" "context"
"database/sql"
"encoding/hex" "encoding/hex"
"fmt" "fmt"
"mime/multipart" "mime/multipart"
@@ -74,7 +75,11 @@ func (s *FileService) List() ([]model.File, error) {
files := make([]model.File, len(dbFiles)) files := make([]model.File, len(dbFiles))
for i, f := range 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 return files, nil
} }
@@ -84,7 +89,11 @@ func (s *FileService) Get(id string) (*model.File, error) {
if err != nil { if err != nil {
return nil, fmt.Errorf("get file: %w", err) 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 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{ return model.File{
ID: f.ID, ID: f.ID,
Name: f.Name, Name: f.Name,
@@ -122,6 +175,7 @@ func dbToModel(f db.File) model.File {
StorageKey: f.StorageKey, StorageKey: f.StorageKey,
Checksum: f.Checksum, Checksum: f.Checksum,
OcrText: f.OcrText, OcrText: f.OcrText,
Tags: tags,
CreatedAt: f.CreatedAt.String(), CreatedAt: f.CreatedAt.String(),
UpdatedAt: f.UpdatedAt.String(), UpdatedAt: f.UpdatedAt.String(),
} }
+45 -5
View File
@@ -46,6 +46,25 @@ function groupByDate(files: FileItem[]): GroupedFiles {
return groups; 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 { function formatDateLabel(key: string): string {
const d = new Date(); const d = new Date();
const today = d.toLocaleDateString('fr-FR', { weekday: 'long', year: 'numeric', month: 'long', day: 'numeric' }); 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<SearchFilters>({ name: true, ocrText: true, tags: true }); const [filters, setFilters] = useState<SearchFilters>({ name: true, ocrText: true, tags: true });
const [keyboardOpen, setKeyboardOpen] = useState(false); const [keyboardOpen, setKeyboardOpen] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [groupByTags, setGroupByTags] = useState(false);
useEffect(() => { useEffect(() => {
const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true)); const show = Keyboard.addListener('keyboardDidShow', () => setKeyboardOpen(true));
@@ -131,7 +151,7 @@ export function HomeScreen() {
[files, searchQuery, filters] [files, searchQuery, filters]
); );
const groups = groupByDate(filteredFiles); const groups = groupByTags ? groupByTag(filteredFiles) : groupByDate(filteredFiles);
const groupKeys = Object.keys(groups); const groupKeys = Object.keys(groups);
const fileIdToIndex = useMemo(() => { const fileIdToIndex = useMemo(() => {
@@ -236,11 +256,16 @@ export function HomeScreen() {
</Text> </Text>
</View> </View>
} }
renderItem={({ item: dateKey }) => { renderItem={({ item: groupKey }) => {
const groupFiles = groups[dateKey]; const groupFiles = groups[groupKey];
const label = groupByTags ? groupKey : formatDateLabel(groupKey);
return ( return (
<View style={styles.section}> <View style={styles.section}>
<Text style={styles.sectionTitle}>{formatDateLabel(dateKey)}</Text> <View style={styles.sectionHeader}>
{groupByTags && <MaterialIcons name="label" size={16} color="#1976D2" style={styles.sectionIcon} />}
<Text style={styles.sectionTitle}>{label}</Text>
<Text style={styles.sectionCount}>{groupFiles.length}</Text>
</View>
<View style={styles.grid}> <View style={styles.grid}>
{groupFiles.map((file) => ( {groupFiles.map((file) => (
<FileGridItem <FileGridItem
@@ -264,6 +289,8 @@ export function HomeScreen() {
onClear={() => setSearchQuery('')} onClear={() => setSearchQuery('')}
filters={filters} filters={filters}
onFiltersChange={setFilters} onFiltersChange={setFilters}
groupByTags={groupByTags}
onGroupToggle={() => setGroupByTags(!groupByTags)}
bottomPadding={keyboardOpen ? insets.bottom+8 : 0} bottomPadding={keyboardOpen ? insets.bottom+8 : 0}
/> />
)} )}
@@ -358,9 +385,22 @@ const styles = StyleSheet.create({
sectionTitle: { sectionTitle: {
fontSize: 18, fontSize: 18,
fontWeight: '700', fontWeight: '700',
marginBottom: 12,
color: '#333', color: '#333',
}, },
sectionHeader: {
flexDirection: 'row',
alignItems: 'center',
marginBottom: 12,
gap: 6,
},
sectionIcon: {
marginTop: 2,
},
sectionCount: {
fontSize: 14,
color: '#999',
fontWeight: '400',
},
grid: { grid: {
flexDirection: 'row', flexDirection: 'row',
flexWrap: 'wrap', flexWrap: 'wrap',
+27 -1
View File
@@ -17,6 +17,8 @@ interface SearchBarProps {
onClear: () => void; onClear: () => void;
filters: SearchFilters; filters: SearchFilters;
onFiltersChange: (f: SearchFilters) => void; onFiltersChange: (f: SearchFilters) => void;
groupByTags: boolean;
onGroupToggle: () => void;
bottomPadding?: number; bottomPadding?: number;
} }
@@ -26,7 +28,7 @@ const FILTER_OPTIONS: { key: keyof SearchFilters; label: string; icon: IconName
{ key: 'tags', label: 'Tags', icon: 'label-outline' }, { 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 animatedHeight = useRef(new Animated.Value(0)).current;
const [panelOpen, setPanelOpen] = React.useState(false); const [panelOpen, setPanelOpen] = React.useState(false);
@@ -81,6 +83,17 @@ export function SearchBar({ query, onQueryChange, onClear, filters, onFiltersCha
color={hasActiveFilter ? '#fff' : '#1976D2'} color={hasActiveFilter ? '#fff' : '#1976D2'}
/> />
</TouchableOpacity> </TouchableOpacity>
<TouchableOpacity
style={[styles.groupBtn, groupByTags && styles.groupBtnActive]}
onPress={onGroupToggle}
>
<MaterialIcons
name="folder-special"
size={22}
color={groupByTags ? '#fff' : '#1976D2'}
/>
</TouchableOpacity>
</View> </View>
<Animated.View style={[styles.filterPanel, { maxHeight: panelMaxHeight, opacity: animatedHeight }]}> <Animated.View style={[styles.filterPanel, { maxHeight: panelMaxHeight, opacity: animatedHeight }]}>
@@ -151,6 +164,19 @@ const styles = StyleSheet.create({
filterBtnActive: { filterBtnActive: {
backgroundColor: '#1976D2', backgroundColor: '#1976D2',
}, },
groupBtn: {
width: 40,
height: 40,
borderRadius: 10,
borderWidth: 1,
borderColor: '#1976D2',
justifyContent: 'center',
alignItems: 'center',
backgroundColor: '#fff',
},
groupBtnActive: {
backgroundColor: '#1976D2',
},
filterPanel: { filterPanel: {
flexDirection: 'row', flexDirection: 'row',
paddingHorizontal: 12, paddingHorizontal: 12,