// Package store implementa a persistência de notas em disco no estilo // Git: conteúdo é armazenado como blobs endereçados por hash SHA-256 e // cada salvamento cria uma revisão imutável apontando para seu pai. package store import ( "crypto/rand" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "os" "path/filepath" "regexp" "slices" "sort" "strings" "sync" "time" "unicode" ) // Formatos aceitos para uma nota. const ( FormatTxt = "txt" FormatMD = "md" FormatHTML = "html" ) var validFormats = map[string]string{ FormatTxt: ".txt", FormatMD: ".md", FormatHTML: ".html", } const ( // MaxContentBytes limita o tamanho do conteúdo de uma nota. MaxContentBytes = 2 << 20 // 2 MiB maxTitleLen = 200 maxCategoryLen = 100 maxTagLen = 64 ) // Erros sentinel da camada de armazenamento. var ( ErrNotFound = errors.New("nota não encontrada") ErrRevision = errors.New("revisão não encontrada") ErrInvalid = errors.New("dados inválidos") ErrTooLarge = errors.New("conteúdo excede o limite de tamanho") ErrUnchanged = errors.New("conteúdo idêntico à versão atual") ) // Note é o estado atual de uma nota (sem o conteúdo). type Note struct { ID string `json:"id"` Title string `json:"title"` Format string `json:"format"` Categories []string `json:"categories"` // IDs de categoria (árvore) Tags []string `json:"tags"` CreatedAt time.Time `json:"created_at"` UpdatedAt time.Time `json:"updated_at"` Hash string `json:"hash"` // blob do conteúdo atual Bytes int `json:"bytes"` } // Filename devolve "titulo.ext" sanitizado para download. func (n *Note) Filename() string { base := n.Title if base == "" { base = "sem-titulo" } var b strings.Builder for _, r := range base { switch { case unicode.IsLetter(r) || unicode.IsDigit(r) || r == '-' || r == '_' || r == ' ': b.WriteRune(r) case r == '.': // permite extensão dupla acidental; mantém apenas um ponto if b.Len() > 0 && b.String()[b.Len()-1] != '.' { b.WriteRune(r) } } } base = strings.TrimSpace(b.String()) if base == "" { base = "sem-titulo" } return base + validFormats[n.Format] } // Revision é uma entrada imutável do histórico de uma nota. type Revision struct { Hash string `json:"hash"` // SHA-256 do conteúdo (hex) Parent string `json:"parent"` // hash da revisão anterior; "" na primeira Author string `json:"author"` When time.Time `json:"when"` Message string `json:"message"` Bytes int `json:"bytes"` } // RevisionWithContent é uma revisão com seu conteúdo, para o painel de histórico. type RevisionWithContent struct { Revision Content string `json:"content"` } // Filtro de listagem. Category aceita "" (todas), "none" (sem categoria) ou um // ID de categoria; Recursive (padrão) inclui notas de categorias descendentes. type Filter struct { Category string Recursive bool Tag string // "" = todas (sem #) Query string // busca em título, categoria e conteúdo (case-insensitive) } // Category é uma pasta da árvore de categorias. type Category struct { ID string `json:"id"` Name string `json:"name"` Parent string `json:"parent"` // "" = raiz CreatedAt time.Time `json:"created_at"` } // CategoryView é uma categoria com agregações para a UI. type CategoryView struct { Category NoteCount int `json:"note_count"` // notas com esta categoria exata HasChild bool `json:"has_child"` // tem subcategorias filhas } // Store é seguro para concorrência. type Store struct { dir string mu sync.RWMutex cats []Category // cache da árvore; persistido em categories.json } // New abre (ou cria) o diretório raiz de dados e migra metadados legados // (nota com campo único "category" vira categoria raiz de mesmo nome). func New(dir string) (*Store, error) { for _, sub := range []string{"notes", "blobs"} { if err := os.MkdirAll(filepath.Join(dir, sub), 0o750); err != nil { return nil, err } } s := &Store{dir: dir} if err := s.loadCats(); err != nil { return nil, err } if err := s.migrateLegacy(); err != nil { return nil, err } return s, nil } // --------------------------------------------------------------------------- // Árvore de categorias (categories.json) // --------------------------------------------------------------------------- func (s *Store) catsPath() string { return filepath.Join(s.dir, "categories.json") } func (s *Store) loadCats() error { s.cats = nil var cats []Category err := readJSON(s.catsPath(), &cats) switch { case errors.Is(err, ErrNotFound): return nil case err != nil: return err } s.cats = cats return nil } func (s *Store) catByID(id string) *Category { for i := range s.cats { if s.cats[i].ID == id { return &s.cats[i] } } return nil } // catSubtree devolve id e todos os descendentes (ordem de percurso). func (s *Store) catSubtree(id string) []string { out := []string{id} for i := 0; i < len(out); i++ { for _, c := range s.cats { if c.Parent == out[i] { out = append(out, c.ID) } } } return out } func (s *Store) catName(id string) string { if c := s.catByID(id); c != nil { return c.Name } return "" } // CreateCategory cria uma pasta; nomes únicos entre irmãos (case-insensitive). func (s *Store) CreateCategory(name, parent string) (*Category, error) { name = strings.TrimSpace(name) if r := []rune(name); name == "" || len(r) > maxCategoryLen { return nil, fmt.Errorf("%w: nome de categoria inválido", ErrInvalid) } s.mu.Lock() defer s.mu.Unlock() if parent != "" && s.catByID(parent) == nil { return nil, fmt.Errorf("%w: categoria pai inexistente", ErrInvalid) } lower := strings.ToLower(name) for _, c := range s.cats { if c.Parent == parent && strings.ToLower(c.Name) == lower { return nil, fmt.Errorf("%w: já existe uma categoria %q nesse nível", ErrInvalid, name) } } cat := Category{ID: newID(), Name: name, Parent: parent, CreatedAt: time.Now().UTC()} s.cats = append(s.cats, cat) if err := writeJSONAtomic(s.catsPath(), s.cats); err != nil { return nil, err } return &cat, nil } // UpdateCategory renomeia (name == "" mantém) e/ou move (parent != nil) a categoria. func (s *Store) UpdateCategory(id, name string, parent *string) (*Category, error) { s.mu.Lock() defer s.mu.Unlock() c := s.catByID(id) if c == nil { return nil, ErrNotFound } if parent != nil && *parent != c.Parent { if *parent != "" && s.catByID(*parent) == nil { return nil, fmt.Errorf("%w: categoria pai inexistente", ErrInvalid) } for _, d := range s.catSubtree(id) { // sem ciclos: pai não pode ser descendente if d == *parent { return nil, fmt.Errorf("%w: não é possível mover uma categoria para dentro dela mesma", ErrInvalid) } } c.Parent = *parent } if n := strings.TrimSpace(name); n != "" && n != c.Name { if len([]rune(n)) > maxCategoryLen { return nil, fmt.Errorf("%w: nome de categoria inválido", ErrInvalid) } lower := strings.ToLower(n) for _, other := range s.cats { if other.ID != id && other.Parent == c.Parent && strings.ToLower(other.Name) == lower { return nil, fmt.Errorf("%w: já existe uma categoria %q nesse nível", ErrInvalid, n) } } c.Name = n } if err := writeJSONAtomic(s.catsPath(), s.cats); err != nil { return nil, err } cp := *c return &cp, nil } // DeleteCategory remove a categoria e suas descendentes; notas permanecem, // apenas perdem as vinculações removidas. func (s *Store) DeleteCategory(id string) error { s.mu.Lock() defer s.mu.Unlock() if s.catByID(id) == nil { return ErrNotFound } sub := map[string]bool{} for _, d := range s.catSubtree(id) { sub[d] = true } kept := s.cats[:0] for _, c := range s.cats { if !sub[c.ID] { kept = append(kept, c) } } s.cats = kept if err := writeJSONAtomic(s.catsPath(), s.cats); err != nil { return err } ids, err := s.noteIDs() if err != nil { return err } for _, nid := range ids { n, err := s.get(nid) if err != nil { continue } cleaned := make([]string, 0, len(n.Categories)) changed := false for _, cid := range n.Categories { if sub[cid] { changed = true continue } cleaned = append(cleaned, cid) } if changed { n.Categories = cleaned if err := writeJSONAtomic(s.metaPath(nid), n); err != nil { return err } } } return nil } // Categories devolve a árvore completa (lista plana; a UI monta os nós). func (s *Store) Categories() ([]CategoryView, error) { s.mu.RLock() defer s.mu.RUnlock() counts := map[string]int{} ids, err := s.noteIDs() if err != nil { return nil, err } for _, id := range ids { n, err := s.get(id) if err != nil { continue } for _, cid := range n.Categories { counts[cid]++ } } out := make([]CategoryView, 0, len(s.cats)) for _, c := range s.cats { v := CategoryView{Category: c, NoteCount: counts[c.ID]} for _, other := range s.cats { if other.Parent == c.ID { v.HasChild = true break } } out = append(out, v) } return out, nil } // validateCatIDs confere se todas as categorias existem (sob lock de escrita). func (s *Store) validateCatIDs(ids []string) ([]string, error) { seen := map[string]bool{} var out []string for _, id := range ids { if id == "" || s.catByID(id) == nil { return nil, fmt.Errorf("%w: categoria inexistente %q", ErrInvalid, id) } if !seen[id] { seen[id] = true out = append(out, id) } } return out, nil } // migrateLegacy converte o campo único "category" (pré-árvore) em categoria raiz. func (s *Store) migrateLegacy() error { ids, err := s.noteIDs() if err != nil { return err } changed := false for _, id := range ids { var meta struct { Note Category string `json:"category"` // legado } if err := readJSON(s.metaPath(id), &meta); err != nil { continue } legacy := strings.TrimSpace(meta.Category) if legacy == "" || len(meta.Categories) > 0 { continue } var cat *Category lower := strings.ToLower(legacy) for i := range s.cats { if s.cats[i].Parent == "" && strings.ToLower(s.cats[i].Name) == lower { cat = &s.cats[i] break } } if cat == nil { cat = &Category{ID: newID(), Name: legacy, Parent: "", CreatedAt: time.Now().UTC()} s.cats = append(s.cats, *cat) changed = true } n := meta.Note n.Categories = []string{cat.ID} if err := writeJSONAtomic(s.metaPath(id), n); err != nil { return err } } if changed { return writeJSONAtomic(s.catsPath(), s.cats) } return nil } // --------------------------------------------------------------------------- // Tipos internos persistidos // --------------------------------------------------------------------------- func (s *Store) noteDir(id string) string { return filepath.Join(s.dir, "notes", id) } func (s *Store) metaPath(id string) string { return filepath.Join(s.noteDir(id), "meta.json") } func (s *Store) histPath(id string) string { return filepath.Join(s.noteDir(id), "history.json") } func (s *Store) blobPath(hash string) string { return filepath.Join(s.dir, "blobs", hash[:2], hash) } func hashContent(content string) string { sum := sha256.Sum256([]byte(content)) return hex.EncodeToString(sum[:]) } func newID() string { var buf [8]byte if _, err := rand.Read(buf[:]); err != nil { // improvável; fallback temporal return fmt.Sprintf("%012x", time.Now().UnixNano()&0xffffffffffff) } return hex.EncodeToString(buf[:])[:12] } // writeJSONAtomic grava v em JSON de forma atômica (tmp + rename). func writeJSONAtomic(path string, v any) error { data, err := json.MarshalIndent(v, "", " ") if err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, append(data, '\n'), 0o640); err != nil { return err } return os.Rename(tmp, path) } func readJSON(path string, v any) error { data, err := os.ReadFile(path) if errors.Is(err, os.ErrNotExist) { return ErrNotFound } if err != nil { return err } return json.Unmarshal(data, v) } // putBlobArmazenado grava o conteúdo se o blob ainda não existe. func (s *Store) putBlob(content string) (string, error) { hash := hashContent(content) p := s.blobPath(hash) if _, err := os.Stat(p); err == nil { return hash, nil } else if !errors.Is(err, os.ErrNotExist) { return "", err } if err := os.MkdirAll(filepath.Dir(p), 0o750); err != nil { return "", err } tmp := p + ".tmp" if err := os.WriteFile(tmp, []byte(content), 0o640); err != nil { return "", err } return hash, os.Rename(tmp, p) } func (s *Store) getBlob(hash string) (string, error) { data, err := os.ReadFile(s.blobPath(hash)) if errors.Is(err, os.ErrNotExist) { return "", ErrNotFound } return string(data), err } // --------------------------------------------------------------------------- // Validação e tags // --------------------------------------------------------------------------- // tagRe casa #tag: # seguido de letra e então letras/dígitos/_/-. // Cabeçalhos Markdown ("# Título") têm espaço após # e ficam de fora. var tagRe = regexp.MustCompile(`(^|[^\p{L}\p{N}_/])#(\p{L}[\p{L}\p{N}_-]{0,63})`) // ExtractTags normaliza (minúsculas) e deduplica as tags presentes no conteúdo. func ExtractTags(content string) []string { seen := map[string]bool{} var out []string for _, m := range tagRe.FindAllStringSubmatch(content, -1) { t := strings.ToLower(m[2]) t = strings.Trim(t, "-_") // "#dev-" e "#dev_" viram "#dev" if t == "" || seen[t] { continue } seen[t] = true out = append(out, t) } slices.Sort(out) return out } func normalizeFormat(f string) (string, error) { f = strings.ToLower(strings.TrimPrefix(f, ".")) if _, ok := validFormats[f]; !ok { return "", fmt.Errorf("%w: formato %q", ErrInvalid, f) } return f, nil } func cleanNoteFields(title, format *string) error { *title = strings.TrimSpace(*title) if r := []rune(*title); len(r) > maxTitleLen { *title = string(r[:maxTitleLen]) } f, err := normalizeFormat(*format) if err != nil { return err } *format = f return nil } func validateContent(content string) error { if len(content) > MaxContentBytes { return ErrTooLarge } return nil } var idRe = regexp.MustCompile(`^[a-z0-9]{6,32}$`) // --------------------------------------------------------------------------- // Operações públicas // --------------------------------------------------------------------------- // Create grava uma nova nota com sua primeira revisão. func (s *Store) Create(title, format string, categories []string, content, author, message string) (*Note, error) { if err := cleanNoteFields(&title, &format); err != nil { return nil, err } if err := validateContent(content); err != nil { return nil, err } s.mu.Lock() defer s.mu.Unlock() cats, err := s.validateCatIDs(categories) if err != nil { return nil, err } id := newID() now := time.Now().UTC() if err := os.MkdirAll(s.noteDir(id), 0o750); err != nil { return nil, err } hash, err := s.putBlob(content) if err != nil { return nil, err } n := &Note{ ID: id, Title: title, Format: format, Categories: cats, Tags: ExtractTags(content), CreatedAt: now, UpdatedAt: now, Hash: hash, Bytes: len(content), } rev := Revision{Hash: hash, Parent: "", Author: fallback(author, "desconhecido"), When: now, Message: fallback(message, "Criação da nota"), Bytes: len(content)} if err := writeJSONAtomic(s.metaPath(id), n); err != nil { return nil, err } if err := writeJSONAtomic(s.histPath(id), []Revision{rev}); err != nil { return nil, err } return n, nil } // Get devolve os metadados da nota id. func (s *Store) Get(id string) (*Note, error) { s.mu.RLock() defer s.mu.RUnlock() return s.get(id) } func (s *Store) get(id string) (*Note, error) { if !idRe.MatchString(id) { return nil, ErrNotFound } var n Note if err := readJSON(s.metaPath(id), &n); err != nil { if errors.Is(err, ErrNotFound) { return nil, ErrNotFound } return nil, err } return &n, nil } // Content devolve o conteúdo atual da nota. func (s *Store) Content(id string) (string, *Note, error) { s.mu.RLock() defer s.mu.RUnlock() n, err := s.get(id) if err != nil { return "", nil, err } c, err := s.getBlob(n.Hash) return c, n, err } // Update salva metadados e, se o conteúdo mudou, uma nova revisão. // Um conteúdo inalterado atualiza apenas título/categorias/formato. func (s *Store) Update(id, title, format string, categories []string, content, author, message string) (*Note, error) { if err := cleanNoteFields(&title, &format); err != nil { return nil, err } if err := validateContent(content); err != nil { return nil, err } s.mu.Lock() defer s.mu.Unlock() cats, err := s.validateCatIDs(categories) if err != nil { return nil, err } n, err := s.get(id) if err != nil { return nil, err } newHash := hashContent(content) contentChanged := newHash != n.Hash if contentChanged { if _, err := s.putBlob(content); err != nil { return nil, err } } hist, err := s.history(id) if err != nil { return nil, err } if contentChanged { rev := Revision{Hash: newHash, Parent: n.Hash, Author: fallback(author, "desconhecido"), When: time.Now().UTC(), Message: fallback(message, "Edição via editor"), Bytes: len(content)} hist = append(hist, rev) if err := writeJSONAtomic(s.histPath(id), hist); err != nil { return nil, err } } tags := n.Tags if contentChanged { tags = ExtractTags(content) } now := time.Now().UTC() meta := &Note{ID: n.ID, Title: title, Format: format, Categories: cats, Tags: tags, CreatedAt: n.CreatedAt, UpdatedAt: now, Hash: newHash, Bytes: len(content)} if err := writeJSONAtomic(s.metaPath(id), meta); err != nil { return nil, err } return meta, nil } // Delete remove a nota e faz GC dos blobs que ficaram órfãos. func (s *Store) Delete(id string) error { s.mu.Lock() defer s.mu.Unlock() if !idRe.MatchString(id) { return ErrNotFound } if _, err := os.Stat(s.noteDir(id)); err != nil { if errors.Is(err, os.ErrNotExist) { return ErrNotFound } return err } if err := os.RemoveAll(s.noteDir(id)); err != nil { return err } return s.gcBlobs() } // gcBlobs apaga blobs não referenciados por nenhuma história restante. func (s *Store) gcBlobs() error { live := map[string]bool{} ids, err := s.noteIDs() if err != nil { return err } for _, id := range ids { hist, err := s.history(id) if err != nil { return err } for _, r := range hist { live[r.Hash] = true } } root := filepath.Join(s.dir, "blobs") shards, err := os.ReadDir(root) if err != nil { return err } for _, shard := range shards { if !shard.IsDir() { continue } dir := filepath.Join(root, shard.Name()) entries, err := os.ReadDir(dir) if err != nil { return err } for _, e := range entries { if !live[e.Name()] { if err := os.Remove(filepath.Join(dir, e.Name())); err != nil { return err } } } if remaining, err := os.ReadDir(dir); err == nil && len(remaining) == 0 { _ = os.Remove(dir) } } return nil } func (s *Store) noteIDs() ([]string, error) { entries, err := os.ReadDir(filepath.Join(s.dir, "notes")) if err != nil { return nil, err } var ids []string for _, e := range entries { if e.IsDir() && idRe.MatchString(e.Name()) { ids = append(ids, e.Name()) } } return ids, nil } // List devolve notas conforme f, ordenadas por atualização mais recente. func (s *Store) List(f Filter) ([]*Note, error) { f.Category = strings.TrimSpace(f.Category) f.Tag = strings.ToLower(strings.TrimPrefix(f.Tag, "#")) f.Query = strings.ToLower(strings.TrimSpace(f.Query)) s.mu.RLock() defer s.mu.RUnlock() // Conjunto de categorias aceitas para o filtro (id + descendentes se recursivo). var allowed map[string]bool if f.Category != "" && f.Category != "none" { if s.catByID(f.Category) == nil { return nil, fmt.Errorf("%w: categoria inexistente", ErrInvalid) } allowed = map[string]bool{} ids := []string{f.Category} if f.Recursive { ids = s.catSubtree(f.Category) } for _, id := range ids { allowed[id] = true } } ids, err := s.noteIDs() if err != nil { return nil, err } out := make([]*Note, 0, len(ids)) for _, id := range ids { n, err := s.get(id) if err != nil { continue } switch f.Category { case "": case "none": if len(n.Categories) > 0 { continue } default: hit := false for _, cid := range n.Categories { if allowed[cid] { hit = true break } } if !hit { continue } } if f.Tag != "" && !slices.Contains(n.Tags, f.Tag) { continue } if f.Query != "" && !s.matchesQuery(n, f.Query) { continue } out = append(out, n) } sort.Slice(out, func(i, j int) bool { return out[i].UpdatedAt.After(out[j].UpdatedAt) }) return out, nil } func (s *Store) matchesQuery(n *Note, q string) bool { if strings.Contains(strings.ToLower(n.Title), q) || slices.ContainsFunc(n.Tags, func(t string) bool { return strings.Contains(t, q) }) { return true } for _, cid := range n.Categories { if strings.Contains(strings.ToLower(s.catName(cid)), q) { return true } } content, err := s.getBlob(n.Hash) if err != nil { return false } return strings.Contains(strings.ToLower(content), q) } // History devolve as revisões da nota, da mais antiga para a mais nova. func (s *Store) History(id string) ([]Revision, error) { s.mu.RLock() defer s.mu.RUnlock() if _, err := s.get(id); err != nil { return nil, err } return s.history(id) } func (s *Store) history(id string) ([]Revision, error) { var hist []Revision if err := readJSON(s.histPath(id), &hist); err != nil { if errors.Is(err, ErrNotFound) { return nil, fmt.Errorf("história ausente para %s: %w", id, ErrNotFound) } return nil, err } return hist, nil } // RevisionWithContent devolve uma revisão específica com seu conteúdo. func (s *Store) RevisionWithContent(id, hash string) (*RevisionWithContent, error) { s.mu.RLock() defer s.mu.RUnlock() hist, err := s.history(id) if err != nil { return nil, err } for _, r := range hist { if r.Hash == hash { content, err := s.getBlob(hash) if err != nil { return nil, err } return &RevisionWithContent{Revision: r, Content: content}, nil } } return nil, ErrRevision } // DiffBetween devolve (conteúdo antigo, conteúdo novo) entre uma revisão e // seu pai. Para a primeira revisão, o antigo é vazio. func (s *Store) DiffBetween(id, hash string) (before, after string, rev Revision, err error) { s.mu.RLock() defer s.mu.RUnlock() hist, err := s.history(id) if err != nil { return "", "", rev, err } for _, r := range hist { if r.Hash == hash { after, err = s.getBlob(r.Hash) if err != nil { return } if r.Parent != "" { before, err = s.getBlob(r.Parent) if err != nil { return } } rev = r return } } return "", "", rev, ErrRevision } // Restore cria uma nova revisão com o conteúdo de uma revisão anterior. // Se o conteúdo já é o atual, devolve ErrUnchanged. func (s *Store) Restore(id, hash, author string) (*Note, error) { s.mu.Lock() defer s.mu.Unlock() n, err := s.get(id) if err != nil { return nil, err } content, err := s.getBlob(hash) if err != nil { if errors.Is(err, ErrNotFound) { return nil, ErrRevision } return nil, err } hist, err := s.history(id) if err != nil { return nil, err } found := false for _, r := range hist { if r.Hash == hash { found = true break } } if !found { return nil, ErrRevision } if hash == n.Hash { return nil, ErrUnchanged } now := time.Now().UTC() newHash, err := s.putBlob(content) // já existe (mesmo hash), no-op if err != nil { return nil, err } short := hash if len(short) > 8 { short = short[:8] } rev := Revision{Hash: newHash, Parent: n.Hash, Author: fallback(author, "desconhecido"), When: now, Message: fmt.Sprintf("Restauração da versão %s", short), Bytes: len(content)} hist = append(hist, rev) if err := writeJSONAtomic(s.histPath(id), hist); err != nil { return nil, err } meta := *n meta.Hash = newHash meta.Bytes = len(content) meta.Tags = ExtractTags(content) meta.UpdatedAt = now if err := writeJSONAtomic(s.metaPath(id), &meta); err != nil { return nil, err } return &meta, nil } // TagCount é uma tag agregada com número de notas. type TagCount struct { Tag string `json:"tag"` Count int `json:"count"` } // Tags devolve todas as tags em uso, ordenadas por contagem desc e nome. func (s *Store) Tags() ([]TagCount, error) { s.mu.RLock() defer s.mu.RUnlock() counts := map[string]int{} ids, err := s.noteIDs() if err != nil { return nil, err } for _, id := range ids { n, err := s.get(id) if err != nil { continue } for _, t := range n.Tags { counts[t]++ } } out := make([]TagCount, 0, len(counts)) for t, c := range counts { out = append(out, TagCount{Tag: t, Count: c}) } sort.Slice(out, func(i, j int) bool { if out[i].Count != out[j].Count { return out[i].Count > out[j].Count } return out[i].Tag < out[j].Tag }) return out, nil } func fallback(v, def string) string { v = strings.TrimSpace(v) if v == "" { return def } if r := []rune(v); len(r) > 80 { v = string(r[:80]) } return v }