Skip to content

Commit 5efef01

Browse files
committed
Add interface settings export/import
Issue #197
1 parent fa2140a commit 5efef01

File tree

6 files changed

+157
-4
lines changed

6 files changed

+157
-4
lines changed

src-tauri/src/commands.rs

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@
1414
// You should have received a copy of the GNU Affero General Public License
1515
// along with this program. If not, see <https://www.gnu.org/licenses/>.
1616

17+
use std::fs;
18+
1719
use base64::{engine::general_purpose::STANDARD as b64engine, Engine as _};
1820
use font_loader::system_fonts;
1921
use lava_torrent::torrent::v1::Torrent;
@@ -266,3 +268,13 @@ pub async fn create_tray(app_handle: tauri::AppHandle) {
266268
.ok();
267269
}
268270
}
271+
272+
#[tauri::command]
273+
pub async fn save_text_file(contents: String, path: String) -> Result<(), String> {
274+
fs::write(path, contents).map_err(|e| format!("Unable to write file: {}", e))
275+
}
276+
277+
#[tauri::command]
278+
pub async fn load_text_file(path: String) -> Result<String, String> {
279+
fs::read_to_string(path).map_err(|e| format!("Unable to read file: {}", e))
280+
}

src-tauri/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,8 @@ fn main() {
173173
commands::pass_to_window,
174174
commands::list_system_fonts,
175175
commands::create_tray,
176+
commands::save_text_file,
177+
commands::load_text_file,
176178
])
177179
.manage(ListenerHandle(Arc::new(RwLock::new(ipc))))
178180
.manage(TorrentCacheHandle::default())

src/components/details.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -139,7 +139,7 @@ function TrackerUpdate(props: { torrent: Torrent }) {
139139

140140
function TransferTable(props: { torrent: Torrent }) {
141141
const seedingTime = secondsToHumanReadableStr(props.torrent.secondsSeeding);
142-
const shareRatio = `${props.torrent.uploadRatio as number} ${seedingTime !== "" ? `(${seedingTime})` : ""}`;
142+
const shareRatio = `${(props.torrent.uploadRatio as number).toFixed(5)} ${seedingTime !== "" ? `(${seedingTime})` : ""}`;
143143

144144
const [ref, rect] = useResizeObserver();
145145

src/components/toolbar.tsx

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import type { MantineTheme } from "@mantine/core";
2020
import { ActionIcon, Button, Flex, Kbd, Menu, TextInput, useMantineTheme } from "@mantine/core";
2121
import debounce from "lodash-es/debounce";
22-
import React, { forwardRef, memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
22+
import React, { forwardRef, memo, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
2323
import * as Icon from "react-bootstrap-icons";
2424
import PriorityIcon from "svg/icons/priority.svg";
2525
import type { PriorityNumberType } from "rpc/transmission";
@@ -33,6 +33,9 @@ import { useHotkeysContext } from "hotkeys";
3333
import { useHotkeys } from "@mantine/hooks";
3434
import { modKeyString } from "trutil";
3535
import { useServerSelectedTorrents } from "rpc/torrent";
36+
import { ConfigContext } from "config";
37+
38+
const { saveJsonFile, loadJsonFile } = await import(/* webpackChunkName: "taurishim" */"taurishim");
3639

3740
interface ToolbarButtonProps extends React.PropsWithChildren<React.ComponentPropsWithRef<"button">> {
3841
depressed?: boolean,
@@ -167,6 +170,8 @@ function useButtonHandlers(
167170
}
168171

169172
function Toolbar(props: ToolbarProps) {
173+
const config = useContext(ConfigContext);
174+
170175
const debouncedSetSearchTerms = useMemo(
171176
() => debounce(props.setSearchTerms, 500, { trailing: true, leading: false }),
172177
[props.setSearchTerms]);
@@ -208,6 +213,30 @@ function Toolbar(props: ToolbarProps) {
208213
["mod + I", props.toggleDetailsPanel],
209214
]);
210215

216+
const onSettingsExport = useCallback(() => {
217+
void saveJsonFile(config.getExportedInterfaceSettings(), "trguing-interface.json");
218+
}, [config]);
219+
220+
const onSettingsImport = useCallback(async () => {
221+
try {
222+
const settings = await loadJsonFile();
223+
await config.tryMergeInterfaceSettings(JSON.parse(settings));
224+
window.location.reload();
225+
} catch (e) {
226+
let msg = "";
227+
if (typeof e === "string") {
228+
msg = e;
229+
} else if (e instanceof Error) {
230+
msg = e.message;
231+
}
232+
notifications.show({
233+
title: "Error importing settings",
234+
message: msg,
235+
color: "red",
236+
});
237+
}
238+
}, [config]);
239+
211240
return (
212241
<Flex w="100%" align="stretch">
213242
<Button.Group mx="sm">
@@ -329,6 +358,14 @@ function Toolbar(props: ToolbarProps) {
329358
onClick={props.toggleDetailsPanel} rightSection={<Kbd>{`${modKeyString()} I`}</Kbd>}>
330359
Toggle details
331360
</Menu.Item>
361+
<Menu.Divider />
362+
<Menu.Label>Interface settings</Menu.Label>
363+
<Menu.Item onClick={onSettingsExport}>
364+
Export
365+
</Menu.Item>
366+
<Menu.Item onClick={() => { void onSettingsImport(); }}>
367+
Import
368+
</Menu.Item>
332369
</Menu.Dropdown>
333370
</Menu>
334371

src/config.ts

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ export interface SortByConfig {
5555
}
5656

5757
interface TableSettings {
58-
columns: string[],
5958
columnVisibility: Record<string, boolean>,
6059
columnOrder: string[],
6160
columnSizes: Record<string, number>,
@@ -165,6 +164,7 @@ interface Settings {
165164
styleOverrides: StyleOverrides,
166165
progressbarStyle: ProgressbarStyleOption,
167166
},
167+
configVersion: number,
168168
}
169169

170170
const DefaultColumnVisibility: Partial<Record<TableName, VisibilityState>> = {
@@ -286,6 +286,11 @@ const DefaultSettings: Settings = {
286286
},
287287
progressbarStyle: "animated",
288288
},
289+
// This field is used to verify config struct compatibility when importing settings
290+
// Bump this only when incompatible changes are made that cannot be imported into older
291+
// version.
292+
// 1 is used in v1.4 and later
293+
configVersion: 1,
289294
};
290295

291296
export class Config {
@@ -429,6 +434,35 @@ export class Config {
429434
if (index >= 0) saveDirs.splice(index, 1);
430435
return saveDirs;
431436
}
437+
438+
getExportedInterfaceSettings(): string {
439+
const settings = {
440+
interface: this.values.interface,
441+
meta: {
442+
configType: "trguing interface settings",
443+
configVersion: this.values.configVersion,
444+
},
445+
};
446+
return JSON.stringify(settings, null, 4);
447+
}
448+
449+
async tryMergeInterfaceSettings(obj: any) {
450+
if (!Object.prototype.hasOwnProperty.call(obj, "meta") ||
451+
!Object.prototype.hasOwnProperty.call(obj, "interface") ||
452+
!Object.prototype.hasOwnProperty.call(obj.meta, "configType") ||
453+
!Object.prototype.hasOwnProperty.call(obj.meta, "configVersion") ||
454+
obj.meta.configType !== "trguing interface settings") {
455+
throw new Error("File does not appear to contain valid trguing interface settings");
456+
}
457+
if (obj.meta.configVersion > this.values.configVersion) {
458+
throw new Error(
459+
"This interface settings file was generated by a newer " +
460+
"version of TrguiNG and can not be safely imported");
461+
}
462+
const merge = (await import(/* webpackChunkName: "lodash" */ "lodash-es/merge")).default;
463+
merge(this.values.interface, obj.interface);
464+
await this.save();
465+
}
432466
}
433467

434468
export const ConfigContext = React.createContext(new Config());

src/taurishim.ts

Lines changed: 69 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
* along with this program. If not, see <https://www.gnu.org/licenses/>.
1717
*/
1818

19-
import type { OpenDialogOptions } from "@tauri-apps/api/dialog";
19+
import type { OpenDialogOptions, SaveDialogOptions } from "@tauri-apps/api/dialog";
2020
import type { EventCallback } from "@tauri-apps/api/event";
2121
import type { CloseRequestedEvent, PhysicalPosition, PhysicalSize } from "@tauri-apps/api/window";
2222

@@ -79,6 +79,11 @@ export const dialogOpen = TAURI
7979
: async (options?: OpenDialogOptions) =>
8080
await Promise.reject<string[] | string | null>(new Error("Running outside of tauri app"));
8181

82+
export const dialogSave = TAURI
83+
? (await import(/* webpackMode: "lazy-once" */ "@tauri-apps/api/dialog")).save
84+
: async (options?: SaveDialogOptions) =>
85+
await Promise.reject<string | null>(new Error("Running outside of tauri app"));
86+
8287
export async function makeCreateTorrentView() {
8388
if (WebviewWindow !== undefined) {
8489
const webview = new WebviewWindow(`createtorrent-${Math.floor(Math.random() * 2 ** 30)}`, {
@@ -136,3 +141,66 @@ export function copyToClipboard(text: string) {
136141
document.body.removeChild(textArea);
137142
}
138143
}
144+
145+
export async function saveJsonFile(contents: string, filename: string) {
146+
if (fs !== undefined) {
147+
dialogSave({
148+
title: "Save interface settings",
149+
defaultPath: filename,
150+
filters: [{
151+
name: "JSON",
152+
extensions: ["json"],
153+
}],
154+
}).then((path) => {
155+
if (path != null) {
156+
void invoke("save_text_file", { contents, path });
157+
}
158+
}).catch(console.error);
159+
} else {
160+
const blob = new Blob([contents], { type: "application/json" });
161+
const link = document.createElement("a");
162+
const objurl = URL.createObjectURL(blob);
163+
link.download = filename;
164+
link.href = objurl;
165+
link.click();
166+
}
167+
}
168+
169+
export async function loadJsonFile(): Promise<string> {
170+
if (fs !== undefined) {
171+
return await new Promise((resolve, reject) => {
172+
dialogOpen({
173+
title: "Select interface settings file",
174+
filters: [{
175+
name: "JSON",
176+
extensions: ["json"],
177+
}],
178+
}).then((path) => {
179+
if (path != null) {
180+
invoke<string>("load_text_file", { path }).then(resolve).catch(reject);
181+
}
182+
}).catch(reject);
183+
});
184+
} else {
185+
return await new Promise((resolve, reject) => {
186+
const input = document.createElement("input");
187+
input.type = "file";
188+
input.accept = ".json";
189+
input.onchange = () => {
190+
const files = input.files;
191+
if (files == null) reject(new Error("file not chosen"));
192+
else {
193+
const reader = new FileReader();
194+
reader.onload = () => {
195+
resolve(reader.result as string);
196+
};
197+
reader.onerror = () => {
198+
reject(new Error("Unable to read file"));
199+
};
200+
reader.readAsText(files[0], "UTF-8");
201+
}
202+
};
203+
input.click();
204+
});
205+
}
206+
}

0 commit comments

Comments
 (0)