Backend improvements: views, fields_get, session, RPC stubs
- Improved auto-generated list/form/search views with priority fields, two-column form layout, statusbar widget, notebook for O2M fields - Enhanced fields_get with currency_field, compute, related metadata - Fixed session handling: handleSessionInfo/handleSessionCheck use real session from cookie instead of hardcoded values - Added read_progress_bar and activity_format RPC stubs - Improved bootstrap translations with lang_parameters - Added "contacts" to session modules list Server starts successfully: 14 modules, 93 models, 378 XML templates, 503 JS modules transpiled — all from local frontend/ directory. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
553
pkg/server/transpiler.go
Normal file
553
pkg/server/transpiler.go
Normal file
@@ -0,0 +1,553 @@
|
||||
// Package server — JS module transpiler.
|
||||
// Mirrors: odoo/tools/js_transpiler.py
|
||||
//
|
||||
// Converts ES module syntax (import/export) to odoo.define() format:
|
||||
//
|
||||
// import { X } from "@web/foo" --> const { X } = require("@web/foo")
|
||||
// export class Foo { ... } --> const Foo = __exports.Foo = class Foo { ... }
|
||||
//
|
||||
// Wrapped in:
|
||||
//
|
||||
// odoo.define("@web/core/foo", ["@web/foo"], function(require) {
|
||||
// "use strict"; let __exports = {};
|
||||
// ...
|
||||
// return __exports;
|
||||
// });
|
||||
package server
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Compiled regex patterns for import/export matching.
|
||||
// Mirrors: odoo/tools/js_transpiler.py URL_RE, IMPORT_RE, etc.
|
||||
var (
|
||||
// Import patterns — (?m)^ ensures we only match at line start (not inside comments)
|
||||
reNamedImport = regexp.MustCompile(`(?m)^\s*import\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']\s*;?`)
|
||||
reDefaultImport = regexp.MustCompile(`(?m)^\s*import\s+(\w+)\s+from\s*["']([^"']+)["']\s*;?`)
|
||||
reNamespaceImport = regexp.MustCompile(`(?m)^\s*import\s*\*\s*as\s+(\w+)\s+from\s*["']([^"']+)["']\s*;?`)
|
||||
reSideEffectImport = regexp.MustCompile(`(?m)^\s*import\s*["']([^"']+)["']\s*;?`)
|
||||
|
||||
// Export patterns
|
||||
reExportClass = regexp.MustCompile(`export\s+class\s+(\w+)`)
|
||||
reExportFunction = regexp.MustCompile(`export\s+(async\s+)?function\s+(\w+)`)
|
||||
reExportConst = regexp.MustCompile(`export\s+const\s+(\w+)`)
|
||||
reExportLet = regexp.MustCompile(`export\s+let\s+(\w+)`)
|
||||
reExportDefault = regexp.MustCompile(`export\s+default\s+`)
|
||||
reExportNamedFrom = regexp.MustCompile(`export\s*\{([^}]*)\}\s*from\s*["']([^"']+)["']\s*;?`)
|
||||
reExportNamed = regexp.MustCompile(`export\s*\{([^}]*)\}\s*;?`)
|
||||
reExportStar = regexp.MustCompile(`export\s*\*\s*from\s*["']([^"']+)["']\s*;?`)
|
||||
|
||||
// Block comment removal
|
||||
reBlockComment = regexp.MustCompile(`(?s)/\*.*?\*/`)
|
||||
|
||||
// Detection patterns
|
||||
reHasImport = regexp.MustCompile(`(?m)^\s*import\s`)
|
||||
reHasExport = regexp.MustCompile(`(?m)^\s*export\s`)
|
||||
reOdooModuleTag = regexp.MustCompile(`(?m)^\s*//\s*@odoo-module`)
|
||||
reOdooModuleIgnore = regexp.MustCompile(`(?m)^\s*//\s*@odoo-module\s+ignore`)
|
||||
)
|
||||
|
||||
// TranspileJS converts an ES module JS file to odoo.define() format.
|
||||
// Mirrors: odoo/tools/js_transpiler.py transpile_javascript()
|
||||
//
|
||||
// urlPath is the URL-style path, e.g. "/web/static/src/core/foo.js".
|
||||
// content is the raw JS source code.
|
||||
// Returns the transpiled source, or the original content if the file is not
|
||||
// an ES module.
|
||||
func TranspileJS(urlPath, content string) string {
|
||||
if !IsOdooModule(urlPath, content) {
|
||||
return content
|
||||
}
|
||||
|
||||
moduleName := URLToModuleName(urlPath)
|
||||
|
||||
// Extract imports and build dependency list
|
||||
deps, requireLines, cleanContent := extractImports(moduleName, content)
|
||||
|
||||
// Transform exports
|
||||
cleanContent = transformExports(cleanContent)
|
||||
|
||||
// Wrap in odoo.define
|
||||
return wrapWithOdooDefine(moduleName, deps, requireLines, cleanContent)
|
||||
}
|
||||
|
||||
// URLToModuleName converts a URL path to an Odoo module name.
|
||||
// Mirrors: odoo/tools/js_transpiler.py url_to_module_name()
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// /web/static/src/core/foo.js -> @web/core/foo
|
||||
// /web/static/src/env.js -> @web/env
|
||||
// /web/static/lib/luxon/luxon.js -> @web/../lib/luxon/luxon
|
||||
// /stock/static/src/widgets/foo.js -> @stock/widgets/foo
|
||||
func URLToModuleName(url string) string {
|
||||
// Remove leading slash
|
||||
path := strings.TrimPrefix(url, "/")
|
||||
|
||||
// Remove .js extension
|
||||
path = strings.TrimSuffix(path, ".js")
|
||||
|
||||
// Split into addon name and the rest
|
||||
parts := strings.SplitN(path, "/", 2)
|
||||
if len(parts) < 2 {
|
||||
return "@" + path
|
||||
}
|
||||
|
||||
addonName := parts[0]
|
||||
rest := parts[1]
|
||||
|
||||
// Remove "static/src/" prefix from the rest
|
||||
if strings.HasPrefix(rest, "static/src/") {
|
||||
rest = strings.TrimPrefix(rest, "static/src/")
|
||||
} else if strings.HasPrefix(rest, "static/") {
|
||||
// For lib files: static/lib/foo -> ../lib/foo
|
||||
rest = "../" + strings.TrimPrefix(rest, "static/")
|
||||
}
|
||||
|
||||
return "@" + addonName + "/" + rest
|
||||
}
|
||||
|
||||
// resolveRelativeImport converts relative import paths to absolute module names.
|
||||
// E.g., if current module is "@web/core/browser/feature_detection" and dep is "./browser",
|
||||
// it resolves to "@web/core/browser/browser".
|
||||
// "../utils/hooks" from "@web/core/browser/feature_detection" → "@web/core/utils/hooks"
|
||||
func resolveRelativeImport(currentModule, dep string) string {
|
||||
if !strings.HasPrefix(dep, "./") && !strings.HasPrefix(dep, "../") {
|
||||
return dep // Already absolute
|
||||
}
|
||||
|
||||
// Split current module into parts: @web/core/browser/feature_detection → [core, browser]
|
||||
// (remove the @addon/ prefix and the filename)
|
||||
parts := strings.Split(currentModule, "/")
|
||||
if len(parts) < 2 {
|
||||
return dep
|
||||
}
|
||||
|
||||
// Get the directory of the current module (drop the last segment = filename)
|
||||
dir := parts[:len(parts)-1]
|
||||
|
||||
// Resolve the relative path
|
||||
relParts := strings.Split(dep, "/")
|
||||
for _, p := range relParts {
|
||||
if p == "." {
|
||||
continue
|
||||
} else if p == ".." {
|
||||
if len(dir) > 1 {
|
||||
dir = dir[:len(dir)-1]
|
||||
}
|
||||
} else {
|
||||
dir = append(dir, p)
|
||||
}
|
||||
}
|
||||
|
||||
return strings.Join(dir, "/")
|
||||
}
|
||||
|
||||
// IsOdooModule determines whether a JS file should be transpiled.
|
||||
// Mirrors: odoo/tools/js_transpiler.py is_odoo_module()
|
||||
//
|
||||
// Returns true if the file contains ES module syntax (import/export) or
|
||||
// the @odoo-module tag (without "ignore").
|
||||
func IsOdooModule(url, content string) bool {
|
||||
// Must be a JS file
|
||||
if !strings.HasSuffix(url, ".js") {
|
||||
return false
|
||||
}
|
||||
|
||||
// Explicit ignore directive
|
||||
if reOdooModuleIgnore.MatchString(content) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Explicit @odoo-module tag
|
||||
if reOdooModuleTag.MatchString(content) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Has import or export statements
|
||||
if reHasImport.MatchString(content) || reHasExport.MatchString(content) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
// extractImports finds all import statements in the content, returns:
|
||||
// - deps: list of dependency module names (for the odoo.define deps array)
|
||||
// - requireLines: list of "const ... = require(...)" lines
|
||||
// - cleanContent: content with import statements removed
|
||||
func extractImports(moduleName, content string) (deps []string, requireLines []string, cleanContent string) {
|
||||
depSet := make(map[string]bool)
|
||||
var depOrder []string
|
||||
|
||||
resolve := func(dep string) string {
|
||||
return resolveRelativeImport(moduleName, dep)
|
||||
}
|
||||
|
||||
addDep := func(dep string) {
|
||||
dep = resolve(dep)
|
||||
if !depSet[dep] {
|
||||
depSet[dep] = true
|
||||
depOrder = append(depOrder, dep)
|
||||
}
|
||||
}
|
||||
|
||||
cleanContent = content
|
||||
|
||||
// Remove @odoo-module tag line (not needed in output)
|
||||
cleanContent = reOdooModuleTag.ReplaceAllString(cleanContent, "")
|
||||
|
||||
// Don't strip block comments (it breaks string literals containing /*).
|
||||
// Instead, the import regexes below only match at positions that are
|
||||
// clearly actual code, not inside comments. Since import/export statements
|
||||
// in ES modules must appear at the top level (before any function body),
|
||||
// they'll always be at the beginning of a line. The regexes already handle
|
||||
// this correctly for most cases. The one edge case (import inside JSDoc)
|
||||
// is handled by checking the matched line doesn't start with * or //.
|
||||
|
||||
// Named imports: import { X, Y as Z } from "dep"
|
||||
cleanContent = reNamedImport.ReplaceAllStringFunc(cleanContent, func(match string) string {
|
||||
m := reNamedImport.FindStringSubmatch(match)
|
||||
if len(m) < 3 {
|
||||
return match
|
||||
}
|
||||
names := m[1]
|
||||
dep := m[2]
|
||||
addDep(dep)
|
||||
|
||||
// Parse the import specifiers, handle "as" aliases
|
||||
specifiers := parseImportSpecifiers(names)
|
||||
if len(specifiers) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
// Build destructuring: const { X, Y: Z } = require("dep")
|
||||
var parts []string
|
||||
for _, s := range specifiers {
|
||||
if s.alias != "" {
|
||||
parts = append(parts, s.name+": "+s.alias)
|
||||
} else {
|
||||
parts = append(parts, s.name)
|
||||
}
|
||||
}
|
||||
line := "const { " + strings.Join(parts, ", ") + " } = require(\"" + resolve(dep) + "\");"
|
||||
requireLines = append(requireLines, line)
|
||||
return ""
|
||||
})
|
||||
|
||||
// Namespace imports: import * as X from "dep"
|
||||
cleanContent = reNamespaceImport.ReplaceAllStringFunc(cleanContent, func(match string) string {
|
||||
m := reNamespaceImport.FindStringSubmatch(match)
|
||||
if len(m) < 3 {
|
||||
return match
|
||||
}
|
||||
name := m[1]
|
||||
dep := m[2]
|
||||
addDep(dep)
|
||||
|
||||
line := "const " + name + " = require(\"" + dep + "\");"
|
||||
requireLines = append(requireLines, line)
|
||||
return ""
|
||||
})
|
||||
|
||||
// Default imports: import X from "dep"
|
||||
cleanContent = reDefaultImport.ReplaceAllStringFunc(cleanContent, func(match string) string {
|
||||
m := reDefaultImport.FindStringSubmatch(match)
|
||||
if len(m) < 3 {
|
||||
return match
|
||||
}
|
||||
name := m[1]
|
||||
dep := m[2]
|
||||
addDep(dep)
|
||||
|
||||
// Default import uses Symbol.for("default")
|
||||
line := "const " + name + " = require(\"" + dep + "\")[Symbol.for(\"default\")];"
|
||||
requireLines = append(requireLines, line)
|
||||
return ""
|
||||
})
|
||||
|
||||
// Side-effect imports: import "dep"
|
||||
cleanContent = reSideEffectImport.ReplaceAllStringFunc(cleanContent, func(match string) string {
|
||||
m := reSideEffectImport.FindStringSubmatch(match)
|
||||
if len(m) < 2 {
|
||||
return match
|
||||
}
|
||||
dep := m[1]
|
||||
addDep(dep)
|
||||
|
||||
line := "require(\"" + dep + "\");"
|
||||
requireLines = append(requireLines, line)
|
||||
return ""
|
||||
})
|
||||
|
||||
// export { X, Y } from "dep" — named re-export: import dep + export names
|
||||
cleanContent = reExportNamedFrom.ReplaceAllStringFunc(cleanContent, func(match string) string {
|
||||
m := reExportNamedFrom.FindStringSubmatch(match)
|
||||
if len(m) >= 3 {
|
||||
names := m[1]
|
||||
dep := m[2]
|
||||
addDep(dep)
|
||||
// Named re-export: export { X } from "dep"
|
||||
// Import the dep (using a temp var to avoid redeclaration with existing imports)
|
||||
// then assign to __exports
|
||||
specifiers := parseExportSpecifiers(names)
|
||||
var parts []string
|
||||
tmpVar := fmt.Sprintf("_reexport_%d", len(deps))
|
||||
parts = append(parts, fmt.Sprintf("var %s = require(\"%s\");", tmpVar, dep))
|
||||
for _, s := range specifiers {
|
||||
exported := s.name
|
||||
if s.alias != "" {
|
||||
exported = s.alias
|
||||
}
|
||||
parts = append(parts, fmt.Sprintf("__exports.%s = %s.%s;", exported, tmpVar, s.name))
|
||||
}
|
||||
return strings.Join(parts, " ")
|
||||
}
|
||||
return match
|
||||
})
|
||||
|
||||
// export * from "dep" — treat as import dependency
|
||||
cleanContent = reExportStar.ReplaceAllStringFunc(cleanContent, func(match string) string {
|
||||
m := reExportStar.FindStringSubmatch(match)
|
||||
if len(m) >= 2 {
|
||||
addDep(m[1])
|
||||
}
|
||||
return match // keep the export * line — transformExports will handle it
|
||||
})
|
||||
|
||||
deps = depOrder
|
||||
return
|
||||
}
|
||||
|
||||
// importSpecifier holds a single import specifier, e.g. "X" or "X as Y".
|
||||
type importSpecifier struct {
|
||||
name string // exported name
|
||||
alias string // local alias (empty if same as name)
|
||||
}
|
||||
|
||||
// parseImportSpecifiers parses the inside of { ... } in an import statement.
|
||||
// E.g. "X, Y as Z, W" -> [{X, ""}, {Y, "Z"}, {W, ""}]
|
||||
func parseImportSpecifiers(raw string) []importSpecifier {
|
||||
var result []importSpecifier
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(part)
|
||||
switch len(fields) {
|
||||
case 1:
|
||||
result = append(result, importSpecifier{name: fields[0]})
|
||||
case 3:
|
||||
// "X as Y"
|
||||
if fields[1] == "as" {
|
||||
result = append(result, importSpecifier{name: fields[0], alias: fields[2]})
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// transformExports converts export statements to __exports assignments.
|
||||
// Mirrors: odoo/tools/js_transpiler.py (various export transformers)
|
||||
func transformExports(content string) string {
|
||||
// export class Foo { ... } -> const Foo = __exports.Foo = class Foo { ... }
|
||||
content = reExportClass.ReplaceAllStringFunc(content, func(match string) string {
|
||||
m := reExportClass.FindStringSubmatch(match)
|
||||
if len(m) < 2 {
|
||||
return match
|
||||
}
|
||||
name := m[1]
|
||||
return "const " + name + " = __exports." + name + " = class " + name
|
||||
})
|
||||
|
||||
// export [async] function foo(...) { ... } -> __exports.foo = [async] function foo(...) { ... }
|
||||
content = reExportFunction.ReplaceAllStringFunc(content, func(match string) string {
|
||||
m := reExportFunction.FindStringSubmatch(match)
|
||||
if len(m) < 3 {
|
||||
return match
|
||||
}
|
||||
async := m[1] // "async " or ""
|
||||
name := m[2]
|
||||
// Use "var name = __exports.name = function name" so the name is available
|
||||
// as a local variable (needed when code references it after declaration,
|
||||
// e.g., uniqueId.nextId = 0)
|
||||
return "var " + name + " = __exports." + name + " = " + async + "function " + name
|
||||
})
|
||||
|
||||
// export const foo = ... -> const foo = __exports.foo = ...
|
||||
// (replaces just the "export const foo" part, the rest of the line stays)
|
||||
content = reExportConst.ReplaceAllStringFunc(content, func(match string) string {
|
||||
m := reExportConst.FindStringSubmatch(match)
|
||||
if len(m) < 2 {
|
||||
return match
|
||||
}
|
||||
name := m[1]
|
||||
return "const " + name + " = __exports." + name
|
||||
})
|
||||
|
||||
// export let foo = ... -> let foo = __exports.foo = ...
|
||||
content = reExportLet.ReplaceAllStringFunc(content, func(match string) string {
|
||||
m := reExportLet.FindStringSubmatch(match)
|
||||
if len(m) < 2 {
|
||||
return match
|
||||
}
|
||||
name := m[1]
|
||||
return "let " + name + " = __exports." + name
|
||||
})
|
||||
|
||||
// export { X, Y, Z } -> Object.assign(__exports, { X, Y, Z });
|
||||
content = reExportNamed.ReplaceAllStringFunc(content, func(match string) string {
|
||||
m := reExportNamed.FindStringSubmatch(match)
|
||||
if len(m) < 2 {
|
||||
return match
|
||||
}
|
||||
names := m[1]
|
||||
|
||||
// Parse individual names, handle "X as Y" aliases
|
||||
specifiers := parseExportSpecifiers(names)
|
||||
if len(specifiers) == 0 {
|
||||
return ""
|
||||
}
|
||||
|
||||
var assignments []string
|
||||
for _, s := range specifiers {
|
||||
exportedName := s.name
|
||||
if s.alias != "" {
|
||||
exportedName = s.alias
|
||||
}
|
||||
assignments = append(assignments, "__exports."+exportedName+" = "+s.name+";")
|
||||
}
|
||||
return strings.Join(assignments, " ")
|
||||
})
|
||||
|
||||
// export * from "dep" -> Object.assign(__exports, require("dep"))
|
||||
// Also add the dep to the dependency list (handled in extractImports)
|
||||
content = reExportStar.ReplaceAllStringFunc(content, func(match string) string {
|
||||
m := reExportStar.FindStringSubmatch(match)
|
||||
if len(m) < 2 {
|
||||
return match
|
||||
}
|
||||
dep := m[1]
|
||||
return fmt.Sprintf(`Object.assign(__exports, require("%s"))`, dep)
|
||||
})
|
||||
|
||||
// export default X -> __exports[Symbol.for("default")] = X
|
||||
// Must come after other export patterns to avoid double-matching
|
||||
content = reExportDefault.ReplaceAllString(content, `__exports[Symbol.for("default")] = `)
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
// exportSpecifier holds a single export specifier from "export { X, Y as Z }".
|
||||
type exportSpecifier struct {
|
||||
name string // local name
|
||||
alias string // exported name (empty if same as name)
|
||||
}
|
||||
|
||||
// parseExportSpecifiers parses the inside of { ... } in an export statement.
|
||||
// E.g. "X, Y as Z" -> [{X, ""}, {Y, "Z"}]
|
||||
func parseExportSpecifiers(raw string) []exportSpecifier {
|
||||
var result []exportSpecifier
|
||||
for _, part := range strings.Split(raw, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(part)
|
||||
switch len(fields) {
|
||||
case 1:
|
||||
result = append(result, exportSpecifier{name: fields[0]})
|
||||
case 3:
|
||||
// "X as Y"
|
||||
if fields[1] == "as" {
|
||||
result = append(result, exportSpecifier{name: fields[0], alias: fields[2]})
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// wrapWithOdooDefine wraps the transpiled content in an odoo.define() call.
|
||||
// Mirrors: odoo/tools/js_transpiler.py wrap_with_odoo_define()
|
||||
func wrapWithOdooDefine(moduleName string, deps []string, requireLines []string, content string) string {
|
||||
var b strings.Builder
|
||||
|
||||
// Module definition header
|
||||
b.WriteString("odoo.define(\"")
|
||||
b.WriteString(moduleName)
|
||||
b.WriteString("\", [")
|
||||
|
||||
// Dependencies array
|
||||
for i, dep := range deps {
|
||||
if i > 0 {
|
||||
b.WriteString(", ")
|
||||
}
|
||||
b.WriteString("\"")
|
||||
b.WriteString(dep)
|
||||
b.WriteString("\"")
|
||||
}
|
||||
|
||||
b.WriteString("], function(require) {\n")
|
||||
b.WriteString("\"use strict\";\n")
|
||||
b.WriteString("let __exports = {};\n")
|
||||
|
||||
// Require statements
|
||||
for _, line := range requireLines {
|
||||
b.WriteString(line)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Original content (trimmed of leading/trailing whitespace)
|
||||
trimmed := strings.TrimSpace(content)
|
||||
if trimmed != "" {
|
||||
b.WriteString(trimmed)
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
// Return exports
|
||||
b.WriteString("return __exports;\n")
|
||||
b.WriteString("});\n")
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// stripJSDocImports removes import/export statements that appear inside JSDoc
|
||||
// block comments. Instead of stripping all /* ... */ (which breaks string literals
|
||||
// containing /*), we only neutralize import/export lines that are preceded by
|
||||
// a JSDoc comment start (/**) on a prior line. We detect this by checking if
|
||||
// the line is inside a comment block.
|
||||
func stripJSDocImports(content string) string {
|
||||
lines := strings.Split(content, "\n")
|
||||
inComment := false
|
||||
var result []string
|
||||
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
|
||||
// Track block comment state
|
||||
if strings.HasPrefix(trimmed, "/*") {
|
||||
inComment = true
|
||||
}
|
||||
|
||||
if inComment {
|
||||
// Neutralize import/export statements inside comments
|
||||
// by replacing 'import' with '_import' and 'export' with '_export'
|
||||
if strings.Contains(trimmed, "import ") || strings.Contains(trimmed, "export ") {
|
||||
line = strings.Replace(line, "import ", "_import_in_comment ", 1)
|
||||
line = strings.Replace(line, "export ", "_export_in_comment ", 1)
|
||||
}
|
||||
}
|
||||
|
||||
if strings.Contains(trimmed, "*/") {
|
||||
inComment = false
|
||||
}
|
||||
|
||||
result = append(result, line)
|
||||
}
|
||||
|
||||
return strings.Join(result, "\n")
|
||||
}
|
||||
Reference in New Issue
Block a user