Coffee Time

Changes On Branch wiki
Login

Changes On Branch wiki

Many hyperlinks are disabled.
Use anonymous login to enable hyperlinks.

Changes In Branch wiki Excluding Merge-Ins

This is equivalent to a diff from ad120f4256 to c6f863dd80

2024-08-04
21:11
Merged back links check-in: c743e4b553 user: scstarkey tags: main, trunk
21:11
Back links Closed-Leaf check-in: c6f863dd80 user: scstarkey tags: wiki
2024-08-02
14:40
Brought fixes back in from main check-in: 993637cf98 user: scstarkey tags: wiki
14:31
Switched to basic from standard check-in: ad120f4256 user: scstarkey tags: main, trunk
14:03
Switching to NIOFSDirectory to see if it'll start up in podman container check-in: a2f0bd53de user: scstarkey tags: main, trunk

Changes to src/main/java/app/coffeetime/SearchInterface.java.

1
2
3
4
5
6
7
8
9
10
11

12
13
14
15
16
17


18
19
20
21
22
23
24
25
26
27
28
29
30

31
32
33
34
35
















36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54


55

56
57

58
59
60
61
62
63
64
65
66
67
1
2
3
4
5
6
7
8
9
10

11



12


13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64

65


66
67

68
69

70



71
72
73
74
75
76
77










-
+
-
-
-

-
-
+
+













+





+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+















-

-
-
+
+
-
+

-
+
-
-
-







package app.coffeetime;

import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.core.WhitespaceAnalyzer;
import org.apache.lucene.analysis.miscellaneous.PerFieldAnalyzerWrapper;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.document.Document;
import org.apache.lucene.document.Field;
import org.apache.lucene.document.TextField;
import org.apache.lucene.index.*;
import org.apache.lucene.search.BooleanClause;
import org.apache.lucene.search.*;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.IndexSearcher;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.store.Directory;
import org.apache.lucene.store.FSDirectory;
import org.apache.lucene.store.NIOFSDirectory;
import org.apache.lucene.store.NIOFSDirectory;
import org.apache.lucene.util.QueryBuilder;
import org.jetbrains.annotations.NotNull;
import org.pcollections.*;
import org.slf4j.Logger;

import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.Optional;
import java.util.UUID;
import java.util.function.Function;
import java.util.function.Supplier;
import java.util.stream.Collectors;
import java.util.stream.Stream;

import static app.coffeetime.util.Logging.logSupplier;

public abstract class SearchInterface {
    private static final Supplier<Logger> LOG = logSupplier(SearchInterface.class);

    public record SearchField(String name, Object value, boolean stored, boolean analyzed) {
        public SearchField(final String name, final Object value) {
            this(name, value, false, false);
        }
    }

    public record SearchResult(PMap<String, SearchField> searchFields) {
    }

    public record SearchResults(long totalRecords,
                                PVector<SearchResult> searchResults) {
    }

    public static final SearchResults EMPTY_SEARCH_RESULTS = new SearchResults(0, TreePVector.empty());

    public static final SearchInterface SEARCH_NOOP = new SearchInterface() {
        @Override
        public void save(final PVector<SearchField> fields) {

        }

        @Override
        public void delete(final SearchField field) {

        }

        @Override
        public Optional<SearchResult> findDocument(final TreePVector<SearchField> searchFields) {
            return Optional.empty();
        }
    };

    public record SearchField(String name, Object value, boolean stored) {
        public SearchField(final String name, final Object value) {
        @Override
        public SearchResults findDocuments(final TreePVector<SearchField> searchFields, int limit) {
            this(name, value, false);
            return EMPTY_SEARCH_RESULTS;
        }
    }
    };

    public record SearchResult(PMap<String, SearchField> searchFields) {
    }

    public static SearchInterface disk(final String path,
                                       final PVector<String> notAnalyzedFields) {
        try {
            final var indexPath = Path.of(path);
            final var indexFile = indexPath.toFile();

117
118
119
120
121
122
123

124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142

143
144
145
146


147
148
149
150
151

152




153
154
155
156
157









158
159

160
161
162
163
164
165
166





167
168





169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188




127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152

153
154
155


156
157

158
159
160

161
162
163
164
165
166





167
168
169
170
171
172
173
174
175
176

177
178
179
180
181
182
183
184
185
186
187
188
189
190

191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214

215
216
217
218







+


















-
+


-
-
+
+
-



-
+

+
+
+
+
-
-
-
-
-
+
+
+
+
+
+
+
+
+

-
+







+
+
+
+
+

-
+
+
+
+
+



















-
+
+
+
+
            return new SearchInterface() {
                @Override
                public void save(final PVector<SearchField> fields) {
                    try {
                        final var document = new Document();
                        fields.forEach(field -> document.add(new Field(field.name(), String.valueOf(field.value()),
                                field.stored() ? TextField.TYPE_STORED : TextField.TYPE_NOT_STORED)));

                        try (final var writer = writerSupplier.get()) {
                            writer.addDocument(document);
                        }
                    } catch (IOException e) {
                        throw new IllegalStateException(e);
                    }
                }

                @Override
                public void delete(final SearchField field) {
                    try (final var writer = writerSupplier.get()) {
                        writer.deleteDocuments(term(field));
                    } catch (IOException e) {
                        throw new IllegalStateException(e);
                    }
                }

                @Override
                public Optional<SearchResult> findDocument(final TreePVector<SearchField> searchFields) {
                public SearchResults findDocuments(final TreePVector<SearchField> searchFields, int limit) {
                    try {
                        final var queryBuilder = new BooleanQuery.Builder();
                        searchFields.forEach(searchField -> {
                            queryBuilder.add(new BooleanClause(termQuery(searchField), BooleanClause.Occur.MUST));
                        searchFields.forEach(searchField ->
                                queryBuilder.add(new BooleanClause(buildQuery(analyzer, searchField), BooleanClause.Occur.MUST)));
                        });
                        final var finalQuery = queryBuilder.build();

                        try (final var reader = readerSupplier.get()) {
                            final var searchResult = new IndexSearcher(reader).search(finalQuery, 1);
                            final var searchResult = new IndexSearcher(reader).search(finalQuery, limit);
                            if (searchResult.totalHits.value > 0) {
                                return new SearchResults(searchResult.totalHits.value,
                                        Stream.of(searchResult.scoreDocs[0])
                                                .map(scoreDoc -> {
                                                    try {
                                final var document = reader.storedFields().document(searchResult.scoreDocs[0].doc);
                                return Optional.of(new SearchResult(document.getFields().stream()
                                        .reduce(HashTreePMap.empty(),
                                                (m, f) -> m.plus(f.name(), new SearchField(f.name(), f.stringValue())),
                                                HashPMap::plusAll)));
                                                        final var document = reader.storedFields().document(scoreDoc.doc);
                                                        return new SearchResult(document.getFields().stream()
                                                                .reduce(HashTreePMap.empty(),
                                                                        (m, f) -> m.plus(f.name(), new SearchField(f.name(), f.stringValue())),
                                                                        HashPMap::plusAll));
                                                    } catch (IOException e) {
                                                        throw new IllegalStateException(e);
                                                    }
                                                }).reduce(TreePVector.empty(), TreePVector::plus, TreePVector::plusAll));
                            } else {
                                return Optional.empty();
                                return EMPTY_SEARCH_RESULTS;
                            }
                        }
                    } catch (IOException e) {
                        throw new IllegalStateException(e);
                    }
                }

                @Override
                public Optional<SearchResult> findDocument(final TreePVector<SearchField> searchFields) {
                    return findDocuments(searchFields, 1).searchResults().stream().findFirst();
                }

                @NotNull
                private static TermQuery termQuery(final SearchField searchField) {
                private static Query buildQuery(final Analyzer analyzer, final SearchField searchField) {
                    if (searchField.analyzed()) {
                        return new QueryBuilder(analyzer).createPhraseQuery(searchField.name(),
                                String.valueOf(searchField.value()));
                    }
                    return new TermQuery(term(searchField));
                }

                @NotNull
                private static Term term(final SearchField searchField) {
                    return new Term(searchField.name(), String.valueOf(searchField.value()));
                }
            };
        } catch (Throwable t) {
            LOG.get().error("Unable to initialize search engine", t);
            return SEARCH_NOOP;
        }
    }

    public abstract void save(PVector<SearchField> fields);

    public abstract void delete(final SearchField field);

    public abstract Optional<SearchResult> findDocument(final TreePVector<SearchField> searchFields);
}

    public abstract SearchResults findDocuments(final TreePVector<SearchField> searchFields, int limit);

}

Changes to src/main/java/app/coffeetime/models/WikiModel.java.

1
2
3
4
5
6

7

8
9
10
11
12
13
14
15
16
17
18
19

20
21
22

23
24
25

26
27
28
29
30
31
32
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37






+

+












+



+



+







package app.coffeetime.models;


import app.coffeetime.DatabaseInterface;
import app.coffeetime.SearchInterface;
import app.coffeetime.SearchInterface.SearchField;
import app.coffeetime.util.TextSanitizer;
import org.apache.commons.io.IOUtils;
import org.jetbrains.annotations.NotNull;
import org.pcollections.PVector;
import org.pcollections.TreePVector;
import org.slf4j.Logger;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.time.Instant;
import java.util.Optional;
import java.util.TimeZone;
import java.util.function.Supplier;
import java.util.regex.Pattern;

import static app.coffeetime.DatabaseInterface.*;
import static app.coffeetime.util.Logging.logSupplier;
import static java.lang.String.format;
import static java.util.Optional.ofNullable;

public class WikiModel {
    private static final Pattern WIKI_LINK_PATTERN = Pattern.compile("\\[\\[([^\\[]+)]]");
    private static final Supplier<Logger> LOG = logSupplier(WikiModel.class);
    public static final String SEARCH_FIELD_LATEST = "wikiIsLatest";
    public static final String SEARCH_FIELD_PAGE_ID = "wikiPageId";
    public static final String SEARCH_FIELD_TEAM_ID = "wikiTeamId";
    public static final String SEARCH_FIELD_PAGE_NAME = "wikiPageName";
    public static final String SEARCH_FIELD_VERSION = "wikiPageVersion";
    public static final String SEARCH_FIELD_CONTENT = "wikiContent";
73
74
75
76
77
78
79
80


81
82
83
84
85

86

87
88
89
90
91
92
93
94

95
96
97
98
99
100
101
102
















103
104
105
106
107
108

109
110
111
112
113
114
115
116
117
118




































119
120
121
122
123
124
125

126
127

128
129
130
131
132
133
134
135
136
137
138


139
140
141
142
143
144
145
146

147
148
149
150
151
152
153
154
78
79
80
81
82
83
84

85
86
87
88
89
90
91
92

93








94
95







96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116

117
118









119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175

176
177
178
179
180
181
182
183
184

185

186
187
188
189
190
191
192







-
+
+





+
-
+
-
-
-
-
-
-
-
-
+

-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+





-
+

-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+







+


+










-
+
+







-
+
-







                            }
                        })
                        .orElse("Welcome to your wiki!");
            }
        }.get();
    }

    public record WikiPage(Integer id, String content, Integer version) {
    public record WikiPage(Integer id, String name, String content,
                           Integer version) {

    }

    public static final String HOME_PAGE = "HomePage";

    @NotNull
    private Optional<WikiPage> getPageContents(final Connection conn,
    private static Optional<WikiPage> loadPageContents(final Connection conn, final SearchInterface.SearchResult latestPage) {
                                               final String orgId,
                                               final String pageName) {
        return search.findDocument(TreePVector.<SearchField>empty()
                        .plus(new SearchField(SEARCH_FIELD_TEAM_ID, orgId))
                        .plus(new SearchField(SEARCH_FIELD_PAGE_NAME, pageName))
                        .plus(new SearchField(SEARCH_FIELD_LATEST, true)))
                .flatMap(searchResult -> {
                    final int pageId = Integer.parseInt(String.valueOf(searchResult.searchFields().get("wikiPageId").value()));
        final int pageId = Integer.parseInt(String.valueOf(latestPage.searchFields().get("wikiPageId").value()));

                    return query(conn, "select * from organization_wiki_pages where id = ?",
                            stmt -> stmt.setInt(1, pageId),
                            rsi -> resultSetStream(rsi, () -> {
                                final var rs = rsi.getResultSet();
                                return new WikiPage(pageId, rs.getString("page_contents"), rs.getInt("version"));
                            }).findFirst());
                });
        return query(conn, "select * from organization_wiki_pages where id = ?",
                stmt -> stmt.setInt(1, pageId),
                rsi -> resultSetStream(rsi, () -> {
                    final var rs = rsi.getResultSet();
                    return new WikiPage(pageId,
                            rs.getString("page_name"),
                            rs.getString("page_contents"),
                            rs.getInt("version"));
                }).findFirst());
    }

    private Optional<SearchInterface.SearchResult> findLatestPage(final String orgId, final String pageName) {
        return search.findDocument(TreePVector.<SearchField>empty()
                .plus(new SearchField(SEARCH_FIELD_TEAM_ID, orgId))
                .plus(new SearchField(SEARCH_FIELD_PAGE_NAME, pageName))
                .plus(new SearchField(SEARCH_FIELD_LATEST, true)));
    }

    public WikiPage getPage(final String requesterEmail,
                            final String friendlyOrgId,
                            final String pageName) {
        return database.withConnection(true, conn ->
        final var isAbleToSeePage = database.withConnection(true, conn ->
                userModel.getUserId(conn, requesterEmail)
                        .flatMap(userId -> teamModel.getTeam(requesterEmail, TimeZone.getDefault(), friendlyOrgId)
                                .filter(team -> team.isPubliclyVisible() || team.isRequesterMember())
                                .map(team -> getPageContents(conn, team.getId(), pageName)
                                        .orElseGet(() -> new WikiPage(null, HOME_PAGE.equals(pageName) ?
                                                this.defaultHomeContent :
                                                "There is no content here. Yet!",
                                                null))))
                        .orElse(null)
        );
                        .flatMap(userId -> teamModel.getTeam(requesterEmail, TimeZone.getDefault(), friendlyOrgId))
                        .map(team -> team.isPubliclyVisible() || team.isRequesterMember())
                        .orElse(false));

        if (isAbleToSeePage) {
            final var latestPage = findLatestPage(friendlyOrgId, pageName);
            return latestPage
                    .map(searchResult -> database.withConnection(true, conn -> loadPageContents(conn, searchResult).orElse(null)))
                    .orElseGet(() -> new WikiPage(null, pageName,
                            HOME_PAGE.equals(pageName) ?
                                    this.defaultHomeContent :
                                    "There is no content here. Yet!",
                            null));
        }
        return null;
    }

    public PVector<WikiPage> getBackLinks(final String requesterEmail,
                                          final String friendlyOrgId,
                                          final String pageName) {
        final var teamO = database.withConnection(true,
                conn -> teamModel.getTeam(requesterEmail, TimeZone.getDefault(), friendlyOrgId));
        if (teamO.filter(team -> team.isPubliclyVisible() || team.isRequesterMember()).isPresent()) {
            final var documents = search.findDocuments(TreePVector.<SearchField>empty()
                    .plus(new SearchField(SEARCH_FIELD_TEAM_ID, friendlyOrgId))
                    .plus(new SearchField(SEARCH_FIELD_CONTENT, pageName, false, true)), 50);
            return database.withConnection(true, conn -> documents
                    .searchResults()
                    .stream()
                    .map(searchResult -> loadPageContents(conn, searchResult)
                            .orElseThrow(() -> new IllegalStateException(format("Document %s found in index but not database",
                                    searchResult.searchFields()))))
                    .reduce(TreePVector.empty(), TreePVector::plus, TreePVector::plusAll));
        } else {
            return TreePVector.empty();
        }
    }

    public WikiSaveResult savePage(final String requesterEmail,
                                   final String friendlyOrgId,
                                   final String pageName,
                                   final Integer currentVersion,
                                   final String pageContents) {
        final var latestPage = findLatestPage(friendlyOrgId, pageName);
        return database.withTransaction(conn -> {
            final var userId = userModel.getUserId(conn, requesterEmail).orElseThrow();

            return teamModel.getTeam(requesterEmail, TimeZone.getDefault(), friendlyOrgId)
                    .map(team -> {
                        if (!team.isRequesterMember()) {
                            return WikiSaveResult.accessDenied;
                        }

                        final var nowStr = nowSupplier.get().toString();

                        final var currentVersionOpt = ofNullable(currentVersion);

                        final var updateResult = getPageContents(conn, team.getId(), pageName)
                        final var updateResult = latestPage
                                .flatMap(result -> loadPageContents(conn, result))
                                .map(wikiPage -> currentVersionOpt.map(v -> {
                                    if (v.equals(wikiPage.version())) {
                                        markPageNotLatest(conn, pageName, team.getId(), wikiPage, nowStr);
                                    } else {
                                        return WikiSaveResult.collision;
                                    }
                                    return WikiSaveResult.success;
                                }).orElse(WikiSaveResult.collision))
                                }).orElse(WikiSaveResult.collision)).orElseGet(() -> currentVersionOpt
                                .orElseGet(() -> currentVersionOpt
                                        .map(integer -> WikiSaveResult.pageMissing)
                                        .orElse(WikiSaveResult.success));

                        if (updateResult != WikiSaveResult.success) {
                            return updateResult;
                        }

180
181
182
183
184
185
186

187
188
189
190
191
192
193











194
195
196
197
198
199
200
218
219
220
221
222
223
224
225
226






227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244







+

-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+








    private void indexPage(final String teamId,
                           final Integer pageId,
                           final String pageName,
                           final String pageContents,
                           final int version,
                           final boolean latest) {
        final var processedContents = sanitizeWikiLinks(pageContents);
        search.save(TreePVector.<SearchField>empty()
                .plus(new SearchField(SEARCH_FIELD_PAGE_ID, pageId, true))
                .plus(new SearchField(SEARCH_FIELD_TEAM_ID, teamId, true))
                .plus(new SearchField(SEARCH_FIELD_PAGE_NAME, pageName, true))
                .plus(new SearchField(SEARCH_FIELD_VERSION, version, true))
                .plus(new SearchField(SEARCH_FIELD_LATEST, latest, true))
                .plus(new SearchField(SEARCH_FIELD_CONTENT, pageContents)));
                .plus(new SearchField(SEARCH_FIELD_PAGE_ID, pageId, true, false))
                .plus(new SearchField(SEARCH_FIELD_TEAM_ID, teamId, true, false))
                .plus(new SearchField(SEARCH_FIELD_PAGE_NAME, pageName, true, false))
                .plus(new SearchField(SEARCH_FIELD_VERSION, version, true, false))
                .plus(new SearchField(SEARCH_FIELD_LATEST, latest, true, false))
                .plus(new SearchField(SEARCH_FIELD_CONTENT, processedContents, false, true)));
    }

    private static String sanitizeWikiLinks(final String pageContents) {
        return WIKI_LINK_PATTERN.matcher(pageContents)
                .replaceAll(matchResult -> TextSanitizer.wikiWord(matchResult.group(1)));
    }

    private void markPageNotLatest(final Connection conn,
                                   final String teamId,
                                   final String pageName,
                                   final WikiPage page,
                                   final String nowStr) {

Changes to src/main/java/app/coffeetime/web/WikiRouting.java.

65
66
67
68
69
70
71


















72
73
74
75
76
77
78
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96







+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+







                                    });
                        }
                    } catch (InvalidTokenException e) {
                        LOG.get().error("Invalid CSRF token", e);
                        return redirect("/", e.getUserFriendlyMessage());
                    }
                }));

        router.GET("/teams/:teamId/wiki/:pageName/_backLinks", wrapAuth(userModel, sessionProperties,
                req -> {
                    final var teamId = req.requestPropertyAsString("teamId");
                    final var requesterEmail = req.principal().orElse(null);
                    final var team = teamModel.getTeam(requesterEmail, req.timeZone(), teamId).orElseThrow();
                    final var pageName = req.requestPropertyAsOptionalString("pageName").orElse(WikiModel.HOME_PAGE);

                    return htmlResponse(renderPage(mergeTool, req,
                            "/templates/web/wikiBackLinks.vm",
                            HashTreePMap.<String, Object>empty()
                                    .plus("homePage", WikiModel.HOME_PAGE.equals(pageName))
                                    .plus("homePageName", WikiModel.HOME_PAGE)
                                    .plus("pageName", pageName)
                                    .plus("team", team)
                                    .plus("backLinks", wikiModel.getBackLinks(requesterEmail, teamId, pageName))));
                }));

    }

    @NotNull
    private static Response wikiResponse(final MergeTool mergeTool,
                                         final WikiModel wikiModel,
                                         final TeamModel teamModel,
                                         final Request req,

Changes to src/main/resources/main.scss.

1928
1929
1930
1931
1932
1933
1934
1935

1936
1937
1938
1939
1940
1941
1942
1943
1944
1945

1946
1947
1948
1949
1950
1951
1952
1928
1929
1930
1931
1932
1933
1934

1935
1936
1937
1938
1939
1940
1941
1942
1943
1944

1945
1946
1947
1948
1949
1950
1951
1952







-
+









-
+







    grid-row-gap: 1rem;
    grid-template-columns: min-content;
  }
}

#teamsPanel, #teamPanel, #wikiPanel {
  h1 {
    a {
    a:not(.headerText) {
      font-size: 1rem;
      line-height: 1rem;
      vertical-align: top;
    }
  }
}

@media (min-width: 420px) {
  #teamsPanel, #teamPanel, #wikiPanel {
    h1 a {
    h1 a:not(.headerText) {
      float: right;
      margin-left: 0.5rem;
    }
  }
}

@media (max-width: 419px) {

Added src/main/resources/templates/web/wikiBackLinks.vm.




















1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
<div id="wikiPanel">
    <div id="header">
        <h1><a class="headerText" href="/teams/${team.id}/wiki/${pageName}">${pageName}</a>
            references
            <a href="/teams/${team.id}">${team.name}</a>
            #if(!${homePage})
                <a href="/teams/${team.id}/wiki">${homePageName}</a>
            #end
            #if(${team.requesterMember})
                <a href="/teams/${team.id}/wiki/${pageName}/_edit">edit</a>
            #end
        </h1>
    </div>
    <ul>
        #foreach($page in ${backLinks})
           <li> <a class="headerText" href="/teams/${team.id}/wiki/${page.name()}">${page.name()}</a></li>
        #end
    </ul>
</div>

Changes to src/main/resources/templates/web/wikiPage.vm.

1
2
3

4
5
6
7
8
9
10
1
2

3
4
5
6
7
8
9
10


-
+







<div id="wikiPanel">
    <div id="header">
        <h1>${pageName}
        <h1><a class="headerText" href="/teams/${team.id}/wiki/${pageName}/_backLinks">${pageName}</a>
            <a href="/teams/${team.id}">${team.name}</a>
            #if(!${homePage})
                <a href="/teams/${team.id}/wiki">${homePageName}</a>
            #end
            #if(${team.requesterMember})
                <a href="/teams/${team.id}/wiki/${pageName}/_edit">edit</a>
            #end

Changes to src/test/java/app/coffeetime/models/WikiModelTest.java.

1
2
3
4
5
6
7
8



9
10
11
12
13
14
15

16
17
18
19
20

21
22
23
24
25
26
27
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32








+
+
+







+





+







package app.coffeetime.models;

import app.coffeetime.fixtures.TestCoffeeTime;
import app.coffeetime.util.Logging;
import ch.qos.logback.classic.Level;
import ch.qos.logback.classic.spi.ILoggingEvent;
import ch.qos.logback.core.ConsoleAppender;
import io.sentry.SentryOptions;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.standard.StandardAnalyzer;
import org.apache.lucene.util.AttributeImpl;
import org.jetbrains.annotations.NotNull;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.pcollections.HashTreePMap;
import org.pcollections.TreePVector;

import java.time.Instant;
import java.util.Iterator;
import java.util.TimeZone;
import java.util.concurrent.atomic.AtomicReference;
import java.util.function.Supplier;

import static app.coffeetime.fixtures.TestCoffeeTime.ADMIN_EMAIL;
import static app.coffeetime.models.WikiModel.HOME_PAGE;
import static org.junit.jupiter.api.Assertions.*;

public class WikiModelTest {
    private static final Instant EXPECTED_NOW = Instant.parse("2024-06-10T12:30:00Z");
    private static final AtomicReference<Instant> CURRENT_NOW = new AtomicReference<>();
    private static final String DEFAULT_HOME_CONTENT_SNIPPET = "Welcome to your wiki!";
    private static final String DEFAULT_NON_HOME_CONTENT_SNIPPET = "There is no content here";
86
87
88
89
90
91
92
93

94
95
96
97
98
99
100
101
102
103
104
105



106
107

108
109
110
111
112
113
114
115
116
117


118
119

120
121
122
123
124
125
126
127
128
129
130
131
132
133





134
135
136
137
138
139
140
141
142
143
144
145
146


147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162

163
164
165
166
167
168
169
91
92
93
94
95
96
97

98
99
100
101
102
103
104
105
106
107



108
109
110
111
112
113
114
115
116
117
118
119
120
121


122
123
124

125
126
127
128
129
130
131
132
133
134





135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150


151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176







-
+









-
-
-
+
+
+


+








-
-
+
+

-
+









-
-
-
-
-
+
+
+
+
+











-
-
+
+
















+








        teamModel.addMember(ADMIN_EMAIL, this.publicTeam.getId(), nonAdminMemberUser.userInfo());
        teamModel.addMember(ADMIN_EMAIL, this.privateTeam.getId(), nonAdminMemberUser.userInfo());
    }

    @Test
    public void missingHomePage() {
        final var page = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE);
        final var page = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE);

        assertTrue(page.content().contains(DEFAULT_HOME_CONTENT_SNIPPET));
        assertNull(page.version());
    }

    @Test
    public void newHomePage() {
        final var expectedContent = "This is our wiki";

        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE, null, expectedContent);
        final var secondTeamHome = wikiModel.getPage(ADMIN_EMAIL, privateTeam.getId(), WikiModel.HOME_PAGE);
        final var page = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE);
        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE, null, expectedContent);
        final var secondTeamHome = wikiModel.getPage(ADMIN_EMAIL, privateTeam.getId(), HOME_PAGE);
        final var page = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE);

        assertTrue(secondTeamHome.content().contains(DEFAULT_HOME_CONTENT_SNIPPET), secondTeamHome.toString());
        assertEquals(HOME_PAGE, page.name());
        assertEquals(expectedContent, page.content());
        assertEquals(1, page.version());
    }

    @Test
    public void existingPage() {
        final var expectedContent = "This is our wiki";

        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE, null, "First update ignored");
        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE, 1, expectedContent);
        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE, null, "First update ignored");
        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE, 1, expectedContent);

        final var secondPage = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE);
        final var secondPage = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE);

        assertEquals(expectedContent, secondPage.content());
        assertEquals(2, secondPage.version());
    }

    @Test
    public void conflictingUpdatesRejected() {
        final var expectedContent = "This is our wiki";

        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE, null, "First update ignored");
        final var firstResult = wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE, 1, expectedContent);
        final var secondResult = wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE, 1, "Bad update");
        final var thirdResult = wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE, null, "Bad update");
        final var secondPage = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE);
        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE, null, "First update ignored");
        final var firstResult = wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE, 1, expectedContent);
        final var secondResult = wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE, 1, "Bad update");
        final var thirdResult = wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE, null, "Bad update");
        final var secondPage = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE);

        assertEquals(WikiModel.WikiSaveResult.success, firstResult);
        assertEquals(WikiModel.WikiSaveResult.collision, secondResult);
        assertEquals(WikiModel.WikiSaveResult.collision, thirdResult);
        assertEquals(expectedContent, secondPage.content());
        assertEquals(2, secondPage.version());
    }

    @Test
    public void nonexistentPreviousVersion() {
        final var teamId = publicTeam.getId();
        final var saveResult = wikiModel.savePage(ADMIN_EMAIL, teamId, WikiModel.HOME_PAGE, 2, "Bad save");
        final var page = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), WikiModel.HOME_PAGE);
        final var saveResult = wikiModel.savePage(ADMIN_EMAIL, teamId, HOME_PAGE, 2, "Bad save");
        final var page = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), HOME_PAGE);

        assertEquals(WikiModel.WikiSaveResult.pageMissing, saveResult);
        assertTrue(page.content().contains(DEFAULT_HOME_CONTENT_SNIPPET));
        assertNull(page.version());
    }

    @Test
    public void updateNotHomePage() {
        final var pageName = "NotHome";
        final var expectedContent = "This is our wiki";

        wikiModel.savePage(ADMIN_EMAIL, publicTeam.getId(), pageName, null, expectedContent);
        final var secondTeamPage = wikiModel.getPage(ADMIN_EMAIL, privateTeam.getId(), pageName);
        final var page = wikiModel.getPage(ADMIN_EMAIL, publicTeam.getId(), pageName);

        assertTrue(secondTeamPage.content().contains(DEFAULT_NON_HOME_CONTENT_SNIPPET), secondTeamPage.toString());
        assertEquals(pageName, page.name());
        assertEquals(expectedContent, page.content());
        assertEquals(1, page.version());
    }

    @Test
    public void nonAdminsCanUpdateAndView() {
        final var expectedPublicContent = "This is our public wiki";
216
217
218
219
220
221
222


223
























224
225
226
227
228
229
230
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263







+
+

+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+








        assertNull(wikiModel.getPage(null, publicTeam.getId(), pageName));
        assertNull(wikiModel.getPage(null, privateTeam.getId(), pageName));
    }

    @Test
    public void backLinks() {
        final var expectedPublicContents = "This is a public page that refers to [[AnotherPage]] that is of note";
        final var expectedPrivateContents = "This is a private page that refers to [[Another Page]] that is of note";

        wikiModel.savePage(NON_ADMIN_TEAM_MEMBER, publicTeam.getId(), "HomePage", null,
                "This is a page without any links");
        final var publicLinkedResult = wikiModel.savePage(NON_ADMIN_TEAM_MEMBER, publicTeam.getId(), "HomePage", 1,
                expectedPublicContents);
        final var privateLinkedResult = wikiModel.savePage(NON_ADMIN_TEAM_MEMBER, privateTeam.getId(), "HomePage", null,
                expectedPrivateContents);
        wikiModel.savePage(NON_ADMIN_TEAM_MEMBER, publicTeam.getId(), "AnotherPage", null,
                "This is another fun page");

        final var publicBackLinks = wikiModel.getBackLinks(NON_TEAM_MEMBER, publicTeam.getId(), "AnotherPage");
        final var privateBackLinks = wikiModel.getBackLinks(ADMIN_EMAIL, privateTeam.getId(), "AnotherPage");
        final var notAllowedBackLinks = wikiModel.getBackLinks(NON_TEAM_MEMBER, privateTeam.getId(), "AnotherPage");

        assertEquals(WikiModel.WikiSaveResult.success, publicLinkedResult);
        assertEquals(WikiModel.WikiSaveResult.success, privateLinkedResult);

        assertEquals(1, publicBackLinks.size());
        assertEquals(1, privateBackLinks.size());
        assertEquals(0, notAllowedBackLinks.size());

        assertEquals(expectedPublicContents, publicBackLinks.getFirst().content());
        assertEquals(HOME_PAGE, publicBackLinks.getFirst().name());
        assertEquals(expectedPrivateContents, privateBackLinks.getFirst().content());
        assertEquals(HOME_PAGE, privateBackLinks.getFirst().name());
    }

    @Test
    public void searchAmongLatestPages() {

    }