endpoints to add tags
This commit is contained in:
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
type Tag struct {
|
|
||||||
ID string `json:"id"`
|
|
||||||
Name string `json:"name"`
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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
@@ -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',
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user