Merge branch 'pulsejet/nn'
commit
dfa4ab6695
|
@ -23,6 +23,7 @@ return [
|
|||
['name' => 'Page#thisday', 'url' => '/thisday', 'verb' => 'GET'],
|
||||
['name' => 'Page#map', 'url' => '/map', 'verb' => 'GET'],
|
||||
['name' => 'Page#explore', 'url' => '/explore', 'verb' => 'GET'],
|
||||
['name' => 'Page#nxsetup', 'url' => '/nxsetup', 'verb' => 'GET'],
|
||||
|
||||
// Routes with params
|
||||
w(['name' => 'Page#folder', 'url' => '/folders/{path}', 'verb' => 'GET'], 'path'),
|
||||
|
|
|
@ -94,6 +94,7 @@ class DaysController extends GenericApiController
|
|||
$dayIds,
|
||||
$this->isRecursive(),
|
||||
$this->isArchive(),
|
||||
$this->isHidden(),
|
||||
$this->getTransformations(),
|
||||
);
|
||||
|
||||
|
@ -183,7 +184,7 @@ class DaysController extends GenericApiController
|
|||
{
|
||||
// Do not preload anything for native clients.
|
||||
// Since the contents of preloads are trusted, clients will not load locals.
|
||||
if (Util::callerIsNative()) {
|
||||
if (Util::callerIsNative() || $this->noPreload()) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -211,6 +212,7 @@ class DaysController extends GenericApiController
|
|||
$preloadDayIds,
|
||||
$this->isRecursive(),
|
||||
$this->isArchive(),
|
||||
$this->isHidden(),
|
||||
$transforms,
|
||||
);
|
||||
|
||||
|
@ -276,6 +278,16 @@ class DaysController extends GenericApiController
|
|||
return null !== $this->request->getParam('archive');
|
||||
}
|
||||
|
||||
private function isHidden()
|
||||
{
|
||||
return null !== $this->request->getParam('hidden');
|
||||
}
|
||||
|
||||
private function noPreload()
|
||||
{
|
||||
return null !== $this->request->getParam('nopreload');
|
||||
}
|
||||
|
||||
private function isMonthView()
|
||||
{
|
||||
return null !== $this->request->getParam('monthView');
|
||||
|
|
|
@ -234,4 +234,14 @@ class PageController extends Controller
|
|||
{
|
||||
return $this->main();
|
||||
}
|
||||
|
||||
/**
|
||||
* @NoAdminRequired
|
||||
*
|
||||
* @NoCSRFRequired
|
||||
*/
|
||||
public function nxsetup()
|
||||
{
|
||||
return $this->main();
|
||||
}
|
||||
}
|
||||
|
|
|
@ -10,23 +10,15 @@ trait TimelineQueryCTE
|
|||
* CTE to get all files recursively in the given top folders
|
||||
* :topFolderIds - The top folders to get files from.
|
||||
*
|
||||
* @param bool $noHidden Whether to filter out files in hidden folders
|
||||
* @param bool $hidden Whether to include files in hidden folders
|
||||
* If the top folder is hidden, the files in it will still be returned
|
||||
* Hidden files are marked as such in the "hidden" field
|
||||
*/
|
||||
protected static function CTE_FOLDERS_ALL(bool $noHidden): string
|
||||
protected static function CTE_FOLDERS_ALL(bool $hidden): string
|
||||
{
|
||||
// Whether to filter out the archive folder
|
||||
$CLS_HIDDEN_JOIN = $noHidden ? "f.name NOT LIKE '.%'" : '1 = 1';
|
||||
|
||||
// Filter out folder MIME types
|
||||
$FOLDER_MIME_QUERY = "SELECT MAX(id) FROM *PREFIX*mimetypes WHERE mimetype = 'httpd/unix-directory'";
|
||||
|
||||
// Select filecache as f
|
||||
$BASE_QUERY = 'SELECT f.fileid, f.name FROM *PREFIX*filecache f';
|
||||
|
||||
// From top folders
|
||||
$CLS_TOP_FOLDER = 'f.fileid IN (:topFolderIds)';
|
||||
|
||||
// Select 1 if there is a .nomedia file in the folder
|
||||
$SEL_NOMEDIA = "SELECT 1 FROM *PREFIX*filecache f2
|
||||
WHERE (f2.parent = f.fileid)
|
||||
|
@ -35,22 +27,29 @@ trait TimelineQueryCTE
|
|||
// Check no nomedia file exists in the folder
|
||||
$CLS_NOMEDIA = "NOT EXISTS ({$SEL_NOMEDIA})";
|
||||
|
||||
// Whether to filter out hidden folders
|
||||
$CLS_HIDDEN_JOIN = $hidden ? '1 = 1' : "f.name NOT LIKE '.%'";
|
||||
|
||||
return
|
||||
"*PREFIX*cte_folders_all(fileid, name) AS (
|
||||
{$BASE_QUERY}
|
||||
"*PREFIX*cte_folders_all(fileid, name, hidden) AS (
|
||||
SELECT f.fileid, f.name,
|
||||
(0) AS hidden
|
||||
FROM *PREFIX*filecache f
|
||||
WHERE (
|
||||
{$CLS_TOP_FOLDER} AND
|
||||
f.fileid IN (:topFolderIds) AND
|
||||
{$CLS_NOMEDIA}
|
||||
)
|
||||
|
||||
UNION ALL
|
||||
|
||||
{$BASE_QUERY}
|
||||
SELECT f.fileid, f.name,
|
||||
(CASE WHEN c.hidden = 1 OR f.name LIKE '.%' THEN 1 ELSE 0 END) AS hidden
|
||||
FROM *PREFIX*filecache f
|
||||
INNER JOIN *PREFIX*cte_folders_all c
|
||||
ON (
|
||||
f.parent = c.fileid AND
|
||||
f.mimetype = ({$FOLDER_MIME_QUERY}) AND
|
||||
{$CLS_HIDDEN_JOIN}
|
||||
({$CLS_HIDDEN_JOIN})
|
||||
)
|
||||
WHERE (
|
||||
{$CLS_NOMEDIA}
|
||||
|
@ -58,19 +57,25 @@ trait TimelineQueryCTE
|
|||
)";
|
||||
}
|
||||
|
||||
/** CTE to get all folders recursively in the given top folders excluding archive */
|
||||
protected static function CTE_FOLDERS(): string
|
||||
/**
|
||||
* CTE to get all folders recursively in the given top folders.
|
||||
*
|
||||
* @param bool $hidden Whether to include files in hidden folders
|
||||
*/
|
||||
protected static function CTE_FOLDERS(bool $hidden): string
|
||||
{
|
||||
$cte = '*PREFIX*cte_folders AS (
|
||||
$CLS_HIDDEN = $hidden ? 'MIN(hidden)' : '0';
|
||||
|
||||
$cte = "*PREFIX*cte_folders AS (
|
||||
SELECT
|
||||
fileid
|
||||
fileid, ({$CLS_HIDDEN}) AS hidden
|
||||
FROM
|
||||
*PREFIX*cte_folders_all
|
||||
GROUP BY
|
||||
fileid
|
||||
)';
|
||||
)";
|
||||
|
||||
return self::bundleCTEs([self::CTE_FOLDERS_ALL(true), $cte]);
|
||||
return self::bundleCTEs([self::CTE_FOLDERS_ALL($hidden), $cte]);
|
||||
}
|
||||
|
||||
/** CTE to get all archive folders recursively in the given top folders */
|
||||
|
@ -94,7 +99,7 @@ trait TimelineQueryCTE
|
|||
ON (f.parent = c.fileid)
|
||||
)";
|
||||
|
||||
return self::bundleCTEs([self::CTE_FOLDERS_ALL(false), $cte]);
|
||||
return self::bundleCTEs([self::CTE_FOLDERS_ALL(true), $cte]);
|
||||
}
|
||||
|
||||
protected static function bundleCTEs(array $ctes): string
|
||||
|
|
|
@ -60,6 +60,7 @@ trait TimelineQueryDays
|
|||
* @param int[] $day_ids The day ids to fetch
|
||||
* @param bool $recursive If the query should be recursive
|
||||
* @param bool $archive If the query should include only the archive folder
|
||||
* @param bool $hidden If the query should include hidden files
|
||||
* @param array $queryTransforms The query transformations to apply
|
||||
*
|
||||
* @return array An array of day responses
|
||||
|
@ -68,6 +69,7 @@ trait TimelineQueryDays
|
|||
?array $day_ids,
|
||||
bool $recursive,
|
||||
bool $archive,
|
||||
bool $hidden,
|
||||
array $queryTransforms = []
|
||||
): array {
|
||||
$query = $this->connection->getQueryBuilder();
|
||||
|
@ -82,6 +84,11 @@ trait TimelineQueryDays
|
|||
->from('memories', 'm')
|
||||
;
|
||||
|
||||
// Add hidden field
|
||||
if ($hidden) {
|
||||
$query->addSelect('cte_f.hidden');
|
||||
}
|
||||
|
||||
// JOIN with mimetypes to get the mimetype
|
||||
$query->join('f', 'mimetypes', 'mimetypes', $query->expr()->eq('f.mimetype', 'mimetypes.id'));
|
||||
|
||||
|
@ -104,7 +111,7 @@ trait TimelineQueryDays
|
|||
$this->applyAllTransforms($queryTransforms, $query, false);
|
||||
|
||||
// JOIN with filecache for existing files
|
||||
$query = $this->joinFilecache($query, null, $recursive, $archive);
|
||||
$query = $this->joinFilecache($query, null, $recursive, $archive, $hidden);
|
||||
|
||||
// FETCH all photos in this day
|
||||
$day = $this->executeQueryWithCTEs($query)->fetchAll();
|
||||
|
@ -124,9 +131,9 @@ trait TimelineQueryDays
|
|||
$types = $query->getParameterTypes();
|
||||
|
||||
// Get SQL
|
||||
$CTE_SQL = \array_key_exists('cteFoldersArchive', $params) && $params['cteFoldersArchive']
|
||||
$CTE_SQL = \array_key_exists('cteFoldersArchive', $params)
|
||||
? self::CTE_FOLDERS_ARCHIVE()
|
||||
: self::CTE_FOLDERS();
|
||||
: self::CTE_FOLDERS(\array_key_exists('cteIncludeHidden', $params));
|
||||
|
||||
// Add WITH clause if needed
|
||||
if (false !== strpos($sql, 'cte_folders')) {
|
||||
|
@ -143,12 +150,14 @@ trait TimelineQueryDays
|
|||
* @param TimelineRoot $root Either the top folder or null for all
|
||||
* @param bool $recursive Whether to get the days recursively
|
||||
* @param bool $archive Whether to get the days only from the archive folder
|
||||
* @param bool $hidden Whether to include hidden files
|
||||
*/
|
||||
public function joinFilecache(
|
||||
IQueryBuilder $query,
|
||||
?TimelineRoot $root = null,
|
||||
bool $recursive = true,
|
||||
bool $archive = false
|
||||
bool $archive = false,
|
||||
bool $hidden = false
|
||||
): IQueryBuilder {
|
||||
// This will throw if the root is illegally empty
|
||||
$root = $this->root($root);
|
||||
|
@ -163,7 +172,7 @@ trait TimelineQueryDays
|
|||
$pathOp = null;
|
||||
if ($recursive) {
|
||||
// Join with folders CTE
|
||||
$this->addSubfolderJoinParams($query, $root, $archive);
|
||||
$this->addSubfolderJoinParams($query, $root, $archive, $hidden);
|
||||
$query->innerJoin('f', 'cte_folders', 'cte_f', $query->expr()->eq('f.parent', 'cte_f.fileid'));
|
||||
} else {
|
||||
// If getting non-recursively folder only check for parent
|
||||
|
@ -218,6 +227,12 @@ trait TimelineQueryDays
|
|||
}
|
||||
unset($row['categoryid']);
|
||||
|
||||
// Get hidden field if present
|
||||
if (\array_key_exists('hidden', $row) && $row['hidden']) {
|
||||
$row['ishidden'] = 1;
|
||||
}
|
||||
unset($row['hidden']);
|
||||
|
||||
// All cluster transformations
|
||||
ClustersBackend\Manager::applyDayPostTransforms($this->request, $row);
|
||||
|
||||
|
@ -238,10 +253,18 @@ trait TimelineQueryDays
|
|||
private function addSubfolderJoinParams(
|
||||
IQueryBuilder &$query,
|
||||
TimelineRoot &$root,
|
||||
bool $archive
|
||||
bool $archive,
|
||||
bool $hidden
|
||||
) {
|
||||
// Add query parameters
|
||||
$query->setParameter('topFolderIds', $root->getIds(), IQueryBuilder::PARAM_INT_ARRAY);
|
||||
$query->setParameter('cteFoldersArchive', $archive, IQueryBuilder::PARAM_BOOL);
|
||||
|
||||
if ($archive) {
|
||||
$query->setParameter('cteFoldersArchive', true, IQueryBuilder::PARAM_BOOL);
|
||||
}
|
||||
|
||||
if ($hidden) {
|
||||
$query->setParameter('cteIncludeHidden', true, IQueryBuilder::PARAM_BOOL);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -71,6 +71,16 @@ class Version505000Date20230821044807 extends SimpleMigrationStep
|
|||
{
|
||||
// extracts the epoch value from the EXIF json and stores it in the epoch column
|
||||
try {
|
||||
// get count of rows to update
|
||||
$query = $this->dbc->getQueryBuilder();
|
||||
$maxCount = $query
|
||||
->select($query->createFunction('COUNT(m.fileid)'))
|
||||
->from('memories', 'm')
|
||||
->executeQuery()
|
||||
->fetchOne()
|
||||
;
|
||||
$output->startProgress($maxCount);
|
||||
|
||||
// get the required records
|
||||
$result = $this->dbc->getQueryBuilder()
|
||||
->select('m.id', 'm.exif')
|
||||
|
@ -104,6 +114,8 @@ class Version505000Date20230821044807 extends SimpleMigrationStep
|
|||
}
|
||||
} catch (\Exception $e) {
|
||||
continue;
|
||||
} finally {
|
||||
$output->advance();
|
||||
}
|
||||
|
||||
// commit every 50 rows
|
||||
|
@ -119,8 +131,8 @@ class Version505000Date20230821044807 extends SimpleMigrationStep
|
|||
// close the cursor
|
||||
$result->closeCursor();
|
||||
} catch (\Exception $e) {
|
||||
error_log('Automatic migration failed: '.$e->getMessage());
|
||||
error_log('Please run occ memories:index -f');
|
||||
$output->warning('Automatic migration failed: '.$e->getMessage());
|
||||
$output->warning('Please run occ memories:index -f');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
15
src/App.vue
15
src/App.vue
|
@ -1,5 +1,7 @@
|
|||
<template>
|
||||
<FirstStart v-if="isFirstStart" />
|
||||
<router-view v-if="onlyRouterView" />
|
||||
|
||||
<FirstStart v-else-if="isFirstStart" />
|
||||
|
||||
<NcContent
|
||||
app-name="memories"
|
||||
|
@ -195,6 +197,10 @@ export default defineComponent({
|
|||
return t('memories', 'People');
|
||||
},
|
||||
|
||||
onlyRouterView(): boolean {
|
||||
return ['nxsetup'].includes(this.$route.name ?? '');
|
||||
},
|
||||
|
||||
isFirstStart(): boolean {
|
||||
return this.config.timeline_path === '_empty_' && !this.routeIsPublic && !this.$route.query.noinit;
|
||||
},
|
||||
|
@ -304,13 +310,10 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
async beforeMount() {
|
||||
if ('serviceWorker' in navigator) {
|
||||
// Check if dev instance
|
||||
if (window.location.hostname === 'localhost') {
|
||||
// Disable on dev instances
|
||||
console.warn('Service Worker is not enabled on localhost.');
|
||||
return;
|
||||
}
|
||||
|
||||
} else if ('serviceWorker' in navigator) {
|
||||
// Get the config before loading
|
||||
const previousVersion = staticConfig.getSync('version');
|
||||
|
||||
|
|
|
@ -42,7 +42,6 @@
|
|||
import { defineComponent } from 'vue';
|
||||
import type { Component } from 'vue';
|
||||
|
||||
import axios from '@nextcloud/axios';
|
||||
import { translate as t } from '@nextcloud/l10n';
|
||||
|
||||
import ClusterHList from './ClusterHList.vue';
|
||||
|
@ -58,7 +57,6 @@ import MapIcon from 'vue-material-design-icons/Map.vue';
|
|||
import CogIcon from 'vue-material-design-icons/Cog.vue';
|
||||
|
||||
import config from '../services/static-config';
|
||||
import { API } from '../services/API';
|
||||
import * as dav from '../services/dav';
|
||||
|
||||
import type { ICluster, IConfig } from '../types';
|
||||
|
|
|
@ -110,6 +110,10 @@
|
|||
>
|
||||
{{ folder.name }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
|
||||
<NcButton @click="runNxSetup()" type="secondary">
|
||||
{{ t('memories', 'Run initial device setup') }}
|
||||
</NcButton>
|
||||
</NcAppSettingsSection>
|
||||
|
||||
<NcAppSettingsSection id="folders-settings" :title="t('memories', 'Folders')">
|
||||
|
@ -288,12 +292,16 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
// --------------- Native APIs start -----------------------------
|
||||
async refreshNativeConfig() {
|
||||
this.localFolders = await nativex.getLocalFolders();
|
||||
refreshNativeConfig() {
|
||||
this.localFolders = nativex.getLocalFolders();
|
||||
},
|
||||
|
||||
async updateDeviceFolders() {
|
||||
await nativex.setLocalFolders(this.localFolders);
|
||||
updateDeviceFolders() {
|
||||
nativex.setLocalFolders(this.localFolders);
|
||||
},
|
||||
|
||||
runNxSetup() {
|
||||
this.$router.replace('/nxsetup');
|
||||
},
|
||||
|
||||
async logout() {
|
||||
|
|
|
@ -146,6 +146,8 @@ export default defineComponent({
|
|||
numCols: 0,
|
||||
/** Header rows for dayId key */
|
||||
heads: {} as { [dayid: number]: IHeadRow },
|
||||
/** Current list (days response) was loaded from cache */
|
||||
daysIsCache: false,
|
||||
|
||||
/** Size of outer container [w, h] */
|
||||
containerSize: [0, 0] as [number, number],
|
||||
|
@ -691,10 +693,10 @@ export default defineComponent({
|
|||
try {
|
||||
if ((cache = await utils.getCachedData(cacheUrl))) {
|
||||
if (this.routeHasNative) {
|
||||
await nativex.extendDaysWithLocal(cache);
|
||||
cache = nativex.mergeDays(cache, await nativex.getLocalDays());
|
||||
}
|
||||
|
||||
await this.processDays(cache);
|
||||
await this.processDays(cache, true);
|
||||
this.updateLoading(-1);
|
||||
}
|
||||
} catch {
|
||||
|
@ -714,12 +716,12 @@ export default defineComponent({
|
|||
|
||||
// Extend with native days
|
||||
if (this.routeHasNative) {
|
||||
await nativex.extendDaysWithLocal(data);
|
||||
data = nativex.mergeDays(data, await nativex.getLocalDays());
|
||||
}
|
||||
|
||||
// Make sure we're still on the same page
|
||||
if (this.state !== startState) return;
|
||||
await this.processDays(data);
|
||||
await this.processDays(data, false);
|
||||
} catch (e) {
|
||||
if (!utils.isNetworkError(e)) {
|
||||
showError(e?.response?.data?.message ?? e.message);
|
||||
|
@ -731,8 +733,12 @@ export default defineComponent({
|
|||
}
|
||||
},
|
||||
|
||||
/** Process the data for days call including folders */
|
||||
async processDays(data: IDay[]) {
|
||||
/**
|
||||
* Process the data for days call including folders
|
||||
* @param data Days data
|
||||
* @param cache Whether the data was from cache
|
||||
*/
|
||||
async processDays(data: IDay[], cache: boolean) {
|
||||
if (!data || !this.state) return;
|
||||
|
||||
const list: typeof this.list = [];
|
||||
|
@ -824,6 +830,9 @@ export default defineComponent({
|
|||
this.loadedDays.clear();
|
||||
this.sizedDays.clear();
|
||||
|
||||
// Mark if the data was from cache
|
||||
this.daysIsCache = cache;
|
||||
|
||||
// Iterate the preload map
|
||||
// Now the inner detail objects are reactive
|
||||
for (const dayId in preloads) {
|
||||
|
@ -844,8 +853,16 @@ export default defineComponent({
|
|||
},
|
||||
|
||||
/** API url for Day call */
|
||||
getDayUrl(dayId: number | string) {
|
||||
return API.Q(API.DAY(dayId), this.getQuery());
|
||||
getDayUrl(dayIds: number[]) {
|
||||
const query = this.getQuery();
|
||||
|
||||
// If any day in the fetch list has local images we need to fetch
|
||||
// the remote hidden images for the merging to happen correctly
|
||||
if (this.routeHasNative && dayIds.some((id) => this.heads[id]?.day?.haslocal)) {
|
||||
query[DaysFilterType.HIDDEN] = '1';
|
||||
}
|
||||
|
||||
return API.Q(API.DAY(dayIds.join(',')), query);
|
||||
},
|
||||
|
||||
/** Fetch image data for one dayId */
|
||||
|
@ -858,20 +875,31 @@ export default defineComponent({
|
|||
this.sizedDays.add(dayId);
|
||||
|
||||
// Look for cache
|
||||
const cacheUrl = this.getDayUrl(dayId);
|
||||
const cacheUrl = this.getDayUrl([dayId]);
|
||||
try {
|
||||
const cache = await utils.getCachedData<IPhoto[]>(cacheUrl);
|
||||
if (cache) {
|
||||
// Cache only contains remote images; update from local too
|
||||
if (this.routeHasNative) {
|
||||
await nativex.extendDayWithLocal(dayId, cache);
|
||||
if (this.routeHasNative && head.day?.haslocal) {
|
||||
nativex.mergeDay(cache, await nativex.getLocalDay(dayId));
|
||||
}
|
||||
|
||||
// Process the cache
|
||||
utils.removeHiddenPhotos(cache);
|
||||
|
||||
// If this is a cached response and the list is not, then we don't
|
||||
// want to take any destructive actions like removing a day.
|
||||
// 1. If a day is removed then it will not be fetched again
|
||||
// 2. But it probably does exist on the server
|
||||
// 3. Since days could be fetched, the user probably is connected
|
||||
if (!this.daysIsCache && !cache.length) {
|
||||
throw new Error('Skipping empty cache because view is fresh');
|
||||
}
|
||||
|
||||
this.processDay(dayId, cache);
|
||||
}
|
||||
} catch {
|
||||
console.warn(`Failed to process day cache: ${cacheUrl}`);
|
||||
} catch (e) {
|
||||
console.warn(`Failed or skipped processing day cache: ${cacheUrl}`, e);
|
||||
}
|
||||
|
||||
// Aggregate fetch requests
|
||||
|
@ -900,8 +928,7 @@ export default defineComponent({
|
|||
for (const dayId of dayIds) dayMap.set(dayId, []);
|
||||
|
||||
// Construct URL
|
||||
const dayStr = dayIds.join(',');
|
||||
const url = this.getDayUrl(dayStr);
|
||||
const url = this.getDayUrl(dayIds);
|
||||
this.fetchDayQueue = [];
|
||||
|
||||
try {
|
||||
|
@ -911,7 +938,7 @@ export default defineComponent({
|
|||
const data = res.data;
|
||||
|
||||
// Check if the state has changed
|
||||
if (this.state !== startState || this.getDayUrl(dayStr) !== url) {
|
||||
if (this.state !== startState || this.getDayUrl(dayIds) !== url) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -929,21 +956,28 @@ export default defineComponent({
|
|||
// creates circular references which cannot be stringified
|
||||
for (const [dayId, photos] of dayMap) {
|
||||
if (photos.length === 0) continue;
|
||||
utils.cacheData(this.getDayUrl(dayId), photos);
|
||||
utils.cacheData(this.getDayUrl([dayId]), photos);
|
||||
}
|
||||
|
||||
// Get local images if we are running in native environment.
|
||||
// Get them all together for each day here.
|
||||
if (this.routeHasNative) {
|
||||
await Promise.all(
|
||||
Array.from(dayMap.entries()).map(([dayId, photos]) => {
|
||||
return nativex.extendDayWithLocal(dayId, photos);
|
||||
const promises = Array.from(dayMap.entries())
|
||||
.filter(([dayId, photos]) => {
|
||||
return this.heads[dayId]?.day?.haslocal;
|
||||
})
|
||||
);
|
||||
.map(async ([dayId, photos]) => {
|
||||
nativex.processFreshServerDay(dayId, photos);
|
||||
nativex.mergeDay(photos, await nativex.getLocalDay(dayId));
|
||||
});
|
||||
if (promises.length) await Promise.all(promises);
|
||||
}
|
||||
|
||||
// Process each day as needed
|
||||
for (const [dayId, photos] of dayMap) {
|
||||
// Remove hidden photos
|
||||
utils.removeHiddenPhotos(photos);
|
||||
|
||||
// Check if the response has any delta
|
||||
const head = this.heads[dayId];
|
||||
if (head?.day?.detail?.length === photos.length) {
|
||||
|
|
|
@ -807,7 +807,7 @@ export default defineComponent({
|
|||
if (!isvideo) {
|
||||
// Try local file if NativeX is available
|
||||
if (photo.auid && nativex.has()) {
|
||||
highSrc.push(nativex.API.IMAGE_FULL(photo.auid));
|
||||
highSrc.push(nativex.NAPI.IMAGE_FULL(photo.auid));
|
||||
}
|
||||
|
||||
// Decodable full resolution image
|
||||
|
|
368
src/native.ts
368
src/native.ts
|
@ -1,368 +0,0 @@
|
|||
import axios from '@nextcloud/axios';
|
||||
import { generateUrl } from '@nextcloud/router';
|
||||
import type { IDay, IPhoto } from './types';
|
||||
import { API as SAPI } from './services/API';
|
||||
const euc = encodeURIComponent;
|
||||
|
||||
/** Access NativeX over localhost */
|
||||
const BASE_URL = 'http://127.0.0.1';
|
||||
|
||||
/** NativeX asynchronous API */
|
||||
export const API = {
|
||||
/**
|
||||
* Local days API.
|
||||
* @regex ^/api/days$
|
||||
* @returns {IDay[]} for all locally available days.
|
||||
*/
|
||||
DAYS: () => `${BASE_URL}/api/days`,
|
||||
/**
|
||||
* Local photos API.
|
||||
* @regex ^/api/days/\d+$
|
||||
* @param dayId Day ID to fetch photos for
|
||||
* @returns {IPhoto[]} for all locally available photos for this day.
|
||||
*/
|
||||
DAY: (dayId: number) => `${BASE_URL}/api/days/${dayId}`,
|
||||
|
||||
/**
|
||||
* Local photo metadata API.
|
||||
* @regex ^/api/image/info/\d+$
|
||||
* @param fileId File ID of the photo
|
||||
* @returns {IImageInfo} for the given file ID (local).
|
||||
*/
|
||||
IMAGE_INFO: (fileId: number) => `${BASE_URL}/api/image/info/${fileId}`,
|
||||
|
||||
/**
|
||||
* Delete files using local fileids.
|
||||
* @regex ^/api/image/delete/\d+(,\d+)*$
|
||||
* @param fileIds List of AUIDs to delete
|
||||
* @param dry (Query) Only check for confirmation and count of local files
|
||||
* @returns {void}
|
||||
* @throws Return an error code if the user denies the deletion.
|
||||
*/
|
||||
IMAGE_DELETE: (auids: number[]) => `${BASE_URL}/api/image/delete/${auids.join(',')}`,
|
||||
|
||||
/**
|
||||
* Local photo preview API.
|
||||
* @regex ^/image/preview/\d+$
|
||||
* @param fileId File ID of the photo
|
||||
* @returns {Blob} JPEG preview of the photo.
|
||||
*/
|
||||
IMAGE_PREVIEW: (fileId: number) => `${BASE_URL}/image/preview/${fileId}`,
|
||||
/**
|
||||
* Local photo full API.
|
||||
* @regex ^/image/full/\d+$
|
||||
* @param auid AUID of the photo
|
||||
* @returns {Blob} JPEG full image of the photo.
|
||||
*/
|
||||
IMAGE_FULL: (auid: number) => `${BASE_URL}/image/full/${auid}`,
|
||||
|
||||
/**
|
||||
* Share a URL with native page.
|
||||
* The native client MUST NOT download the object but share the URL directly.
|
||||
* @regex ^/api/share/url/.+$
|
||||
* @param url URL to share (double-encoded)
|
||||
* @returns {void}
|
||||
*/
|
||||
SHARE_URL: (url: string) => `${BASE_URL}/api/share/url/${euc(euc(url))}`,
|
||||
/**
|
||||
* Share an object (as blob) natively using a given URL.
|
||||
* The native client MUST download the object using a download manager
|
||||
* and immediately prompt the user to download it. The asynchronous call
|
||||
* must return only after the object has been downloaded.
|
||||
* @regex ^/api/share/blob/.+$
|
||||
* @param url URL to share (double-encoded)
|
||||
* @returns {void}
|
||||
*/
|
||||
SHARE_BLOB: (url: string) => `${BASE_URL}/api/share/blob/${euc(euc(url))}`,
|
||||
/**
|
||||
* Share a local file (as blob) with native page.
|
||||
* @regex ^/api/share/local/\d+$
|
||||
* @param auid AUID of the photo
|
||||
* @returns {void}
|
||||
*/
|
||||
SHARE_LOCAL: (auid: number) => `${BASE_URL}/api/share/local/${auid}`,
|
||||
|
||||
/**
|
||||
* Get list of local folders configuration.
|
||||
* @regex ^/api/config/local-folders$
|
||||
* @returns {LocalFolderConfig[]} List of local folders configuration
|
||||
*/
|
||||
CONFIG_LOCAL_FOLDERS: () => `${BASE_URL}/api/config/local-folders`,
|
||||
};
|
||||
|
||||
/** NativeX synchronous API. */
|
||||
export type NativeX = {
|
||||
/**
|
||||
* Check if the native interface is available.
|
||||
* @returns Should always return true.
|
||||
*/
|
||||
isNative: () => boolean;
|
||||
|
||||
/**
|
||||
* Set the theme color of the app.
|
||||
* @param color Color to set
|
||||
* @param isDark Whether the theme is dark (for navigation bar)
|
||||
*/
|
||||
setThemeColor: (color: string, isDark: boolean) => void;
|
||||
|
||||
/**
|
||||
* Play a tap sound for UI interaction.
|
||||
*/
|
||||
playTouchSound: () => void;
|
||||
|
||||
/**
|
||||
* Make a native toast to the user.
|
||||
* @param message Message to show
|
||||
* @param long Whether the toast should be shown for a long time
|
||||
*/
|
||||
toast: (message: string, long?: boolean) => void;
|
||||
|
||||
/**
|
||||
* Start downloading a file from a given URL.
|
||||
* @param url URL to download from
|
||||
* @param filename Filename to save as
|
||||
* @details An error must be shown to the user natively if the download fails.
|
||||
*/
|
||||
downloadFromUrl: (url: string, filename: string) => void;
|
||||
|
||||
/**
|
||||
* Play a video from the given AUID or URL(s).
|
||||
* @param auid AUID of file (will play local if available)
|
||||
* @param fileid File ID of the video (only used for file tracking)
|
||||
* @param urlArray JSON-encoded array of URLs to play
|
||||
* @details The URL array may contain multiple URLs, e.g. direct playback
|
||||
* and HLS separately. The native client must try to play the first URL.
|
||||
*/
|
||||
playVideo: (auid: string, fileid: string, urlArray: string) => void;
|
||||
/**
|
||||
* Destroy the video player.
|
||||
* @param fileid File ID of the video
|
||||
* @details The native client must destroy the video player and free up resources.
|
||||
* If the fileid doesn't match the playing video, the call must be ignored.
|
||||
*/
|
||||
destroyVideo: (fileid: string) => void;
|
||||
|
||||
/**
|
||||
* Set the local folders configuration to show in the timeline.
|
||||
* @param json JSON-encoded array of LocalFolderConfig
|
||||
*/
|
||||
configSetLocalFolders: (json: string) => void;
|
||||
|
||||
/**
|
||||
* Start the login process
|
||||
* @param baseUrl Base URL of the Nextcloud instance
|
||||
* @param loginFlowUrl URL to start the login flow
|
||||
*/
|
||||
login: (baseUrl: string, loginFlowUrl: string) => void;
|
||||
|
||||
/**
|
||||
* Log out from Nextcloud and delete the tokens.
|
||||
*/
|
||||
logout: () => void;
|
||||
|
||||
/**
|
||||
* Reload the app.
|
||||
*/
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
/** Setting of whether a local folder is enabled */
|
||||
export type LocalFolderConfig = {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/** The native interface is a global object that is injected by the native app. */
|
||||
const nativex: NativeX = globalThis.nativex;
|
||||
|
||||
/**
|
||||
* @returns Whether the native interface is available.
|
||||
*/
|
||||
export function has() {
|
||||
return !!nativex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the theme color of the app to default.
|
||||
*/
|
||||
export async function setTheme(color?: string, dark?: boolean) {
|
||||
if (!has()) return;
|
||||
|
||||
color ??= getComputedStyle(document.body).getPropertyValue('--color-main-background');
|
||||
dark ??=
|
||||
(document.body.hasAttribute('data-theme-default') && window.matchMedia('(prefers-color-scheme: dark)').matches) ||
|
||||
document.body.hasAttribute('data-theme-dark') ||
|
||||
document.body.hasAttribute('data-theme-dark-highcontrast');
|
||||
nativex?.setThemeColor?.(color, dark);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a file from the given URL.
|
||||
*/
|
||||
export async function downloadFromUrl(url: string) {
|
||||
// Make HEAD request to get filename
|
||||
const res = await axios.head(url);
|
||||
let filename = res.headers['content-disposition'];
|
||||
if (res.status !== 200 || !filename) return;
|
||||
|
||||
// Extract filename from header without quotes
|
||||
filename = filename.split('filename="')[1].slice(0, -1);
|
||||
|
||||
// Hand off to download manager
|
||||
nativex?.downloadFromUrl?.(addOrigin(url), filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play touch sound.
|
||||
*/
|
||||
export async function playTouchSound() {
|
||||
nativex?.playTouchSound?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Play a video from the given URL.
|
||||
* @param photo Photo to play
|
||||
* @param urls URLs to play (remote)
|
||||
*/
|
||||
export async function playVideo(photo: IPhoto, urls: string[]) {
|
||||
const auid = photo.auid ?? photo.fileid;
|
||||
nativex?.playVideo?.(auid.toString(), photo.fileid.toString(), JSON.stringify(urls.map(addOrigin)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the video player.
|
||||
*/
|
||||
export async function destroyVideo(photo: IPhoto) {
|
||||
nativex?.destroyVideo?.(photo.fileid.toString());
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a URL with native page.
|
||||
*/
|
||||
export async function shareUrl(url: string) {
|
||||
await axios.get(API.SHARE_URL(addOrigin(url)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a blob from the given URL and share it.
|
||||
*/
|
||||
export async function shareBlobFromUrl(url: string) {
|
||||
if (url.startsWith(BASE_URL)) {
|
||||
throw new Error('Cannot share localhost URL');
|
||||
}
|
||||
await axios.get(API.SHARE_BLOB(addOrigin(url)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a local file with native page.
|
||||
*/
|
||||
export async function shareLocal(photo: IPhoto) {
|
||||
if (!photo.auid) throw new Error('Cannot share local file without AUID');
|
||||
await axios.get(API.SHARE_LOCAL(photo.auid));
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend a list of days with local days.
|
||||
* Fetches the local days from the native interface.
|
||||
*/
|
||||
export async function extendDaysWithLocal(days: IDay[]) {
|
||||
if (!has()) return;
|
||||
|
||||
// Query native part
|
||||
const res = await fetch(API.DAYS());
|
||||
if (!res.ok) return;
|
||||
const local: IDay[] = await res.json();
|
||||
const remoteMap = new Map(days.map((d) => [d.dayid, d]));
|
||||
|
||||
// Merge local days into remote days
|
||||
for (const day of local) {
|
||||
const remote = remoteMap.get(day.dayid);
|
||||
if (remote) {
|
||||
remote.count = Math.max(remote.count, day.count);
|
||||
} else {
|
||||
days.push(day);
|
||||
}
|
||||
}
|
||||
|
||||
// TODO: sort depends on view
|
||||
// (but we show it for only timeline anyway for now)
|
||||
days.sort((a, b) => b.dayid - a.dayid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extend a list of photos with local photos.
|
||||
* Fetches the local photos from the native interface and filters out duplicates.
|
||||
*
|
||||
* @param dayId Day ID to append local photos to
|
||||
* @param photos List of photos to append to (duplicates will not be added)
|
||||
* @returns
|
||||
*/
|
||||
export async function extendDayWithLocal(dayId: number, photos: IPhoto[]) {
|
||||
if (!has()) return;
|
||||
|
||||
// Query native part
|
||||
const res = await fetch(API.DAY(dayId));
|
||||
if (!res.ok) return;
|
||||
|
||||
// Merge local photos into remote photos
|
||||
const localPhotos: IPhoto[] = await res.json();
|
||||
const serverAUIDs = new Set(photos.map((p) => p.auid));
|
||||
|
||||
// Filter out files that are only available locally
|
||||
const localOnly = localPhotos.filter((p) => !serverAUIDs.has(p.auid));
|
||||
localOnly.forEach((p) => (p.islocal = true));
|
||||
photos.push(...localOnly);
|
||||
|
||||
// Sort by epoch value
|
||||
photos.sort((a, b) => (b.epoch ?? 0) - (a.epoch ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Request deletion of local photos wherever available.
|
||||
* @param photos List of photos to delete
|
||||
* @returns The number of photos for which confirmation was received
|
||||
* @throws If the request fails
|
||||
*/
|
||||
export async function deleteLocalPhotos(photos: IPhoto[], dry: boolean = false): Promise<number> {
|
||||
if (!has()) return 0;
|
||||
|
||||
const auids = photos.map((p) => p.auid).filter((a) => !!a) as number[];
|
||||
const res = await axios.get(SAPI.Q(API.IMAGE_DELETE(auids), { dry }));
|
||||
return res.data.confirms ? res.data.count : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of local folders configuration.
|
||||
* Should be called only if NativeX is available.
|
||||
*/
|
||||
export async function getLocalFolders() {
|
||||
return (await axios.get<LocalFolderConfig[]>(API.CONFIG_LOCAL_FOLDERS())).data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set list of local folders configuration.
|
||||
*/
|
||||
export async function setLocalFolders(config: LocalFolderConfig[]) {
|
||||
nativex?.configSetLocalFolders(JSON.stringify(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out from Nextcloud and pass ahead.
|
||||
*/
|
||||
export async function logout() {
|
||||
await axios.get(generateUrl('logout'));
|
||||
if (!has()) window.location.reload();
|
||||
nativex?.logout();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add current origin to URL if doesn't have any protocol or origin.
|
||||
*/
|
||||
function addOrigin(url: string) {
|
||||
return url.match(/^(https?:)?\/\//)
|
||||
? url
|
||||
: url.startsWith('/')
|
||||
? `${location.origin}${url}`
|
||||
: `${location.origin}/${url}`;
|
||||
}
|
|
@ -0,0 +1,232 @@
|
|||
<template>
|
||||
<div class="nxsetup-outer">
|
||||
<XImg class="banner" :src="banner" :svg-tag="true" />
|
||||
|
||||
<div class="setup-section" v-if="step === 1">
|
||||
{{ t('memories', 'You are now logged in to the server!') }}
|
||||
<br /><br />
|
||||
{{
|
||||
t(
|
||||
'memories',
|
||||
'You can set up automatic uploads from this device using the Nextcloud mobile app. Click the button below to download the app, or skip this step and continue.'
|
||||
)
|
||||
}}
|
||||
<br />
|
||||
|
||||
<div class="buttons">
|
||||
<NcButton
|
||||
type="secondary"
|
||||
class="button"
|
||||
href="https://play.google.com/store/apps/details?id=com.nextcloud.client"
|
||||
>
|
||||
{{ t('memories', 'Set up automatic upload') }}
|
||||
</NcButton>
|
||||
|
||||
<NcButton type="primary" class="button" @click="step++">
|
||||
{{ t('memories', 'Continue') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setup-section" v-if="step === 2">
|
||||
{{
|
||||
t(
|
||||
'memories',
|
||||
'Memories can show local media on your device alongside the media on your server. This requires access to the media on this device.'
|
||||
)
|
||||
}}
|
||||
<br /><br />
|
||||
{{
|
||||
hasMediaPermission
|
||||
? t('memories', 'Access to media has been granted.')
|
||||
: t(
|
||||
'memories',
|
||||
'Access to media is not available yet. If the button below does not work, grant the permission through settings.'
|
||||
)
|
||||
}}
|
||||
|
||||
<div class="buttons">
|
||||
<NcButton type="secondary" class="button" @click="grantMediaPermission" v-if="!hasMediaPermission">
|
||||
{{ t('memories', 'Grant permissions') }}
|
||||
</NcButton>
|
||||
|
||||
<NcButton type="primary" class="button" @click="step += hasMediaPermission ? 1 : 2">
|
||||
{{ hasMediaPermission ? t('memories', 'Continue') : t('memories', 'Skip this step') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="setup-section" v-else-if="step === 3">
|
||||
{{ t('memories', 'Choose the folders on this device to show on your timeline.') }}
|
||||
{{
|
||||
t(
|
||||
'memories',
|
||||
'If no folders are visible here, you may need to grant the app storage permissions, or wait for the app to index your files.'
|
||||
)
|
||||
}}
|
||||
<br /><br />
|
||||
{{ t('memories', 'You can always change this in settings. Note that this does not affect automatic uploading.') }}
|
||||
<br />
|
||||
|
||||
<div id="folder-list">
|
||||
<div v-if="syncStatus != -1">
|
||||
{{ t('memories', 'Synchronizing local files ({n} done).', { n: syncStatus }) }}
|
||||
<br />
|
||||
{{ t('memories', 'This may take a while. Do not close this window.') }}
|
||||
</div>
|
||||
<template v-else>
|
||||
<NcCheckboxRadioSwitch
|
||||
v-for="folder in localFolders"
|
||||
:key="folder.id"
|
||||
:checked.sync="folder.enabled"
|
||||
@update:checked="updateDeviceFolders"
|
||||
type="switch"
|
||||
>
|
||||
{{ folder.name }}
|
||||
</NcCheckboxRadioSwitch>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="buttons">
|
||||
<NcButton type="secondary" class="button" @click="step++">
|
||||
{{ t('memories', 'Finish') }}
|
||||
</NcButton>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts">
|
||||
import { defineComponent } from 'vue';
|
||||
|
||||
import * as nativex from '../native';
|
||||
import * as util from '../services/utils';
|
||||
|
||||
import NcButton from '@nextcloud/vue/dist/Components/NcButton';
|
||||
const NcCheckboxRadioSwitch = () => import('@nextcloud/vue/dist/Components/NcCheckboxRadioSwitch');
|
||||
|
||||
import banner from '../assets/banner.svg';
|
||||
|
||||
export default defineComponent({
|
||||
name: 'NXSetup',
|
||||
|
||||
components: {
|
||||
NcButton,
|
||||
NcCheckboxRadioSwitch,
|
||||
},
|
||||
|
||||
data: () => ({
|
||||
banner,
|
||||
hasMediaPermission: false,
|
||||
step: util.uid ? 1 : 0,
|
||||
localFolders: [] as nativex.LocalFolderConfig[],
|
||||
syncStatus: -1,
|
||||
syncStatusWatch: 0,
|
||||
}),
|
||||
|
||||
watch: {
|
||||
step() {
|
||||
switch (this.step) {
|
||||
case 2:
|
||||
this.hasMediaPermission = nativex.configHasMediaPermission();
|
||||
break;
|
||||
case 3:
|
||||
this.localFolders = nativex.getLocalFolders();
|
||||
break;
|
||||
case 4:
|
||||
this.$router.push('/');
|
||||
break;
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
beforeMount() {
|
||||
if (!nativex.has() || !this.step) {
|
||||
this.$router.push('/');
|
||||
}
|
||||
},
|
||||
|
||||
async mounted() {
|
||||
await this.$nextTick();
|
||||
|
||||
// set nativex theme
|
||||
nativex.setTheme(getComputedStyle(document.body).getPropertyValue('--color-background-plain'));
|
||||
|
||||
// set up sync status watcher
|
||||
this.syncStatusWatch = window.setInterval(() => {
|
||||
if (this.hasMediaPermission && this.step === 3) {
|
||||
const newStatus = nativex.nativex.getSyncStatus();
|
||||
|
||||
// Refresh local folders if newly reached state -1
|
||||
if (newStatus === -1 && this.syncStatus !== -1) {
|
||||
this.localFolders = nativex.getLocalFolders();
|
||||
}
|
||||
|
||||
this.syncStatus = newStatus;
|
||||
}
|
||||
}, 500);
|
||||
},
|
||||
|
||||
beforeDestroy() {
|
||||
nativex.setTheme(); // reset theme
|
||||
window.clearInterval(this.syncStatusWatch);
|
||||
},
|
||||
|
||||
methods: {
|
||||
updateDeviceFolders() {
|
||||
nativex.setLocalFolders(this.localFolders);
|
||||
},
|
||||
|
||||
async grantMediaPermission() {
|
||||
await nativex.configAllowMedia();
|
||||
this.hasMediaPermission = nativex.configHasMediaPermission();
|
||||
},
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.nxsetup-outer {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background-color: var(--color-background-plain);
|
||||
color: var(--color-primary-text);
|
||||
text-align: center;
|
||||
|
||||
.setup-section {
|
||||
margin: 0 auto;
|
||||
width: 90%;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.banner {
|
||||
padding: 30px 20px;
|
||||
:deep > svg {
|
||||
width: 60%;
|
||||
max-width: 400px;
|
||||
}
|
||||
}
|
||||
|
||||
.buttons {
|
||||
margin-top: 20px;
|
||||
.button {
|
||||
margin: 10px auto;
|
||||
}
|
||||
}
|
||||
|
||||
#folder-list {
|
||||
background: var(--color-main-background);
|
||||
color: var(--color-main-text);
|
||||
padding: 10px;
|
||||
margin-top: 15px;
|
||||
border-radius: 20px;
|
||||
|
||||
.checkbox-radio-switch {
|
||||
margin-left: 10px;
|
||||
:deep .checkbox-radio-switch__label {
|
||||
min-height: unset;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -0,0 +1,191 @@
|
|||
const euc = encodeURIComponent;
|
||||
|
||||
/** Access NativeX over localhost */
|
||||
export const BASE_URL = 'http://127.0.0.1';
|
||||
|
||||
/** NativeX asynchronous API */
|
||||
export const NAPI = {
|
||||
/**
|
||||
* Local days API.
|
||||
* @regex ^/api/days$
|
||||
* @returns {IDay[]} for all locally available days.
|
||||
*/
|
||||
DAYS: () => `${BASE_URL}/api/days`,
|
||||
/**
|
||||
* Local photos API.
|
||||
* @regex ^/api/days/\d+$
|
||||
* @param dayId Day ID to fetch photos for
|
||||
* @returns {IPhoto[]} for all locally available photos for this day.
|
||||
*/
|
||||
DAY: (dayId: number) => `${BASE_URL}/api/days/${dayId}`,
|
||||
|
||||
/**
|
||||
* Local photo metadata API.
|
||||
* @regex ^/api/image/info/\d+$
|
||||
* @param fileId File ID of the photo
|
||||
* @returns {IImageInfo} for the given file ID (local).
|
||||
*/
|
||||
IMAGE_INFO: (fileId: number) => `${BASE_URL}/api/image/info/${fileId}`,
|
||||
|
||||
/**
|
||||
* Delete files using local fileids.
|
||||
* @regex ^/api/image/delete/\d+(,\d+)*$
|
||||
* @param fileIds List of AUIDs to delete
|
||||
* @param dry (Query) Only check for confirmation and count of local files
|
||||
* @returns {void}
|
||||
* @throws Return an error code if the user denies the deletion.
|
||||
*/
|
||||
IMAGE_DELETE: (auids: number[]) => `${BASE_URL}/api/image/delete/${auids.join(',')}`,
|
||||
|
||||
/**
|
||||
* Local photo preview API.
|
||||
* @regex ^/image/preview/\d+$
|
||||
* @param fileId File ID of the photo
|
||||
* @returns {Blob} JPEG preview of the photo.
|
||||
*/
|
||||
IMAGE_PREVIEW: (fileId: number) => `${BASE_URL}/image/preview/${fileId}`,
|
||||
/**
|
||||
* Local photo full API.
|
||||
* @regex ^/image/full/\d+$
|
||||
* @param auid AUID of the photo
|
||||
* @returns {Blob} JPEG full image of the photo.
|
||||
*/
|
||||
IMAGE_FULL: (auid: number) => `${BASE_URL}/image/full/${auid}`,
|
||||
|
||||
/**
|
||||
* Share a URL with native page.
|
||||
* The native client MUST NOT download the object but share the URL directly.
|
||||
* @regex ^/api/share/url/.+$
|
||||
* @param url URL to share (double-encoded)
|
||||
* @returns {void}
|
||||
*/
|
||||
SHARE_URL: (url: string) => `${BASE_URL}/api/share/url/${euc(euc(url))}`,
|
||||
/**
|
||||
* Share an object (as blob) natively using a given URL.
|
||||
* The native client MUST download the object using a download manager
|
||||
* and immediately prompt the user to download it. The asynchronous call
|
||||
* must return only after the object has been downloaded.
|
||||
* @regex ^/api/share/blob/.+$
|
||||
* @param url URL to share (double-encoded)
|
||||
* @returns {void}
|
||||
*/
|
||||
SHARE_BLOB: (url: string) => `${BASE_URL}/api/share/blob/${euc(euc(url))}`,
|
||||
/**
|
||||
* Share a local file (as blob) with native page.
|
||||
* @regex ^/api/share/local/\d+$
|
||||
* @param auid AUID of the photo
|
||||
* @returns {void}
|
||||
*/
|
||||
SHARE_LOCAL: (auid: number) => `${BASE_URL}/api/share/local/${auid}`,
|
||||
|
||||
/**
|
||||
* Allow usage of local media (permissions request)
|
||||
* @param val Allow or disallow media
|
||||
* @returns
|
||||
*/
|
||||
CONFIG_ALLOW_MEDIA: (val: boolean) => `${BASE_URL}/api/config/allow_media/${val ? '1' : '0'}`,
|
||||
};
|
||||
|
||||
/** NativeX synchronous API. */
|
||||
export type NativeX = {
|
||||
/**
|
||||
* Check if the native interface is available.
|
||||
* @returns Should always return true.
|
||||
*/
|
||||
isNative: () => boolean;
|
||||
|
||||
/**
|
||||
* Set the theme color of the app.
|
||||
* @param color Color to set
|
||||
* @param isDark Whether the theme is dark (for navigation bar)
|
||||
*/
|
||||
setThemeColor: (color: string, isDark: boolean) => void;
|
||||
|
||||
/**
|
||||
* Play a tap sound for UI interaction.
|
||||
*/
|
||||
playTouchSound: () => void;
|
||||
|
||||
/**
|
||||
* Make a native toast to the user.
|
||||
* @param message Message to show
|
||||
* @param long Whether the toast should be shown for a long time
|
||||
*/
|
||||
toast: (message: string, long?: boolean) => void;
|
||||
|
||||
/**
|
||||
* Start the login process
|
||||
* @param baseUrl Base URL of the Nextcloud instance
|
||||
* @param loginFlowUrl URL to start the login flow
|
||||
*/
|
||||
login: (baseUrl: string, loginFlowUrl: string) => void;
|
||||
|
||||
/**
|
||||
* Log out from Nextcloud and delete the tokens.
|
||||
*/
|
||||
logout: () => void;
|
||||
|
||||
/**
|
||||
* Reload the app.
|
||||
*/
|
||||
reload: () => void;
|
||||
|
||||
/**
|
||||
* Start downloading a file from a given URL.
|
||||
* @param url URL to download from
|
||||
* @param filename Filename to save as
|
||||
* @details An error must be shown to the user natively if the download fails.
|
||||
*/
|
||||
downloadFromUrl: (url: string, filename: string) => void;
|
||||
|
||||
/**
|
||||
* Play a video from the given AUID or URL(s).
|
||||
* @param auid AUID of file (will play local if available)
|
||||
* @param fileid File ID of the video (only used for file tracking)
|
||||
* @param urlArray JSON-encoded array of URLs to play
|
||||
* @details The URL array may contain multiple URLs, e.g. direct playback
|
||||
* and HLS separately. The native client must try to play the first URL.
|
||||
*/
|
||||
playVideo: (auid: string, fileid: string, urlArray: string) => void;
|
||||
/**
|
||||
* Destroy the video player.
|
||||
* @param fileid File ID of the video
|
||||
* @details The native client must destroy the video player and free up resources.
|
||||
* If the fileid doesn't match the playing video, the call must be ignored.
|
||||
*/
|
||||
destroyVideo: (fileid: string) => void;
|
||||
|
||||
/**
|
||||
* Set the local folders configuration to show in the timeline.
|
||||
* @param json JSON-encoded array of LocalFolderConfig
|
||||
*/
|
||||
configSetLocalFolders: (json: string) => void;
|
||||
|
||||
/**
|
||||
* Get the local folders configuration to show in the timeline.
|
||||
* @returns JSON-encoded array of LocalFolderConfig
|
||||
*/
|
||||
configGetLocalFolders: () => string;
|
||||
|
||||
/**
|
||||
* Check if the user has allowed media access.
|
||||
* @returns Whether the user has allowed media access.
|
||||
*/
|
||||
configHasMediaPermission: () => boolean;
|
||||
|
||||
/**
|
||||
* Get the current sync status.
|
||||
* @returns number of file synced or -1
|
||||
*/
|
||||
getSyncStatus: () => number;
|
||||
|
||||
/**
|
||||
* Set if the given files have remote copies.
|
||||
* @param auid List of AUIDs to set the server ID for (JSON-encoded)
|
||||
* @param value Value of remote
|
||||
*/
|
||||
setHasRemote: (auids: string, value: boolean) => void;
|
||||
};
|
||||
|
||||
/** The native interface is a global object that is injected by the native app. */
|
||||
export const nativex: NativeX = globalThis.nativex;
|
|
@ -0,0 +1,51 @@
|
|||
import axios from '@nextcloud/axios';
|
||||
import { generateUrl } from '@nextcloud/router';
|
||||
import { nativex } from './api';
|
||||
|
||||
/**
|
||||
* @returns Whether the native interface is available.
|
||||
*/
|
||||
export function has() {
|
||||
return !!nativex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the theme color of the app to default.
|
||||
*/
|
||||
export async function setTheme(color?: string, dark?: boolean) {
|
||||
if (!has()) return;
|
||||
|
||||
color ??= getComputedStyle(document.body).getPropertyValue('--color-main-background');
|
||||
dark ??=
|
||||
(document.body.hasAttribute('data-theme-default') && window.matchMedia('(prefers-color-scheme: dark)').matches) ||
|
||||
document.body.hasAttribute('data-theme-dark') ||
|
||||
document.body.hasAttribute('data-theme-dark-highcontrast');
|
||||
nativex?.setThemeColor?.(color, dark);
|
||||
}
|
||||
|
||||
/**
|
||||
* Play touch sound.
|
||||
*/
|
||||
export async function playTouchSound() {
|
||||
nativex?.playTouchSound?.();
|
||||
}
|
||||
|
||||
/**
|
||||
* Log out from Nextcloud and pass ahead.
|
||||
*/
|
||||
export async function logout() {
|
||||
await axios.get(generateUrl('logout'));
|
||||
if (!has()) window.location.reload();
|
||||
nativex?.logout();
|
||||
}
|
||||
|
||||
/**
|
||||
* Add current origin to URL if doesn't have any protocol or origin.
|
||||
*/
|
||||
export function addOrigin(url: string) {
|
||||
return url.match(/^(https?:)?\/\//)
|
||||
? url
|
||||
: url.startsWith('/')
|
||||
? `${location.origin}${url}`
|
||||
: `${location.origin}/${url}`;
|
||||
}
|
|
@ -0,0 +1,37 @@
|
|||
import { NAPI, nativex } from './api';
|
||||
|
||||
/** Setting of whether a local folder is enabled */
|
||||
export type LocalFolderConfig = {
|
||||
id: string;
|
||||
name: string;
|
||||
enabled: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Set list of local folders configuration.
|
||||
*/
|
||||
export function setLocalFolders(config: LocalFolderConfig[]) {
|
||||
return nativex?.configSetLocalFolders(JSON.stringify(config));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get list of local folders configuration.
|
||||
* Should be called only if NativeX is available.
|
||||
*/
|
||||
export function getLocalFolders() {
|
||||
return JSON.parse(nativex?.configGetLocalFolders?.() ?? '[]') as LocalFolderConfig[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the user has allowed media access.
|
||||
*/
|
||||
export function configHasMediaPermission() {
|
||||
return nativex?.configHasMediaPermission?.() ?? false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow access to media.
|
||||
*/
|
||||
export async function configAllowMedia(val: boolean = true) {
|
||||
return await fetch(NAPI.CONFIG_ALLOW_MEDIA(val));
|
||||
}
|
|
@ -0,0 +1,155 @@
|
|||
import { NAPI, nativex } from './api';
|
||||
import { API } from '../services/API';
|
||||
import { has } from './basic';
|
||||
import * as utils from '../services/utils';
|
||||
import type { IDay, IPhoto } from '../types';
|
||||
|
||||
/** Memcache for <dayId, Photos> */
|
||||
const daysCache = new Map<number, IPhoto[]>();
|
||||
|
||||
// Clear the cache whenever the timeline is refreshed
|
||||
if (has()) {
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
utils.bus.on('memories:timeline:soft-refresh', () => daysCache.clear());
|
||||
utils.bus.on('memories:timeline:hard-refresh', () => daysCache.clear());
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge incoming days into current days.
|
||||
* Both arrays MUST be sorted by dayid descending.
|
||||
* @param current Response to update
|
||||
* @param incoming Incoming response
|
||||
* @return merged days
|
||||
*/
|
||||
export function mergeDays(current: IDay[], incoming: IDay[]): IDay[] {
|
||||
// Do a two pointer merge keeping the days sorted in O(n) time
|
||||
// If a day is missing from current, add it
|
||||
// If a day already exists in current, update haslocal on it
|
||||
let i = 0;
|
||||
let j = 0;
|
||||
|
||||
// Merge local photos into remote photos
|
||||
const merged: IDay[] = [];
|
||||
while (i < current.length && j < incoming.length) {
|
||||
const curr = current[i];
|
||||
const inc = incoming[j];
|
||||
if (curr.dayid === inc.dayid) {
|
||||
curr.haslocal ||= inc.haslocal;
|
||||
merged.push(curr);
|
||||
i++;
|
||||
j++;
|
||||
} else if (curr.dayid > inc.dayid) {
|
||||
merged.push(curr);
|
||||
i++;
|
||||
} else {
|
||||
merged.push(inc);
|
||||
j++;
|
||||
}
|
||||
}
|
||||
|
||||
// Add remaining current days
|
||||
while (i < current.length) {
|
||||
merged.push(current[i]);
|
||||
i++;
|
||||
}
|
||||
|
||||
// Add remaining incoming days
|
||||
while (j < incoming.length) {
|
||||
merged.push(incoming[j]);
|
||||
j++;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
/**
|
||||
* Merge incoming photos into current photos.
|
||||
* @param current Response to update
|
||||
* @param incoming Incoming response
|
||||
*/
|
||||
export function mergeDay(current: IPhoto[], incoming: IPhoto[]): void {
|
||||
// Merge local photos into remote photos
|
||||
const currentAUIDs = new Set<number>();
|
||||
for (const photo of current) {
|
||||
currentAUIDs.add(photo.auid!);
|
||||
}
|
||||
|
||||
// Filter out files that are only available locally
|
||||
for (const photo of incoming) {
|
||||
if (!currentAUIDs.has(photo.auid!)) {
|
||||
current.push(photo);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort by epoch value
|
||||
current.sort((a, b) => (b.epoch ?? 0) - (a.epoch ?? 0));
|
||||
}
|
||||
|
||||
/**
|
||||
* Run internal hooks on fresh day received from server
|
||||
* Does not update the passed objects in any way
|
||||
* @param current Photos from day response
|
||||
*/
|
||||
export function processFreshServerDay(dayId: number, photos: IPhoto[]): void {
|
||||
const auids = photos.map((p) => p.auid).filter((a) => !!a) as number[];
|
||||
if (!auids.length) return;
|
||||
nativex.setHasRemote(JSON.stringify(auids), true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the local days response
|
||||
*/
|
||||
export async function getLocalDays(): Promise<IDay[]> {
|
||||
if (!has()) return [];
|
||||
|
||||
const res = await fetch(NAPI.DAYS());
|
||||
if (!res.ok) return [];
|
||||
|
||||
const days: IDay[] = await res.json();
|
||||
days.forEach((d) => (d.haslocal = true));
|
||||
|
||||
return days;
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the local photos from the native interface
|
||||
* @param dayId Day ID to get local photos for
|
||||
* @returns
|
||||
*/
|
||||
export async function getLocalDay(dayId: number): Promise<IPhoto[]> {
|
||||
if (!has()) return [];
|
||||
|
||||
// Check cache
|
||||
if (daysCache.has(dayId)) return daysCache.get(dayId)!;
|
||||
|
||||
const res = await fetch(NAPI.DAY(dayId));
|
||||
if (!res.ok) return [];
|
||||
|
||||
const photos: IPhoto[] = await res.json();
|
||||
photos.forEach((p) => (p.islocal = true));
|
||||
|
||||
// Cache the response
|
||||
daysCache.set(dayId, photos);
|
||||
|
||||
return photos;
|
||||
}
|
||||
|
||||
/**
|
||||
* Request deletion of local photos wherever available.
|
||||
* @param photos List of photos to delete
|
||||
* @returns The number of photos for which confirmation was received
|
||||
* @throws If the request fails
|
||||
*/
|
||||
export async function deleteLocalPhotos(photos: IPhoto[], dry: boolean = false): Promise<number> {
|
||||
if (!has()) return 0;
|
||||
|
||||
const auids = photos.map((p) => p.auid).filter((a) => !!a) as number[];
|
||||
|
||||
// Delete local photos
|
||||
const res = await fetch(API.Q(NAPI.IMAGE_DELETE(auids), { dry }));
|
||||
if (!res.ok) throw new Error('Failed to delete photos');
|
||||
|
||||
const data = await res.json();
|
||||
return data.confirms ? data.count : 0;
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
export * from './api';
|
||||
export * from './basic';
|
||||
export * from './config';
|
||||
export * from './days';
|
||||
export * from './share';
|
||||
export * from './video';
|
|
@ -0,0 +1,45 @@
|
|||
import axios from '@nextcloud/axios';
|
||||
import { BASE_URL, NAPI, nativex } from './api';
|
||||
import { addOrigin } from './basic';
|
||||
import type { IPhoto } from '../types';
|
||||
|
||||
/**
|
||||
* Download a file from the given URL.
|
||||
*/
|
||||
export async function downloadFromUrl(url: string) {
|
||||
// Make HEAD request to get filename
|
||||
const res = await axios.head(url);
|
||||
let filename = res.headers['content-disposition'];
|
||||
if (res.status !== 200 || !filename) return;
|
||||
|
||||
// Extract filename from header without quotes
|
||||
filename = filename.split('filename="')[1].slice(0, -1);
|
||||
|
||||
// Hand off to download manager
|
||||
nativex?.downloadFromUrl?.(addOrigin(url), filename);
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a URL with native page.
|
||||
*/
|
||||
export async function shareUrl(url: string) {
|
||||
await axios.get(NAPI.SHARE_URL(addOrigin(url)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Download a blob from the given URL and share it.
|
||||
*/
|
||||
export async function shareBlobFromUrl(url: string) {
|
||||
if (url.startsWith(BASE_URL)) {
|
||||
throw new Error('Cannot share localhost URL');
|
||||
}
|
||||
await axios.get(NAPI.SHARE_BLOB(addOrigin(url)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Share a local file with native page.
|
||||
*/
|
||||
export async function shareLocal(photo: IPhoto) {
|
||||
if (!photo.auid) throw new Error('Cannot share local file without AUID');
|
||||
await axios.get(NAPI.SHARE_LOCAL(photo.auid));
|
||||
}
|
|
@ -0,0 +1,20 @@
|
|||
import { nativex } from './api';
|
||||
import { addOrigin } from './basic';
|
||||
import type { IPhoto } from '../types';
|
||||
|
||||
/**
|
||||
* Play a video from the given URL.
|
||||
* @param photo Photo to play
|
||||
* @param urls URLs to play (remote)
|
||||
*/
|
||||
export async function playVideo(photo: IPhoto, urls: string[]) {
|
||||
const auid = photo.auid ?? photo.fileid;
|
||||
nativex?.playVideo?.(auid.toString(), photo.fileid.toString(), JSON.stringify(urls.map(addOrigin)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroy the video player.
|
||||
*/
|
||||
export async function destroyVideo(photo: IPhoto) {
|
||||
nativex?.destroyVideo?.(photo.fileid.toString());
|
||||
}
|
|
@ -6,6 +6,7 @@ import Timeline from './components/Timeline.vue';
|
|||
import Explore from './components/Explore.vue';
|
||||
import SplitTimeline from './components/SplitTimeline.vue';
|
||||
import ClusterView from './components/ClusterView.vue';
|
||||
import NativeXSetup from './native/Setup.vue';
|
||||
|
||||
Vue.use(Router);
|
||||
|
||||
|
@ -150,5 +151,14 @@ export default new Router({
|
|||
rootTitle: t('memories', 'Explore'),
|
||||
}),
|
||||
},
|
||||
|
||||
{
|
||||
path: '/nxsetup',
|
||||
component: NativeXSetup,
|
||||
name: 'nxsetup',
|
||||
props: (route) => ({
|
||||
rootTitle: t('memories', 'Setup'),
|
||||
}),
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
|
@ -34,6 +34,8 @@ export enum DaysFilterType {
|
|||
RECURSIVE = 'recursive',
|
||||
MONTH_VIEW = 'monthView',
|
||||
REVERSE = 'reverse',
|
||||
HIDDEN = 'hidden',
|
||||
NO_PRELOAD = 'nopreload',
|
||||
}
|
||||
|
||||
export class API {
|
||||
|
|
|
@ -66,7 +66,7 @@ export function getPreviewUrl(opts: PreviewOptsSize | PreviewOptsMsize | Preview
|
|||
|
||||
// Native preview
|
||||
if (isLocalPhoto(photo)) {
|
||||
return API.Q(nativex.API.IMAGE_PREVIEW(photo.fileid), { c: photo.etag });
|
||||
return API.Q(nativex.NAPI.IMAGE_PREVIEW(photo.fileid), { c: photo.etag });
|
||||
}
|
||||
|
||||
// Screen-appropriate size
|
||||
|
@ -114,7 +114,7 @@ export function getImageInfoUrl(photo: IPhoto | number): string {
|
|||
const fileid = typeof photo === 'number' ? photo : photo.fileid;
|
||||
|
||||
if (typeof photo === 'object' && isLocalPhoto(photo)) {
|
||||
return nativex.API.IMAGE_INFO(fileid);
|
||||
return nativex.NAPI.IMAGE_INFO(fileid);
|
||||
}
|
||||
|
||||
return API.IMAGE_INFO(fileid);
|
||||
|
@ -135,6 +135,18 @@ export function updatePhotoFromImageInfo(photo: IPhoto, imageInfo: IImageInfo) {
|
|||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove hidden photos from the list in place
|
||||
* @param photos List of photos
|
||||
*/
|
||||
export function removeHiddenPhotos(photos: IPhoto[]) {
|
||||
for (let i = photos.length - 1; i >= 0; i--) {
|
||||
if (photos[i].ishidden) {
|
||||
photos.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path of the folder on folders route
|
||||
* This function does not check if this is the folder route
|
||||
|
|
|
@ -20,6 +20,8 @@ export type IDay = {
|
|||
rows?: IRow[];
|
||||
/** List of photos for this day */
|
||||
detail?: IPhoto[];
|
||||
/** This day has some local photos */
|
||||
haslocal?: boolean;
|
||||
};
|
||||
|
||||
export type IPhoto = {
|
||||
|
@ -78,6 +80,11 @@ export type IPhoto = {
|
|||
isfavorite?: boolean;
|
||||
/** Local file from native */
|
||||
islocal?: boolean;
|
||||
/**
|
||||
* Photo is hidden from timeline; discard immediately.
|
||||
* This field exists so that we can merge with locals.
|
||||
*/
|
||||
ishidden?: boolean;
|
||||
|
||||
/** AUID of file (optional, NativeX) */
|
||||
auid?: number;
|
||||
|
|
Loading…
Reference in New Issue