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

@ -58,8 +58,10 @@ class _NoteEditorScreenState extends State<NoteEditorScreen> {
try { try {
final notesDirectory = widget.notesDirectoryPath ?? final notesDirectory = widget.notesDirectoryPath ??
widget.note.parent.path; // Use correct directory widget.note.parent.path; // Use correct directory
final newFilePath =
path.join(notesDirectory, widget.note.uri.pathSegments.last); // Get the full path of the original file, preserving subfolder structure
final relativeNotePath = path.relative(widget.note.path, from: notesDirectory);
final newFilePath = path.join(notesDirectory, relativeNotePath); // Preserve subfolder
print("📝 Attempting to save note at: $newFilePath"); print("📝 Attempting to save note at: $newFilePath");
final newFile = File(newFilePath); final newFile = File(newFilePath);

View file

@ -139,33 +139,82 @@ class _NoteListScreenState extends State<NoteListScreen> {
Future<void> _loadNotes() async { Future<void> _loadNotes() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
notesDirectoryPath = prefs.getString('notes_directory') ?? notesDirectoryPath = prefs.getString('notes_directory') ?? defaultDir;
defaultDir; // Use custom or default directory
final notesDir = Directory(notesDirectoryPath!); final notesDir = Directory(notesDirectoryPath!);
if (!notesDir.existsSync()) { if (!notesDir.existsSync()) {
notesDir.createSync(recursive: true); notesDir.createSync(recursive: true);
} }
final files = notesDir List<File> allNotes = [];
.listSync() Map<String, DateTime> folderTimestamps = {}; // Store latest edit time per folder
.whereType<File>()
.where((file) => file.path.endsWith('.md')) void fetchNotes(Directory dir, String relativePath) {
.toList(); 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(() { setState(() {
notes = files; notes = allNotes;
filteredNotes = files; filteredNotes = allNotes;
}); });
} }
void _filterNotes() { void _filterNotes() {
final query = searchController.text.toLowerCase(); final query = searchController.text.trim().toLowerCase();
setState(() { setState(() {
filteredNotes = notes.where((note) { if (query.startsWith('/')) {
final content = note.readAsStringSync().toLowerCase(); // Extract subfolder path and search term
return note.uri.pathSegments.last.toLowerCase().contains(query) || final parts = query.split('/');
content.contains(query); final subfolderPath = parts.sublist(1, parts.length - 1).join('/');
}).toList(); 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 { void _createNewNote(String title) async {
try { try {
final safeTitle = _sanitizeFilename(title); String notePath = title.trim();
final newNote = File(path.join(notesDirectoryPath!, '$safeTitle.md')); 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()) { if (!newNote.existsSync()) {
newNote.writeAsStringSync('# $title\n\n'); newNote.writeAsStringSync('# $safeTitle\n\n');
setState(() { setState(() {
notes.add(newNote); notes.add(newNote);
filteredNotes.add(newNote); filteredNotes.add(newNote);
}); });
Navigator.push( Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
@ -218,8 +299,27 @@ class _NoteListScreenState extends State<NoteListScreen> {
} }
bool _isNewNote(String title) { bool _isNewNote(String title) {
final safeTitle = _sanitizeFilename(title); String notePath = title.trim();
return !notes.any((note) => note.uri.pathSegments.last == '$safeTitle.md'); 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) { String _sanitizeFilename(String title) {
@ -229,13 +329,29 @@ class _NoteListScreenState extends State<NoteListScreen> {
} }
void _deleteNote(File note) { void _deleteNote(File note) {
final trashDir = Directory('${note.parent.path}/trash'); final rootTrashDir = Directory(path.join(notesDirectoryPath!, 'trash'));
if (!trashDir.existsSync()) {
trashDir.createSync(recursive: true); 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); note.renameSync(trashedNote.path);
// Check if the original directory is now empty and delete it if needed
_deleteEmptyParentFolders(note.parent);
setState(() { setState(() {
notes.remove(note); notes.remove(note);
filteredNotes.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 { Future<void> _emptyTrash() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final notesPath = prefs.getString('notes_directory') ?? final notesPath = prefs.getString('notes_directory') ??
@ -461,50 +591,61 @@ class _NoteListScreenState extends State<NoteListScreen> {
child: ListTile( child: ListTile(
title: title:
Text(noteFile.uri.pathSegments.last.replaceAll('.md', '')), Text(noteFile.uri.pathSegments.last.replaceAll('.md', '')),
subtitle: widget.showTags && tagSpans.isNotEmpty subtitle: Row(
? RichText( 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( text: TextSpan(
style: DefaultTextStyle.of(context).style, style: DefaultTextStyle.of(context).style,
children: tagSpans)) children: tagSpans)
: null, )
onTap: () async { : Text("")
if (_useExternalEditor) { ],
// Open with system's default editor ),
try { onTap: () async {
if (Platform.isLinux) { if (_useExternalEditor) {
final result = await Process.run('which', ['xdg-open']); // Open with system's default editor
if (result.stdout.toString().trim().isNotEmpty) { try {
await Process.run('xdg-open', [noteFile.path]); if (Platform.isLinux) {
} else { final result = await Process.run('which', ['xdg-open']);
print("❌ `xdg-open` is not available."); 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) { } catch (e) {
await _requestStoragePermission(); print("❌ Failed to open external editor: $e");
final result = await OpenFilex.open(
noteFile.path,
type: "text/markdown",
);
print("✅ OpenFilex result: ${result.type}, message: ${result.message}");
} }
} catch (e) { } else {
print("❌ Failed to open external editor: $e"); // Open internally in NoteEditorScreen
} await Navigator.push(
} else { context,
// Open internally MaterialPageRoute(
await Navigator.push( builder: (context) => NoteEditorScreen(
context, note: noteFile, // Pass the full file path
MaterialPageRoute( isDarkMode: widget.isDarkMode,
builder: (context) => NoteEditorScreen( notesDirectoryPath: notesDirectoryPath,
note: File(path.join(notesDirectoryPath!, noteFile.uri.pathSegments.last)), ),
isDarkMode: widget.isDarkMode,
notesDirectoryPath: notesDirectoryPath,
), ),
), );
); _loadNotes(); // Reload notes after returning
_loadNotes(); // Reload notes after returning setState(() {}); // Force UI refresh
setState(() {}); // Force UI refresh }
} }
},
), ),
); );
}, },