categorisation for gifs
This commit is contained in:
parent
5e18bd3830
commit
d0669bdc5e
15 changed files with 468 additions and 49 deletions
|
|
@ -290,13 +290,14 @@ type GifCatalogRow struct {
|
||||||
DocumentID int64 `json:"DocumentID,string"`
|
DocumentID int64 `json:"DocumentID,string"`
|
||||||
Enabled bool
|
Enabled bool
|
||||||
SortOrder int
|
SortOrder int
|
||||||
|
Category string
|
||||||
CreatedBy string
|
CreatedBy string
|
||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *readStore) ListGifCatalog(ctx context.Context) ([]GifCatalogRow, error) {
|
func (s *readStore) ListGifCatalog(ctx context.Context) ([]GifCatalogRow, error) {
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT id, title, document_id, enabled, sort_order, created_by, created_at
|
SELECT id, title, document_id, enabled, sort_order, category, created_by, created_at
|
||||||
FROM gif_catalog
|
FROM gif_catalog
|
||||||
ORDER BY sort_order, id`)
|
ORDER BY sort_order, id`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -306,7 +307,7 @@ ORDER BY sort_order, id`)
|
||||||
out := make([]GifCatalogRow, 0)
|
out := make([]GifCatalogRow, 0)
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item GifCatalogRow
|
var item GifCatalogRow
|
||||||
if err := rows.Scan(&item.ID, &item.Title, &item.DocumentID, &item.Enabled, &item.SortOrder, &item.CreatedBy, &item.CreatedAt); err != nil {
|
if err := rows.Scan(&item.ID, &item.Title, &item.DocumentID, &item.Enabled, &item.SortOrder, &item.Category, &item.CreatedBy, &item.CreatedAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, item)
|
out = append(out, item)
|
||||||
|
|
|
||||||
|
|
@ -124,6 +124,8 @@ func (s *server) routes() http.Handler {
|
||||||
mux.Handle("POST /api/actions/create-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleCreateGifCatalogEntryAPI)))
|
mux.Handle("POST /api/actions/create-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleCreateGifCatalogEntryAPI)))
|
||||||
mux.Handle("POST /api/actions/set-gif-catalog-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogEnabledAPI)))
|
mux.Handle("POST /api/actions/set-gif-catalog-enabled", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogEnabledAPI)))
|
||||||
mux.Handle("POST /api/actions/set-gif-catalog-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogSortOrderAPI)))
|
mux.Handle("POST /api/actions/set-gif-catalog-sort-order", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogSortOrderAPI)))
|
||||||
|
mux.Handle("POST /api/actions/set-gif-catalog-category", s.requireAuthAPI(http.HandlerFunc(s.handleSetGifCatalogCategoryAPI)))
|
||||||
|
mux.Handle("POST /api/actions/auto-categorize-gif-catalog", s.requireAuthAPI(http.HandlerFunc(s.handleAutoCategorizeGifCatalogAPI)))
|
||||||
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI)))
|
mux.Handle("POST /api/actions/delete-gif-catalog-entry", s.requireAuthAPI(http.HandlerFunc(s.handleDeleteGifCatalogEntryAPI)))
|
||||||
mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
|
mux.Handle("POST /api/actions/mint-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleMintCollectibleUsernameAPI)))
|
||||||
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
|
mux.Handle("POST /api/actions/transfer-collectible-username", s.requireAuthAPI(http.HandlerFunc(s.handleTransferCollectibleUsernameAPI)))
|
||||||
|
|
@ -1920,6 +1922,45 @@ func (s *server) handleSetGifCatalogSortOrderAPI(w http.ResponseWriter, r *http.
|
||||||
writeCommandResultAPI(w, result, err)
|
writeCommandResultAPI(w, result, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type setGifCatalogCategoryAPIRequest struct {
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
ID int64 `json:"id,string"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleSetGifCatalogCategoryAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body setGifCatalogCategoryAPIRequest
|
||||||
|
if !decodeAction(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := admin.SetGifCatalogCategoryRequest{
|
||||||
|
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "set-gif-catalog-category"),
|
||||||
|
ID: body.ID, Category: body.Category,
|
||||||
|
}
|
||||||
|
result, err := s.callAdminAPI(r.Context(), "/v1/gif-catalog/set-category", req)
|
||||||
|
writeCommandResultAPI(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type autoCategorizeGifCatalogAPIRequest struct {
|
||||||
|
CommandID string `json:"command_id"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Confirm bool `json:"confirm"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *server) handleAutoCategorizeGifCatalogAPI(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var body autoCategorizeGifCatalogAPIRequest
|
||||||
|
if !decodeAction(w, r, &body) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
req := admin.AutoCategorizeGifCatalogRequest{
|
||||||
|
CommandMeta: s.commandMetaFromAPI(r, body.CommandID, body.Reason, body.Confirm, "auto-categorize-gif-catalog"),
|
||||||
|
}
|
||||||
|
result, err := s.callAdminAPI(r.Context(), "/v1/gif-catalog/auto-categorize", req)
|
||||||
|
writeCommandResultAPI(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
type deleteGifCatalogEntryAPIRequest struct {
|
type deleteGifCatalogEntryAPIRequest struct {
|
||||||
CommandID string `json:"command_id"`
|
CommandID string `json:"command_id"`
|
||||||
Reason string `json:"reason"`
|
Reason string `json:"reason"`
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import { createPortal } from "react-dom";
|
||||||
import { api, errorMessage } from "../api";
|
import { api, errorMessage } from "../api";
|
||||||
import { ActionButton } from "../components/ActionButton";
|
import { ActionButton } from "../components/ActionButton";
|
||||||
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
import { Alert, Badge, EmptyRow, Metric, PageFrame, QueryPanel } from "../components/ui";
|
||||||
import type { GifCatalogRow } from "../types";
|
import { GIF_CATALOG_CATEGORIES, type GifCatalogRow } from "../types";
|
||||||
|
|
||||||
type GifPageSize = 10 | 20 | 50 | 100 | "all";
|
type GifPageSize = 10 | 20 | 50 | 100 | "all";
|
||||||
|
|
||||||
|
|
@ -45,6 +45,7 @@ export function GifCatalogPage() {
|
||||||
const [pageSize, setPageSize] = useState<GifPageSize>(10);
|
const [pageSize, setPageSize] = useState<GifPageSize>(10);
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const [orderDrafts, setOrderDrafts] = useState<Record<string, string>>({});
|
const [orderDrafts, setOrderDrafts] = useState<Record<string, string>>({});
|
||||||
|
const [categoryDrafts, setCategoryDrafts] = useState<Record<string, string>>({});
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
|
|
@ -83,7 +84,8 @@ export function GifCatalogPage() {
|
||||||
|
|
||||||
const counts = useMemo(() => ({
|
const counts = useMemo(() => ({
|
||||||
total: rows.length,
|
total: rows.length,
|
||||||
enabled: rows.filter((row) => row.Enabled).length
|
enabled: rows.filter((row) => row.Enabled).length,
|
||||||
|
uncategorized: rows.filter((row) => !row.Category).length
|
||||||
}), [rows]);
|
}), [rows]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -95,6 +97,13 @@ export function GifCatalogPage() {
|
||||||
<button className="btn" type="button" onClick={() => load()} disabled={busy}>
|
<button className="btn" type="button" onClick={() => load()} disabled={busy}>
|
||||||
<RefreshCw size={15} /> {"Refresh"}
|
<RefreshCw size={15} /> {"Refresh"}
|
||||||
</button>
|
</button>
|
||||||
|
<ActionButton
|
||||||
|
tone="neutral"
|
||||||
|
label={"Auto-categorize"}
|
||||||
|
path="/api/actions/auto-categorize-gif-catalog"
|
||||||
|
payload={() => ({})}
|
||||||
|
onDone={() => void load()}
|
||||||
|
/>
|
||||||
<button className="btn primary" type="button" onClick={() => setCreateOpen(true)}>
|
<button className="btn primary" type="button" onClick={() => setCreateOpen(true)}>
|
||||||
<Plus size={15} /> {"Add GIF"}
|
<Plus size={15} /> {"Add GIF"}
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -105,6 +114,7 @@ export function GifCatalogPage() {
|
||||||
<div className="metric-row">
|
<div className="metric-row">
|
||||||
<Metric label={"Total GIFs"} value={String(counts.total)} />
|
<Metric label={"Total GIFs"} value={String(counts.total)} />
|
||||||
<Metric label={"Enabled"} value={String(counts.enabled)} tone="good" />
|
<Metric label={"Enabled"} value={String(counts.enabled)} tone="good" />
|
||||||
|
<Metric label={"Uncategorized"} value={String(counts.uncategorized)} tone={counts.uncategorized > 0 ? "warn" : undefined} />
|
||||||
</div>
|
</div>
|
||||||
<QueryPanel>
|
<QueryPanel>
|
||||||
<div className="toolbar">
|
<div className="toolbar">
|
||||||
|
|
@ -138,6 +148,7 @@ export function GifCatalogPage() {
|
||||||
<th>{"Document ID"}</th>
|
<th>{"Document ID"}</th>
|
||||||
<th>{"Added by"}</th>
|
<th>{"Added by"}</th>
|
||||||
<th>{"Status"}</th>
|
<th>{"Status"}</th>
|
||||||
|
<th>{"Category"}</th>
|
||||||
<th>{"Sort order"}</th>
|
<th>{"Sort order"}</th>
|
||||||
<th>{"Actions"}</th>
|
<th>{"Actions"}</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -151,6 +162,28 @@ export function GifCatalogPage() {
|
||||||
<td className="mono">{row.DocumentID}</td>
|
<td className="mono">{row.DocumentID}</td>
|
||||||
<td>{row.CreatedBy || <span className="muted-cell">{"—"}</span>}</td>
|
<td>{row.CreatedBy || <span className="muted-cell">{"—"}</span>}</td>
|
||||||
<td>{row.Enabled ? <Badge tone="good">{"Enabled"}</Badge> : <Badge tone="danger">{"Disabled"}</Badge>}</td>
|
<td>{row.Enabled ? <Badge tone="good">{"Enabled"}</Badge> : <Badge tone="danger">{"Disabled"}</Badge>}</td>
|
||||||
|
<td>
|
||||||
|
<div className="sort-order-editor">
|
||||||
|
<select
|
||||||
|
className="small-input"
|
||||||
|
value={categoryDrafts[row.ID] ?? row.Category}
|
||||||
|
onChange={(event) => setCategoryDrafts((prev) => ({ ...prev, [row.ID]: event.target.value }))}
|
||||||
|
>
|
||||||
|
<option value="">{"Uncategorized"}</option>
|
||||||
|
{GIF_CATALOG_CATEGORIES.map((category) => (
|
||||||
|
<option key={category} value={category}>{category}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
<ActionButton
|
||||||
|
compact
|
||||||
|
tone="neutral"
|
||||||
|
label={"Save"}
|
||||||
|
path="/api/actions/set-gif-catalog-category"
|
||||||
|
payload={() => ({ id: row.ID, category: categoryDrafts[row.ID] ?? row.Category })}
|
||||||
|
onDone={() => void load()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="sort-order-editor">
|
<div className="sort-order-editor">
|
||||||
<input
|
<input
|
||||||
|
|
@ -191,7 +224,7 @@ export function GifCatalogPage() {
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
{paged.length === 0 && <EmptyRow colSpan={8} />}
|
{paged.length === 0 && <EmptyRow colSpan={9} />}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -636,6 +636,14 @@ export type StickerSetRow = {
|
||||||
|
|
||||||
export type StickerSetListResponse = { rows: StickerSetRow[] };
|
export type StickerSetListResponse = { rows: StickerSetRow[] };
|
||||||
|
|
||||||
|
// GifCatalogCategories mirrors domain.GifCatalogCategories -- the titles
|
||||||
|
// internal/seed/catalog/emoji_groups.json uses for the GIF picker's category
|
||||||
|
// icons. "" (rendered as "Uncategorized") is always a valid value too.
|
||||||
|
export const GIF_CATALOG_CATEGORIES = [
|
||||||
|
"Love", "Approval", "Disapproval", "Cheers", "Laughter",
|
||||||
|
"Astonishment", "Sadness", "Anger", "Neutral", "Doubt", "Silly",
|
||||||
|
];
|
||||||
|
|
||||||
export type GifCatalogRow = {
|
export type GifCatalogRow = {
|
||||||
// String, not number: 18-19 digit snowflake ids, past JS's 2^53
|
// String, not number: 18-19 digit snowflake ids, past JS's 2^53
|
||||||
// safe-integer limit.
|
// safe-integer limit.
|
||||||
|
|
@ -644,6 +652,7 @@ export type GifCatalogRow = {
|
||||||
DocumentID: string;
|
DocumentID: string;
|
||||||
Enabled: boolean;
|
Enabled: boolean;
|
||||||
SortOrder: number;
|
SortOrder: number;
|
||||||
|
Category: string;
|
||||||
CreatedBy: string;
|
CreatedBy: string;
|
||||||
CreatedAt: string;
|
CreatedAt: string;
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
DROP INDEX IF EXISTS public.gif_catalog_category_idx;
|
||||||
|
ALTER TABLE public.gif_catalog DROP COLUMN IF EXISTS category;
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
-- Tags a gif_catalog entry with one of the messages.getEmojiGroups category
|
||||||
|
-- titles (see internal/seed/catalog/emoji_groups.json) so tapping a category
|
||||||
|
-- icon in the client's GIF picker can filter to that emotion instead of
|
||||||
|
-- always showing the whole catalog (see files.ClassifyGifCategory). Empty
|
||||||
|
-- means uncategorized -- still served by plain text search, just not by any
|
||||||
|
-- category tap.
|
||||||
|
ALTER TABLE public.gif_catalog ADD COLUMN category text DEFAULT ''::text NOT NULL;
|
||||||
|
|
||||||
|
CREATE INDEX gif_catalog_category_idx ON public.gif_catalog (category) WHERE category <> '';
|
||||||
|
|
@ -57,6 +57,8 @@ const (
|
||||||
ActionCreateGifCatalogEntry = "gif_catalog.create"
|
ActionCreateGifCatalogEntry = "gif_catalog.create"
|
||||||
ActionSetGifCatalogEnabled = "gif_catalog.set_enabled"
|
ActionSetGifCatalogEnabled = "gif_catalog.set_enabled"
|
||||||
ActionSetGifCatalogSortOrder = "gif_catalog.set_sort_order"
|
ActionSetGifCatalogSortOrder = "gif_catalog.set_sort_order"
|
||||||
|
ActionSetGifCatalogCategory = "gif_catalog.set_category"
|
||||||
|
ActionAutoCategorizeGifCatalog = "gif_catalog.auto_categorize"
|
||||||
ActionDeleteGifCatalogEntry = "gif_catalog.delete"
|
ActionDeleteGifCatalogEntry = "gif_catalog.delete"
|
||||||
// Collectible (Fragment-style) username lifecycle.
|
// Collectible (Fragment-style) username lifecycle.
|
||||||
ActionMintCollectibleUsername = "usernames.collectible.mint"
|
ActionMintCollectibleUsername = "usernames.collectible.mint"
|
||||||
|
|
@ -305,6 +307,11 @@ type GifCatalogService interface {
|
||||||
AdminListGifCatalog(ctx context.Context) ([]domain.GifCatalogEntry, error)
|
AdminListGifCatalog(ctx context.Context) ([]domain.GifCatalogEntry, error)
|
||||||
AdminSetGifCatalogEnabled(ctx context.Context, id int64, enabled bool) (bool, error)
|
AdminSetGifCatalogEnabled(ctx context.Context, id int64, enabled bool) (bool, error)
|
||||||
AdminSetGifCatalogSortOrder(ctx context.Context, id int64, order int) (bool, error)
|
AdminSetGifCatalogSortOrder(ctx context.Context, id int64, order int) (bool, error)
|
||||||
|
AdminSetGifCatalogCategory(ctx context.Context, id int64, category string) (bool, error)
|
||||||
|
// AdminAutoCategorizeGifCatalog runs files.ClassifyGifCategory over every
|
||||||
|
// currently-uncategorized entry's title and returns how many got a
|
||||||
|
// category assigned.
|
||||||
|
AdminAutoCategorizeGifCatalog(ctx context.Context) (int, error)
|
||||||
AdminDeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error)
|
AdminDeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -700,6 +707,16 @@ type SetGifCatalogSortOrderRequest struct {
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type SetGifCatalogCategoryRequest struct {
|
||||||
|
CommandMeta
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Category string `json:"category"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type AutoCategorizeGifCatalogRequest struct {
|
||||||
|
CommandMeta
|
||||||
|
}
|
||||||
|
|
||||||
type DeleteGifCatalogEntryRequest struct {
|
type DeleteGifCatalogEntryRequest struct {
|
||||||
CommandMeta
|
CommandMeta
|
||||||
ID int64 `json:"id"`
|
ID int64 `json:"id"`
|
||||||
|
|
@ -2847,6 +2864,39 @@ func (s *Service) SetGifCatalogSortOrder(ctx context.Context, req SetGifCatalogS
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) SetGifCatalogCategory(ctx context.Context, req SetGifCatalogCategoryRequest) (CommandResult, error) {
|
||||||
|
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
|
||||||
|
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")
|
||||||
|
}
|
||||||
|
if !domain.ValidGifCatalogCategory(req.Category) {
|
||||||
|
return CommandResult{}, domain.ErrGifCatalogEntryInvalid
|
||||||
|
}
|
||||||
|
return s.runCommand(ctx, req.CommandMeta, ActionSetGifCatalogCategory, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||||
|
details := map[string]any{"id": strconv.FormatInt(req.ID, 10), "category": req.Category}
|
||||||
|
if req.DryRun {
|
||||||
|
return CommandResult{Message: "gif catalog entry category change validated", Details: details}, nil
|
||||||
|
}
|
||||||
|
changed, err := s.gifCatalog.AdminSetGifCatalogCategory(ctx, req.ID, req.Category)
|
||||||
|
details["changed"] = changed
|
||||||
|
return CommandResult{Message: "gif catalog entry category updated", Details: details}, err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) AutoCategorizeGifCatalog(ctx context.Context, req AutoCategorizeGifCatalogRequest) (CommandResult, error) {
|
||||||
|
if s == nil || s.gifCatalog == nil {
|
||||||
|
return CommandResult{}, fmt.Errorf("gif catalog service is not configured")
|
||||||
|
}
|
||||||
|
return s.runCommand(ctx, req.CommandMeta, ActionAutoCategorizeGifCatalog, 0, domain.Peer{}, req, func() (CommandResult, error) {
|
||||||
|
details := map[string]any{}
|
||||||
|
if req.DryRun {
|
||||||
|
return CommandResult{Message: "gif catalog auto-categorize validated", Details: details}, nil
|
||||||
|
}
|
||||||
|
count, err := s.gifCatalog.AdminAutoCategorizeGifCatalog(ctx)
|
||||||
|
details["categorized"] = count
|
||||||
|
return CommandResult{Message: "gif catalog auto-categorized", Details: details}, err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) DeleteGifCatalogEntry(ctx context.Context, req DeleteGifCatalogEntryRequest) (CommandResult, error) {
|
func (s *Service) DeleteGifCatalogEntry(ctx context.Context, req DeleteGifCatalogEntryRequest) (CommandResult, error) {
|
||||||
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
|
if s == nil || s.gifCatalog == nil || req.ID <= 0 {
|
||||||
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")
|
return CommandResult{}, fmt.Errorf("valid gif catalog entry and service are required")
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,8 @@ type Service interface {
|
||||||
CreateGifCatalogEntry(ctx context.Context, req admin.CreateGifCatalogEntryRequest) (admin.CommandResult, error)
|
CreateGifCatalogEntry(ctx context.Context, req admin.CreateGifCatalogEntryRequest) (admin.CommandResult, error)
|
||||||
SetGifCatalogEnabled(ctx context.Context, req admin.SetGifCatalogEnabledRequest) (admin.CommandResult, error)
|
SetGifCatalogEnabled(ctx context.Context, req admin.SetGifCatalogEnabledRequest) (admin.CommandResult, error)
|
||||||
SetGifCatalogSortOrder(ctx context.Context, req admin.SetGifCatalogSortOrderRequest) (admin.CommandResult, error)
|
SetGifCatalogSortOrder(ctx context.Context, req admin.SetGifCatalogSortOrderRequest) (admin.CommandResult, error)
|
||||||
|
SetGifCatalogCategory(ctx context.Context, req admin.SetGifCatalogCategoryRequest) (admin.CommandResult, error)
|
||||||
|
AutoCategorizeGifCatalog(ctx context.Context, req admin.AutoCategorizeGifCatalogRequest) (admin.CommandResult, error)
|
||||||
DeleteGifCatalogEntry(ctx context.Context, req admin.DeleteGifCatalogEntryRequest) (admin.CommandResult, error)
|
DeleteGifCatalogEntry(ctx context.Context, req admin.DeleteGifCatalogEntryRequest) (admin.CommandResult, error)
|
||||||
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error)
|
EmojiAnimation(ctx context.Context, documentID int64) ([]byte, bool, error)
|
||||||
ModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error)
|
ModerationCases(ctx context.Context, filter domain.ModerationCaseFilter) ([]domain.ModerationCase, error)
|
||||||
|
|
@ -211,6 +213,8 @@ func (s *Server) routes() http.Handler {
|
||||||
mux.HandleFunc("POST /v1/gif-catalog/create", s.authenticated(s.handleCreateGifCatalogEntry))
|
mux.HandleFunc("POST /v1/gif-catalog/create", s.authenticated(s.handleCreateGifCatalogEntry))
|
||||||
mux.HandleFunc("POST /v1/gif-catalog/set-enabled", s.authenticated(s.handleSetGifCatalogEnabled))
|
mux.HandleFunc("POST /v1/gif-catalog/set-enabled", s.authenticated(s.handleSetGifCatalogEnabled))
|
||||||
mux.HandleFunc("POST /v1/gif-catalog/set-sort-order", s.authenticated(s.handleSetGifCatalogSortOrder))
|
mux.HandleFunc("POST /v1/gif-catalog/set-sort-order", s.authenticated(s.handleSetGifCatalogSortOrder))
|
||||||
|
mux.HandleFunc("POST /v1/gif-catalog/set-category", s.authenticated(s.handleSetGifCatalogCategory))
|
||||||
|
mux.HandleFunc("POST /v1/gif-catalog/auto-categorize", s.authenticated(s.handleAutoCategorizeGifCatalog))
|
||||||
mux.HandleFunc("POST /v1/gif-catalog/delete", s.authenticated(s.handleDeleteGifCatalogEntry))
|
mux.HandleFunc("POST /v1/gif-catalog/delete", s.authenticated(s.handleDeleteGifCatalogEntry))
|
||||||
mux.HandleFunc("GET /v1/stickers/documents/{id}/animation", s.authenticated(s.handleStickerDocumentAnimation))
|
mux.HandleFunc("GET /v1/stickers/documents/{id}/animation", s.authenticated(s.handleStickerDocumentAnimation))
|
||||||
mux.HandleFunc("GET /v1/gif-catalog/documents/{id}/preview", s.authenticated(s.handleGifCatalogDocumentPreview))
|
mux.HandleFunc("GET /v1/gif-catalog/documents/{id}/preview", s.authenticated(s.handleGifCatalogDocumentPreview))
|
||||||
|
|
@ -755,6 +759,24 @@ func (s *Server) handleSetGifCatalogSortOrder(w http.ResponseWriter, r *http.Req
|
||||||
writeCommandResult(w, result, err)
|
writeCommandResult(w, result, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleSetGifCatalogCategory(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req admin.SetGifCatalogCategoryRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := s.svc.SetGifCatalogCategory(r.Context(), req)
|
||||||
|
writeCommandResult(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Server) handleAutoCategorizeGifCatalog(w http.ResponseWriter, r *http.Request) {
|
||||||
|
var req admin.AutoCategorizeGifCatalogRequest
|
||||||
|
if !decodeJSON(w, r, &req) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := s.svc.AutoCategorizeGifCatalog(r.Context(), req)
|
||||||
|
writeCommandResult(w, result, err)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Server) handleDeleteGifCatalogEntry(w http.ResponseWriter, r *http.Request) {
|
func (s *Server) handleDeleteGifCatalogEntry(w http.ResponseWriter, r *http.Request) {
|
||||||
var req admin.DeleteGifCatalogEntryRequest
|
var req admin.DeleteGifCatalogEntryRequest
|
||||||
if !decodeJSON(w, r, &req) {
|
if !decodeJSON(w, r, &req) {
|
||||||
|
|
|
||||||
|
|
@ -460,6 +460,14 @@ func (fakeService) SetGifCatalogSortOrder(_ context.Context, req admin.SetGifCat
|
||||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (fakeService) SetGifCatalogCategory(_ context.Context, req admin.SetGifCatalogCategoryRequest) (admin.CommandResult, error) {
|
||||||
|
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (fakeService) AutoCategorizeGifCatalog(_ context.Context, req admin.AutoCategorizeGifCatalogRequest) (admin.CommandResult, error) {
|
||||||
|
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (fakeService) DeleteGifCatalogEntry(_ context.Context, req admin.DeleteGifCatalogEntryRequest) (admin.CommandResult, error) {
|
func (fakeService) DeleteGifCatalogEntry(_ context.Context, req admin.DeleteGifCatalogEntryRequest) (admin.CommandResult, error) {
|
||||||
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
return admin.CommandResult{CommandID: req.CommandID, Status: "completed", DryRun: req.DryRun}, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"telesrv/internal/domain"
|
"telesrv/internal/domain"
|
||||||
|
"telesrv/internal/seed/catalog"
|
||||||
)
|
)
|
||||||
|
|
||||||
// HandlesInlineBot reports whether botUserID is a built-in bot this service
|
// HandlesInlineBot reports whether botUserID is a built-in bot this service
|
||||||
|
|
@ -39,7 +40,15 @@ func (s *Service) OnInlineQuery(ctx context.Context, botUserID, _ int64, query,
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.BotInlineResults{}, false, err
|
return domain.BotInlineResults{}, false, err
|
||||||
}
|
}
|
||||||
|
if category := gifCategoryFromQuery(query); category != "" {
|
||||||
|
// A category-icon tap, not a typed word (see gifCategoryFromQuery) --
|
||||||
|
// filter by domain.GifCatalogEntry.Category instead of ranking by
|
||||||
|
// title, since the query is an emoji/emoji-blob a title never
|
||||||
|
// contains.
|
||||||
|
entries = filterGifCatalogEntriesByCategory(entries, category)
|
||||||
|
} else {
|
||||||
entries = rankGifCatalogEntries(entries, query)
|
entries = rankGifCatalogEntries(entries, query)
|
||||||
|
}
|
||||||
if len(entries) == 0 {
|
if len(entries) == 0 {
|
||||||
return domain.BotInlineResults{Gallery: true}, true, nil
|
return domain.BotInlineResults{Gallery: true}, true, nil
|
||||||
}
|
}
|
||||||
|
|
@ -84,6 +93,66 @@ func (s *Service) OnInlineQuery(ctx context.Context, botUserID, _ int64, query,
|
||||||
// with the closest titles first keeps every query useful while still honouring
|
// with the closest titles first keeps every query useful while still honouring
|
||||||
// the search term. Order within each group stays the admin-set
|
// the search term. Order within each group stays the admin-set
|
||||||
// (sort_order, id) order the store already applied.
|
// (sort_order, id) order the store already applied.
|
||||||
|
// gifCategoryFromQuery recognizes a GIF-picker category-icon tap and returns
|
||||||
|
// which domain.GifCatalogCategories entry it names, or "" for an ordinary
|
||||||
|
// typed search.
|
||||||
|
//
|
||||||
|
// The two clients encode a tap completely differently, and neither sends the
|
||||||
|
// category's name:
|
||||||
|
// - Android (StickerCategoriesListView.EmojiCategory.remote, EmojiView.java)
|
||||||
|
// concatenates the whole tapped group's emoticons with no separator and
|
||||||
|
// sends that as the query -- an exact match against
|
||||||
|
// strings.Join(group.Emoticons, "").
|
||||||
|
// - TDesktop (GifSectionsValue, stickers_list_footer.cpp) reads a *separate*
|
||||||
|
// fixed emoji list (app config's gif_search_emojies, defaulting to 10
|
||||||
|
// emoji including some this server never configured) and sends the
|
||||||
|
// single tapped emoji as the query -- an exact match against one
|
||||||
|
// Emoticons entry.
|
||||||
|
//
|
||||||
|
// Both are checked against the same internal/seed/catalog data (the source of
|
||||||
|
// truth messages.getEmojiGroups itself serves), so no client-side changes or
|
||||||
|
// server-side emoji-list duplication are needed.
|
||||||
|
func gifCategoryFromQuery(query string) string {
|
||||||
|
query = strings.TrimSpace(query)
|
||||||
|
if query == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
groups, _ := catalog.EmojiGroups()
|
||||||
|
for _, g := range groups {
|
||||||
|
if len(g.Emoticons) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Join(g.Emoticons, "") == query {
|
||||||
|
return g.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, g := range groups {
|
||||||
|
for _, e := range g.Emoticons {
|
||||||
|
if e == query {
|
||||||
|
return g.Title
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// filterGifCatalogEntriesByCategory keeps only entries tagged with category,
|
||||||
|
// falling back to the full (enabled) catalog if none are tagged yet -- an
|
||||||
|
// operator who hasn't categorized anything should still see every GIF on a
|
||||||
|
// category tap, not an empty picker.
|
||||||
|
func filterGifCatalogEntriesByCategory(entries []domain.GifCatalogEntry, category string) []domain.GifCatalogEntry {
|
||||||
|
filtered := make([]domain.GifCatalogEntry, 0, len(entries))
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Category == category {
|
||||||
|
filtered = append(filtered, e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(filtered) == 0 {
|
||||||
|
return entries
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
func rankGifCatalogEntries(entries []domain.GifCatalogEntry, query string) []domain.GifCatalogEntry {
|
func rankGifCatalogEntries(entries []domain.GifCatalogEntry, query string) []domain.GifCatalogEntry {
|
||||||
query = strings.TrimSpace(strings.ToLower(query))
|
query = strings.TrimSpace(strings.ToLower(query))
|
||||||
if query == "" {
|
if query == "" {
|
||||||
|
|
|
||||||
|
|
@ -190,6 +190,59 @@ func (s *Service) AdminSetGifCatalogSortOrder(ctx context.Context, id int64, ord
|
||||||
return true, nil
|
return true, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AdminSetGifCatalogCategory sets (or clears, via "") an entry's category.
|
||||||
|
func (s *Service) AdminSetGifCatalogCategory(ctx context.Context, id int64, category string) (bool, error) {
|
||||||
|
if s.gifCatalog == nil {
|
||||||
|
return false, domain.ErrGifCatalogUnavailable
|
||||||
|
}
|
||||||
|
if !domain.ValidGifCatalogCategory(category) {
|
||||||
|
return false, domain.ErrGifCatalogEntryInvalid
|
||||||
|
}
|
||||||
|
changed, err := s.gifCatalog.SetGifCatalogCategory(ctx, id, category)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
if !changed {
|
||||||
|
return false, domain.ErrGifCatalogEntryNotFound
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// AdminAutoCategorizeGifCatalog runs ClassifyGifCategory against every
|
||||||
|
// currently-uncategorized entry's title and assigns whatever category it
|
||||||
|
// guesses (category stays "" -- i.e. the entry is left for manual tagging --
|
||||||
|
// when nothing matches). Already-categorized entries are left untouched, so
|
||||||
|
// this is safe to re-run after every new batch of GIFs lands (seeded or
|
||||||
|
// admin-uploaded) without clobbering an operator's manual corrections.
|
||||||
|
// Returns how many entries got a category assigned.
|
||||||
|
func (s *Service) AdminAutoCategorizeGifCatalog(ctx context.Context) (int, error) {
|
||||||
|
if s.gifCatalog == nil {
|
||||||
|
return 0, domain.ErrGifCatalogUnavailable
|
||||||
|
}
|
||||||
|
entries, err := s.gifCatalog.ListGifCatalog(ctx, false)
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
changed := 0
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Category != "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
category := ClassifyGifCategory(e.Title)
|
||||||
|
if category == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ok, err := s.gifCatalog.SetGifCatalogCategory(ctx, e.ID, category)
|
||||||
|
if err != nil {
|
||||||
|
return changed, err
|
||||||
|
}
|
||||||
|
if ok {
|
||||||
|
changed++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed, nil
|
||||||
|
}
|
||||||
|
|
||||||
// AdminDeleteGifCatalogEntry removes an entry from the catalog. The
|
// AdminDeleteGifCatalogEntry removes an entry from the catalog. The
|
||||||
// referenced document is left alone.
|
// referenced document is left alone.
|
||||||
func (s *Service) AdminDeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error) {
|
func (s *Service) AdminDeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error) {
|
||||||
|
|
|
||||||
85
internal/app/files/gif_classify.go
Normal file
85
internal/app/files/gif_classify.go
Normal file
|
|
@ -0,0 +1,85 @@
|
||||||
|
package files
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// gifCategoryKeywords maps each domain.GifCatalogCategories entry to a list
|
||||||
|
// of lowercase words/phrases whose presence in a GIF title is decent
|
||||||
|
// (imperfect, but reversible -- an operator can always correct it in the
|
||||||
|
// admin panel) evidence the GIF belongs to that emotional category. Checked
|
||||||
|
// in this order, first match wins, so more specific/less ambiguous
|
||||||
|
// categories are listed before catch-alls like Neutral/Silly.
|
||||||
|
var gifCategoryKeywords = map[string][]string{
|
||||||
|
"Love": {
|
||||||
|
"love", "heart", "kiss", "kissing", "crush", "romance", "romantic",
|
||||||
|
"valentine", "adorable", "hug", "hugging", "xoxo", "sweetheart",
|
||||||
|
},
|
||||||
|
"Cheers": {
|
||||||
|
"party", "celebrate", "celebration", "celebrating", "woohoo", "cheers",
|
||||||
|
"congrats", "congratulations", "dance", "dancing", "yay", "hooray",
|
||||||
|
"victory", "champagne", "toast", "birthday", "win", "winning",
|
||||||
|
},
|
||||||
|
"Laughter": {
|
||||||
|
"laugh", "laughing", "lol", "lmao", "haha", "hilarious", "funny",
|
||||||
|
"joke", "giggle", "chuckle", "rofl", "lulz",
|
||||||
|
},
|
||||||
|
"Astonishment": {
|
||||||
|
"shock", "shocked", "shocking", "omg", "wow", "surprised", "surprise",
|
||||||
|
"gasp", "whoa", "unbelievable", "stunned", "speechless", "mindblown",
|
||||||
|
"mind blown", "jawdrop",
|
||||||
|
},
|
||||||
|
"Sadness": {
|
||||||
|
"sad", "sadness", "cry", "crying", "cries", "tears", "depressed",
|
||||||
|
"heartbroken", "sorrow", "sob", "sobbing", "disappointed", "unhappy",
|
||||||
|
},
|
||||||
|
"Anger": {
|
||||||
|
"angry", "anger", "mad", "rage", "furious", "pissed", "annoyed",
|
||||||
|
"irritated", "punch", "smash", "yell", "yelling", "scream", "fist",
|
||||||
|
},
|
||||||
|
"Disapproval": {
|
||||||
|
"disgusting", "disgusted", "gross", "eww", "yuck", "ugh", "cringe",
|
||||||
|
"boo", "nope", "fail",
|
||||||
|
},
|
||||||
|
"Approval": {
|
||||||
|
"agree", "thumbsup", "thumbs up", "nailed it", "well done",
|
||||||
|
"applause", "clap", "clapping", "bravo", "approved", "salute",
|
||||||
|
"respect", "nice one",
|
||||||
|
},
|
||||||
|
"Doubt": {
|
||||||
|
"confused", "confusion", "suspicious", "skeptical", "hmm", "hmmm",
|
||||||
|
"thinking", "uncertain", "unsure", "really",
|
||||||
|
},
|
||||||
|
"Silly": {
|
||||||
|
"silly", "goofy", "derp", "wacky", "ridiculous", "nonsense",
|
||||||
|
},
|
||||||
|
"Neutral": {
|
||||||
|
"meh", "whatever", "shrug", "indifferent", "blank stare", "boring",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClassifyGifCategory guesses one of domain.GifCatalogCategories from a GIF's
|
||||||
|
// title via keyword matching, or "" if nothing matches. See
|
||||||
|
// gifCategoryKeywords for the word lists and Service.AdminAutoCategorizeGifCatalog
|
||||||
|
// for how this gets applied in bulk.
|
||||||
|
//
|
||||||
|
// This is deliberately simple substring matching, not NLP: real GIF titles
|
||||||
|
// range from descriptive ("Sad Tenant") to meaningless filename fragments
|
||||||
|
// ("jIDL92q") that no text-based heuristic can classify -- those are left
|
||||||
|
// uncategorized for an operator to tag by hand rather than guessed wrong.
|
||||||
|
func ClassifyGifCategory(title string) string {
|
||||||
|
lower := strings.ToLower(title)
|
||||||
|
for _, category := range gifCategoryOrder {
|
||||||
|
for _, kw := range gifCategoryKeywords[category] {
|
||||||
|
if strings.Contains(lower, kw) {
|
||||||
|
return category
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// gifCategoryOrder is gifCategoryKeywords' check order (Go map iteration is
|
||||||
|
// unordered, and the order is meaningful -- see gifCategoryKeywords' doc).
|
||||||
|
var gifCategoryOrder = []string{
|
||||||
|
"Love", "Cheers", "Laughter", "Astonishment", "Sadness", "Anger",
|
||||||
|
"Disapproval", "Approval", "Doubt", "Silly", "Neutral",
|
||||||
|
}
|
||||||
|
|
@ -42,6 +42,29 @@ const (
|
||||||
MaxGifCatalogUploadSize = 50 << 20
|
MaxGifCatalogUploadSize = 50 << 20
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// GifCatalogCategories are the valid values for GifCatalogEntry.Category --
|
||||||
|
// exactly the titles internal/seed/catalog/emoji_groups.json uses, so a
|
||||||
|
// category tap in the client's GIF picker (see files.ClassifyGifCategory and
|
||||||
|
// bots.rankGifCatalogEntries) maps onto them with no translation step.
|
||||||
|
var GifCatalogCategories = []string{
|
||||||
|
"Love", "Approval", "Disapproval", "Cheers", "Laughter",
|
||||||
|
"Astonishment", "Sadness", "Anger", "Neutral", "Doubt", "Silly",
|
||||||
|
}
|
||||||
|
|
||||||
|
// ValidGifCatalogCategory reports whether category is "" (uncategorized) or
|
||||||
|
// one of GifCatalogCategories.
|
||||||
|
func ValidGifCatalogCategory(category string) bool {
|
||||||
|
if category == "" {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, c := range GifCatalogCategories {
|
||||||
|
if c == category {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// GifCatalogEntry is one admin-curated GIF served by the built-in @gif inline
|
// GifCatalogEntry is one admin-curated GIF served by the built-in @gif inline
|
||||||
// bot (see rpc.ServiceBotInlineResults) for the client's GIF picker
|
// bot (see rpc.ServiceBotInlineResults) for the client's GIF picker
|
||||||
// trending/search panel. DocumentID references an already-uploaded document
|
// trending/search panel. DocumentID references an already-uploaded document
|
||||||
|
|
@ -54,6 +77,9 @@ type GifCatalogEntry struct {
|
||||||
Enabled bool
|
Enabled bool
|
||||||
SortOrder int
|
SortOrder int
|
||||||
CreatedBy string
|
CreatedBy string
|
||||||
|
// Category is one of GifCatalogCategories, or "" if uncategorized. Set
|
||||||
|
// manually via the admin panel or in bulk via files.ClassifyGifCategory.
|
||||||
|
Category string
|
||||||
// SourceFilename is set only for entries files.Service.SeedGifs imported
|
// SourceFilename is set only for entries files.Service.SeedGifs imported
|
||||||
// from the data/gifs/ drop directory -- empty for anything created through
|
// from the data/gifs/ drop directory -- empty for anything created through
|
||||||
// the admin panel. It exists purely so a restart can tell "this file was
|
// the admin panel. It exists purely so a restart can tell "this file was
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,9 @@ type GifCatalogStore interface {
|
||||||
SetGifCatalogEnabled(ctx context.Context, id int64, enabled bool) (bool, error)
|
SetGifCatalogEnabled(ctx context.Context, id int64, enabled bool) (bool, error)
|
||||||
// SetGifCatalogSortOrder rewrites an entry's display position.
|
// SetGifCatalogSortOrder rewrites an entry's display position.
|
||||||
SetGifCatalogSortOrder(ctx context.Context, id int64, order int) (bool, error)
|
SetGifCatalogSortOrder(ctx context.Context, id int64, order int) (bool, error)
|
||||||
|
// SetGifCatalogCategory sets (or clears, via "") an entry's category.
|
||||||
|
// changed=false if the id doesn't exist.
|
||||||
|
SetGifCatalogCategory(ctx context.Context, id int64, category string) (bool, error)
|
||||||
// DeleteGifCatalogEntry removes an entry. The referenced document is left
|
// DeleteGifCatalogEntry removes an entry. The referenced document is left
|
||||||
// alone -- catalog membership, not the document itself, is what's deleted.
|
// alone -- catalog membership, not the document itself, is what's deleted.
|
||||||
DeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error)
|
DeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error)
|
||||||
|
|
|
||||||
|
|
@ -21,10 +21,10 @@ func (s *GifCatalogStore) CreateGifCatalogEntry(ctx context.Context, entry domai
|
||||||
return domain.GifCatalogEntry{}, fmt.Errorf("create gif catalog entry: id and document_id are required")
|
return domain.GifCatalogEntry{}, fmt.Errorf("create gif catalog entry: id and document_id are required")
|
||||||
}
|
}
|
||||||
row := s.db.QueryRow(ctx, `
|
row := s.db.QueryRow(ctx, `
|
||||||
INSERT INTO gif_catalog (id, title, document_id, enabled, sort_order, created_by, source_filename)
|
INSERT INTO gif_catalog (id, title, document_id, enabled, sort_order, created_by, source_filename, category)
|
||||||
VALUES ($1, $2, $3, true, $4, $5, $6)
|
VALUES ($1, $2, $3, true, $4, $5, $6, $7)
|
||||||
RETURNING id, title, document_id, enabled, sort_order, created_by, created_at, updated_at, source_filename`,
|
RETURNING id, title, document_id, enabled, sort_order, created_by, created_at, updated_at, source_filename, category`,
|
||||||
entry.ID, entry.Title, entry.DocumentID, entry.SortOrder, entry.CreatedBy, entry.SourceFilename)
|
entry.ID, entry.Title, entry.DocumentID, entry.SortOrder, entry.CreatedBy, entry.SourceFilename, entry.Category)
|
||||||
out, err := scanGifCatalogEntry(row.Scan)
|
out, err := scanGifCatalogEntry(row.Scan)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return domain.GifCatalogEntry{}, fmt.Errorf("create gif catalog entry: %w", err)
|
return domain.GifCatalogEntry{}, fmt.Errorf("create gif catalog entry: %w", err)
|
||||||
|
|
@ -50,7 +50,7 @@ func (s *GifCatalogStore) HasGifCatalogSourceFilename(ctx context.Context, filen
|
||||||
|
|
||||||
func (s *GifCatalogStore) ListGifCatalog(ctx context.Context, onlyEnabled bool) ([]domain.GifCatalogEntry, error) {
|
func (s *GifCatalogStore) ListGifCatalog(ctx context.Context, onlyEnabled bool) ([]domain.GifCatalogEntry, error) {
|
||||||
rows, err := s.db.Query(ctx, `
|
rows, err := s.db.Query(ctx, `
|
||||||
SELECT id, title, document_id, enabled, sort_order, created_by, created_at, updated_at, source_filename
|
SELECT id, title, document_id, enabled, sort_order, created_by, created_at, updated_at, source_filename, category
|
||||||
FROM gif_catalog
|
FROM gif_catalog
|
||||||
WHERE NOT $1 OR enabled
|
WHERE NOT $1 OR enabled
|
||||||
ORDER BY sort_order, id
|
ORDER BY sort_order, id
|
||||||
|
|
@ -86,6 +86,14 @@ func (s *GifCatalogStore) SetGifCatalogSortOrder(ctx context.Context, id int64,
|
||||||
return tag.RowsAffected() > 0, nil
|
return tag.RowsAffected() > 0, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *GifCatalogStore) SetGifCatalogCategory(ctx context.Context, id int64, category string) (bool, error) {
|
||||||
|
tag, err := s.db.Exec(ctx, `UPDATE gif_catalog SET category = $2, updated_at = now() WHERE id = $1`, id, category)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("set gif catalog entry category: %w", err)
|
||||||
|
}
|
||||||
|
return tag.RowsAffected() > 0, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *GifCatalogStore) DeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error) {
|
func (s *GifCatalogStore) DeleteGifCatalogEntry(ctx context.Context, id int64) (bool, error) {
|
||||||
tag, err := s.db.Exec(ctx, `DELETE FROM gif_catalog WHERE id = $1`, id)
|
tag, err := s.db.Exec(ctx, `DELETE FROM gif_catalog WHERE id = $1`, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -96,7 +104,7 @@ func (s *GifCatalogStore) DeleteGifCatalogEntry(ctx context.Context, id int64) (
|
||||||
|
|
||||||
func scanGifCatalogEntry(scan func(dest ...any) error) (domain.GifCatalogEntry, error) {
|
func scanGifCatalogEntry(scan func(dest ...any) error) (domain.GifCatalogEntry, error) {
|
||||||
var e domain.GifCatalogEntry
|
var e domain.GifCatalogEntry
|
||||||
if err := scan(&e.ID, &e.Title, &e.DocumentID, &e.Enabled, &e.SortOrder, &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt, &e.SourceFilename); err != nil {
|
if err := scan(&e.ID, &e.Title, &e.DocumentID, &e.Enabled, &e.SortOrder, &e.CreatedBy, &e.CreatedAt, &e.UpdatedAt, &e.SourceFilename, &e.Category); err != nil {
|
||||||
return domain.GifCatalogEntry{}, err
|
return domain.GifCatalogEntry{}, err
|
||||||
}
|
}
|
||||||
return e, nil
|
return e, nil
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue