Merge branch 'pulsejet/nn'

pull/807/merge
Varun Patil 2023-10-03 10:44:14 -07:00
commit dfa4ab6695
24 changed files with 946 additions and 440 deletions

View File

@ -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'),

View File

@ -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');

View File

@ -234,4 +234,14 @@ class PageController extends Controller
{
return $this->main();
}
/**
* @NoAdminRequired
*
* @NoCSRFRequired
*/
public function nxsetup()
{
return $this->main();
}
}

View File

@ -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

View File

@ -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);
}
}
}

View File

@ -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');
}
}
}

View File

@ -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');

View File

@ -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';

View File

@ -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() {

View File

@ -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) {

View File

@ -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

View File

@ -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}`;
}

View File

@ -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>

191
src/native/api.ts 100644
View File

@ -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;

View File

@ -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}`;
}

View File

@ -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));
}

155
src/native/days.ts 100644
View File

@ -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;
}

View File

@ -0,0 +1,6 @@
export * from './api';
export * from './basic';
export * from './config';
export * from './days';
export * from './share';
export * from './video';

View File

@ -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));
}

View File

@ -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());
}

View File

@ -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'),
}),
},
],
});

View File

@ -34,6 +34,8 @@ export enum DaysFilterType {
RECURSIVE = 'recursive',
MONTH_VIEW = 'monthView',
REVERSE = 'reverse',
HIDDEN = 'hidden',
NO_PRELOAD = 'nopreload',
}
export class API {

View File

@ -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

View File

@ -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;