import '../l10n/tr.dart'; import 'dart:math' as math; /// 즐겨찾기 항목 (주소) 또는 폴더 (children 이 있음) class BookmarkNode { final String id; /// 저장된 이름 그대로 (기본 즐겨찾기는 한국어 열쇠) String rawTitle; String? url; final List? children; BookmarkNode.link(this.id, this.rawTitle, String this.url) : children = null; BookmarkNode.folder(this.id, this.rawTitle, [List? items]) : url = null, children = items ?? []; /// 화면에 보일 이름. 61: 기본 즐겨찾기 ("YouTube 구독" · 표시줄 · 기타) 는 이름을 바꾸지 않았으면 지금 화면 언어로 /// (처음 켤 때의 언어로 저장돼 그 뒤 언어를 바꿔도 그대로였다). 사용자가 바꾼 이름은 그대로. String get title { final names = _defaultNames[id]; return names != null && names.contains(rawTitle) ? tr(names.first) : rawTitle; } set title(String v) => rawTitle = v; /// 기본 즐겨찾기의 이름: 한국어 열쇠 (맨 앞) 와 예전 판이 저장했을 수 있는 번역 (en · ja · zh-Hans) static const _defaultNames = { 'b2': ['YouTube 구독', 'YouTube subscriptions', 'YouTube 登録チャンネル', 'YouTube 订阅'], BookmarkTree.barId: ['즐겨찾기 표시줄', 'Bookmarks bar', 'ブックマークバー', '书签栏'], BookmarkTree.otherId: ['기타 즐겨찾기', 'Other bookmarks', 'その他のブックマーク', '其他书签'], }; bool get isFolder => children != null; Map toJson() => { 'id': id, 'title': rawTitle, if (url != null) 'url': url, if (children != null) 'children': [for (final c in children!) c.toJson()], }; static BookmarkNode fromJson(Map j) { final kids = j['children'] as List?; return kids != null ? BookmarkNode.folder(j['id'] as String, j['title'] as String? ?? '', [for (final k in kids) fromJson(k as Map)]) : BookmarkNode.link(j['id'] as String, j['title'] as String? ?? '', j['url'] as String? ?? ''); } } /// 즐겨찾기 전체. [bar] = 즐겨찾기 표시줄 (브라우저 위쪽 줄), [other] = 기타 즐겨찾기 class BookmarkTree { final BookmarkNode bar; final BookmarkNode other; int _seq; BookmarkTree(this.bar, this.other, [this._seq = 1000]); static const barId = 'bar', otherId = 'other'; /// 처음 쓰는 경우 기본 즐겨찾기 factory BookmarkTree.defaults() => BookmarkTree( // 이름은 한국어 열쇠로 저장하고 보일 때 화면 언어로 (BookmarkNode.title) BookmarkNode.folder(barId, '즐겨찾기 표시줄', [ BookmarkNode.link('b1', 'YouTube', 'https://www.youtube.com/'), BookmarkNode.link('b2', 'YouTube 구독', 'https://www.youtube.com/feed/subscriptions'), BookmarkNode.link('b3', 'OpenSubtitles', 'https://www.opensubtitles.com/'), ]), BookmarkNode.folder(otherId, '기타 즐겨찾기'), ); String newId() => 'n${++_seq}${math.Random().nextInt(1 << 20)}'; List get roots => [bar, other]; Map toJson() => {'seq': _seq, 'bar': bar.toJson(), 'other': other.toJson()}; factory BookmarkTree.fromJson(Map j) => BookmarkTree( BookmarkNode.fromJson(j['bar'] as Map), BookmarkNode.fromJson(j['other'] as Map), (j['seq'] as num?)?.toInt() ?? 1000, ); BookmarkNode? find(String id) { BookmarkNode? walk(BookmarkNode n) { if (n.id == id) return n; for (final c in n.children ?? const []) { final r = walk(c); if (r != null) return r; } return null; } for (final r in roots) { final f = walk(r); if (f != null) return f; } return null; } BookmarkNode? parentOf(String id) { BookmarkNode? walk(BookmarkNode n) { for (final c in n.children ?? const []) { if (c.id == id) return n; final r = walk(c); if (r != null) return r; } return null; } for (final r in roots) { final f = walk(r); if (f != null) return f; } return null; } /// [url] 과 같은 주소의 즐겨찾기 (끝의 / 무시) BookmarkNode? findByUrl(String url) { String norm(String u) => u.trim().replaceFirst(RegExp(r'/+$'), ''); final target = norm(url); BookmarkNode? walk(BookmarkNode n) { if (n.url != null && norm(n.url!) == target) return n; for (final c in n.children ?? const []) { final r = walk(c); if (r != null) return r; } return null; } for (final r in roots) { final f = walk(r); if (f != null) return f; } return null; } /// 폴더 [parentId] 의 [index] 위치에 추가 (없으면 맨 뒤) BookmarkNode add(String parentId, BookmarkNode node, {int? index}) { final parent = find(parentId); if (parent == null || !parent.isFolder) throw ArgumentError(trf('폴더가 아닙니다: {0}', [parentId])); final list = parent.children!; list.insert((index ?? list.length).clamp(0, list.length), node); return node; } /// 삭제. 되살리기용으로 (노드, 부모 id, 위치) 반환. 기본 폴더는 지울 수 없음. (BookmarkNode, String, int)? remove(String id) { if (id == barId || id == otherId) return null; final parent = parentOf(id); if (parent == null) return null; final i = parent.children!.indexWhere((c) => c.id == id); return (parent.children!.removeAt(i), parent.id, i); } /// [id] 가 [ancestorId] 의 안쪽(자손)인지 bool isInside(String id, String ancestorId) { final a = find(ancestorId); if (a == null) return false; bool walk(BookmarkNode n) => n.children?.any((c) => c.id == id || walk(c)) ?? false; return walk(a); } /// 이동 (같은 폴더 안 순서 바꾸기 포함). 폴더를 자기 안으로 옮길 수는 없음. bool move(String id, String newParentId, int index) { if (id == barId || id == otherId) return false; if (id == newParentId || isInside(newParentId, id)) return false; final target = find(newParentId); if (target == null || !target.isFolder) return false; final from = parentOf(id); if (from == null) return false; final oldIndex = from.children!.indexWhere((c) => c.id == id); final node = from.children!.removeAt(oldIndex); var i = index; if (identical(from, target) && oldIndex < index) i--; // 같은 폴더에서 뒤로 옮길 때 target.children!.insert(i.clamp(0, target.children!.length), node); return true; } /// 모든 폴더 (이동 대상 고르기용): (폴더, 깊이) List<(BookmarkNode, int)> folders() { final out = <(BookmarkNode, int)>[]; void walk(BookmarkNode n, int depth) { if (!n.isFolder) return; out.add((n, depth)); for (final c in n.children!) { walk(c, depth + 1); } } for (final r in roots) { walk(r, 0); } return out; } /// [id] 까지의 경로 (최상위 폴더부터 [id] 자신까지). 없으면 빈 목록. List pathTo(String id) { final out = []; bool walk(BookmarkNode n) { out.add(n); if (n.id == id) return true; for (final c in n.children ?? const []) { if (walk(c)) return true; } out.removeLast(); return false; } for (final r in roots) { if (walk(r)) return out; } return const []; } /// 이름 · 주소로 찾기 (대소문자 무시). 폴더도 이름으로 찾는다. List search(String query) { final q = query.trim().toLowerCase(); if (q.isEmpty) return const []; final out = []; void walk(BookmarkNode n) { for (final c in n.children ?? const []) { if (c.title.toLowerCase().contains(q) || (c.url?.toLowerCase().contains(q) ?? false)) out.add(c); walk(c); } } for (final r in roots) { walk(r); } return out; } /// 폴더 안 주소 수 (하위 폴더 포함) int countLinks(BookmarkNode n) => n.isFolder ? n.children!.fold(0, (s, c) => s + countLinks(c)) : 1; /// 브라우저 공통 "즐겨찾기 HTML" (Netscape 형식) 로 내보내기 - Chrome · Edge · Firefox 에서 가져올 수 있다 String toNetscapeHtml() { String esc(String s) => s.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"'); final b = StringBuffer() ..writeln('') ..writeln('') ..writeln('Bookmarks') ..writeln('

Bookmarks

') ..writeln('

'); void walk(BookmarkNode n, String indent, {bool toolbar = false}) { if (n.isFolder) { b.writeln('$indent

${esc(n.title)}'); b.writeln('$indent

'); for (final c in n.children!) { walk(c, '$indent '); } b.writeln('$indent

'); } else { b.writeln('$indent

${esc(n.title)}'); } } walk(bar, ' ', toolbar: true); walk(other, ' '); b.writeln('

'); return b.toString(); } /// "즐겨찾기 HTML" 가져오기 → [folderTitle] 폴더 (기타 즐겨찾기 안) 에. 가져온 주소 수. int importNetscapeHtml(String html, String folderTitle) { String unesc(String s) => s .replaceAll('<', '<') .replaceAll('>', '>') .replaceAll('"', '"') .replaceAll(''', "'") .replaceAll('&', '&'); final root = BookmarkNode.folder(newId(), folderTitle); final stack = [root]; BookmarkNode? pendingFolder; var count = 0; final token = RegExp(r'

]*>(.*?)|
]*HREF="([^"]*)"[^>]*>(.*?)|
|
', caseSensitive: false, dotAll: true); for (final m in token.allMatches(html)) { final t = m.group(0)!.toUpperCase(); if (m.group(1) != null) { pendingFolder = BookmarkNode.folder(newId(), unesc(m.group(1)!.trim())); stack.last.children!.add(pendingFolder); } else if (m.group(2) != null) { final url = unesc(m.group(2)!); if (!url.startsWith('http')) continue; final title = unesc(m.group(3)!.replaceAll(RegExp(r'<[^>]*>'), '').trim()); stack.last.children!.add(BookmarkNode.link(newId(), title.isEmpty ? url : title, url)); count++; } else if (t.startsWith(' 은 문서 전체, 그 뒤는 바로 앞 폴더의 내용 if (pendingFolder != null) { stack.add(pendingFolder); pendingFolder = null; } } else if (t.startsWith(' 1) { stack.removeLast(); } } if (count > 0) other.children!.add(root); return count; } /// Chrome · Edge 의 "Bookmarks" 파일 (JSON) 을 [folderTitle] 폴더로 가져오기. 가져온 주소 수 반환. int importChromium(Map json, String folderTitle) { final rootsJ = json['roots'] as Map? ?? const {}; final folder = BookmarkNode.folder(newId(), folderTitle); var count = 0; BookmarkNode? conv(Map n) { if (n['type'] == 'url') { final u = n['url'] as String? ?? ''; if (!u.startsWith('http')) return null; count++; return BookmarkNode.link(newId(), n['name'] as String? ?? u, u); } if (n['type'] == 'folder') { final f = BookmarkNode.folder(newId(), n['name'] as String? ?? tr('폴더')); for (final c in (n['children'] as List? ?? const [])) { final x = conv(c as Map); if (x != null) f.children!.add(x); } return f; } return null; } for (final key in ['bookmark_bar', 'other', 'synced']) { final r = rootsJ[key]; if (r is! Map) continue; final f = conv(r); if (f != null && f.children!.isNotEmpty) folder.children!.add(f); } if (count > 0) other.children!.add(folder); return count; } }