Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -34,3 +34,6 @@ coverage/

# zenflow
.zenflow

.tmp/
.architect/
4 changes: 2 additions & 2 deletions src/NutrientModal.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { App, Modal, Setting, Notice, normalizePath, requestUrl } from "obsidian";
import type FoodTrackerPlugin from "./FoodTrackerPlugin";
import { INVALID_FILENAME_CHARS_REGEX, convertGermanUmlauts, isBarcode } from "./constants";
import { INVALID_FILENAME_CHARS_REGEX, isBarcode } from "./constants";

interface NutrientData {
name: string;
Expand Down Expand Up @@ -193,7 +193,7 @@ export default class NutrientModal extends Modal {

try {
const directory = this.plugin.settings.nutrientDirectory;
const fileName = `${convertGermanUmlauts(this.nutrientData.name).replace(INVALID_FILENAME_CHARS_REGEX, "_")}.md`;
const fileName = `${this.nutrientData.name.replace(INVALID_FILENAME_CHARS_REGEX, "_")}.md`;
const filePath = normalizePath(`${directory}/${fileName}`);

// Check if file already exists
Expand Down
2 changes: 1 addition & 1 deletion src/NutritionCalculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -183,7 +183,7 @@ export function normalizeFilename(raw: string): string | undefined {
}

try {
filename = decodeURIComponent(filename);
filename = decodeURIComponent(filename.replace(/%(?![0-9A-Fa-f]{2})/g, "%25"));
} catch (error) {
console.error("Failed to decode filename:", filename, error);
}
Expand Down
66 changes: 65 additions & 1 deletion src/__tests__/NutritionCalculator.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { calculateNutritionTotals, NutritionCalculationParams } from "../NutritionCalculator";
import { calculateNutritionTotals, normalizeFilename, NutritionCalculationParams } from "../NutritionCalculator";

describe("calculateNutritionTotals", () => {
const buildParams = (overrides: Partial<NutritionCalculationParams>): NutritionCalculationParams => ({
Expand Down Expand Up @@ -252,6 +252,23 @@ describe("calculateNutritionTotals", () => {
});
});

test("resolves linked food with percent in filename without errors", () => {
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
const getNutritionData = jest.fn().mockReturnValue({ calories: 67 });

const result = calculateNutritionTotals(
buildParams({
content: "#food [[Jogurt 3.7% (Pilos)]] 100g",
getNutritionData,
})
);

expect(getNutritionData).toHaveBeenCalledWith("Jogurt 3.7% (Pilos)");
expect(result?.linkedTotals.calories).toBeCloseTo(67);
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});

test("handles zero goal gracefully", () => {
const getNutritionData = jest.fn().mockReturnValue({ calories: 100 });

Expand Down Expand Up @@ -327,3 +344,50 @@ describe("calculateNutritionTotals", () => {
});
});
});

describe("normalizeFilename", () => {
test("decodes percent-encoded filenames", () => {
expect(normalizeFilename("Tartine%20brioche%20Nutella")).toBe("Tartine brioche Nutella");
});

test("handles filenames with literal percent sign without logging errors", () => {
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
expect(normalizeFilename("Jogurt 3.7% (Pilos)")).toBe("Jogurt 3.7% (Pilos)");
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});

test("handles percent at end of filename", () => {
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
expect(normalizeFilename("Fat 3.7%")).toBe("Fat 3.7%");
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});

test("handles mixed valid encoding and literal percent", () => {
const consoleSpy = jest.spyOn(console, "error").mockImplementation();
expect(normalizeFilename("Jogurt%203.7%25%20(Pilos)")).toBe("Jogurt 3.7% (Pilos)");
expect(consoleSpy).not.toHaveBeenCalled();
consoleSpy.mockRestore();
});

test("strips .md extension", () => {
expect(normalizeFilename("Apple.md")).toBe("Apple");
});

test("extracts filename from path", () => {
expect(normalizeFilename("folder/subfolder/Apple")).toBe("Apple");
});

test("strips alias from wikilink", () => {
expect(normalizeFilename("Apple|Green Apple")).toBe("Apple");
});

test("strips heading from wikilink", () => {
expect(normalizeFilename("Apple#Nutrition")).toBe("Apple");
});

test("returns undefined for empty input", () => {
expect(normalizeFilename("")).toBeUndefined();
});
});
81 changes: 25 additions & 56 deletions src/__tests__/constants.test.ts
Original file line number Diff line number Diff line change
@@ -1,71 +1,40 @@
import { convertGermanUmlauts, INVALID_FILENAME_CHARS_REGEX, isBarcode } from "../constants";
import { INVALID_FILENAME_CHARS_REGEX, isBarcode } from "../constants";

describe("convertGermanUmlauts", () => {
test("converts lowercase umlauts correctly", () => {
expect(convertGermanUmlauts("ü")).toBe("ue");
expect(convertGermanUmlauts("ä")).toBe("ae");
expect(convertGermanUmlauts("ö")).toBe("oe");
});

test("converts uppercase umlauts correctly", () => {
expect(convertGermanUmlauts("Ü")).toBe("Ue");
expect(convertGermanUmlauts("Ä")).toBe("Ae");
expect(convertGermanUmlauts("Ö")).toBe("Oe");
});

test("converts multiple umlauts in a single string", () => {
expect(convertGermanUmlauts("Müsli")).toBe("Muesli");
expect(convertGermanUmlauts("Käse")).toBe("Kaese");
expect(convertGermanUmlauts("Döner")).toBe("Doener");
expect(convertGermanUmlauts("Hühnchenbrösel")).toBe("Huehnchenbroesel");
});

test("handles mixed case umlauts", () => {
expect(convertGermanUmlauts("ÄpfelÖl")).toBe("AepfelOel");
expect(convertGermanUmlauts("GrünKöhl")).toBe("GruenKoehl");
});

test("preserves non-umlaut characters", () => {
expect(convertGermanUmlauts("Chicken")).toBe("Chicken");
expect(convertGermanUmlauts("123")).toBe("123");
expect(convertGermanUmlauts("Test-Food")).toBe("Test-Food");
describe("filename handling integration", () => {
test("preserves German umlauts and Eszett", () => {
expect("Müsli123".replace(INVALID_FILENAME_CHARS_REGEX, "_")).toBe("Müsli123");
expect("Müsli mit Früchten".replace(INVALID_FILENAME_CHARS_REGEX, "_")).toBe("Müsli_mit_Früchten");
expect("Weißbrot".replace(INVALID_FILENAME_CHARS_REGEX, "_")).toBe("Weißbrot");
});

test("handles empty strings and special cases", () => {
expect(convertGermanUmlauts("")).toBe("");
expect(convertGermanUmlauts(" ")).toBe(" ");
expect(convertGermanUmlauts("no umlauts here")).toBe("no umlauts here");
test("preserves Czech diacritical characters", () => {
const czech = "Jogurt bílý krémový";
const result = czech.replace(INVALID_FILENAME_CHARS_REGEX, "_");
expect(result).toBe("Jogurt_bílý_krémový");
});

test("handles real food names", () => {
expect(convertGermanUmlauts("Müsli mit Früchten")).toBe("Muesli mit Fruechten");
expect(convertGermanUmlauts("Würstchen")).toBe("Wuerstchen");
expect(convertGermanUmlauts("Süßkartoffel")).toBe("Suesskartoffel");
test("preserves Slovak diacritical characters", () => {
const slovak = "Smotana Kyslá";
const result = slovak.replace(INVALID_FILENAME_CHARS_REGEX, "_");
expect(result).toBe("Smotana_Kyslá");
});

test("converts Eszett (sharp s) correctly", () => {
expect(convertGermanUmlauts("ß")).toBe("ss");
expect(convertGermanUmlauts("Weißbrot")).toBe("Weissbrot");
expect(convertGermanUmlauts("Groß")).toBe("Gross");
test("preserves French diacritical characters", () => {
const french = "Crème brûlée";
const result = french.replace(INVALID_FILENAME_CHARS_REGEX, "_");
expect(result).toBe("Crème_brûlée");
});
});

describe("filename handling integration", () => {
test("umlauts are preserved by INVALID_FILENAME_CHARS_REGEX", () => {
const textWithUmlauts = "Müsli123";
const invalidCharsRemoved = textWithUmlauts.replace(INVALID_FILENAME_CHARS_REGEX, "_");
// Should preserve umlauts, only replace spaces and special chars
expect(invalidCharsRemoved).toBe("Müsli123");

const textWithSpaces = "Müsli mit Früchten";
const spacesReplaced = textWithSpaces.replace(INVALID_FILENAME_CHARS_REGEX, "_");
expect(spacesReplaced).toBe("Müsli_mit_Früchten");
test("preserves Spanish diacritical characters", () => {
const spanish = "Jalapeño picañón";
const result = spanish.replace(INVALID_FILENAME_CHARS_REGEX, "_");
expect(result).toBe("Jalapeño_picañón");
});

test("full filename conversion workflow", () => {
test("full filename conversion preserves umlauts and diacritics directly", () => {
const foodName = "Müsli mit Früchten & Nüssen!";
const converted = convertGermanUmlauts(foodName).replace(INVALID_FILENAME_CHARS_REGEX, "_");
expect(converted).toBe("Muesli_mit_Fruechten___Nuessen_");
const converted = foodName.replace(INVALID_FILENAME_CHARS_REGEX, "_");
expect(converted).toBe("Müsli_mit_Früchten___Nüssen_");
});
});

Expand Down
26 changes: 2 additions & 24 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,30 +36,8 @@ export const SUGAR_REGEX = /(\d+(?:\.\d+)?)sugar/i;
// File and content validation patterns
// ================================

/** Regex to identify invalid characters for filenames (preserves German umlauts and Eszett) */
export const INVALID_FILENAME_CHARS_REGEX = /[^a-zA-Z0-9äöüÄÖÜß]/g;

/** Lookup map for German umlaut and Eszett conversion - created once for performance */
const UMLAUT_MAP: Record<string, string> = {
ü: "ue",
ä: "ae",
ö: "oe",
Ü: "Ue",
Ä: "Ae",
Ö: "Oe",
ß: "ss",
};

/**
* Converts German umlauts and Eszett to their letter equivalents for filename safety
* while preserving readability. Uses single regex pass for optimal performance.
*
* @param text - The text containing potential umlauts or Eszett
* @returns Text with umlauts converted to letter pairs
*/
export const convertGermanUmlauts = (text: string): string => {
return text.replace(/[üäöÜÄÖß]/g, match => UMLAUT_MAP[match]);
};
/** Regex to identify invalid characters for filenames (preserves all Unicode letters and digits) */
export const INVALID_FILENAME_CHARS_REGEX = /[^\p{L}\p{N}]/gu;

// ================================
// Factory functions for dynamic regex creation
Expand Down