Skip to content

Commit 7238297

Browse files
committed
Per-Friend Language + Translated READMEs
1 parent e2d6521 commit 7238297

32 files changed

Lines changed: 922 additions & 324 deletions

docs/guide.md

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ This guide walks you through using ReMemory to create encrypted recovery bundles
2020
- [Project Structure](#project-structure)
2121
- [Commands Reference](#commands-reference)
2222
- [Advanced: Anonymous Mode](#advanced-anonymous-mode)
23+
- [Advanced: Multilingual Bundles](#advanced-multilingual-bundles)
2324

2425
## Overview
2526

@@ -506,3 +507,54 @@ Recovery works the same way, but:
506507
- Shares show generic labels like "Share 1" instead of names
507508

508509
Since there's no built-in contact list, make sure share holders know how to reach each other (or you) when recovery is needed.
510+
511+
## Advanced: Multilingual Bundles
512+
513+
Each friend can receive their bundle (README.txt, README.pdf, and recover.html) in their preferred language. ReMemory supports 5 languages: English (en), Spanish (es), German (de), French (fr), and Slovenian (sl).
514+
515+
### CLI Usage
516+
517+
Set the project-level default language with `--language`:
518+
519+
```bash
520+
# All bundles in Spanish
521+
rememory init my-recovery --language es
522+
523+
# Per-friend language customization
524+
rememory init my-recovery --language es \
525+
--friend "Alice,alice@example.com,en" \
526+
--friend "Roberto,roberto@example.com,es" \
527+
--friend "Hans,hans@example.com,de"
528+
```
529+
530+
The `--friend` flag now accepts an optional third field for language: `"Name,contact,lang"`.
531+
532+
### project.yml Format
533+
534+
You can also set languages directly in `project.yml`:
535+
536+
```yaml
537+
name: my-recovery-2026
538+
threshold: 3
539+
language: es # default bundle language (optional, defaults to "en")
540+
friends:
541+
- name: Alice
542+
contact: alice@example.com
543+
language: en # override per friend
544+
- name: Roberto
545+
contact: roberto@example.com
546+
# uses project language (es)
547+
- name: Hans
548+
contact: hans@example.com
549+
language: de
550+
```
551+
552+
### Web UI
553+
554+
In the web-based bundle creator (maker.html), each friend entry has a **Bundle language** dropdown. The default is the current UI language. Friends can always switch languages in recover.html regardless of the bundle default.
555+
556+
### What Gets Translated
557+
558+
- **README.txt**: All instructions, warnings, and section headings
559+
- **README.pdf**: Same content as README.txt in PDF format
560+
- **recover.html**: Opens in the friend's language by default (they can still switch)

e2e/helpers.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,24 @@ export function extractAnonymousBundles(bundlesDir: string, shareNums: number[])
9898
return shareNums.map(num => extractAnonymousBundle(bundlesDir, num));
9999
}
100100

101-
// Extract the 25 recovery words from a README.txt file as a space-separated string
101+
// Known README filenames across all supported languages
102+
const README_FILENAMES = ['README', 'LEEME', 'LIESMICH', 'LISEZMOI', 'PREBERI'];
103+
104+
// Find the README .txt file in an extracted bundle directory (any language)
105+
export function findReadmeFile(bundleDir: string, ext: string = '.txt'): string {
106+
for (const name of README_FILENAMES) {
107+
const filePath = path.join(bundleDir, name + ext);
108+
if (fs.existsSync(filePath)) return filePath;
109+
}
110+
throw new Error(`No README${ext} file found in ${bundleDir}`);
111+
}
112+
113+
// Extract the 25 recovery words from a README file as a space-separated string
102114
export function extractWordsFromReadme(readmePath: string): string {
103115
const readme = fs.readFileSync(readmePath, 'utf8');
104-
const wordsMatch = readme.match(/YOUR 25 RECOVERY WORDS:\n\n([\s\S]*?)\n\nRead these words/);
105-
if (!wordsMatch) throw new Error('Could not find recovery words in README.txt');
116+
// Match word grid: look for "25 RECOVERY WORDS" (any language) or numbered word lines
117+
const wordsMatch = readme.match(/\b25\b[^\n]*:\n\n([\s\S]*?)\n\n/);
118+
if (!wordsMatch) throw new Error('Could not find recovery words in ' + readmePath);
106119

107120
const wordLines = wordsMatch[1].trim().split('\n');
108121
const leftWords: string[] = [];
@@ -150,9 +163,9 @@ export class RecoveryPage {
150163
);
151164
}
152165

153-
// Add shares from README.txt files
166+
// Add shares from README files (supports translated filenames)
154167
async addShares(...bundleDirs: string[]): Promise<void> {
155-
const readmePaths = bundleDirs.map(dir => path.join(dir, 'README.txt'));
168+
const readmePaths = bundleDirs.map(dir => findReadmeFile(dir, '.txt'));
156169
await this.page.locator('#share-file-input').setInputFiles(readmePaths);
157170
}
158171

e2e/qr-scanner.spec.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
createTestProject,
77
extractBundle,
88
extractBundles,
9+
findReadmeFile,
910
RecoveryPage
1011
} from './helpers';
1112

@@ -106,7 +107,7 @@ test.describe('QR Scanner', () => {
106107
const recovery = new RecoveryPage(page, aliceDir);
107108

108109
// Read Bob's PEM share
109-
const bobReadme = fs.readFileSync(path.join(bobDir, 'README.txt'), 'utf8');
110+
const bobReadme = fs.readFileSync(findReadmeFile(bobDir), 'utf8');
110111
const pemMatch = bobReadme.match(
111112
/-----BEGIN REMEMORY SHARE-----([\s\S]*?)-----END REMEMORY SHARE-----/
112113
);
@@ -183,7 +184,7 @@ test.describe('QR Scanner', () => {
183184
test('scanning a URL with fragment adds the share', async ({ page }) => {
184185
const [aliceDir, bobDir] = extractBundles(bundlesDir, ['Alice', 'Bob']);
185186

186-
const bobReadme = fs.readFileSync(path.join(bobDir, 'README.txt'), 'utf8');
187+
const bobReadme = fs.readFileSync(findReadmeFile(bobDir), 'utf8');
187188
const pemMatch = bobReadme.match(
188189
/-----BEGIN REMEMORY SHARE-----([\s\S]*?)-----END REMEMORY SHARE-----/
189190
);

e2e/recovery.spec.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
extractBundles,
1111
extractAnonymousBundles,
1212
extractWordsFromReadme,
13+
findReadmeFile,
1314
generateStandaloneHTML,
1415
RecoveryPage
1516
} from './helpers';
@@ -102,7 +103,7 @@ test.describe('Browser Recovery Tool', () => {
102103
await recovery.expectPasteAreaVisible();
103104

104105
// Read Bob's share and paste it
105-
const bobShare = fs.readFileSync(path.join(bobDir, 'README.txt'), 'utf8');
106+
const bobShare = fs.readFileSync(findReadmeFile(bobDir), 'utf8');
106107
await recovery.pasteShare(bobShare);
107108
await recovery.submitPaste();
108109

@@ -211,7 +212,7 @@ test.describe('Browser Recovery Tool', () => {
211212
await recovery.expectShareCount(1);
212213

213214
// Extract Bob's 25 recovery words from his README.txt
214-
const words = extractWordsFromReadme(path.join(bobDir, 'README.txt'));
215+
const words = extractWordsFromReadme(findReadmeFile(bobDir));
215216
expect(words.split(' ').length).toBe(25);
216217

217218
// Type the 25 words into the paste area (includes index as 25th word)
@@ -232,7 +233,7 @@ test.describe('Browser Recovery Tool', () => {
232233
await recovery.expectShareCount(1);
233234

234235
// Read Bob's README.txt and extract the word grid section as-is
235-
const bobReadme = fs.readFileSync(path.join(bobDir, 'README.txt'), 'utf8');
236+
const bobReadme = fs.readFileSync(findReadmeFile(bobDir), 'utf8');
236237
const wordsMatch = bobReadme.match(/YOUR 25 RECOVERY WORDS:\n\n([\s\S]*?)\n\nRead these words/);
237238
expect(wordsMatch).not.toBeNull();
238239
const wordGrid = wordsMatch![1]; // The numbered two-column grid
@@ -374,14 +375,14 @@ test.describe('Generic recover.html (no personalization)', () => {
374375
await recovery.expectShareCount(0);
375376

376377
// Paste Alice's words
377-
const aliceWords = extractWordsFromReadme(path.join(aliceDir, 'README.txt'));
378+
const aliceWords = extractWordsFromReadme(findReadmeFile(aliceDir));
378379
await recovery.clickPasteButton();
379380
await recovery.pasteShare(aliceWords);
380381
await recovery.submitPaste();
381382
await recovery.expectShareCount(1);
382383

383384
// Paste Bob's words
384-
const bobWords = extractWordsFromReadme(path.join(bobDir, 'README.txt'));
385+
const bobWords = extractWordsFromReadme(findReadmeFile(bobDir));
385386
await recovery.clickPasteButton();
386387
await recovery.pasteShare(bobWords);
387388
await recovery.submitPaste();
@@ -408,7 +409,7 @@ test.describe('Generic recover.html (no personalization)', () => {
408409
await recovery.expectShareCount(0);
409410

410411
// Extract Alice's 25 recovery words from her README.txt
411-
const aliceWords = extractWordsFromReadme(path.join(aliceDir, 'README.txt'));
412+
const aliceWords = extractWordsFromReadme(findReadmeFile(aliceDir));
412413
expect(aliceWords.split(' ').length).toBe(25);
413414

414415
// Paste Alice's words as the FIRST share (no threshold/total available)

internal/bundle/bundle.go

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import (
1414
"github.com/eljojo/rememory/internal/html"
1515
"github.com/eljojo/rememory/internal/pdf"
1616
"github.com/eljojo/rememory/internal/project"
17+
"github.com/eljojo/rememory/internal/translations"
1718
)
1819

1920
// Config holds configuration for bundle generation.
@@ -53,6 +54,15 @@ func GenerateAll(p *project.Project, cfg Config) error {
5354
for i, friend := range p.Friends {
5455
share := shares[i]
5556

57+
// Resolve language: friend override > project default > "en"
58+
lang := friend.Language
59+
if lang == "" {
60+
lang = p.Language
61+
}
62+
if lang == "" {
63+
lang = "en"
64+
}
65+
5666
// Get other friends (excluding this one) - empty for anonymous mode
5767
var otherFriends []project.Friend
5868
var otherFriendsInfo []html.FriendInfo
@@ -78,6 +88,7 @@ func GenerateAll(p *project.Project, cfg Config) error {
7888
OtherFriends: otherFriendsInfo,
7989
Threshold: p.Threshold,
8090
Total: len(p.Friends),
91+
Language: lang,
8192
}
8293
recoverHTML := html.GenerateRecoverHTML(cfg.WASMBytes, cfg.Version, cfg.GitHubReleaseURL, personalization)
8394
recoverChecksum := core.HashString(recoverHTML)
@@ -101,6 +112,7 @@ func GenerateAll(p *project.Project, cfg Config) error {
101112
SealedAt: p.Sealed.At,
102113
Anonymous: p.Anonymous,
103114
RecoveryURL: cfg.RecoveryURL,
115+
Language: lang,
104116
})
105117
if err != nil {
106118
return fmt.Errorf("generating bundle for %s: %w", friend.Name, err)
@@ -133,6 +145,7 @@ type BundleParams struct {
133145
SealedAt time.Time
134146
Anonymous bool
135147
RecoveryURL string
148+
Language string // Bundle language for this friend
136149
}
137150

138151
// GenerateBundle creates a single bundle ZIP file for one friend.
@@ -151,6 +164,7 @@ func GenerateBundle(params BundleParams) error {
151164
RecoverChecksum: params.RecoverChecksum,
152165
Created: params.SealedAt,
153166
Anonymous: params.Anonymous,
167+
Language: params.Language,
154168
}
155169

156170
// Generate README.txt
@@ -171,15 +185,18 @@ func GenerateBundle(params BundleParams) error {
171185
Created: readmeData.Created,
172186
Anonymous: readmeData.Anonymous,
173187
RecoveryURL: params.RecoveryURL,
188+
Language: params.Language,
174189
})
175190
if err != nil {
176191
return fmt.Errorf("generating PDF: %w", err)
177192
}
178193

179194
// Create ZIP with all files, using sealed date as modification time
195+
readmeFileTxt := translations.ReadmeFilename(params.Language, ".txt")
196+
readmeFilePdf := translations.ReadmeFilename(params.Language, ".pdf")
180197
files := []ZipFile{
181-
{Name: "README.txt", Content: []byte(readmeContent), ModTime: params.SealedAt},
182-
{Name: "README.pdf", Content: pdfContent, ModTime: params.SealedAt},
198+
{Name: readmeFileTxt, Content: []byte(readmeContent), ModTime: params.SealedAt},
199+
{Name: readmeFilePdf, Content: pdfContent, ModTime: params.SealedAt},
183200
{Name: "MANIFEST.age", Content: params.ManifestData, ModTime: params.SealedAt},
184201
{Name: "recover.html", Content: []byte(params.RecoverHTML), ModTime: params.SealedAt},
185202
}
@@ -240,23 +257,23 @@ func VerifyBundle(bundlePath string) error {
240257
return fmt.Errorf("reading %s: %w", f.Name, err)
241258
}
242259

243-
switch f.Name {
244-
case "README.txt":
260+
switch {
261+
case translations.IsReadmeFile(f.Name, ".txt"):
245262
readmeContent = string(data)
246-
case "README.pdf":
263+
case translations.IsReadmeFile(f.Name, ".pdf"):
247264
pdfData = data
248-
case "MANIFEST.age":
265+
case f.Name == "MANIFEST.age":
249266
manifestData = data
250-
case "recover.html":
267+
case f.Name == "recover.html":
251268
recoverData = data
252269
}
253270
}
254271

255272
if readmeContent == "" {
256-
return fmt.Errorf("README.txt not found in bundle")
273+
return fmt.Errorf("README file (.txt) not found in bundle")
257274
}
258275
if len(pdfData) == 0 {
259-
return fmt.Errorf("README.pdf not found in bundle")
276+
return fmt.Errorf("README file (.pdf) not found in bundle")
260277
}
261278
if len(manifestData) == 0 {
262279
return fmt.Errorf("MANIFEST.age not found in bundle")

0 commit comments

Comments
 (0)