Search Apps Documentation Source Content File Folder Download Copy Actions Download State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

wiki.gno

14.24 Kb · 512 lines
  1package wiki
  2
  3import (
  4	"errors"
  5	"time"
  6
  7	"gno.land/p/moul/fifo/v0"
  8	"gno.land/p/moul/ulist/v0"
  9	"gno.land/p/nt/avl/v0"
 10)
 11
 12// Defaults for New. Retention is the number of most-recent revisions per page
 13// whose body stays on chain; MaxBody caps a single revision.
 14const (
 15	DefaultRetention = 3
 16	DefaultMaxBody   = 32 << 10 // 32 KiB
 17	DefaultRecent    = 50
 18	MaxSummaryLen    = 200
 19)
 20
 21var (
 22	ErrNoSuchPage     = errors.New("wiki: no such page")
 23	ErrPageExists     = errors.New("wiki: page already exists")
 24	ErrBodyTooLarge   = errors.New("wiki: body exceeds the size limit")
 25	ErrEmptyBody      = errors.New("wiki: empty body")
 26	ErrNoChange       = errors.New("wiki: body is identical to the current revision")
 27	ErrSummaryTooLong = errors.New("wiki: edit summary too long")
 28	ErrBadProtection  = errors.New("wiki: unknown protection level")
 29	ErrNoSuchRevision = errors.New("wiki: no such revision")
 30	ErrBodyEvicted    = errors.New("wiki: that revision's body is no longer held on chain")
 31	ErrSpecial        = errors.New("wiki: the Special namespace is generated, not stored")
 32)
 33
 34// Wiki is the whole encyclopedia: pages, the indexes derived from them, and
 35// the recent-changes feed.
 36//
 37// Every mutating method takes author, now and height from the caller instead
 38// of reading them from the chain, so the engine has no chain imports and the
 39// tests drive time and height explicitly.
 40type Wiki struct {
 41	pages     *avl.Tree // Title.Key() -> *Page
 42	backlinks *avl.Tree // target Title.Key() -> *avl.Tree (source key -> Title)
 43	cats      *avl.Tree // category Title.Key() -> *avl.Tree (member key -> Title)
 44	talk      *avl.Tree // Title.Key() -> *ulist.List of *Comment (see talk.gno)
 45	recent    *fifo.List
 46
 47	nextRev     uint64
 48	nextComment uint64
 49	retention int
 50	maxBody   int
 51
 52	numPages    int // pages with at least one revision and not blanked
 53	numRevs     int
 54	numComments int
 55	bytesHeld   int // retained body and comment bytes, the realm's visible rent
 56}
 57
 58// New returns an empty wiki. A retention or maxBody below 1 falls back to the
 59// package default.
 60func New(retention, maxBody int) *Wiki {
 61	if retention < 1 {
 62		retention = DefaultRetention
 63	}
 64	if maxBody < 1 {
 65		maxBody = DefaultMaxBody
 66	}
 67	return &Wiki{
 68		pages:     avl.NewTree(),
 69		backlinks: avl.NewTree(),
 70		cats:      avl.NewTree(),
 71		talk:      avl.NewTree(),
 72		recent:    fifo.New(DefaultRecent),
 73		retention: retention,
 74		maxBody:   maxBody,
 75	}
 76}
 77
 78// Stats is a snapshot of the wiki's size and of what it is paying for.
 79type Stats struct {
 80	Pages     int
 81	Revisions int
 82	Comments  int
 83	BytesHeld int // bytes of article text and comments currently on chain
 84	Retention int
 85}
 86
 87// Stats returns the current counters.
 88func (w *Wiki) Stats() Stats {
 89	return Stats{
 90		Pages:     w.numPages,
 91		Revisions: w.numRevs,
 92		Comments:  w.numComments,
 93		BytesHeld: w.bytesHeld,
 94		Retention: w.retention,
 95	}
 96}
 97
 98// Page returns the page stored at raw, without following redirects.
 99func (w *Wiki) Page(raw string) (*Page, error) {
100	t, err := ParseTitle(raw)
101	if err != nil {
102		return nil, err
103	}
104	return w.PageByTitle(t)
105}
106
107// PageByTitle is Page for an already-parsed title.
108func (w *Wiki) PageByTitle(t Title) (*Page, error) {
109	v := w.pages.Get(t.Key())
110	if v == nil {
111		return nil, ErrNoSuchPage
112	}
113	return v.(*Page), nil
114}
115
116// Exists reports whether a title has a page. A blanked page still exists: its
117// history is the point of the wiki.
118func (w *Wiki) Exists(t Title) bool { return w.pages.Has(t.Key()) }
119
120// Resolve follows at most one redirect hop and returns the destination page
121// along with the page that was asked for. MediaWiki also stops at one hop:
122// chains are a vandalism vector and a loop is unrenderable.
123func (w *Wiki) Resolve(raw string) (dest, asked *Page, err error) {
124	p, err := w.Page(raw)
125	if err != nil {
126		return nil, nil, err
127	}
128	if p.redirect == "" {
129		return p, p, nil
130	}
131	target, err := w.Page(p.redirect)
132	if err != nil {
133		return p, p, nil // dangling redirect: render the stub itself
134	}
135	return target, p, nil
136}
137
138// Edit writes a new revision. It does not check authority: the caller decides
139// who may write, using Page.Protection and whatever roster it keeps. Passing a
140// body byte-identical to the current one is an error, so a no-op edit cannot
141// be used to spam the history or the recent-changes feed.
142func (w *Wiki) Edit(author address, now time.Time, height int64, raw, body, summary string, minor bool) (*Revision, error) {
143	t, err := ParseTitle(raw)
144	if err != nil {
145		return nil, err
146	}
147	if t.NS == NSSpecial {
148		return nil, ErrSpecial
149	}
150	if body == "" {
151		return nil, ErrEmptyBody
152	}
153	if len(body) > w.maxBody {
154		return nil, ErrBodyTooLarge
155	}
156	if len(summary) > MaxSummaryLen {
157		return nil, ErrSummaryTooLong
158	}
159
160	p, _ := w.PageByTitle(t)
161	kind := KindEdit
162	if p == nil {
163		kind = KindCreate
164	} else if p.head != nil && p.head.Hash == hashBody(body) {
165		return nil, ErrNoChange
166	}
167	return w.commit(p, t, author, now, height, body, summary, kind, minor), nil
168}
169
170// Revert restores the body of an earlier revision as a new revision, the way
171// a wiki undo works: the vandalized revision stays in the history, it is just
172// no longer current. It fails if that revision's body has aged out of the
173// retention window, which is the honest failure mode of a bounded history.
174func (w *Wiki) Revert(author address, now time.Time, height int64, raw string, revID uint64, summary string) (*Revision, error) {
175	p, err := w.Page(raw)
176	if err != nil {
177		return nil, err
178	}
179	old := p.Revision(revID)
180	if old == nil {
181		return nil, ErrNoSuchRevision
182	}
183	body, ok := old.Body()
184	if !ok {
185		return nil, ErrBodyEvicted
186	}
187	if p.head != nil && p.head.Hash == old.Hash {
188		return nil, ErrNoChange
189	}
190	if len(summary) > MaxSummaryLen {
191		return nil, ErrSummaryTooLong
192	}
193	return w.commit(p, p.Title, author, now, height, body, summary, KindRevert, false), nil
194}
195
196// Blank replaces a page's content with a tombstone revision. It is the
197// deletion a chain can honestly offer: the page stops rendering and stops
198// costing rent as its bodies age out, while the revision spine stays as proof
199// that something was there and who removed it. Use Purge to release the
200// retained bytes immediately.
201func (w *Wiki) Blank(author address, now time.Time, height int64, raw, reason string) (*Revision, error) {
202	p, err := w.Page(raw)
203	if err != nil {
204		return nil, err
205	}
206	if len(reason) > MaxSummaryLen {
207		return nil, ErrSummaryTooLong
208	}
209	rev := w.commit(p, p.Title, author, now, height, "", reason, KindBlank, false)
210	if !p.Blanked {
211		p.Blanked = true
212		w.numPages--
213	}
214	return rev, nil
215}
216
217// Purge evicts every retained body of a page immediately and returns the
218// number of bytes released. The spine, including each body's hash, is
219// untouched. This is the lever for content that must stop being served from
220// realm state; it cannot and does not remove the transactions that wrote it.
221func (w *Wiki) Purge(raw string) (int, error) {
222	p, err := w.Page(raw)
223	if err != nil {
224		return 0, err
225	}
226	released := 0
227	p.revs.Iterator(0, p.revs.Size()-1, func(_ int, v any) bool {
228		released += v.(*Revision).evict()
229		return false
230	})
231	w.bytesHeld -= released
232	return released, nil
233}
234
235// Move renames a page, keeping its history, and leaves a redirect behind at
236// the old title so existing links keep resolving.
237func (w *Wiki) Move(author address, now time.Time, height int64, from, to, summary string) error {
238	src, err := w.Page(from)
239	if err != nil {
240		return err
241	}
242	dst, err := ParseTitle(to)
243	if err != nil {
244		return err
245	}
246	if dst.NS == NSSpecial {
247		return ErrSpecial
248	}
249	if w.Exists(dst) {
250		return ErrPageExists
251	}
252
253	old := src.Title
254	w.unindex(src)
255	w.pages.Remove(old.Key())
256	src.Title = dst
257	w.pages.Set(dst.Key(), src)
258	w.index(src)
259
260	w.pushRecent(src.Title, w.metaRevision(src, author, now, height, "moved from "+old.String(), KindMove))
261
262	stub := "#REDIRECT [[" + dst.String() + "]]\n"
263	w.commit(nil, old, author, now, height, stub, "moved to "+dst.String(), KindMove, false)
264	return nil
265}
266
267// SetProtection changes a page's edit gate.
268func (w *Wiki) SetProtection(author address, now time.Time, height int64, raw, level string) error {
269	p, err := w.Page(raw)
270	if err != nil {
271		return err
272	}
273	lvl, err := ParseProtection(level)
274	if err != nil {
275		return err
276	}
277	p.Protection = lvl
278	w.pushRecent(p.Title, w.metaRevision(p, author, now, height, "protection: "+lvl.String(), KindProtect))
279	return nil
280}
281
282// metaRevision records an event that changed a page without changing its text
283// (a move, a protection change). It carries no body of its own and inherits
284// the current revision's hash and size, so the history reads as "this is still
285// the same text" instead of "the article was blanked".
286func (w *Wiki) metaRevision(p *Page, author address, now time.Time, height int64, summary string, kind Kind) *Revision {
287	rev := w.newRevision(p, author, now, height, "", summary, kind, true)
288	rev.kept = false
289	if p.head != nil {
290		rev.Hash = p.head.Hash
291		rev.Size = p.head.Size
292	}
293	return rev
294}
295
296// commit appends a revision, re-derives the page's indexes from the new body
297// and enforces the retention window. p may be nil, in which case the page is
298// created at t.
299func (w *Wiki) commit(p *Page, t Title, author address, now time.Time, height int64, body, summary string, kind Kind, minor bool) *Revision {
300	if p == nil {
301		p = &Page{Title: t, Created: now, revs: ulist.New()}
302		w.pages.Set(t.Key(), p)
303		w.numPages++
304	} else {
305		w.unindex(p)
306		if p.Blanked && body != "" {
307			p.Blanked = false
308			w.numPages++
309		}
310	}
311
312	rev := w.newRevision(p, author, now, height, body, summary, kind, minor)
313	p.head = rev
314	w.reindex(p, body)
315	w.enforceRetention(p)
316	w.pushRecent(p.Title, rev)
317	return rev
318}
319
320// newRevision allocates the next revision, appends it and accounts its bytes.
321func (w *Wiki) newRevision(p *Page, author address, now time.Time, height int64, body, summary string, kind Kind, minor bool) *Revision {
322	w.nextRev++
323	prev := uint64(0)
324	if n := p.revs.Size(); n > 0 {
325		prev = p.revs.MustGet(n - 1).(*Revision).ID
326	}
327	rev := &Revision{
328		ID:      w.nextRev,
329		Prev:    prev,
330		Kind:    kind,
331		Author:  author,
332		Time:    now,
333		Height:  height,
334		Summary: summary,
335		Hash:    hashBody(body),
336		Size:    len(body),
337		Minor:   minor,
338		body:    body,
339		kept:    true,
340	}
341	p.revs.Append(rev)
342	w.numRevs++
343	w.bytesHeld += len(body)
344	return rev
345}
346
347// enforceRetention evicts the body that just fell out of the window.
348//
349// It counts only revisions that still hold a body, so bodyless move and
350// protection entries do not push real text out of the window early. One
351// eviction per commit is enough because the window only ever moves by one, and
352// the backward walk is bounded by scanLimit so a page with a long run of meta
353// revisions cannot make a single edit O(history).
354func (w *Wiki) enforceRetention(p *Page) {
355	const scanLimit = 64
356	kept, scanned := 0, 0
357	p.revs.Iterator(p.revs.Size()-1, 0, func(_ int, v any) bool {
358		scanned++
359		r := v.(*Revision)
360		if !r.kept {
361			return scanned >= scanLimit
362		}
363		kept++
364		if kept > w.retention {
365			w.bytesHeld -= r.evict()
366			return true
367		}
368		return scanned >= scanLimit
369	})
370}
371
372func (w *Wiki) pushRecent(t Title, r *Revision) {
373	w.recent.Prepend(&Change{Title: t, Rev: r})
374}
375
376// Recent returns up to n changes, newest first.
377func (w *Wiki) Recent(n int) []*Change {
378	out := []*Change{}
379	for _, e := range w.recent.Entries() {
380		if len(out) >= n {
381			break
382		}
383		out = append(out, e.(*Change))
384	}
385	return out
386}
387
388// reindex derives the page's redirect target, outgoing links and categories
389// from body, and writes them into the wiki-wide indexes.
390func (w *Wiki) reindex(p *Page, body string) {
391	p.redirect = redirectTarget(body)
392	p.links = nil
393	p.cats = nil
394
395	for _, raw := range ScanLinks(body, "[[", "]]") {
396		t, err := ParseTitle(raw.Target)
397		if err != nil || t == p.Title {
398			continue
399		}
400		if t.NS == NSCategory && !raw.Explicit {
401			p.cats = appendUnique(p.cats, t.Key())
402			continue
403		}
404		p.links = appendUnique(p.links, t.Key())
405	}
406	w.index(p)
407}
408
409// index adds the page to every index its current content implies.
410func (w *Wiki) index(p *Page) {
411	for _, key := range p.links {
412		addTo(w.backlinks, key, p)
413	}
414	for _, key := range p.cats {
415		addTo(w.cats, key, p)
416	}
417}
418
419// unindex removes the page from every index, so the next reindex starts clean.
420func (w *Wiki) unindex(p *Page) {
421	for _, key := range p.links {
422		removeFrom(w.backlinks, key, p.Title.Key())
423	}
424	for _, key := range p.cats {
425		removeFrom(w.cats, key, p.Title.Key())
426	}
427}
428
429func addTo(idx *avl.Tree, key string, p *Page) {
430	v := idx.Get(key)
431	var set *avl.Tree
432	if v == nil {
433		set = avl.NewTree()
434		idx.Set(key, set)
435	} else {
436		set = v.(*avl.Tree)
437	}
438	set.Set(p.Title.Key(), p.Title)
439}
440
441func removeFrom(idx *avl.Tree, key, member string) {
442	v := idx.Get(key)
443	if v == nil {
444		return
445	}
446	set := v.(*avl.Tree)
447	set.Remove(member)
448	if set.Size() == 0 {
449		idx.Remove(key)
450	}
451}
452
453func members(idx *avl.Tree, key string) []Title {
454	out := []Title{}
455	v := idx.Get(key)
456	if v == nil {
457		return out
458	}
459	v.(*avl.Tree).Iterate("", "", func(_ string, val any) bool {
460		out = append(out, val.(Title))
461		return false
462	})
463	return out
464}
465
466// Backlinks returns the titles whose current revision links to t, in key
467// order. Redlinks are indexed too, so a page created later immediately knows
468// who was already pointing at it.
469func (w *Wiki) Backlinks(t Title) []Title { return members(w.backlinks, t.Key()) }
470
471// CategoryMembers returns the pages that declare [[Category:name]].
472func (w *Wiki) CategoryMembers(t Title) []Title { return members(w.cats, t.Key()) }
473
474// Categories returns every category that has at least one member.
475func (w *Wiki) Categories() []Title {
476	out := []Title{}
477	w.cats.Iterate("", "", func(key string, _ any) bool {
478		out = append(out, Title{NS: NSCategory, Name: key[len(NSCategory.Prefix()):]})
479		return false
480	})
481	return out
482}
483
484// Titles returns up to count page titles in key order, skipping offset of
485// them. An empty ns prefix walks every namespace.
486func (w *Wiki) Titles(prefix string, offset, count int) []Title {
487	out := []Title{}
488	skipped := 0
489	w.pages.Iterate(prefix, "", func(key string, v any) bool {
490		if prefix != "" && !hasPrefix(key, prefix) {
491			return true
492		}
493		if skipped < offset {
494			skipped++
495			return false
496		}
497		out = append(out, v.(*Page).Title)
498		return len(out) >= count
499	})
500	return out
501}
502
503func hasPrefix(s, p string) bool { return len(s) >= len(p) && s[:len(p)] == p }
504
505func appendUnique(list []string, v string) []string {
506	for _, e := range list {
507		if e == v {
508			return list
509		}
510	}
511	return append(list, v)
512}