diff --git a/README.md b/README.md index fd300ce..f9dee2e 100644 --- a/README.md +++ b/README.md @@ -112,7 +112,7 @@ Global (Settings UI or `settings.json`): - `csv.defaultSeparator` (string, default `,`): Fallback separator. Use `\\t` in `settings.json` for tabs. - `csv.separatorByExtension` (object): Extension-to-separator mapping (defaults include `.csv`→`,`, `.tsv`/`.tab`→tab, `.psv`→`|`). - `csv.maxFileSizeMB` (number, default `10`): Soft limit for opening files in CSV view. If exceeded, CSV prompts: `Cancel`, `Continue This Time`, or `Ignore Forever` (sets this setting to `0`). -- Per-file encoding: use `CSV: Change File Encoding` to set a file's encoding (e.g., `utf8`, `utf16le`, `windows1250`, `gbk`). The extension will reopen the file using the chosen encoding. +- Per-file encoding: `CSV: Change File Encoding` opens VS Code's encoding picker. Use **Reopen with Encoding** and select the source file's encoding to correct incorrectly displayed characters before editing or saving. Per-file (stored by the extension; set via commands): @@ -121,6 +121,16 @@ Per-file (stored by the extension; set via commands): - CSV separator override — `CSV: Change CSV Separator` (or clear it with `CSV: Reset CSV Separator (Inherit)`) - Hide first N rows (default `0`) — `CSV: Hide First N Rows` +### File encodings and accented characters + +CSV displays the Unicode text supplied by VS Code; VS Code controls decoding the file's bytes and encoding saved files. If accents look wrong, run `CSV: Change File Encoding`, choose **Reopen with Encoding**, and select the encoding used by the source. Check the names before saving. Cancelling the unsaved-changes prompt stops the command and preserves your open edits. + +The editor shows a nonmodal warning once per opened document when it contains the replacement character `�`, offering the same encoding command. This character can be legitimate text, so the warning indicates a possible mismatch and does not guess an encoding or change any data. Some incorrect decodings produce other characters instead and will not trigger this warning. + +For example, the original attachment in [issue #76](https://github.com/jonaraphael/csv/issues/76) is not valid UTF-8. Explicitly decoding it as **DOS (CP 437)** or **Western European DOS (CP 850)** restores names such as `Knäckebröd` and `Thüringer`; both encodings produce the same text for that particular file, so its bytes do not uniquely identify which one created it. Use the source's documented encoding for other files. Available picker labels depend on your VS Code version. + +**Save with Encoding** changes how the current text is written; it cannot restore characters already decoded incorrectly. If a corrupted version was saved, reopen the original source or a backup using the correct encoding first. + --- ## Editing Modes and Shortcuts diff --git a/package-lock.json b/package-lock.json index 2d9813c..6f578fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@typescript-eslint/parser": "^7.9.0", "@vscode/vsce": "^3.7.1", "eslint": "^8.57.0", + "iconv-lite": "^0.6.3", "jsdom": "^26.1.0", "typescript": "^4.4.3", "vscode": "^1.1.37" diff --git a/package.json b/package.json index 092489b..a1745ea 100644 --- a/package.json +++ b/package.json @@ -250,6 +250,7 @@ "@typescript-eslint/parser": "^7.9.0", "@vscode/vsce": "^3.7.1", "eslint": "^8.57.0", + "iconv-lite": "^0.6.3", "jsdom": "^26.1.0", "typescript": "^4.4.3", "vscode": "^1.1.37" diff --git a/src/CsvEditorProvider.ts b/src/CsvEditorProvider.ts index 2f1babf..d45e0c7 100644 --- a/src/CsvEditorProvider.ts +++ b/src/CsvEditorProvider.ts @@ -61,9 +61,11 @@ class CsvEditorController { private static readonly DEFAULT_MAX_FILE_SIZE_MB = 10; private static readonly LARGE_FILE_CONTINUE_THIS_TIME = 'Continue This Time'; private static readonly LARGE_FILE_IGNORE_FOREVER = 'Ignore Forever'; + private static readonly encodingWarnings = new WeakSet(); private isUpdatingDocument = false; private isSaving = false; + private disposed = false; private pendingOperation: Promise = Promise.resolve(); private currentWebviewPanel: vscode.WebviewPanel | undefined; private document!: vscode.TextDocument; @@ -109,6 +111,9 @@ class CsvEditorController { if (webviewPanel.active) { CsvEditorProvider.currentActive = this; } + void this.warnAboutPossibleDecoding().catch(error => { + console.warn('CSV: Encoding warning action failed', error); + }); webviewPanel.webview.postMessage({ type: 'focus' }); let wasVisible = webviewPanel.visible; @@ -209,6 +214,7 @@ class CsvEditorController { }); webviewPanel.onDidDispose(() => { + this.disposed = true; changeDocumentSubscription.dispose(); saveDocumentSubscription.dispose(); CsvEditorProvider.editors = CsvEditorProvider.editors.filter(ed => ed !== this); @@ -225,6 +231,22 @@ class CsvEditorController { return this.pendingOperation; } + private async warnAboutPossibleDecoding(): Promise { + if (!this.document.getText().includes('\uFFFD') || CsvEditorController.encodingWarnings.has(this.document)) { + return; + } + CsvEditorController.encodingWarnings.add(this.document); + const action = 'Change File Encoding'; + const selected = await vscode.window.showWarningMessage( + 'CSV: This file contains replacement characters (�), which can indicate a decoding mismatch. Check the source encoding before saving.', + action + ); + if (selected === action && !this.disposed) { + // Bind the action to this document even if another tab becomes active. + await vscode.commands.executeCommand('csv.changeEncoding', this.document.uri); + } + } + private getMaxFileSizeLimitMb(config: vscode.WorkspaceConfiguration): number { const raw = Number(config.get('maxFileSizeMB', CsvEditorController.DEFAULT_MAX_FILE_SIZE_MB)); if (!Number.isFinite(raw) || raw <= 0) { diff --git a/src/commands.ts b/src/commands.ts index 6241b3d..f0feb8d 100644 --- a/src/commands.ts +++ b/src/commands.ts @@ -127,10 +127,10 @@ export function registerCsvCommands(context: vscode.ExtensionContext) { .forEach(ed => ed.refresh()); vscode.window.showInformationMessage(`CSV: Hiding first ${n} row(s) for this file.`); }), - vscode.commands.registerCommand('csv.changeEncoding', async () => { + vscode.commands.registerCommand('csv.changeEncoding', async (requestedUri?: vscode.Uri) => { const active = CsvEditorProvider.getActiveProvider(); - if (!active) { vscode.window.showInformationMessage('Open a CSV/TSV/PSV file in the CSV editor.'); return; } - const uri = active.getDocumentUri(); + const uri = requestedUri instanceof vscode.Uri ? requestedUri : active?.getDocumentUri(); + if (!uri) { vscode.window.showInformationMessage('Open a CSV/TSV/PSV file in the CSV editor.'); return; } // Close any existing tabs for this URI so we can reuse the slot cleanly try { @@ -144,10 +144,14 @@ export function registerCsvCommands(context: vscode.ExtensionContext) { }); if (toClose.length) { console.log(`[CSV(encoding)]: closing ${toClose.length} tab(s) for ${uri.fsPath}`); - await vscode.window.tabGroups.close(toClose); + const closed = await vscode.window.tabGroups.close(toClose); + if (!closed) { + return; + } } } catch (e) { console.warn('[CSV(encoding)]: failed to close existing tabs', e); + return; } // Open the default text editor in-place and invoke the built-in encoding picker diff --git a/src/test/edit-integrity-harness.ts b/src/test/edit-integrity-harness.ts index 1a48ed0..6b1e5b9 100644 --- a/src/test/edit-integrity-harness.ts +++ b/src/test/edit-integrity-harness.ts @@ -19,7 +19,33 @@ const vscodeStub = { replace(_uri: unknown, _range: unknown, text: string) { this.text = text; } }, ColorThemeKind: { Dark: 1 }, - window: { activeColorTheme: { kind: 1 } }, + window: { + activeColorTheme: { kind: 1 }, + showWarningMessage: async (message: string) => { + activeHarness.warnings.push(message); + return activeHarness.warningChoice; + }, + showInformationMessage() {}, + tabGroups: { + get all() { return [{ tabs: [{ input: { uri: activeHarness.document.uri, viewType: 'csv.editor' } }] }]; }, + close: async () => activeHarness.closeResult + } + }, + commands: { + registerCommand: (name: string, command: (...args: any[]) => unknown) => { + activeHarness.commands.set(name, command); + return { dispose() {} }; + }, + executeCommand: async (name: string, ...args: unknown[]) => { + activeHarness.executedCommands.push({ name, args }); + if (activeHarness.commandError) { + throw activeHarness.commandError; + } + if (name === 'workbench.action.editor.changeEncoding') { + await activeHarness.commandWait; + } + } + }, workspace: { getConfiguration: () => ({ get: (key: string, fallback: unknown) => key === 'maxFileSizeMB' ? 0 : fallback }), applyEdit: (edit: { text: string }) => activeHarness.applyEdit(edit.text), @@ -45,18 +71,21 @@ const { CsvEditorProvider } = require('../CsvEditorProvider') as typeof import(' const { JSDOM } = require('jsdom'); export async function createEditorHarness(options: { + text?: string; + warningChoice?: string | Promise; rows?: string[][]; separator?: string; header?: boolean; remote?: boolean; } = {}) { const separator = options.separator ?? ','; - let text = Papa.unparse(options.rows ?? [['name', 'value'], ['one', 'two']], { delimiter: separator }); + let text = options.text ?? Papa.unparse(options.rows ?? [['name', 'value'], ['one', 'two']], { delimiter: separator }); let diskText = text; let messageListener: (message: any) => Promise; const changeListeners: Array<(event: unknown) => void> = []; const viewListeners: Array<(event: unknown) => void> = []; const saveListeners: Array<(event: unknown) => void> = []; + const disposeListeners: Array<() => void> = []; const messages: any[] = []; const delayedEdits: Array<() => void> = []; let deferEdits = false; @@ -76,6 +105,7 @@ export async function createEditorHarness(options: { } }; const context = { + subscriptions: [] as unknown[], extensionPath: process.cwd(), workspaceState: { values: {} as Record, @@ -97,10 +127,22 @@ export async function createEditorHarness(options: { onDidReceiveMessage: (listener: typeof messageListener) => { messageListener = listener; } }, onDidChangeViewState: (listener: (event: unknown) => void) => { viewListeners.push(listener); }, - onDidDispose() {} + onDidDispose(listener: () => void) { disposeListeners.push(listener); } }; const harness = { document, panel, messages, changeListeners, saveListeners, + warnings: [] as string[], + warningChoice: options.warningChoice, + commandError: undefined as Error | undefined, + commandWait: undefined as Promise | undefined, + dispose() { disposeListeners.forEach(listener => listener()); }, + closeResult: true, + commands: new Map unknown>(), + executedCommands: [] as Array<{ name: string; args: unknown[] }>, + registerCommands() { + const { registerCsvCommands } = require('../commands') as typeof import('../commands'); + registerCsvCommands(context as any); + }, get diskText() { return diskText; }, get pendingEdits() { return delayedEdits.length; }, deferEdits() { deferEdits = true; }, diff --git a/src/test/encoding-integrity.test.ts b/src/test/encoding-integrity.test.ts new file mode 100644 index 0000000..6b95fe3 --- /dev/null +++ b/src/test/encoding-integrity.test.ts @@ -0,0 +1,128 @@ +import assert from 'assert'; +import fs from 'fs'; +import path from 'path'; +import { describe, it } from 'node:test'; +import { createEditorHarness } from './edit-integrity-harness'; +import iconv from 'iconv-lite'; + +describe('Explicit source decoding preserves original non-UTF8 characters', () => { + const original = fs.readFileSync(path.join(process.cwd(), 'src', 'test', 'fixtures', 'issue-76-products.csv')); + const decoded = iconv.decode(original, 'cp437'); + + it('keeps the reporter fixture byte-for-byte and establishes the supported decoding ambiguity', () => { + assert.strictEqual(original.length, 4052); + assert.ok(original.toString('utf8').includes('\uFFFD')); + assert.ok(!decoded.includes('\uFFFD')); + assert.strictEqual(iconv.decode(original, 'cp850'), decoded); + assert.deepStrictEqual(iconv.encode(decoded, 'cp437'), original); + for (const name of ['Knäckebröd', 'Thüringer', 'Guaraná', 'Nuß']) { + assert.ok(decoded.includes(name)); + } + }); + + it('renders and edits the correctly decoded document without changing accented names', async () => { + const host = await createEditorHarness({ text: decoded }); + assert.strictEqual(host.warnings.length, 0); + assert.ok(host.panel.webview.html.includes('Knäckebröd')); + const row = host.rows().findIndex(record => record[1]?.includes('Knäckebröd')); + await host.receive({ type: 'editCell', row, col: 5, value: '22.00' }); + await host.receive({ type: 'save' }); + const saved = iconv.decode(iconv.encode(host.diskText, 'cp437'), 'cp437'); + for (const name of ['Knäckebröd', 'Thüringer', 'Guaraná', 'Nuß']) { + assert.ok(saved.includes(name)); + } + }); +}); + +describe('Possible decoding mismatches use an explicit encoding workflow', () => { + it('warns once without changing already decoded data when dismissed', async () => { + const host = await createEditorHarness({ rows: [['text'], ['replacement � may be intentional']] }); + const originalText = host.document.getText(); + assert.strictEqual(host.warnings.length, 1); + assert.match(host.warnings[0], /can indicate a decoding mismatch/); + await host.view(false, false); + await host.view(true, true); + assert.strictEqual(host.warnings.length, 1); + assert.strictEqual(host.executedCommands.length, 0); + assert.strictEqual(host.document.getText(), originalText); + }); + + it('binds the warning action to the originating document URI', async () => { + const host = await createEditorHarness({ rows: [['text'], ['�']], warningChoice: 'Change File Encoding' }); + await new Promise(resolve => setImmediate(resolve)); + assert.deepStrictEqual(host.executedCommands, [{ name: 'csv.changeEncoding', args: [host.document.uri] }]); + }); + + it('does not act on a warning from a disposed editor', async () => { + let choose!: (value: string) => void; + const choice = new Promise(resolve => { choose = resolve; }); + const host = await createEditorHarness({ rows: [['text'], ['�']], warningChoice: choice }); + host.dispose(); + choose('Change File Encoding'); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(host.executedCommands.length, 0); + }); + + it('handles a rejected warning action without an unhandled promise rejection', async () => { + let choose!: (value: string) => void; + const choice = new Promise(resolve => { choose = resolve; }); + const host = await createEditorHarness({ rows: [['text'], ['�']], warningChoice: choice }); + host.commandError = new Error('test command rejection'); + const warnings: unknown[][] = []; + const originalWarn = console.warn; + console.warn = (...args: unknown[]) => { warnings.push(args); }; + try { + choose('Change File Encoding'); + await new Promise(resolve => setImmediate(resolve)); + assert.strictEqual(warnings[0]?.[0], 'CSV: Encoding warning action failed'); + } finally { + console.warn = originalWarn; + } + }); + + it('stops the encoding command when the user cancels closing a dirty document', async () => { + const host = await createEditorHarness(); + await host.receive({ type: 'editCell', row: 1, col: 0, value: 'unsaved' }); + host.closeResult = false; + host.registerCommands(); + await host.commands.get('csv.changeEncoding')?.(); + assert.strictEqual(host.executedCommands.length, 0); + assert.strictEqual(host.rows()[1][0], 'unsaved'); + assert.ok(!host.diskText.includes('unsaved')); + }); + + it('opens the native encoding picker and returns to the CSV view after a successful close', async () => { + const host = await createEditorHarness(); + host.registerCommands(); + await host.commands.get('csv.changeEncoding')?.(host.document.uri); + assert.deepStrictEqual(host.executedCommands.map(command => command.name), [ + 'vscode.openWith', 'workbench.action.editor.changeEncoding', 'vscode.openWith' + ]); + assert.strictEqual(host.executedCommands[0].args[0], host.document.uri); + assert.strictEqual(host.executedCommands[2].args[1], 'csv.editor'); + }); + + it('waits for the native picker command to finish before reopening the CSV view', async () => { + const host = await createEditorHarness(); + let complete!: () => void; + host.commandWait = new Promise(resolve => { complete = resolve; }); + host.registerCommands(); + const changing = host.commands.get('csv.changeEncoding')?.(); + await new Promise(resolve => setImmediate(resolve)); + assert.deepStrictEqual(host.executedCommands.map(command => command.name), ['vscode.openWith', 'workbench.action.editor.changeEncoding']); + complete(); + await changing; + assert.strictEqual(host.executedCommands[2].name, 'vscode.openWith'); + }); + + it('round-trips UTF-8 accented CSV and TSV without triggering a warning', async () => { + for (const separator of [',', '\t']) { + const host = await createEditorHarness({ separator, rows: [['name', 'value'], ['éäöüßøå', 'before']] }); + await host.receive({ type: 'editCell', row: 1, col: 1, value: 'after' }); + await host.receive({ type: 'save' }); + assert.strictEqual(host.warnings.length, 0); + assert.strictEqual(host.rows()[1][0], 'éäöüßøå'); + assert.ok(Buffer.from(host.diskText, 'utf8').toString('utf8').includes('éäöüßøå')); + } + }); +}); diff --git a/src/test/fixtures/README.md b/src/test/fixtures/README.md new file mode 100644 index 0000000..4af6753 --- /dev/null +++ b/src/test/fixtures/README.md @@ -0,0 +1,7 @@ +`issue-76-products.csv` is the original 4,052-byte attachment from +https://github.com/jonaraphael/csv/issues/76, downloaded without conversion from +https://github.com/user-attachments/files/25353528/w3schools_dbo_Products.csv. + +It is not valid UTF-8. CP437 and CP850 produce identical Unicode text for all +bytes present in this particular fixture; the fixture cannot distinguish which +encoding created it. Tests decode explicitly and do not infer or repair bytes. diff --git a/src/test/fixtures/issue-76-products.csv b/src/test/fixtures/issue-76-products.csv new file mode 100644 index 0000000..c5d87b3 --- /dev/null +++ b/src/test/fixtures/issue-76-products.csv @@ -0,0 +1,79 @@ +[ProductID],[ProductName],[SuplID],[CatID],[Unit],[Price] +[int],[varchar(50)],[int],[int],[varchar(50)],[decimal(18, 2)] +1,"Chais",1,1,"10 boxes x 20 bags",18.00 +2,"Chang",1,1,"24 - 12 oz bottles",19.00 +3,"Aniseed Syrup",1,2,"12 - 550 ml bottles",10.00 +4,"Chef Antons Cajun Seasoning",2,2,"48 - 6 oz jars",22.00 +5,"Chef Antons Gumbo Mix",2,2,"36 boxes",21.35 +6,"Grandmas Boysenberry Spread",3,2,"12 - 8 oz jars",25.00 +7,"Uncle Bobs Organic Dried Pears",3,7,"12 - 1 lb pkgs",30.00 +8,"Northwoods Cranberry Sauce",3,2,"12 - 12 oz jars",40.00 +9,"Mishi Kobe Niku",4,6,"18 - 500 g pkgs",97.00 +10,"Ikura",4,8,"12 - 200 ml jars",31.00 +11,"Queso Cabrales",5,4,"1 kg pkg",21.00 +12,"Queso Manchego La Pastora",5,4,"10 - 500 g pkgs",38.00 +13,"Konbu",6,8,"2 kg box",6.00 +14,"Tofu",6,7,"40 - 100 g pkgs",23.25 +15,"Genen Shouyu",6,2,"24 - 250 ml bottles",15.50 +16,"Pavlova",7,3,"32 - 500 g boxes",17.45 +17,"Alice Mutton",7,6,"20 - 1 kg tins",39.00 +18,"Carnarvon Tigers",7,8,"16 kg pkg",62.50 +19,"Teatime Chocolate Biscuits",8,3,"10 boxes x 12 pieces",9.20 +20,"Sir Rodneys Marmalade",8,3,"30 gift boxes",81.00 +21,"Sir Rodneys Scones",8,3,"24 pkgs. x 4 pieces",10.00 +22,"Gustafs Kn„ckebr”d",9,5,"24 - 500 g pkgs",21.00 +23,"Tunnbr”d",9,5,"12 - 250 g pkgs",9.00 +24,"Guaran  Fant stica",10,1,"12 - 355 ml cans",4.50 +25,"NuNuCa Nuá-Nougat-Creme",11,3,"20 - 450 g glasses",14.00 +26,"Gumb„r Gummib„rchen",11,3,"100 - 250 g bags",31.23 +27,"Schoggi Schokolade",11,3,"100 - 100 g pieces",43.90 +28,"R”ssle Sauerkraut",12,7,"25 - 825 g cans",45.60 +29,"Th�ringer Rostbratwurst",12,6,"50 bags x 30 sausgs",123.79 +30,"Nord-Ost Matjeshering",13,8,"10 - 200 g glasses",25.89 +31,"Gorgonzola Telino",14,4,"12 - 100 g pkgs",12.50 +32,"Mascarpone Fabioli",14,4,"24 - 200 g pkgs",32.00 +33,"Geitost",15,4,"500 g",2.50 +34,"Sasquatch Ale",16,1,"24 - 12 oz bottles",14.00 +35,"Steeleye Stout",16,1,"24 - 12 oz bottles",18.00 +36,"Inlagd Sill",17,8,"24 - 250 g jars",19.00 +37,"Gravad lax",17,8,"12 - 500 g pkgs",26.00 +38,"C“te de Blaye",18,1,"12 - 75 cl bottles",263.50 +39,"Chartreuse verte",18,1,"750 cc per bottle",18.00 +40,"Boston Crab Meat",19,8,"24 - 4 oz tins",18.40 +41,"Jacks New England Clam Chowder",19,8,"12 - 12 oz cans",9.65 +42,"Singaporean Hokkien Fried Mee",20,5,"32 - 1 kg pkgs",14.00 +43,"Ipoh Coffee",20,1,"16 - 500 g tins",46.00 +44,"Gula Malacca",20,2,"20 - 2 kg bags",19.45 +45,"Rogede sild",21,8,"1k pkg",9.50 +46,"Spegesild",21,8,"4 - 450 g glasses",12.00 +47,"Zaanse koeken",22,3,"10 - 4 oz boxes",9.50 +48,"Chocolade",22,3,"10 pkgs",12.75 +49,"Maxilaku",23,3,"24 - 50 g pkgs",20.00 +50,"Valkoinen suklaa",23,3,"12 - 100 g bars",16.25 +51,"Manjimup Dried Apples",24,7,"50 - 300 g pkgs",53.00 +52,"Filo Mix",24,5,"16 - 2 kg boxes",7.00 +53,"Perth Pasties",24,6,"48 pieces",32.80 +54,"TourtiŠre",25,6,"16 pies",7.45 +55,"Pƒt‚ chinois",25,6,"24 boxes x 2 pies",24.00 +56,"Gnocchi di nonna Alice",26,5,"24 - 250 g pkgs",38.00 +57,"Ravioli Angelo",26,5,"24 - 250 g pkgs",19.50 +58,"Escargots de Bourgogne",27,8,"24 pieces",13.25 +59,"Raclette Courdavault",28,4,"5 kg pkg",55.00 +60,"Camembert Pierrot",28,4,"15 - 300 g rounds",34.00 +61,"Sirop d‚rable",29,2,"24 - 500 ml bottles",28.50 +62,"Tarte au sucre",29,3,"48 pies",49.30 +63,"Vegie-spread",7,2,"15 - 625 g jars",43.90 +64,"Wimmers gute Semmelkn”del",12,5,"20 bags x 4 pieces",33.25 +65,"Louisiana Fiery Hot Pepper Sauce",2,2,"32 - 8 oz bottles",21.05 +66,"Louisiana Hot Spiced Okra",2,2,"24 - 8 oz jars",17.00 +67,"Laughing Lumberjack Lager",16,1,"24 - 12 oz bottles",14.00 +68,"Scottish Longbreads",8,3,"10 boxes x 8 pieces",12.50 +69,"Gudbrandsdalsost",15,4,"10 kg pkg",36.00 +70,"Outback Lager",7,1,"24 - 355 ml bottles",15.00 +71,"Flotemysost",15,4,"10 - 500 g pkgs",21.50 +72,"Mozzarella di Giovanni",14,4,"24 - 200 g pkgs",34.80 +73,"R”d Kaviar",17,8,"24 - 150 g jars",15.00 +74,"Longlife Tofu",4,7,"5 kg pkg",10.00 +75,"Rh”nbr„u Klosterbier",12,1,"24 - 0.5 l bottles",7.75 +76,"Lakkalik””ri",23,1,"500 ml",18.00 +77,"Original Frankfurter gr�ne Soáe",12,2,"12 boxes",13.00