frindlymap/lib/modules/groups/src/group_book.dart

263 lines
8.4 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import 'dart:convert';
import 'group.dart';
import 'pin.dart';
/// 그룹 나무 전체 (순서·하위 그룹·구성원·잠금·다시 잠그는 시간). 글자(JSON)로 저장했다가 되살린다.
///
/// 같은 부모 아래의 순서는 [_groups] 에 놓인 순서를 따른다.
class GroupBook {
GroupBook({List<Group>? groups, this.relockMinutes = defaultRelockMinutes, int nextId = 1})
: _groups = [...(groups ?? Group.defaults())],
_next = nextId;
static const defaultRelockMinutes = 5;
static const maxNameLength = 20;
/// 가장 깊은 단계 (맨 위가 1).
static const maxDepth = 4;
final List<Group> _groups;
int _next;
/// 잠긴 그룹을 연 뒤 이 시간(분)이 지나면 다시 잠근다.
int relockMinutes;
/// 나무 순서(부모 다음에 그 자식들)로 펼친 전체 목록.
List<Group> get groups {
final out = <Group>[];
void walk(String? parent) {
for (final g in _groups) {
if (g.parent == parent) {
out.add(g);
walk(g.id);
}
}
}
walk(null);
return List.unmodifiable(out);
}
List<Group> get roots => children(null);
/// [id] 바로 아래 그룹들 (null 이면 맨 위 그룹들).
List<Group> children(String? id) => [for (final g in _groups) if (g.parent == id) g];
Group? byId(String id) {
for (final g in _groups) {
if (g.id == id) return g;
}
return null;
}
/// 위쪽 그룹들 (가까운 부모부터).
List<Group> ancestors(String id) {
final out = <Group>[];
var p = byId(id)?.parent;
while (p != null) {
final g = byId(p);
if (g == null || out.contains(g)) break;
out.add(g);
p = g.parent;
}
return out;
}
int depth(String id) => ancestors(id).length + 1;
/// "학교 › 고등학교" 처럼 맨 위부터 이어 쓴 이름.
String path(String id) => [...ancestors(id).reversed.map((g) => g.name), byId(id)?.name ?? ''].join(' › ');
/// 자신과 모든 하위 그룹의 id.
Set<String> subtree(String id) {
final out = <String>{};
void walk(String x) {
if (!out.add(x)) return;
for (final c in children(x)) {
walk(c.id);
}
}
if (byId(id) != null) walk(id);
return out;
}
/// 자신과 하위 그룹 구성원 모두.
Set<String> membersDeep(String id) => {for (final x in subtree(id)) ...byId(x)!.members};
/// 이름을 다듬는다. 비었거나 같은 부모 아래에 이미 있는 이름이면 null.
String? _cleanName(String name, String? parent, {String? except}) {
final n = name.trim();
if (n.isEmpty) return null;
final cut = n.length > maxNameLength ? n.substring(0, maxNameLength) : n;
for (final g in _groups) {
if (g.parent == parent && g.id != except && g.name == cut) return null;
}
return cut;
}
/// 새 그룹을 [parent] 아래 맨 뒤에 더한다. 이름이 비었거나 겹치거나, 부모가 없거나 너무 깊으면 null.
Group? add(String name, String kind, {String? parent}) {
if (parent != null && (byId(parent) == null || depth(parent) >= maxDepth)) return null;
final n = _cleanName(name, parent);
if (n == null) return null;
var id = 'g${_next++}';
while (byId(id) != null) {
id = 'g${_next++}';
}
final g = Group(id: id, name: n, kind: kind, parent: parent);
_groups.add(g);
return g;
}
bool rename(String id, String name) {
final i = _index(id);
if (i < 0) return false;
final n = _cleanName(name, _groups[i].parent, except: id);
if (n == null) return false;
_groups[i] = _groups[i].copyWith(name: n);
return true;
}
bool setKind(String id, String kind) {
final i = _index(id);
if (i < 0) return false;
_groups[i] = _groups[i].copyWith(kind: kind);
return true;
}
/// 그룹과 그 하위 그룹을 모두 지운다 (사람은 그대로 남는다). 지운 id 들.
Set<String> remove(String id) {
final gone = subtree(id);
_groups.removeWhere((g) => gone.contains(g.id));
return gone;
}
/// [id] 그룹을 같은 부모 아래 [to] 번째로 옮긴다.
bool move(String id, int to) {
final g = byId(id);
if (g == null) return false;
_groups.remove(g);
final sib = children(g.parent);
if (sib.isEmpty || to >= sib.length) {
_groups.add(g);
} else {
_groups.insert(_groups.indexOf(sib[to < 0 ? 0 : to]), g);
}
return true;
}
bool setLock(String id, PinHash? lock) {
final i = _index(id);
if (i < 0) return false;
_groups[i] = _groups[i].withLock(lock);
return true;
}
/// 구성원을 더한다 (이전 구성원이었으면 다시 구성원으로). 새로 들어간 수를 돌려준다.
int addMembers(String id, Iterable<String> people) {
final i = _index(id);
if (i < 0) return 0;
final m = {..._groups[i].members};
final before = m.length;
m.addAll(people);
_groups[i] = _groups[i].copyWith(members: m, formers: {..._groups[i].formers}..removeAll(people));
return m.length - before;
}
bool removeMember(String id, String person) {
final i = _index(id);
if (i < 0) return false;
final m = {..._groups[i].members};
final ok = m.remove(person);
if (ok) _groups[i] = _groups[i].copyWith(members: m);
return ok;
}
/// 구성원을 이전 구성원으로 돌린다 (흐리게 남김). 구성원이 아니었으면 false.
bool markFormer(String id, String person) {
final i = _index(id);
if (i < 0 || !_groups[i].members.contains(person)) return false;
_groups[i] = _groups[i].copyWith(
members: {..._groups[i].members}..remove(person),
formers: {..._groups[i].formers, person},
);
return true;
}
/// 이전 구성원 표시를 지운다.
bool removeFormer(String id, String person) {
final i = _index(id);
if (i < 0 || !_groups[i].formers.contains(person)) return false;
_groups[i] = _groups[i].copyWith(formers: {..._groups[i].formers}..remove(person));
return true;
}
/// 사람을 지울 때 모든 그룹에서 뺀다 (이전 구성원 표시도).
void forgetPerson(String person) {
for (var i = 0; i < _groups.length; i++) {
final g = _groups[i];
if (g.members.contains(person) || g.formers.contains(person)) {
_groups[i] = g.copyWith(members: {...g.members}..remove(person), formers: {...g.formers}..remove(person));
}
}
}
/// 이 사람이 들어 있는 그룹들 (나무 순서).
List<Group> groupsOf(String person) => [for (final g in groups) if (g.members.contains(person)) g];
/// 잠긴 그룹 중 [closed] 인 것(지금 열려 있지 않은 것)과 그 하위 그룹의 구성원.
/// 다른 그룹에도 있어도 잠긴 쪽을 따른다.
Set<String> hiddenPeople(bool Function(Group g) closed) => {
for (final g in _groups)
if (g.locked && closed(g)) ...{
...membersDeep(g.id),
for (final x in subtree(g.id)) ...byId(x)!.formers,
},
};
int _index(String id) => _groups.indexWhere((g) => g.id == id);
String toJson() => jsonEncode({
'v': 2,
'next': _next,
'relock': relockMinutes,
'groups': [for (final g in _groups) g.toJson()],
});
/// 비었거나 깨진 글자면 처음 그룹들로 시작한다. 없는 부모나 돌고 도는 부모는 맨 위로 올린다.
factory GroupBook.fromJson(String? text) {
if (text == null || text.isEmpty) return GroupBook();
try {
final j = jsonDecode(text);
if (j is! Map || j['groups'] is! List) return GroupBook();
final list = <Group>[];
final seen = <String>{};
for (final x in j['groups'] as List) {
final g = Group.fromJson(x);
if (g != null && seen.add(g.id)) list.add(g);
}
final byId = {for (final g in list) g.id: g};
for (var i = 0; i < list.length; i++) {
final chain = <String>{list[i].id};
var p = list[i].parent;
while (p != null && byId.containsKey(p) && chain.add(p)) {
p = byId[p]!.parent;
}
// 맨 위까지 올라가지 못했으면(없는 부모, 돌고 도는 부모) 맨 위로.
if (p != null) list[i] = list[i].withParent(null);
}
final relock = j['relock'];
final next = j['next'];
return GroupBook(
groups: list,
relockMinutes: relock is int ? relock.clamp(1, 60) : defaultRelockMinutes,
nextId: next is int && next > 0 ? next : list.length + 1,
);
} on FormatException {
return GroupBook();
}
}
}