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

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

wiki.gno

14.26 Kb · 512 lines
  1package wiki
  2
  3import (
  4	"errors"
  5	"time"
  6
  7	"gno.land/p/nym-moultest123/fifo/v0"
  8	"gno.land/p/nym-moultest123/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}