PathrockNetwork Gno Explorer
HomeBlocksTransactionsTokensRealmsPackagesValidatorsAnalytics

PathrockNetwork Gno Explorer — an independent explorer for Gno.land Mainnet (gnoland-1), operated by PathrockNetwork. Not an official Gno.land service.

gnowebarchive RPC

gno.land/p/moul/x/wiki/v0

Package
Open in gnoweb ↗

Overview

Kind
Pure package
Name
v0
Namespace
moul / x / wiki
Files
18 (README)(gnomod.toml)
Exported functions
n/a — not supported for pure packages by the node (vm/qfuncs)
Module
gno.land/p/moul/x/wiki/v0
gno
0.9

Files (18)

  • README.mdmarkdown
  • gnomod.tomltoml
  • diff.gnogno
  • doc.gnogno
  • links.gnogno
  • page.gnogno
  • render.gnogno
  • revision.gnogno
  • talk.gnogno
  • title.gnogno
  • wiki.gnogno
  • diff_test.gnogno
  • limits_test.gnogno
  • links_test.gnogno
  • render_test.gnogno
  • talk_test.gnogno
  • title_test.gnogno
  • wiki_test.gnogno
  • wiki.gnogno
    1package wiki23import (4	"errors"5	"time"67	"gno.land/p/moul/fifo/v0"8

    Functions

    not supported for pure packages by the node (vm/qfuncs)

    Signatures reconstructed verbatim from vm/qfuncs — interface params keep their inline definitions.

    "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}
    513