subfolder support

This commit is contained in:
randogoth 2025-02-22 15:45:04 +02:00
parent a7f2e1ca89
commit f77a66d5d8
2 changed files with 208 additions and 65 deletions

View file

@ -139,33 +139,82 @@ class _NoteListScreenState extends State<NoteListScreen> {
Future<void> _loadNotes() async {
final prefs = await SharedPreferences.getInstance();
notesDirectoryPath = prefs.getString('notes_directory') ??
defaultDir; // Use custom or default directory
notesDirectoryPath = prefs.getString('notes_directory') ?? defaultDir;
final notesDir = Directory(notesDirectoryPath!);
if (!notesDir.existsSync()) {
notesDir.createSync(recursive: true);
}
final files = notesDir
.listSync()
.whereType<File>()
.where((file) => file.path.endsWith('.md'))
.toList();
List<File> allNotes = [];
Map<String, DateTime> folderTimestamps = {}; // Store latest edit time per folder
void fetchNotes(Directory dir, String relativePath) {
final entries = dir.listSync(recursive: false);
for (var entry in entries) {
if (entry is File && entry.path.endsWith('.md')) {
allNotes.add(entry);
// Store latest edit time per folder
final folder = path.dirname(entry.path);
if (!folderTimestamps.containsKey(folder) ||
entry.lastModifiedSync().isAfter(folderTimestamps[folder]!)) {
folderTimestamps[folder] = entry.lastModifiedSync();
}
} else if (entry is Directory && path.basename(entry.path) != 'trash') {
fetchNotes(entry, path.join(relativePath, path.basename(entry.path)));
}
}
}
fetchNotes(notesDir, '');
// Sorting logic:
allNotes.sort((a, b) {
String folderA = path.dirname(a.path);
String folderB = path.dirname(b.path);
bool isRootA = folderA == notesDirectoryPath; // Root folder check
bool isRootB = folderB == notesDirectoryPath;
if (isRootA && !isRootB) return -1; // Root files first
if (!isRootA && isRootB) return 1; // Subfolder files later
// If both are in the root OR both are in subfolders, sort by latest edit time
DateTime timeA = folderTimestamps[folderA] ?? DateTime(0);
DateTime timeB = folderTimestamps[folderB] ?? DateTime(0);
return timeB.compareTo(timeA);
});
setState(() {
notes = files;
filteredNotes = files;
notes = allNotes;
filteredNotes = allNotes;
});
}
void _filterNotes() {
final query = searchController.text.toLowerCase();
final query = searchController.text.trim().toLowerCase();
setState(() {
filteredNotes = notes.where((note) {
final content = note.readAsStringSync().toLowerCase();
return note.uri.pathSegments.last.toLowerCase().contains(query) ||
content.contains(query);
}).toList();
if (query.startsWith('/')) {
// Extract subfolder path and search term
final parts = query.split('/');
final subfolderPath = parts.sublist(1, parts.length - 1).join('/');
final searchTerm = parts.last;
filteredNotes = notes.where((note) {
final relativePath = path.relative(note.path, from: notesDirectoryPath!);
return relativePath.startsWith(subfolderPath) &&
(relativePath.contains(searchTerm) || note.readAsStringSync().toLowerCase().contains(searchTerm));
}).toList();
} else {
// Default search across all notes
filteredNotes = notes.where((note) {
final content = note.readAsStringSync().toLowerCase();
return note.uri.pathSegments.last.toLowerCase().contains(query) || content.contains(query);
}).toList();
}
});
}
@ -188,15 +237,47 @@ class _NoteListScreenState extends State<NoteListScreen> {
void _createNewNote(String title) async {
try {
final safeTitle = _sanitizeFilename(title);
final newNote = File(path.join(notesDirectoryPath!, '$safeTitle.md'));
String notePath = title.trim();
String? subfolder;
String safeTitle;
if (notePath.startsWith('/')) {
final parts = notePath.split('/');
parts.removeWhere((element) => element.isEmpty);
if (parts.length > 1) {
subfolder = parts.sublist(0, parts.length - 1).join('/');
}
safeTitle = _sanitizeFilename(parts.last);
} else {
safeTitle = _sanitizeFilename(title);
}
// Construct the final file path
String fullPath = subfolder != null
? path.join(notesDirectoryPath!, subfolder, '$safeTitle.md')
: path.join(notesDirectoryPath!, '$safeTitle.md');
final newNote = File(fullPath);
// Prevent duplicate note creation
if (notes.any((note) => note.path == newNote.path)) {
print("❌ Note already exists at: $fullPath");
return;
}
// Ensure subfolder exists
final parentDir = newNote.parent;
if (!parentDir.existsSync()) {
parentDir.createSync(recursive: true);
}
if (!newNote.existsSync()) {
newNote.writeAsStringSync('# $title\n\n');
newNote.writeAsStringSync('# $safeTitle\n\n');
setState(() {
notes.add(newNote);
filteredNotes.add(newNote);
});
Navigator.push(
context,
MaterialPageRoute(
@ -218,8 +299,27 @@ class _NoteListScreenState extends State<NoteListScreen> {
}
bool _isNewNote(String title) {
final safeTitle = _sanitizeFilename(title);
return !notes.any((note) => note.uri.pathSegments.last == '$safeTitle.md');
String notePath = title.trim();
String? subfolder;
String safeTitle;
if (notePath.startsWith('/')) {
final parts = notePath.split('/');
parts.removeWhere((element) => element.isEmpty); // Remove empty elements
if (parts.length > 1) {
subfolder = parts.sublist(0, parts.length - 1).join('/');
}
safeTitle = _sanitizeFilename(parts.last);
} else {
safeTitle = _sanitizeFilename(title);
}
// Construct the expected file path
String fullPath = subfolder != null
? path.join(notesDirectoryPath!, subfolder, '$safeTitle.md')
: path.join(notesDirectoryPath!, '$safeTitle.md');
return !notes.any((note) => note.path == fullPath);
}
String _sanitizeFilename(String title) {
@ -229,13 +329,29 @@ class _NoteListScreenState extends State<NoteListScreen> {
}
void _deleteNote(File note) {
final trashDir = Directory('${note.parent.path}/trash');
if (!trashDir.existsSync()) {
trashDir.createSync(recursive: true);
final rootTrashDir = Directory(path.join(notesDirectoryPath!, 'trash'));
if (!rootTrashDir.existsSync()) {
rootTrashDir.createSync(recursive: true);
}
final trashedNote = File('${trashDir.path}/${note.uri.pathSegments.last}');
// Define the base trashed file path
String trashedPath = path.join(rootTrashDir.path, note.uri.pathSegments.last);
File trashedNote = File(trashedPath);
// If the file already exists in trash, append a timestamp to avoid conflicts
if (trashedNote.existsSync()) {
final timestamp = DateTime.now().millisecondsSinceEpoch;
final newFileName = '${path.basenameWithoutExtension(note.path)}_$timestamp.md';
trashedNote = File(path.join(rootTrashDir.path, newFileName));
}
// Move the file to trash
note.renameSync(trashedNote.path);
// Check if the original directory is now empty and delete it if needed
_deleteEmptyParentFolders(note.parent);
setState(() {
notes.remove(note);
filteredNotes.remove(note);
@ -258,6 +374,20 @@ class _NoteListScreenState extends State<NoteListScreen> {
);
}
// Recursively deletes empty parent folders up to the notes directory
void _deleteEmptyParentFolders(Directory folder) {
if (folder.path == notesDirectoryPath) return; // Don't delete the root folder
try {
if (folder.existsSync() && folder.listSync().isEmpty) {
folder.deleteSync();
_deleteEmptyParentFolders(folder.parent); // Recursively check parent
}
} catch (e) {
print("❌ Error deleting empty folder: $e");
}
}
Future<void> _emptyTrash() async {
final prefs = await SharedPreferences.getInstance();
final notesPath = prefs.getString('notes_directory') ??
@ -461,50 +591,61 @@ class _NoteListScreenState extends State<NoteListScreen> {
child: ListTile(
title:
Text(noteFile.uri.pathSegments.last.replaceAll('.md', '')),
subtitle: widget.showTags && tagSpans.isNotEmpty
? RichText(
subtitle: Row(
children: [
path.relative(path.dirname(noteFile.path), from: notesDirectoryPath!) != "."
? Text(
path.relative(path.dirname(noteFile.path), from: notesDirectoryPath!),
style: TextStyle(color: Colors.blueGrey),
)
: Text(""),
widget.showTags && tagSpans.isNotEmpty
? RichText(
text: TextSpan(
style: DefaultTextStyle.of(context).style,
children: tagSpans))
: null,
onTap: () async {
if (_useExternalEditor) {
// Open with system's default editor
try {
if (Platform.isLinux) {
final result = await Process.run('which', ['xdg-open']);
if (result.stdout.toString().trim().isNotEmpty) {
await Process.run('xdg-open', [noteFile.path]);
} else {
print("❌ `xdg-open` is not available.");
style: DefaultTextStyle.of(context).style,
children: tagSpans)
)
: Text("")
],
),
onTap: () async {
if (_useExternalEditor) {
// Open with system's default editor
try {
if (Platform.isLinux) {
final result = await Process.run('which', ['xdg-open']);
if (result.stdout.toString().trim().isNotEmpty) {
await Process.run('xdg-open', [noteFile.path]); // Use full path
} else {
print("❌ `xdg-open` is not available.");
}
} else if (Platform.isAndroid) {
await _requestStoragePermission();
final result = await OpenFilex.open(
noteFile.path, // Use full path
type: "text/markdown",
);
print("✅ OpenFilex result: ${result.type}, message: ${result.message}");
}
} else if (Platform.isAndroid) {
await _requestStoragePermission();
final result = await OpenFilex.open(
noteFile.path,
type: "text/markdown",
);
print("✅ OpenFilex result: ${result.type}, message: ${result.message}");
} catch (e) {
print("❌ Failed to open external editor: $e");
}
} catch (e) {
print("❌ Failed to open external editor: $e");
}
} else {
// Open internally
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NoteEditorScreen(
note: File(path.join(notesDirectoryPath!, noteFile.uri.pathSegments.last)),
isDarkMode: widget.isDarkMode,
notesDirectoryPath: notesDirectoryPath,
} else {
// Open internally in NoteEditorScreen
await Navigator.push(
context,
MaterialPageRoute(
builder: (context) => NoteEditorScreen(
note: noteFile, // Pass the full file path
isDarkMode: widget.isDarkMode,
notesDirectoryPath: notesDirectoryPath,
),
),
),
);
_loadNotes(); // Reload notes after returning
setState(() {}); // Force UI refresh
);
_loadNotes(); // Reload notes after returning
setState(() {}); // Force UI refresh
}
}
},
),
);
},