Merge branch 'pulsejet/nn'
commit
dfa4ab6695
|
@ -23,6 +23,7 @@ return [
|
||||||
['name' => 'Page#thisday', 'url' => '/thisday', 'verb' => 'GET'],
|
['name' => 'Page#thisday', 'url' => '/thisday', 'verb' => 'GET'],
|
||||||
['name' => 'Page#map', 'url' => '/map', 'verb' => 'GET'],
|
['name' => 'Page#map', 'url' => '/map', 'verb' => 'GET'],
|
||||||
['name' => 'Page#explore', 'url' => '/explore', 'verb' => 'GET'],
|
['name' => 'Page#explore', 'url' => '/explore', 'verb' => 'GET'],
|
||||||
|
['name' => 'Page#nxsetup', 'url' => '/nxsetup', 'verb' => 'GET'],
|
||||||
|
|
||||||
// Routes with params
|
// Routes with params
|
||||||
w(['name' => 'Page#folder', 'url' => '/folders/{path}', 'verb' => 'GET'], 'path'),
|
w(['name' => 'Page#folder', 'url' => '/folders/{path}', 'verb' => 'GET'], 'path'),
|
||||||
|
|
|
@ -94,6 +94,7 @@ class DaysController extends GenericApiController
|
||||||
$dayIds,
|
$dayIds,
|
||||||
$this->isRecursive(),
|
$this->isRecursive(),
|
||||||
$this->isArchive(),
|
$this->isArchive(),
|
||||||
|
$this->isHidden(),
|
||||||
$this->getTransformations(),
|
$this->getTransformations(),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -183,7 +184,7 @@ class DaysController extends GenericApiController
|
||||||
{
|
{
|
||||||
// Do not preload anything for native clients.
|
// Do not preload anything for native clients.
|
||||||
// Since the contents of preloads are trusted, clients will not load locals.
|
// Since the contents of preloads are trusted, clients will not load locals.
|
||||||
if (Util::callerIsNative()) {
|
if (Util::callerIsNative() || $this->noPreload()) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -211,6 +212,7 @@ class DaysController extends GenericApiController
|
||||||
$preloadDayIds,
|
$preloadDayIds,
|
||||||
$this->isRecursive(),
|
$this->isRecursive(),
|
||||||
$this->isArchive(),
|
$this->isArchive(),
|
||||||
|
$this->isHidden(),
|
||||||
$transforms,
|
$transforms,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
@ -276,6 +278,16 @@ class DaysController extends GenericApiController
|
||||||
return null !== $this->request->getParam('archive');
|
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()
|
private function isMonthView()
|
||||||
{
|
{
|
||||||
return null !== $this->request->getParam('monthView');
|
return null !== $this->request->getParam('monthView');
|
||||||
|
|
|
@ -234,4 +234,14 @@ class PageController extends Controller
|
||||||
{
|
{
|
||||||
return $this->main();
|
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
|
* CTE to get all files recursively in the given top folders
|
||||||
* :topFolderIds - The top folders to get files from.
|
* :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
|
* 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
|
// Filter out folder MIME types
|
||||||
$FOLDER_MIME_QUERY = "SELECT MAX(id) FROM *PREFIX*mimetypes WHERE mimetype = 'httpd/unix-directory'";
|
$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
|
// Select 1 if there is a .nomedia file in the folder
|
||||||
$SEL_NOMEDIA = "SELECT 1 FROM *PREFIX*filecache f2
|
$SEL_NOMEDIA = "SELECT 1 FROM *PREFIX*filecache f2
|
||||||
WHERE (f2.parent = f.fileid)
|
WHERE (f2.parent = f.fileid)
|
||||||
|
@ -35,22 +27,29 @@ trait TimelineQueryCTE
|
||||||
// Check no nomedia file exists in the folder
|
// Check no nomedia file exists in the folder
|
||||||
$CLS_NOMEDIA = "NOT EXISTS ({$SEL_NOMEDIA})";
|
$CLS_NOMEDIA = "NOT EXISTS ({$SEL_NOMEDIA})";
|
||||||
|
|
||||||
|
// Whether to filter out hidden folders
|
||||||
|
$CLS_HIDDEN_JOIN = $hidden ? '1 = 1' : "f.name NOT LIKE '.%'";
|
||||||
|
|
||||||
return
|
return
|
||||||
"*PREFIX*cte_folders_all(fileid, name) AS (
|
"*PREFIX*cte_folders_all(fileid, name, hidden) AS (
|
||||||
{$BASE_QUERY}
|
SELECT f.fileid, f.name,
|
||||||
|
(0) AS hidden
|
||||||
|
FROM *PREFIX*filecache f
|
||||||
WHERE (
|
WHERE (
|
||||||
{$CLS_TOP_FOLDER} AND
|
f.fileid IN (:topFolderIds) AND
|
||||||
{$CLS_NOMEDIA}
|
{$CLS_NOMEDIA}
|
||||||
)
|
)
|
||||||
|
|
||||||
UNION ALL
|
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
|
INNER JOIN *PREFIX*cte_folders_all c
|
||||||
ON (
|
ON (
|
||||||
f.parent = c.fileid AND
|
f.parent = c.fileid AND
|
||||||
f.mimetype = ({$FOLDER_MIME_QUERY}) AND
|
f.mimetype = ({$FOLDER_MIME_QUERY}) AND
|
||||||
{$CLS_HIDDEN_JOIN}
|
({$CLS_HIDDEN_JOIN})
|
||||||
)
|
)
|
||||||
WHERE (
|
WHERE (
|
||||||
{$CLS_NOMEDIA}
|
{$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
|
SELECT
|
||||||
fileid
|
fileid, ({$CLS_HIDDEN}) AS hidden
|
||||||
FROM
|
FROM
|
||||||
*PREFIX*cte_folders_all
|
*PREFIX*cte_folders_all
|
||||||
GROUP BY
|
GROUP BY
|
||||||
fileid
|
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 */
|
/** CTE to get all archive folders recursively in the given top folders */
|
||||||
|
@ -94,7 +99,7 @@ trait TimelineQueryCTE
|
||||||
ON (f.parent = c.fileid)
|
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
|
protected static function bundleCTEs(array $ctes): string
|
||||||
|
|
|
@ -60,6 +60,7 @@ trait TimelineQueryDays
|
||||||
* @param int[] $day_ids The day ids to fetch
|
* @param int[] $day_ids The day ids to fetch
|
||||||
* @param bool $recursive If the query should be recursive
|
* @param bool $recursive If the query should be recursive
|
||||||
* @param bool $archive If the query should include only the archive folder
|
* @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
|
* @param array $queryTransforms The query transformations to apply
|
||||||
*
|
*
|
||||||
* @return array An array of day responses
|
* @return array An array of day responses
|
||||||
|
@ -68,6 +69,7 @@ trait TimelineQueryDays
|
||||||
?array $day_ids,
|
?array $day_ids,
|
||||||
bool $recursive,
|
bool $recursive,
|
||||||
bool $archive,
|
bool $archive,
|
||||||
|
bool $hidden,
|
||||||
array $queryTransforms = []
|
array $queryTransforms = []
|
||||||
): array {
|
): array {
|
||||||
$query = $this->connection->getQueryBuilder();
|
$query = $this->connection->getQueryBuilder();
|
||||||
|
@ -82,6 +84,11 @@ trait TimelineQueryDays
|
||||||
->from('memories', 'm')
|
->from('memories', 'm')
|
||||||
;
|
;
|
||||||
|
|
||||||
|
// Add hidden field
|
||||||
|
if ($hidden) {
|
||||||
|
$query->addSelect('cte_f.hidden');
|
||||||
|
}
|
||||||
|
|
||||||
// JOIN with mimetypes to get the mimetype
|
// JOIN with mimetypes to get the mimetype
|
||||||
$query->join('f', 'mimetypes', 'mimetypes', $query->expr()->eq('f.mimetype', 'mimetypes.id'));
|
$query->join('f', 'mimetypes', 'mimetypes', $query->expr()->eq('f.mimetype', 'mimetypes.id'));
|
||||||
|
|
||||||
|
@ -104,7 +111,7 @@ trait TimelineQueryDays
|
||||||
$this->applyAllTransforms($queryTransforms, $query, false);
|
$this->applyAllTransforms($queryTransforms, $query, false);
|
||||||
|
|
||||||
// JOIN with filecache for existing files
|
// 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
|
// FETCH all photos in this day
|
||||||
$day = $this->executeQueryWithCTEs($query)->fetchAll();
|
$day = $this->executeQueryWithCTEs($query)->fetchAll();
|
||||||
|
@ -124,9 +131,9 @@ trait TimelineQueryDays
|
||||||
$types = $query->getParameterTypes();
|
$types = $query->getParameterTypes();
|
||||||
|
|
||||||
// Get SQL
|
// 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_ARCHIVE()
|
||||||
: self::CTE_FOLDERS();
|
: self::CTE_FOLDERS(\array_key_exists('cteIncludeHidden', $params));
|
||||||
|
|
||||||
// Add WITH clause if needed
|
// Add WITH clause if needed
|
||||||
if (false !== strpos($sql, 'cte_folders')) {
|
if (false !== strpos($sql, 'cte_folders')) {
|
||||||
|
@ -143,12 +150,14 @@ trait TimelineQueryDays
|
||||||
* @param TimelineRoot $root Either the top folder or null for all
|
* @param TimelineRoot $root Either the top folder or null for all
|
||||||
* @param bool $recursive Whether to get the days recursively
|
* @param bool $recursive Whether to get the days recursively
|
||||||
* @param bool $archive Whether to get the days only from the archive folder
|
* @param bool $archive Whether to get the days only from the archive folder
|
||||||
|
* @param bool $hidden Whether to include hidden files
|
||||||
*/
|
*/
|
||||||
public function joinFilecache(
|
public function joinFilecache(
|
||||||
IQueryBuilder $query,
|
IQueryBuilder $query,
|
||||||
?TimelineRoot $root = null,
|
?TimelineRoot $root = null,
|
||||||
bool $recursive = true,
|
bool $recursive = true,
|
||||||
bool $archive = false
|
bool $archive = false,
|
||||||
|
bool $hidden = false
|
||||||
): IQueryBuilder {
|
): IQueryBuilder {
|
||||||
// This will throw if the root is illegally empty
|
// This will throw if the root is illegally empty
|
||||||
$root = $this->root($root);
|
$root = $this->root($root);
|
||||||
|
@ -163,7 +172,7 @@ trait TimelineQueryDays
|
||||||
$pathOp = null;
|
$pathOp = null;
|
||||||
if ($recursive) {
|
if ($recursive) {
|
||||||
// Join with folders CTE
|
// 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'));
|
$query->innerJoin('f', 'cte_folders', 'cte_f', $query->expr()->eq('f.parent', 'cte_f.fileid'));
|
||||||
} else {
|
} else {
|
||||||
// If getting non-recursively folder only check for parent
|
// If getting non-recursively folder only check for parent
|
||||||
|
@ -218,6 +227,12 @@ trait TimelineQueryDays
|
||||||
}
|
}
|
||||||
unset($row['categoryid']);
|
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
|
// All cluster transformations
|
||||||
ClustersBackend\Manager::applyDayPostTransforms($this->request, $row);
|
ClustersBackend\Manager::applyDayPostTransforms($this->request, $row);
|
||||||
|
|
||||||
|
@ -238,10 +253,18 @@ trait TimelineQueryDays
|
||||||
private function addSubfolderJoinParams(
|
private function addSubfolderJoinParams(
|
||||||
IQueryBuilder &$query,
|
IQueryBuilder &$query,
|
||||||
TimelineRoot &$root,
|
TimelineRoot &$root,
|
||||||
bool $archive
|
bool $archive,
|
||||||
|
bool $hidden
|
||||||
) {
|
) {
|
||||||
// Add query parameters
|
// Add query parameters
|
||||||
$query->setParameter('topFolderIds', $root->getIds(), IQueryBuilder::PARAM_INT_ARRAY);
|
$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
|
// extracts the epoch value from the EXIF json and stores it in the epoch column
|
||||||
try {
|
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
|
// get the required records
|
||||||
$result = $this->dbc->getQueryBuilder()
|
$result = $this->dbc->getQueryBuilder()
|
||||||
->select('m.id', 'm.exif')
|
->select('m.id', 'm.exif')
|
||||||
|
@ -104,6 +114,8 @@ class Version505000Date20230821044807 extends SimpleMigrationStep
|
||||||
}
|
}
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
continue;
|
continue;
|
||||||
|
} finally {
|
||||||
|
$output->advance();
|
||||||
}
|
}
|
||||||
|
|
||||||
// commit every 50 rows
|
// commit every 50 rows
|
||||||
|
@ -119,8 +131,8 @@ class Version505000Date20230821044807 extends SimpleMigrationStep
|
||||||
// close the cursor
|
// close the cursor
|
||||||
$result->closeCursor();
|
$result->closeCursor();
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
error_log('Automatic migration failed: '.$e->getMessage());
|
$output->warning('Automatic migration failed: '.$e->getMessage());
|
||||||
error_log('Please run occ memories:index -f');
|
$output->warning('Please run occ memories:index -f');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
19
src/App.vue
19
src/App.vue
|
@ -1,5 +1,7 @@
|
||||||
<template>
|
<template>
|
||||||
<FirstStart v-if="isFirstStart" />
|
<router-view v-if="onlyRouterView" />
|
||||||
|
|
||||||
|
<FirstStart v-else-if="isFirstStart" />
|
||||||
|
|
||||||
<NcContent
|
<NcContent
|
||||||
app-name="memories"
|
app-name="memories"
|
||||||
|
@ -195,6 +197,10 @@ export default defineComponent({
|
||||||
return t('memories', 'People');
|
return t('memories', 'People');
|
||||||
},
|
},
|
||||||
|
|
||||||
|
onlyRouterView(): boolean {
|
||||||
|
return ['nxsetup'].includes(this.$route.name ?? '');
|
||||||
|
},
|
||||||
|
|
||||||
isFirstStart(): boolean {
|
isFirstStart(): boolean {
|
||||||
return this.config.timeline_path === '_empty_' && !this.routeIsPublic && !this.$route.query.noinit;
|
return this.config.timeline_path === '_empty_' && !this.routeIsPublic && !this.$route.query.noinit;
|
||||||
},
|
},
|
||||||
|
@ -304,13 +310,10 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
|
|
||||||
async beforeMount() {
|
async beforeMount() {
|
||||||
if ('serviceWorker' in navigator) {
|
if (window.location.hostname === 'localhost') {
|
||||||
// Check if dev instance
|
// Disable on dev instances
|
||||||
if (window.location.hostname === 'localhost') {
|
console.warn('Service Worker is not enabled on localhost.');
|
||||||
console.warn('Service Worker is not enabled on localhost.');
|
} else if ('serviceWorker' in navigator) {
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get the config before loading
|
// Get the config before loading
|
||||||
const previousVersion = staticConfig.getSync('version');
|
const previousVersion = staticConfig.getSync('version');
|
||||||
|
|
||||||
|
|
|
@ -42,7 +42,6 @@
|
||||||
import { defineComponent } from 'vue';
|
import { defineComponent } from 'vue';
|
||||||
import type { Component } from 'vue';
|
import type { Component } from 'vue';
|
||||||
|
|
||||||
import axios from '@nextcloud/axios';
|
|
||||||
import { translate as t } from '@nextcloud/l10n';
|
import { translate as t } from '@nextcloud/l10n';
|
||||||
|
|
||||||
import ClusterHList from './ClusterHList.vue';
|
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 CogIcon from 'vue-material-design-icons/Cog.vue';
|
||||||
|
|
||||||
import config from '../services/static-config';
|
import config from '../services/static-config';
|
||||||
import { API } from '../services/API';
|
|
||||||
import * as dav from '../services/dav';
|
import * as dav from '../services/dav';
|
||||||
|
|
||||||
import type { ICluster, IConfig } from '../types';
|
import type { ICluster, IConfig } from '../types';
|
||||||
|
|
|
@ -110,6 +110,10 @@
|
||||||
>
|
>
|
||||||
{{ folder.name }}
|
{{ folder.name }}
|
||||||
</NcCheckboxRadioSwitch>
|
</NcCheckboxRadioSwitch>
|
||||||
|
|
||||||
|
<NcButton @click="runNxSetup()" type="secondary">
|
||||||
|
{{ t('memories', 'Run initial device setup') }}
|
||||||
|
</NcButton>
|
||||||
</NcAppSettingsSection>
|
</NcAppSettingsSection>
|
||||||
|
|
||||||
<NcAppSettingsSection id="folders-settings" :title="t('memories', 'Folders')">
|
<NcAppSettingsSection id="folders-settings" :title="t('memories', 'Folders')">
|
||||||
|
@ -288,12 +292,16 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
|
|
||||||
// --------------- Native APIs start -----------------------------
|
// --------------- Native APIs start -----------------------------
|
||||||
async refreshNativeConfig() {
|
refreshNativeConfig() {
|
||||||
this.localFolders = await nativex.getLocalFolders();
|
this.localFolders = nativex.getLocalFolders();
|
||||||
},
|
},
|
||||||
|
|
||||||
async updateDeviceFolders() {
|
updateDeviceFolders() {
|
||||||
await nativex.setLocalFolders(this.localFolders);
|
nativex.setLocalFolders(this.localFolders);
|
||||||
|
},
|
||||||
|
|
||||||
|
runNxSetup() {
|
||||||
|
this.$router.replace('/nxsetup');
|
||||||
},
|
},
|
||||||
|
|
||||||
async logout() {
|
async logout() {
|
||||||
|
|
|
@ -146,6 +146,8 @@ export default defineComponent({
|
||||||
numCols: 0,
|
numCols: 0,
|
||||||
/** Header rows for dayId key */
|
/** Header rows for dayId key */
|
||||||
heads: {} as { [dayid: number]: IHeadRow },
|
heads: {} as { [dayid: number]: IHeadRow },
|
||||||
|
/** Current list (days response) was loaded from cache */
|
||||||
|
daysIsCache: false,
|
||||||
|
|
||||||
/** Size of outer container [w, h] */
|
/** Size of outer container [w, h] */
|
||||||
containerSize: [0, 0] as [number, number],
|
containerSize: [0, 0] as [number, number],
|
||||||
|
@ -691,10 +693,10 @@ export default defineComponent({
|
||||||
try {
|
try {
|
||||||
if ((cache = await utils.getCachedData(cacheUrl))) {
|
if ((cache = await utils.getCachedData(cacheUrl))) {
|
||||||
if (this.routeHasNative) {
|
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);
|
this.updateLoading(-1);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
@ -714,12 +716,12 @@ export default defineComponent({
|
||||||
|
|
||||||
// Extend with native days
|
// Extend with native days
|
||||||
if (this.routeHasNative) {
|
if (this.routeHasNative) {
|
||||||
await nativex.extendDaysWithLocal(data);
|
data = nativex.mergeDays(data, await nativex.getLocalDays());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Make sure we're still on the same page
|
// Make sure we're still on the same page
|
||||||
if (this.state !== startState) return;
|
if (this.state !== startState) return;
|
||||||
await this.processDays(data);
|
await this.processDays(data, false);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (!utils.isNetworkError(e)) {
|
if (!utils.isNetworkError(e)) {
|
||||||
showError(e?.response?.data?.message ?? e.message);
|
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;
|
if (!data || !this.state) return;
|
||||||
|
|
||||||
const list: typeof this.list = [];
|
const list: typeof this.list = [];
|
||||||
|
@ -824,6 +830,9 @@ export default defineComponent({
|
||||||
this.loadedDays.clear();
|
this.loadedDays.clear();
|
||||||
this.sizedDays.clear();
|
this.sizedDays.clear();
|
||||||
|
|
||||||
|
// Mark if the data was from cache
|
||||||
|
this.daysIsCache = cache;
|
||||||
|
|
||||||
// Iterate the preload map
|
// Iterate the preload map
|
||||||
// Now the inner detail objects are reactive
|
// Now the inner detail objects are reactive
|
||||||
for (const dayId in preloads) {
|
for (const dayId in preloads) {
|
||||||
|
@ -844,8 +853,16 @@ export default defineComponent({
|
||||||
},
|
},
|
||||||
|
|
||||||
/** API url for Day call */
|
/** API url for Day call */
|
||||||
getDayUrl(dayId: number | string) {
|
getDayUrl(dayIds: number[]) {
|
||||||
return API.Q(API.DAY(dayId), this.getQuery());
|
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 */
|
/** Fetch image data for one dayId */
|
||||||
|
@ -858,20 +875,31 @@ export default defineComponent({
|
||||||
this.sizedDays.add(dayId);
|
this.sizedDays.add(dayId);
|
||||||
|
|
||||||
// Look for cache
|
// Look for cache
|
||||||
const cacheUrl = this.getDayUrl(dayId);
|
const cacheUrl = this.getDayUrl([dayId]);
|
||||||
try {
|
try {
|
||||||
const cache = await utils.getCachedData<IPhoto[]>(cacheUrl);
|
const cache = await utils.getCachedData<IPhoto[]>(cacheUrl);
|
||||||
if (cache) {
|
if (cache) {
|
||||||
// Cache only contains remote images; update from local too
|
// Cache only contains remote images; update from local too
|
||||||
if (this.routeHasNative) {
|
if (this.routeHasNative && head.day?.haslocal) {
|
||||||
await nativex.extendDayWithLocal(dayId, cache);
|
nativex.mergeDay(cache, await nativex.getLocalDay(dayId));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Process the cache
|
// 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);
|
this.processDay(dayId, cache);
|
||||||
}
|
}
|
||||||
} catch {
|
} catch (e) {
|
||||||
console.warn(`Failed to process day cache: ${cacheUrl}`);
|
console.warn(`Failed or skipped processing day cache: ${cacheUrl}`, e);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Aggregate fetch requests
|
// Aggregate fetch requests
|
||||||
|
@ -900,8 +928,7 @@ export default defineComponent({
|
||||||
for (const dayId of dayIds) dayMap.set(dayId, []);
|
for (const dayId of dayIds) dayMap.set(dayId, []);
|
||||||
|
|
||||||
// Construct URL
|
// Construct URL
|
||||||
const dayStr = dayIds.join(',');
|
const url = this.getDayUrl(dayIds);
|
||||||
const url = this.getDayUrl(dayStr);
|
|
||||||
this.fetchDayQueue = [];
|
this.fetchDayQueue = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
@ -911,7 +938,7 @@ export default defineComponent({
|
||||||
const data = res.data;
|
const data = res.data;
|
||||||
|
|
||||||
// Check if the state has changed
|
// Check if the state has changed
|
||||||
if (this.state !== startState || this.getDayUrl(dayStr) !== url) {
|
if (this.state !== startState || this.getDayUrl(dayIds) !== url) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -929,21 +956,28 @@ export default defineComponent({
|
||||||
// creates circular references which cannot be stringified
|
// creates circular references which cannot be stringified
|
||||||
for (const [dayId, photos] of dayMap) {
|
for (const [dayId, photos] of dayMap) {
|
||||||
if (photos.length === 0) continue;
|
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 local images if we are running in native environment.
|
||||||
// Get them all together for each day here.
|
// Get them all together for each day here.
|
||||||
if (this.routeHasNative) {
|
if (this.routeHasNative) {
|
||||||
await Promise.all(
|
const promises = Array.from(dayMap.entries())
|
||||||
Array.from(dayMap.entries()).map(([dayId, photos]) => {
|
.filter(([dayId, photos]) => {
|
||||||
return nativex.extendDayWithLocal(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
|
// Process each day as needed
|
||||||
for (const [dayId, photos] of dayMap) {
|
for (const [dayId, photos] of dayMap) {
|
||||||
|
// Remove hidden photos
|
||||||
|
utils.removeHiddenPhotos(photos);
|
||||||
|
|
||||||
// Check if the response has any delta
|
// Check if the response has any delta
|
||||||
const head = this.heads[dayId];
|
const head = this.heads[dayId];
|
||||||
if (head?.day?.detail?.length === photos.length) {
|
if (head?.day?.detail?.length === photos.length) {
|
||||||
|
|
|
@ -807,7 +807,7 @@ export default defineComponent({
|
||||||
if (!isvideo) {
|
if (!isvideo) {
|
||||||
// Try local file if NativeX is available
|
// Try local file if NativeX is available
|
||||||
if (photo.auid && nativex.has()) {
|
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
|
// 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 Explore from './components/Explore.vue';
|
||||||
import SplitTimeline from './components/SplitTimeline.vue';
|
import SplitTimeline from './components/SplitTimeline.vue';
|
||||||
import ClusterView from './components/ClusterView.vue';
|
import ClusterView from './components/ClusterView.vue';
|
||||||
|
import NativeXSetup from './native/Setup.vue';
|
||||||
|
|
||||||
Vue.use(Router);
|
Vue.use(Router);
|
||||||
|
|
||||||
|
@ -150,5 +151,14 @@ export default new Router({
|
||||||
rootTitle: t('memories', 'Explore'),
|
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',
|
RECURSIVE = 'recursive',
|
||||||
MONTH_VIEW = 'monthView',
|
MONTH_VIEW = 'monthView',
|
||||||
REVERSE = 'reverse',
|
REVERSE = 'reverse',
|
||||||
|
HIDDEN = 'hidden',
|
||||||
|
NO_PRELOAD = 'nopreload',
|
||||||
}
|
}
|
||||||
|
|
||||||
export class API {
|
export class API {
|
||||||
|
|
|
@ -66,7 +66,7 @@ export function getPreviewUrl(opts: PreviewOptsSize | PreviewOptsMsize | Preview
|
||||||
|
|
||||||
// Native preview
|
// Native preview
|
||||||
if (isLocalPhoto(photo)) {
|
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
|
// Screen-appropriate size
|
||||||
|
@ -114,7 +114,7 @@ export function getImageInfoUrl(photo: IPhoto | number): string {
|
||||||
const fileid = typeof photo === 'number' ? photo : photo.fileid;
|
const fileid = typeof photo === 'number' ? photo : photo.fileid;
|
||||||
|
|
||||||
if (typeof photo === 'object' && isLocalPhoto(photo)) {
|
if (typeof photo === 'object' && isLocalPhoto(photo)) {
|
||||||
return nativex.API.IMAGE_INFO(fileid);
|
return nativex.NAPI.IMAGE_INFO(fileid);
|
||||||
}
|
}
|
||||||
|
|
||||||
return API.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
|
* Get the path of the folder on folders route
|
||||||
* This function does not check if this is the folder route
|
* This function does not check if this is the folder route
|
||||||
|
|
|
@ -20,6 +20,8 @@ export type IDay = {
|
||||||
rows?: IRow[];
|
rows?: IRow[];
|
||||||
/** List of photos for this day */
|
/** List of photos for this day */
|
||||||
detail?: IPhoto[];
|
detail?: IPhoto[];
|
||||||
|
/** This day has some local photos */
|
||||||
|
haslocal?: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type IPhoto = {
|
export type IPhoto = {
|
||||||
|
@ -78,6 +80,11 @@ export type IPhoto = {
|
||||||
isfavorite?: boolean;
|
isfavorite?: boolean;
|
||||||
/** Local file from native */
|
/** Local file from native */
|
||||||
islocal?: boolean;
|
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 of file (optional, NativeX) */
|
||||||
auid?: number;
|
auid?: number;
|
||||||
|
|
Loading…
Reference in New Issue