Modul:UserGroups

aus Wikipedia, der freien Enzyklopädie
Zur Navigation springen Zur Suche springen
Vorlagenprogrammierung Diskussionen Lua Unterseiten
Modul Deutsch English

Modul: Dokumentation

Diese Seite enthält Code in der Programmiersprache Lua. Einbindungszahl Cirrus

Dies ist die (produktive) Mutterversion eines global benutzten Lua-Moduls.
Wenn die serial-Information nicht übereinstimmt, müsste eine Kopie hiervon in das lokale Wiki geschrieben werden.
Versionsbezeichnung auf WikiData: 2022-12-12

local UserGroups = { suite  = "userGroups",
                     serial = "2022-12-12",
                     setup  = ".json",
                     item   = 115663308 }
--[=[
Administration of users with special properties like sysop or bureaucrat
]=]
local Failsafe  = UserGroups



UserGroups.I18N = {
    aliasFormer         = { en = "previously" },
    aliasLater          = { en = "later" },
    dataSource          = { en = "Data source" },
    entryCount          = { en = "$1 entries" },
    errAccountBad       = { en = "Invalid account: $1" },
    errAccountDuplicate = { en = "Duplicated account: $1" },
    errAccountEmpty     = { en = "Empty account name detected" },
    errAccountUntrimmed = { en = "Untrimmed account: $1" },
    errDataBad          = { en = "Invalid data: $1" },
    errDataGroup        = { en = "Invalid groups: $1" },
    errDuplicatingRedir = { en = "Duplicated renaming: $1" },
    errLinkTarget       = { en = "Bad link target: $1" },
    errNoData           = { en = "No data to process" },
    errPingingModule    = { en = "No Pinging module" },
    errUnknownType      = { en = "Unknown result type: $1" },
    THaccount           = { en = "Account" },
    THcode              = { en = "Group" },
    THcodes             = { en = "Groups" },
    THdetails           = { en = "Details" },
    THfrom              = { en = "From" },
    THgender            = { en = mw.ustring.char( 0x2640, 0x2642 ) },
    THinfo              = { en = "Info" },
    THsince             = { en = "Since" },
    THtill              = { en = "Till" }
}
UserGroups.r =
    { gender   = { },
      graph    = { },
      Lua      = { },
      number   = { },
      ol       = { },
      ping     = { },
      plain    = { },
      raw      = { },
      table    = { died = "†" },
      target   = { },
      timeline = { seek  = "<pre id=\"usergroups%-timeline\"[^>]*>",
                   stamp = "dd/mm/yyyy" },
      ul       = { }
    }
UserGroups.DateTime = { }
UserGroups.Remark   = { max = 1000000 }



local function factory( apply, about )
    -- Localization of messages
    --     apply  -- string, with message key
    --     about  -- string, with explanation, or not
    -- Returns message text; at least english
    local entry = UserGroups.I18N[ apply ]
    local r
    if entry then
        r = entry[ mw.language.getContentLanguage():getCode() ]
        if not r then
            r = entry.en
        end
        if about  and  r:find( "$1", 1, true ) then
            r = r:gsub( "%$1", mw.text.nowiki( about ) )
        end
    else
        r = string.format( "????NoMessage@%s %s????",
                           UserGroups.suite, apply )
    end
    return r
end -- factory()



local function fair( account )
    -- Normalize a user nick
    --     account  -- string, with nick
    -- Returns string
    local r = mw.text.decode( account )
    r = r:gsub( "_", " " )
         :gsub( "&lrm;", "" )
         :gsub( "&rlm;", "" )
         :gsub( mw.ustring.char( 0xA0 ),  " " )
         :gsub( mw.ustring.char( 0x200E ),  "" )
         :gsub( mw.ustring.char( 0x200F ),  "" )
         :gsub( "%s%s+", " " )
    r = mw.text.trim( r )
    return r
end -- fair()



local function familiar( adapt )
    -- Poor man’s sort normalization for names
    --     adapt  -- string
    -- Returns string
    local r = mw.ustring.gsub( adapt, "ß" , "ss" )
    r = mw.ustring.gsub( r, "ä" , "ae" )
    r = mw.ustring.gsub( r, "ö" , "oe" )
    r = mw.ustring.gsub( r, "ü" , "ue" )
    r = mw.ustring.gsub( r, "Ä" , "Ae" )
    r = mw.ustring.gsub( r, "Ö" , "Oe" )
    r = mw.ustring.gsub( r, "Ü" , "Ue" )
    r = mw.ustring.gsub( r, "é" , "e" )
    r = mw.ustring.gsub( r, "ó" , "o" )
    r = mw.ustring.gsub( r, "ú" , "u" )
    return r
end -- familiar()



local function fault( alert )
    -- Format message with class="error"
    --     alert  -- string, with message
    -- Returns mw.html
    local r = mw.html.create( "span" )
                     :addClass( "error" )
    if type( alert ) == "string"  and  alert ~= "" then
        r:wikitext( alert )
    else
        r:wikitext( string.format( "????fault@%s????",
                                   UserGroups.suite ) )
    end
    return r
end -- fault()



local function feed( apply )
    -- Clone table without metatable
    --     apply  -- table
    -- Returns table
    local r
    if type( apply ) == "table" then
        r = { }
        for k, v in pairs( apply ) do
            if type( v ) == "table" then
                v = feed( v )
            end
            r[ k ] = v
        end -- for k, v
    else
        r = apply
    end
    return r
end -- feed()



local function female( alike )
    -- Retrieve gender mark
    --     alike  -- string, with word, or not
    -- Returns string, or not
    local r
    if type( alike ) == "string" then
        r = mw.text.trim( alike )
        r = r:sub( 1, 1 )
             :lower()
        if r == "f" then
            r = 0x2640
        elseif r == "m" then
            r = 0x2642
        else
            r = false
        end
        if r then
            r = mw.ustring.char( r )
        end
    end
    return r
end -- female()



local function fence( at )
    -- Check whether this is a live date
    --     at  -- table, with entry
    -- Returns
    --     1  --  table, with improved entry, or false
    --     2  --  boolean
    local s = type( at )
    local r1, r2
    if s == "string" then
        r1 = { date = at }
    elseif s == "table" then
        r1 = at
    else
        r1 = false
        r2 = true
    end
    if r1 then
        if type( r1.date ) == "string" then
            s = mw.text.trim( r1.date )
            if s == "" then
                r1.date = false
                r2 = true
            elseif s == "-"  or  s == true then
                r2 = false
            else
                local DateTime = UserGroups.DateTime.fetch()
                if DateTime then
                    local date
                    if type( r1.sort ) == "string" then
                        date = r1.sort
                    else
                        date = r1.date
                    end
                    date = DateTime( date )
                    if date then
                        if not UserGroups.today then
                            UserGroups.today = DateTime()
                        end
                        r2 = ( date >= UserGroups.today )
                    else
                        r1.date = false
                        r2 = true
                    end
                else
                    r2 = false
                end
            end
        elseif r1.date == true then
            r2 = false
        else
            r1.date = false
            r2 = true
        end
    elseif at == true then
        r2 = false
    else
        r2 = true
    end
    return r1, r2
end -- fence()



local function fields( accept )
    -- Count elements without metatable
    --     accept  -- table
    -- Returns number
    local r
    if type( accept ) == "table" then
        r = 0
        for k, v in pairs( accept ) do
            if type( k ) == "number"  and  k > r then
               r = k
            end
        end -- for k, v
    end
    return r
end -- fields()



local function fill( ask, account, assign )
    -- Analyze and normalize one user entry
    --     ask      -- table, with current request
    --                 .sole    -- single account
    --                 .subset  -- account pattern
    --                 .codes   -- groups
    --                 .live    -- active
    --                 .state   -- member|ping
    --     account  -- string, with validated user nick
    --     assign   -- table, with entry
    -- Returns
    --     1  --  table, with processed entry, or not
    --     2  --  string, with localized error, or not
    --     3  --  string, with renamed account, or not
    local e, r1, r2, r3
    r1 = { sign = account }
    if type( assign ) == "string" then
        e = { code = assign }
    else
        e = assign
    end
    if type( e ) == "table" then
        if fields( e ) > 0 then
            r1.groups = feed( e )
            e = false
        elseif type( e.code ) == "string" then
            r1.groups = { }
            table.insert( r1.groups,  feed( e ) )
            e = false
        elseif type( e.groups ) == "table" then
            if fields( e.groups ) == 0 then
                if type( e.groups.code ) == "string" then
                    r1.groups = { }
                    table.insert( r1.groups,  feed( e.groups ) )
                else
                    r2 = factory( "errDataGroup", account )
                    r1 = false
                end
            else
                r1.groups = feed( e.groups )
            end
        elseif type( e.groups ) == "string" then
            r1.groups = { }
            table.insert( r1.groups, e.groups )
        elseif type( e.renamed ) == "string" then
            r3 = e.renamed
            r1 = false
        else
            r2 = factory( "errDataBad", account )
            r1 = false
        end
        if r1 then
            if e then
                for k, v in pairs( e ) do
                    if k ~= "groups" then
                        r1[ k ] = feed( v )
                    end
                end -- for k, v
            end
            if r1.groups then
                local g, s
                for k, v in pairs( r1.groups ) do
                    s = type( v )
                    if s == "string" then
                        r1.groups[ k ] = { code = v }
                    elseif s ~= "table" then
                        r2 = factory( "errDataGroup", account )
                        r1 = false
                    end
                end -- for k, v
                if r1 and ask.limit then
                    for i = #r1.groups, 1, -1 do
                        g = r1.groups[ i ]
                        if not ask.codes.bunch[ g.code ] then
                            table.remove( r1.groups, i )
                        end
                    end -- for i
                    if #r1.groups == 0 then
                        r1 = false
                    end
                end
                if r1  and  type( ask.live ) == "boolean" then
                    local live
                    for i = #r1.groups, 1, -1 do
                        g = r1.groups[ i ]
                        s = type( g.till )
                        if s == "string" then
                            g.till = mw.text.trim( g.till )
                            if g.till == "" then
                                g.till = false
                            else
                                g.till = { date = g.till }
                            end
                        elseif s == "table" then
                            if g.till.date == "" then
                                g.till = false
                            end
                        elseif g.till ~= true then
                            g.till = false
                        end
                        if g.till then
                            g.till, live = fence( g.till )
                        else
                            live = true
                        end
                        r1.groups[ i ].till = g.till
                        if live == ask.live then
                            r1.groups[ i ].live = live
                        else
                            table.remove( r1.groups, i )
                        end
                    end -- for i
                    if #r1.groups == 0 then
                        r1 = false
                    end
                end
                if r1 and ask.codes and ask.codes.exclude then
                    for k, v in pairs( ask.codes.exclude ) do
                        for i = 1, #r1.groups do
                            g = r1.groups[ i ]
                            if g.code == k then
                                r1 = false
                                break -- for i
                            end
                        end -- for i
                        if not r1 then
                            break -- for k, v
                        end
                    end -- for k, v
                end
                if r1 and ask.codes then
                    e = true
                    for i = #r1.groups, 1, -1 do
                        g = r1.groups[ i ]
                        for k, v in pairs( ask.codes.bunch ) do
                            if g.code == k then
                                if ask.live == false  and
                                   g.live then
                                    r1 = false
                                end
                                e = false
                                break -- for k, v
                            end
                        end -- for k, v
                        if not r1 then
                            break -- for i
                        end
                    end -- for i
                    if r1  and
                       #r1.groups == 0  or  e then
                        r1 = false
                    end
                end
                if r1 and ask.codes and ask.codes.law then
                    local bunch = { }
                    for k, v in pairs( ask.codes.bunch ) do
                        bunch[ k ] = true
                    end -- for k, v
                    for k, v in pairs( ask.codes.bunch ) do
                        for i = 1, #r1.groups do
                            if r1.groups[ i ].code == k then
                                bunch[ k ] = false
                            end
                        end -- for i
                    end -- for k, v
                    for k, v in pairs( bunch ) do
                        if v then
                            r1 = false
                            break -- for v
                        end
                    end -- for k, v
                end
                if r1 then
                    for i = 1, #r1.groups do
                        g = r1.groups[ i ]
                        if g.state == ""  or
                           g.state == "member" then
                            g.state = false
                        end
                        if ask.state == "member"  and  g.state then
                            table.remove( r1.groups, 1 )
                        end
                    end -- for i
                    if #r1.groups == 0 then
                        r1 = false
                    end
                end
            elseif not r3 then
                r2 = factory( "errNoGroup", account )
                r1 = false
            end
        end
    else
        r2 = factory( "errDataBad", account )
        r1 = false
    end
    return r1, r2, r3
end -- fill()



local function filling( ask, account, assign )
    -- Analyze and normalize one user nick and entry
    --     ask      -- table, with current request
    --     account  -- string, with user nick
    --     assign   -- table, with entry
    -- Returns
    --     1  --  table, with processed entry, or not
    --     2  --  string, with localized error, or not
    local s = type( account )
    local r1, r2, r3
    if s == "string" then
        s = fair( account )
        if s == "" then
            r2 = factory( "errAccountEmpty" )
        elseif s == account then
            if s:find( "[@/|:{}%[%]<>]" ) then
                r2 = factory( "errAccountBad", s )
            else
                r1, r2, r3 = fill( ask, s, assign )
            end
        else
            r2 = factory( "errAccountUntrimmed", s )
        end
    else
        s  = string.format( "%s: %s", s, tostring( account ) )
        r2 = factory( "errAccountBad", s )
    end
    return r1, r2, r3
end -- filling()



local function filter( accounts, arglist )
    -- Narrow data to current request
    --     accounts  -- table, with data
    --     arglist   -- table, with current request
    -- Returns
    --     1  --  table, with processed data map, or not
    --     2  --  table, with errors, or not
    --     3  --  sequence table, with sort order, or not
    local query = { }
    local entry, err, p, r1, r2, r3, renamed, s
    if type( arglist.sole ) == "string" then
        query.sole = arglist.sole
    elseif type( arglist.subset ) == "string" then
        query.subset = arglist.subset
    end
    if arglist.scheme == "ping"  or  arglist.scheme == "target" then
        arglist.live = true
    end
    if type( arglist.live ) == "boolean" then
        query.live = arglist.live
        if not query.live  and
           type( arglist.codes ) == "table"  and
           #arglist.codes ~= 1 then
            arglist.codes = false
        end
    end
    if type( arglist.codes ) == "table" then
        local n = 0
        local q = { }
        for i = 1, #arglist.codes do
            s = arglist.codes[ i ]
            if type( s ) == "string" then
                s = mw.text.trim( s )
                if s == "&" then
                    q.law = query.live
                elseif s ~= ""  and  s ~= "-" then
                    if s:sub( 1, 1 ) == "-" then
                        if query.live then
                            s = s:sub( 2 )
                            q.exclude = q.exclude  or  { }
                            q.exclude[ s ] = true
                        end
                    else
                        n = n + 1
                    end
                    q.bunch = q.bunch  or  { }
                    q.bunch[ s ] = true
                end
            end
        end -- for i
        if q.bunch then
            if q.law  and  n < 2 then
                q.law = false
            end
            query.codes = q
        end
    else
        arglist.codes = false
    end
    if arglist.codes  and
       query.codes  and
       arglist.scheme ~= "table" then
        query.limit = true
    end
    if arglist.scheme == "ping" then
        query.state = "ping"
    else
        query.state = "member"
    end
    for k, v in pairs( accounts ) do
        if query.sole then
            if k ~= query.sole then
                k = false
            end
        elseif query.subset then
            if not mw.ustring.match( k, query.subset ) then
                k = false
            end
        end
        if k then
            if type( k ) == "number" then
                k = tostring( k )
            end
            entry, err, p = filling( query, k, v )
            if err then
                r2 = r2  or  { }
                table.insert( r2, err )
                r1 = false
            elseif p then
                renamed = renamed  or  { }
                table.insert( renamed,
                              { [1] = k,
                                [2] = p } )
            end
            if entry  and  not r2 then
                r1 = r1  or  { }
                s  = mw.ustring.sub( entry.sign, 1, 1 )
                s  = mw.ustring.upper( s )  ..
                     mw.ustring.sub( entry.sign, 2 )
                if r1[ entry.sign ]  or  r1[ s ] then
                    r2 = { }
                    table.insert( r2,
                                  factory( "errAccountDuplicate",
                                           entry.sign ) )
                    r1 = false
                else
                    r1[ entry.sign ] = entry
                end
            end
        end
    end -- for k, v
    if r1 then
        local f = function ( a1, a2 )
                      return a1.s < a2.s
                  end -- f()
        local o = { }
        if renamed then
            for k, v in pairs( renamed ) do
                if r1[ v[ 2 ] ] then
                    if r1[ v[ 1 ] ] then
                        r2 = { }
                        table.insert( r2,
                                      factory( "errDuplicatingRedir",
                                               v[ 1 ] ) )
                    elseif arglist.scheme == "table"  and
                           not arglist.live then
                        r1[ v[ 1 ] ] = { shift = v[ 2 ],
                                         sign  = v[ 1 ] }
                    end
                end
            end -- for k, v
        end
        for k, v in pairs( r1 ) do
            s = familiar( k )
            s = string.format( "%s |%s",  mw.ustring.upper( k ),  k )
            table.insert( o,
                          { a = k,
                            s = s } )
        end -- for k, v
        table.sort( o, f )
        r3 = { }
        for i = 1, #o do
            table.insert( r3,  o[ i ].a )
        end -- for i
    end
    return r1, r2, r3
end -- filter()



local function first()
    -- Initialize configuration
    if not UserGroups.config then
        local s = string.format( "Module:%s%s",
                                 UserGroups.suite, UserGroups.setup )
        local lucky, json = pcall( mw.loadJsonData, s )
        if type( json ) ~= "table"  and
           type( UserGroups.item ) == "number"  and
           UserGroups.item > 0 then
            s = string.format( "Q%d", UserGroups.item )
            s = mw.wikibase.getSitelink( s )
            if type( s ) == "string" then
                s = string.format( "%s%s", s, UserGroups.setup )
                lucky, json = pcall( mw.loadJsonData, s )
            end
        end
        if type( json ) == "table" then
            UserGroups.config = feed( json )
            if type( UserGroups.config.i18n ) == "table" then
                local trsl
                for k, v in pairs( UserGroups.config.i18n ) do
                    trsl = UserGroups.I18N[ k ]
                    if type( trsl ) == "table"   and
                       type( v ) == "table" then
                        for slang, s in pairs( v ) do
                            if type( s ) == "string"   and
                               mw.text.trim( s ) ~= "" then
                                UserGroups.I18N[ k ][ slang ] = s
                            end
                        end -- for slang, s
                    end
                end -- for k, v
            end
        else
            UserGroups.config = { }
        end
    end
end -- first()



local function furnish( adjust, add, assign )
    -- Assign attributes to element
    --     adjust  -- mw.html, or not
    --     add     -- table, string, with class, or not
    --     assign  -- table, string, with , or not
    if type( adjust ) == "table"  and
       type( adjust.create ) == "function" then
        local s
        if add then
            s = type( add )
            if s == "string" then
                adjust:addClass( add )
            elseif s == "table" then
                for k, v in pairs( add ) do
                    if type( v ) == "string"
                       and  v ~= "" then
                        adjust:addClass( v )
                    end
                end -- for k, v
            end
        end
        if assign then
            s = type( assign )
            if s == "string" then
                adjust:cssText( assign )
            elseif s == "table" then
                adjust:css( assign )
            end
        end
    end
end -- furnish()



UserGroups.f = function ( arglist )
    -- Major productive function
    --     arglist  -- table, with current request
    -- Returns mw.html
    local data, lucky, params, r
    first()
    if type( arglist ) == "table" then
        params = arglist
    else
        params = { }
    end
    if params.json then
        lucky, data = pcall( mw.text.jsonDecode, params.json )
    elseif type( params.data ) == "table" then
        data  = params.data
        lucky = true
    else
        local source
        if type( params.source ) == "string" then
            source = params.source
        elseif type( UserGroups.config.source ) == "string" then
            source        = UserGroups.config.source
            params.source = source
        end
        if source then
            lucky, data = pcall( mw.loadJsonData, source )
        end
    end
    if lucky then
        if type( data.accounts ) == "table" then
            local explain = data.codes
            local err, order
            data, err, order = filter( data.accounts, params )
            if data  and  not err then
                params.scheme = params.scheme or "table"
                r = UserGroups.r[ params.scheme ]
                if type( r ) == "table"  and
                   type( r.format ) == "function" then
                    r, err = r.format( data, order, params, explain )
                else
                    err = { }
                    table.insert( err,
                                  factory( "errUnknownType",
                                           params.scheme ) )
                end
                if not err  and
                   type( r ) == "table"  and
                   params.scheme ~= "Lua" then
                    furnish( r,
                             UserGroups.config.class,
                             UserGroups.config.style )
                    furnish( r, params.class, params.style )
                    if type( params.id ) == "string"  and
                       params.id ~= "" then
                        r:attr( "id", params.id )
                    end
                end
            end
            if err then
                local li
                r = mw.html.create( "ul" )
                for i = 1, #err do
                    s  = err[ i ]
                    li = mw.html.create( "li" )
                    li:wikitext( err[ i ] )
                    r:newline()
                     :node( li )
                end -- for i
            end
        else
            r = fault( factory( "errNoData" ) )
        end
    else
        if not data then
            data = factory( "errNoData" )
        end
        r = fault( data )
    end
    return r or false
end -- UserGroups.f()



UserGroups.r.gender.format = function ( accounts, along, arglist )
    -- Retrieve gender of account
    --     accounts  -- table, with data
    --     along     -- table, with order
    --     arglist   -- table, with current request
    --                         .account
    -- Returns
    --     1  --  "f", "m", "-", false
    local r
    if type( arglist ) == "table"  and
       type( arglist.account ) == "string" then
        local e = accounts[ arglist.account ]
        if type( e ) == "table" then
            if type( e.gender ) == "string" then
                local s = mw.text.trim( e.gender ):sub( 1, 1 ):lower()
                if s == "f"  or  s == "m" then
                    r = s
                end
            end
            r = r or "-"
        end
    end
    return r or false
end -- UserGroups.r.gender.format()



UserGroups.r.Lua.format = function ( accounts, along, arglist )
    -- Format data as Lua sequence table of strings
    --     accounts  -- table, with data
    --     along     -- table, with order
    --     arglist   -- table, with current request
    -- Returns
    --     1  --  sequence table
    return along
end -- UserGroups.r.Lua.format()



UserGroups.r.number.format = function ( accounts, along, arglist )
    -- Retrieve number of accounts
    --     accounts  -- table, with data
    --     along     -- table, with order
    --     arglist   -- table, with current request
    -- Returns
    --     1  --  number
    return #along
end -- UserGroups.r.number.format()



UserGroups.r.ol.format = function ( accounts, along, arglist )
    -- Format data as <ol>
    --     accounts  -- table, with data
    --     along     -- table, with order
    --     arglist   -- table, with current request
    -- Returns
    --     1  --  mw.html.ol
    return UserGroups.r.ul.format( accounts, along, arglist, nil, true )
end -- UserGroups.r.ol.format()



UserGroups.r.ping.format = function ( accounts, along, arglist )
    -- Format data as ping list
    --     accounts  -- table, with data
    --     along     -- table, with order
    --     arglist   -- table, with current request
    -- Returns
    --     1  --  mw.html.span
    --     2  --  table, with errors, or not
    local r1, r2
    if not UserGroups.Pinging then
        local lucky, Pinging = pcall( require, "Module:Pinging" )
        if type( Pinging ) == "table" then
            UserGroups.Pinging = Pinging()
        else
            UserGroups.Pinging = true
        end
    end
    if type( UserGroups.Pinging ) == "table"  and
       type( UserGroups.Pinging.f ) == "function" then
        local cnf, light, show
        if type( arglist ) == "table"  and
           type( arglist.ping ) == "table" then
            cnf   = arglist.ping
            light = cnf.light
            show  = cnf.show
        end
        r1 = mw.html.create( "span" )
        r1:wikitext( UserGroups.Pinging.f( along,
                                           false,
                                           light,
                                           cnf,
                                           false,
                                           show ) )
    else
        r2 =  { }
        table.insert( r2, factory( "errPingingModule" ) )
    end
    return r1, r2
end -- UserGroups.r.ping.format()



UserGroups.r.plain.format = function ( accounts, along, arglist )
    -- Format data as plain text
    --     accounts  -- table, with data
    --     along     -- table, with order
    --     arglist   -- table, with current request
    -- Returns
    --     1  --  mw.html.div
    local r1 = mw.html.create( "div" )
    local s = UserGroups.r.raw.format( accounts, along, arglist )
    if s then
        local pre = { style = "white-space: nowrap" }
        UserGroups.frame = UserGroups.frame or mw.getCurrentFrame()
        pre[ 1 ] = s
        r1:newline()
          :wikitext( UserGroups.frame:extensionTag( "pre", pre ) )
          :newline()
    end
    return r1
end -- UserGroups.r.plain.format()



UserGroups.r.raw.format = function ( accounts, along, arglist )
    -- Format data as raw text, \n separated
    --     accounts  -- table, with data
    --     along     -- table, with order
    --     arglist   -- table, with current request
    -- Returns
    --     1  --  string, or nil
    local r1, s
    for i = 1, #along do
        s = along[ i ]
        if r1 then
            r1 = string.format( "%s\n%s", r1, s )
        else
            r1 = s
        end
    end -- for i
    return r1
end -- UserGroups.r.raw.format()



UserGroups.r.table.factory = function ( account, apply )
    -- Process account through external
    --     account   -- table, with user data
    --     apply     -- string, with processing scheme
    -- Returns string, with wikitext
    local r
    if type( UserGroups.config.process ) == "table" then
        local def = UserGroups.config.process[ apply ]
        if type( def ) == "table"  and
           type( def.transclude ) == "string"  and
           def.nick then
            local p = { }
            UserGroups.frame = UserGroups.frame or mw.getCurrentFrame()
            p[ def.nick ] = account.sign
            if def.groups then
                for i = 1, #account.groups do
                    p[ account.groups[ i ].code ] = "1"
                end -- for i
            end
            r = UserGroups.frame:expandTemplate{ title = def.transclude,
                                                 args = p }
        end
    end
    return r
end -- UserGroups.r.table.factory()



UserGroups.r.table.feature = function ( above )
    -- Label table head cell
    --     above     -- string, with code
    -- Returns string, with wikitext
    local s = above
    local l = ( s:sub( 1, 1 ) == "#" )
    local h, r
    if l then
        s = s:sub( 2 )
    end
    h = mw.text.split( s, "#" )
    s = h[ 1 ]
    if l then
        if s == "groups" then
            s = h[ 2 ]
        end
        r = factory( "TH" .. s )
    else
        if mw.ustring.find( s, "%A" ) then
            local e = mw.html.create( "span" )
            e:css( "white-space", "nowrap" )
             :wikitext( s )
            s = tostring( e )
        end
        r = s
    end
    return r
end -- UserGroups.r.table.feature()



UserGroups.r.table.fiat = function ( at, assign )
    -- Assign content in cell to row
    --     at       -- mw.html.tr
    --     assign   -- table or string, with something
    local td = mw.html.create( "td" )
    local s  = type( assign )
    if s == "string"  and  assign ~= "" then
        s = assign
    elseif s == "table" then
        s = assign.content
        if s  and
           type( assign.attr ) == "table"   and
           #assign.attr > 0 then
            local e
            for i = 1, #assign.attr do
                e = assign.attr[ i ]
                if type( e.key ) == "string"  and
                   type( e.value ) == "string" then
                    td:attr( e.key, e.value )
                end
            end -- for i
        end
    else
        s = false
    end
    if s  and  s ~= true then
        td:wikitext( s )
    end
    at:node( td )
      :newline()
end -- UserGroups.r.table.fiat()



UserGroups.r.table.fill = function ( at, account, accounts, arglist )
    -- Content for entire table data column
    --     at        -- number, of column
    --     account   -- string, with nick
    --     accounts  -- table, with data
    --     arglist   -- table, with current request
    -- Returns table, with
    --         [1]   -- wikitext or table of first row
    --         [2]   -- wikitext or table of second row, or not
    --         [*]   -- wikitext or table of third etc. row, or not
    local stack = arglist.table.columns[ at ]
    local entry = accounts[ account ]
    local r = { }
    local k, s, sub
    if stack:sub( 1, 1 ) == "#" then
        s = stack:sub( 2 )
        k = s:find( "#", 2, true )
        if k then
            sub = s:sub( k + 1 )
            s   = s:sub( 1,  k - 1 )
        end
        if s == "account" then
            local opts = { }
            if sub then
                local o = mw.text.split( sub, "#" )
                for i = 1, #o do
                    s = o[ i ]
                    k = s:find( ":", 2, true )
                    if k == 8  and  s:sub( 1, 7 ) == "process" then
                        opts.process = s:sub( 9 )
                    else
                        opts[ s ] = true
                    end
                end -- for i
            end
            s = false
            if opts.process then
                s = UserGroups.r.table.factory( entry, opts.process )
            end
            if not s then
                s = string.format( "[[user:%s|%s]]", account, account )
            end
            if opts.gender then
                k = female( entry.gender )
                if k then
                    s = string.format( "%s&nbsp;%s", s, k )
                end
            end
            if opts.died  and  entry.died then
                local e = mw.html.create( "span" )
                e:css( "vertical-align", "super" )
                 :css( "margin-left",  "0.2em" )
                 :css( "margin-right", "0.2em" )
                 :wikitext( UserGroups.r.table.died )
                s = s .. tostring( e )
            end
            if opts.alias  and  entry.alias then
                local x = { }
                local e
                if type( entry.alias ) == "string" then
                    table.insert( x, { account = entry.alias } )
                elseif type( entry.alias ) == "table" then
                    if #entry.alias > 0 then
                        for i = 1, #entry.alias do
                            e = entry.alias[ i ]
                            if type( e ) == "string" then
                                table.insert( x,
                                              { account = e } )
                            elseif type( e ) == "table" then
                                table.insert( x, e )
                            end
                        end -- for i
                    else
                        table.insert( x, entry.alias )
                    end
                end
                if #x > 0 then
                    local history = mw.html.create( "div" )
                    local div, h, same, sign
                    for i = 1, #x do
                        h = x[ i ]
                        div = mw.html.create( "div" )
                        if h.later then
                            same = "aliasLater"
                        else
                            same = "aliasFormer"
                        end
                        e = mw.html.create( "span" )
                        e:css( "font-style", "italic" )
                         :wikitext( factory( same )  ..  " " )
                        div:newline()
                           :node( e )
                           :wikitext( " " )
                        if type( h.account ) == "string" then
                            sign = mw.text.trim( h.account )
                            if sign == "" then
                                sign = false
                            end
                        else
                            sign = false
                        end
                        if sign then
                            if h.link == true then
                                sign = string.format( "[[user:%s|%s]]",
                                                      sign, sign )
                            end
                        else
                            sign = "?????"
                        end
                        div:wikitext( sign )
                        if h.suffix then
                            local post = UserGroups.Remark.feed(
                                                               h.suffix )
                            if post then
                                div:node( post )
                            end
                        end
                        history:newline()
                               :node( div )
                               :newline()
                    end -- for i
                    s = s .. tostring( history )
                end
            end
            if type( entry.groups ) == "table" then
                if arglist.table.large then
                    k = #entry.groups
                else
                    k = 1
                end
                if k == 1 then
                    table.insert( r, s )
                else
                    local o = { content = tostring( s ),
                                attr    = { } }
                    table.insert( o.attr,
                                  { key  = "rowspan",
                                    value= tostring( k ) } )
                    table.insert( r, o )
                    s = false
                    for i = 1, k - 1 do
                        table.insert( r, false )
                    end -- for i
                end
            end
        elseif s == "codes" then
            if entry.shift then
                s = UserGroups.r.table.forward( entry.shift,
                                                accounts,
                                                arglist )
            else
                local o = UserGroups.r.table.focus( entry.groups )
                local sign
                s = false
                for i = 1, #o do
                    sign = o[ i ]
                    if mw.ustring.find( sign, "%A" ) then
                        local e = mw.html.create( "span" )
                        e:css( "white-space", "nowrap" )
                         :wikitext( sign )
                        sign = tostring( e )
                    end
                    if s then
                        s = string.format( "%s %s", s, sign )
                    else
                        s = sign
                    end
                end -- for i
            end
        elseif s == "details" then
            s = entry.details
        elseif s == "gender" then
            s = female( entry.gender ) or true
            if sub == "preference" then
                UserGroups.cLang = UserGroups.cLang  or
                                        mw.language.getContentLanguage()
                sub = UserGroups.cLang:gender( account, "m", "f", "-" )
                if sub ~= "-" then
                    if s == true then
                        s = sub
                    else
                        s = string.format( "%s %s",
                                           tostring( s ),
                                           sub )
                    end
                end
            end
        elseif s == "groups" then
            if entry.shift then
                if at == 2 then
                    s = UserGroups.r.table.forward( entry.shift,
                                                    accounts,
                                                    arglist )
                else
                    s = false
                end
            else
                local codes, e
                if sub == "code#see" then
                    sub   = "code"
                    codes = UserGroups.config.codes
                else
                    sub = sub or "code"
                end
                for i = 1, #entry.groups do
                    e = entry.groups[ i ]
                    s = e[ sub ]
                    if sub == "code" then
                        if mw.ustring.find( s, "%A" ) then
                            local html = mw.html.create( "span" )
                            html:css( "white-space", "nowrap" )
                                :wikitext( s )
                            s = tostring( html )
                        end
                        if codes then
                            local group = codes[ e.code ]
                            if type( group ) == "table"  and
                               type( group.see ) == "string" then
                                s = string.format( "[[%s|%s]]",
                                                   group.see, s )
                            end
                        end
                    elseif sub == "from"  or
                           sub == "since"  or
                           sub == "till" then
                        s = UserGroups.r.table.fromto( s )
                    elseif sub == "info" then
                    end
                    table.insert( r,  s or true )
                end -- for i
                if sub == "code"  and  #r > 1 then
                    local n = 1
                    for i = #r, 1, -1 do
                        if i == 1 then
                            s = true
                        else
                            s = r[ i - 1 ]
                        end
                        if r[ i ] == s then
                            r[ i ] = false
                            n      = n + 1
                        elseif n > 1 then
                            e      = { content = r[ i ],
                                       attr    = { } }
                            table.insert( e.attr,
                                          { key  = "rowspan",
                                            value= tostring( n ) } )
                            r[ i ] = e
                            r[ i + 1 ] = false
                            n = 1
                        end
                    end -- for i
                end
                s = true
            end
        else
            s = true
        end
        if s then
            table.insert( r, s )
        end
    elseif entry.groups then
        local g = entry.groups
        local e, v
        s = stack
        k = s:find( "#", 2, true )
        if k then
            sub = s:sub( k + 1 )
            s   = s:sub( 1,  k - 1 )
        end
        sub = sub or "1"
        for i = 1, #g do
            e = g[ i ]
            if e.code == s then
                if sub == "1" then
                    v = { content = "X",
                          attr    = { } }
                    table.insert( v.attr,
                                  { key   = "style",
                                    value = "font-weight:bold;"
                                            .. "text-align:center;" } )
                    table.insert( v.attr,
                                  { key   = "title",
                                    value = e.code } )
                elseif sub == "from"  or
                       sub == "since"  or
                       sub == "till" then
                    v = UserGroups.r.table.fromto( e[ sub ] )
                elseif sub == "info" then
                    v = e.info
                    if e.suffix then
                        local post = UserGroups.Remark.feed( e.suffix )
                        if post then
                            v = { content = v .. tostring( post ),
                                  attr    = { } }
                            table.insert( v.attr,
                                          { key   = "data-sort-value",
                                            value = e.info } )
                        end
                    end
                end
                break -- for i
            end
        end -- for i
        table.insert( r,  v or true )
    end
    return r
end -- UserGroups.r.table.fill()



UserGroups.r.table.focus = function ( assigned )
    -- Collect group identifiers of an account
    --     assigned  -- table, with user group data
    -- Returns table, with sorted unique group identifiers
    local r = { }
    if #assigned == 1 then
        table.insert( r,  assigned[ 1 ].code )
    else
        r = { }
        for i = 1, #assigned do
            table.insert( r,  assigned[ i ].code )
        end -- for i
        table.sort( r, UserGroups.r.table.follows )
        for i = #r, 2, -1 do
            if r[ i ] == r[ i - 1 ] then
                table.remove( r, i )
            end
        end -- for i
    end
    return r
end -- UserGroups.r.table.focus()



UserGroups.r.table.follows = function ( a1, a2 )
    -- Group ranking sort order
    --     a1  -- string, with group code
    --     a2  -- string, with group code
    -- Returns  true, if a1 < a2
    local r
    if UserGroups.r.table.order then
        local k1 = UserGroups.r.table.order[ a1 ]
        local k2 = UserGroups.r.table.order[ a2 ]
        if k1 then
            if k2 then
                r = ( k1 < k2 )
            else
                r = true
            end
        elseif k2 then
            r = false
        else
            r = ( a1 < a2 )
        end
    else
        r = ( a1 < a2 )
    end
    return r
end -- UserGroups.r.table.follows()



UserGroups.r.table.format = function ( accounts,
                                       along,
                                       arglist,
                                       about )
    -- Format data as table
    --     accounts  -- table, with data
    --     along     -- table, with order
    --     arglist   -- table, with current request
    --     about     -- table, with codes for legend and order, or not
    -- Returns
    --     1  --  mw.html.table / mw.html.div
    --     2  --  table, with errors, or not
    local r1     = mw.html.create( "table" )
                          :addClass( "wikitable" )
    local config = UserGroups.config
    local multi  = 0
    local c, caption, columns, e, lead, n, order, r2, s, td, th, tr
    UserGroups.Remark.first()
    arglist.table = arglist.table  or  { }
    if arglist.table.caption then
        e = mw.html.create( "caption" )
        s = type( arglist.table.caption )
        if s == "string" then
            if s ~= "" then
                caption = e:wikitext( arglist.table.caption )
            end
        elseif s == "table" then
            caption = e:newline()
                       :node( arglist.table.caption )
        end
    end
    if type( config.table ) == "table" then
        furnish( r1, config.table.class, config.table.style )
    end
    if type( config.died ) == "string"  and
        mw.text.trim( config.died ) ~= "" then
         UserGroups.r.table.died = config.died
    end
    if type( arglist.table.columns ) == "table" then
        for i = 1, #arglist.table.columns do
            s = arglist.table.columns[ i ]
            if type( s ) == "string" then
                s = mw.text.trim( s )
                if s ~= "" then
                    if s:sub( 1, 8 ) == "#account" then
                        if i == 1 then
                            lead = true
                        else
                            s = false
                        end
                    end
                    if s then
                        columns = columns  or  { }
                        table.insert( columns, s )
                    end
                end
            end
        end -- for i
    end
    arglist.table.columns = columns  or  { }
    if not lead  and  #along > 1 then
        table.insert( arglist.table.columns, 1, "#account" )
    end
    if #arglist.table.columns > 1 then
        tr = mw.html.create( "tr" )
        furnish( tr, arglist.table.THclass, arglist.table.THstyle )
        tr:newline()
        for i = 1, #arglist.table.columns do
            s  = arglist.table.columns[ i ]
            th = mw.html.create( "th" )
            th:wikitext( UserGroups.r.table.feature( s ) )
            tr:node( th )
              :newline()
            if s:sub( 1, 1 ) == "#" then
                if s:sub( 2, 7 ) == "groups" then
                    arglist.table.large = true
                end
            else
                arglist.table.light = true
            end
        end -- for i
    end
    if arglist.table.large  and  arglist.table.light then
        for i = #arglist.table.columns, 1, -1 do
            s  = arglist.table.columns[ i ]
            if s:sub( 1, 7 ) == "#groups" then
                table.remove( arglist.columns, i )
            end
        end -- for i
        arglist.table.large = false
    end
--   :node( mw.html.create( "thead" )
             if caption then
                 r1:node( caption )
                   :newline()
             end
             if tr then
                 r1:newline()
                   :node( tr )
                   :newline()
             end
--        )
    if type( config.codes ) ~= "table" then
        config.codes = { }
    end
    if arglist.codes  and  type( arglist.codes ) ~= "table" then
        arglist.codes = false
    end
    if type( about ) == "table" then
        if type( about.groups ) == "table" then
            for k, v in pairs( about.groups ) do
                if type( v ) == "table"  and
                   type( v.see ) == "string" then
                    e = config.codes[ k ]
                    if type( e ) ~= "table" then
                        config.codes[ k ] = { }
                        e = config.codes[ k ]
                    end
                    if type( e.see ) ~= "string" then
                        config.codes[ k ].see = v.see
                    end
                end
            end -- for k, v
        end
        if type( about.order ) == "table" then
            UserGroups.r.table.order = { }
            for k, v in pairs( about.order ) do
                if type( v ) == "string"  and  v ~= "" then
                    UserGroups.r.table.order[ v ] = k
                end
            end -- for k, v
        end
    end
    if arglist.table.large and arglist.codes then
        order = { }
        for k = #along, 1, -1 do
            s = along[ k ]
            c = accounts[ s ]
            if type( c.groups ) == "table" then
                for i = #c.groups, 1, -1 do
                    e = c.groups[ i ]
                    for j = 1, #arglist.codes do
                        if arglist.codes[ j ] == e.code then
                             e = false
                             break -- for j
                        end
                    end -- for j
                    if e then
                        table.remove( accounts[ s ].groups,  i )
                    end
                end
                if #accounts[ s ].groups > 0 then
                    table.insert( order, 1, s )
                end
            end
        end
    else
        order = along
    end
    for k = 1, #order do
        columns = { }
        tr      = mw.html.create( "tr" )
        multi   = multi + 1
        s       = order[ k ]
        n       = 1
        tr:attr( "id",  UserGroups.r.table.fragment( s ) )
        for i = 1, #arglist.table.columns do
            c = UserGroups.r.table.fill( i, s, accounts, arglist )
            table.insert( columns, c )
            if c[ 1 ] then
                UserGroups.r.table.fiat( tr, c[ 1 ] )
            end
            if #c > n then
                n = #c
            end
        end -- for i
        tr:newline()
        r1:node( tr )
          :newline()
        for m = 2, n - 1 do
            tr    = mw.html.create( "tr" )
            multi = multi + 1
            for i = 1, #columns do
                c = columns[ i ]
                if c[ m ] then
                    UserGroups.r.table.fiat( tr, c[ m ] )
                end
            end -- for i
            r1:node( tr )
              :newline()
        end -- for m
    end -- for k
    if #order > 1  and  arglist.table.number then
        tr = mw.html.create( "tr" )
        td = mw.html.create( "td" )
        td:addClass( "sortbottom" )
          :attr( "colspan",  tostring( #arglist.table.columns ) )
          :attr( "data-sort-value", "{entryCount}" )
          :wikitext( factory( "entryCount",  tostring( #along ) ) )
        tr:newline()
          :node( td )
          :newline()
        r1:node( tr )
          :newline()
    end
    if type( arglist.source ) == "string" then
        tr = mw.html.create( "tr" )
        td = mw.html.create( "td" )
        td:addClass( "sortbottom" )
          :attr( "colspan",  tostring( #arglist.table.columns ) )
          :attr( "data-sort-value", "{source}" )
          :css( "font-size", "86%" )
          :wikitext( factory( "dataSource" ) )
          :wikitext( string.format( " [[%s]]", arglist.source ) )
        tr:newline()
          :node( td )
          :newline()
        r1:node( tr )
          :newline()
    end
    if multi > 1  and  #arglist.table.columns > 1 then
        r1:addClass( "sortable" )
    end
    r1 = UserGroups.Remark.finish( r1 )
    return r1, r2
end -- UserGroups.r.table.format()



UserGroups.r.table.forward = function ( across, accounts, arglist )
    -- Shift renamed account
    --     across    -- string, with renamed account
    --     accounts  -- table, with data
    --     arglist   -- table, with current request
    -- Returns string or element, or not
    local r
    if accounts[ across ] then
        local n = #arglist.table.columns - 1
        r = UserGroups.r.table.fragment( across )
        r = string.format( "→ [[#%s|%s]]", r, across )
        if n > 1 then
            r = { content = r,
                  attr    = { } }
            table.insert( r.attr,
                          { key   = "colspan",
                            value = tostring( n ) } )
        end
    end
    return r
end -- UserGroups.r.table.forward()



UserGroups.r.table.fragment = function ( account )
    -- Fragment identifier
    --     account  -- string, with validated user nick
    -- Returns identifier
    return  string.format( "@%s@", account )
end -- UserGroups.r.table.fragment()



UserGroups.r.table.fromto = function ( at )
    -- Assign date to structure
    --     at  -- string, with date, or table, or nothing
    -- Returns string or table
    --                   .content = formatted date
    --                   .attr { { key   = "data-sort-value"
    --                             value = ISO date } )
    local s = type( at )
    local o, r, sort
    if s == "string" then
        s = at
    elseif s == "table" then
        o = at
        s = o.date
        if type( o.sort ) == "string"  and
           o.sort ~= "" then
            sort = o.sort
        end
    else
        s = false
    end
    if type( s ) == "string" then
        s = mw.text.trim( s )
        if s == "" then
            s = false
        end
    else
        s = false
    end
    if s then
        if s == "-"  or  s == true then
            r = false
        elseif s:match( "^%?+$" ) then
            r = "???"
        elseif s:find( "?", 2, true ) then
            r = s
        else
            local DateTime = UserGroups.DateTime.fetch()
            r = { attr = { } }
            if DateTime then
                local date = DateTime( sort or s )
                if date.year then
                    local scheme
                    if type( UserGroups.config.date ) == "string" then
                        scheme = UserGroups.config.date
                    else
                        if date.month then
                            if date.dom then
                                scheme = "Y-m-d"
                            else
                                scheme = "Y-m"
                            end
                        else
                            scheme = "Y"
                        end
                    end
                    if sort then
                        r.content = s
                    else
                        r.content = date:format( scheme )
                    end
                    sort = "#" .. date:format( "Ymd" )
                end
            else
                r.content = s
                sort      = s
            end
            table.insert( r.attr, { key   = "data-sort-value",
                                    value = sort } )
            if r.content then
                if o then
                    local snap
                    s = type( o.vsn )
                    if s == "string" then
                        snap = mw.text.trim( o.vsn )
                        if snap == "" then
                            snap = false
                        end
                    elseif s == "number" then
                        if o.vsn > 0  and
                           math.floor( o.vsn ) == o.vsn then
                            snap = tostring( o.vsn )
                        end
                    end
                    if snap then
                        s = "Special:PermaLink/" .. snap
                    elseif type( o.log ) == "number" then
                        if o.log > 0  and
                           math.floor( o.log ) == o.log then
                            s = string.format( "%s%s",
                                               "Special:Redirect/logid/",
                                               tostring( o.log ) )
                        else
                            s = false
                        end
                    elseif type( o.page ) == "string" then
                        s = mw.text.trim( o.page )
                        if s == "" then
                            s = false
                        elseif not s:find( ":", 1, true ) then
                            local e = mw.html.create( "span" )
                            s = factory( "errLinkTarget", s )
                            e:addClass( "error" )
                             :wikitext( s )
                            r.content = tostring( e )
                            s = false
                        end
                    else
                        s = false
                    end
                    if s  and
                       type( o.prefix ) == "string" then
                        snap = mw.text.trim( o.prefix )
                        if snap ~= "" then
                            if not snap:match( ":$" ) then
                                snap = snap .. ":"
                            end
                            s = snap .. s
                        end
                    end
                    if s then
                        if s:find( "[", 1, true )  or
                           s:find( "|", 1, true )  or
                           s:find( "]", 1, true ) then
                            local e = mw.html.create( "span" )
                            s = factory( "errLinkTarget", s )
                            e:addClass( "error" )
                             :wikitext( s )
                            r.content = tostring( e )
                        else
                            r.content = string.format( "[[%s|%s]]",
                                                       s, r.content )
                        end
                    end
                end
            else
                local e = mw.html.create( "span" )
                                 :addClass( "error" )
                                 :wikitext( s )
                r.content = tostring( e )
                r.attr[ 1 ].value = s
            end
        end
    end
    return r or true
end -- UserGroups.r.table.fromto()



UserGroups.r.target.format = function ( accounts, along, arglist )
    -- Format data as #target list
    --     accounts  -- table, with data
    --     along     -- table, with order
    --     arglist   -- table, with current request
    -- Returns
    --     1  --  mw.html.ul
    local r = mw.html.create( "ul" )
    local target = { }
    local li
    UserGroups.frame = UserGroups.frame or mw.getCurrentFrame()
    for i = 1, #along do
        li    = mw.html.create( "li" )
        target[ 1 ] = mw.title.makeTitle( 2, along[ i ] ).prefixedText
        li:wikitext( UserGroups.frame:callParserFunction( "#target",
                                                          target ) )
        r:newline()
         :node( li )
    end -- for i
    r:newline()
    return r
end -- UserGroups.r.target.format()



UserGroups.r.timeline.factory = function ( area, ahead, after, arglist )
    -- Combine timeline template with current content and parameters
    --     area     -- string, with major source code
    --     ahead    -- number, with lowest date (8digit)
    --     after    -- number, with highest date (8digit)
    --     arglist  -- table, with current request
    --                        arglist.timeline {}
    -- Returns  mw.html.div, or not
    local t = mw.title.new( arglist.timeline.template )
    local r
    if t.exists then
        local scheme = t:getContent()
        if scheme then
            local Timeline = UserGroups.r.timeline
            r = scheme:match( Timeline.seek  ..  "(.+)</pre>" )
            if r then
                local i = ahead  -  ahead % 10000
                local k = after  -  after % 10000
                local s
                Timeline = arglist.timeline
                r = r:gsub( "§MAINBAR§", area )
                     :gsub( "§FROM§",  tostring( i * 0.0001 ) )
                     :gsub( "§TILL§",  tostring( k * 0.0001 ) )
                if type( Timeline.caption ) == "string" then
                    s = Timeline.caption:gsub( "%s+", "_" )
                    r = r:gsub( "§CAPTION§", s )
                end
                if type( Timeline.previous ) == "string" then
                    s = Timeline.previous:gsub( "%s+", "_" )
                    r = r:gsub( "§PREVIOUS§", s )
                end
                if type( Timeline.elected ) == "string" then
                    s = Timeline.elected:gsub( "%s+", "_" )
                    r = r:gsub( "§ELECTED§", s )
                end
            end
        end
    end
    if r then
        UserGroups.frame = UserGroups.frame or mw.getCurrentFrame()
        r = UserGroups.frame:extensionTag( "timeline", r )
        if r then
            local div = mw.html.create( "div" )
            div:newline()
               :wikitext( r )
               :newline()
            r = div
        end
    end
    return r
end -- UserGroups.r.timeline.factory()



UserGroups.r.timeline.fetch = function ( accounts, alone )
    -- Retrieve ordered lists of periods per account
    --     accounts  -- table, with data
    --     alone     -- string, with group
    -- Returns  table, or not
    --          account: sequence table of periods (chronolcal)
    --                   period: table of { f= t= b= e= } periods
    local r
    if type( accounts ) == "table" then
        local f = function ( a1, a2 )
                      return a1.f < a2.f
                  end -- f()
        local d, g, from, till
        for k, v in pairs( accounts ) do
            if type( v ) == "table"  and
               type( v.groups ) == "table"  and
               #v.groups > 0 then
                for i = 1, #v.groups do
                    g = v.groups[ i ]
                    if g.code == alone then
                        from = UserGroups.r.timeline.fit( g.from )
                        till = UserGroups.r.timeline.fit( g.till )
                        if from  and  till  and  from <= till then
                            d = { f = from,
                                  t = till,
                                  b = UserGroups.r.timeline.fit( g.on ),
                                  e = UserGroups.r.timeline.fit( g.off )
                                }
                            r      = r  or  { }
                            r[ k ] = r[ k ]  or  { }
                            table.insert( r[ k ], d )
                        end -- for i
                        if r  and  r[ k ] then
                            table.sort( r[ k ], f )
                        end
                    end
                end
            end
        end -- for k, v
    end
    return r
end -- UserGroups.r.timeline.fetch()



UserGroups.r.timeline.fiat = function ( at )
    -- Format date for <timeline>
    --     at  -- number, with 8 digits date
    -- Returns  string
    local i = at % 100
    local k = ( at - i )  *  0.01
    local m = k % 100
    local j = ( k - m )  *  0.01
    local r
    if UserGroups.r.timeline.stamp == "dd/mm/yyyy" then
        r = string.format( "%02d/%02d/%04d", i, m, j )
    elseif UserGroups.r.timeline.stamp == "mm/dd/yyyy" then
        r = string.format( "%02d/%02d/%04d", i, m, j )
    else
        r = string.format( "%04d-%02d-%02d", j, m, i )
    end
    return r
end -- UserGroups.r.timeline.fiat()



UserGroups.r.timeline.fill = function ( arrive, along, amount )
    -- Retrieve timeline MainBar plot area
    --     arrive  -- table, with events per account
    --     along   -- table, with ordered list of accounts by lowest from
    --     amount  -- number, with longest event list
    -- Returns  string, or not
    local DateTime = UserGroups.DateTime.fetch()
    local r
    if DateTime then
        local fiat = UserGroups.r.timeline.fiat
        local s1 = "\n  at:%s mark:(line, term)"
        local s2 = "\n  color:%s from:%s till:%s text:\"[[user:%s|%s]]\""
        local event, events, n, now, s, sign, state
        if not UserGroups.today then
            UserGroups.today = DateTime()
        end
        now = tonumber( UserGroups.today:format( "Ymd" ) )
        for i = 1, amount do
            n = 0
            if r then
                r = r .. "\n barset:break"
            else
                r = ""
            end
            for j = 1, #along do
                sign   = along[ j ]
                events = arrive[ sign ]
                event  = events[ i ]
                s      = type( event )
                if s == "number" then
                    s = string.format( s1, fiat( event ) )
                elseif s == "table" then
                    if   event.t > now then
                        state = "elected"
                    else
                        state = "previous"
                    end
                    s = string.format( s2,
                                       state,
                                       fiat( event.f ),
                                       fiat( event.t ),
                                       sign,
                                       sign )
                else
                    s = false
                    n = n + 1
                end
                if s then
                    if n > 0 then
                        for k = 1, n do
                            r = r .. "\n  barset:skip"
                        end -- for k
                        n = 0
                    end
                    r = r .. s
                end
            end -- for j
        end -- for i
    end
    return r
end -- UserGroups.r.timeline.fiat()



UserGroups.r.timeline.first = function ( accounts )
    -- Retrieve ordered list of accounts per lowest from
    --     adjacent  -- table, with account period sequences
    -- Returns
    --     1  --  sequence table, with nicks
    --     2  --  number, with first year
    local f = function ( a1, a2 )
                  local k1 = accounts[ a1 ][ 1 ].f
                  local k2 = accounts[ a2 ][ 1 ].f
                  local rs
                  if k1 == k2 then
                      rs = a1 < a2
                  else
                      rs = k1 < k2
                  end
                  return rs
              end -- f()
    local r1 = { }
    for k, v in pairs( accounts ) do
        table.insert( r1, k )
    end -- for k, v
    table.sort( r1, f )
    return r1,  accounts[ r1[ 1 ] ][ 1 ].f
end -- UserGroups.r.timeline.first()



UserGroups.r.timeline.fit = function ( at )
    -- Retrieve YYYY-MM-DD
    --     at  -- string, or table
    -- Returns  number, or not
    local s = type( at )
    local r
    if s == "string" then
        s = at
    elseif s == "table" then
        s = at.date
        if type( at.sort ) == "string"  and
            at.sort ~= "" then
            s = at.sort
        elseif type( at.date ) == "string"  and
            at.date ~= "" then
            s = at.date
        end
    else
        s = false
    end
    if type( s ) == "string" then
        local j, m, i = s:match( "^%s*(20%d%d)-([01]%d)-([0-3]%d)%s*$" )
        if j then
            j = tonumber( j )
            m = tonumber( m )
            i = tonumber( i )
            if m > 0  and  m <= 12  and  i > 0  and  i <= 31 then
                r = 10000 * j  +  100 * m  +  i
            end
        end
    end
    return r
end -- UserGroups.r.timeline.fit()



UserGroups.r.timeline.follows = function ( along, adjacent, accounts )
    -- Retrieve ordered lists of periods per account
    --     along     -- table, with chronological order
    --     adjacent  -- table, with period sequences
    --     accounts  -- table, with user data
    -- Returns
    --     1  --  table, with chronological events per account
    --     2  --  number, maximum event count
    --     3  --  number, last date at all
    local r1 = { }
    local r2 = 0
    local r3 = 0
    local breaks, events, m, p, period, periods, sign, term
    for i = 1, #along do
        sign    = along[ i ]
        periods = adjacent[ sign ]
        events  = { }
        breaks  = { }
        m       = 0
        term    = false
        for j = 1, #periods do
            p = periods[ j ]
            if p.b then
                table.insert( breaks, p.b )
            end
            if m == p.f then
                if term then
                    term.t = p.t
                    table.insert( breaks, m )
                else
                    term = p
                end
            elseif term then
                table.insert( events,  mw.clone( term ) )
                term = false
            else
                term = p
            end
            if p.e then
                table.insert( breaks, p.e )
            end
            if p.f then
            end
            m = p.t
            if m > r3 then
                r3 = m
            end
        end -- for j
        if term then
            table.insert( events, term )
        end
        for j = 1, #breaks do
            table.insert( events, breaks[ j ] )
        end -- for j
        r1[ sign ] = events
        if #events > r2 then
            r2 = #events
        end
    end -- for i
    return r1, r2, r3
end -- UserGroups.r.timeline.follows()



UserGroups.r.timeline.format = function ( accounts, along, arglist )
    -- Format data as <timeline>
    --     accounts  -- table, with data
    --     along     -- table, with alphabetical order
    --     arglist   -- table, with current request
    -- Returns
    --     1  --  mw.html.div
    --     2  --  table, with errors, or not
    local r1, r2
    if type( arglist ) == "table"  and
       type( arglist.codes ) == "table"  and
       #arglist.codes == 1  and
       type( arglist.codes[ 1 ] ) == "string"  and
       type( arglist.timeline ) == "table"  and
       type( arglist.timeline.template ) == "string" then
        local single = mw.text.trim( arglist.codes[ 1 ] )
        if single  and  single ~= "" then
            local Timeline = UserGroups.r.timeline
            local timed = Timeline.fetch( accounts, single )
            if timed then
                local rows, min = Timeline.first( timed )
                local terms, n, max = Timeline.follows( rows,
                                                        timed,
                                                        accounts )
                if n > 0 then
                    local story = Timeline.fill( terms, rows, n )
                    if story then
                        r1 = Timeline.factory( story,
                                               min,
                                               max,
                                               arglist )
                    end
                end
            end
        end
    end
    return r1, r2
end -- UserGroups.r.timeline.format()



UserGroups.r.ul.format = function ( accounts,
                                    along,
                                    arglist,
                                    about,
                                    amount )
    -- Format data as <ul>, or <ol>
    --     accounts  -- table, with data
    --     along     -- table, with order
    --     arglist   -- table, with current request
    --     about     -- ignored
    --     amount    -- boolean, for <ol>
    -- Returns
    --     1  --  mw.html.ul, or mw.html.ol
    local li, r, s
    if amount then
        s = "ol"
    else
        s = "ul"
    end
    r = mw.html.create( s )
    for i = 1, #along do
        s  = along[ i ]
        li = mw.html.create( "li" )
        li:wikitext( string.format( "[[user:%s|%s]]", s, s ) )
        r:newline()
         :node( li )
    end -- for i
    r:newline()
    return r
end -- UserGroups.r.ul.format()



UserGroups.DateTime.fetch = function ()
    -- Returns  object, or false if unavailable
    local r
    if UserGroups.DateTime.obj then
        r = UserGroups.DateTime.obj
    else
        local lucky, DateTime = pcall( require, "Module:DateTime" )
        if type( DateTime ) == "table" then
            UserGroups.DateTime.obj = DateTime.DateTime()
            r                       = UserGroups.DateTime.obj
        else
            UserGroups.DateTime.obj = true
        end
    end
    return r
end -- UserGroups.DateTime.fetch()



UserGroups.Remark.feed = function ( apply )
    -- Register one remark
    --     apply  -- string, with wikitext
    -- Returns  mw.html.span, with link to remark, or false if empty
    local r, s
    if type( apply ) == "string" then
        s = mw.text.trim( apply )
        if s == "" then
             s = false
        end
    end
    if s then
        local n, sign
        table.insert( UserGroups.Remark.collection, s )
        n    = #UserGroups.Remark.collection
        sign = UserGroups.Remark.fragment( n )
        r = mw.html.create( "span" )
        r:attr( "id", sign .. "*" )
         :css( "vertical-align", "super" )
         :wikitext( string.format( "[[#%s|(%d)]]", sign, n ) )
    end
    return r
end -- UserGroups.Remark.feed()



UserGroups.Remark.finish = function ( already )
    -- Append remarks to already, if any
    --     already  -- mw.html, with main content
    -- Returns  mw.html.div  or  already
    local n = #UserGroups.Remark.collection
    local div, r, sign, story
    if n > 0 then
        r = mw.html.create( "div" )
        r:newline()
         :node( already )
        for i = 1, n do
            story = UserGroups.Remark.collection[ i ]
            sign  = UserGroups.Remark.fragment( i )
            div   = mw.html.create( "div" )
            div:attr( "id", sign )
               :wikitext( string.format( "[[#%s*|(%d)]] %s",
                                         sign, i, story ) )
            r:newline()
             :node( div )
        end -- for i
        r:newline()
    else
        r = already
    end
    return r
end -- UserGroups.Remark.finish()



UserGroups.Remark.first = function ()
    -- Initialize or reset Remarks
    UserGroups.Remark.collection = { }
    UserGroups.Remark.sign = false
end -- UserGroups.Remark.first()



UserGroups.Remark.fragment = function ( at )
    -- Retrieve remark identifier
    --     at  -- number, in sequence
    if not UserGroups.Remark.sign then
        local id = math.floor( os.clock() * UserGroups.Remark.max )
        UserGroups.Remark.sign = string.format( "%s-%d-",
                                                UserGroups.suite, id )
    end
    return  string.format( "%s%d", UserGroups.Remark.sign, at )
end -- UserGroups.Remark.fragment()



Failsafe.failsafe = function ( atleast )
    -- Retrieve versioning and check for compliance
    -- Precondition:
    --     atleast  -- string, with required version
    --                         or wikidata|item|~|@ or false
    -- Postcondition:
    --     Returns  string  -- with queried version/item, also if problem
    --              false   -- if appropriate
    -- 2020-08-17
    local since  = atleast
    local last   = ( since == "~" )
    local linked = ( since == "@" )
    local link   = ( since == "item" )
    local r
    if last  or  link  or  linked  or  since == "wikidata" then
        local item = Failsafe.item
        since = false
        if type( item ) == "number"  and  item > 0 then
            local suited = string.format( "Q%d", item )
            if link then
                r = suited
            else
                local entity = mw.wikibase.getEntity( suited )
                if type( entity ) == "table" then
                    local seek = Failsafe.serialProperty or "P348"
                    local vsn  = entity:formatPropertyValues( seek )
                    if type( vsn ) == "table"  and
                       type( vsn.value ) == "string"  and
                       vsn.value ~= "" then
                        if last  and  vsn.value == Failsafe.serial then
                            r = false
                        elseif linked then
                            if mw.title.getCurrentTitle().prefixedText
                               ==  mw.wikibase.getSitelink( suited ) then
                                r = false
                            else
                                r = suited
                            end
                        else
                            r = vsn.value
                        end
                    end
                end
            end
        end
    end
    if type( r ) == "nil" then
        if not since  or  since <= Failsafe.serial then
            r = Failsafe.serial
        else
            r = false
        end
    end
    return r
end -- Failsafe.failsafe()



local p = { }

p.f = function ( frame )
    local params = { source = frame.args.source,
                     json   = frame.args.JSON,
                     sole   = frame.args.account,
                     subset = frame.args.pattern,
                     codes  = frame.args.codes,
                     scheme = frame.args.type,
                     class  = frame.args.class,
                     style  = frame.args.style,
                     id     = frame.args.id
    }
    local lucky, r
    UserGroups.frame = frame
    for k, v in pairs( params ) do
        if v == "" then
            params[ k ] = false
        end
    end -- for k, v
    if params.codes then
        params.codes = mw.text.split( params.codes, "%s+" )
    end
    if frame.args["active.state"] == "1" then
        params.live = true
    elseif frame.args["active.state"] == "0" then
        params.live = false
    end
    if frame.args.caption  and
       frame.args.caption ~= ""  and
       ( params.scheme == "table"  or
         params.scheme == "timeline" ) then
        params[ params.scheme ] = { caption = frame.args.caption }
    end
    if frame.args["table.columns"]  or
       frame.args["table.number"]  or
       frame.args["table.THclass"]  or
       frame.args["table.THstyle"] then
        params.table = params.table or { }
        params.table.columns = frame.args["table.columns"]
        params.table.number  = ( frame.args["table.number"] == "1" )
        params.table.THclass = frame.args["table.THclass"]
        params.table.THstyle = frame.args["table.THstyle"]
        for k, v in pairs( params.table ) do
            if v == "" then
                params.table[ k ] = false
            end
        end -- for k, v
        if params.table.columns then
            params.table.columns =
                             mw.text.split( params.table.columns, "%s+" )
        end
    end
    if frame.args["timeline.elected"]  or
       frame.args["timeline.previous"]  or
       frame.args["timeline.template"] then
        params.timeline = params.timeline or { }
        params.timeline.elected = frame.args["timeline.elected"]
        params.timeline.previous = frame.args["timeline.previous"]
        params.timeline.template = frame.args["timeline.template"]
        for k, v in pairs( params.timeline ) do
            if v == "" then
                params.timeline[ k ] = false
            end
        end -- for k, v
    end
    lucky, r = pcall( UserGroups.f, params )
    if not lucky then
        r = fault( r )
    end
    return tostring( r or "" )
end -- p.f()



p.failsafe = function ( frame )
    -- Versioning interface
    local s = type( frame )
    local since
    if s == "table" then
        since = frame.args[ 1 ]
    elseif s == "string" then
        since = frame
    end
    if since then
        since = mw.text.trim( since )
        if since == "" then
            since = false
        end
    end
    return Failsafe.failsafe( since )  or  ""
end -- p.failsafe



setmetatable( p,  { __call = function ( func, ... )
                                 setmetatable( p, nil )
                                 return Failsafe
                             end } )

return p    -- userGroups