e9ecc4c1f7
CI / test (pull_request) Successful in 2m13s
Fix goroutine-unsafe ULID entropy by wrapping in LockedMonotonicReader. Move PRAGMA foreign_keys outside transaction in v3 migration where SQLite was silently ignoring it. Escape LIKE wildcards in link resolution to prevent false matches. Add non-localhost binding warning, log writeJSON encoder errors, add ?permanent=true for explicit hard delete, preserve title/description during absorb, use millisecond backup timestamps, add path.Clean to spaHandler. Frontend gains checkedJSON() for resp.ok validation, consistent stopPropagation, and shared renderCardSections() to eliminate duplicate rendering.
89 lines
2.1 KiB
Go
89 lines
2.1 KiB
Go
package db
|
|
|
|
import (
|
|
"context"
|
|
"database/sql"
|
|
"strings"
|
|
|
|
"github.com/lerko/nib/internal/link"
|
|
)
|
|
|
|
type Backlink struct {
|
|
EntityID string
|
|
Title *string
|
|
Body string
|
|
LinkText string
|
|
}
|
|
|
|
func escapeLike(s string) string {
|
|
r := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`)
|
|
return r.Replace(s)
|
|
}
|
|
|
|
func (s *Store) resolveLink(ctx context.Context, tx *sql.Tx, linkText string, excludeID string) *string {
|
|
lower := strings.ToLower(linkText)
|
|
|
|
var id string
|
|
err := tx.QueryRowContext(ctx, `
|
|
SELECT id FROM entities
|
|
WHERE LOWER(title) = ? AND id != ? AND deleted_at IS NULL
|
|
ORDER BY created_at DESC LIMIT 1`, lower, excludeID).Scan(&id)
|
|
if err == nil {
|
|
return &id
|
|
}
|
|
|
|
err = tx.QueryRowContext(ctx, `
|
|
SELECT id FROM entities
|
|
WHERE LOWER(body) LIKE ? ESCAPE '\' AND id != ? AND deleted_at IS NULL
|
|
ORDER BY created_at DESC LIMIT 1`, "%"+escapeLike(lower)+"%", excludeID).Scan(&id)
|
|
if err == nil {
|
|
return &id
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func syncLinks(ctx context.Context, tx *sql.Tx, s *Store, entityID string, body string) error {
|
|
if _, err := tx.ExecContext(ctx, "DELETE FROM entity_links WHERE from_id = ?", entityID); err != nil {
|
|
return err
|
|
}
|
|
|
|
linkTexts := link.ExtractLinks(body)
|
|
for _, lt := range linkTexts {
|
|
toID := s.resolveLink(ctx, tx, lt, entityID)
|
|
if _, err := tx.ExecContext(ctx,
|
|
"INSERT OR IGNORE INTO entity_links (from_id, to_id, link_text) VALUES (?, ?, ?)",
|
|
entityID, toID, lt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (s *Store) LoadBacklinks(ctx context.Context, entityID string) ([]Backlink, error) {
|
|
rows, err := s.db.QueryContext(ctx, `
|
|
SELECT e.id, e.title, e.body, el.link_text
|
|
FROM entity_links el
|
|
JOIN entities e ON e.id = el.from_id
|
|
WHERE el.to_id = ? AND e.deleted_at IS NULL
|
|
ORDER BY e.created_at DESC`, entityID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
var backlinks []Backlink
|
|
for rows.Next() {
|
|
var bl Backlink
|
|
var title sql.NullString
|
|
if err := rows.Scan(&bl.EntityID, &title, &bl.Body, &bl.LinkText); err != nil {
|
|
return nil, err
|
|
}
|
|
if title.Valid {
|
|
bl.Title = &title.String
|
|
}
|
|
backlinks = append(backlinks, bl)
|
|
}
|
|
return backlinks, rows.Err()
|
|
}
|