diff --git a/chat/ChatView.vue b/chat/ChatView.vue index c2727a251..4099850d6 100644 --- a/chat/ChatView.vue +++ b/chat/ChatView.vue @@ -331,6 +331,13 @@ + + @@ -366,6 +373,9 @@ import AdLauncherDialog from './ads/AdLauncher.vue'; import Modal from '../components/Modal.vue'; import QuickJump from './QuickJump.vue'; + import { ipcRenderer } from 'electron'; + import { toasts, showToast, updateToast, dismissToast } from './toast'; + import Toast from '../components/Toast.vue'; const unreadClasses = { [Conversation.UnreadState.None]: '', @@ -391,7 +401,8 @@ adCenter: AdCenterDialog, adLauncher: AdLauncherDialog, modal: Modal, - 'quick-jump': QuickJump + 'quick-jump': QuickJump, + toast: Toast } }) export default class ChatView extends Vue { @@ -405,6 +416,8 @@ focusListener!: () => void; blurListener!: () => void; readonly isMac = process.platform === 'darwin'; + toasts = toasts; + dismissToast = dismissToast; channelConversations = core.conversations.channelConversations; privateConversations = core.conversations.privateConversations; @@ -426,6 +439,42 @@ this.mouseButtonListener = (e: MouseEvent) => this.onMouseButton(e); window.addEventListener('mouseup', this.mouseButtonListener); + ipcRenderer.on( + 'auto-backup-status', + (_e, status: string, progress?: number) => { + const id = 'auto-backup'; + if (status === 'started') { + showToast({ + id, + message: 'Auto backup in progress...', + icon: 'fa-sync', + iconSpin: true, + progress: 0 + }); + } else if (status === 'progress' && typeof progress === 'number') { + updateToast(id, { progress }); + } else if (status === 'success') { + updateToast(id, { + message: 'Auto backup complete', + icon: 'fa-check', + iconSpin: false, + variant: 'success', + progress: 1, + autoDismiss: 5000 + }); + } else if (status === 'error') { + updateToast(id, { + message: 'Auto backup failed', + icon: 'fa-exclamation-triangle', + iconSpin: false, + variant: 'error', + progress: undefined, + autoDismiss: 5000 + }); + } + } + ); + //We do this because it's a massive pain in the ๐Ÿซ to read some monstrosity of //an if-else statement to compare our platforms and then pick a keyboard shortcut in our keyboard handle event this.historyNavigateHandleForward = !this.isMac diff --git a/chat/locales/en_us.json b/chat/locales/en_us.json index 301891a4a..62688ac56 100644 --- a/chat/locales/en_us.json +++ b/chat/locales/en_us.json @@ -644,6 +644,7 @@ "settings.export.noCharacters": "No character data is available to export.", "settings.export.saveTitle": "Save Horizon export", "settings.export.summary": "Exported data for {0} character(s) to {1}.", + "settings.export.manageData": "Manage Data", "settings.export.title": "Export Horizon data", "settings.export.zipping": "Finalizing export", "settings.filter.hideAds": "Hide [b]ads[/b] from matching characters", diff --git a/chat/locales/en_uwu.json b/chat/locales/en_uwu.json index ef6bd8d75..198d05474 100644 --- a/chat/locales/en_uwu.json +++ b/chat/locales/en_uwu.json @@ -636,6 +636,7 @@ "settings.export.noCharacters": "Sumimasen... No chawacter data is available to export.", "settings.export.saveTitle": "Save Howizon expowt", "settings.export.summary": "Expowted data for {0} chawacter(s) to {1}.", + "settings.export.manageData": "Manage Data", "settings.export.title": "Expowt Howizon data", "settings.export.zipping": "Finalizing expowt", "settings.filter.hideAds": "Hide [b]ads[/b] fwom matching chawactews", diff --git a/chat/toast.ts b/chat/toast.ts new file mode 100644 index 000000000..e2900b10d --- /dev/null +++ b/chat/toast.ts @@ -0,0 +1,70 @@ +import Vue from 'vue'; + +export interface ToastEntry { + id: string; + message: string; + icon?: string; + iconSpin?: boolean; + variant?: 'default' | 'error' | 'success'; + progress?: number; + autoDismiss?: number; +} + +type ToastUpdate = Partial>; + +const state = Vue.observable({ toasts: [] as ToastEntry[] }); + +const timers = new Map>(); + +let nextId = 0; + +function scheduleAutoDismiss(id: string, ms: number): void { + clearTimer(id); + if (ms > 0) { + timers.set( + id, + setTimeout(() => dismissToast(id), ms) + ); + } +} + +function clearTimer(id: string): void { + const t = timers.get(id); + if (t !== undefined) { + clearTimeout(t); + timers.delete(id); + } +} + +export function showToast( + opts: Omit & { id?: string } +): string { + const id = opts.id ?? `toast-${++nextId}`; + const entry: ToastEntry = { + variant: 'default', + autoDismiss: 0, + ...opts, + id + }; + state.toasts.push(entry); + if (entry.autoDismiss) scheduleAutoDismiss(id, entry.autoDismiss); + return id; +} + +export function updateToast(id: string, partial: ToastUpdate): void { + const idx = state.toasts.findIndex(t => t.id === id); + if (idx === -1) return; + const entry = { ...state.toasts[idx], ...partial }; + Vue.set(state.toasts, idx, entry); + if (partial.autoDismiss !== undefined) { + scheduleAutoDismiss(id, partial.autoDismiss); + } +} + +export function dismissToast(id: string): void { + clearTimer(id); + const idx = state.toasts.findIndex(t => t.id === id); + if (idx !== -1) state.toasts.splice(idx, 1); +} + +export const toasts = state.toasts; diff --git a/components/Toast.vue b/components/Toast.vue new file mode 100644 index 000000000..278d82d64 --- /dev/null +++ b/components/Toast.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/docs/export-format.md b/docs/export-format.md new file mode 100644 index 000000000..b3d570350 --- /dev/null +++ b/docs/export-format.md @@ -0,0 +1,137 @@ +# Horizon Export Format (v2) + +This document describes the ZIP-based export format used by Horizon for +backing up and restoring user data. Other clients can produce or consume +this format by following the specification below. + +## Overview + +A Horizon export is a standard ZIP archive (deflate, level 6 recommended) +containing a flat `manifest.json`, an optional top-level `settings` file, +and per-character data nested under `characters//`. + +## ZIP layout + +``` +manifest.json # required (v2+) +settings # general app settings (JSON, no extension) +characters/ + / + logs/ + .json # chat log (JSON array, see below) + settings/ + pinned # pinned conversations (JSON, no extension) + favoriteEIcons # favorite eicons (JSON, no extension) + recent # recent conversations (JSON, no extension) + recentChannels # recent channels (JSON, no extension) + hiddenUsers # hidden users list (JSON, no extension) + ... # other character settings files + drafts.txt # message drafts (JSON, .txt extension) +``` + +### Path conventions + +- All paths use forward slashes (`/`). +- Character names are used verbatim as directory names (case-sensitive). +- Settings files have no file extension; they contain JSON. +- Log files are JSON arrays (`.txt.json`), organized by target name and + date. Each element is an object with `time` (unix seconds), `type` + (message type integer), `sender` (character name), and `text` fields. + On import, these are converted back to the application's binary format. + +## manifest.json + +The manifest **must** be the first entry in the ZIP. It is a JSON object +with the following fields: + +| Field | Type | Description | +| --------------- | ---------- | ----------------------------------------------------- | +| `version` | `number` | Format version. Currently `2`. | +| `createdAt` | `string` | ISO 8601 timestamp of when the export was created. | +| `app` | `string` | Always `"horizon"`. | +| `expectedFiles` | `number` | Number of data files (excludes `manifest.json`). | +| `characters` | `string[]` | Character names included in the export. | +| `includes` | `object` | Flags indicating which data categories were selected. | + +### `includes` object + +| Field | Type | Description | +| ------------------- | --------- | -------------------------------------------- | +| `generalSettings` | `boolean` | Top-level `settings` file is included. | +| `logs` | `boolean` | Chat log files are included. | +| `drafts` | `boolean` | Draft files are included. | +| `characterSettings` | `boolean` | Full character settings dirs included. | +| `pinned` | `boolean` | Pinned conversations files included. | +| `eicons` | `boolean` | Favorite eicons files included. | +| `recents` | `boolean` | Recent conversations/channels included. | +| `hidden` | `boolean` | Hidden users lists included. | +| `jsonLogs` | `boolean` | If `true`, log files are JSON (`.txt.json`). | + +When `characterSettings` is `true`, all files under each character's +`settings/` directory are included. The `pinned`, `eicons`, `recents`, +and `hidden` flags are still set to reflect what is present, but +`characterSettings` being `true` implies all of them. + +### Example + +```json +{ + "version": 2, + "createdAt": "2026-02-09T14:30:00.000Z", + "app": "horizon", + "expectedFiles": 142, + "characters": ["Alice", "Bob"], + "includes": { + "generalSettings": true, + "logs": true, + "drafts": true, + "characterSettings": true, + "pinned": true, + "eicons": true, + "recents": true, + "hidden": true + } +} +``` + +## Backwards compatibility + +Exports created before v2 do not contain a `manifest.json`. Importers +should handle this gracefully: + +- If `manifest.json` is absent, scan the ZIP entries directly to + determine available characters and data categories. +- If `manifest.json` is present but has an unrecognized `version`, + the importer should warn the user but may still attempt import. + +## Producing an export + +1. Build the list of data files to include. +2. Create the manifest with `expectedFiles` set to the data file count. +3. Write `manifest.json` as the **first** ZIP entry. +4. Add all data files. +5. Finalize the archive. +6. **Verify**: Re-open the ZIP and confirm: + - `manifest.json` exists and parses as valid JSON. + - The entry count matches `expectedFiles + 1` (within a tolerance + of 1 to account for the manifest itself). + - Each character listed in the manifest has at least one entry under + `characters//`. + +## Consuming an import + +1. Open the ZIP and look for `manifest.json`. +2. If present, parse it and validate the `version` and `app` fields. + Use the manifest to populate the UI (character list, available + categories, file counts). +3. If absent, fall back to scanning ZIP entries to build the character + list and detect available data categories. +4. For each file written during import, catch and count per-file errors + rather than aborting the entire operation. +5. After import, report success counts, skip counts, and error counts. + +## Security + +- Validate all ZIP entry paths. Reject entries containing `..` or + entries that resolve outside the target data directory (path + traversal). diff --git a/electron/Exporter.vue b/electron/Exporter.vue index fb90c8251..87379f548 100644 --- a/electron/Exporter.vue +++ b/electron/Exporter.vue @@ -11,8 +11,8 @@