mirror of
https://github.com/aliasvault/aliasvault.git
synced 2026-05-18 13:28:12 -04:00
Merge pull request #1482 from aliasvault/1481-feature-request-add-folder-support-to-import-methods
Update importers to detect folders and optionally new item types
This commit is contained in:
@@ -478,6 +478,12 @@ const ItemsList: React.FC = () => {
|
||||
|
||||
const folders = getFoldersWithCounts();
|
||||
|
||||
/**
|
||||
* Check if all items are in folders (no items at root level but items exist in folders).
|
||||
* This is used to show a helpful message when the user has imported credentials that were all in folders.
|
||||
*/
|
||||
const hasItemsInFoldersOnly = items.length > 0 && items.every((item: Item) => item.FolderId !== null);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex justify-center items-center p-8">
|
||||
@@ -490,28 +496,35 @@ const ItemsList: React.FC = () => {
|
||||
<div>
|
||||
<div className="flex justify-between items-center gap-2 mb-4">
|
||||
<div className="relative min-w-0 flex-1 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setShowFilterMenu(!showFilterMenu)}
|
||||
className="flex items-center gap-1 text-gray-900 dark:text-white text-xl hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none min-w-0"
|
||||
>
|
||||
<h2 className="flex items-baseline gap-1.5 min-w-0 overflow-hidden">
|
||||
<span className="truncate">{getFilterTitle()}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 shrink-0">
|
||||
({filteredItems.length})
|
||||
</span>
|
||||
{/* When all items are in folders at root level, show simple title without dropdown */}
|
||||
{hasItemsInFoldersOnly && !currentFolderId ? (
|
||||
<h2 className="text-gray-900 dark:text-white text-xl min-w-0">
|
||||
<span className="truncate">{t('items.title')}</span>
|
||||
</h2>
|
||||
<svg
|
||||
className="w-4 h-4 mt-1 shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowFilterMenu(!showFilterMenu)}
|
||||
className="flex items-center gap-1 text-gray-900 dark:text-white text-xl hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none min-w-0"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
<h2 className="flex items-baseline gap-1.5 min-w-0 overflow-hidden">
|
||||
<span className="truncate">{getFilterTitle()}</span>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400 shrink-0">
|
||||
({filteredItems.length})
|
||||
</span>
|
||||
</h2>
|
||||
<svg
|
||||
className="w-4 h-4 mt-1 shrink-0"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
{/* Edit and Delete buttons when inside a folder */}
|
||||
{currentFolderId && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
@@ -536,7 +549,7 @@ const ItemsList: React.FC = () => {
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
{showFilterMenu && (
|
||||
{showFilterMenu && !(hasItemsInFoldersOnly && !currentFolderId) && (
|
||||
<>
|
||||
<div
|
||||
className="fixed inset-0 z-10"
|
||||
@@ -657,34 +670,8 @@ const ItemsList: React.FC = () => {
|
||||
{t('items.welcomeDescription')}
|
||||
</p>
|
||||
</div>
|
||||
{/* Show Recently Deleted even when vault is empty */}
|
||||
{recentlyDeletedCount > 0 && (
|
||||
<button
|
||||
onClick={() => navigate('/items/deleted')}
|
||||
className="w-full p-3 flex items-center justify-between text-left bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
<span className="text-gray-700 dark:text-gray-300">{t('recentlyDeleted.title')}</span>
|
||||
</div>
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{recentlyDeletedCount}
|
||||
</span>
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : filteredItems.length === 0 && folders.length === 0 ? (
|
||||
) : filteredItems.length === 0 && folders.length === 0 && !hasItemsInFoldersOnly ? (
|
||||
<div className="text-gray-500 dark:text-gray-400 space-y-3 mb-10">
|
||||
{/* Show filter/search-specific messages only when actively filtering or searching */}
|
||||
{(filterType !== 'all' || searchTerm) && (
|
||||
@@ -746,6 +733,35 @@ const ItemsList: React.FC = () => {
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : hasItemsInFoldersOnly && filteredItems.length === 0 && !currentFolderId && !searchTerm ? (
|
||||
/* Show message when all items are in folders and we're at root level */
|
||||
<>
|
||||
{/* Folders as inline pills */}
|
||||
<div className="flex flex-wrap items-center gap-2 mb-4">
|
||||
{folders.map(folder => (
|
||||
<FolderPill
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onClick={() => handleFolderClick(folder.id, folder.name)}
|
||||
/>
|
||||
))}
|
||||
<button
|
||||
onClick={handleAddFolder}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 text-xs rounded-full transition-colors focus:outline-none text-gray-500 dark:text-gray-400 hover:text-orange-600 dark:hover:text-orange-400 hover:bg-gray-100 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" d="M3 7v10a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-6l-2-2H5a2 2 0 00-2 2z" />
|
||||
</svg>
|
||||
<svg className="w-3 h-3 -ml-0.5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div className="text-gray-500 dark:text-gray-400 text-sm">
|
||||
<p>{t('items.allItemsInFolders')}</p>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Folders as inline pills (only show at root level when not searching) */}
|
||||
@@ -829,35 +845,6 @@ const ItemsList: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recently Deleted link (only show at root level when not searching and not filtering) */}
|
||||
{!currentFolderId && !searchTerm && filterType === 'all' && (
|
||||
<button
|
||||
onClick={() => navigate('/items/deleted')}
|
||||
className="w-full mt-4 p-3 flex items-center justify-between text-left bg-gray-50 dark:bg-gray-800/50 border border-gray-200 dark:border-gray-700 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700/50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<svg
|
||||
className="w-5 h-5 text-gray-400"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2" />
|
||||
</svg>
|
||||
<span className="text-gray-700 dark:text-gray-300">{t('recentlyDeleted.title')}</span>
|
||||
</div>
|
||||
{recentlyDeletedCount > 0 && (
|
||||
<span className="text-sm text-gray-500 dark:text-gray-400">
|
||||
{recentlyDeletedCount}
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -194,6 +194,7 @@
|
||||
"clearSearch": "Clear search",
|
||||
"clearFilter": "Clear filter",
|
||||
"emptyFolderHint": "This folder is empty. To move items to this folder, edit the item and tap the folder icon in the name field.",
|
||||
"allItemsInFolders": "All your items are organized in folders. Click a folder above to view your credentials, or use the search to find specific items.",
|
||||
"deleteFolder": "Delete Folder",
|
||||
"deleteFolderKeepItems": "Delete folder only",
|
||||
"deleteFolderKeepItemsDescription": "Items will be moved back to the main list.",
|
||||
|
||||
@@ -9,8 +9,8 @@ import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import Toast from 'react-native-toast-message';
|
||||
|
||||
import type { Folder } from '@/utils/db/repositories/FolderRepository';
|
||||
import type { Item } from '@/utils/dist/core/models/vault';
|
||||
import { getFieldValue, FieldKey } from '@/utils/dist/core/models/vault';
|
||||
import type { Item, ItemType } from '@/utils/dist/core/models/vault';
|
||||
import { getFieldValue, FieldKey, ItemTypes } from '@/utils/dist/core/models/vault';
|
||||
import emitter from '@/utils/EventEmitter';
|
||||
import { VaultAuthenticationError } from '@/utils/types/errors/VaultAuthenticationError';
|
||||
|
||||
@@ -31,6 +31,37 @@ import { useApp } from '@/context/AppContext';
|
||||
import { useDb } from '@/context/DbContext';
|
||||
import { useDialog } from '@/context/DialogContext';
|
||||
|
||||
/**
|
||||
* Filter types for the items list.
|
||||
*/
|
||||
type FilterType = 'all' | 'passkeys' | 'attachments' | ItemType;
|
||||
|
||||
/**
|
||||
* Check if a filter is an item type filter.
|
||||
*/
|
||||
const isItemTypeFilter = (filter: FilterType): filter is ItemType => {
|
||||
return Object.values(ItemTypes).includes(filter as ItemType);
|
||||
};
|
||||
|
||||
/**
|
||||
* Item type filter option configuration.
|
||||
*/
|
||||
type ItemTypeOption = {
|
||||
type: ItemType;
|
||||
titleKey: string;
|
||||
iconName: keyof typeof MaterialIcons.glyphMap;
|
||||
};
|
||||
|
||||
/**
|
||||
* Available item type filter options with icons.
|
||||
*/
|
||||
const ITEM_TYPE_OPTIONS: ItemTypeOption[] = [
|
||||
{ type: ItemTypes.Login, titleKey: 'itemTypes.login.title', iconName: 'key' },
|
||||
{ type: ItemTypes.Alias, titleKey: 'itemTypes.alias.title', iconName: 'person' },
|
||||
{ type: ItemTypes.CreditCard, titleKey: 'itemTypes.creditCard.title', iconName: 'credit-card' },
|
||||
{ type: ItemTypes.Note, titleKey: 'itemTypes.note.title', iconName: 'description' },
|
||||
];
|
||||
|
||||
/**
|
||||
* Folder view screen - displays items within a specific folder.
|
||||
* Simplified view with search scoped to this folder only.
|
||||
@@ -47,12 +78,15 @@ export default function FolderViewScreen(): React.ReactNode {
|
||||
|
||||
const [itemsList, setItemsList] = useState<Item[]>([]);
|
||||
const [folder, setFolder] = useState<Folder | null>(null);
|
||||
const [isLoadingItems, setIsLoadingItems] = useMinDurationLoading(false, 200);
|
||||
// No minimum loading delay for folder view since data is already in memory
|
||||
const [isLoadingItems, setIsLoadingItems] = useState(false);
|
||||
const [refreshing, setRefreshing] = useMinDurationLoading(false, 200);
|
||||
const { executeVaultMutation } = useVaultMutate();
|
||||
|
||||
// Search state (scoped to this folder)
|
||||
// Search and filter state (scoped to this folder)
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [filterType, setFilterType] = useState<FilterType>('all');
|
||||
const [showFilterMenu, setShowFilterMenu] = useState(false);
|
||||
|
||||
// Folder modals
|
||||
const [showEditFolderModal, setShowEditFolderModal] = useState(false);
|
||||
@@ -66,16 +100,55 @@ export default function FolderViewScreen(): React.ReactNode {
|
||||
const isDatabaseAvailable = dbContext.dbAvailable;
|
||||
|
||||
/**
|
||||
* Filter items by search query (within this folder only).
|
||||
* Get the title based on the active filter.
|
||||
* Shows "Items" for 'all' filter since folder name is already in the navigation header.
|
||||
*/
|
||||
const getFilterTitle = useCallback((): string => {
|
||||
switch (filterType) {
|
||||
case 'passkeys':
|
||||
return t('items.filters.passkeys');
|
||||
case 'attachments':
|
||||
return t('common.attachments');
|
||||
case 'all':
|
||||
return t('items.title');
|
||||
default:
|
||||
if (isItemTypeFilter(filterType)) {
|
||||
const itemTypeOption = ITEM_TYPE_OPTIONS.find(opt => opt.type === filterType);
|
||||
if (itemTypeOption) {
|
||||
return t(itemTypeOption.titleKey);
|
||||
}
|
||||
}
|
||||
return t('items.title');
|
||||
}
|
||||
}, [filterType, t]);
|
||||
|
||||
/**
|
||||
* Filter items by search query and type (within this folder only).
|
||||
*/
|
||||
const filteredItems = useMemo(() => {
|
||||
const searchLower = searchQuery.toLowerCase().trim();
|
||||
|
||||
if (!searchLower) {
|
||||
return itemsList;
|
||||
}
|
||||
|
||||
return itemsList.filter(item => {
|
||||
// Apply type filter
|
||||
let passesTypeFilter = true;
|
||||
|
||||
if (filterType === 'passkeys') {
|
||||
passesTypeFilter = item.HasPasskey === true;
|
||||
} else if (filterType === 'attachments') {
|
||||
passesTypeFilter = item.HasAttachment === true;
|
||||
} else if (isItemTypeFilter(filterType)) {
|
||||
passesTypeFilter = item.ItemType === filterType;
|
||||
}
|
||||
|
||||
if (!passesTypeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Apply search filter
|
||||
const searchLower = searchQuery.toLowerCase().trim();
|
||||
|
||||
if (!searchLower) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const searchableFields = [
|
||||
item.Name?.toLowerCase() || '',
|
||||
getFieldValue(item, FieldKey.LoginUsername)?.toLowerCase() || '',
|
||||
@@ -90,7 +163,7 @@ export default function FolderViewScreen(): React.ReactNode {
|
||||
searchableFields.some(field => field.includes(word))
|
||||
);
|
||||
});
|
||||
}, [itemsList, searchQuery]);
|
||||
}, [itemsList, searchQuery, filterType]);
|
||||
|
||||
/**
|
||||
* Load items in this folder and folder details.
|
||||
@@ -368,14 +441,61 @@ export default function FolderViewScreen(): React.ReactNode {
|
||||
color: colors.textMuted,
|
||||
fontSize: 20,
|
||||
},
|
||||
// Item count styles
|
||||
itemCountContainer: {
|
||||
marginBottom: 12,
|
||||
// Filter button styles
|
||||
filterButton: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
marginBottom: 16,
|
||||
gap: 8,
|
||||
},
|
||||
itemCountText: {
|
||||
filterButtonText: {
|
||||
color: colors.text,
|
||||
fontSize: 22,
|
||||
fontWeight: 'bold',
|
||||
lineHeight: 28,
|
||||
},
|
||||
filterCount: {
|
||||
color: colors.textMuted,
|
||||
fontSize: 16,
|
||||
lineHeight: 22,
|
||||
},
|
||||
// Filter menu styles
|
||||
filterMenu: {
|
||||
backgroundColor: colors.accentBackground,
|
||||
borderColor: colors.accentBorder,
|
||||
borderRadius: 8,
|
||||
borderWidth: 1,
|
||||
marginBottom: 8,
|
||||
overflow: 'hidden',
|
||||
},
|
||||
filterMenuItem: {
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 12,
|
||||
},
|
||||
filterMenuItemWithIcon: {
|
||||
alignItems: 'center',
|
||||
flexDirection: 'row',
|
||||
gap: 8,
|
||||
},
|
||||
filterMenuItemIcon: {
|
||||
width: 18,
|
||||
},
|
||||
filterMenuItemActive: {
|
||||
backgroundColor: colors.primary + '20',
|
||||
},
|
||||
filterMenuItemText: {
|
||||
color: colors.text,
|
||||
fontSize: 14,
|
||||
},
|
||||
filterMenuItemTextActive: {
|
||||
color: colors.primary,
|
||||
fontWeight: '600',
|
||||
},
|
||||
filterMenuSeparator: {
|
||||
backgroundColor: colors.accentBorder,
|
||||
height: 1,
|
||||
marginVertical: 4,
|
||||
},
|
||||
// Empty state styles
|
||||
emptyText: {
|
||||
color: colors.textMuted,
|
||||
@@ -420,11 +540,135 @@ export default function FolderViewScreen(): React.ReactNode {
|
||||
});
|
||||
|
||||
/**
|
||||
* Render the list header with search.
|
||||
* Render the filter menu.
|
||||
*/
|
||||
const renderFilterMenu = (): React.ReactNode => {
|
||||
if (!showFilterMenu) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.filterMenu}>
|
||||
{/* All items filter */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.filterMenuItem,
|
||||
filterType === 'all' && styles.filterMenuItemActive
|
||||
]}
|
||||
onPress={() => {
|
||||
setFilterType('all');
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
>
|
||||
<ThemedText style={[
|
||||
styles.filterMenuItemText,
|
||||
filterType === 'all' && styles.filterMenuItemTextActive
|
||||
]}>
|
||||
{t('items.filters.all')}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
<ThemedView style={styles.filterMenuSeparator} />
|
||||
|
||||
{/* Item type filters */}
|
||||
{ITEM_TYPE_OPTIONS.map((option) => (
|
||||
<TouchableOpacity
|
||||
key={option.type}
|
||||
style={[
|
||||
styles.filterMenuItem,
|
||||
styles.filterMenuItemWithIcon,
|
||||
filterType === option.type && styles.filterMenuItemActive
|
||||
]}
|
||||
onPress={() => {
|
||||
setFilterType(option.type);
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
>
|
||||
<MaterialIcons
|
||||
name={option.iconName}
|
||||
size={18}
|
||||
color={filterType === option.type ? colors.primary : colors.textMuted}
|
||||
style={styles.filterMenuItemIcon}
|
||||
/>
|
||||
<ThemedText style={[
|
||||
styles.filterMenuItemText,
|
||||
filterType === option.type && styles.filterMenuItemTextActive
|
||||
]}>
|
||||
{t(option.titleKey)}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
|
||||
<ThemedView style={styles.filterMenuSeparator} />
|
||||
|
||||
{/* Passkeys filter */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.filterMenuItem,
|
||||
filterType === 'passkeys' && styles.filterMenuItemActive
|
||||
]}
|
||||
onPress={() => {
|
||||
setFilterType('passkeys');
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
>
|
||||
<ThemedText style={[
|
||||
styles.filterMenuItemText,
|
||||
filterType === 'passkeys' && styles.filterMenuItemTextActive
|
||||
]}>
|
||||
{t('items.filters.passkeys')}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Attachments filter */}
|
||||
<TouchableOpacity
|
||||
style={[
|
||||
styles.filterMenuItem,
|
||||
filterType === 'attachments' && styles.filterMenuItemActive
|
||||
]}
|
||||
onPress={() => {
|
||||
setFilterType('attachments');
|
||||
setShowFilterMenu(false);
|
||||
}}
|
||||
>
|
||||
<ThemedText style={[
|
||||
styles.filterMenuItemText,
|
||||
filterType === 'attachments' && styles.filterMenuItemTextActive
|
||||
]}>
|
||||
{t('common.attachments')}
|
||||
</ThemedText>
|
||||
</TouchableOpacity>
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* Render the list header with filter and search.
|
||||
*/
|
||||
const renderListHeader = (): React.ReactNode => {
|
||||
return (
|
||||
<ThemedView>
|
||||
{/* Filter button */}
|
||||
<TouchableOpacity
|
||||
style={styles.filterButton}
|
||||
onPress={() => setShowFilterMenu(!showFilterMenu)}
|
||||
>
|
||||
<ThemedText style={styles.filterButtonText}>
|
||||
{getFilterTitle()}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.filterCount}>
|
||||
({filteredItems.length})
|
||||
</ThemedText>
|
||||
<MaterialIcons
|
||||
name={showFilterMenu ? "keyboard-arrow-up" : "keyboard-arrow-down"}
|
||||
size={24}
|
||||
color={colors.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
|
||||
{/* Filter menu */}
|
||||
{renderFilterMenu()}
|
||||
|
||||
{/* Search input */}
|
||||
<ThemedView style={styles.searchContainer}>
|
||||
<MaterialIcons
|
||||
|
||||
@@ -166,6 +166,14 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
}
|
||||
}, [filterType, t]);
|
||||
|
||||
/**
|
||||
* Check if all items are in folders (no items at root level but items exist in folders).
|
||||
* This is used to show a helpful message when the user has imported credentials that were all in folders.
|
||||
*/
|
||||
const hasItemsInFoldersOnly = useMemo(() => {
|
||||
return itemsList.length > 0 && itemsList.every((item: Item) => item.FolderId !== null);
|
||||
}, [itemsList]);
|
||||
|
||||
/**
|
||||
* Filter items by folder, type, and search query.
|
||||
*/
|
||||
@@ -251,6 +259,8 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
|
||||
const tabPressSub = emitter.addListener('tabPress', (routeName: string) => {
|
||||
if (routeName === 'items' && isTabFocused) {
|
||||
// Reset search and scroll to top when tapping the tab again
|
||||
setSearchQuery('');
|
||||
flatListRef.current?.scrollToOffset({ offset: 0, animated: true });
|
||||
}
|
||||
});
|
||||
@@ -358,6 +368,17 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
*/
|
||||
headerTitle: (): React.ReactNode => {
|
||||
if (Platform.OS === 'android') {
|
||||
// When all items are in folders, show simple title without dropdown
|
||||
if (hasItemsInFoldersOnly) {
|
||||
return (
|
||||
<AndroidHeader
|
||||
title={t('items.title')}
|
||||
subtitle=""
|
||||
onTitlePress={undefined}
|
||||
isDropdownOpen={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<AndroidHeader
|
||||
title={getFilterTitle()}
|
||||
@@ -370,7 +391,7 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
return <Text>{t('items.title')}</Text>;
|
||||
},
|
||||
});
|
||||
}, [navigation, t, getFilterTitle, filteredItems.length, showFilterMenu]);
|
||||
}, [navigation, t, getFilterTitle, filteredItems.length, showFilterMenu, hasItemsInFoldersOnly]);
|
||||
|
||||
/**
|
||||
* Delete an item (move to trash).
|
||||
@@ -789,7 +810,7 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
* Render the Android filter menu as an absolute overlay.
|
||||
*/
|
||||
const renderAndroidFilterOverlay = (): React.ReactNode => {
|
||||
if (Platform.OS !== 'android' || !showFilterMenu) {
|
||||
if (Platform.OS !== 'android' || !showFilterMenu || hasItemsInFoldersOnly) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -927,60 +948,38 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
<ThemedView>
|
||||
{/* Large header with logo (iOS only) */}
|
||||
{Platform.OS === 'ios' && (
|
||||
<TouchableOpacity
|
||||
style={styles.filterButton}
|
||||
onPress={() => setShowFilterMenu(!showFilterMenu)}
|
||||
>
|
||||
<Logo width={40} height={40} />
|
||||
<ThemedText style={styles.filterButtonText}>
|
||||
{getFilterTitle()}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.filterCount}>
|
||||
({filteredItems.length})
|
||||
</ThemedText>
|
||||
<MaterialIcons
|
||||
name={showFilterMenu ? "keyboard-arrow-up" : "keyboard-arrow-down"}
|
||||
size={28}
|
||||
color={colors.text}
|
||||
/>
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
|
||||
{/* Filter menu (iOS only - Android uses absolute overlay) */}
|
||||
{Platform.OS === 'ios' && renderFilterMenu()}
|
||||
|
||||
{/* Folder pills */}
|
||||
{foldersWithCounts.length > 0 && (
|
||||
<View style={styles.folderPillsContainer}>
|
||||
{foldersWithCounts.map((folder) => (
|
||||
<FolderPill
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onPress={() => handleFolderClick(folder.id)}
|
||||
hasItemsInFoldersOnly ? (
|
||||
/* When all items are in folders, show simple title without dropdown */
|
||||
<View style={styles.filterButton}>
|
||||
<Logo width={40} height={40} />
|
||||
<ThemedText style={styles.filterButtonText}>
|
||||
{t('items.title')}
|
||||
</ThemedText>
|
||||
</View>
|
||||
) : (
|
||||
/* Normal filter dropdown when there are items at root */
|
||||
<TouchableOpacity
|
||||
style={styles.filterButton}
|
||||
onPress={() => setShowFilterMenu(!showFilterMenu)}
|
||||
>
|
||||
<Logo width={40} height={40} />
|
||||
<ThemedText style={styles.filterButtonText}>
|
||||
{getFilterTitle()}
|
||||
</ThemedText>
|
||||
<ThemedText style={styles.filterCount}>
|
||||
({filteredItems.length})
|
||||
</ThemedText>
|
||||
<MaterialIcons
|
||||
name={showFilterMenu ? "keyboard-arrow-up" : "keyboard-arrow-down"}
|
||||
size={28}
|
||||
color={colors.text}
|
||||
/>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
style={styles.newFolderButton}
|
||||
onPress={() => setShowFolderModal(true)}
|
||||
>
|
||||
<MaterialIcons name="create-new-folder" size={16} color={colors.textMuted} />
|
||||
<Text style={styles.newFolderButtonText}>{t('items.folders.newFolder')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* New folder button when no folders exist */}
|
||||
{foldersWithCounts.length === 0 && !searchQuery && (
|
||||
<View style={styles.folderPillsContainer}>
|
||||
<TouchableOpacity
|
||||
style={styles.newFolderButton}
|
||||
onPress={() => setShowFolderModal(true)}
|
||||
>
|
||||
<MaterialIcons name="create-new-folder" size={16} color={colors.textMuted} />
|
||||
<Text style={styles.newFolderButtonText}>{t('items.folders.newFolder')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
{/* Filter menu (iOS only - Android uses absolute overlay, only when not all items in folders) */}
|
||||
{Platform.OS === 'ios' && !hasItemsInFoldersOnly && renderFilterMenu()}
|
||||
|
||||
{/* Search input */}
|
||||
<ThemedView style={styles.searchContainer}>
|
||||
@@ -1013,6 +1012,26 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
</TouchableOpacity>
|
||||
)}
|
||||
</ThemedView>
|
||||
|
||||
{/* Folder pills (shown below search when not searching) */}
|
||||
{!searchQuery && (
|
||||
<View style={styles.folderPillsContainer}>
|
||||
{foldersWithCounts.map((folder) => (
|
||||
<FolderPill
|
||||
key={folder.id}
|
||||
folder={folder}
|
||||
onPress={() => handleFolderClick(folder.id)}
|
||||
/>
|
||||
))}
|
||||
<TouchableOpacity
|
||||
style={styles.newFolderButton}
|
||||
onPress={() => setShowFolderModal(true)}
|
||||
>
|
||||
<MaterialIcons name="create-new-folder" size={16} color={colors.textMuted} />
|
||||
<Text style={styles.newFolderButtonText}>{t('items.folders.newFolder')}</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
);
|
||||
};
|
||||
@@ -1047,6 +1066,10 @@ export default function ItemsScreen(): React.ReactNode {
|
||||
if (isItemTypeFilter(filterType)) {
|
||||
return t('items.noItemsOfTypeFound', { type: getFilterTitle() });
|
||||
}
|
||||
// All items are in folders - show helpful message
|
||||
if (hasItemsInFoldersOnly) {
|
||||
return t('items.allItemsInFolders');
|
||||
}
|
||||
// No search, no filter - truly empty vault
|
||||
return t('items.noItemsFound');
|
||||
};
|
||||
|
||||
@@ -419,6 +419,7 @@
|
||||
"noMatchingItemsSearch": "No items matching \"{{search}}\"",
|
||||
"noMatchingItemsWithFilter": "No {{filter}} items matching \"{{search}}\"",
|
||||
"noItemsFound": "No items found. Create one to get started. Tip: you can also login to the AliasVault web app to import credentials from other password managers.",
|
||||
"allItemsInFolders": "All your items are organized in folders. Tap a folder above to view your credentials, or use the search to find specific items.",
|
||||
"noPasskeysFound": "No passkeys have been created yet. Passkeys are created by visiting a website that offers passkeys as an authentication method.",
|
||||
"noAttachmentsFound": "No items with attachments found",
|
||||
"noItemsOfTypeFound": "No {{type}} items found",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
@using AliasVault.Client.Main.Models
|
||||
|
||||
@* FolderPill component - displays a folder as a compact clickable pill *@
|
||||
<button @onclick="OnClick" class="inline-flex items-center gap-2 px-5 py-2.5 text-sm rounded-lg bg-gray-100 dark:bg-gray-700/50 hover:bg-gray-200 dark:hover:bg-gray-600/50 transition-colors focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1 dark:focus:ring-offset-gray-800">
|
||||
<button @onclick="OnClick" class="inline-flex items-center gap-2 px-5 py-2.5 text-sm rounded-lg bg-white dark:bg-gray-700/50 border border-gray-200 dark:border-gray-600 hover:bg-gray-50 dark:hover:bg-gray-600/50 hover:border-gray-300 dark:hover:border-gray-500 transition-colors focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-1 dark:focus:ring-offset-gray-800">
|
||||
<svg class="w-4 h-4 text-orange-500 flex-shrink-0" viewBox="0 0 24 24" fill="currentColor">
|
||||
<path d="M10 4H4a2 2 0 0 0-2 2v12a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-8l-2-2z"/>
|
||||
</svg>
|
||||
|
||||
@@ -19,15 +19,24 @@
|
||||
Description="@Localizer["PageDescription"]">
|
||||
<TitleActions>
|
||||
<div class="relative flex items-center gap-2">
|
||||
<button @onclick="ToggleFilterDropdown" id="filterButton" class="flex items-center gap-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none">
|
||||
<h1 class="flex items-baseline gap-1.5 text-xl font-semibold tracking-tight text-gray-900 dark:text-white sm:text-2xl">
|
||||
<span>@GetFilterTitle()</span>
|
||||
<span class="text-base text-gray-500 dark:text-gray-400">(@TotalFilteredItems)</span>
|
||||
@if (IsInFolder || HasRootItems)
|
||||
{
|
||||
<button @onclick="ToggleFilterDropdown" id="filterButton" class="flex items-center gap-2 text-gray-900 dark:text-white hover:text-gray-700 dark:hover:text-gray-300 focus:outline-none">
|
||||
<h1 class="flex items-baseline gap-1.5 text-xl font-semibold tracking-tight text-gray-900 dark:text-white sm:text-2xl">
|
||||
<span>@GetFilterTitle()</span>
|
||||
<span class="text-base text-gray-500 dark:text-gray-400">(@TotalFilteredItems)</span>
|
||||
</h1>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
else
|
||||
{
|
||||
<h1 class="text-xl font-semibold tracking-tight text-gray-900 dark:text-white sm:text-2xl">
|
||||
@GetFilterTitle()
|
||||
</h1>
|
||||
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<polyline points="6 9 12 15 18 9" />
|
||||
</svg>
|
||||
</button>
|
||||
}
|
||||
@if (IsInFolder)
|
||||
{
|
||||
<button @onclick="ShowEditFolderModal" title="@Localizer["EditFolder"]" class="p-1.5 text-gray-400 hover:text-orange-500 dark:text-gray-500 dark:hover:text-orange-400 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg transition-colors">
|
||||
@@ -271,6 +280,10 @@ else
|
||||
{
|
||||
<p>@Localizer["EmptyFolderMessage"]</p>
|
||||
}
|
||||
else if (HasItemsInFoldersOnly)
|
||||
{
|
||||
<p>@Localizer["AllItemsInFoldersMessage"]</p>
|
||||
}
|
||||
else
|
||||
{
|
||||
<p>@Localizer["NoItemsFound"]</p>
|
||||
@@ -451,6 +464,16 @@ else
|
||||
/// </summary>
|
||||
private bool IsSearching => !string.IsNullOrEmpty(SearchTerm);
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether all items are in folders (no items at root level but items exist in folders).
|
||||
/// </summary>
|
||||
private bool HasItemsInFoldersOnly => Items.Count > 0 && Items.All(x => x.FolderId != null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets whether there are items at the root level (not in any folder).
|
||||
/// </summary>
|
||||
private bool HasRootItems => Items.Any(x => x.FolderId == null);
|
||||
|
||||
/// <summary>
|
||||
/// Gets the items filtered and sorted according to the current filter and sort order (all items, not paginated).
|
||||
/// </summary>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
@inject ILogger<ImportServiceCard> Logger
|
||||
@inject ItemService ItemService
|
||||
@inject DbService DbService
|
||||
@inject FolderService FolderService
|
||||
@inject NavigationManager NavigationManager
|
||||
@inject GlobalNotificationService GlobalNotificationService
|
||||
@inject HttpClient HttpClient
|
||||
@@ -128,11 +129,19 @@
|
||||
</div>
|
||||
@if (ImportedCredentials.Count > 0)
|
||||
{
|
||||
<div class="mb-4">
|
||||
<label class="inline-flex items-center">
|
||||
<div class="mb-4 space-y-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" @bind="ExtractFavicons" class="form-checkbox h-4 w-4 text-primary-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700">
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-300">Extract favicons for services with URLs</span>
|
||||
</label>
|
||||
@if (DetectedFolderCount > 0)
|
||||
{
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" @bind="ImportFolders" class="form-checkbox h-4 w-4 text-primary-600 rounded border-gray-300 dark:border-gray-600 dark:bg-gray-700">
|
||||
<span class="ml-2 text-gray-700 dark:text-gray-300">@Localizer["ImportFoldersLabel"]</span>
|
||||
<span class="ml-2 text-sm text-gray-500 dark:text-gray-400">(@string.Format(Localizer["FoldersDetected"], DetectedFolderCount))</span>
|
||||
</label>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div class="flex justify-end mt-6 space-x-2">
|
||||
@@ -152,10 +161,7 @@
|
||||
{
|
||||
<div class="text-center">
|
||||
<LoadingIndicator />
|
||||
<p class="mt-4 text-gray-700 dark:text-gray-300">Extracting favicons... @(FaviconExtractionProgress)/@(TotalFaviconsToExtract)</p>
|
||||
<div class="w-full bg-gray-200 rounded-full h-2.5 mt-4 dark:bg-gray-700">
|
||||
<div class="bg-primary-600 h-2.5 rounded-full transition-all duration-300" style="width: @(GetFaviconProgressPercentage())%"></div>
|
||||
</div>
|
||||
<p class="mt-4 text-gray-700 dark:text-gray-300">Extracting favicons... @(FaviconExtractionProgress) / @(TotalFaviconsToExtract)</p>
|
||||
<div class="mt-4">
|
||||
<Button OnClick="@CancelFaviconExtraction" Color="secondary">Cancel</Button>
|
||||
</div>
|
||||
@@ -248,6 +254,9 @@
|
||||
private List<ImportedCredential> ImportedCredentials { get; set; } = new();
|
||||
|
||||
private bool ExtractFavicons { get; set; } = true;
|
||||
private bool ImportFolders { get; set; } = true;
|
||||
private int DetectedFolderCount { get; set; } = 0;
|
||||
private HashSet<string> DetectedFolderNames { get; set; } = new();
|
||||
private bool IsExtractingFavicons { get; set; }
|
||||
private int FaviconExtractionProgress { get; set; }
|
||||
private int TotalFaviconsToExtract { get; set; }
|
||||
@@ -475,7 +484,14 @@
|
||||
/// </summary>
|
||||
private async Task ImportCredentialsToDatabase()
|
||||
{
|
||||
var items = BaseImporter.ConvertToItem(ImportedCredentials);
|
||||
// Build folder mapping if folder import is enabled
|
||||
Dictionary<string, Guid>? folderNameToId = null;
|
||||
if (ImportFolders && DetectedFolderCount > 0)
|
||||
{
|
||||
folderNameToId = await CreateOrGetFolders();
|
||||
}
|
||||
|
||||
var items = BaseImporter.ConvertToItem(ImportedCredentials, folderNameToId);
|
||||
foreach (var item in items)
|
||||
{
|
||||
await ProcessSingleItem(item);
|
||||
@@ -494,6 +510,35 @@
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Creates folders that don't exist and returns a mapping of folder names to folder IDs.
|
||||
/// </summary>
|
||||
private async Task<Dictionary<string, Guid>> CreateOrGetFolders()
|
||||
{
|
||||
var folderNameToId = new Dictionary<string, Guid>(StringComparer.OrdinalIgnoreCase);
|
||||
var existingFolders = await FolderService.GetAllWithCountsAsync();
|
||||
|
||||
foreach (var folderName in DetectedFolderNames)
|
||||
{
|
||||
// Check if folder already exists (case-insensitive)
|
||||
var existingFolder = existingFolders.FirstOrDefault(f =>
|
||||
f.Name.Equals(folderName, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
if (existingFolder != null)
|
||||
{
|
||||
folderNameToId[folderName] = existingFolder.Id;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Create new folder - CreateAsync returns the new folder ID directly
|
||||
var newFolderId = await FolderService.CreateAsync(folderName);
|
||||
folderNameToId[folderName] = newFolderId;
|
||||
}
|
||||
}
|
||||
|
||||
return folderNameToId;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Processes a single item.
|
||||
/// </summary>
|
||||
@@ -603,7 +648,7 @@
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Detects and removes duplicates from the import list.
|
||||
/// Detects and removes duplicates from the import list, and also detects folders.
|
||||
/// </summary>
|
||||
private async Task DetectAndRemoveDuplicates()
|
||||
{
|
||||
@@ -621,6 +666,10 @@
|
||||
|
||||
// Remove duplicates from the import list
|
||||
ImportedCredentials = ImportedCredentials.Except(duplicates).ToList();
|
||||
|
||||
// Detect folders in the import
|
||||
DetectedFolderNames = BaseImporter.CollectUniqueFolderNames(ImportedCredentials);
|
||||
DetectedFolderCount = DetectedFolderNames.Count;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -632,15 +681,6 @@
|
||||
return (int)CurrentStep * 100 / (Enum.GetValues(typeof(ImportStep)).Length - 1);
|
||||
}
|
||||
|
||||
private int GetFaviconProgressPercentage()
|
||||
{
|
||||
if (TotalFaviconsToExtract == 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return (FaviconExtractionProgress * 100) / TotalFaviconsToExtract;
|
||||
}
|
||||
|
||||
private void CancelFaviconExtraction()
|
||||
{
|
||||
FaviconExtractionCancellation?.Cancel();
|
||||
|
||||
@@ -86,4 +86,12 @@
|
||||
<value>Import</value>
|
||||
<comment>Import button text</comment>
|
||||
</data>
|
||||
<data name="ImportFoldersLabel" xml:space="preserve">
|
||||
<value>Import folders (if available)</value>
|
||||
<comment>Checkbox label for importing folder structure from the source password manager</comment>
|
||||
</data>
|
||||
<data name="FoldersDetected" xml:space="preserve">
|
||||
<value>{0} folder(s) detected</value>
|
||||
<comment>Info text showing number of folders detected in the import. {0} is the count</comment>
|
||||
</data>
|
||||
</root>
|
||||
@@ -186,6 +186,10 @@
|
||||
<value>This folder is empty.</value>
|
||||
<comment>Empty state message when folder has no items</comment>
|
||||
</data>
|
||||
<data name="AllItemsInFoldersMessage" xml:space="preserve">
|
||||
<value>All your items are organized in folders. Click a folder above to view your credentials, or use the search to find specific items.</value>
|
||||
<comment>Empty state message when all items are in folders and none at root level</comment>
|
||||
</data>
|
||||
|
||||
<!-- Folder Management -->
|
||||
<data name="NewFolder" xml:space="preserve">
|
||||
|
||||
@@ -666,6 +666,7 @@ public sealed class ItemService(HttpClient httpClient, DbService dbService, Conf
|
||||
/// <summary>
|
||||
/// Hard delete all items from the database. This permanently removes all item records
|
||||
/// (including soft-deleted ones) from the database for a complete vault reset.
|
||||
/// Also removes all folders.
|
||||
/// </summary>
|
||||
/// <returns>True if successful, false otherwise.</returns>
|
||||
public async Task<bool> HardDeleteAllItemsAsync()
|
||||
@@ -675,9 +676,11 @@ public sealed class ItemService(HttpClient httpClient, DbService dbService, Conf
|
||||
// Hard delete all related entities and items.
|
||||
context.Attachments.RemoveRange(context.Attachments);
|
||||
context.FieldValues.RemoveRange(context.FieldValues);
|
||||
context.FieldHistories.RemoveRange(context.FieldHistories);
|
||||
context.TotpCodes.RemoveRange(context.TotpCodes);
|
||||
context.Passkeys.RemoveRange(context.Passkeys);
|
||||
context.Items.RemoveRange(context.Items);
|
||||
context.Folders.RemoveRange(context.Folders);
|
||||
|
||||
// Save changes locally
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
@@ -2724,7 +2724,12 @@ video {
|
||||
|
||||
.hover\:border-primary-500:hover {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(244 149 65 / var(--tw-border-opacity));
|
||||
border-color: rgb(246 167 82 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.hover\:border-gray-300:hover {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(209 213 219 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.hover\:bg-blue-600:hover {
|
||||
@@ -3627,6 +3632,11 @@ video {
|
||||
border-color: rgb(244 149 65 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:border-gray-500:hover:is(.dark *) {
|
||||
--tw-border-opacity: 1;
|
||||
border-color: rgb(107 114 128 / var(--tw-border-opacity));
|
||||
}
|
||||
|
||||
.dark\:hover\:bg-blue-500:hover:is(.dark *) {
|
||||
--tw-bg-opacity: 1;
|
||||
background-color: rgb(59 130 246 / var(--tw-bg-opacity));
|
||||
|
||||
@@ -59,12 +59,29 @@ public class ImportTests : ClientPlaywrightTest
|
||||
await NavigateUsingBlazorRouter("items");
|
||||
await WaitForUrlAsync("items", "Find all of your items");
|
||||
|
||||
// Verify that expected items from the Bitwarden CSV are present.
|
||||
// Verify root-level items (items without a folder) are present.
|
||||
var pageContent = await Page.TextContentAsync("body");
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(pageContent, Does.Contain("TutaNota"), "TutaNota item not imported");
|
||||
Assert.That(pageContent, Does.Contain("Aliasvault.net"), "Aliasvault.net item not imported");
|
||||
Assert.That(pageContent, Does.Contain("Test"), "Test item not imported at root level");
|
||||
Assert.That(pageContent, Does.Not.Contain("TutaNota"), "TutaNota should be in Business folder, not at root");
|
||||
Assert.That(pageContent, Does.Not.Contain("Aliasvault.net"), "Aliasvault.net should be in Business folder, not at root");
|
||||
});
|
||||
|
||||
// Verify the Business folder was created.
|
||||
Assert.That(pageContent, Does.Contain("Business"), "Business folder not created");
|
||||
|
||||
// Navigate to the Business folder by clicking on it.
|
||||
await Page.ClickAsync("text=Business");
|
||||
await Page.WaitForSelectorAsync("text=Item for business folder");
|
||||
|
||||
// Verify items in the Business folder are present.
|
||||
var folderPageContent = await Page.TextContentAsync("body");
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(folderPageContent, Does.Contain("Item for business folder"), "Item for business folder not imported");
|
||||
Assert.That(folderPageContent, Does.Contain("TutaNota"), "TutaNota item not imported in Business folder");
|
||||
Assert.That(folderPageContent, Does.Contain("Aliasvault.net"), "Aliasvault.net item not imported in Business folder");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ using AliasClientDb;
|
||||
using AliasClientDb.Models;
|
||||
using AliasVault.ImportExport;
|
||||
using AliasVault.ImportExport.Importers;
|
||||
using AliasVault.ImportExport.Models;
|
||||
using AliasVault.UnitTests.Common;
|
||||
|
||||
/// <summary>
|
||||
@@ -30,7 +31,7 @@ public class ImportExportTests
|
||||
{
|
||||
Id = new Guid("00000000-0000-0000-0000-000000000001"),
|
||||
Name = "Test Service",
|
||||
ItemType = "Login",
|
||||
ItemType = ItemType.Login,
|
||||
CreatedAt = DateTime.Now,
|
||||
UpdatedAt = DateTime.Now,
|
||||
};
|
||||
@@ -533,7 +534,7 @@ public class ImportExportTests
|
||||
Assert.That(secureNoteCredential.TwoFactorSecret, Is.Empty);
|
||||
});
|
||||
|
||||
// Test credit card entry (stored as note-only credential)
|
||||
// Test credit card entry
|
||||
var creditCardCredential = importedCredentials.First(c => c.ServiceName == "Paymentcard1");
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
@@ -541,11 +542,12 @@ public class ImportExportTests
|
||||
Assert.That(creditCardCredential.ServiceUrl, Is.Null); // Should be normalized to null
|
||||
Assert.That(creditCardCredential.Username, Is.Empty);
|
||||
Assert.That(creditCardCredential.Password, Is.Empty);
|
||||
Assert.That(creditCardCredential.Notes, Does.Contain("NoteType:Credit Card"));
|
||||
Assert.That(creditCardCredential.Notes, Does.Contain("Name on Card:Cardname"));
|
||||
Assert.That(creditCardCredential.Notes, Does.Contain("Number:123456781234"));
|
||||
Assert.That(creditCardCredential.Notes, Does.Contain("Security Code:1234"));
|
||||
Assert.That(creditCardCredential.Notes, Does.Contain("Creditcardnotes here"));
|
||||
Assert.That(creditCardCredential.ItemType, Is.EqualTo(ImportedItemType.Creditcard));
|
||||
Assert.That(creditCardCredential.Creditcard, Is.Not.Null);
|
||||
Assert.That(creditCardCredential.Creditcard!.CardholderName, Is.EqualTo("Cardname"));
|
||||
Assert.That(creditCardCredential.Creditcard.Number, Is.EqualTo("123456781234"));
|
||||
Assert.That(creditCardCredential.Creditcard.Cvv, Is.EqualTo("1234"));
|
||||
Assert.That(creditCardCredential.Notes, Is.EqualTo("Creditcardnotes here")); // Extracted notes
|
||||
Assert.That(creditCardCredential.TwoFactorSecret, Is.Empty);
|
||||
});
|
||||
}
|
||||
@@ -758,6 +760,243 @@ public class ImportExportTests
|
||||
Assert.That(template, Does.Contain("your_totp_secret_here"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test case for Bitwarden import with folder path extraction.
|
||||
/// </summary>
|
||||
/// <returns>Async task.</returns>
|
||||
[Test]
|
||||
public async Task ImportBitwardenWithFolders()
|
||||
{
|
||||
// Arrange
|
||||
var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.bitwarden.csv");
|
||||
|
||||
// Act
|
||||
var importedCredentials = await BitwardenImporter.ImportFromCsvAsync(fileContent);
|
||||
|
||||
// Assert - check folder path is extracted (6 items in Business folder in test data)
|
||||
var businessFolderItems = importedCredentials.Where(c => c.FolderPath == "Business").ToList();
|
||||
Assert.That(businessFolderItems, Has.Count.EqualTo(6), "Should have 6 items in Business folder");
|
||||
|
||||
// Verify folder names are collected correctly
|
||||
var folderNames = BaseImporter.CollectUniqueFolderNames(importedCredentials);
|
||||
Assert.That(folderNames, Does.Contain("Business"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test case for multi-level folder path extraction (takes deepest folder).
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ExtractDeepestFolderName()
|
||||
{
|
||||
Assert.Multiple(() =>
|
||||
{
|
||||
Assert.That(BaseImporter.ExtractDeepestFolderName("Root/Business/Banking"), Is.EqualTo("Banking"));
|
||||
Assert.That(BaseImporter.ExtractDeepestFolderName("Business"), Is.EqualTo("Business"));
|
||||
Assert.That(BaseImporter.ExtractDeepestFolderName("Root\\Work\\Finance"), Is.EqualTo("Finance"));
|
||||
Assert.That(BaseImporter.ExtractDeepestFolderName(string.Empty), Is.Null);
|
||||
Assert.That(BaseImporter.ExtractDeepestFolderName(null), Is.Null);
|
||||
Assert.That(BaseImporter.ExtractDeepestFolderName(" / "), Is.Null);
|
||||
Assert.That(BaseImporter.ExtractDeepestFolderName("Single"), Is.EqualTo("Single"));
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test case for Bitwarden type detection (login, note, card).
|
||||
/// </summary>
|
||||
/// <returns>Async task.</returns>
|
||||
[Test]
|
||||
public async Task BitwardenTypeDetection()
|
||||
{
|
||||
// Arrange
|
||||
var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.bitwarden.csv");
|
||||
|
||||
// Act
|
||||
var importedCredentials = await BitwardenImporter.ImportFromCsvAsync(fileContent);
|
||||
var items = BaseImporter.ConvertToItem(importedCredentials);
|
||||
|
||||
// Assert - verify login type items have Login item type
|
||||
var loginItems = items.Where(i => i.ItemType == ItemType.Login).ToList();
|
||||
Assert.That(loginItems, Has.Count.GreaterThan(0), "Should have at least one Login item");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test case for LastPass secure note detection.
|
||||
/// </summary>
|
||||
/// <returns>Async task.</returns>
|
||||
[Test]
|
||||
public async Task LastPassSecureNoteDetection()
|
||||
{
|
||||
// Arrange
|
||||
var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.lastpass.csv");
|
||||
|
||||
// Act
|
||||
var importedCredentials = await LastPassImporter.ImportFromCsvAsync(fileContent);
|
||||
var items = BaseImporter.ConvertToItem(importedCredentials);
|
||||
|
||||
// Assert - verify secure note is detected
|
||||
var secureNoteItem = items.FirstOrDefault(i => i.Name == "securenote1");
|
||||
Assert.That(secureNoteItem, Is.Not.Null, "Should find securenote1");
|
||||
Assert.That(secureNoteItem!.ItemType, Is.EqualTo(ItemType.Note), "Secure note should have Note item type");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test case for LastPass credit card detection and parsing.
|
||||
/// </summary>
|
||||
/// <returns>Async task.</returns>
|
||||
[Test]
|
||||
public async Task LastPassCreditCardDetectionAndParsing()
|
||||
{
|
||||
// Arrange
|
||||
var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.lastpass.csv");
|
||||
|
||||
// Act
|
||||
var importedCredentials = await LastPassImporter.ImportFromCsvAsync(fileContent);
|
||||
|
||||
// Assert - verify credit card is detected
|
||||
var creditCardCredential = importedCredentials.FirstOrDefault(c => c.ServiceName == "Paymentcard1");
|
||||
Assert.That(creditCardCredential, Is.Not.Null, "Should find Paymentcard1");
|
||||
Assert.That(creditCardCredential!.ItemType, Is.EqualTo(ImportedItemType.Creditcard), "Should be Creditcard type");
|
||||
Assert.That(creditCardCredential.Creditcard, Is.Not.Null, "Should have Creditcard data");
|
||||
Assert.That(creditCardCredential.Creditcard!.CardholderName, Is.EqualTo("Cardname"));
|
||||
Assert.That(creditCardCredential.Creditcard.Number, Is.EqualTo("123456781234"));
|
||||
Assert.That(creditCardCredential.Creditcard.Cvv, Is.EqualTo("1234"));
|
||||
|
||||
// Convert to item and verify fields
|
||||
var items = BaseImporter.ConvertToItem([creditCardCredential]);
|
||||
var creditCardItem = items[0];
|
||||
Assert.That(creditCardItem.ItemType, Is.EqualTo(ItemType.CreditCard));
|
||||
|
||||
var cardNumber = creditCardItem.FieldValues.FirstOrDefault(fv => fv.FieldKey == FieldKey.CardNumber);
|
||||
Assert.That(cardNumber?.Value, Is.EqualTo("123456781234"));
|
||||
|
||||
var cardholderName = creditCardItem.FieldValues.FirstOrDefault(fv => fv.FieldKey == FieldKey.CardCardholderName);
|
||||
Assert.That(cardholderName?.Value, Is.EqualTo("Cardname"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test case for LastPass folder (grouping) import.
|
||||
/// </summary>
|
||||
/// <returns>Async task.</returns>
|
||||
[Test]
|
||||
public async Task LastPassFolderImport()
|
||||
{
|
||||
// Arrange
|
||||
var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.lastpass.csv");
|
||||
|
||||
// Act
|
||||
var importedCredentials = await LastPassImporter.ImportFromCsvAsync(fileContent);
|
||||
|
||||
// Assert - verify folder path is extracted
|
||||
var credentialWithFolder = importedCredentials.FirstOrDefault(c => !string.IsNullOrEmpty(c.FolderPath));
|
||||
Assert.That(credentialWithFolder, Is.Not.Null, "Should have at least one credential with folder");
|
||||
Assert.That(credentialWithFolder!.FolderPath, Is.EqualTo("examplefolder"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test case for KeePassXC group (folder) import.
|
||||
/// </summary>
|
||||
/// <returns>Async task.</returns>
|
||||
[Test]
|
||||
public async Task KeePassXcGroupImport()
|
||||
{
|
||||
// Arrange
|
||||
var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.keepassxc.csv");
|
||||
|
||||
// Act
|
||||
var importedCredentials = await KeePassXcImporter.ImportFromCsvAsync(fileContent);
|
||||
|
||||
// Assert - verify folder path is extracted (KeePassXC uses Group column which contains folder hierarchy)
|
||||
var folderNames = BaseImporter.CollectUniqueFolderNames(importedCredentials);
|
||||
Assert.That(folderNames, Has.Count.GreaterThanOrEqualTo(0), "Should collect any folders present");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test case for folder assignment during ConvertToItem.
|
||||
/// </summary>
|
||||
[Test]
|
||||
public void ConvertToItemWithFolderMapping()
|
||||
{
|
||||
// Arrange
|
||||
var credentials = new List<AliasVault.ImportExport.Models.ImportedCredential>
|
||||
{
|
||||
new()
|
||||
{
|
||||
ServiceName = "Test Service",
|
||||
FolderPath = "Work/Projects",
|
||||
Username = "user1",
|
||||
Password = "pass1",
|
||||
},
|
||||
new()
|
||||
{
|
||||
ServiceName = "Test Service 2",
|
||||
FolderPath = "Personal",
|
||||
Username = "user2",
|
||||
Password = "pass2",
|
||||
},
|
||||
new()
|
||||
{
|
||||
ServiceName = "No Folder",
|
||||
Username = "user3",
|
||||
Password = "pass3",
|
||||
},
|
||||
};
|
||||
|
||||
var folderMapping = new Dictionary<string, Guid>
|
||||
{
|
||||
{ "Projects", Guid.NewGuid() }, // Deepest folder from "Work/Projects"
|
||||
{ "Personal", Guid.NewGuid() },
|
||||
};
|
||||
|
||||
// Act
|
||||
var items = BaseImporter.ConvertToItem(credentials, folderMapping);
|
||||
|
||||
// Assert
|
||||
Assert.That(items[0].FolderId, Is.EqualTo(folderMapping["Projects"]), "Should assign Projects folder");
|
||||
Assert.That(items[1].FolderId, Is.EqualTo(folderMapping["Personal"]), "Should assign Personal folder");
|
||||
Assert.That(items[2].FolderId, Is.Null, "Should have no folder");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test case for ProtonPass type and vault (folder) import.
|
||||
/// </summary>
|
||||
/// <returns>Async task.</returns>
|
||||
[Test]
|
||||
public async Task ProtonPassTypeAndVaultImport()
|
||||
{
|
||||
// Arrange
|
||||
var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.protonpass.csv");
|
||||
|
||||
// Act
|
||||
var importedCredentials = await ProtonPassImporter.ImportFromCsvAsync(fileContent);
|
||||
|
||||
// Assert - verify vault (folder) is extracted
|
||||
var credentialsWithVault = importedCredentials.Where(c => !string.IsNullOrEmpty(c.FolderPath)).ToList();
|
||||
Assert.That(credentialsWithVault.Count, Is.GreaterThan(0), "Should have credentials with vault/folder");
|
||||
|
||||
// Verify type detection
|
||||
var loginCredential = importedCredentials.FirstOrDefault(c => c.ItemType == ImportedItemType.Login);
|
||||
Assert.That(loginCredential, Is.Not.Null, "Should have at least one Login type");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test case for Dashlane category (folder) import.
|
||||
/// </summary>
|
||||
/// <returns>Async task.</returns>
|
||||
[Test]
|
||||
public async Task DashlaneCategoryImport()
|
||||
{
|
||||
// Arrange
|
||||
var fileContent = await ResourceReaderUtility.ReadEmbeddedResourceStringAsync("AliasVault.UnitTests.TestData.Exports.dashlane.csv");
|
||||
|
||||
// Act
|
||||
var importedCredentials = await DashlaneImporter.ImportFromCsvAsync(fileContent);
|
||||
|
||||
// Assert - check if any credentials have folder path from category
|
||||
// Note: Dashlane test data may or may not have categories
|
||||
var folderNames = BaseImporter.CollectUniqueFolderNames(importedCredentials);
|
||||
Assert.That(folderNames, Is.Not.Null, "Should return a set (even if empty)");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Helper method to add a field value to an item.
|
||||
/// </summary>
|
||||
|
||||
@@ -142,8 +142,9 @@ public static class BaseImporter
|
||||
/// Converts a list of imported credentials to a list of AliasVault Items.
|
||||
/// </summary>
|
||||
/// <param name="importedCredentials">The list of imported credentials.</param>
|
||||
/// <param name="folderNameToId">Optional dictionary mapping folder names to folder IDs for folder import.</param>
|
||||
/// <returns>The list of AliasVault Items.</returns>
|
||||
public static List<Item> ConvertToItem(List<ImportedCredential> importedCredentials)
|
||||
public static List<Item> ConvertToItem(List<ImportedCredential> importedCredentials, Dictionary<string, Guid>? folderNameToId = null)
|
||||
{
|
||||
var items = new List<Item>();
|
||||
|
||||
@@ -154,27 +155,43 @@ public static class BaseImporter
|
||||
var createdAt = importedCredential.CreatedAt ?? currentDateTime;
|
||||
var updatedAt = importedCredential.UpdatedAt ?? currentDateTime;
|
||||
|
||||
// Determine if this is an Alias item type (has alias identity data)
|
||||
var hasAliasData = importedCredential.Alias != null &&
|
||||
(!string.IsNullOrEmpty(importedCredential.Alias.FirstName) ||
|
||||
!string.IsNullOrEmpty(importedCredential.Alias.LastName) ||
|
||||
!string.IsNullOrEmpty(importedCredential.Alias.Gender) ||
|
||||
importedCredential.Alias.BirthDate.HasValue);
|
||||
// Determine the item type (uses ItemType from importer if set)
|
||||
var itemType = DetermineItemType(importedCredential);
|
||||
|
||||
var item = new Item
|
||||
{
|
||||
Id = Guid.NewGuid(),
|
||||
Name = importedCredential.ServiceName ?? string.Empty,
|
||||
ItemType = hasAliasData ? "Alias" : "Login",
|
||||
ItemType = itemType,
|
||||
CreatedAt = createdAt,
|
||||
UpdatedAt = updatedAt,
|
||||
};
|
||||
|
||||
// Add field values for non-empty fields
|
||||
AddFieldValueIfNotEmpty(item, FieldKey.LoginUrl, importedCredential.ServiceUrl, createdAt, updatedAt);
|
||||
AddFieldValueIfNotEmpty(item, FieldKey.LoginUsername, importedCredential.Username, createdAt, updatedAt);
|
||||
AddFieldValueIfNotEmpty(item, FieldKey.LoginPassword, importedCredential.Password, createdAt, updatedAt);
|
||||
AddFieldValueIfNotEmpty(item, FieldKey.LoginEmail, importedCredential.Email, createdAt, updatedAt);
|
||||
// Handle folder assignment if folder mapping is provided
|
||||
if (folderNameToId != null && !string.IsNullOrWhiteSpace(importedCredential.FolderPath))
|
||||
{
|
||||
var folderName = ExtractDeepestFolderName(importedCredential.FolderPath);
|
||||
if (!string.IsNullOrWhiteSpace(folderName) && folderNameToId.TryGetValue(folderName, out var folderId))
|
||||
{
|
||||
item.FolderId = folderId;
|
||||
}
|
||||
}
|
||||
|
||||
// Handle credit card type - parse structured notes and add card fields
|
||||
if (itemType == ItemType.CreditCard)
|
||||
{
|
||||
AddCreditCardFields(item, importedCredential, createdAt, updatedAt);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Add standard field values for non-empty fields (Login, Alias, Note types)
|
||||
AddFieldValueIfNotEmpty(item, FieldKey.LoginUrl, importedCredential.ServiceUrl, createdAt, updatedAt);
|
||||
AddFieldValueIfNotEmpty(item, FieldKey.LoginUsername, importedCredential.Username, createdAt, updatedAt);
|
||||
AddFieldValueIfNotEmpty(item, FieldKey.LoginPassword, importedCredential.Password, createdAt, updatedAt);
|
||||
AddFieldValueIfNotEmpty(item, FieldKey.LoginEmail, importedCredential.Email, createdAt, updatedAt);
|
||||
}
|
||||
|
||||
// Add notes for all item types
|
||||
AddFieldValueIfNotEmpty(item, FieldKey.NotesContent, importedCredential.Notes, createdAt, updatedAt);
|
||||
|
||||
// Add alias fields if present
|
||||
@@ -220,6 +237,123 @@ public static class BaseImporter
|
||||
return items;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determines the item type based on the imported credential.
|
||||
/// Uses ItemType if set by the importer, otherwise checks for alias data.
|
||||
/// </summary>
|
||||
/// <param name="credential">The imported credential to analyze.</param>
|
||||
/// <returns>The item type constant from <see cref="ItemType"/>.</returns>
|
||||
private static string DetermineItemType(ImportedCredential credential)
|
||||
{
|
||||
// If the importer explicitly set a type, use it
|
||||
if (credential.ItemType.HasValue)
|
||||
{
|
||||
// If Login type was set but has alias data, upgrade to Alias
|
||||
if (credential.ItemType == ImportedItemType.Login && HasAliasData(credential))
|
||||
{
|
||||
return ItemType.Alias;
|
||||
}
|
||||
|
||||
return credential.ItemType.Value switch
|
||||
{
|
||||
ImportedItemType.Login => ItemType.Login,
|
||||
ImportedItemType.Note => ItemType.Note,
|
||||
ImportedItemType.Creditcard => ItemType.CreditCard,
|
||||
ImportedItemType.Alias => ItemType.Alias,
|
||||
_ => ItemType.Login,
|
||||
};
|
||||
}
|
||||
|
||||
// Fallback: check for alias data
|
||||
if (HasAliasData(credential))
|
||||
{
|
||||
return ItemType.Alias;
|
||||
}
|
||||
|
||||
// Default to Login
|
||||
return ItemType.Login;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Checks if the credential has alias identity data.
|
||||
/// </summary>
|
||||
private static bool HasAliasData(ImportedCredential credential)
|
||||
{
|
||||
return credential.Alias != null &&
|
||||
(!string.IsNullOrEmpty(credential.Alias.FirstName) ||
|
||||
!string.IsNullOrEmpty(credential.Alias.LastName) ||
|
||||
!string.IsNullOrEmpty(credential.Alias.Gender) ||
|
||||
credential.Alias.BirthDate.HasValue);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the deepest (most specific) folder name from a potentially hierarchical path.
|
||||
/// For example: "Root/Business/Banking" returns "Banking".
|
||||
/// </summary>
|
||||
/// <param name="folderPath">The folder path, potentially with hierarchy separators.</param>
|
||||
/// <returns>The deepest folder name.</returns>
|
||||
public static string? ExtractDeepestFolderName(string? folderPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(folderPath))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle common hierarchy separators: / and \
|
||||
var separators = new[] { '/', '\\' };
|
||||
var parts = folderPath.Split(separators, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
// Return the last (deepest) part, or null if no valid folder name
|
||||
if (parts.Length == 0)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
var result = parts[^1].Trim();
|
||||
return string.IsNullOrEmpty(result) ? null : result;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Collects unique folder names from imported credentials for folder creation.
|
||||
/// </summary>
|
||||
/// <param name="credentials">The list of imported credentials.</param>
|
||||
/// <returns>A set of unique folder names (deepest level only).</returns>
|
||||
public static HashSet<string> CollectUniqueFolderNames(List<ImportedCredential> credentials)
|
||||
{
|
||||
var folderNames = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var credential in credentials)
|
||||
{
|
||||
var folderName = ExtractDeepestFolderName(credential.FolderPath);
|
||||
if (!string.IsNullOrWhiteSpace(folderName))
|
||||
{
|
||||
folderNames.Add(folderName);
|
||||
}
|
||||
}
|
||||
|
||||
return folderNames;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds credit card fields to an item from the ImportedCreditcard model.
|
||||
/// Each importer is responsible for populating the Creditcard property.
|
||||
/// </summary>
|
||||
private static void AddCreditCardFields(Item item, ImportedCredential credential, DateTime createdAt, DateTime updatedAt)
|
||||
{
|
||||
if (credential.Creditcard == null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
var card = credential.Creditcard;
|
||||
AddFieldValueIfNotEmpty(item, FieldKey.CardCardholderName, card.CardholderName, createdAt, updatedAt);
|
||||
AddFieldValueIfNotEmpty(item, FieldKey.CardNumber, card.Number, createdAt, updatedAt);
|
||||
AddFieldValueIfNotEmpty(item, FieldKey.CardCvv, card.Cvv, createdAt, updatedAt);
|
||||
AddFieldValueIfNotEmpty(item, FieldKey.CardPin, card.Pin, createdAt, updatedAt);
|
||||
AddFieldValueIfNotEmpty(item, FieldKey.CardExpiryMonth, card.ExpiryMonth, createdAt, updatedAt);
|
||||
AddFieldValueIfNotEmpty(item, FieldKey.CardExpiryYear, card.ExpiryYear, createdAt, updatedAt);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Adds a field value to an item if the value is not empty.
|
||||
/// </summary>
|
||||
|
||||
@@ -37,7 +37,9 @@ public static class BitwardenImporter
|
||||
Username = record.Username,
|
||||
Password = record.Password,
|
||||
TwoFactorSecret = record.OTPAuth,
|
||||
Notes = record.Notes
|
||||
Notes = record.Notes,
|
||||
FolderPath = string.IsNullOrWhiteSpace(record.Folder) ? null : record.Folder,
|
||||
ItemType = MapBitwardenType(record.Type),
|
||||
};
|
||||
|
||||
credentials.Add(credential);
|
||||
@@ -45,4 +47,25 @@ public static class BitwardenImporter
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps Bitwarden type values to ImportedItemType.
|
||||
/// Bitwarden types: login, note, card, identity.
|
||||
/// </summary>
|
||||
private static ImportedItemType? MapBitwardenType(string? bitwardenType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(bitwardenType))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return bitwardenType.ToLowerInvariant() switch
|
||||
{
|
||||
"login" => ImportedItemType.Login,
|
||||
"note" or "securenote" => ImportedItemType.Note,
|
||||
"card" => ImportedItemType.Creditcard,
|
||||
"identity" => ImportedItemType.Alias,
|
||||
_ => ImportedItemType.Login,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ public static class DashlaneImporter
|
||||
Username = record.Username,
|
||||
Password = record.Password,
|
||||
TwoFactorSecret = record.OTPUrl,
|
||||
Notes = BuildNotes(record)
|
||||
Notes = BuildNotes(record),
|
||||
FolderPath = string.IsNullOrWhiteSpace(record.Category) ? null : record.Category,
|
||||
};
|
||||
|
||||
credentials.Add(credential);
|
||||
@@ -62,11 +63,6 @@ public static class DashlaneImporter
|
||||
notes.Add($"Alternative username 2: {record.Username3}");
|
||||
}
|
||||
|
||||
if (!string.IsNullOrEmpty(record.Category))
|
||||
{
|
||||
notes.Add($"Category: {record.Category}");
|
||||
}
|
||||
|
||||
return notes.Count > 0 ? string.Join(Environment.NewLine, notes) : null;
|
||||
}
|
||||
}
|
||||
@@ -34,7 +34,8 @@ public static class KeePassXcImporter
|
||||
Username = record.Username,
|
||||
Password = record.Password,
|
||||
TwoFactorSecret = record.TOTP,
|
||||
Notes = record.Notes
|
||||
Notes = record.Notes,
|
||||
FolderPath = string.IsNullOrWhiteSpace(record.Group) ? null : record.Group,
|
||||
};
|
||||
|
||||
credentials.Add(credential);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
//-----------------------------------------------------------------------
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="LastPassImporter.cs" company="aliasvault">
|
||||
// Copyright (c) aliasvault. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
@@ -33,14 +33,42 @@ public static class LastPassImporter
|
||||
continue;
|
||||
}
|
||||
|
||||
// Normalize URL - LastPass uses "http://sn" for secure notes and "http://" for entries without URLs
|
||||
var normalizedUrl = string.IsNullOrWhiteSpace(record.URL) || record.URL == "http://" || record.URL == "http://sn"
|
||||
? null
|
||||
: record.URL;
|
||||
|
||||
// Determine item type and parse structured data inline
|
||||
ImportedItemType? itemType = null;
|
||||
ImportedCreditcard? creditcard = null;
|
||||
string? notes = record.Extra;
|
||||
|
||||
// Check for credit card (structured data in Extra field)
|
||||
if (!string.IsNullOrEmpty(record.Extra) && record.Extra.Contains("NoteType:Credit Card"))
|
||||
{
|
||||
itemType = ImportedItemType.Creditcard;
|
||||
creditcard = ParseCreditcardFromNotes(record.Extra);
|
||||
notes = ExtractNotesFromCreditcard(record.Extra);
|
||||
}
|
||||
// Check for secure note: URL is "http://sn" and no username/password
|
||||
else if (record.URL == "http://sn" &&
|
||||
string.IsNullOrEmpty(record.Username) &&
|
||||
string.IsNullOrEmpty(record.Password))
|
||||
{
|
||||
itemType = ImportedItemType.Note;
|
||||
}
|
||||
|
||||
var credential = new ImportedCredential
|
||||
{
|
||||
ServiceName = record.Title,
|
||||
ServiceUrl = NormalizeUrl(record.URL),
|
||||
ServiceUrl = normalizedUrl,
|
||||
Username = record.Username,
|
||||
Password = record.Password,
|
||||
TwoFactorSecret = record.TwoFactorSecret,
|
||||
Notes = record.Extra
|
||||
Notes = notes,
|
||||
FolderPath = string.IsNullOrWhiteSpace(record.Grouping) ? null : record.Grouping,
|
||||
ItemType = itemType,
|
||||
Creditcard = creditcard,
|
||||
};
|
||||
|
||||
credentials.Add(credential);
|
||||
@@ -50,18 +78,101 @@ public static class LastPassImporter
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes URL values from LastPass CSV format.
|
||||
/// LastPass uses "http://sn" for secure notes and "http://" for entries without URLs.
|
||||
/// Parses credit card data from LastPass structured notes format.
|
||||
/// </summary>
|
||||
/// <param name="url">The URL from the CSV record.</param>
|
||||
/// <returns>The normalized URL or null if it's a special LastPass placeholder.</returns>
|
||||
private static string? NormalizeUrl(string? url)
|
||||
private static ImportedCreditcard ParseCreditcardFromNotes(string notes)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url) || url == "http://" || url == "http://sn")
|
||||
var creditcard = new ImportedCreditcard();
|
||||
var lines = notes.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
return null;
|
||||
var colonIndex = line.IndexOf(':');
|
||||
if (colonIndex <= 0)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
var key = line[..colonIndex].Trim();
|
||||
var value = line[(colonIndex + 1)..].Trim();
|
||||
|
||||
if (string.IsNullOrEmpty(value))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
switch (key)
|
||||
{
|
||||
case "Name on Card":
|
||||
creditcard.CardholderName = value;
|
||||
break;
|
||||
case "Number":
|
||||
creditcard.Number = value;
|
||||
break;
|
||||
case "Security Code":
|
||||
creditcard.Cvv = value;
|
||||
break;
|
||||
case "Expiration Date":
|
||||
// LastPass format: "May,2028"
|
||||
var parts = value.Split(',');
|
||||
if (parts.Length == 2)
|
||||
{
|
||||
creditcard.ExpiryMonth = parts[0].Trim().ToLowerInvariant() switch
|
||||
{
|
||||
"january" => "01",
|
||||
"february" => "02",
|
||||
"march" => "03",
|
||||
"april" => "04",
|
||||
"may" => "05",
|
||||
"june" => "06",
|
||||
"july" => "07",
|
||||
"august" => "08",
|
||||
"september" => "09",
|
||||
"october" => "10",
|
||||
"november" => "11",
|
||||
"december" => "12",
|
||||
_ => null,
|
||||
};
|
||||
creditcard.ExpiryYear = parts[1].Trim();
|
||||
}
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return url;
|
||||
return creditcard;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Extracts the actual notes from a LastPass credit card structured note.
|
||||
/// The notes section is after the "Notes:" line.
|
||||
/// </summary>
|
||||
private static string? ExtractNotesFromCreditcard(string structuredNotes)
|
||||
{
|
||||
var lines = structuredNotes.Split(new[] { '\n', '\r' }, StringSplitOptions.RemoveEmptyEntries);
|
||||
var foundNotesSection = false;
|
||||
var noteLines = new List<string>();
|
||||
|
||||
foreach (var line in lines)
|
||||
{
|
||||
if (line.StartsWith("Notes:"))
|
||||
{
|
||||
foundNotesSection = true;
|
||||
var noteValue = line["Notes:".Length..].Trim();
|
||||
if (!string.IsNullOrEmpty(noteValue))
|
||||
{
|
||||
noteLines.Add(noteValue);
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
|
||||
if (foundNotesSection)
|
||||
{
|
||||
noteLines.Add(line);
|
||||
}
|
||||
}
|
||||
|
||||
return noteLines.Count > 0 ? string.Join(Environment.NewLine, noteLines) : null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,7 +34,8 @@ public static class OnePasswordImporter
|
||||
Username = record.Username,
|
||||
Password = record.Password,
|
||||
TwoFactorSecret = record.OTPAuth,
|
||||
Notes = record.Notes
|
||||
Notes = record.Notes,
|
||||
FolderPath = string.IsNullOrWhiteSpace(record.Tags) ? null : record.Tags,
|
||||
};
|
||||
|
||||
credentials.Add(credential);
|
||||
|
||||
@@ -36,6 +36,8 @@ public static class ProtonPassImporter
|
||||
Password = record.Password,
|
||||
Notes = record.Note,
|
||||
TwoFactorSecret = record.Totp,
|
||||
FolderPath = string.IsNullOrWhiteSpace(record.Vault) ? null : record.Vault,
|
||||
ItemType = MapProtonPassType(record.Type),
|
||||
};
|
||||
|
||||
credentials.Add(credential);
|
||||
@@ -43,4 +45,25 @@ public static class ProtonPassImporter
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Maps ProtonPass type values to ImportedItemType.
|
||||
/// ProtonPass types: login, note, alias, creditCard.
|
||||
/// </summary>
|
||||
private static ImportedItemType? MapProtonPassType(string? protonPassType)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(protonPassType))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return protonPassType.ToLowerInvariant() switch
|
||||
{
|
||||
"login" => ImportedItemType.Login,
|
||||
"note" => ImportedItemType.Note,
|
||||
"alias" => ImportedItemType.Login, // ProtonPass alias is email alias, not identity alias
|
||||
"creditcard" => ImportedItemType.Creditcard,
|
||||
_ => ImportedItemType.Login,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,4 +68,22 @@ public class ImportedCredential
|
||||
/// Gets or sets the alias information.
|
||||
/// </summary>
|
||||
public ImportedAlias? Alias { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the folder path from the source (e.g., "Business" or "Personal/Work").
|
||||
/// For multi-level paths, the deepest folder will be used during import.
|
||||
/// </summary>
|
||||
public string? FolderPath { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the item type. Each importer is responsible for setting this based on the source data.
|
||||
/// If null, defaults to Login or Alias (if alias data is present).
|
||||
/// </summary>
|
||||
public ImportedItemType? ItemType { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets credit card information if the item is a credit card type.
|
||||
/// Each importer should populate this from its own format.
|
||||
/// </summary>
|
||||
public ImportedCreditcard? Creditcard { get; set; }
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="ImportedCreditcard.cs" company="aliasvault">
|
||||
// Copyright (c) aliasvault. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.ImportExport.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents credit card information in an intermediary format that is imported from various sources.
|
||||
/// Each importer is responsible for populating this from its own format.
|
||||
/// </summary>
|
||||
public class ImportedCreditcard
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the cardholder name.
|
||||
/// </summary>
|
||||
public string? CardholderName { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the card number.
|
||||
/// </summary>
|
||||
public string? Number { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the expiry month (01-12).
|
||||
/// </summary>
|
||||
public string? ExpiryMonth { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the expiry year (e.g., "2028").
|
||||
/// </summary>
|
||||
public string? ExpiryYear { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the CVV/security code.
|
||||
/// </summary>
|
||||
public string? Cvv { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the PIN.
|
||||
/// </summary>
|
||||
public string? Pin { get; set; }
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
//-----------------------------------------------------------------------
|
||||
// <copyright file="ImportedItemType.cs" company="aliasvault">
|
||||
// Copyright (c) aliasvault. All rights reserved.
|
||||
// Licensed under the AGPLv3 license. See LICENSE.md file in the project root for full license information.
|
||||
// </copyright>
|
||||
//-----------------------------------------------------------------------
|
||||
|
||||
namespace AliasVault.ImportExport.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Represents the type of item being imported from a password manager.
|
||||
/// Each importer is responsible for mapping its source types to these values.
|
||||
/// </summary>
|
||||
public enum ImportedItemType
|
||||
{
|
||||
/// <summary>
|
||||
/// Standard login credentials (username/password).
|
||||
/// </summary>
|
||||
Login,
|
||||
|
||||
/// <summary>
|
||||
/// Secure note without login credentials.
|
||||
/// </summary>
|
||||
Note,
|
||||
|
||||
/// <summary>
|
||||
/// Credit card information.
|
||||
/// </summary>
|
||||
Creditcard,
|
||||
|
||||
/// <summary>
|
||||
/// Identity/alias information (name, birthdate, etc).
|
||||
/// </summary>
|
||||
Alias,
|
||||
}
|
||||
Reference in New Issue
Block a user