Compare commits

..

11 Commits

19 changed files with 99 additions and 177 deletions

View File

@@ -33,7 +33,7 @@ namespace EliteBGS.BGS {
StringBuilder log = new StringBuilder();
log.AppendFormat("**Date:** {0}\n", DateTime.Now.ToString("dd/MM/yyyy"));
log.AppendFormat("**Location:** {0}\n", objective.ToShortString());
log.AppendFormat("**Location:** {0}\n", objective.ToLocationString());
log.AppendFormat("**Faction:** {0}\n", objective.Faction);
log.AppendLine("");
log.AppendLine("```");

View File

@@ -15,6 +15,8 @@ namespace EliteBGS.BGS {
suffix = "st";
} else if (today.Day == 2 || today.Day == 22) {
suffix = "nd";
} else if (today.Day == 23) {
suffix = "rd";
} else {
suffix = "th";
}
@@ -31,7 +33,7 @@ namespace EliteBGS.BGS {
protected override string GenerateObjectiveHeader(Objective objective) {
StringBuilder log = new StringBuilder();
log.AppendFormat(":globe_with_meridians: `Location:` {0}\n", objective.ToShortString());
log.AppendFormat(":globe_with_meridians: `Location:` {0}\n", objective.ToLocationString());
log.Append(":clipboard: `Conducted:`\n");
log.Append("```");

View File

@@ -5,37 +5,30 @@ using Newtonsoft.Json;
namespace EliteBGS.BGS {
public class Objective : IComparable<Objective> {
private string system;
private string station;
private string faction;
private List<LogEntry> entries = new List<LogEntry>();
[JsonIgnore]
public bool IsEnabled { get; set; }
[JsonIgnore]
public List<LogEntry> Children {
get => entries;
}
public List<LogEntry> Children { get; } = new List<LogEntry>();
[JsonIgnore]
public string Name => this.ToString();
public string Name {
get { return this.ToString(); }
}
[JsonIgnore]
public bool IsExpanded { get; set; }
[JsonIgnore]
public List<LogEntry> LogEntries {
get => entries;
set => entries = value;
get => Children;
}
public void Clear() {
if (entries == null) {
if (LogEntries == null) {
return;
}
entries.RemoveAll(x => !x.ManuallyAdded);
LogEntries.RemoveAll(x => !x.ManuallyAdded);
}
public bool ManuallyAdded { get; set; }
@@ -49,8 +42,8 @@ namespace EliteBGS.BGS {
}
}
if (e.Faction != null && faction != null) {
if (string.Compare(e.Faction, faction, true) != 0) {
if (e.Faction != null && Faction != null) {
if (string.Compare(e.Faction, Faction, true) != 0) {
/* if we have a faction, and it doesn't match we don't care.
* faction is the most important comparision, so if it doesn't match
* it is not the right objective
@@ -62,18 +55,13 @@ namespace EliteBGS.BGS {
}
/* system and station only add to the match strength though */
if (e.System != null && system != null) {
if (string.Compare(e.System, system, true) == 0) {
if (e.System != null && System != null) {
if (string.Compare(e.System, System, true) == 0) {
++match_count;
}
}
/* if system and faction already match, station is not so important */
if (e.Station != null && station != null) {
if (string.Compare(e.Station, station, true) == 0) {
++match_count;
}
}
/* station does not matter */
return match_count;
}
@@ -86,51 +74,36 @@ namespace EliteBGS.BGS {
public bool IsValid => System != null && Faction != null;
public string System {
get { return system; }
set { system = value; }
}
public string System { get; set; }
public string Station {
get { return station; }
set { station = value; }
}
public string Station { get; set; }
public string Faction {
get { return faction; }
set { faction = value; }
}
public string Faction { get; set; }
public override string ToString() {
StringBuilder str = new StringBuilder();
if (system != null && system.Length > 0) {
str.AppendFormat("System: {0}", system);
if (!string.IsNullOrEmpty(System)) {
str.AppendFormat("System: {0}", System);
}
if (station != null && station.Length > 0) {
if (!string.IsNullOrEmpty(Faction)) {
if (str.Length > 0) {
str.Append(", ");
}
str.AppendFormat("Station: {0}", station);
}
if (faction != null && faction.Length > 0) {
if (str.Length > 0) {
str.Append(", ");
}
str.AppendFormat("Faction: {0}", faction);
str.AppendFormat("Faction: {0}", Faction);
}
return str.ToString();
}
public string ToShortString() {
public string ToLocationString() {
StringBuilder str = new StringBuilder();
if (system != null && system.Length > 0) {
str.AppendFormat("{0}", system);
if (!string.IsNullOrEmpty(System)) {
str.AppendFormat("{0}", System);
}
if (station != null && station.Length > 0) {
if (!string.IsNullOrEmpty(Station)) {
if (str.Length > 0) {
str.Append(", ");
}
str.AppendFormat("{0}", station);
str.AppendFormat("{0}", Station);
}
return str.ToString();
}

View File

@@ -49,7 +49,7 @@ namespace EliteBGS.BGS {
;
}
public void Scan(PlayerJournal journal, DateTime start, DateTime end) {
public void Scan(PlayerJournal journal, DateTime start, DateTime end, bool CollateEntries = true) {
/* Log files only get rotated if you restart the game client. This means that there might
* be - say - entries from the 4th of May in the file with a timestamp of 3rd of May. This
* happens if you happen to play a session late into the night.
@@ -72,10 +72,10 @@ namespace EliteBGS.BGS {
.Where(e => e.Timestamp >= start && e.Timestamp < actualend)
.ToList()
;
Scan(entries);
Scan(entries, CollateEntries);
}
public void Scan(List<Entry> entries) {
public void Scan(List<Entry> entries, bool CollateEntries = true) {
if (entries.Count <= 0) {
return;
}
@@ -198,7 +198,7 @@ namespace EliteBGS.BGS {
System = current_system,
Faction = faction,
});
collate = true;
collate = CollateEntries;
} else if (e.Is(Events.MissionCompleted)) {
MissionCompletedEntry completed = e as MissionCompletedEntry;
MissionAcceptedEntry accepted = null;
@@ -411,7 +411,7 @@ namespace EliteBGS.BGS {
/* Mission failed should be collated if they are in the same system/station
*/
collate = true;
collate = CollateEntries;
} else if (e.Is(Events.SellExplorationData)) {
results.Add(new Cartographics(e as SellExplorationDataEntry) {
System = current_system,
@@ -420,7 +420,7 @@ namespace EliteBGS.BGS {
});
/* colate single cartographic selling into one */
collate = true;
collate = CollateEntries;
} else if (e.Is(Events.SellOrganicData)) {
/* organic data sold to Vista Genomics */
results.Add(new OrganicData(e as SellOrganicDataEntry) {
@@ -429,7 +429,7 @@ namespace EliteBGS.BGS {
Faction = controlling_faction,
});
collate = true;
collate = CollateEntries;
} else if (e.Is(Events.MultiSellExplorationData)) {
/* For multi-sell-exploraton-data only the controlling faction of the station sold to matters.
*/
@@ -439,7 +439,7 @@ namespace EliteBGS.BGS {
Faction = controlling_faction
});
collate = true;
collate = CollateEntries;
} else if (e.Is(Events.RedeemVoucher)) {
RedeemVoucherEntry voucher = e as RedeemVoucherEntry;
List<Faction> current_factions = new List<Faction>();
@@ -470,7 +470,7 @@ namespace EliteBGS.BGS {
});
}
collate = true;
collate = CollateEntries;
} else if (e.Is(Events.SellMicroResources)) {
results.Add(new SellMicroResources(e as SellMicroResourcesEntry) {
Faction = controlling_faction,
@@ -490,7 +490,7 @@ namespace EliteBGS.BGS {
System = current_system,
});
collate = true;
collate = CollateEntries;
} else if (e.Is(Events.SearchAndRescue)) {
results.Add(new SearchAndRescue(e as SearchAndRescueEntry) {
Faction = controlling_faction,
@@ -498,7 +498,7 @@ namespace EliteBGS.BGS {
System = current_system,
});
collate = true;
collate = CollateEntries;
} else if (e.Is(Events.MarketSell)) {
MarketSellEntry sell = e as MarketSellEntry;
long profit = 0;

View File

@@ -185,15 +185,8 @@
<None Include="App.config" />
</ItemGroup>
<ItemGroup>
<Content Include="docs\main-objectives.png" />
<Resource Include="main-entries.png">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Resource>
<Resource Include="main-objectives.png">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</Resource>
<Resource Include="main-report.png">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
<Resource Include="main-page.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
<None Include="README.md">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
@@ -213,10 +206,9 @@
</None>
</ItemGroup>
<ItemGroup>
<Content Include="docs\main-entries.png" />
</ItemGroup>
<ItemGroup>
<Content Include="docs\main-report.png" />
<Resource Include="docs\main-page.png">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Resource>
</ItemGroup>
<ItemGroup>
<Content Include="LICENCE.txt">

View File

@@ -22,26 +22,14 @@
<RowDefinition Height="Auto"/>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<ToolBar VerticalAlignment="Top" Grid.Row="0" Width="Auto" Grid.ColumnSpan="3" Height="Auto" Margin="0,0,0,0" HorizontalAlignment="Left">
<Label Content="System:" HorizontalAlignment="Left" VerticalAlignment="Top"/>
<TextBox x:Name="system" VerticalAlignment="Center" MinWidth="120" MinHeight="22" KeyDown="Filter_KeyDown"/>
<Label Content="Station:" Height="26.2857142857143" VerticalAlignment="Top"/>
<TextBox x:Name="station" Margin="0" VerticalAlignment="Center" MinWidth="120" MinHeight="22" KeyDown="Filter_KeyDown" />
<Label Content="Faction:" Height="26.2857142857143" VerticalAlignment="Top"/>
<TextBox x:Name="faction" Margin="0" VerticalAlignment="Center" MinWidth="120" MinHeight="22" KeyDown="Filter_KeyDown"/>
<Separator Height="26.2857142857143" Margin="0" VerticalAlignment="Top"/>
<Button x:Name="AddFilter" Content="Add Objective" VerticalAlignment="Stretch" Click="AddFilter_Click" Margin="0,0,0,0.286" RenderTransformOrigin="0.5,0.505"/>
<Separator Height="26.2857142857143" Margin="0" VerticalAlignment="Top"/>
<Button x:Name="AddCombatZone" Content="Add Combat Zone Win" VerticalAlignment="Stretch" Margin="0,0,0,0.286" RenderTransformOrigin="0.5,0.505" Click="AddCombatZone_Click"/>
<Separator Height="26.2857142857143" Margin="0" VerticalAlignment="Top"/>
<Button x:Name="AdjustProfit" Content="Adjust Trade Profit" Margin="0" VerticalAlignment="Stretch" Click="AdjustProfit_Click" />
</ToolBar>
<ToolBar VerticalAlignment="Top" Grid.Row="1" Width="Auto" Margin="0,0,0,0" Height="Auto" Grid.ColumnSpan="3" HorizontalAlignment="Left">
<Button x:Name="ParseJournal" Content="Parse Journal" VerticalAlignment="Center" Click="ParseJournal_Click" HorizontalAlignment="Center"/>
<Separator Margin="1" VerticalAlignment="Center" MinWidth="1" HorizontalAlignment="Center" MinHeight="22"/>
@@ -50,9 +38,15 @@
<Label Content="To:" Height="26.2857142857143" VerticalAlignment="Top"/>
<DatePicker x:Name="enddate" Height="26.2857142857143" VerticalAlignment="Center" HorizontalAlignment="Center"/>
<Separator Margin="1" VerticalAlignment="Center" MinWidth="1" HorizontalAlignment="Center" MinHeight="22"/>
<CheckBox x:Name="collate" Margin="1" Content="Collate entries" IsChecked="True" IsThreeState="False"/>
<Separator Height="26.2857142857143" Margin="0" VerticalAlignment="Top"/>
<Button x:Name="AddCombatZone" Content="Add Combat Zone Win" VerticalAlignment="Stretch" Margin="0,0,0,0.286" RenderTransformOrigin="0.5,0.505" Click="AddCombatZone_Click"/>
<Separator Height="26.2857142857143" Margin="0" VerticalAlignment="Top"/>
<Button x:Name="AdjustProfit" Content="Adjust Trade Profit" Margin="0" VerticalAlignment="Stretch" Click="AdjustProfit_Click" />
<Separator Margin="1" VerticalAlignment="Center" MinWidth="1" HorizontalAlignment="Center" MinHeight="22"/>
<Button x:Name="ManuallyParse" Content="Manually Parse JSON" Click="ManuallyParse_Click" />
</ToolBar>
<TreeView x:Name="entries" Margin="0,0,0,0" Grid.ColumnSpan="3" Grid.Row="2" KeyUp="entries_KeyUp">
<TreeView CheckBox.Checked="TreeView_CheckBox_Updated" CheckBox.Unchecked="TreeView_CheckBox_Updated" x:Name="entries" Margin="0,0,0,0" Grid.ColumnSpan="3" Grid.Row="2" KeyUp="entries_KeyUp">
<TreeView.ItemTemplate>
<HierarchicalDataTemplate DataType="{x:Type BGS:Objective}" ItemsSource="{Binding Children}">
<StackPanel Orientation="Horizontal">
@@ -76,25 +70,12 @@
</Style>
</TreeView.ItemContainerStyle>
</TreeView>
</Grid>
</TabItem>
<TabItem Header="Discord Report">
<Grid Background="#FFE5E5E5">
<Grid.RowDefinitions>
<RowDefinition Height="Auto"/>
<RowDefinition Height="*"/>
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="53*"/>
<ColumnDefinition Width="385*"/>
<ColumnDefinition Width="438*"/>
</Grid.ColumnDefinitions>
<ToolBar HorizontalAlignment="Left" Height="36" VerticalAlignment="Top" Width="Auto" Grid.ColumnSpan="2">
<ToolBar HorizontalAlignment="Left" Height="36" VerticalAlignment="Top" Width="Auto" Grid.Row="3" Grid.ColumnSpan="2">
<Button x:Name="GenerateDiscord" Content="Generate Discord Report" VerticalAlignment="Center" Margin="0,0,0,4.857" Click="GenerateDiscord_Click" Height="26"/>
<Separator />
<ComboBox x:Name="LogType" Height="36" Margin="0" VerticalAlignment="Center" Width="140" SelectionChanged="LogType_SelectionChanged" />
</ToolBar>
<TextBox x:Name="DiscordLog" Height="Auto" TextWrapping="Wrap" FontFamily="Consolas" FontSize="14" Grid.Row="1" Grid.ColumnSpan="3" AcceptsReturn="True" AcceptsTab="True"/>
<TextBox x:Name="DiscordLog" Height="Auto" TextWrapping="Wrap" FontFamily="Consolas" FontSize="14" Grid.Row="4" Grid.ColumnSpan="3" AcceptsReturn="True" AcceptsTab="True"/>
</Grid>
</TabItem>
<TabItem Header="Settings" HorizontalAlignment="Left" Height="20" VerticalAlignment="Top" Width="53.7142857142857">

View File

@@ -68,6 +68,10 @@ namespace EliteBGS {
}
}
private void TreeView_CheckBox_Updated(object sender, RoutedEventArgs args) {
GenerateLog();
}
private void Loadentries_EntriesLoaded(List<Entry> lines) {
try {
report.Scan(lines);
@@ -110,11 +114,13 @@ namespace EliteBGS {
private void ParseJournal_Click(object sender, RoutedEventArgs e) {
try {
bool collate = (this.collate.IsChecked ?? true);
journal.Open(); // Load all files
var start = startdate.SelectedDate ?? DateTime.Now;
var end = enddate.SelectedDate ?? DateTime.Now;
report.Scan(journal, start, end);
report.Scan(journal, start, end, collate);
RefreshObjectives();
GenerateLog();
} catch (Exception exception) {
Log("Something went terribly wrong while parsing the E:D player journal.");
Log("Please send this to CMDR Hekateh:");
@@ -122,29 +128,7 @@ namespace EliteBGS {
}
}
private void AddObjective() {
Objective objective = new Objective {
System = system.Text,
Faction = faction.Text,
Station = station.Text,
ManuallyAdded = true,
};
if (!objective.IsValid) {
return;
}
if (report.AddObjective(objective)) {
RefreshObjectives();
config.SaveObjectives(Report);
}
}
private void AddFilter_Click(object sender, RoutedEventArgs e) {
AddObjective();
}
private void GenerateDiscord_Click(object sender, RoutedEventArgs e) {
private void GenerateLog() {
try {
DiscordLogGenerator discord = LogType.SelectedItem as DiscordLogGenerator;
string report = discord.GenerateDiscordLog(Report);
@@ -157,6 +141,10 @@ namespace EliteBGS {
}
}
private void GenerateDiscord_Click(object sender, RoutedEventArgs e) {
GenerateLog();
}
private void RemoveCurrentObjective() {
if (entries.SelectedItem == null) {
return;
@@ -178,7 +166,7 @@ namespace EliteBGS {
if (removed) {
RefreshObjectives();
config.SaveObjectives(Report);
GenerateLog();
}
}
@@ -200,12 +188,6 @@ namespace EliteBGS {
journal = new PlayerJournal(config.Global.JournalLocation);
}
private void Filter_KeyDown(object sender, KeyEventArgs e) {
if (e.Key == Key.Enter) {
AddObjective();
}
}
/// <summary>
/// Gets the currently selected objective, even if a log entry in said objective
/// is selected instead. If nothing is selected, returns null.
@@ -262,6 +244,7 @@ namespace EliteBGS {
objective.LogEntries.Add(zone);
RefreshObjectives();
GenerateLog();
}
private void AdjustProfit_Click(object sender, RoutedEventArgs e) {
@@ -281,6 +264,7 @@ namespace EliteBGS {
if (int.TryParse(adjust.Profit.Text, out int newprofit)) {
sell.Profit = newprofit;
RefreshObjectives();
GenerateLog();
}
}
@@ -291,6 +275,7 @@ namespace EliteBGS {
string template = LogType.SelectedItem.ToString();
config.Global.LastUsedDiscordTemplate = template;
GenerateLog();
}
private void ManuallyParse_Click(object sender, RoutedEventArgs e) {

View File

@@ -11,17 +11,8 @@ Binary downloads can be found here: [https://bgs.n0la.org/](https://bgs.n0la.org
## How To
![Main Window Objectives](main-objectives.png)
Use the main tab to add objectives to the program. To do this, insert the system name,
faction, and, optionally, a station. Then press "Add Objective". Objectives can be deleted
by selecting them and pressing the "DEL" key. Manually added objectives like this are
enabled by default. You can always enable, and disable an objective by checking the box
next to its name. Disabled objectives are not included in the BGS discord log generation.
Once you have your objectives have been configured, you can press "Parse Journal", which
will check your Elite Dangerous player journal for completed missions. Currently the tool
recognises the following completed tasks:
Press "Parse Journal", which will check your Elite Dangerous player journal for completed
transactions. Currently the tool recognises the following transactions:
* Buying of cargo from stations (new in Update 10)
* Completed missions
@@ -72,7 +63,7 @@ you the bounty. And not the faction of the victim. The tool will look for an eve
scanned your victim, and gleem the victim's faction from that. If you did not scan your victim, then
sadly the tool cannot connect the victim's faction to the victim.
![Main Window with entries](main-entries.png)
![Main Window with entries](main-page.png)
The window will then list all the journal entries it has found, and group them by objectives. You
can select which objectives you wish to report, by using the checkmarks.
@@ -82,13 +73,10 @@ This way said entry will not appear in the final log. You can also remove indivi
(if you think the tool detected something you thought was wrong), by selecting the entry,
and pressing the "DEL" key.
Once you are satisfied with the result, move to "Discord Report" tab, select a template, and click
"Generate Report".
![Generated Report](main-report.png)
Before you copy/paste it into the discord of your squadron, you should check the log. You can of
course edit it, if something is wrong or the tool itself missed something.
Once you are satisfied with the result, the discord report should be displayed below, ready to be
copy and pasted. Before you copy/paste it into the discord of your squadron, you should check the log.
You can of course edit it, if something is wrong or the tool itself missed something. If you want to
regenerate it, just click "Generate Log".
## Known Issues and Bugs

View File

@@ -1,5 +1,16 @@
# EliteBGS changelog
## 0.1.7 on 09.11.2022
* Fixed a bug related to total amount of credits gained by turning in organic data.
* Changed UI to have report, and objectives on the same page.
* Report now automatically updates when objectives and entries are selected, deselected or removed.
* Removed manual adding of objectives.
## 0.1.6 on 24.09.2022
* Fixed datetime format.
## 0.1.5 on 24.08.2022
* Added some mission names.

View File

@@ -11,17 +11,8 @@ Binary downloads can be found here: [https://bgs.n0la.org/](https://bgs.n0la.org
## How To
![Main Window Objectives](main-objectives.png)
Use the main tab to add objectives to the program. To do this, insert the system name,
faction, and, optionally, a station. Then press "Add Objective". Objectives can be deleted
by selecting them and pressing the "DEL" key. Manually added objectives like this are
enabled by default. You can always enable, and disable an objective by checking the box
next to its name. Disabled objectives are not included in the BGS discord log generation.
Once you have your objectives have been configured, you can press "Parse Journal", which
will check your Elite Dangerous player journal for completed missions. Currently the tool
recognises the following completed tasks:
Press "Parse Journal", which will check your Elite Dangerous player journal for completed
missions. Currently the tool recognises the following completed tasks:
* Buying of cargo from stations (new in Update 10)
* Completed missions
@@ -72,7 +63,7 @@ you the bounty. And not the faction of the victim. The tool will look for an eve
scanned your victim, and gleem the victim's faction from that. If you did not scan your victim, then
sadly the tool cannot connect the victim's faction to the victim.
![Main Window with entries](main-entries.png)
![Main Window with entries](main-page.png)
The window will then list all the journal entries it has found, and group them by objectives. You
can select which objectives you wish to report, by using the checkmarks.
@@ -82,13 +73,12 @@ This way said entry will not appear in the final log. You can also remove indivi
(if you think the tool detected something you thought was wrong), by selecting the entry,
and pressing the "DEL" key.
Once you are satisfied with the result, move to "Discord Report" tab, select a template, and click
"Generate Report".
Once you are satisfied with the result, you can copy and paste the final report to the discord
server of your choice. Before you copy/paste it into the discord of your squadron, you should
check the log. You can of course also edit it, either if something is wrong because the tool
missed something, or you just wish to add a note the report itself.
![Generated Report](main-report.png)
Before you copy/paste it into the discord of your squadron, you should check the log. You can of
course edit it, if something is wrong or the tool itself missed something.
If you wish to regenerate the discord log, simply click "Generate Log".
## Known Issues and Bugs

View File

@@ -53,9 +53,9 @@ It requires a separate library, called EDJournal, which is also open source:
## Downloads
The latest version of EliteBGS **0.1.5** is available for download here:
The latest version of EliteBGS **0.1.7** is available for download here:
* [https://bgs.n0la.org/elitebgs-0.1.5.zip](https://bgs.n0la.org/elitebgs-0.1.5.zip)
* [https://bgs.n0la.org/elitebgs-0.1.7.zip](https://bgs.n0la.org/elitebgs-0.1.7.zip)
Older versions are available in the archive:

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

BIN
docs/main-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

BIN
main-page.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB