Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,7 @@ curl "localhost:8080/api/extensions/1/stats?from=2026-10-01&granularity=week" -H
| POST, GET | `/api/publishers` | Track a publisher on every store; list yours |
| POST | `/api/extensions` | Add an extension by hand (for Chrome extensions) |
| POST | `/api/extensions/{id}/listings` | Add a listing by its store ID (for example a Chrome item ID) |
| DELETE | `/api/extensions/{id}` | Stop tracking an extension and delete its numbers and reviews |
| GET | `/api/extensions` | Installs today and gain over 7 days, per extension and store |
| GET | `/api/extensions/{id}/stats` | Installs over time: `from`, `to` (ISO dates), `granularity=day\|week` |
| GET | `/api/reviews` | Reviews from every store, newest first: `unread`, `unanswered`, `maxRating`, `store`, `extensionId`, `limit`, `offset` |
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
Expand Down Expand Up @@ -65,6 +66,13 @@ ListingResponse addListing(@AuthenticationPrincipal Jwt token, @PathVariable lon
.of(this.catalog.addListing(AuthenticatedAccount.id(token), id, request.store(), request.externalId()));
}

/** Stops tracking the extension and deletes its numbers and reviews. */
@DeleteMapping("/api/extensions/{id}")
@ResponseStatus(HttpStatus.NO_CONTENT)
void removeExtension(@AuthenticationPrincipal Jwt token, @PathVariable long id) {
this.catalog.removeExtension(AuthenticatedAccount.id(token), id);
}

record AddPublisherRequest(@NotBlank @Size(max = 100) @Pattern(regexp = "[A-Za-z0-9][A-Za-z0-9._-]*",
message = "letters, digits, dots, dashes and underscores") String name) {
}
Expand Down
14 changes: 14 additions & 0 deletions src/main/java/com/mccprogrammer/extpulse/catalog/CatalogPages.java
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
import org.springframework.web.ErrorResponseException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;

Expand Down Expand Up @@ -66,6 +67,19 @@ String addListing(@AuthenticationPrincipal SignedInUser user, @Valid @ModelAttri
});
}

@PostMapping("/app/catalog/extensions/{id}/remove")
String removeExtension(@AuthenticationPrincipal SignedInUser user, @PathVariable long id,
RedirectAttributes redirect) {
try {
redirect.addFlashAttribute("notice",
"Removed " + this.catalog.removeExtension(user.id(), id) + " and its history.");
}
catch (ErrorResponseException ex) {
redirect.addFlashAttribute("error", ex.getBody().getDetail());
}
return "redirect:/app/catalog";
}

/**
* Runs the change and goes back to the page with what happened: the result, or why it
* was refused.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -158,6 +158,18 @@ Listing addListing(long userId, long extensionId, Store store, String externalId
}
}

/**
* Stops tracking the extension, for example one taken off its store. Its listings,
* snapshots and reviews go with it (the foreign keys cascade); past collection runs
* keep their entries without the listing.
* @return the name it had, to say what was removed
*/
String removeExtension(long userId, long extensionId) {
Extension extension = requireOwnedExtension(userId, extensionId);
this.extensions.delete(extension);
return extension.getDisplayName();
}

List<Publisher> publishersOf(long userId) {
return this.publishers.findByUserIdOrderByName(userId);
}
Expand Down
23 changes: 23 additions & 0 deletions src/main/resources/static/css/app.css
Original file line number Diff line number Diff line change
Expand Up @@ -351,6 +351,11 @@ button.secondary,
border-color: var(--axis);
}

button.danger {
background: var(--bad);
color: #fff;
}

button.link {
background: none;
border: 0;
Expand Down Expand Up @@ -555,6 +560,24 @@ button.link:hover {
opacity: 0.8;
}

.removable {
list-style: none;
padding: 0;
margin: 0;
}

.removable li + li {
border-top: 1px solid var(--axis);
}

.removable li {
padding: 6px 0;
}

.removable details summary {
margin: 4px 0;
}

details summary {
cursor: pointer;
color: var(--text-2);
Expand Down
19 changes: 19 additions & 0 deletions src/main/resources/templates/catalog/catalog.html
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,25 @@ <h2>2. Where it is published</h2>
</div>
</div>
<p class="card muted" th:if="${publishers.isEmpty()}">Add a publisher first.</p>

<th:block th:unless="${extensions.isEmpty()}">
<h2>Your extensions</h2>
<p class="muted">Remove one you no longer track, for example after taking it off its store.</p>
<div class="card">
<ul class="removable">
<li th:each="extension : ${extensions}">
<span th:text="${extension.displayName}">extension</span>
<details>
<summary>Remove</summary>
<p class="muted">Deletes its listings, numbers and reviews. This can't be undone.</p>
<form method="post" th:action="@{/app/catalog/extensions/{id}/remove(id=${extension.id})}">
<button type="submit" class="danger" th:text="'Remove ' + ${extension.displayName}">Remove</button>
</form>
</details>
</li>
</ul>
</div>
</th:block>
</main>
<footer th:replace="~{fragments :: footer}"></footer>
</body>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import static org.assertj.core.api.Assertions.assertThat;
import static org.hamcrest.Matchers.containsInAnyOrder;
import static org.hamcrest.Matchers.hasSize;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.delete;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
Expand Down Expand Up @@ -165,6 +166,36 @@ void anotherAccountsExtensionLooksMissing() throws Exception {
addListing(other, extension, "CHROME_WEB_STORE", DIRECTORS_CUT).andExpect(status().isNotFound());
}

@Test
void removingAnExtensionDeletesItsListingsAndNumbers() throws Exception {
long extension = addExtensionNamed("Director's Cut for Letterboxd");
stubChromePage(DIRECTORS_CUT, "chrome-web-store/" + DIRECTORS_CUT + ".html");
addListing(this.token, extension, "CHROME_WEB_STORE", DIRECTORS_CUT).andExpect(status().isCreated());
this.jdbc
.sql("INSERT INTO snapshots (listing_id, taken_on, installs) "
+ "SELECT id, current_date, 37 FROM listings WHERE extension_id = :extension")
.param("extension", extension)
.update();
long others = count("extensions") - 1;

removeExtension(this.token, extension).andExpect(status().isNoContent());

assertThat(count("extensions")).isEqualTo(others);
assertThat(countWhere("listings", "extension_id = " + extension)).isZero();
assertThat(countWhere("snapshots", "installs = 37")).isZero();
removeExtension(this.token, extension).andExpect(status().isNotFound());
}

@Test
void anotherAccountCannotRemoveAnExtension() throws Exception {
long extension = addExtensionNamed("Director's Cut for Letterboxd");
String other = TestAccounts.tokenFor(this.mvc, "someone@example.com");

removeExtension(other, extension).andExpect(status().isNotFound());

assertThat(countWhere("extensions", "id = " + extension)).isEqualTo(1);
}

@Test
void needsALogin() throws Exception {
this.mvc.perform(post("/api/publishers").contentType(MediaType.APPLICATION_JSON).content("{\"name\": \"x\"}"))
Expand Down Expand Up @@ -192,6 +223,10 @@ private ResultActions addPublisher(String token, String name) throws Exception {
.content("{\"name\": \"%s\"}".formatted(name)));
}

private ResultActions removeExtension(String token, long extension) throws Exception {
return this.mvc.perform(delete("/api/extensions/" + extension).header("Authorization", "Bearer " + token));
}

private ResultActions addListing(String token, long extension, String store, String externalId) throws Exception {
return this.mvc
.perform(post("/api/extensions/" + extension + "/listings").header("Authorization", "Bearer " + token)
Expand All @@ -218,6 +253,10 @@ private void stubChromePage(String itemId, String bodyFile) {
.willReturn(aResponse().withHeader("Content-Type", "text/html; charset=utf-8").withBodyFile(bodyFile)));
}

private long countWhere(String table, String condition) {
return this.jdbc.sql("SELECT count(*) FROM " + table + " WHERE " + condition).query(Long.class).single();
}

private long count(String table) {
return this.jdbc.sql("SELECT count(*) FROM " + table).query(Long.class).single();
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
import static com.github.tomakehurst.wiremock.client.WireMock.urlPathEqualTo;
import static com.github.tomakehurst.wiremock.core.WireMockConfiguration.wireMockConfig;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.startsWith;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
Expand Down Expand Up @@ -123,6 +124,30 @@ void aChromeExtensionIsAddedInTwoSteps() throws Exception {
.andExpect(flash().attribute("notice", "Added " + DIRECTORS_CUT + " on Chrome Web Store."));
}

@Test
void anExtensionIsRemovedFromThePage() throws Exception {
long publisher = this.jdbc
.sql("INSERT INTO publishers (user_id, name) VALUES (:user, 'MCCProgrammer') RETURNING id")
.param("user", TestAccounts.idOf(this.jdbc, "miguel@example.com"))
.query(Long.class)
.single();
long extension = this.jdbc.sql(
"INSERT INTO extensions (publisher_id, display_name) VALUES (:publisher, 'Unified Inbox') RETURNING id")
.param("publisher", publisher)
.query(Long.class)
.single();
this.mvc.perform(get("/app/catalog").session(this.session))
.andExpect(content().string(containsString("Remove Unified Inbox")));

this.mvc.perform(post("/app/catalog/extensions/{id}/remove", extension).session(this.session).with(csrf()))
.andExpect(redirectedUrl("/app/catalog"))
.andExpect(flash().attribute("notice", "Removed Unified Inbox and its history."));
this.mvc.perform(post("/app/catalog/extensions/{id}/remove", extension).session(this.session).with(csrf()))
.andExpect(flash().attribute("error", "You have no extension " + extension));
this.mvc.perform(get("/app/catalog").session(this.session))
.andExpect(content().string(not(containsString("Remove Unified Inbox"))));
}

@Test
void aRefusedChangeShowsWhy() throws Exception {
this.mvc
Expand Down
Loading