Skip to content

Commit 10483a7

Browse files
committed
feat: releases api
1 parent fcb9728 commit 10483a7

File tree

5 files changed

+322
-49
lines changed

5 files changed

+322
-49
lines changed

appinfo/routes.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@
113113
['name' => 'termsagreement_api#agree', 'url' => '/api/1.0/termsagreement/agree/{version}', 'verb' => 'POST'],
114114
['name' => 'termsagreement_api#isAgreed', 'url' => '/api/1.0/termsagreement/isagreed/{version}', 'verb' => 'GET'],
115115

116+
['name' => 'releases_api#index', 'url' => '/api/1.0/releases', 'verb' => 'GET'],
117+
116118
[
117119
'name' => 'setting_groups_management#preflighted_cors',
118120
'url' => '/api/2.0/{path}',
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
/**
4+
* @copyright Copyright (c) 2026 Sendent B.V.
5+
*
6+
* @author Sendent B.V. <info@sendent.com>
7+
*
8+
* @license GNU AGPL version 3 or any later version
9+
*
10+
* This program is free software: you can redistribute it and/or modify
11+
* it under the terms of the GNU Affero General Public License as
12+
* published by the Free Software Foundation, either version 3 of the
13+
* License, or (at your option) any later version.
14+
*
15+
* This program is distributed in the hope that it will be useful,
16+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
17+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
18+
* GNU Affero General Public License for more details.
19+
*
20+
* You should have received a copy of the GNU Affero General Public License
21+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
22+
*/
23+
24+
namespace OCA\Sendent\Controller;
25+
26+
use OCP\AppFramework\ApiController;
27+
use OCP\AppFramework\Http;
28+
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
29+
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
30+
use OCP\AppFramework\Http\DataResponse;
31+
use OCP\Http\Client\IClientService;
32+
use OCP\IRequest;
33+
use Psr\Log\LoggerInterface;
34+
35+
class ReleasesApiController extends ApiController {
36+
private const BASE_URL = 'https://releasesapp.com/api/entries/latest/';
37+
38+
private const WORKSPACES = [
39+
'outlook-cross-platform' => 'dc9b6f11-139c-46ad-83a7-efca461ef0d0',
40+
'ms-teams' => '6880e895-ba93-4433-9729-371ea9dc0ac1',
41+
'outlook-windows' => '13f7862f-8df4-4106-a818-4d5a82f6fe50',
42+
];
43+
44+
private $client;
45+
private $logger;
46+
47+
public function __construct(
48+
$appName,
49+
IRequest $request,
50+
IClientService $clientService,
51+
LoggerInterface $logger,
52+
) {
53+
parent::__construct($appName, $request);
54+
$this->client = $clientService->newClient();
55+
$this->logger = $logger;
56+
}
57+
58+
/**
59+
* Fetches the latest release entry for all products.
60+
*
61+
* @return DataResponse
62+
*/
63+
#[NoAdminRequired]
64+
#[NoCSRFRequired]
65+
public function index(): DataResponse {
66+
$results = [];
67+
foreach (self::WORKSPACES as $slug => $uuid) {
68+
try {
69+
$response = $this->client->get(self::BASE_URL . $uuid);
70+
$data = json_decode($response->getBody(), true);
71+
if ($data) {
72+
$results[$slug] = $data;
73+
}
74+
} catch (\Exception $e) {
75+
$this->logger->warning('Failed to fetch release for ' . $slug . ': ' . $e->getMessage());
76+
}
77+
}
78+
return new DataResponse($results);
79+
}
80+
}

src/components/general/OutlookAddonInfo.vue

Lines changed: 172 additions & 49 deletions
Original file line numberDiff line numberDiff line change
@@ -19,75 +19,198 @@
1919
- along with this program. If not, see <http://www.gnu.org/licenses/>.
2020
-->
2121
<template>
22-
<div class="addon-info">
23-
<h3>Outlook Add-in</h3>
24-
<div v-if="licenseStore.addinVersion" class="addon-info__details">
25-
<p><strong>Latest version:</strong> {{ licenseStore.addinVersion.Version }}</p>
26-
<p v-if="licenseStore.addinVersion.ReleaseDate">
27-
<strong>Release date:</strong> {{ formatDate(licenseStore.addinVersion.ReleaseDate) }}
28-
</p>
29-
<div class="addon-info__links">
30-
<a v-if="licenseStore.addinVersion.UrlBinary"
31-
:href="licenseStore.addinVersion.UrlBinary"
32-
class="button primary"
33-
target="_blank"
34-
rel="noopener">
35-
Download
36-
</a>
37-
<a v-if="licenseStore.addinVersion.UrlManual"
38-
:href="licenseStore.addinVersion.UrlManual"
39-
target="_blank"
40-
rel="noopener"
41-
class="addon-info__link">
42-
Manual ↗
43-
</a>
44-
<a v-if="licenseStore.addinVersion.UrlReleaseNotes"
45-
:href="licenseStore.addinVersion.UrlReleaseNotes"
46-
target="_blank"
47-
rel="noopener"
48-
class="addon-info__link">
49-
Release Notes ↗
50-
</a>
22+
<div class="product-releases">
23+
<div v-if="loading" class="product-releases__loading">
24+
<span class="icon-loading" />
25+
Loading release info...
26+
</div>
27+
<div v-else-if="Object.keys(releases).length === 0" class="product-releases__empty">
28+
No release information available.
29+
</div>
30+
<div v-else class="product-releases__grid">
31+
<div v-for="product in products"
32+
:key="product.slug"
33+
class="product-card">
34+
<div v-if="releases[product.slug]" class="product-card__content">
35+
<h3>{{ product.label }}</h3>
36+
<p>
37+
<strong>Latest version:</strong>
38+
{{ extractVersion(releases[product.slug].title) }}
39+
</p>
40+
<p v-if="releases[product.slug].date">
41+
<strong>Release date:</strong>
42+
{{ formatDate(releases[product.slug].date) }}
43+
</p>
44+
<div v-if="releases[product.slug].tags?.length" class="product-card__tags">
45+
<span v-for="tag in releases[product.slug].tags"
46+
:key="tag"
47+
class="product-card__tag">
48+
{{ tag }}
49+
</span>
50+
</div>
51+
<div class="product-card__actions">
52+
<button class="product-card__notes-toggle"
53+
@click="toggleNotes(product.slug)">
54+
{{ expandedNotes[product.slug] ? 'Hide release notes' : 'Show release notes' }}
55+
</button>
56+
</div>
57+
<div v-if="expandedNotes[product.slug]"
58+
class="product-card__release-notes"
59+
v-html="releases[product.slug].content" />
60+
</div>
5161
</div>
5262
</div>
53-
<p v-else class="addon-info__empty">
54-
No add-in version information available.
55-
</p>
5663
</div>
5764
</template>
5865

5966
<script setup lang="ts">
60-
import { useLicenseStore } from '../../stores/license'
67+
import { ref, reactive, onMounted } from 'vue'
68+
import type { ReleaseEntry } from '../../types/releases'
69+
import { fetchLatestReleases } from '../../services/releasesApi'
6170
import { formatDate } from '../../utils/date-utils'
6271
63-
const licenseStore = useLicenseStore()
72+
const products = [
73+
{ slug: 'outlook-cross-platform', label: 'Sendent for Outlook (Cross-Platform)' },
74+
{ slug: 'ms-teams', label: 'Sendent for MS Teams' },
75+
{ slug: 'outlook-windows', label: 'Sendent for Outlook (Windows-Only)' },
76+
]
77+
78+
const releases = ref<Record<string, ReleaseEntry>>({})
79+
const loading = ref(true)
80+
const expandedNotes = reactive<Record<string, boolean>>({})
81+
82+
/**
83+
* Extracts a version number from a release title like "Release Notes v2.3.0"
84+
* @param title
85+
*/
86+
function extractVersion(title: string): string {
87+
const match = title.match(/v?(\d+\.\d+(?:\.\d+)?)/i)
88+
return match ? match[1] : title
89+
}
90+
91+
/**
92+
* @param slug
93+
*/
94+
function toggleNotes(slug: string) {
95+
expandedNotes[slug] = !expandedNotes[slug]
96+
}
97+
98+
onMounted(async () => {
99+
try {
100+
releases.value = await fetchLatestReleases()
101+
} catch {
102+
// Silently fail — the empty state handles this
103+
} finally {
104+
loading.value = false
105+
}
106+
})
64107
</script>
65108

66109
<style scoped>
67-
.addon-info {
110+
.product-releases__loading {
111+
display: flex;
112+
align-items: center;
113+
gap: 8px;
114+
padding: 12px 0;
115+
color: var(--color-text-maxcontrast);
116+
}
117+
118+
.product-releases__empty {
119+
color: var(--color-text-maxcontrast);
120+
padding: 12px 0;
121+
}
122+
123+
.product-releases__grid {
124+
display: flex;
125+
flex-wrap: wrap;
126+
gap: 16px;
68127
margin-bottom: 24px;
69128
}
70129
71-
.addon-info h3 {
72-
font-size: 16px;
130+
.product-card {
131+
flex: 1;
132+
min-width: 280px;
133+
max-width: 400px;
134+
border: 1px solid var(--color-border);
135+
border-radius: var(--border-radius-large);
136+
padding: 16px;
137+
}
138+
139+
.product-card h3 {
140+
font-size: 15px;
73141
font-weight: 600;
74-
margin-bottom: 12px;
142+
margin-bottom: 8px;
143+
}
144+
145+
.product-card p {
146+
margin: 4px 0;
147+
font-size: 14px;
75148
}
76149
77-
.addon-info__links {
150+
.product-card__tags {
78151
display: flex;
79-
gap: 16px;
80-
margin-top: 8px;
81-
align-items: center;
152+
gap: 6px;
153+
margin-top: 6px;
154+
flex-wrap: wrap;
82155
}
83156
84-
.addon-info__link {
85-
display: inline-flex;
86-
align-items: center;
87-
gap: 4px;
157+
.product-card__tag {
158+
display: inline-block;
159+
padding: 2px 8px;
160+
font-size: 12px;
161+
border-radius: var(--border-radius-pill);
162+
background: var(--color-primary-element-light);
163+
color: var(--color-primary-element);
88164
}
89165
90-
.addon-info__empty {
91-
color: var(--color-text-maxcontrast);
166+
.product-card__actions {
167+
margin-top: 10px;
168+
}
169+
170+
.product-card__notes-toggle {
171+
background: none;
172+
border: 1px solid var(--color-border-dark);
173+
border-radius: var(--border-radius);
174+
padding: 4px 12px;
175+
font-size: 13px;
176+
cursor: pointer;
177+
color: var(--color-primary-element);
178+
}
179+
180+
.product-card__notes-toggle:hover {
181+
background: var(--color-background-hover);
182+
}
183+
184+
.product-card__release-notes {
185+
margin-top: 12px;
186+
padding: 12px;
187+
background: var(--color-background-hover);
188+
border-radius: var(--border-radius);
189+
font-size: 14px;
190+
line-height: 1.6;
191+
overflow-x: auto;
192+
}
193+
194+
.product-card__release-notes :deep(h2) {
195+
font-size: 14px;
196+
font-weight: 600;
197+
margin: 12px 0 6px;
198+
}
199+
200+
.product-card__release-notes :deep(h2:first-child) {
201+
margin-top: 0;
202+
}
203+
204+
.product-card__release-notes :deep(ul) {
205+
padding-left: 20px;
206+
margin: 6px 0;
207+
}
208+
209+
.product-card__release-notes :deep(li) {
210+
margin: 4px 0;
211+
}
212+
213+
.product-card__release-notes :deep(a) {
214+
color: var(--color-primary-element);
92215
}
93-
</style>
216+
</style>

src/services/releasesApi.ts

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
/**
2+
* @copyright Copyright (c) 2026 Sendent B.V.
3+
*
4+
* @author Sendent B.V. <info@sendent.com>
5+
*
6+
* @license AGPL-3.0-or-later
7+
*
8+
* This program is free software: you can redistribute it and/or modify
9+
* it under the terms of the GNU Affero General Public License as
10+
* published by the Free Software Foundation, either version 3 of the
11+
* License, or (at your option) any later version.
12+
*
13+
* This program is distributed in the hope that it will be useful,
14+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
15+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
16+
* GNU Affero General Public License for more details.
17+
*
18+
* You should have received a copy of the GNU Affero General Public License
19+
* along with this program. If not, see <http://www.gnu.org/licenses/>.
20+
*/
21+
import axios from '@nextcloud/axios'
22+
import { generateUrl } from '@nextcloud/router'
23+
import type { ReleaseEntry } from '../types/releases'
24+
25+
const baseUrl = '/apps/sendent/api/1.0'
26+
27+
/**
28+
* Fetches the latest release entries for all products.
29+
*
30+
* @return Map of product slug to release entry
31+
*/
32+
export async function fetchLatestReleases(): Promise<Record<string, ReleaseEntry>> {
33+
const url = generateUrl(`${baseUrl}/releases`)
34+
const response = await axios.get(url)
35+
return response.data
36+
}

0 commit comments

Comments
 (0)