Added to use max intensity from reference spectra as default. using the peak fit area is now an option.
This commit is contained in:
1 parent
e5226802a3
commit
5767a2e26d
1 file changed
+273
-91
+273
-91
@@ -1,39 +1,46 @@
|
||||
function DataTable = nappNorm(DataTable, processSelection, refSelection, options)
|
||||
% NAPPNORM Normalize XPS spectra based on provided reference selection or self-maximum.
|
||||
%
|
||||
% DataTable = NAPPNORM(DataTable, processSelection, refSelection) normalizes
|
||||
% the spectra where processSelection is true, dividing them by the peak area
|
||||
% of matched spectra from the pool where refSelection is true. Both must
|
||||
% be logical arrays matching the height of the DataTable.
|
||||
% DataTable = NAPPNORM(DataTable, processSelection, refSelection) normalizes
|
||||
% the spectra where processSelection is true, dividing them by the peak area
|
||||
% of matched spectra from the pool where refSelection is true. Both must
|
||||
% be logical arrays matching the height of the DataTable.
|
||||
%
|
||||
% DataTable = NAPPNORM(DataTable, processSelection) scales each spectrum
|
||||
% selected by the logical array by its own maximum value.
|
||||
% DataTable = NAPPNORM(DataTable, processSelection) scales each spectrum
|
||||
% selected by the logical array by its own maximum value.
|
||||
%
|
||||
% Name-Value arguments:
|
||||
% matchStrategy - 'earlier', 'after', 'spectrumNumber', 'internalMax'
|
||||
% tableVariable - Target column ('spectrum_countsPerSecond' by default)
|
||||
% timeVariable - Target time column ('lastModifiedDatenum' by default)
|
||||
% peakNumber - Which peak area to use if multiple exist (1 by default)
|
||||
% Name-Value arguments:
|
||||
% matchStrategy - 'earlier', 'after', 'spectrumNumber', 'internal'
|
||||
% tableVariable - Target column ('spectrum_countsPerSecond' by default)
|
||||
% timeVariable - Target time column ('lastModifiedDatenum' by default)
|
||||
% peakNumber - Which peak area to use if multiple exist (1 by default)
|
||||
% mode - "max" per default, "area" as option
|
||||
% range- energy range where max is found. first mist be first value, second
|
||||
% second (low to high for kinetic, high to low for binding)
|
||||
|
||||
arguments
|
||||
DataTable table
|
||||
processSelection logical {mustBeVector}
|
||||
|
||||
% refSelection is optional. If omitted, it defaults to empty.
|
||||
refSelection logical {mustBeVector} = false(0,1);
|
||||
|
||||
% Name-Value arguments
|
||||
options.matchStrategy char {mustBeMember(options.matchStrategy, ...
|
||||
{'earlier', 'after', 'spectrumNumber', 'internalMax'})} = 'earlier'
|
||||
{'earlier', 'after', 'spectrumNumber', 'internal'})} = 'earlier'
|
||||
options.tableVariable char {mustBeMember(options.tableVariable, ...
|
||||
{'spectrum_counts', 'spectrum_countsPerSecond', 'spectrum_countsNorm'})} = 'spectrum_countsPerSecond'
|
||||
options.timeVariable char {mustBeMember(options.timeVariable, ...
|
||||
{'lastModifiedDatenum', 'creationDate'})} = 'lastModifiedDatenum'
|
||||
options.peakNumber (1,1) double {mustBeInteger, mustBePositive} = 1
|
||||
options.mode char {mustBeMember(options.mode, ...
|
||||
{'max', 'area'})} = 'max'
|
||||
options.range (1,:) double {mustBePositive} =[ ];
|
||||
options.energyScale char {mustBeMember(options.energyScale, ...
|
||||
{'kinetic', 'binding'})} = 'kinetic'
|
||||
end
|
||||
|
||||
numRowsTable = height(DataTable);
|
||||
|
||||
|
||||
numRowsTable = height(DataTable);
|
||||
% Validate length of the logical arrays
|
||||
if length(processSelection) ~= numRowsTable
|
||||
error('processSelection must have the exact same length as the DataTable height (%d).', numRowsTable);
|
||||
@@ -41,14 +48,14 @@ end
|
||||
if ~isempty(refSelection) && any(refSelection) && length(refSelection) ~= numRowsTable
|
||||
error('refSelection must have the exact same length as the DataTable height (%d).', numRowsTable);
|
||||
end
|
||||
|
||||
% Auto-detect mode: If no valid reference array is passed OR 'internalMax' is chosen
|
||||
if strcmpi(options.matchStrategy, 'internalMax') || isempty(refSelection) || ~any(refSelection)
|
||||
mode = 'maximum';
|
||||
% Auto-detect mode: If no valid reference array is passed OR 'internal' is chosen
|
||||
if strcmpi(options.matchStrategy, 'internal') && ( isempty(refSelection) || ~any(refSelection) ) && strcmpi(options.mode, 'max')
|
||||
modus = 'internalMax';
|
||||
elseif strcmpi(options.mode, 'max')
|
||||
modus = 'externalMax';
|
||||
else
|
||||
mode = 'referenceArea';
|
||||
modus = 'referenceArea';
|
||||
end
|
||||
|
||||
% Initialize new columns if they don't exist
|
||||
if ~ismember('spectrum_countsNorm', DataTable.Properties.VariableNames)
|
||||
DataTable.spectrum_countsNorm = cell(numRowsTable, 1);
|
||||
@@ -59,11 +66,9 @@ end
|
||||
if ~ismember('lognappNormWith', DataTable.Properties.VariableNames)
|
||||
DataTable.lognappNormWith = cell(numRowsTable, 1); % New column for spectrum name
|
||||
end
|
||||
|
||||
%% Processing Loop
|
||||
processIdx = find(processSelection);
|
||||
refIdx = find(refSelection);
|
||||
|
||||
for i = 1:length(processIdx)
|
||||
currRow = processIdx(i);
|
||||
yData = DataTable.(options.tableVariable){currRow};
|
||||
@@ -71,24 +76,17 @@ for i = 1:length(processIdx)
|
||||
refTimes = datenum(DataTable.(options.timeVariable)(refIdx, :));
|
||||
currName = DataTable.name{currRow};
|
||||
bestRefRow = NaN;
|
||||
|
||||
if isempty(yData)
|
||||
continue;
|
||||
end
|
||||
|
||||
switch mode
|
||||
case 'maximum'
|
||||
switch modus
|
||||
case 'internalMax'
|
||||
normFactor = max(yData);
|
||||
if normFactor == 0, normFactor = 1; end % Protect against /0
|
||||
|
||||
DataTable.spectrum_countsNorm{currRow} = yData ./ normFactor;
|
||||
DataTable.logNormMode{currRow} = 'maximum';
|
||||
DataTable.logNormMode{currRow} = 'maximum';
|
||||
DataTable.logNormWith{currRow} = num2str(normFactor); % Log internal maximum
|
||||
|
||||
case 'referenceArea'
|
||||
|
||||
|
||||
|
||||
% Internal Matching Engine using Code Words
|
||||
switch options.matchStrategy
|
||||
case 'earlier'
|
||||
@@ -98,7 +96,6 @@ for i = 1:length(processIdx)
|
||||
[~, minIdx] = min(timeDiffs);
|
||||
bestRefRow = validRefs(minIdx);
|
||||
end
|
||||
|
||||
case 'after'
|
||||
validRefs = refIdx(refTimes >= currTime);
|
||||
if ~isempty(validRefs)
|
||||
@@ -106,11 +103,9 @@ for i = 1:length(processIdx)
|
||||
[~, minIdx] = min(timeDiffs);
|
||||
bestRefRow = validRefs(minIdx);
|
||||
end
|
||||
|
||||
case 'spectrumNumber'
|
||||
% Extract the 4-digit number at the start of the name (e.g., '0033')
|
||||
specNumStr = regexp(currName, '^\d{4}', 'match', 'once');
|
||||
|
||||
if ~isempty(specNumStr)
|
||||
% Find references that start with 'XXXX_'
|
||||
searchPattern = [specNumStr, '_'];
|
||||
@@ -122,64 +117,251 @@ for i = 1:length(processIdx)
|
||||
warning('Row %d: Could not extract a 4-digit spectrum number from name "%s".', currRow, currName);
|
||||
end
|
||||
|
||||
|
||||
end
|
||||
% Validate we found a match
|
||||
if isnan(bestRefRow)
|
||||
warning('Row %d (%s): No matching reference found for strategy "%s". Skipping.', ...
|
||||
currRow, currName, options.matchStrategy);
|
||||
continue;
|
||||
end
|
||||
% Extract area and validate
|
||||
refAreas = DataTable.peakArea(bestRefRow,:);
|
||||
if size(refAreas,2) < options.peakNumber
|
||||
warning('Row %d: peakNumber %d exceeds available peaks in reference row %d. Skipping.', ...
|
||||
currRow, options.peakNumber, bestRefRow);
|
||||
continue;
|
||||
end
|
||||
normFactor = refAreas(options.peakNumber);
|
||||
if normFactor == 0
|
||||
warning('Row %d: Reference area in row %d is zero. Skipping.', currRow, bestRefRow);
|
||||
continue;
|
||||
end
|
||||
% Apply normalization and log the exact name of the reference
|
||||
DataTable.spectrum_countsNorm{currRow} = yData ./ normFactor;
|
||||
DataTable.lognappNormMode{currRow} = sprintf('referenceArea (Row %d)', bestRefRow);
|
||||
DataTable.lognappNormWith{currRow} = DataTable.name{bestRefRow};
|
||||
% -------------------------------------------------------------------------
|
||||
% 1. Dynamically get this function's name
|
||||
thisFuncName = mfilename;
|
||||
colDate = sprintf('log%sDate', thisFuncName);
|
||||
colCaller = sprintf('log%sCaller', thisFuncName);
|
||||
% 2. Probe for column existence, create empty string arrays if missing
|
||||
if ~ismember(colDate, DataTable.Properties.VariableNames)
|
||||
DataTable.(colDate) = repmat("", numRowsTable, 1);
|
||||
end
|
||||
if ~ismember(colCaller, DataTable.Properties.VariableNames)
|
||||
DataTable.(colCaller) = repmat("", numRowsTable, 1);
|
||||
end
|
||||
% 3. Get current time as a string
|
||||
currentTimeStr = string(datetime('now', 'Format', 'yyyy-MM-dd HH:mm:ss'));
|
||||
% 4. Determine the calling script using the call stack
|
||||
stackInfo = dbstack;
|
||||
if length(stackInfo) > 1
|
||||
callerScript = string(stackInfo(2).file); % The script that called this function
|
||||
else
|
||||
callerScript = " "; % Called directly from the command window
|
||||
end
|
||||
% 5. Write the log data only to the rows being processed
|
||||
DataTable.(colDate)(processIdx) = currentTimeStr;
|
||||
DataTable.(colCaller)(processIdx) = callerScript;
|
||||
|
||||
% Validate we found a match
|
||||
if isnan(bestRefRow)
|
||||
warning('Row %d (%s): No matching reference found for strategy "%s". Skipping.', ...
|
||||
currRow, currName, options.matchStrategy);
|
||||
continue;
|
||||
end
|
||||
end % -------------------------------------------------------------------------
|
||||
case 'externalMax'
|
||||
% Internal Matching Engine using Code Words
|
||||
|
||||
% Extract area and validate
|
||||
refAreas = DataTable.peakArea(bestRefRow,:);
|
||||
if size(refAreas,2) < options.peakNumber
|
||||
warning('Row %d: peakNumber %d exceeds available peaks in reference row %d. Skipping.', ...
|
||||
currRow, options.peakNumber, bestRefRow);
|
||||
continue;
|
||||
end
|
||||
switch options.matchStrategy
|
||||
case 'earlier'
|
||||
validRefs = refIdx(refTimes <= currTime);
|
||||
if ~isempty(validRefs)
|
||||
timeDiffs = currTime - refTimes(refTimes <= currTime);
|
||||
[~, minIdx] = min(timeDiffs);
|
||||
bestRefRow = validRefs(minIdx);
|
||||
|
||||
normFactor = refAreas(options.peakNumber);
|
||||
if normFactor == 0
|
||||
warning('Row %d: Reference area in row %d is zero. Skipping.', currRow, bestRefRow);
|
||||
continue;
|
||||
end
|
||||
if ~isempty(options.range)
|
||||
if strcmpi(options.energyScale, 'kinetic')
|
||||
% Extract the X and Y data for cleaner code
|
||||
xData = DataTable.scientaEkin_eV{bestRefRow};
|
||||
yDataRef = DataTable.(options.tableVariable){bestRefRow};
|
||||
|
||||
% Apply normalization and log the exact name of the reference
|
||||
DataTable.spectrum_countsNorm{currRow} = yData ./ normFactor;
|
||||
DataTable.lognappNormMode{currRow} = sprintf('referenceArea (Row %d)', bestRefRow);
|
||||
DataTable.lognappNormWith{currRow} = DataTable.name{bestRefRow};
|
||||
% -------------------------------------------------------------------------
|
||||
% 1. Dynamically get this function's name
|
||||
thisFuncName = mfilename;
|
||||
colDate = sprintf('log%sDate', thisFuncName);
|
||||
colCaller = sprintf('log%sCaller', thisFuncName);
|
||||
|
||||
|
||||
% 2. Probe for column existence, create empty string arrays if missing
|
||||
if ~ismember(colDate, DataTable.Properties.VariableNames)
|
||||
DataTable.(colDate) = repmat("", numRowsTable, 1);
|
||||
% Find all points within the range (safely handles ascending/descending)
|
||||
minR = min(options.range);
|
||||
maxR = max(options.range);
|
||||
idxRange = (xData >= minR) & (xData <= maxR);
|
||||
|
||||
% Extract the subset and find the max
|
||||
searchY = yDataRef(idxRange);
|
||||
[normFactor, relativeMaxIdx] = max(searchY);
|
||||
|
||||
% Convert the relative index back to the absolute index to get the true energy
|
||||
validIndices = find(idxRange);
|
||||
absoluteMaxIndex = validIndices(relativeMaxIdx);
|
||||
|
||||
fprintf('Norming %s with %s at %.1f eV: %.1e\n', DataTable.name{currRow}, DataTable.name{bestRefRow}, xData(absoluteMaxIndex),normFactor);
|
||||
else
|
||||
% Extract the X and Y data for cleaner code
|
||||
xData = DataTable.bindingEnergy_eV{bestRefRow};
|
||||
yDataRef = DataTable.(options.tableVariable){bestRefRow};
|
||||
|
||||
% Find all points within the range (safely handles ascending/descending)
|
||||
minR = min(options.range);
|
||||
maxR = max(options.range);
|
||||
idxRange = (xData >= minR) & (xData <= maxR);
|
||||
|
||||
% Extract the subset and find the max
|
||||
searchY = yDataRef(idxRange);
|
||||
[normFactor, relativeMaxIdx] = max(searchY);
|
||||
|
||||
% Convert the relative index back to the absolute index to get the true energy
|
||||
validIndices = find(idxRange);
|
||||
absoluteMaxIndex = validIndices(relativeMaxIdx);
|
||||
|
||||
fprintf('Norming %s with %s at %.1f eV: %.1e\n', DataTable.name{currRow}, DataTable.name{bestRefRow}, xData(absoluteMaxIndex),normFactor);
|
||||
|
||||
end
|
||||
|
||||
else
|
||||
[normFactor, MaxIdx] = max(DataTable.(options.tableVariable){bestRefRow});
|
||||
if strcmpi(options.energyScale, 'kinetic')
|
||||
fprintf('Norming %s with %s at %.1f eV: %.1e\n', DataTable.name{currRow}, DataTable.name{bestRefRow}, DataTable.scientaEkin_eV{bestRefRow}(MaxIdx),normFactor );
|
||||
else
|
||||
fprintf('Norming %s with %s at %.1f eV: %.1e\n', DataTable.name{currRow}, DataTable.name{bestRefRow}, DataTable.bindingEnergy_eV{bestRefRow}(MaxIdx),normFactor );
|
||||
end
|
||||
end
|
||||
|
||||
if normFactor == 0, normFactor = 1; end % Protect against /0
|
||||
DataTable.spectrum_countsNorm{currRow} = yData ./ normFactor;
|
||||
DataTable.logNormMode{currRow} = 'external maximum';
|
||||
DataTable.logNormWith{currRow} = num2str(normFactor); % Log internal maximum
|
||||
end
|
||||
case 'after'
|
||||
validRefs = refIdx(refTimes >= currTime);
|
||||
if ~isempty(validRefs)
|
||||
timeDiffs = refTimes(refTimes >= currTime) - currTime;
|
||||
[~, minIdx] = min(timeDiffs);
|
||||
bestRefRow = validRefs(minIdx);
|
||||
if ~isempty(options.range)
|
||||
if strcmpi(options.energyScale, 'kinetic')
|
||||
% Extract the X and Y data for cleaner code
|
||||
xData = DataTable.scientaEkin_eV{bestRefRow};
|
||||
yDataRef = DataTable.(options.tableVariable){bestRefRow};
|
||||
|
||||
% Find all points within the range (safely handles ascending/descending)
|
||||
minR = min(options.range);
|
||||
maxR = max(options.range);
|
||||
idxRange = (xData >= minR) & (xData <= maxR);
|
||||
|
||||
% Extract the subset and find the max
|
||||
searchY = yDataRef(idxRange);
|
||||
[normFactor, relativeMaxIdx] = max(searchY);
|
||||
|
||||
% Convert the relative index back to the absolute index to get the true energy
|
||||
validIndices = find(idxRange);
|
||||
absoluteMaxIndex = validIndices(relativeMaxIdx);
|
||||
|
||||
fprintf('Norming %s with %s at %.1f eV: %.1e\n', DataTable.name{currRow}, DataTable.name{bestRefRow}, xData(absoluteMaxIndex),normFactor);
|
||||
|
||||
else
|
||||
% Extract the X and Y data for cleaner code
|
||||
xData = DataTable.bindingEnergy_eV{bestRefRow};
|
||||
yDataRef = DataTable.(options.tableVariable){bestRefRow};
|
||||
|
||||
% Find all points within the range (safely handles ascending/descending)
|
||||
minR = min(options.range);
|
||||
maxR = max(options.range);
|
||||
idxRange = (xData >= minR) & (xData <= maxR);
|
||||
|
||||
% Extract the subset and find the max
|
||||
searchY = yDataRef(idxRange);
|
||||
[normFactor, relativeMaxIdx] = max(searchY);
|
||||
|
||||
% Convert the relative index back to the absolute index to get the true energy
|
||||
validIndices = find(idxRange);
|
||||
absoluteMaxIndex = validIndices(relativeMaxIdx);
|
||||
|
||||
fprintf('Norming %s with %s at %.1f eV: %.1e\n', DataTable.name{currRow}, DataTable.name{bestRefRow}, xData(absoluteMaxIndex),normFactor);
|
||||
|
||||
end
|
||||
|
||||
else
|
||||
[normFactor, MaxIdx] = max(DataTable.(options.tableVariable){bestRefRow});
|
||||
if strcmpi(options.energyScale, 'kinetic')
|
||||
fprintf('Norming %s with %s at %.1f eV: %.1e\n', DataTable.name{currRow}, DataTable.name{bestRefRow}, DataTable.scientaEkin_eV{bestRefRow}(MaxIdx),normFactor );
|
||||
else
|
||||
fprintf('Norming %s with %s at %.1f eV: %.1e\n', DataTable.name{currRow}, DataTable.name{bestRefRow}, DataTable.bindingEnergy_eV{bestRefRow}(MaxIdx),normFactor );
|
||||
end
|
||||
end
|
||||
if normFactor == 0, normFactor = 1; end % Protect against /0
|
||||
DataTable.spectrum_countsNorm{currRow} = yData ./ normFactor;
|
||||
DataTable.logNormMode{currRow} = 'external maximum';
|
||||
DataTable.logNormWith{currRow} = num2str(normFactor); % Log internal maximum
|
||||
end
|
||||
case 'spectrumNumber'
|
||||
% Extract the 4-digit number at the start of the name (e.g., '0033')
|
||||
specNumStr = regexp(currName, '^\d{4}', 'match', 'once');
|
||||
if ~isempty(specNumStr)
|
||||
% Find references that start with 'XXXX_'
|
||||
searchPattern = [specNumStr, '_'];
|
||||
validRefs = refIdx(startsWith(DataTable.name(refIdx), searchPattern));
|
||||
if ~isempty(validRefs)
|
||||
bestRefRow = validRefs(1); % Take the first exact match
|
||||
end
|
||||
else
|
||||
warning('Row %d: Could not extract a 4-digit spectrum number from name "%s".', currRow, currName);
|
||||
end
|
||||
if ~isempty(options.range)
|
||||
if strcmpi(options.energyScale, 'kinetic')
|
||||
% Extract the X and Y data for cleaner code
|
||||
xData = DataTable.scientaEkin_eV{bestRefRow};
|
||||
yDataRef = DataTable.(options.tableVariable){bestRefRow};
|
||||
|
||||
% Find all points within the range (safely handles ascending/descending)
|
||||
minR = min(options.range);
|
||||
maxR = max(options.range);
|
||||
idxRange = (xData >= minR) & (xData <= maxR);
|
||||
|
||||
% Extract the subset and find the max
|
||||
searchY = yDataRef(idxRange);
|
||||
[normFactor, relativeMaxIdx] = max(searchY);
|
||||
|
||||
% Convert the relative index back to the absolute index to get the true energy
|
||||
validIndices = find(idxRange);
|
||||
absoluteMaxIndex = validIndices(relativeMaxIdx);
|
||||
|
||||
fprintf('Norming %s with %s at %.1f eV: %.1e\n', DataTable.name{currRow}, DataTable.name{bestRefRow}, xData(absoluteMaxIndex),normFactor);
|
||||
|
||||
else
|
||||
% Extract the X and Y data for cleaner code
|
||||
xData = DataTable.bindingEnergy_eV{bestRefRow};
|
||||
yDataRef = DataTable.(options.tableVariable){bestRefRow};
|
||||
|
||||
% Find all points within the range (safely handles ascending/descending)
|
||||
minR = min(options.range);
|
||||
maxR = max(options.range);
|
||||
idxRange = (xData >= minR) & (xData <= maxR);
|
||||
|
||||
% Extract the subset and find the max
|
||||
searchY = yDataRef(idxRange);
|
||||
[normFactor, relativeMaxIdx] = max(searchY);
|
||||
|
||||
% Convert the relative index back to the absolute index to get the true energy
|
||||
validIndices = find(idxRange);
|
||||
absoluteMaxIndex = validIndices(relativeMaxIdx);
|
||||
|
||||
fprintf('Norming %s with %s at %.1f eV: %.1e\n', DataTable.name{currRow}, DataTable.name{bestRefRow}, xData(absoluteMaxIndex),normFactor);
|
||||
|
||||
end
|
||||
|
||||
else
|
||||
[normFactor, MaxIdx] = max(DataTable.(options.tableVariable){bestRefRow});
|
||||
if strcmpi(options.energyScale, 'kinetic')
|
||||
fprintf('Norming %s with %s at %.1f eV: %.1e\n', DataTable.name{currRow}, DataTable.name{bestRefRow}, DataTable.scientaEkin_eV{bestRefRow}(MaxIdx),normFactor );
|
||||
else
|
||||
fprintf('Norming %s with %s at %.1f eV: %.1e\n', DataTable.name{currRow}, DataTable.name{bestRefRow}, DataTable.bindingEnergy_eV{bestRefRow}(MaxIdx),normFactor );
|
||||
end
|
||||
end
|
||||
if normFactor == 0, normFactor = 1; end % Protect against /0
|
||||
DataTable.spectrum_countsNorm{currRow} = yData ./ normFactor;
|
||||
DataTable.logNormMode{currRow} = 'external maximum';
|
||||
DataTable.logNormWith{currRow} = num2str(normFactor); % Log internal maximum
|
||||
end
|
||||
end
|
||||
if ~ismember(colCaller, DataTable.Properties.VariableNames)
|
||||
DataTable.(colCaller) = repmat("", numRowsTable, 1);
|
||||
end
|
||||
|
||||
% 3. Get current time as a string
|
||||
currentTimeStr = string(datetime('now', 'Format', 'yyyy-MM-dd HH:mm:ss'));
|
||||
|
||||
% 4. Determine the calling script using the call stack
|
||||
stackInfo = dbstack;
|
||||
if length(stackInfo) > 1
|
||||
callerScript = string(stackInfo(2).file); % The script that called this function
|
||||
else
|
||||
callerScript = " "; % Called directly from the command window
|
||||
end
|
||||
|
||||
% 5. Write the log data only to the rows being processed
|
||||
DataTable.(colDate)(processIdx) = currentTimeStr;
|
||||
DataTable.(colCaller)(processIdx) = callerScript;
|
||||
% -------------------------------------------------------------------------
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in new issue
Block a user