| 1 | package main |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "fmt" |
| 6 | "os" |
| 7 | "path/filepath" |
| 8 | "strings" |
| 9 | "sync" |
| 10 | |
| 11 | "reasonix/internal/fileutil" |
| 12 | ) |
| 13 | |
| 14 | const ( |
| 15 | desktopProjectOrganizationFile = "desktop-project-tree-organization.json" |
| 16 | desktopProjectOrganizationVersion = 2 |
| 17 | ) |
| 18 | |
| 19 | var desktopProjectOrganizationFileMu sync.Mutex |
| 20 | |
| 21 | // Project-tree organization lives in a sidecar older Reasonix builds do not |
| 22 | // know about and therefore cannot rewrite. desktop-projects.json retains the |
| 23 | // inline fields for one-release interoperability, while this file is the |
| 24 | // durable source when a user temporarily downgrades and the old build saves. |
| 25 | type desktopProjectOrganization struct { |
| 26 | Root string `json:"root,omitempty"` |
| 27 | ManualTopicOrder bool `json:"manualTopicOrder,omitempty"` |
| 28 | TopicOrder []string `json:"topicOrder,omitempty"` |
| 29 | ManualSessionOrder bool `json:"manualSessionOrder,omitempty"` |
| 30 | SessionOrder []string `json:"sessionOrder,omitempty"` |
| 31 | Groups []desktopGroup `json:"groups"` |
| 32 | GroupsRevision uint64 `json:"groupsRevision,omitempty"` |
| 33 | } |
| 34 | |
| 35 | type desktopProjectOrganizationFileData struct { |
| 36 | Version int `json:"version"` |
| 37 | Global desktopProjectOrganization `json:"global"` |
| 38 | Projects []desktopProjectOrganization `json:"projects"` |
| 39 | } |
| 40 | |
| 41 | func projectsFileHasOrganization(f desktopProjectFile) bool { |
| 42 | if f.GlobalManualTopicOrder || len(f.GlobalGroups) > 0 || f.GlobalGroupsRevision > 0 { |
| 43 | return true |
| 44 | } |
| 45 | for _, project := range f.Projects { |
| 46 | if project.ManualTopicOrder || len(project.Groups) > 0 || project.GroupsRevision > 0 { |
| 47 | return true |
| 48 | } |
| 49 | } |
| 50 | return false |
| 51 | } |
| 52 | |
| 53 | func organizationFromProjectsFile(f desktopProjectFile) desktopProjectOrganizationFileData { |
| 54 | out := desktopProjectOrganizationFileData{ |
| 55 | Version: desktopProjectOrganizationVersion, |
| 56 | Global: desktopProjectOrganization{ |
| 57 | ManualTopicOrder: f.GlobalManualTopicOrder, |
| 58 | TopicOrder: append([]string(nil), f.GlobalTopics...), |
| 59 | ManualSessionOrder: f.GlobalManualSessionOrder, |
| 60 | SessionOrder: append([]string(nil), f.GlobalSessionOrder...), |
| 61 | Groups: normalizeGroups(f.GlobalGroups), |
| 62 | GroupsRevision: f.GlobalGroupsRevision, |
| 63 | }, |
| 64 | Projects: make([]desktopProjectOrganization, 0, len(f.Projects)), |
| 65 | } |
| 66 | for _, project := range f.Projects { |
| 67 | out.Projects = append(out.Projects, desktopProjectOrganization{ |
| 68 | Root: project.Root, |
| 69 | ManualTopicOrder: project.ManualTopicOrder, |
| 70 | TopicOrder: append([]string(nil), project.Topics...), |
| 71 | ManualSessionOrder: project.ManualSessionOrder, |
| 72 | SessionOrder: append([]string(nil), project.SessionOrder...), |
| 73 | Groups: normalizeGroups(project.Groups), |
| 74 | GroupsRevision: project.GroupsRevision, |
| 75 | }) |
| 76 | } |
| 77 | return out |
| 78 | } |
| 79 | |
| 80 | func applyPersistedTopicOrder(current, persisted []string) []string { |
| 81 | available := make(map[string]bool, len(current)) |
| 82 | for _, topicID := range current { |
| 83 | available[topicID] = true |
| 84 | } |
| 85 | seen := make(map[string]bool, len(current)) |
| 86 | out := make([]string, 0, len(current)) |
| 87 | for _, topicID := range persisted { |
| 88 | topicID = strings.TrimSpace(topicID) |
| 89 | if topicID == "" || !available[topicID] || seen[topicID] { |
| 90 | continue |
| 91 | } |
| 92 | seen[topicID] = true |
| 93 | out = append(out, topicID) |
| 94 | } |
| 95 | for _, topicID := range current { |
| 96 | if !seen[topicID] { |
| 97 | seen[topicID] = true |
| 98 | out = append(out, topicID) |
| 99 | } |
| 100 | } |
| 101 | return out |
| 102 | } |
| 103 | |
| 104 | func groupsWithoutDeletedTopics(groups []desktopGroup, deleted map[string]bool) []desktopGroup { |
| 105 | out := normalizeGroups(groups) |
| 106 | for index := range out { |
| 107 | kept := out[index].TopicIDs[:0] |
| 108 | for _, topicID := range out[index].TopicIDs { |
| 109 | if !deleted[topicID] { |
| 110 | kept = append(kept, topicID) |
| 111 | } |
| 112 | } |
| 113 | out[index].TopicIDs = kept |
| 114 | } |
| 115 | return out |
| 116 | } |
| 117 | |
| 118 | func applyProjectOrganization(f desktopProjectFile, organization desktopProjectOrganizationFileData) desktopProjectFile { |
| 119 | deleted := make(map[string]bool, len(f.DeletedTopics)) |
| 120 | for _, topicID := range f.DeletedTopics { |
| 121 | deleted[topicID] = true |
| 122 | } |
| 123 | f.GlobalManualTopicOrder = organization.Global.ManualTopicOrder |
| 124 | f.GlobalManualSessionOrder = organization.Global.ManualSessionOrder |
| 125 | f.GlobalSessionOrder = append([]string(nil), organization.Global.SessionOrder...) |
| 126 | if f.GlobalManualTopicOrder { |
| 127 | f.GlobalTopics = applyPersistedTopicOrder(f.GlobalTopics, organization.Global.TopicOrder) |
| 128 | } |
| 129 | f.GlobalGroups = groupsWithoutDeletedTopics(organization.Global.Groups, deleted) |
| 130 | f.GlobalGroupsRevision = organization.Global.GroupsRevision |
| 131 | for _, persisted := range organization.Projects { |
| 132 | index := projectIndexByRoot(f.Projects, persisted.Root) |
| 133 | if index < 0 { |
| 134 | continue |
| 135 | } |
| 136 | project := &f.Projects[index] |
| 137 | project.ManualTopicOrder = persisted.ManualTopicOrder |
| 138 | project.ManualSessionOrder = persisted.ManualSessionOrder |
| 139 | project.SessionOrder = append([]string(nil), persisted.SessionOrder...) |
| 140 | if project.ManualTopicOrder { |
| 141 | project.Topics = applyPersistedTopicOrder(project.Topics, persisted.TopicOrder) |
| 142 | } |
| 143 | project.Groups = groupsWithoutDeletedTopics(persisted.Groups, deleted) |
| 144 | project.GroupsRevision = persisted.GroupsRevision |
| 145 | } |
| 146 | return normalizeProjectsFile(f) |
| 147 | } |
| 148 | |
| 149 | func loadProjectOrganizationFile() (desktopProjectOrganizationFileData, bool) { |
| 150 | path := filepath.Join(desktopConfigDir(), desktopProjectOrganizationFile) |
| 151 | b, err := readFileUTF8(path) |
| 152 | if err != nil { |
| 153 | return desktopProjectOrganizationFileData{}, false |
| 154 | } |
| 155 | var organization desktopProjectOrganizationFileData |
| 156 | if json.Unmarshal(b, &organization) != nil || organization.Version < 1 || organization.Version > desktopProjectOrganizationVersion { |
| 157 | return desktopProjectOrganizationFileData{}, false |
| 158 | } |
| 159 | if organization.Global.Groups == nil { |
| 160 | organization.Global.Groups = []desktopGroup{} |
| 161 | } |
| 162 | return organization, true |
| 163 | } |
| 164 | |
| 165 | func saveProjectOrganizationFile(f desktopProjectFile) error { |
| 166 | desktopProjectOrganizationFileMu.Lock() |
| 167 | defer desktopProjectOrganizationFileMu.Unlock() |
| 168 | |
| 169 | dir := desktopConfigDir() |
| 170 | if err := os.MkdirAll(dir, 0o755); err != nil { |
| 171 | return err |
| 172 | } |
| 173 | path := filepath.Join(dir, desktopProjectOrganizationFile) |
| 174 | if existing, err := readFileUTF8(path); err == nil { |
| 175 | var header struct { |
| 176 | Version int `json:"version"` |
| 177 | } |
| 178 | if json.Unmarshal(existing, &header) == nil && header.Version > desktopProjectOrganizationVersion { |
| 179 | return fmt.Errorf("project organization version %d is newer than supported version %d", header.Version, desktopProjectOrganizationVersion) |
| 180 | } |
| 181 | } |
| 182 | b, err := json.MarshalIndent(organizationFromProjectsFile(f), "", " ") |
| 183 | if err != nil { |
| 184 | return err |
| 185 | } |
| 186 | tmp := path + ".tmp" |
| 187 | if err := os.WriteFile(tmp, b, 0o644); err != nil { |
| 188 | return err |
| 189 | } |
| 190 | return fileutil.ReplaceFile(tmp, path) |
| 191 | } |
| 192 |