8000 Import & Export Feature for Address Book by ap211unitech · Pull Request #11383 · polkadot-js/apps · GitHub
[go: up one dir, main page]
More Web Proxy on the site http://driver.im/
Skip to content

Import & Export Feature for Address Book #11383

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 12 commits into from
Mar 20, 2025
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
9 changes: 9 additions & 0 deletions packages/apps/public/locales/en/app-addresses.json
Original file line number Diff line number Diff line change
@@ -1,17 +1,26 @@
{
"Add an address": "Add an address",
"Add contact": "Add contact",
"Export": "Export",
"Forget this address": "Forget this address",
"Import": "Import",
"Import file": "Import file",
"Importing": "Importing",
"My contacts": "My contacts",
"Save": "Save",
"Success": "Success",
"address": "address",
"address created": "address created",
"address edited": "address edited",
"address forgotten": "address forgotten",
"contacts": "contacts",
"file content error": "file content error",
"file error": "file error",
"filter by name or tags": "filter by name or tags",
"name": "name",
"new address": "new address",
"no account imported": "no account imported",
"no addresses saved yet, add any existing address": "no addresses saved yet, add any existing address",
"no file choosen": "no file choosen",
"send": "send"
}
4 changes: 4 additions & 0 deletions packages/apps/public/locales/en/translation.json
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@
"Image": "",
"Import": "",
"Import Success": "",
"Import file": "",
"Import files": "",
"Importing": "",
"In calculating the election outcome, this prioritized vote ordering will be used to determine the final score for the candidates.": "",
Expand Down Expand Up @@ -747,6 +748,7 @@
"Submit {{queueSize}} items": "",
"Submitted at": "",
"Subscan Links": "",
"Success": "",
"Sudo access": "",
"Sudo key": "",
"Supply a JSON file with the list of signatories.": "",
Expand Down Expand Up @@ -1588,7 +1590,9 @@
"next burn": "",
"next index": "",
"no": "",
"no account imported": "",
"no addresses saved yet, add any existing address": "",
"no file choosen": "",
"no name": "",
"no peers connected": "",
"no unapplied slashes found": "",
Expand Down
49 changes: 49 additions & 0 deletions packages/page-addresses/src/Contacts/Export.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
// Copyright 2017-2025 @polkadot/app-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { SortedAddress } from './types.js';

import FileSaver from 'file-saver';
import React, { useCallback } from 'react';

import { Button } from '@polkadot/react-components';
import { keyring } from '@polkadot/ui-keyring';

import { useTranslation } from '../translate.js';

interface Props {
sortedAddresses?: SortedAddress[]
}

function Export ({ sortedAddresses }: Props): React.ReactElement<Props> {
const { t } = useTranslation();

const => {
const accounts = sortedAddresses?.map(({ address, isFavorite }) => {
const account = keyring.getAddress(address); // get account info

return { address, isFavorite, name: account?.meta.name || address };
});

/** **************** Export accounts as JSON ******************/

const blob = new Blob([JSON.stringify(accounts, null, 2)], { type: 'application/json; charset=utf-8' });

// eslint-disable-next-line deprecation/deprecation
FileSaver.saveAs(blob, `batch_exported_address_book_${new Date().getTime()}.json`);

/** ********************* ************** ************************/
}, [sortedAddresses]);

return sortedAddresses?.length
? (
<Button
icon='file-export'
label={t('Export')}
>
/>
)
: <></>;
}

export default React.memo(Export);
201 changes: 201 additions & 0 deletions packages/page-addresses/src/Contacts/Import.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
// Copyright 2017-2025 @polkadot/app-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type { DeriveAccountInfo } from '@polkadot/api-derive/types';
import type { ActionStatus, ActionStatusBase } from '@polkadot/react-components/Status/types';
import type { FunInputFile, SaveFile } from './types.js';

import React, { useCallback, useRef } from 'react';

import { Button, InputAddress } from '@polkadot/react-components';
import { useApi } from '@polkadot/react-hooks';
import keyring from '@polkadot/ui-keyring';
import { hexToU8a } from '@polkadot/util';
import { ethereumEncode } from '@polkadot/util-crypto';

import { useTranslation } from '../translate.js';

interface Props {
favorites: string[];
onStatusChange: (status: ActionStatus) => void;
toggleFavorite: (address: string) => void;
}

function Import ({ favorites, onStatusChange, toggleFavorite }: Props): React.ReactElement<Props> {
const { t } = useTranslation();
const { api, isEthereum } = useApi();
const importInputRef = useRef<HTMLInputElement>(null);

const _onImportResult = useCallback<(m: string, s?: ActionStatusBase['status']) => void>(
(message, status = 'queued') => {
onStatusChange?.({
action: t('Import file'),
message,
status
});
},
[onStatusChange, t]
);

const validateAccountInfo = useCallback(({ address: addressInput, name }: SaveFile) => {
let address = '';
let isAddressValid = true;
let isAddressExisting = false;
let isPublicKey = false;
let isNameValid = !!name.trim();

try {
if (isEthereum) {
const rawAddress = hexToU8a(addressInput);

address = ethereumEncode(rawAddress);
isPublicKey = rawAddress.length === 20;
} else {
const publicKey = keyring.decodeAddress(addressInput);

address = keyring.encodeAddress(publicKey);
isPublicKey = publicKey.length === 32;
}

const old = keyring.getAddress(address);

if (old) {
const newName = old.meta.name || name;

isAddressExisting = true;
isAddressValid = true;
isNameValid = !!(newName || '').trim();
}
} catch {
isAddressValid = false;
}

return {
address,
isAddressExisting,
isAddressValid,
isNameValid,
isPublicKey
};
}, [isEthereum]);

const _onAddAccount = useCallback(
async (account: SaveFile): Promise<boolean> => {
const { address, name } = account;
const info: DeriveAccountInfo = await api.derive.accounts.info(address);
const { isAddressExisting, isAddressValid, isNameValid } = validateAccountInfo(account);
const isValid = (isAddressValid && isNameValid) && !!info?.accountId;

if (!isValid || !info?.accountId || isAddressExisting) {
return false;
}

try {
const address = info.accountId.toString();

// Save address
keyring.saveAddress(address, { genesisHash: keyring.genesisHash, name: name.trim(), tags: [] });
InputAddress.setLastValue('address', address);

if (account.isFavorite && !favorites.includes(address)) {
toggleFavorite(address);
}

return true;
} catch (_) {
return false;
}
},
[api.derive.accounts, favorites, toggleFavorite, validateAccountInfo]
);

const => {
if (!importInputRef.current) {
return;
}

importInputRef.current.value = '';
importInputRef.current.click();
}, []);

const _onInputImportFile = useCallback<FunInputFile>((e) => {
try {
_onImportResult(t('Importing'), 'queued');
const fileReader = new FileReader();
const files = e.target.files;

if (!files) {
return _onImportResult(t('no file choosen'), 'error');
}

// Read uploaded file
fileReader.readAsText(files[0], 'UTF-8');

// Check if the selected file does not have a .json extension.
// If invalid, return error message.
if (!(/(.json)$/i.test(e.target.value))) {
return _onImportResult(t('file error'), 'error');
}

fileReader. (e) => {
try {
// Try parsing file data
const _list = JSON.parse(e.target?.result as string) as SaveFile[];

if (!Array.isArray(_list)) {
return _onImportResult(t('file content error'), 'error');
}

const fitter: SaveFile[] = [];

// Filter out items that match the required schema, ensuring only valid entries are retained.
for (const item of _list) {
if (item.name && item.address) {
fitter.push(item);
}
}

let importedAccounts = 0;

// Add each valid account
for (const account of fitter) {
try {
const flag = await _onAddAccount(account);

importedAccounts += Number(flag);
} catch { }
}

if (importedAccounts > 0) {
_onImportResult(t('Success'), 'success');
} else {
_onImportResult(t('no account imported'), 'eventWarn');
}
} catch {
_onImportResult(t('file content error'), 'error');
}
};
} catch {
_onImportResult(t('file content error'), 'error');
}
}, [_onAddAccount, _onImportResult, t]);

return (
<>
<input
accept='application/json'
>
ref={importInputRef}
style={{ display: 'none' }}
type={'file'}
/>
<Button
icon='file-import'
label={t('Import')}
>
/>
</>
);
}

export default React.memo(Import);
12 changes: 10 additions & 2 deletions packages/page-addresses/src/Contacts/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import type { ActionStatus } from '@polkadot/react-components/Status/types';
import type { SortedAddress } from './types.js';

import React, { useCallback, useEffect, useRef, useState } from 'react';

Expand All @@ -11,8 +12,8 @@ import { useAddresses, useFavorites, useNextTick, useToggle } from '@polkadot/re
import CreateModal from '../modals/Create.js';
import { useTranslation } from '../translate.js';
import Address from './Address.js';

interface SortedAddress { address: string; isFavorite: boolean, isVisible: boolean }
import Export from './Export.js';
import Import from './Import.js';

interface Props {
className?: string;
Expand All @@ -23,6 +24,7 @@ const STORE_FAVS = 'accounts:favorites';

function Overview ({ className = '', onStatusChange }: Props): React.ReactElement<Props> {
const { t } = useTranslation();

const { allAddresses } = useAddresses();
const [isCreateOpen, toggleCreate] = useToggle(false);
const [favorites, toggleFavorite] = useFavorites(STORE_FAVS);
Expand Down Expand Up @@ -73,6 +75,12 @@ function Overview ({ className = '', onStatusChange }: Props): React.ReactElemen
/>
</section>
<Button.Group>
<Import
favorites={favorites}
>
toggleFavorite={toggleFavorite}
/>
<Export sortedAddresses={sortedAddresses} />
<Button
icon='plus'
label={t('Add contact')}
Expand Down
10 changes: 10 additions & 0 deletions packages/page-addresses/src/Contacts/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
// Copyright 2017-2025 @polkadot/app-addresses authors & contributors
// SPDX-License-Identifier: Apache-2.0

import type React from 'react';

export interface SortedAddress { address: string; isFavorite: boolean, isVisible: boolean }

export interface SaveFile { address: string; isFavorite: boolean, name: string }

export type FunInputFile = (e: React.ChangeEvent<HTMLInputElement>) => void
0