Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/ENGINES.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ Filee chooses engines automatically. Settings → *Engines* shows their status,
| PDF slides | built in (PDFium, PdfPig) | **PDF → PPTX**: a picture of each page on its own slide (looks like the PDF, not editable), the page's text in the speaker notes |
| PDF tables | built in (PdfPig) | **PDF → XLSX, ODS, CSV** (#267): each table drawn with lines on its own sheet, numbers and dates as real values, merged cells and column widths kept |
| E-mail | built in (MimeKit, MsgReader) | **EML, Outlook MSG → HTML** (headers + body, inline pictures), **TXT**, **ZIP** (the attachments); **MSG → EML**; **MHTML → HTML** (the saved page in one file, pictures inlined; on to PDF, DOCX, HWPX, TXT, Markdown, EPUB from there) |
| Data | built in (System.Text.Json, YamlDotNet, Tomlyn, System.Xml) | **JSON, JSON Lines, YAML, TOML, XML, CSV ↔ each other** (CSV for tables); **vCard, iCalendar ↔ CSV**; spreadsheets, contacts and events reach JSON / YAML / TOML / XML through CSV |
| Data | built in (System.Text.Json, YamlDotNet, Tomlyn, System.Xml) | **JSON, JSON Lines, YAML, TOML, XML, CSV ↔ each other** (CSV for tables); **vCard, iCalendar ↔ CSV**; FreeMASTER oscilloscope TXT → CSV; spreadsheets, contacts and events reach JSON / YAML / TOML / XML through CSV |
| **E-books** | built in | **EPUB, MOBI/AZW/AZW3/PRC, FB2, HTMLZ, TXTZ → EPUB, HWPX (→ PDF), TXT, HTML, Markdown, FB2, HTMLZ, TXTZ**; HTML, Markdown, TXT, DOCX → EPUB/FB2/HTMLZ/TXTZ; HTML → TXT; comics CBZ/CBR/CB7/CBT/CBC → PDF, EPUB, CBZ; PDF → CBZ; AZW4 → PDF; **EPUB ↔ KEPUB** (Kobo) |
| CAD (ACadSharp + built-in renderer) | built in (library) | **DWG ↔ DXF**; DWG/DXF → **PDF** (vector), **SVG**, PNG, JPG, WEBP, TIFF, BMP, GIF, ICO, AVIF |
| **Subtitles** | built in | **SRT, ASS/SSA, WebVTT, SAMI (SMI), LRC, TTML, MicroDVD, SCC → SRT, ASS, WebVTT, SAMI, LRC, TTML, MicroDVD**, always UTF-8 (Korean CP949 / EUC-KR files read correctly) |
Expand Down
2 changes: 1 addition & 1 deletion src/Filee.Core/Presets/BuiltInData.cs
Original file line number Diff line number Diff line change
Expand Up @@ -651,7 +651,7 @@ .. WithSubtitleProfile(CatalogProfiles()),
NameKey = "profile.text",
Extensions = ExtensionsOf(FormatCategory.Text),
PresetIds = ["to-pdf", "to-hwpx", "to-hwp", "to-docx", "to-html", "to-txt", "to-epub"],
BackPresetIds = ["to-md", "to-odt", "to-rtf", "to-kepub", "merge-pdf", "zip-all"],
BackPresetIds = ["to-md", "to-odt", "to-rtf", "to-kepub", "to-csv", "merge-pdf", "zip-all"],
};

/// <summary>Donuts of the categories added in settings schema 6, in the order they follow "text".</summary>
Expand Down
2 changes: 1 addition & 1 deletion src/Filee.Core/Settings/AppSettings.cs
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ public static bool IsFlipKeyAllowed(string key, IEnumerable<TriggerGesture> gest
public sealed class AppSettings
{
/// <summary>Current schema version. Increase when the format changes and add a migration.</summary>
public const int CurrentSchemaVersion = 54;
public const int CurrentSchemaVersion = 55;

public int SchemaVersion { get; set; } = CurrentSchemaVersion;

Expand Down
8 changes: 8 additions & 0 deletions src/Filee.Core/Settings/UserDataStore.cs
Original file line number Diff line number Diff line change
Expand Up @@ -931,6 +931,14 @@ public static bool ApplyToLibrary(int from, List<Preset> presets, List<ToolbarPr
// donut; custom claims stay put.
if (from < 54)
changed |= LearnExtensions(profiles, out _, ["001", "7z.001", "zip.001", "tar.001"]);
// v55: offer CSV on the text donut for FreeMASTER oscilloscope exports when a slot is free (#571).
if (from < 55 && presets.Any(p => p.Id == "to-csv")
&& profiles.FirstOrDefault(p => p.Id == "text") is { BackPresetIds: { } textBack } textProfile
&& !textProfile.PresetIds.Contains("to-csv") && !textBack.Contains("to-csv") && textBack.Count < ToolbarProfile.MaxSlices)
{
InsertBefore(textBack, "to-csv", "merge-pdf");
changed = true;
}
return changed;
}

Expand Down
12 changes: 12 additions & 0 deletions src/Filee.Engines/Data/DataConverter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ public sealed class DataConverter : IConverter
.. Trees.Append("csv").Select(to => new ConversionEdge("har", to)),
new("ics", "csv"),
new("csv", "ics"),
new("txt", "csv", DirectOnly: true),
];

public EngineStatus GetStatus() =>
Expand All @@ -45,6 +46,17 @@ public Task<IReadOnlyList<string>> ConvertAsync(ConversionStep step, IProgress<d
Task.Run<IReadOnlyList<string>>(() =>
{
progress?.Report(0.1);
if (step.From == "txt" && step.To == "csv")
{
var source = HwpxConverter.DecodeText(File.ReadAllBytes(step.InputPath));
var table = FreeMasterText.Read(source);
cancellationToken.ThrowIfCancellationRequested();
if (step.Output.Allocate("csv") is not { } csv)
return [];
CsvFormat.WriteRows(table, csv);
progress?.Report(1);
return [csv];
}
if (step.From == "vcf")
{
var contacts = VCard.Read(File.ReadAllBytes(step.InputPath));
Expand Down
24 changes: 24 additions & 0 deletions src/Filee.Engines/Data/FreeMasterText.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
// FreeMASTER oscilloscope TXT exports: a marker, a tab-separated header and tab-separated measurement rows.

using Filee.Engines.Office.Sheets;

namespace Filee.Engines.Data;

internal static class FreeMasterText
{
public static List<List<string>> Read(string text)
{
var rows = CsvFormat.Parse(text.TrimStart('\uFEFF'), CsvFormat.Tab)
.Where(row => row.Count != 1 || row[0].Length != 0).ToList();
if (rows.Count < 3 || rows[0].Count != 1 || rows[0][0].Trim() != "# Oscilloscope Data"
|| rows[1].Count < 2 || rows[1][0].Trim() != "# Seconds")
throw new InvalidDataException("Expected a FreeMASTER oscilloscope export with a tab-separated header and data.");

var header = rows[1];
header[0] = "Seconds";
var result = rows.Skip(1).ToList();
if (result.Skip(1).Any(row => row.Count != header.Count))
throw new InvalidDataException("A FreeMASTER data row has a different number of columns from the header.");
return result;
}
}
40 changes: 40 additions & 0 deletions tests/Filee.Core.Tests/FreeMasterProfileTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
// Existing text donuts can offer FreeMASTER TXT → CSV without changing custom layouts.

using Filee.Core.Presets;
using Filee.Core.Profiles;
using Filee.Core.Settings;

namespace Filee.Core.Tests;

public class FreeMasterProfileTests
{
[Fact]
public void New_text_profile_offers_csv()
{
Assert.Contains("to-csv", BuiltInData.TextProfile().BackPresetIds!);
}

[Fact]
public void Migration_offers_csv_only_once_and_respects_existing_choices()
{
var presets = BuiltInData.CreatePresets();
var profiles = BuiltInData.CreateProfiles();
var text = profiles.Single(p => p.Id == "text");
text.BackPresetIds!.Remove("to-csv");
Assert.True(SettingsMigrations.ApplyToLibrary(54, presets, profiles));
Assert.Contains("to-csv", text.BackPresetIds);
Assert.False(SettingsMigrations.ApplyToLibrary(54, presets, profiles));

text.BackPresetIds.Remove("to-csv");
text.PresetIds.Add("to-csv");
Assert.False(SettingsMigrations.ApplyToLibrary(54, presets, profiles));
Assert.DoesNotContain("to-csv", text.BackPresetIds);

text.PresetIds.Remove("to-csv");
while (text.BackPresetIds.Count < ToolbarProfile.MaxSlices)
text.BackPresetIds.Add("user-" + text.BackPresetIds.Count);
Assert.False(SettingsMigrations.ApplyToLibrary(54, presets, profiles));
Assert.DoesNotContain("to-csv", text.BackPresetIds);
Assert.False(SettingsMigrations.ApplyToLibrary(55, presets, profiles));
}
}
49 changes: 49 additions & 0 deletions tests/Filee.Engines.Tests/FreeMasterTextTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// FreeMASTER oscilloscope text exports become CSV without interpreting measurement values.

using System.Text;
using Filee.Core.Conversion;
using Filee.Core.Presets;
using Filee.Engines.Office.Sheets;

namespace Filee.Engines.Tests;

public class FreeMasterTextTests(EngineFixture fx) : IClassFixture<EngineFixture>
{
[Theory]
[InlineData(false)]
[InlineData(true)]
public async Task Oscilloscope_export_becomes_csv_with_text_and_empty_fields(bool utf16)
{
var path = Path.Combine(fx.NewFolder(), "scope.txt");
const string input = "# Oscilloscope Data\r\n\r\n# Seconds\tLabel\tValue\tEmpty\r\n"
+ "0.000\t\"cell, \"\"A\"\"\"\t4.12300\t\r\n0.541\t한글\t007\t\r\n";
await File.WriteAllTextAsync(path, input, utf16 ? Encoding.Unicode : new UTF8Encoding(false), TestContext.Current.CancellationToken);

var job = await fx.ConvertAsync([path], new Preset { TargetFormat = "csv" });

Assert.True(job.State == JobState.Completed, string.Join("; ", job.Files.Select(f => f.ErrorDetail)));
var bytes = await File.ReadAllBytesAsync(Assert.Single(job.Outputs), TestContext.Current.CancellationToken);
Assert.True(bytes is [0xEF, 0xBB, 0xBF, ..]);
var rows = CsvFormat.Parse(Encoding.UTF8.GetString(bytes).TrimStart('\uFEFF'), ',');
Assert.Equal(new[] { "Seconds", "Label", "Value", "Empty" }, rows[0]);
Assert.Equal(new[] { "0.000", "cell, \"A\"", "4.12300", "" }, rows[1]);
Assert.Equal(new[] { "0.541", "한글", "007", "" }, rows[2]);
Assert.Equal(3, rows.Count);
}

[Theory]
[InlineData("ordinary plain text\nwithout table\n")]
[InlineData("# Oscilloscope Data\n# Seconds\tValue\n0.0\t1\t2\n")]
[InlineData("# Oscilloscope Data\n# Seconds\tValue\n")]
public async Task Non_matching_or_incomplete_data_is_refused(string input)
{
var path = Path.Combine(fx.NewFolder(), "scope.txt");
await File.WriteAllTextAsync(path, input, TestContext.Current.CancellationToken);

var job = await fx.ConvertAsync([path], new Preset { TargetFormat = "csv" });

Assert.Equal(JobState.Failed, job.State);
Assert.Empty(job.Outputs);
Assert.Equal(input, await File.ReadAllTextAsync(path, TestContext.Current.CancellationToken));
}
}
Loading