mirror of
https://github.com/JuanCanham/HackMyResume.git
synced 2025-05-03 04:47:07 +01:00
Refactor generation.
Merge implicit and explicit generation paths, start emitting file transform & copy signals, fix various bugs, introduce new bugs, support better --debug outputs in the future.
This commit is contained in:
@ -33,3 +33,6 @@ module.exports =
|
||||
afterInlineConvert: 23
|
||||
beforeValidate: 24
|
||||
afterValidate: 25
|
||||
beforeWrite: 26
|
||||
afterWrite: 27
|
||||
applyTheme: 28
|
||||
|
@ -69,13 +69,8 @@ class FRESHTheme
|
||||
cached[ th ] = cached[th] || new FRESHTheme().open( themePath )
|
||||
formatsHash[ key ] = cached[ th ].getFormat( key )
|
||||
|
||||
# Check for an explicit "formats" entry in the theme JSON. If it has one,
|
||||
# then this theme declares its files explicitly.
|
||||
if !!@formats
|
||||
formatsHash = loadExplicit.call this, formatsHash
|
||||
@explicit = true;
|
||||
else
|
||||
formatsHash = loadImplicit.call this, formatsHash
|
||||
# Load theme files
|
||||
formatsHash = _load.call @, formatsHash
|
||||
|
||||
# Cache
|
||||
@formats = formatsHash
|
||||
@ -91,9 +86,10 @@ class FRESHTheme
|
||||
getFormat: ( fmt ) -> @formats[ fmt ]
|
||||
|
||||
|
||||
|
||||
### Load the theme implicitly, by scanning the theme folder for files. TODO:
|
||||
Refactor duplicated code with loadExplicit. ###
|
||||
loadImplicit = (formatsHash) ->
|
||||
_load = (formatsHash) ->
|
||||
|
||||
# Set up a hash of formats supported by this theme.
|
||||
that = @
|
||||
@ -102,31 +98,43 @@ loadImplicit = (formatsHash) ->
|
||||
# Establish the base theme folder
|
||||
tplFolder = PATH.join @folder, 'src'
|
||||
|
||||
copyOnly = ['.ttf','.otf', '.png','.jpg','.jpeg','.pdf']
|
||||
|
||||
# Iterate over all files in the theme folder, producing an array, fmts,
|
||||
# containing info for each file. While we're doing that, also build up
|
||||
# the formatsHash object.
|
||||
fmts = READFILES(tplFolder).map (absPath) ->
|
||||
|
||||
# If this file lives in a specific format folder within the theme,
|
||||
# such as "/latex" or "/html", then that format is the output format
|
||||
# for all files within the folder.
|
||||
pathInfo = parsePath absPath
|
||||
absPathSafe = absPath.trim().toLowerCase()
|
||||
outFmt = ''
|
||||
isMajor = false
|
||||
portion = pathInfo.dirname.replace tplFolder,''
|
||||
if portion && portion.trim()
|
||||
return if portion[1] == '_'
|
||||
reg = /^(?:\/|\\)(html|latex|doc|pdf|png|partials)(?:\/|\\)?/ig
|
||||
res = reg.exec( portion )
|
||||
if res
|
||||
if res[1] != 'partials'
|
||||
outFmt = res[1]
|
||||
else
|
||||
that.partials = that.partials || []
|
||||
that.partials.push( { name: pathInfo.name, path: absPath } )
|
||||
return null
|
||||
|
||||
# If this file is mentioned in the theme's JSON file under "transforms"
|
||||
if that.formats
|
||||
outFmt = _.find Object.keys( that.formats ), ( fmtKey ) ->
|
||||
fmtVal = that.formats[ fmtKey ]
|
||||
_.some fmtVal.transform, (fpath) ->
|
||||
absPathB = PATH.join( that.folder, fpath ).trim().toLowerCase()
|
||||
absPathB == absPathSafe
|
||||
isMajor = true if outFmt
|
||||
|
||||
if !outFmt
|
||||
# If this file lives in a specific format folder within the theme,
|
||||
# such as "/latex" or "/html", then that format is the output format
|
||||
# for all files within the folder.
|
||||
portion = pathInfo.dirname.replace tplFolder,''
|
||||
if portion && portion.trim()
|
||||
return if portion[1] == '_'
|
||||
reg = /^(?:\/|\\)(html|latex|doc|pdf|png|partials)(?:\/|\\)?/ig
|
||||
res = reg.exec( portion )
|
||||
if res
|
||||
if res[1] != 'partials'
|
||||
outFmt = res[1]
|
||||
else
|
||||
that.partials = that.partials || []
|
||||
that.partials.push( { name: pathInfo.name, path: absPath } )
|
||||
return null
|
||||
|
||||
# Otherwise, the output format is inferred from the filename, as in
|
||||
# compact-[outputformat].[extension], for ex, compact-pdf.html.
|
||||
@ -135,23 +143,27 @@ loadImplicit = (formatsHash) ->
|
||||
outFmt = if idx == -1 then pathInfo.name else pathInfo.name.substr( idx + 1 )
|
||||
isMajor = true
|
||||
|
||||
act = if _.contains copyOnly, pathInfo.extname then 'copy' else 'transform'
|
||||
|
||||
# We should have a valid output format now.
|
||||
formatsHash[ outFmt ] = formatsHash[outFmt] || {
|
||||
outFormat: outFmt,
|
||||
files: []
|
||||
}
|
||||
if that.formats?[ outFmt ]?.symLinks
|
||||
formatsHash[ outFmt ].symLinks = that.formats[ outFmt ].symLinks
|
||||
|
||||
# Create the file representation object.
|
||||
obj =
|
||||
action: 'transform'
|
||||
action: act
|
||||
path: absPath
|
||||
major: isMajor
|
||||
orgPath: PATH.relative(tplFolder, absPath)
|
||||
ext: pathInfo.extname.slice(1)
|
||||
title: friendlyName( outFmt )
|
||||
orgPath: PATH.relative tplFolder, absPath
|
||||
ext: pathInfo.extname.slice 1
|
||||
title: friendlyName outFmt
|
||||
pre: outFmt
|
||||
# outFormat: outFmt || pathInfo.name,
|
||||
data: FS.readFileSync( absPath, 'utf8' )
|
||||
data: FS.readFileSync absPath, 'utf8'
|
||||
css: null
|
||||
|
||||
# Add this file to the list of files for this format type.
|
||||
@ -180,87 +192,90 @@ loadImplicit = (formatsHash) ->
|
||||
|
||||
|
||||
|
||||
###
|
||||
Load the theme explicitly, by following the 'formats' hash
|
||||
in the theme's JSON settings file.
|
||||
###
|
||||
loadExplicit = (formatsHash) ->
|
||||
|
||||
# Housekeeping
|
||||
tplFolder = PATH.join this.folder, 'src'
|
||||
act = null
|
||||
that = this
|
||||
|
||||
# Iterate over all files in the theme folder, producing an array, fmts,
|
||||
# containing info for each file. While we're doing that, also build up
|
||||
# the formatsHash object.
|
||||
fmts = READFILES( tplFolder ).map (absPath) ->
|
||||
|
||||
act = null
|
||||
# If this file is mentioned in the theme's JSON file under "transforms"
|
||||
pathInfo = parsePath(absPath)
|
||||
absPathSafe = absPath.trim().toLowerCase()
|
||||
outFmt = _.find Object.keys( that.formats ), ( fmtKey ) ->
|
||||
fmtVal = that.formats[ fmtKey ]
|
||||
_.some fmtVal.transform, (fpath) ->
|
||||
absPathB = PATH.join( that.folder, fpath ).trim().toLowerCase()
|
||||
absPathB == absPathSafe
|
||||
|
||||
act = 'transform' if outFmt
|
||||
|
||||
# If this file lives in a specific format folder within the theme,
|
||||
# such as "/latex" or "/html", then that format is the output format
|
||||
# for all files within the folder.
|
||||
if !outFmt
|
||||
portion = pathInfo.dirname.replace tplFolder,''
|
||||
if portion && portion.trim()
|
||||
reg = /^(?:\/|\\)(html|latex|doc|pdf)(?:\/|\\)?/ig
|
||||
res = reg.exec portion
|
||||
res && (outFmt = res[1])
|
||||
|
||||
# Otherwise, the output format is inferred from the filename, as in
|
||||
# compact-[outputformat].[extension], for ex, compact-pdf.html.
|
||||
if !outFmt
|
||||
idx = pathInfo.name.lastIndexOf '-'
|
||||
outFmt = if (idx == -1) then pathInfo.name else pathInfo.name.substr(idx + 1)
|
||||
|
||||
# We should have a valid output format now.
|
||||
formatsHash[ outFmt ] =
|
||||
formatsHash[ outFmt ] || {
|
||||
outFormat: outFmt,
|
||||
files: [],
|
||||
symLinks: that.formats[ outFmt ].symLinks
|
||||
};
|
||||
|
||||
# Create the file representation object.
|
||||
obj =
|
||||
action: act
|
||||
orgPath: PATH.relative(that.folder, absPath)
|
||||
path: absPath
|
||||
ext: pathInfo.extname.slice(1)
|
||||
title: friendlyName( outFmt )
|
||||
pre: outFmt
|
||||
# outFormat: outFmt || pathInfo.name,
|
||||
data: FS.readFileSync( absPath, 'utf8' )
|
||||
css: null
|
||||
|
||||
# Add this file to the list of files for this format type.
|
||||
formatsHash[ outFmt ].files.push( obj )
|
||||
obj
|
||||
|
||||
# Now, get all the CSS files...
|
||||
@cssFiles = fmts.filter ( fmt ) -> fmt.ext == 'css'
|
||||
|
||||
# For each CSS file, get its corresponding HTML file
|
||||
@cssFiles.forEach ( cssf ) ->
|
||||
# For each CSS file, get its corresponding HTML file
|
||||
idx = _.findIndex fmts, ( fmt ) ->
|
||||
fmt.pre == cssf.pre && fmt.ext == 'html'
|
||||
fmts[ idx ].css = cssf.data;
|
||||
fmts[ idx ].cssPath = cssf.path;
|
||||
# Remove CSS files from the formats array
|
||||
fmts = fmts.filter ( fmt) -> fmt.ext != 'css'
|
||||
formatsHash
|
||||
# ###
|
||||
# Load the theme explicitly, by following the 'formats' hash
|
||||
# in the theme's JSON settings file.
|
||||
# ###
|
||||
# loadExplicit = (formatsHash) ->
|
||||
#
|
||||
# # Housekeeping
|
||||
# tplFolder = PATH.join this.folder, 'src'
|
||||
# act = null
|
||||
# that = this
|
||||
#
|
||||
# # Iterate over all files in the theme folder, producing an array, fmts,
|
||||
# # containing info for each file. While we're doing that, also build up
|
||||
# # the formatsHash object.
|
||||
# fmts = READFILES( tplFolder ).map (absPath) ->
|
||||
#
|
||||
# act = null
|
||||
#
|
||||
# pathInfo = parsePath absPath
|
||||
# absPathSafe = absPath.trim().toLowerCase()
|
||||
#
|
||||
# # If this file is mentioned in the theme's JSON file under "transforms"
|
||||
# outFmt = _.find Object.keys( that.formats ), ( fmtKey ) ->
|
||||
# fmtVal = that.formats[ fmtKey ]
|
||||
# _.some fmtVal.transform, (fpath) ->
|
||||
# absPathB = PATH.join( that.folder, fpath ).trim().toLowerCase()
|
||||
# absPathB == absPathSafe
|
||||
#
|
||||
# act = 'transform' if outFmt
|
||||
#
|
||||
# # If this file lives in a specific format folder within the theme,
|
||||
# # such as "/latex" or "/html", then that format is the output format
|
||||
# # for all files within the folder.
|
||||
# if !outFmt
|
||||
# portion = pathInfo.dirname.replace tplFolder,''
|
||||
# if portion && portion.trim()
|
||||
# reg = /^(?:\/|\\)(html|latex|doc|pdf)(?:\/|\\)?/ig
|
||||
# res = reg.exec portion
|
||||
# res && (outFmt = res[1])
|
||||
#
|
||||
# # Otherwise, the output format is inferred from the filename, as in
|
||||
# # compact-[outputformat].[extension], for ex, compact-pdf.html.
|
||||
# if !outFmt
|
||||
# idx = pathInfo.name.lastIndexOf '-'
|
||||
# outFmt = if (idx == -1) then pathInfo.name else pathInfo.name.substr(idx + 1)
|
||||
#
|
||||
# # We should have a valid output format now.
|
||||
# formatsHash[ outFmt ] =
|
||||
# formatsHash[ outFmt ] || {
|
||||
# outFormat: outFmt,
|
||||
# files: [],
|
||||
# symLinks: that.formats[ outFmt ].symLinks
|
||||
# };
|
||||
#
|
||||
# # Create the file representation object.
|
||||
# obj =
|
||||
# action: act
|
||||
# orgPath: PATH.relative(that.folder, absPath)
|
||||
# path: absPath
|
||||
# ext: pathInfo.extname.slice(1)
|
||||
# title: friendlyName( outFmt )
|
||||
# pre: outFmt
|
||||
# # outFormat: outFmt || pathInfo.name,
|
||||
# data: FS.readFileSync( absPath, 'utf8' )
|
||||
# css: null
|
||||
#
|
||||
# # Add this file to the list of files for this format type.
|
||||
# formatsHash[ outFmt ].files.push( obj )
|
||||
# obj
|
||||
#
|
||||
# # Now, get all the CSS files...
|
||||
# @cssFiles = fmts.filter ( fmt ) -> fmt.ext == 'css'
|
||||
#
|
||||
# # For each CSS file, get its corresponding HTML file
|
||||
# @cssFiles.forEach ( cssf ) ->
|
||||
# # For each CSS file, get its corresponding HTML file
|
||||
# idx = _.findIndex fmts, ( fmt ) ->
|
||||
# fmt.pre == cssf.pre && fmt.ext == 'html'
|
||||
# fmts[ idx ].css = cssf.data
|
||||
# fmts[ idx ].cssPath = cssf.path
|
||||
#
|
||||
# # Remove CSS files from the formats array
|
||||
# fmts = fmts.filter ( fmt) -> fmt.ext != 'css'
|
||||
# formatsHash
|
||||
|
||||
|
||||
|
||||
|
@ -29,6 +29,8 @@ plain text, and XML versions of Microsoft Word, Excel, and OpenOffice.
|
||||
|
||||
module.exports = class TemplateGenerator extends BaseGenerator
|
||||
|
||||
|
||||
|
||||
###* Constructor. Set the output format and template format for this
|
||||
generator. Will usually be called by a derived generator such as
|
||||
HTMLGenerator or MarkdownGenerator. ###
|
||||
@ -60,19 +62,24 @@ module.exports = class TemplateGenerator extends BaseGenerator
|
||||
curFmt.files = _.sortBy curFmt.files, (fi) -> fi.ext != 'css'
|
||||
|
||||
# Run the transformation!
|
||||
results = curFmt.files.map( ( tplInfo, idx ) ->
|
||||
trx = @.single rez, tplInfo.data, this.format, opts, opts.themeObj, curFmt
|
||||
if tplInfo.ext == 'css'
|
||||
curFmt.files[idx].data = trx
|
||||
else tplInfo.ext == 'html'
|
||||
#tplInfo.css contains the CSS data loaded by theme
|
||||
#tplInfo.cssPath contains the absolute path to the source CSS File
|
||||
results = curFmt.files.map ( tplInfo, idx ) ->
|
||||
if tplInfo.action == 'transform'
|
||||
trx = @transform rez, tplInfo.data, this.format, opts, opts.themeObj, curFmt
|
||||
if tplInfo.ext == 'css'
|
||||
curFmt.files[idx].data = trx
|
||||
else tplInfo.ext == 'html'
|
||||
#tplInfo.css contains the CSS data loaded by theme
|
||||
#tplInfo.cssPath contains the absolute path to the source CSS File
|
||||
else
|
||||
# Images and non-transformable binary files
|
||||
opts.onTransform? tplInfo
|
||||
return info: tplInfo, data: trx
|
||||
, @)
|
||||
, @
|
||||
|
||||
files: results
|
||||
|
||||
|
||||
|
||||
###* Generate a resume using file-based inputs and outputs. Requires access
|
||||
to the local filesystem.
|
||||
@method generate
|
||||
@ -83,7 +90,7 @@ module.exports = class TemplateGenerator extends BaseGenerator
|
||||
generate: ( rez, f, opts ) ->
|
||||
|
||||
# Prepare
|
||||
this.opts = EXTEND( true, { }, _defaultOpts, opts );
|
||||
@opts = EXTEND( true, { }, _defaultOpts, opts );
|
||||
|
||||
# Call the string-based generation method to perform the generation.
|
||||
genInfo = this.invoke( rez, null )
|
||||
@ -93,43 +100,49 @@ module.exports = class TemplateGenerator extends BaseGenerator
|
||||
# Process individual files within this format. For example, the HTML
|
||||
# output format for a theme may have multiple HTML files, CSS files,
|
||||
# etc. Process them here.
|
||||
genInfo.files.forEach(( file ) ->
|
||||
genInfo.files.forEach ( file ) ->
|
||||
|
||||
# Pre-processing
|
||||
file.info.orgPath = file.info.orgPath || '' # <-- For JRS themes
|
||||
file.info.orgPath = file.info.orgPath || ''
|
||||
thisFilePath = PATH.join( outFolder, file.info.orgPath )
|
||||
if this.onBeforeSave
|
||||
|
||||
if file.info.action != 'copy' and @onBeforeSave
|
||||
file.data = this.onBeforeSave
|
||||
theme: opts.themeObj
|
||||
outputFile: if file.info.major then f else thisFilePath
|
||||
outputFile: thisFilePath #if file.info.major then f else thisFilePath
|
||||
mk: file.data
|
||||
opts: this.opts
|
||||
if !file.data
|
||||
return # PDF etc
|
||||
|
||||
# Write the file
|
||||
fileName = if file.info.major then f else thisFilePath
|
||||
MKDIRP.sync PATH.dirname( fileName )
|
||||
FS.writeFileSync fileName, file.data, { encoding: 'utf8', flags: 'w' }
|
||||
opts.beforeWrite? thisFilePath
|
||||
|
||||
MKDIRP.sync PATH.dirname( thisFilePath )
|
||||
|
||||
#console.log( Object.keys(file.info) )
|
||||
console.log file.info.path
|
||||
|
||||
if file.info.action != 'copy'
|
||||
FS.writeFileSync thisFilePath, file.data, encoding: 'utf8', flags: 'w'
|
||||
else
|
||||
FS.copySync file.info.path, thisFilePath
|
||||
|
||||
opts.afterWrite? thisFilePath
|
||||
|
||||
# Post-processing
|
||||
if @onAfterSave
|
||||
@onAfterSave( outputFile: fileName, mk: file.data, opts: this.opts )
|
||||
@onAfterSave outputFile: fileName, mk: file.data, opts: this.opts
|
||||
|
||||
, @)
|
||||
, @
|
||||
|
||||
# Some themes require a symlink structure. If so, create it.
|
||||
if curFmt.symLinks
|
||||
Object.keys( curFmt.symLinks ).forEach (loc) ->
|
||||
absLoc = PATH.join outFolder, loc
|
||||
absTarg = PATH.join PATH.dirname(absLoc), curFmt.symLinks[loc]
|
||||
# 'file', 'dir', or 'junction' (Windows only)
|
||||
type = parsePath( absLoc ).extname ? 'file' : 'junction'
|
||||
FS.symlinkSync absTarg, absLoc, type
|
||||
createSymLinks curFmt, outFolder
|
||||
|
||||
genInfo
|
||||
|
||||
|
||||
|
||||
###* Perform a single resume resume transformation using string-based inputs
|
||||
and outputs without touching the local file system.
|
||||
@param json A FRESH or JRS resume object.
|
||||
@ -138,8 +151,8 @@ module.exports = class TemplateGenerator extends BaseGenerator
|
||||
@param cssInfo Needs to be refactored.
|
||||
@param opts Options and passthrough data. ###
|
||||
|
||||
single: ( json, jst, format, opts, theme, curFmt ) ->
|
||||
if this.opts.freezeBreaks
|
||||
transform: ( json, jst, format, opts, theme, curFmt ) ->
|
||||
if @opts.freezeBreaks
|
||||
jst = freeze jst
|
||||
eng = require '../renderers/' + theme.engine + '-generator'
|
||||
result = eng.generate json, jst, format, curFmt, opts, theme
|
||||
@ -149,6 +162,29 @@ module.exports = class TemplateGenerator extends BaseGenerator
|
||||
|
||||
|
||||
|
||||
createSymLinks = ( curFmt, outFolder ) ->
|
||||
# Some themes require a symlink structure. If so, create it.
|
||||
if curFmt.symLinks
|
||||
Object.keys( curFmt.symLinks ).forEach (loc) ->
|
||||
absLoc = PATH.join outFolder, loc
|
||||
absTarg = PATH.join PATH.dirname(absLoc), curFmt.symLinks[loc]
|
||||
# Set type to 'file', 'dir', or 'junction' (Windows only)
|
||||
type = if parsePath( absLoc ).extname then 'file' else 'junction'
|
||||
|
||||
try
|
||||
FS.symlinkSync absTarg, absLoc, type
|
||||
catch
|
||||
succeeded = false
|
||||
if _error.code == 'EEXIST'
|
||||
FS.unlinkSync absLoc
|
||||
try
|
||||
FS.symlinkSync absTarg, absLoc, type
|
||||
succeeded = true
|
||||
if !succeeded
|
||||
throw ex
|
||||
return
|
||||
|
||||
|
||||
###* Freeze newlines for protection against errant JST parsers. ###
|
||||
freeze = ( markup ) ->
|
||||
markup.replace( _reg.regN, _defaultOpts.nSym )
|
||||
@ -162,6 +198,7 @@ unfreeze = ( markup ) ->
|
||||
markup.replace _reg.regSymN, '\n'
|
||||
|
||||
|
||||
|
||||
###* Default template generator options. ###
|
||||
_defaultOpts =
|
||||
engine: 'underscore'
|
||||
|
@ -22,12 +22,13 @@ UnderscoreGenerator = module.exports =
|
||||
generateSimple: ( data, tpl ) ->
|
||||
try
|
||||
# Compile and run the Handlebars template.
|
||||
tpl = _.template tpl
|
||||
template data
|
||||
t = _.template tpl
|
||||
t data
|
||||
catch
|
||||
#console.dir _error
|
||||
HMS = require '../core/status-codes'
|
||||
throw
|
||||
fluenterror: HMS[if tpl then 'invokeTemplate' else 'compileTemplate']
|
||||
fluenterror: HMS[if t then 'invokeTemplate' else 'compileTemplate']
|
||||
inner: _error
|
||||
|
||||
|
||||
@ -49,9 +50,9 @@ UnderscoreGenerator = module.exports =
|
||||
XML: require 'xml-escape'
|
||||
RAW: json
|
||||
cssInfo: cssInfo
|
||||
#engine: this
|
||||
#engine: @
|
||||
headFragment: opts.headFragment || ''
|
||||
opts: opts
|
||||
|
||||
registerHelpers theme, opts, cssInfo, ctx, this
|
||||
registerHelpers theme, opts, cssInfo, ctx, @
|
||||
@generateSimple ctx, jst
|
||||
|
@ -59,7 +59,7 @@ _build = ( src, dst, opts ) ->
|
||||
@err HMSTATUS.resumeNotFound, quit: true
|
||||
return null
|
||||
|
||||
_prep src, dst, opts
|
||||
_prep.call @, src, dst, opts
|
||||
|
||||
# Load input resumes as JSON...
|
||||
sheetObjects = ResumeFactory.load src,
|
||||
@ -175,6 +175,16 @@ _prep = ( src, dst, opts ) ->
|
||||
_opts.noTips = opts.noTips
|
||||
_opts.debug = opts.debug
|
||||
_opts.sort = opts.sort
|
||||
that = @
|
||||
|
||||
# Set up callbacks for internal generators
|
||||
_opts.onTransform = (info) ->
|
||||
that.stat HMEVENT.afterTransform, info; return
|
||||
_opts.beforeWrite = (info) ->
|
||||
that.stat HMEVENT.beforeWrite, info; return
|
||||
_opts.afterWrite = (info) ->
|
||||
that.stat HMEVENT.afterWrite, info; return
|
||||
|
||||
|
||||
# If two or more files are passed to the GENERATE command and the TO
|
||||
# keyword is omitted, the last file specifies the output file.
|
||||
@ -203,7 +213,7 @@ _single = ( targInfo, theme, finished ) ->
|
||||
fName = PATH.basename f, '.' + fType
|
||||
theFormat = null
|
||||
|
||||
@.stat HMEVENT.beforeGenerate,
|
||||
@stat HMEVENT.beforeGenerate,
|
||||
fmt: targInfo.fmt.outFormat
|
||||
file: PATH.relative(process.cwd(), f)
|
||||
|
||||
|
Reference in New Issue
Block a user