cat / 012
clone

a spreadsheet for the terminal: lotus 1-2-3 looks, google sheets keys and formulas, one go binary

Notes on cells and protected ranges in the engine

Dami committed 1a8430d
A note is part of its cell: it survives clearing the contents, moves and
copies with the cell, and saves as an optional field of the cell. Protected
ranges and sheets live in the sheet's view state, follow inserted and
deleted lines, and save as an optional list; neither needs a version bump.
commit
1a8430ddee55ab50f4285c23aa4988d0718f0b46
parent
fb9d110c486f6ce4a9cc78d2cf661559fe39da83
author
Dami <dami.etoile@gmail.com>
10 files changed, +625 -87
Ainternal/sheet/cellfile.go92+++++++++++++++++
Minternal/sheet/file.go71+------------
Minternal/sheet/lines.go6+-
Ainternal/sheet/notes.go112++++++++++++++++++++
Ainternal/sheet/notes_test.go104+++++++++++++++++++
Minternal/sheet/ops.go17+++-
Ainternal/sheet/protect.go164++++++++++++++++++++++++++++++
Ainternal/sheet/protect_test.go73+++++++++++++
Minternal/sheet/sheet.go33+++++-
Minternal/sheet/view.go40+++++--
A internal/sheet/cellfile.go +92 -0
@@ -0,0 +1,92 @@
1+package sheet2+3+import (4+ "encoding/json"5+ "fmt"6+)7+8+// fileCell is a cell with formatting or a note as the file writes it; a9+// cell with neither is just its input (see fileFormat).10+type fileCell struct {11+ Input string `json:"input,omitempty"`12+ Format string `json:"format,omitempty"`13+ Decimals *int `json:"decimals,omitempty"`14+ Pattern string `json:"pattern,omitempty"`15+ Bold bool `json:"bold,omitempty"`16+ Italic bool `json:"italic,omitempty"`17+ Underline bool `json:"underline,omitempty"`18+ Strikethrough bool `json:"strikethrough,omitempty"`19+ Align string `json:"align,omitempty"`20+ Own bool `json:"own,omitempty"` // not taking its column's or row's formatting21+ // Note needs no version bump: earlier builds ignore it and drop the22+ // note.23+ Note string `json:"note,omitempty"`24+}25+26+func encodeCell(c *Cell) (json.RawMessage, error) {27+ if c.Format.IsZero() && c.Style.IsZero() && c.Note == "" {28+ return json.Marshal(c.Input)29+ }30+ fc := fileCell{31+ Input: c.Input,32+ Pattern: c.Format.Pattern,33+ Bold: c.Style.Bold,34+ Italic: c.Style.Italic,35+ Underline: c.Style.Underline,36+ Strikethrough: c.Style.Strikethrough,37+ Align: c.Style.Align.String(),38+ Own: c.Style.own,39+ Note: c.Note,40+ }41+ if !c.Format.IsZero() {42+ fc.Format = c.Format.Kind.String()43+ }44+ if c.Format.Kind.HasDecimals() {45+ d := c.Format.Decimals46+ fc.Decimals = &d47+ }48+ return json.Marshal(fc)49+}50+51+// decodeCell reads a cell's entry and formatting, as line formats are52+// stored too.53+func decodeCell(raw json.RawMessage) (string, Format, Style, error) {54+ input, f, st, _, err := decodeNoted(raw)55+ return input, f, st, err56+}57+58+// decodeNoted is decodeCell with the cell's note.59+func decodeNoted(raw json.RawMessage) (string, Format, Style, string, error) {60+ var input string61+ if err := json.Unmarshal(raw, &input); err == nil {62+ return input, Format{}, Style{}, "", nil63+ }64+ var fc fileCell65+ if err := json.Unmarshal(raw, &fc); err != nil {66+ return "", Format{}, Style{}, "", err67+ }68+ input, f, st, err := decodeFormatted(fc)69+ return input, f, st, fc.Note, err70+}71+72+// decodeFormatted reads the entry and formatting of a cell written as an73+// object.74+func decodeFormatted(fc fileCell) (string, Format, Style, error) {75+ var f Format76+ if fc.Format != "" {77+ k, ok := ParseFormatKind(fc.Format)78+ if !ok {79+ return "", Format{}, Style{}, fmt.Errorf("unknown format %q", fc.Format)80+ }81+ f = Format{Kind: k, Pattern: fc.Pattern}82+ if fc.Decimals != nil {83+ f.Decimals = clampInt(*fc.Decimals, 0, MaxDecimals)84+ }85+ }86+ al, ok := ParseAlign(fc.Align)87+ if !ok {88+ return "", Format{}, Style{}, fmt.Errorf("unknown alignment %q", fc.Align)89+ }90+ st := Style{Bold: fc.Bold, Italic: fc.Italic, Underline: fc.Underline, Strikethrough: fc.Strikethrough, Align: al, own: fc.Own}91+ return fc.Input, f, st, nil92+}
M internal/sheet/file.go +4 -67
@@ -111,71 +111,6 @@ func decodeChart(fc fileChart) (Chart, error) {
111111 return c.clamped(), nil112112 }113113 114-type fileCell struct {115- Input string `json:"input,omitempty"`116- Format string `json:"format,omitempty"`117- Decimals *int `json:"decimals,omitempty"`118- Pattern string `json:"pattern,omitempty"`119- Bold bool `json:"bold,omitempty"`120- Italic bool `json:"italic,omitempty"`121- Underline bool `json:"underline,omitempty"`122- Strikethrough bool `json:"strikethrough,omitempty"`123- Align string `json:"align,omitempty"`124- Own bool `json:"own,omitempty"` // not taking its column's or row's formatting125-}126-127-func encodeCell(c *Cell) (json.RawMessage, error) {128- if c.Format.IsZero() && c.Style.IsZero() {129- return json.Marshal(c.Input)130- }131- fc := fileCell{132- Input: c.Input,133- Pattern: c.Format.Pattern,134- Bold: c.Style.Bold,135- Italic: c.Style.Italic,136- Underline: c.Style.Underline,137- Strikethrough: c.Style.Strikethrough,138- Align: c.Style.Align.String(),139- Own: c.Style.own,140- }141- if !c.Format.IsZero() {142- fc.Format = c.Format.Kind.String()143- }144- if c.Format.Kind.HasDecimals() {145- d := c.Format.Decimals146- fc.Decimals = &d147- }148- return json.Marshal(fc)149-}150-151-func decodeCell(raw json.RawMessage) (string, Format, Style, error) {152- var input string153- if err := json.Unmarshal(raw, &input); err == nil {154- return input, Format{}, Style{}, nil155- }156- var fc fileCell157- if err := json.Unmarshal(raw, &fc); err != nil {158- return "", Format{}, Style{}, err159- }160- var f Format161- if fc.Format != "" {162- k, ok := ParseFormatKind(fc.Format)163- if !ok {164- return "", Format{}, Style{}, fmt.Errorf("unknown format %q", fc.Format)165- }166- f = Format{Kind: k, Pattern: fc.Pattern}167- if fc.Decimals != nil {168- f.Decimals = clampInt(*fc.Decimals, 0, MaxDecimals)169- }170- }171- al, ok := ParseAlign(fc.Align)172- if !ok {173- return "", Format{}, Style{}, fmt.Errorf("unknown alignment %q", fc.Align)174- }175- st := Style{Bold: fc.Bold, Italic: fc.Italic, Underline: fc.Underline, Strikethrough: fc.Strikethrough, Align: al, own: fc.Own}176- return fc.Input, f, st, nil177-}178-179114 // Write saves the workbook the sheet belongs to; see Workbook.Write.180115 func (s *Sheet) Write(w io.Writer) error { return s.wb.Write(w) }181116
@@ -468,7 +403,7 @@ func (s *Sheet) read(f fileSheet, version int) error {
468403 if !ok {469404 return fmt.Errorf("invalid cell %q", name)470405 }471- input, fm, st, err := decodeCell(raw)406+ input, fm, st, note, err := decodeNoted(raw)472407 if err != nil {473408 return fmt.Errorf("%s: %w", name, err)474409 }
@@ -478,7 +413,9 @@ func (s *Sheet) read(f fileSheet, version int) error {
478413 if err != nil {479414 return fmt.Errorf("%s: %w", name, err)480415 }481- s.place(a, c)416+ if c = c.withNote(CleanNote(note)); c != nil {417+ s.place(a, c)418+ }482419 }483420 for i, fc := range f.Charts {484421 c, err := decodeChart(fc)
M internal/sheet/lines.go +3 -3
A internal/sheet/notes.go +112 -0
@@ -0,0 +1,112 @@
1+package sheet2+3+import (4+ "strings"5+ "unicode"6+)7+8+// Notes are Sheets' notes (Insert > Note): plain text kept on a cell,9+// shown when the cell is active or hovered. A note is part of its cell10+// (Cell.Note), so it moves with the cell when rows are inserted, deleted11+// or sorted, copies with it, stays when the contents are cleared, and12+// every change to one is an undo step like any other change to a cell.13+14+// MaxNote caps a note's length in bytes, so a file or a paste can't15+// make one without bound.16+const MaxNote = 409617+18+// Note returns the note on the cell at a, or "".19+func (s *Sheet) Note(a Addr) string {20+ if c := s.cells.get(a); c != nil {21+ return c.Note22+ }23+ return ""24+}25+26+// CleanNote tidies a note as SetNote stores it: control characters27+// other than line breaks dropped, trailing space trimmed, at most28+// MaxNote bytes.29+func CleanNote(text string) string {30+ text = strings.ReplaceAll(text, "\r\n", "\n")31+ text = strings.Map(func(r rune) rune {32+ if r == '\n' {33+ return r34+ }35+ if unicode.IsControl(r) {36+ return -137+ }38+ return r39+ }, text)40+ text = strings.TrimRightFunc(text, unicode.IsSpace)41+ if len(text) > MaxNote {42+ text = strings.ToValidUTF8(text[:MaxNote], "")43+ }44+ return text45+}46+47+// SetNote sets the note on the cell at a as one undo step; an empty note48+// deletes it. A pivot table's results can't take notes: ErrPivotEdit.49+func (s *Sheet) SetNote(a Addr, text string) error {50+ if s.InPivot(Rect{From: a, To: a}) {51+ return ErrPivotEdit52+ }53+ text = CleanNote(text)54+ if s.Note(a) == text {55+ return nil56+ }57+ label := "note on " + a.String()58+ if text == "" {59+ label = "delete note on " + a.String()60+ }61+ s.change(label, Rect{From: a, To: a}, func() { s.putNote(a, text) })62+ return nil63+}64+65+// putNote stores a note without recording a step of its own.66+func (s *Sheet) putNote(a Addr, text string) {67+ old := s.cells.get(a)68+ c := old.clone()69+ if c == nil {70+ c = &Cell{}71+ }72+ c.Note = text73+ if c.Blank() && c.Format.IsZero() && c.Style.IsZero() && text == "" {74+ c = nil75+ }76+ s.place(a, c)77+}78+79+// LoadNote sets a note as a loader does: without recording undo.80+func (s *Sheet) LoadNote(a Addr, text string) {81+ if text = CleanNote(text); a.Valid() && text != "" {82+ s.putNote(a, text)83+ }84+}85+86+// NotesIn returns the cells in r that have notes, in row-major order.87+// It walks the cells the sheet holds, not r's addresses.88+func (s *Sheet) NotesIn(r Rect) []Addr {89+ var out []Addr90+ for a := range s.cells.anyInRange(r) {91+ if s.cells.get(a).Note != "" {92+ out = append(out, a)93+ }94+ }95+ sortAddrs(out)96+ return out97+}98+99+// ClearNotes deletes the notes in r as one undo step and returns how many100+// there were.101+func (s *Sheet) ClearNotes(r Rect) int {102+ notes := s.NotesIn(r)103+ if len(notes) == 0 {104+ return 0105+ }106+ s.change("delete notes in "+r.String(), r, func() {107+ for _, a := range notes {108+ s.putNote(a, "")109+ }110+ })111+ return len(notes)112+}
A internal/sheet/notes_test.go +104 -0
@@ -0,0 +1,104 @@
1+package sheet2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+)8+9+func TestNotes(t *testing.T) {10+ s := New()11+ s.Set(at("B2"), "10")12+ if err := s.SetNote(at("B2"), "Checked\r\nwith \x1bthe bank "); err != nil {13+ t.Fatal(err)14+ }15+ if got := s.Note(at("B2")); got != "Checked\nwith the bank" {16+ t.Errorf("note %q", got)17+ }18+ // Typing over the cell and clearing it keep the note, as in Sheets.19+ s.Set(at("B2"), "12")20+ s.EraseRange(rng("A1:C3"))21+ if got := s.Note(at("B2")); got != "Checked\nwith the bank" {22+ t.Errorf("after clearing: note %q", got)23+ }24+ // A note on a blank cell is a cell of its own, without contents.25+ s.SetNote(at("D5"), "Later")26+ if c := s.Cell(at("D5")); c == nil || !c.Blank() || c.Note != "Later" {27+ t.Errorf("blank noted cell %+v", c)28+ }29+ // Notes move with their rows.30+ s.InsertRows(0, 2)31+ if s.Note(at("B4")) == "" || s.Note(at("D7")) != "Later" {32+ t.Errorf("after inserting rows: %q %q", s.Note(at("B4")), s.Note(at("D7")))33+ }34+ if got := s.NotesIn(rng("A1:Z99")); len(got) != 2 || got[0] != at("B4") {35+ t.Errorf("NotesIn %v", got)36+ }37+ // Undo steps back one change at a time.38+ s.Undo()39+ if s.Note(at("D5")) != "Later" {40+ t.Errorf("undo insert: %q", s.Note(at("D5")))41+ }42+ s.Undo()43+ if s.Cell(at("D5")) != nil {44+ t.Errorf("undo note: %+v", s.Cell(at("D5")))45+ }46+ // Deleting the last note of a blank, plain cell removes the cell.47+ if n := s.ClearNotes(rng("A1:C3")); n != 1 || s.Cell(at("B2")) != nil {48+ t.Errorf("ClearNotes = %d, cell %+v", n, s.Cell(at("B2")))49+ }50+}51+52+func TestNotesCopyAndFill(t *testing.T) {53+ s := New()54+ s.Set(at("A1"), "1")55+ s.SetNote(at("A1"), "Source")56+ s.SetNote(at("B1"), "Mine")57+ if _, err := s.Paste(s.Copy(rng("A1")), rng("A2"), false); err != nil {58+ t.Fatal(err)59+ }60+ if s.Note(at("A2")) != "Source" {61+ t.Errorf("pasted note %q", s.Note(at("A2")))62+ }63+ // Ctrl+Enter fills contents, each cell keeping its own note.64+ s.FillEntry(rng("A1:B1"), at("A1"), "7")65+ if s.Note(at("A1")) != "Source" || s.Note(at("B1")) != "Mine" || s.Value(at("B1")).Num != 7 {66+ t.Errorf("fill: %q %q %v", s.Note(at("A1")), s.Note(at("B1")), s.Value(at("B1")))67+ }68+}69+70+func TestNotesInPivotRefused(t *testing.T) {71+ s := New()72+ for a, v := range map[string]string{"A1": "Region", "B1": "Sales", "A2": "East", "B2": "5"} {73+ s.Set(at(a), v)74+ }75+ p, err := s.Book().CreatePivot(s, rng("A1:B2"), "", NewPivot(s, rng("A1:B2")))76+ if err != nil {77+ t.Fatal(err)78+ }79+ out, _ := p.PivotRange()80+ if err := p.SetNote(out.From, "x"); err != ErrPivotEdit {81+ t.Errorf("note on pivot results: %v", err)82+ }83+}84+85+func TestNotesFile(t *testing.T) {86+ s := New()87+ s.Set(at("A1"), "Rent")88+ s.SetNote(at("A1"), "Due on\nthe 1st")89+ s.SetNote(at("C3"), "Blank")90+ var b bytes.Buffer91+ if err := s.Write(&b); err != nil {92+ t.Fatal(err)93+ }94+ if !strings.Contains(b.String(), `"A1": {"input":"Rent","note":"Due on\nthe 1st"}`) {95+ t.Errorf("file:\n%s", b.String())96+ }97+ got, err := Read(&b)98+ if err != nil {99+ t.Fatal(err)100+ }101+ if got.Note(at("A1")) != "Due on\nthe 1st" || got.Note(at("C3")) != "Blank" || got.Value(at("A1")).Str != "Rent" {102+ t.Errorf("read back %q %q", got.Note(at("A1")), got.Note(at("C3")))103+ }104+}
M internal/sheet/ops.go +16 -1
A internal/sheet/protect.go +164 -0
@@ -0,0 +1,164 @@
1+package sheet2+3+import (4+ "fmt"5+ "slices"6+7+ "github.com/FelineStateMachine/012/internal/formula"8+)9+10+// Protected ranges and sheets follow Sheets' Data > Protect sheets and11+// ranges in its "Show a warning when editing this range" mode: nothing is12+// locked, but an edit that touches one asks first. The engine only keeps13+// them (saved with the sheet, following inserted and deleted rows and14+// columns, every change an undo step); the UI asks.15+16+// Protection is a protected range, or the whole sheet.17+type Protection struct {18+ Range Rect // the whole grid when Sheet is set19+ Sheet bool // the whole sheet is protected20+ Desc string // what the user wrote to describe it, may be ""21+}22+23+// Label names the protection as the UI shows it: its description, or its24+// range, or "the sheet".25+func (p Protection) Label() string {26+ switch {27+ case p.Desc != "":28+ return p.Desc29+ case p.Sheet:30+ return "the sheet"31+ }32+ return p.Range.String()33+}34+35+// Protections returns the sheet's protected ranges, in the order added.36+func (s *Sheet) Protections() []Protection { return slices.Clone(s.view.protected) }37+38+// Protecting returns the first protection r overlaps.39+func (s *Sheet) Protecting(r Rect) (Protection, bool) {40+ for _, p := range s.view.protected {41+ if p.Sheet || overlaps(p.Range, r) {42+ return p, true43+ }44+ }45+ return Protection{}, false46+}47+48+// Protect adds a protected range, or protects the whole sheet, as one49+// undo step. Protecting the sheet again replaces its description.50+func (s *Sheet) Protect(p Protection) {51+ if p.Sheet {52+ p.Range = Rect{To: Addr{Col: MaxCols - 1, Row: MaxRows - 1}}53+ }54+ next := slices.Clone(s.view.protected)55+ label := "protect " + p.Range.String()56+ if p.Sheet {57+ label = "protect sheet " + s.name58+ next = slices.DeleteFunc(next, func(q Protection) bool { return q.Sheet })59+ }60+ s.setProtected(label, p.Range, append(next, p))61+}62+63+// Unprotect removes protection i (in Protections' order).64+func (s *Sheet) Unprotect(i int) {65+ if i < 0 || i >= len(s.view.protected) {66+ return67+ }68+ p := s.view.protected[i]69+ next := slices.Delete(slices.Clone(s.view.protected), i, i+1)70+ s.setProtected("remove protection of "+p.Label(), p.Range, next)71+}72+73+// UnprotectRange removes every protection r overlaps, the sheet's74+// included, as one undo step, and returns how many there were.75+func (s *Sheet) UnprotectRange(r Rect) int {76+ next := slices.DeleteFunc(slices.Clone(s.view.protected), func(p Protection) bool {77+ return p.Sheet || overlaps(p.Range, r)78+ })79+ n := len(s.view.protected) - len(next)80+ if n > 0 {81+ s.setProtected("remove protection", r, next)82+ }83+ return n84+}85+86+func (s *Sheet) setProtected(label string, focus Rect, next []Protection) {87+ if len(next) == 0 {88+ next = nil89+ }90+ s.change(label, focus, func() {91+ s.recordView()92+ s.view.protected = next93+ })94+}95+96+// LoadProtection adds a protection as a loader does: without recording97+// undo.98+func (s *Sheet) LoadProtection(p Protection) {99+ if p.Sheet {100+ p.Range = Rect{To: Addr{Col: MaxCols - 1, Row: MaxRows - 1}}101+ }102+ s.view.protected = append(slices.Clone(s.view.protected), p)103+}104+105+// shiftProtected moves protected ranges with inserted or deleted rows or106+// columns, as a range reference moves; one whose cells are all deleted107+// goes.108+func shiftProtected(ps []Protection, rows bool, sp formula.Span) []Protection {109+ if len(ps) == 0 {110+ return ps111+ }112+ _, rng := formula.AxisMaps(rows, sp)113+ var out []Protection114+ for _, p := range ps {115+ if !p.Sheet {116+ r, ok := rng(p.Range)117+ if !ok {118+ continue119+ }120+ p.Range = r121+ }122+ out = append(out, p)123+ }124+ return out125+}126+127+// fileProtection is a protection in the file: {"range": "B2:C9",128+// "description": "Totals"}, or {"sheet": true}. Earlier builds ignore129+// the field, so it needs no version bump; they simply don't warn.130+type fileProtection struct {131+ Range string `json:"range,omitempty"`132+ Sheet bool `json:"sheet,omitempty"`133+ Desc string `json:"description,omitempty"`134+}135+136+func encodeProtections(ps []Protection) []fileProtection {137+ var out []fileProtection138+ for _, p := range ps {139+ fp := fileProtection{Sheet: p.Sheet, Desc: p.Desc}140+ if !p.Sheet {141+ fp.Range = p.Range.String()142+ }143+ out = append(out, fp)144+ }145+ return out146+}147+148+func decodeProtections(fps []fileProtection) ([]Protection, error) {149+ var out []Protection150+ for _, fp := range fps {151+ p := Protection{Sheet: fp.Sheet, Desc: fp.Desc}152+ if p.Sheet {153+ p.Range = Rect{To: Addr{Col: MaxCols - 1, Row: MaxRows - 1}}154+ } else {155+ r, ok := ParseRange(fp.Range)156+ if !ok {157+ return nil, fmt.Errorf("invalid protected range %q", fp.Range)158+ }159+ p.Range = r160+ }161+ out = append(out, p)162+ }163+ return out, nil164+}
A internal/sheet/protect_test.go +73 -0
@@ -0,0 +1,73 @@
1+package sheet2+3+import (4+ "bytes"5+ "strings"6+ "testing"7+)8+9+func TestProtect(t *testing.T) {10+ s := New()11+ s.Protect(Protection{Range: rng("B2:C4"), Desc: "Totals"})12+ if p, ok := s.Protecting(rng("C4:D9")); !ok || p.Desc != "Totals" {13+ t.Errorf("Protecting overlap = %+v, %v", p, ok)14+ }15+ if _, ok := s.Protecting(rng("A1:A9")); ok {16+ t.Error("A1:A9 protected")17+ }18+ // Protections follow inserted and deleted rows, and undo.19+ s.InsertRows(0, 1)20+ if got := s.Protections(); len(got) != 1 || got[0].Range != rng("B3:C5") {21+ t.Errorf("after insert %+v", got)22+ }23+ s.DeleteRows(2, 3)24+ if got := s.Protections(); len(got) != 0 {25+ t.Errorf("all rows deleted, still %+v", got)26+ }27+ s.Undo()28+ s.Undo()29+ if got := s.Protections(); len(got) != 1 || got[0].Range != rng("B2:C4") {30+ t.Errorf("after undo %+v", got)31+ }32+ // The whole sheet: everything is protected, once.33+ s.Protect(Protection{Sheet: true})34+ s.Protect(Protection{Sheet: true, Desc: "Final"})35+ if got := s.Protections(); len(got) != 2 || got[1].Label() != "Final" {36+ t.Errorf("sheet protections %+v", got)37+ }38+ if _, ok := s.Protecting(rng("Z99")); !ok {39+ t.Error("sheet protection doesn't cover Z99")40+ }41+ if n := s.UnprotectRange(rng("A1")); n != 1 {42+ t.Errorf("UnprotectRange(A1) = %d", n)43+ }44+ s.Unprotect(0)45+ if len(s.Protections()) != 0 {46+ t.Errorf("left %+v", s.Protections())47+ }48+ s.Undo()49+ if len(s.Protections()) != 1 {50+ t.Errorf("undo unprotect %+v", s.Protections())51+ }52+}53+54+func TestProtectFile(t *testing.T) {55+ s := New()56+ s.Set(at("A1"), "x")57+ s.Protect(Protection{Range: rng("A1:B2"), Desc: "Inputs"})58+ s.Protect(Protection{Sheet: true})59+ var b bytes.Buffer60+ if err := s.Write(&b); err != nil {61+ t.Fatal(err)62+ }63+ if !strings.Contains(b.String(), `"protected": [{"range":"A1:B2","description":"Inputs"},{"sheet":true}]`) || !strings.Contains(b.String(), `"version": 2`) {64+ t.Errorf("file:\n%s", b.String())65+ }66+ got, err := Read(&b)67+ if err != nil {68+ t.Fatal(err)69+ }70+ if ps := got.Protections(); len(ps) != 2 || ps[0].Range != rng("A1:B2") || !ps[1].Sheet {71+ t.Errorf("read back %+v", ps)72+ }73+}
M internal/sheet/sheet.go +29 -4
@@ -31,6 +31,11 @@ type Cell struct {
3131 Format Format3232 Style Style3333 34+ // Note is the cell's note, as Sheets' Insert > Note: text shown when35+ // the cell is active or hovered. Like formatting, it survives clearing36+ // the contents, and it moves and copies with the cell.37+ Note string38+3439 auto Format // format inferred from a formula, shown when Format is Automatic3540 expr Node // nil for text3641 refs []Addr // single-cell references in expr to its own sheet
@@ -226,8 +231,9 @@ func (s *Sheet) Set(a Addr, input string) error {
226231 func (s *Sheet) put(a Addr, input string) error {227232 var f Format228233 var st Style234+ var note string229235 if old := s.cells.get(a); old != nil {230- f, st = old.Format, old.Style236+ f, st, note = old.Format, old.Style, old.Note231237 }232238 if f.IsZero() && !st.own && s.inherited(a).Format.Kind == FmtText {233239 f = Format{Kind: FmtText} // typed into a plain text column: text
@@ -236,7 +242,7 @@ func (s *Sheet) put(a Addr, input string) error {
236242 if err != nil {237243 return err238244 }239- s.place(a, c)245+ s.place(a, c.withNote(note))240246 return nil241247 }242248
@@ -280,6 +286,25 @@ func formattingOnly(f Format, st Style) *Cell {
280286 return &Cell{Format: f, Style: st}281287 }282288 289+// leftover is what clearing c's contents leaves: its formatting and its290+// note, or nil when there are neither.291+func (c *Cell) leftover() *Cell {292+ return formattingOnly(c.Format, c.Style).withNote(c.Note)293+}294+295+// withNote returns c with note, making a blank cell to hold it if c is296+// nil. c must be a cell not yet placed.297+func (c *Cell) withNote(note string) *Cell {298+ if note == "" {299+ return c300+ }301+ if c == nil {302+ c = &Cell{}303+ }304+ c.Note = note305+ return c306+}307+283308 // setExpr sets c's expression and the references indexed from it.284309 func (c *Cell) setExpr(n Node) {285310 c.expr, c.refs, c.ranges, c.xrefs, c.names, c.volatile = n, nil, nil, nil, nil, false
@@ -336,12 +361,12 @@ func (s *Sheet) place(a Addr, c *Cell) {
336361 }337362 338363 // EraseRange clears the contents of every cell in r, keeping their339-// formatting as Sheets' Delete does.364+// formatting and notes as Sheets' Delete does.340365 func (s *Sheet) EraseRange(r Rect) {341366 s.change("clear "+r.String(), r, func() {342367 for _, a := range s.cellsIn(r) {343368 if c := s.cells.get(a); !c.Blank() {344- s.place(a, formattingOnly(c.Format, c.Style))369+ s.place(a, c.leftover())345370 }346371 }347372 })
M internal/sheet/view.go +28 -12
@@ -5,16 +5,18 @@ import (
55 "encoding/json"66 "fmt"77 "reflect"8+ "slices"89 910 "github.com/FelineStateMachine/012/internal/formula"1011 )1112 1213 // View state that belongs to the worksheet rather than to a cell: frozen13-// rows and columns, and the filter. It is saved in the file and every14-// change to it is an undo step, as in Sheets.14+// rows and columns, the filter and the protected ranges. It is saved in15+// the file and every change to it is an undo step, as in Sheets.1516 type viewState struct {1617 frozenRows, frozenCols int17- filter *Filter // never modified in place; replaced whole18+ filter *Filter // never modified in place; replaced whole19+ protected []Protection // see protect.go; replaced whole too1820 }1921 2022 // Frozen returns how many rows and columns are frozen at the top and left.
@@ -75,15 +77,18 @@ func (s *Sheet) recordView() {
7577 7678 func (v viewState) equal(w viewState) bool {7779 return v.frozenRows == w.frozenRows && v.frozenCols == w.frozenCols &&78- (v.filter == w.filter || reflect.DeepEqual(v.filter, w.filter))80+ (v.filter == w.filter || reflect.DeepEqual(v.filter, w.filter)) &&81+ slices.Equal(v.protected, w.protected)7982 }8083 81-// shiftView keeps frozen lines and the filter in step with inserted or82-// deleted rows or columns: inserting inside the frozen area freezes the83-// new lines too, and the filter's range grows, shrinks or moves like a84-// range reference (and goes away when all of it is deleted).84+// shiftView keeps frozen lines, the filter and protected ranges in step85+// with inserted or deleted rows or columns: inserting inside the frozen86+// area freezes the new lines too, and the filter's and protections'87+// ranges grow, shrink or move like range references (and go away when88+// all of one is deleted).8589 func (s *Sheet) shiftView(rows bool, sp formula.Span) {8690 v := s.view91+ v.protected = shiftProtected(v.protected, rows, sp)8792 frozen := &v.frozenCols8893 if rows {8994 frozen = &v.frozenRows
@@ -125,8 +130,9 @@ func (s *Sheet) shiftView(rows bool, sp formula.Span) {
125130 // "freeze": {"rows": 1},126131 // "filter": {"range": "A1:C20", "columns": {"B": {"condition": "gt", "value": "100"}}},127132 type fileView struct {128- Freeze *fileFreeze `json:"freeze,omitempty"`129- Filter *fileFilter `json:"filter,omitempty"`133+ Freeze *fileFreeze `json:"freeze,omitempty"`134+ Filter *fileFilter `json:"filter,omitempty"`135+ Protected []fileProtection `json:"protected,omitempty"`130136 }131137 132138 type fileFreeze struct {
@@ -145,7 +151,8 @@ type fileCriteria struct {
145151 Value string `json:"value,omitempty"`146152 }147153 148-// writeView adds the frozen panes and the filter to a file being written.154+// writeView adds the frozen panes, the filter and the protected ranges to155+// a file being written.149156 func (s *Sheet) writeView(b *bytes.Buffer, indent string) error {150157 var keys []string151158 var parts []any
@@ -159,6 +166,9 @@ func (s *Sheet) writeView(b *bytes.Buffer, indent string) error {
159166 }160167 keys, parts = append(keys, "filter"), append(parts, ff)161168 }169+ if ps := s.view.protected; len(ps) > 0 {170+ keys, parts = append(keys, "protected"), append(parts, encodeProtections(ps))171+ }162172 for i, p := range parts {163173 raw, err := json.Marshal(p)164174 if err != nil {
@@ -169,8 +179,14 @@ func (s *Sheet) writeView(b *bytes.Buffer, indent string) error {
169179 return nil170180 }171181 172-// readView restores the frozen panes and the filter from a file.182+// readView restores the frozen panes, the filter and the protected183+// ranges from a file.173184 func (s *Sheet) readView(fv fileView) error {185+ ps, err := decodeProtections(fv.Protected)186+ if err != nil {187+ return err188+ }189+ s.view.protected = ps174190 if fz := fv.Freeze; fz != nil {175191 s.view.frozenRows, s.view.frozenCols = clampInt(fz.Rows, 0, MaxFrozen), clampInt(fz.Cols, 0, MaxFrozen)176192 }