Skip to content
Merged
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
52 changes: 52 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ This guide walks you through using ReMemory to create encrypted recovery bundles
- [Project Structure](#project-structure)
- [Commands Reference](#commands-reference)
- [Advanced: Anonymous Mode](#advanced-anonymous-mode)
- [Advanced: Multilingual Bundles](#advanced-multilingual-bundles)

## Overview

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

Since there's no built-in contact list, make sure share holders know how to reach each other (or you) when recovery is needed.

## Advanced: Multilingual Bundles

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).

### CLI Usage

Set the project-level default language with `--language`:

```bash
# All bundles in Spanish
rememory init my-recovery --language es

# Per-friend language customization
rememory init my-recovery --language es \
--friend "Alice,alice@example.com,en" \
--friend "Roberto,roberto@example.com,es" \
--friend "Hans,hans@example.com,de"
```

The `--friend` flag now accepts an optional third field for language: `"Name,contact,lang"`.

### project.yml Format

You can also set languages directly in `project.yml`:

```yaml
name: my-recovery-2026
threshold: 3
language: es # default bundle language (optional, defaults to "en")
friends:
- name: Alice
contact: alice@example.com
language: en # override per friend
- name: Roberto
contact: roberto@example.com
# uses project language (es)
- name: Hans
contact: hans@example.com
language: de
```

### Web UI

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.

### What Gets Translated

- **README.txt**: All instructions, warnings, and section headings
- **README.pdf**: Same content as README.txt in PDF format
- **recover.html**: Opens in the friend's language by default (they can still switch)
2 changes: 1 addition & 1 deletion e2e/creation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ friends:

// Switch to Spanish
await creation.setLanguage('es');
await creation.expectPageTitle('Crear Sobres');
await creation.expectPageTitle('Crear Kits de Recuperación');

// Switch to German
await creation.setLanguage('de');
Expand Down
23 changes: 18 additions & 5 deletions e2e/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,24 @@ export function extractAnonymousBundles(bundlesDir: string, shareNums: number[])
return shareNums.map(num => extractAnonymousBundle(bundlesDir, num));
}

// Extract the 25 recovery words from a README.txt file as a space-separated string
// Known README filenames across all supported languages
const README_FILENAMES = ['README', 'LEEME', 'LIESMICH', 'LISEZMOI', 'PREBERI'];

// Find the README .txt file in an extracted bundle directory (any language)
export function findReadmeFile(bundleDir: string, ext: string = '.txt'): string {
for (const name of README_FILENAMES) {
const filePath = path.join(bundleDir, name + ext);
if (fs.existsSync(filePath)) return filePath;
}
throw new Error(`No README${ext} file found in ${bundleDir}`);
}

// Extract the 25 recovery words from a README file as a space-separated string
export function extractWordsFromReadme(readmePath: string): string {
const readme = fs.readFileSync(readmePath, 'utf8');
const wordsMatch = readme.match(/YOUR 25 RECOVERY WORDS:\n\n([\s\S]*?)\n\nRead these words/);
if (!wordsMatch) throw new Error('Could not find recovery words in README.txt');
// Match word grid: look for "25 RECOVERY WORDS" (any language) or numbered word lines
const wordsMatch = readme.match(/\b25\b[^\n]*:\n\n([\s\S]*?)\n\n/);
if (!wordsMatch) throw new Error('Could not find recovery words in ' + readmePath);

const wordLines = wordsMatch[1].trim().split('\n');
const leftWords: string[] = [];
Expand Down Expand Up @@ -150,9 +163,9 @@ export class RecoveryPage {
);
}

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

Expand Down
5 changes: 3 additions & 2 deletions e2e/qr-scanner.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
createTestProject,
extractBundle,
extractBundles,
findReadmeFile,
RecoveryPage
} from './helpers';

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

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

const bobReadme = fs.readFileSync(path.join(bobDir, 'README.txt'), 'utf8');
const bobReadme = fs.readFileSync(findReadmeFile(bobDir), 'utf8');
const pemMatch = bobReadme.match(
/-----BEGIN REMEMORY SHARE-----([\s\S]*?)-----END REMEMORY SHARE-----/
);
Expand Down
13 changes: 7 additions & 6 deletions e2e/recovery.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
extractBundles,
extractAnonymousBundles,
extractWordsFromReadme,
findReadmeFile,
generateStandaloneHTML,
RecoveryPage
} from './helpers';
Expand Down Expand Up @@ -102,7 +103,7 @@ test.describe('Browser Recovery Tool', () => {
await recovery.expectPasteAreaVisible();

// Read Bob's share and paste it
const bobShare = fs.readFileSync(path.join(bobDir, 'README.txt'), 'utf8');
const bobShare = fs.readFileSync(findReadmeFile(bobDir), 'utf8');
await recovery.pasteShare(bobShare);
await recovery.submitPaste();

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

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

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

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

// Paste Alice's words
const aliceWords = extractWordsFromReadme(path.join(aliceDir, 'README.txt'));
const aliceWords = extractWordsFromReadme(findReadmeFile(aliceDir));
await recovery.clickPasteButton();
await recovery.pasteShare(aliceWords);
await recovery.submitPaste();
await recovery.expectShareCount(1);

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

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

// Paste Alice's words as the FIRST share (no threshold/total available)
Expand Down
35 changes: 26 additions & 9 deletions internal/bundle/bundle.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/eljojo/rememory/internal/html"
"github.com/eljojo/rememory/internal/pdf"
"github.com/eljojo/rememory/internal/project"
"github.com/eljojo/rememory/internal/translations"
)

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

// Resolve language: friend override > project default > "en"
lang := friend.Language
if lang == "" {
lang = p.Language
}
if lang == "" {
lang = "en"
}

// Get other friends (excluding this one) - empty for anonymous mode
var otherFriends []project.Friend
var otherFriendsInfo []html.FriendInfo
Expand All @@ -78,6 +88,7 @@ func GenerateAll(p *project.Project, cfg Config) error {
OtherFriends: otherFriendsInfo,
Threshold: p.Threshold,
Total: len(p.Friends),
Language: lang,
}
recoverHTML := html.GenerateRecoverHTML(cfg.WASMBytes, cfg.Version, cfg.GitHubReleaseURL, personalization)
recoverChecksum := core.HashString(recoverHTML)
Expand All @@ -101,6 +112,7 @@ func GenerateAll(p *project.Project, cfg Config) error {
SealedAt: p.Sealed.At,
Anonymous: p.Anonymous,
RecoveryURL: cfg.RecoveryURL,
Language: lang,
})
if err != nil {
return fmt.Errorf("generating bundle for %s: %w", friend.Name, err)
Expand Down Expand Up @@ -133,6 +145,7 @@ type BundleParams struct {
SealedAt time.Time
Anonymous bool
RecoveryURL string
Language string // Bundle language for this friend
}

// GenerateBundle creates a single bundle ZIP file for one friend.
Expand All @@ -151,6 +164,7 @@ func GenerateBundle(params BundleParams) error {
RecoverChecksum: params.RecoverChecksum,
Created: params.SealedAt,
Anonymous: params.Anonymous,
Language: params.Language,
}

// Generate README.txt
Expand All @@ -171,15 +185,18 @@ func GenerateBundle(params BundleParams) error {
Created: readmeData.Created,
Anonymous: readmeData.Anonymous,
RecoveryURL: params.RecoveryURL,
Language: params.Language,
})
if err != nil {
return fmt.Errorf("generating PDF: %w", err)
}

// Create ZIP with all files, using sealed date as modification time
readmeFileTxt := translations.ReadmeFilename(params.Language, ".txt")
readmeFilePdf := translations.ReadmeFilename(params.Language, ".pdf")
files := []ZipFile{
{Name: "README.txt", Content: []byte(readmeContent), ModTime: params.SealedAt},
{Name: "README.pdf", Content: pdfContent, ModTime: params.SealedAt},
{Name: readmeFileTxt, Content: []byte(readmeContent), ModTime: params.SealedAt},
{Name: readmeFilePdf, Content: pdfContent, ModTime: params.SealedAt},
{Name: "MANIFEST.age", Content: params.ManifestData, ModTime: params.SealedAt},
{Name: "recover.html", Content: []byte(params.RecoverHTML), ModTime: params.SealedAt},
}
Expand Down Expand Up @@ -240,23 +257,23 @@ func VerifyBundle(bundlePath string) error {
return fmt.Errorf("reading %s: %w", f.Name, err)
}

switch f.Name {
case "README.txt":
switch {
case translations.IsReadmeFile(f.Name, ".txt"):
readmeContent = string(data)
case "README.pdf":
case translations.IsReadmeFile(f.Name, ".pdf"):
pdfData = data
case "MANIFEST.age":
case f.Name == "MANIFEST.age":
manifestData = data
case "recover.html":
case f.Name == "recover.html":
recoverData = data
}
}

if readmeContent == "" {
return fmt.Errorf("README.txt not found in bundle")
return fmt.Errorf("README file (.txt) not found in bundle")
}
if len(pdfData) == 0 {
return fmt.Errorf("README.pdf not found in bundle")
return fmt.Errorf("README file (.pdf) not found in bundle")
}
if len(manifestData) == 0 {
return fmt.Errorf("MANIFEST.age not found in bundle")
Expand Down
Loading