fix: mobile layout, task clicks, list tab corners, hashtags in URLs
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m25s
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 2m25s
Mobile
- Plus button in triage did nothing: the stored action returned the
capture function instead of calling it
- Workspace: tabs share the full width; save status sits as an icon next
to the note title instead of wrapping into its own row; "Neue Notiz" /
"Neuer Ordner" only in the top menu (and +), not repeated in the list
- Outliner fields measured while hidden stayed one line high (cut-off
callout); they are measured again when their width changes
- Task dialog: scrolls in a div instead of a fieldset, height in dvh,
notes editor grows with its text, so long notes are fully reachable
Desktop
- Delete button of the selected task/shopping list sits inside the tab as
its own rounded button (matches rounded themes like Soft)
Both
- Tasks sometimes did not open: every reload after an action replaced the
list with a loading placeholder, swallowing clicks; reloads are now
silent after the first load of a list, stale responses are dropped
- Hashtags need whitespace or "(" before "#", so URL anchors are no
longer tags (server, browser); one-time cleanup of stored anchor tags
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
41b02b9564
commit
f82ab2fd32
16 files changed
+283
-47
No files matched your search
@@ -21,7 +21,31 @@ func NowISO() string {
|
||||
return time.Now().UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
var hashtagRegex = regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
|
||||
// A hashtag starts the text or follows whitespace or "(". Fragments in links (example.com/blog#ueberschrift)
|
||||
// and words like "C#" are not tags. The browser uses the same rule (lib/markdown.tsx, lib/api.ts).
|
||||
var hashtagRegex = regexp.MustCompile(`(?:^|[\s(])#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
|
||||
|
||||
// legacyHashtagRegex is the rule before anchors were excluded; only used to clean up stored tags.
|
||||
var legacyHashtagRegex = regexp.MustCompile(`#([a-zA-Z0-9äöüÄÖÜß_-]+)`)
|
||||
|
||||
// StaleAnchorTags returns tags the old rule found in content that the current rule does not, i.e. tags that
|
||||
// only came from URL fragments and similar.
|
||||
func StaleAnchorTags(content string) []string {
|
||||
current := map[string]bool{}
|
||||
for _, t := range ExtractTags(content) {
|
||||
current[t] = true
|
||||
}
|
||||
var stale []string
|
||||
seen := map[string]bool{}
|
||||
for _, m := range legacyHashtagRegex.FindAllStringSubmatch(content, -1) {
|
||||
t := strings.ToLower(m[1])
|
||||
if !current[t] && !seen[t] {
|
||||
seen[t] = true
|
||||
stale = append(stale, t)
|
||||
}
|
||||
}
|
||||
return stale
|
||||
}
|
||||
|
||||
// ExtractTags extracts #hashtags from content
|
||||
func ExtractTags(content string) []string {
|
||||
@@ -78,7 +102,7 @@ func FormatGermanDateTime(isoStr string) string {
|
||||
time.May: "Mai",
|
||||
time.June: "Juni",
|
||||
time.July: "Juli",
|
||||
time.August: "Aug.",
|
||||
time.August: "Aug.",
|
||||
time.September: "Sept.",
|
||||
time.October: "Okt.",
|
||||
time.November: "Nov.",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
package domain
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExtractTagsIgnoresURLAnchors(t *testing.T) {
|
||||
cases := map[string][]string{
|
||||
"#start mitten #zwei": {"start", "zwei"},
|
||||
"siehe example.com/blog#ueberschrift": {},
|
||||
"https://example.com/blog#ueberschrift und #echt": {"echt"},
|
||||
"[Link](https://x.de/#anker) #tag": {"tag"},
|
||||
"(#inKlammern) C# ist kein Tag": {"inklammern"},
|
||||
"Zeile eins\n#zeilenanfang": {"zeilenanfang"},
|
||||
"#Doppelt #doppelt": {"doppelt"},
|
||||
}
|
||||
for in, want := range cases {
|
||||
got := ExtractTags(in)
|
||||
if len(got) == 0 && len(want) == 0 {
|
||||
continue
|
||||
}
|
||||
if !reflect.DeepEqual(got, want) {
|
||||
t.Errorf("ExtractTags(%q) = %v, want %v", in, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStaleAnchorTags(t *testing.T) {
|
||||
got := StaleAnchorTags("example.com/a#teil und #teil sowie x.de/#nur")
|
||||
if !reflect.DeepEqual(got, []string{"nur"}) {
|
||||
t.Fatalf("only tags that exist solely as anchors are stale, got %v", got)
|
||||
}
|
||||
}
|
||||
@@ -406,3 +406,22 @@ func TestDemoAccountsGetSampleDataAndShares(t *testing.T) {
|
||||
t.Fatalf("restart seeded again: %d notes", docs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnchorTagMigrationKeepsRealTags(t *testing.T) {
|
||||
s, u := newSyncTestStore(t)
|
||||
th, err := s.CreateThought(u.ID, "Lesen: example.com/blog#ueberschrift #lesen", "normal", "manual", "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(th.Tags) != 1 || th.Tags[0] != "lesen" {
|
||||
t.Fatalf("new thoughts must not get anchor tags: %v", th.Tags)
|
||||
}
|
||||
// State before the rule change: anchor stored as tag, plus a tag added by hand
|
||||
s.db.Exec(`UPDATE thoughts SET tags = '["ueberschrift","lesen","manuell"]' WHERE id = ?`, th.ID)
|
||||
s.db.Exec(`DELETE FROM system_settings WHERE key = ?`, tagsAnchorMigrationKey)
|
||||
s.migrateAnchorTags()
|
||||
got, _ := s.GetThoughtByID(u.ID, th.ID)
|
||||
if len(got.Tags) != 2 || got.Tags[0] != "lesen" || got.Tags[1] != "manuell" {
|
||||
t.Fatalf("only the anchor tag must go: %v", got.Tags)
|
||||
}
|
||||
}
|
||||
@@ -470,6 +470,7 @@ func (s *Store) migrate() error {
|
||||
if tagCount == 0 {
|
||||
_ = s.reindexAllDocBlockTags()
|
||||
}
|
||||
s.migrateAnchorTags()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
|
||||
"github.com/sandrodach/mindsift/internal/domain"
|
||||
)
|
||||
|
||||
const tagsAnchorMigrationKey = "migration_tags_without_anchors"
|
||||
|
||||
// migrateAnchorTags removes tags that only came from URL fragments (example.com/blog#ueberschrift) since the
|
||||
// hashtag rule now requires whitespace or "(" before "#". Runs once. Only such tags are removed from
|
||||
// thoughts and notes, so tags users added by hand stay; block_tags is rebuilt from the blocks.
|
||||
func (s *Store) migrateAnchorTags() {
|
||||
var done string
|
||||
if s.db.QueryRow(`SELECT value FROM system_settings WHERE key = ?`, tagsAnchorMigrationKey).Scan(&done) == nil {
|
||||
return
|
||||
}
|
||||
cleaned := 0
|
||||
for _, table := range []struct{ name, text string }{{"thoughts", "content"}, {"documents", "content"}} {
|
||||
rows, err := s.db.Query(`SELECT id, ` + table.text + `, tags FROM ` + table.name + ` WHERE ` + table.text + ` LIKE '%#%'`)
|
||||
if err != nil {
|
||||
slog.Error("Bereinigung der Hashtags fehlgeschlagen", "table", table.name, "error", err)
|
||||
return
|
||||
}
|
||||
type row struct{ id, text, tags string }
|
||||
var list []row
|
||||
for rows.Next() {
|
||||
var r row
|
||||
if rows.Scan(&r.id, &r.text, &r.tags) == nil {
|
||||
list = append(list, r)
|
||||
}
|
||||
}
|
||||
rows.Close()
|
||||
for _, r := range list {
|
||||
stale := domain.StaleAnchorTags(r.text)
|
||||
if len(stale) == 0 {
|
||||
continue
|
||||
}
|
||||
drop := map[string]bool{}
|
||||
for _, t := range stale {
|
||||
drop[t] = true
|
||||
}
|
||||
var tags []string
|
||||
if json.Unmarshal([]byte(r.tags), &tags) != nil {
|
||||
continue
|
||||
}
|
||||
kept := make([]string, 0, len(tags))
|
||||
for _, t := range tags {
|
||||
if !drop[t] {
|
||||
kept = append(kept, t)
|
||||
}
|
||||
}
|
||||
if len(kept) != len(tags) {
|
||||
out, _ := json.Marshal(kept)
|
||||
if _, err := s.db.Exec(`UPDATE `+table.name+` SET tags = ? WHERE id = ?`, string(out), r.id); err == nil {
|
||||
cleaned++
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := s.reindexAllDocBlockTags(); err != nil {
|
||||
slog.Error("Neuaufbau der Block-Hashtags fehlgeschlagen", "error", err)
|
||||
return
|
||||
}
|
||||
_, _ = s.db.Exec(`INSERT OR REPLACE INTO system_settings (key, value) VALUES (?, 'done')`, tagsAnchorMigrationKey)
|
||||
if cleaned > 0 {
|
||||
slog.Info("Hashtags aus URL-Ankern entfernt", "entries", cleaned)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user