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:
Leendert de Borst
2026-01-25 20:30:19 +00:00
committed by GitHub
24 changed files with 1203 additions and 216 deletions

View File

@@ -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>
)}
</>
)}

View File

@@ -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.",

View File

@@ -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

View File

@@ -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');
};

View File

@@ -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",

View File

@@ -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>

View File

@@ -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>

View File

@@ -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();

View File

@@ -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>

View File

@@ -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">

View File

@@ -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();

View File

@@ -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));

View File

@@ -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");
});
}
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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,
};
}
}

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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);

View File

@@ -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,
};
}
}

View File

@@ -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; }
}

View File

@@ -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; }
}

View File

@@ -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,
}