diff --git a/internal/api/issue235_type_test.go b/internal/api/issue235_type_test.go new file mode 100644 index 0000000..2164fd8 --- /dev/null +++ b/internal/api/issue235_type_test.go @@ -0,0 +1,40 @@ +package api + +import "testing" + +// #235: Type column. Text pastes show the language, attachment pastes show +// the file extension (lowercase, no dot). +func TestDisplayType(t *testing.T) { + lang := "python" + cases := []struct { + lang *string + att string + want string + }{ + {nil, "", "text"}, + {&lang, "", "python"}, + {&lang, "report.pdf", "pdf"}, + {nil, "photo.PNG", "png"}, + {&lang, "archive.tar.gz", "gz"}, + {&lang, "noext", "python"}, // no extension: fall back to language + {&lang, ".hidden", "python"}, // dotfile: no extension + {&lang, "dir/name.txt", "txt"}, // path component only + } + for _, c := range cases { + if got := displayType(c.lang, c.att); got != c.want { + t.Errorf("displayType(%v, %q) = %q, want %q", c.lang, c.att, got, c.want) + } + } +} + +func TestAttachmentExtName(t *testing.T) { + cases := map[string]string{ + "a.txt": "txt", "A.PNG": "png", "noext": "", ".hidden": "", + "x.": "", "dir/b.md": "md", "": "", + } + for in, want := range cases { + if got := attachmentExtName(in); got != want { + t.Errorf("attachmentExtName(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/internal/api/server.go b/internal/api/server.go index 5a9128f..aae5c96 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -315,11 +315,26 @@ func (a *apiServer) handleGetPaste(w http.ResponseWriter, r *http.Request) { writeJSON(w, 200, map[string]any{ "id": row.ID, "content": row.Content, "content_type": row.ContentType, "language": store.NullStrPtr(row.Language), "title": store.NullStrPtr(row.Title), "created_at": row.CreatedAt, + "type": a.pasteTypeLabel(row), "view_count": row.ViewCount, "visibility": row.Visibility, "reads_remaining": rem, }) } +// pasteTypeLabel computes the Type value for a single paste (#235): file +// extension for attachment pastes, else the stored language, else "text". +func (a *apiServer) pasteTypeLabel(row *store.PasteRow) string { + if att, err := a.store.GetAttachmentForPaste(row.ID); err == nil && att != nil { + if ext := attachmentExtName(att.Filename); ext != "" { + return ext + } + } + if !row.Language.Valid || row.Language.String == "" { + return "text" + } + return row.Language.String +} + func (a *apiServer) handleDeletePaste(w http.ResponseWriter, r *http.Request) { id := chi.URLParam(r, "id") row, err := a.store.GetPaste(id) @@ -376,6 +391,33 @@ func (a *apiServer) deletionAuthorized(r *http.Request, row *store.PasteRow) boo row.ViewerID.String != "" && row.ViewerID.String == vid } +// displayType returns the Type-column value for a list row (#235): the file +// extension for attachment pastes, otherwise the detected language (default +// "text"). +func displayType(lang *string, attFilename string) string { + if ext := attachmentExtName(attFilename); ext != "" { + return ext + } + if lang == nil || *lang == "" { + return "text" + } + return *lang +} + +// attachmentExtName returns the lowercase extension (without dot) of a +// filename, or "" when there is none. +func attachmentExtName(filename string) string { + name := filename + if i := strings.LastIndexByte(name, '/'); i >= 0 { + name = name[i+1:] + } + i := strings.LastIndexByte(name, '.') + if i <= 0 || i == len(name)-1 { + return "" + } + return strings.ToLower(name[i+1:]) +} + // handleListMine serves /api/mine: pastes created from this browser (#37). func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) { vid := currentViewerID(r) @@ -394,7 +436,7 @@ func (a *apiServer) handleListMine(w http.ResponseWriter, r *http.Request) { for _, row := range rows { lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title) items = append(items, map[string]any{ - "id": row.ID, "title": title, "language": lang, + "id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename), "created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size, "custom_slug": store.NullStrPtr(row.CustomSlug), "visibility": row.Visibility, "is_can": row.IsCan, @@ -415,7 +457,7 @@ func (a *apiServer) handleListPublic(w http.ResponseWriter, r *http.Request) { for _, row := range rows { lang, title := store.NullStrPtr(row.Language), store.NullStrPtr(row.Title) items = append(items, map[string]any{ - "id": row.ID, "title": title, "language": lang, + "id": row.ID, "title": title, "language": lang, "type": displayType(lang, row.AttFilename), "created_at": row.CreatedAt, "view_count": row.ViewCount, "size": row.Size, "custom_slug": store.NullStrPtr(row.CustomSlug), "is_can": row.IsCan, diff --git a/internal/store/store.go b/internal/store/store.go index a7c491c..2025152 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -63,6 +63,7 @@ type PasteRow struct { DeletionToken sql.NullString ViewerID sql.NullString IsCan bool // set on list rows that are cans (#4) + AttFilename string // attachment filename when the paste is a file paste (#235); empty otherwise } type CanRow struct { @@ -304,11 +305,12 @@ func (s *Store) GetPaste(idOrSlug string) (*PasteRow, error) { // listed, and password-protected pastes are excluded at the query level // (#65) so their metadata (title, slug, existence) never leaks. func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) { - rows, err := s.db.Query(`SELECT id, custom_slug, content_type, language, title, visibility, created_at, view_count, LENGTH(content), 0 - FROM pastes - WHERE visibility='public' AND deleted_at IS NULL AND can_id IS NULL AND password_hash IS NULL AND (expires_at IS NULL OR expires_at > ?) + rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.content_type, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), 0, COALESCE(a.filename, '') + FROM pastes p + LEFT JOIN attachments a ON a.paste_id = p.id + WHERE p.visibility='public' AND p.deleted_at IS NULL AND p.can_id IS NULL AND p.password_hash IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?) UNION ALL - SELECT id, NULL, 'text/plain', NULL, title, visibility, created_at, 0, 0, 1 + SELECT id, NULL, 'text/plain', NULL, title, visibility, created_at, 0, 0, 1, '' FROM paste_cans WHERE visibility='public' AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at DESC LIMIT ? OFFSET ?`, time.Now().Unix(), time.Now().Unix(), limit, offset) @@ -321,7 +323,7 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) { var r PasteRow var cs, lang, title sql.NullString var isCan int - if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan); err != nil { + if err := rows.Scan(&r.ID, &cs, &r.ContentType, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil { return nil, 0, err } r.CustomSlug = cs @@ -339,11 +341,12 @@ func (s *Store) ListPublic(limit, offset int) ([]PasteRow, int, error) { // ListMine lists pastes created from the given viewer id (browser cookie), newest first. func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, error) { - rows, err := s.db.Query(`SELECT id, custom_slug, language, title, visibility, created_at, view_count, LENGTH(content), 0 - FROM pastes - WHERE viewer_id = ? AND deleted_at IS NULL AND can_id IS NULL AND (expires_at IS NULL OR expires_at > ?) + rows, err := s.db.Query(`SELECT p.id, p.custom_slug, p.language, p.title, p.visibility, p.created_at, p.view_count, LENGTH(p.content), 0, COALESCE(a.filename, '') + FROM pastes p + LEFT JOIN attachments a ON a.paste_id = p.id + WHERE p.viewer_id = ? AND p.deleted_at IS NULL AND p.can_id IS NULL AND (p.expires_at IS NULL OR p.expires_at > ?) UNION ALL - SELECT id, NULL, NULL, title, visibility, created_at, 0, 0, 1 + SELECT id, NULL, NULL, title, visibility, created_at, 0, 0, 1, '' FROM paste_cans WHERE viewer_id = ? AND deleted_at IS NULL AND (expires_at IS NULL OR expires_at > ?) ORDER BY created_at DESC LIMIT ? OFFSET ?`, viewerID, time.Now().Unix(), viewerID, time.Now().Unix(), limit, offset) @@ -356,7 +359,7 @@ func (s *Store) ListMine(viewerID string, limit, offset int) ([]PasteRow, int, e var r PasteRow var cs, lang, title sql.NullString var isCan int - if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan); err != nil { + if err := rows.Scan(&r.ID, &cs, &lang, &title, &r.Visibility, &r.CreatedAt, &r.ViewCount, &r.Size, &isCan, &r.AttFilename); err != nil { return nil, 0, err } r.CustomSlug, r.Language, r.Title = cs, lang, title diff --git a/internal/web/static/history.js b/internal/web/static/history.js index 4e6b719..ad0ba86 100644 --- a/internal/web/static/history.js +++ b/internal/web/static/history.js @@ -9,7 +9,7 @@ const t = PaletteTable.init({ ? `${t.esc(it.title)}${it.is_can ? ' can' : ''}` : `${t.esc(it.id)}${it.is_can ? ' can' : ''}`) + `` + - `${t.esc(it.language || 'text')}` + + `${t.esc(it.type || it.language || 'text')}` + `${t.fmtSize(it.size)}${it.view_count}${t.ago(it.created_at)}` + (it.custom_slug ? `/${t.esc(it.custom_slug)}` : `none`) + `${t.esc(it.id)}`, diff --git a/internal/web/static/mine.js b/internal/web/static/mine.js index 9967d4b..e074afc 100644 --- a/internal/web/static/mine.js +++ b/internal/web/static/mine.js @@ -21,7 +21,7 @@ const t = PaletteTable.init({ ? `${t.esc(it.title)}${it.is_can ? ' can' : ''}` : `${t.esc(it.id)}${it.is_can ? ' can' : ''}`) + `` + - `${t.esc(it.language || 'text')}` + + `${t.esc(it.type || it.language || 'text')}` + `${t.fmtSize(it.size)}${t.ago(it.created_at)}` + (it.custom_slug ? `/${t.esc(it.custom_slug)}` : `none`) + `${t.esc(it.id)}` + diff --git a/internal/web/static/table.js b/internal/web/static/table.js index ed8c8f6..89793c0 100644 --- a/internal/web/static/table.js +++ b/internal/web/static/table.js @@ -16,7 +16,7 @@ const PaletteTable = (() => { const sortVal = (it, k) => { let v = it[k]; if (k === 'title' || k === 'custom_slug') v = (v == null || v === '') ? null : String(v).toLowerCase(); - if (k === 'language') v = (v == null || v === '') ? 'text' : String(v).toLowerCase(); + if (k === 'language' || k === 'type') v = (v == null || v === '') ? 'text' : String(v).toLowerCase(); if (k === 'size' || k === 'view_count' || k === 'created_at') return v == null ? -1 : v; return v == null ? null : v; }; diff --git a/internal/web/templates/history.html b/internal/web/templates/history.html index c4bf56f..88359c7 100644 --- a/internal/web/templates/history.html +++ b/internal/web/templates/history.html @@ -11,7 +11,7 @@ Paste - Language + Type Size Views Created diff --git a/internal/web/templates/mine.html b/internal/web/templates/mine.html index c7922cb..5ef7d9e 100644 --- a/internal/web/templates/mine.html +++ b/internal/web/templates/mine.html @@ -11,7 +11,7 @@ Paste - Language + Type Size Created URL diff --git a/internal/web/templates/paste.html b/internal/web/templates/paste.html index 320ca10..a984a06 100644 --- a/internal/web/templates/paste.html +++ b/internal/web/templates/paste.html @@ -20,7 +20,7 @@