Merge pull request 'fix: batch tag queries, inline edit, delete response, SPA catch-all, link glyph' (#1) from feat/review-fixes into main
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
@@ -26,6 +26,7 @@ func Execute() error {
|
|||||||
isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ")
|
isFlag := strings.HasPrefix(first, "-") && !strings.Contains(first, " ")
|
||||||
if first != "help" && first != "completion" &&
|
if first != "help" && first != "completion" &&
|
||||||
!isFlag && !isSubcommand(first) {
|
!isFlag && !isSubcommand(first) {
|
||||||
|
// "--" stops cobra from parsing glyph prefixes like "-" as flags
|
||||||
rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...))
|
rootCmd.SetArgs(append([]string{"add", "--"}, os.Args[1:]...))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -235,16 +235,25 @@ func TestDeleteEntity_SoftThenHard(t *testing.T) {
|
|||||||
// Soft delete
|
// Soft delete
|
||||||
req, _ := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
|
req, _ := http.NewRequest("DELETE", srv.URL+"/api/entities/"+created.ID, nil)
|
||||||
resp, _ := http.DefaultClient.Do(req)
|
resp, _ := http.DefaultClient.Do(req)
|
||||||
|
var delResp DeleteResponse
|
||||||
|
json.NewDecoder(resp.Body).Decode(&delResp)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusNoContent {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("soft delete: expected 204, got %d", resp.StatusCode)
|
t.Fatalf("soft delete: expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if delResp.Result != "soft" {
|
||||||
|
t.Fatalf("soft delete: expected result 'soft', got %q", delResp.Result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hard delete
|
// Hard delete
|
||||||
resp, _ = http.DefaultClient.Do(req)
|
resp, _ = http.DefaultClient.Do(req)
|
||||||
|
json.NewDecoder(resp.Body).Decode(&delResp)
|
||||||
resp.Body.Close()
|
resp.Body.Close()
|
||||||
if resp.StatusCode != http.StatusNoContent {
|
if resp.StatusCode != http.StatusOK {
|
||||||
t.Fatalf("hard delete: expected 204, got %d", resp.StatusCode)
|
t.Fatalf("hard delete: expected 200, got %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
if delResp.Result != "hard" {
|
||||||
|
t.Fatalf("hard delete: expected result 'hard', got %q", delResp.Result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Gone
|
// Gone
|
||||||
|
|||||||
@@ -214,10 +214,14 @@ func updateEntity(store *db.Store) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type DeleteResponse struct {
|
||||||
|
Result string `json:"result"`
|
||||||
|
}
|
||||||
|
|
||||||
func deleteEntity(store *db.Store) http.HandlerFunc {
|
func deleteEntity(store *db.Store) http.HandlerFunc {
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
id := chi.URLParam(r, "id")
|
id := chi.URLParam(r, "id")
|
||||||
_, err := store.SoftDelete(id)
|
result, err := store.SoftDelete(id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if err == db.ErrNotFound {
|
if err == db.ErrNotFound {
|
||||||
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
writeError(w, http.StatusNotFound, "not_found", "no entity with id "+id)
|
||||||
@@ -226,7 +230,11 @@ func deleteEntity(store *db.Store) http.HandlerFunc {
|
|||||||
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
writeError(w, http.StatusInternalServerError, "internal", err.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.WriteHeader(http.StatusNoContent)
|
label := "soft"
|
||||||
|
if result == db.DeletedHard {
|
||||||
|
label = "hard"
|
||||||
|
}
|
||||||
|
writeJSON(w, http.StatusOK, DeleteResponse{Result: label})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package api
|
|||||||
import (
|
import (
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"path"
|
||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
@@ -45,8 +46,8 @@ func spaHandler(fsys fs.FS) http.HandlerFunc {
|
|||||||
indexHTML, _ := fs.ReadFile(fsys, "index.html")
|
indexHTML, _ := fs.ReadFile(fsys, "index.html")
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
path := r.URL.Path
|
p := r.URL.Path
|
||||||
if path == "/" || path == "/cards" {
|
if p == "/" || path.Ext(p) == "" {
|
||||||
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
w.Header().Set("Content-Type", "text/html; charset=utf-8")
|
||||||
w.Write(indexHTML)
|
w.Write(indexHTML)
|
||||||
return
|
return
|
||||||
|
|||||||
+39
-5
@@ -269,13 +269,9 @@ func (s *Store) List(params ListParams) ([]*Entity, error) {
|
|||||||
entities = append(entities, e)
|
entities = append(entities, e)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, e := range entities {
|
if err := s.batchLoadTags(entities); err != nil {
|
||||||
tags, err := s.loadTags(e.ID)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
e.Tags = tags
|
|
||||||
}
|
|
||||||
|
|
||||||
return entities, nil
|
return entities, nil
|
||||||
}
|
}
|
||||||
@@ -452,6 +448,44 @@ func (s *Store) Resolve(prefix string) (string, error) {
|
|||||||
|
|
||||||
// helpers
|
// helpers
|
||||||
|
|
||||||
|
func (s *Store) batchLoadTags(entities []*Entity) error {
|
||||||
|
if len(entities) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
idMap := make(map[string]*Entity, len(entities))
|
||||||
|
placeholders := make([]string, len(entities))
|
||||||
|
args := make([]any, len(entities))
|
||||||
|
for i, e := range entities {
|
||||||
|
e.Tags = []string{}
|
||||||
|
idMap[e.ID] = e
|
||||||
|
placeholders[i] = "?"
|
||||||
|
args[i] = e.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(
|
||||||
|
"SELECT entity_id, tag FROM entity_tags WHERE entity_id IN (%s) ORDER BY entity_id, tag",
|
||||||
|
strings.Join(placeholders, ","),
|
||||||
|
)
|
||||||
|
|
||||||
|
rows, err := s.db.Query(query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var entityID, tag string
|
||||||
|
if err := rows.Scan(&entityID, &tag); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if e, ok := idMap[entityID]; ok {
|
||||||
|
e.Tags = append(e.Tags, tag)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Store) loadTags(entityID string) ([]string, error) {
|
func (s *Store) loadTags(entityID string) ([]string, error) {
|
||||||
rows, err := s.db.Query("SELECT tag FROM entity_tags WHERE entity_id = ? ORDER BY tag", entityID)
|
rows, err := s.db.Query("SELECT tag FROM entity_tags WHERE entity_id = ? ORDER BY tag", entityID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ var cardGlyphMap = map[db.CardType]string{
|
|||||||
db.CardTemplate: "◈",
|
db.CardTemplate: "◈",
|
||||||
db.CardChecklist: "☐",
|
db.CardChecklist: "☐",
|
||||||
db.CardDecision: "⚖",
|
db.CardDecision: "⚖",
|
||||||
db.CardLink: "🔗",
|
db.CardLink: "↗",
|
||||||
}
|
}
|
||||||
|
|
||||||
func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string {
|
func DisplayGlyph(glyph db.Glyph, cardType *db.CardType) string {
|
||||||
|
|||||||
+46
-4
@@ -4,7 +4,7 @@
|
|||||||
const GLYPHS = {
|
const GLYPHS = {
|
||||||
note: '◦', todo: '▸', event: '◇',
|
note: '◦', todo: '▸', event: '◇',
|
||||||
snippet: '◆', template: '◈', checklist: '☐',
|
snippet: '◆', template: '◈', checklist: '☐',
|
||||||
decision: '⚖', link: '🔗',
|
decision: '⚖', link: '↗',
|
||||||
};
|
};
|
||||||
|
|
||||||
const GLYPH_CLASSES = {
|
const GLYPH_CLASSES = {
|
||||||
@@ -278,11 +278,14 @@
|
|||||||
<span class="detail-id">${shortId}</span>
|
<span class="detail-id">${shortId}</span>
|
||||||
${e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : ''}
|
${e.time_anchor ? `<span class="entity-time">@${e.time_anchor}</span>` : ''}
|
||||||
</div>
|
</div>
|
||||||
<div class="detail-body">${escHtml(e.body)}</div>
|
<div class="detail-body" data-id="${e.id}">${escHtml(e.body)}</div>
|
||||||
${tags ? `<div class="detail-tags">${tags}</div>` : ''}
|
${tags ? `<div class="detail-tags">${tags}</div>` : ''}
|
||||||
${cardContent}
|
${cardContent}
|
||||||
<div class="detail-actions">${actions}</div>
|
<div class="detail-actions">${actions}</div>
|
||||||
`;
|
`;
|
||||||
|
|
||||||
|
const bodyEl = pane.querySelector('.detail-body');
|
||||||
|
if (bodyEl) bodyEl.addEventListener('dblclick', startEditBody);
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderCardContent(e) {
|
function renderCardContent(e) {
|
||||||
@@ -334,6 +337,40 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========== Inline edit ==========
|
||||||
|
|
||||||
|
function startEditBody() {
|
||||||
|
const e = state.entities[state.selectedIndex];
|
||||||
|
if (!e) return;
|
||||||
|
const el = $(`.detail-body[data-id="${e.id}"]`);
|
||||||
|
if (!el || el.tagName === 'TEXTAREA') return;
|
||||||
|
|
||||||
|
const ta = document.createElement('textarea');
|
||||||
|
ta.className = 'detail-body-edit';
|
||||||
|
ta.value = e.body;
|
||||||
|
el.replaceWith(ta);
|
||||||
|
ta.focus();
|
||||||
|
ta.setSelectionRange(ta.value.length, ta.value.length);
|
||||||
|
|
||||||
|
async function save() {
|
||||||
|
const newBody = ta.value.trim();
|
||||||
|
if (newBody && newBody !== e.body) {
|
||||||
|
await api.updateEntity(e.id, { body: newBody });
|
||||||
|
await loadEntities();
|
||||||
|
const idx = state.entities.findIndex(x => x.id === e.id);
|
||||||
|
if (idx >= 0) selectEntity(idx);
|
||||||
|
} else {
|
||||||
|
renderDetailPane();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ta.addEventListener('blur', save);
|
||||||
|
ta.addEventListener('keydown', (ev) => {
|
||||||
|
if (ev.key === 'Enter' && ev.ctrlKey) { ev.preventDefault(); ta.removeEventListener('blur', save); save(); }
|
||||||
|
if (ev.key === 'Escape') { ev.preventDefault(); ta.removeEventListener('blur', save); renderDetailPane(); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ========== Actions ==========
|
// ========== Actions ==========
|
||||||
|
|
||||||
function selectEntity(idx) {
|
function selectEntity(idx) {
|
||||||
@@ -498,8 +535,9 @@
|
|||||||
const captureInput = $('#capture-input');
|
const captureInput = $('#capture-input');
|
||||||
|
|
||||||
document.addEventListener('keydown', (ev) => {
|
document.addEventListener('keydown', (ev) => {
|
||||||
if (document.activeElement === captureInput) {
|
if (document.activeElement === captureInput ||
|
||||||
if (ev.key === 'Escape') captureInput.blur();
|
document.activeElement.classList.contains('detail-body-edit')) {
|
||||||
|
if (ev.key === 'Escape') document.activeElement.blur();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -544,6 +582,10 @@
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
case 'e': {
|
||||||
|
startEditBody();
|
||||||
|
break;
|
||||||
|
}
|
||||||
case '1': switchView('stream'); break;
|
case '1': switchView('stream'); break;
|
||||||
case '2': switchView('cards'); break;
|
case '2': switchView('cards'); break;
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -51,7 +51,7 @@
|
|||||||
<span>decision</span>
|
<span>decision</span>
|
||||||
</button>
|
</button>
|
||||||
<button data-type="link" class="type-btn">
|
<button data-type="link" class="type-btn">
|
||||||
<span class="type-glyph">🔗</span>
|
<span class="type-glyph">↗</span>
|
||||||
<span>link</span>
|
<span>link</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+37
-3
@@ -92,22 +92,28 @@ nav {
|
|||||||
#capture-input {
|
#capture-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--text-muted);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
font-family: var(--font-mono);
|
font-family: var(--font-mono);
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
outline: none;
|
outline: none;
|
||||||
transition: border-color 0.15s;
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
#capture-input:hover {
|
||||||
|
border-color: var(--accent-dim);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
#capture-input:focus {
|
#capture-input:focus {
|
||||||
border-color: var(--accent);
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 1px var(--accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
#capture-input::placeholder {
|
#capture-input::placeholder {
|
||||||
color: var(--text-muted);
|
color: var(--text-dim);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Main layout */
|
/* Main layout */
|
||||||
@@ -273,6 +279,34 @@ main {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
cursor: text;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: 4px 6px;
|
||||||
|
margin-left: -6px;
|
||||||
|
transition: background 0.1s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-body-edit {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
min-height: 80px;
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.7;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg);
|
||||||
|
color: var(--text);
|
||||||
|
border: 1px solid var(--accent);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
outline: none;
|
||||||
|
resize: vertical;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-tags {
|
.detail-tags {
|
||||||
|
|||||||
Reference in New Issue
Block a user