wrolpi/app/src/api.js
lrnselfreliance 1ad451cddf
Resume videos and audio where the user left off; show progress on cards (#791)
* Resume videos and audio where the user left off; show progress on cards

Videos and audio resume where the user left off, on the video page and in
the file preview, and keep playing.  `?t=` starts at an explicit time
instead.

useFileProgress saves how far the user is through a file: every 15 seconds
while it changes, and a final save (a beacon, which survives the page
closing) on pause, end, hiding the page, leaving it, or switching files.
Each save is also kept in sessionStorage; on open the newer of that and the
server's position wins, so coming straight back resumes exactly even if the
final save is still in flight.

Cards show a progress bar along the bottom of their poster.  The dashboard's
Recently Viewed Files gains All / Continue; Continue uses the new
`in_progress` file search filter.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Progress review fixes: in_progress totals, latest dashboard request, preview viewed

- The in_progress search filter skips the uncounted fast path and is part of
  the totals cache key, so Continue never reports All's (or the library's)
  count.
- The dashboard's Recently Viewed hook applies only its latest response, and
  clears the list when switching All/Continue until the new files arrive.
- POST /api/files/file returns `viewed`, so a preview can tell a file
  finished elsewhere from an older position this tab holds.
- The progress hook tests restore Blob, fetch and sendBeacon.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 17:29:22 -06:00

2630 lines
91 KiB
JavaScript

import {emptyToNull} from "./components/Common";
import {toast} from "./components/ui";
import _ from "lodash";
import {
API_URI,
ARCHIVES_API,
COLLECTIONS_API,
DEFAULT_LIMIT,
Downloaders,
REPOS_API,
VIDEOS_API,
ZIM_API
} from "./components/Vars";
function timeoutPromise(ms, promise) {
// Create a timeout wrapper around a promise. If the timeout is reached, throw an error. Otherwise, return
// the results of the promise.
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
reject(new Error("promise timeout"))
}, ms);
promise.then((res) => {
clearTimeout(timeoutId);
resolve(res);
}, (err) => {
clearTimeout(timeoutId);
reject(err);
});
})
}
export class ApiDownError extends Error {
}
const apiDownError = new ApiDownError('API is down');
async function apiCall(url, method, body, ms = 60_000) {
let init = {method};
if (body !== undefined) {
init.body = JSON.stringify(body);
}
let promise;
try {
// Create a fetch promise, this will be awaited by the timeout.
promise = fetch(url, init);
// await the response or error.
const response = await timeoutPromise(ms, promise);
if (200 <= response.status && response.status < 300) {
// Request was successful.
return response;
}
if (response.status === 502) {
// API is down.
throw apiDownError;
}
// Request encountered an error.
let copy = response.clone();
const content = await copy.json();
console.error('API error response json', content);
const code = content['code'];
if (response.status === 403 && code === 'WROL_MODE_ENABLED') {
toast({
type: 'error',
title: 'WROL Mode is enabled!',
description: 'This functionality is disabled while WROL Mode is enabled!',
time: 5000,
});
}
return response;
} catch (e) {
if (e instanceof ApiDownError) {
// Throw ApiDownError immediately without logging.
throw e;
}
// Ignore SyntaxError because they happen when the API is down.
if (!(e instanceof SyntaxError)) {
console.error(e);
}
throw e;
}
}
// Convenience functions for API calls.
let apiGet = async (url) => {
return await apiCall(url, 'GET')
};
let apiDelete = async (url) => {
return await apiCall(url, 'DELETE')
};
let apiPost = async (url, body) => {
return await apiCall(url, 'POST', body)
};
let apiPut = async (url, body) => {
return await apiCall(url, 'PUT', body)
};
let apiPatch = async (url, body) => {
return await apiCall(url, 'PATCH', body)
};
async function getErrorMessage(response, fallbackMessage) {
try {
const json = await response.clone().json();
return json.error || json.message || fallbackMessage;
} catch (error) {
// Server did not response with JSON. Return default message.
return fallbackMessage;
}
}
export async function updateChannel(id, channel) {
channel = emptyToNull(channel);
return await apiPut(`${VIDEOS_API}/channels/${id}`, channel);
}
export async function createChannel(channel) {
channel = emptyToNull(channel);
return await apiPost(`${VIDEOS_API}/channels`, channel);
}
export async function deleteChannel(channelId) {
const response = await apiDelete(`${VIDEOS_API}/channels/${channelId}`);
if (response.status !== 204) {
const message = await getErrorMessage(response, 'Failed to delete channel.');
toast({
type: 'error',
title: 'Unexpected server response',
description: message,
time: 5000,
});
}
return response;
}
export async function getChannels() {
const response = await apiGet(`${VIDEOS_API}/channels`);
if (response.status === 200) {
return (await response.json())['channels'];
} else {
const message = await getErrorMessage(response, 'Failed to get channels.');
toast({
type: 'error',
title: 'Unexpected server response',
description: message,
time: 5000,
});
}
}
export async function getChannel(id) {
const response = await apiGet(`${VIDEOS_API}/channels/${id}`);
return (await response.json())['channel'];
}
export async function tagChannel(channelId, tagName, directory) {
const body = {
tag_name: tagName,
}
if (directory) {
body['directory'] = directory;
}
const response = await apiPost(`${VIDEOS_API}/channels/${channelId}/tag`, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to tag channel. See server logs.');
toast({
type: 'error',
title: 'Tagging Channel Failed',
description: message,
time: 5000,
});
}
}
export async function tagChannelInfo(channelId, tagName) {
channelId = channelId ? parseInt(channelId) : null;
const body = {channel_id: channelId, tag_name: tagName};
const response = await apiPost(`${VIDEOS_API}/tag_info`, body);
if (response.ok) {
return (await response.json()).videos_destination;
}
const message = await getErrorMessage(response, 'Failed to get channel tag info.');
toast({
type: 'error',
title: 'Getting Channel Tag Info Failed',
description: message,
time: 5000,
});
}
export async function searchVideos(offset, limit, channelId, searchStr, order_by, tagNames, headline, censored, deep) {
// Build a search query to retrieve a list of videos from the API
offset = parseInt(offset || 0);
limit = parseInt(limit || DEFAULT_LIMIT);
let body = {offset, limit, order_by: order_by || 'rank'};
if (searchStr) {
body['search_str'] = searchStr;
}
if (channelId) {
body['channel_id'] = parseInt(channelId);
}
if (tagNames) {
body['tag_names'] = tagNames;
}
if (headline) {
body['headline'] = true;
}
if (censored) {
body['censored'] = true;
}
if (deep) {
body['deep'] = true;
}
console.debug('searching videos', body);
const response = await apiPost(`${VIDEOS_API}/search`, body);
if (response.status === 200) {
let data = await response.json();
return [data['file_groups'], data['totals']['file_groups']];
} else {
const message = await getErrorMessage(response, 'Failed to search videos.');
toast({
type: 'error',
title: 'Searching Videos failed',
description: message,
time: 5000,
});
return [[], 0];
}
}
export async function getVideo(fileGroupId) {
const response = await apiGet(`${VIDEOS_API}/${fileGroupId}`);
let data = await response.json();
return [data['file_group'], data['prev'], data['next']];
}
export async function updateVideo(fileGroupId, {title, description}) {
// Edits the Video's details (written to its info json). Only the fields given are changed.
// Returns the updated video file group.
const response = await apiPut(`${VIDEOS_API}/${fileGroupId}`, {title, description});
if (!response.ok) {
const message = await getErrorMessage(response, 'Unable to save the video. See server logs.');
toast({type: 'error', title: 'Failed to save', description: message, time: 5000});
throw new Error(message);
}
const data = await response.json();
return data['file_group'];
}
export async function transcodeVideo(fileGroupId, {
video_codec = null, audio_codec = null, container = 'mp4', fragmented = false,
}) {
// Queues a transcode Job; returns the job id. Watch it with `getJob`.
const body = {video_codec, audio_codec, container, fragmented};
const response = await apiPost(`${VIDEOS_API}/${fileGroupId}/transcode`, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Unable to queue the transcode. See server logs.');
toast({type: 'error', title: 'Failed to transcode', description: message, time: 5000});
throw new Error(message);
}
const data = await response.json();
return data['job_id'];
}
export async function getJobs() {
const response = await apiGet(`${API_URI}/jobs`);
if (!response.ok) {
throw new Error(await getErrorMessage(response, 'Unable to get the jobs'));
}
const data = await response.json();
return data['jobs'];
}
export async function getJob(jobId) {
const response = await apiGet(`${API_URI}/jobs/${jobId}`);
if (!response.ok) {
const error = new Error(await getErrorMessage(response, 'Unable to get the job'));
error.status = response.status;
throw error;
}
const data = await response.json();
return data['job'];
}
export async function cancelJob(jobId) {
const response = await apiDelete(`${API_URI}/jobs/${jobId}`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Unable to cancel the job');
toast({type: 'error', title: 'Failed to cancel', description: message, time: 5000});
throw new Error(message);
}
const data = await response.json();
return data['job'];
}
export async function getVideoComments(fileGroupId) {
const response = await apiGet(`${VIDEOS_API}/${fileGroupId}/comments`);
let data = await response.json();
return {
comments: data.comments,
}
}
export async function getVideoDescription(fileGroupId) {
const response = await apiGet(`${VIDEOS_API}/${fileGroupId}/description`);
let data = await response.json();
return {
description: data.description,
}
}
export async function getVideoCaptions(fileGroupId) {
const response = await apiGet(`${VIDEOS_API}/${fileGroupId}/captions`);
let data = await response.json();
return {
captions: data.captions,
}
}
export async function deleteFileGroups(fileGroupIds, force = false) {
if (!fileGroupIds || fileGroupIds.length === 0) {
toast({
type: 'error',
title: 'Empty request',
description: 'Unable to delete FileGroups because no IDs were passed.',
time: 5000,
});
return;
}
console.info(`Deleting FileGroups: ${fileGroupIds}`);
const body = {file_group_ids: fileGroupIds, force};
const response = await apiPost(`${API_URI}/files/delete_groups`, body);
if (response.ok) {
return response;
}
try {
const json = await response.clone().json();
if (json.code === 'FILE_GROUP_IS_TAGGED' && json.file_groups) {
return {tagged: true, file_groups: json.file_groups};
}
} catch (e) {
// Not JSON, fall through to generic error.
}
const message = await getErrorMessage(response, 'Failed to delete files.');
toast({
type: 'error',
title: 'Delete failed',
description: message,
time: 5000,
});
throw Error(message);
}
export async function downloadVideoMetadata(videoUrl) {
const downloadData = {
urls: [videoUrl],
downloader: Downloaders.Video,
settings: {download_metadata_only: true},
};
return await postDownload(downloadData);
}
export async function getStatus() {
const response = await apiGet(`${API_URI}/status`);
if (response.status === 200) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Could not get server status.');
toast({
type: 'error',
title: 'Fetching Status Failed',
description: message,
time: 5000,
});
}
}
export async function getSettings() {
const response = await apiGet(`${API_URI}/settings`);
if (response.ok) {
const content = await response.json();
return {
archive_destination: content.archive_destination,
download_manager_disabled: content.download_manager_disabled,
download_manager_stopped: content.download_manager_stopped,
download_daily_limit_global: content.download_daily_limit_global,
download_daily_limit_per_domain: content.download_daily_limit_per_domain,
download_on_startup: content.download_on_startup,
download_timeout: content.download_timeout,
download_wait: content.download_wait,
download_window_start: content.download_window_start,
download_window_end: content.download_window_end,
hotspot_device: content.hotspot_device,
hotspot_on_startup: content.hotspot_on_startup,
hotspot_password: content.hotspot_password,
hotspot_ssid: content.hotspot_ssid,
hotspot_status: content.hotspot_status,
check_for_upgrades: content.check_for_upgrades,
ignore_outdated_zims: content.ignore_outdated_zims,
ignored_directories: content.ignored_directories,
log_level: content.log_level,
map_default_location: content.map_default_location,
map_destination: content.map_destination,
nav_color: content.nav_color,
media_directory: content.media_directory,
playlists_destination: content.playlists_destination,
repos_destination: content.repos_destination,
require_cookies_unlocked: content.require_cookies_unlocked,
require_media_mounted: content.require_media_mounted,
save_ffprobe_json: content.save_ffprobe_json,
tags_directory: content.tags_directory,
throttle_on_startup: content.throttle_on_startup,
throttle_status: content.throttle_status,
timezone: content.timezone,
videos_destination: content.videos_destination,
wrol_mode: content.wrol_mode,
zims_destination: content.zims_destination,
}
} else {
const message = await getErrorMessage(response, 'Could not get settings.');
toast({
type: 'error',
title: 'Fetching Settings Failed',
description: message,
time: 5000,
});
}
}
export async function saveSettings(settings) {
const response = await apiPatch(`${API_URI}/settings`, settings);
if (!response.ok) {
const message = await getErrorMessage(response, 'Could not save settings.');
toast({
type: 'error',
title: 'Saving Settings Failed',
description: message,
time: 5000,
});
}
return response
}
export async function getDownloads() {
const response = await apiGet(`${API_URI}/download`);
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Could not get downloads.');
toast({
type: 'error',
title: 'Getting Downloads Failed',
description: message,
time: 5000,
});
}
return response
}
export async function getConfigs() {
const response = await apiGet(`${API_URI}/config`);
if (response.ok) {
const data = await response.json();
return {
configs: data.configs,
}
} else {
const message = await getErrorMessage(response, 'Could not get configs.');
toast({
type: 'error',
title: 'Getting Configs Failed',
description: message,
time: 5000,
});
}
return response
}
export async function postImportConfig(fileName) {
const body = {file_name: fileName};
const response = await apiPost(`${API_URI}/config/import`, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Could not import config.');
toast({
type: 'error',
title: 'Importing config Failed',
description: message,
time: 5000,
});
}
return response
}
export async function postDumpConfig(fileName) {
const body = {file_name: fileName, overwrite: true};
const response = await apiPost(`${API_URI}/config/dump`, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Could not dump config.');
toast({
type: 'error',
title: 'Dumping config failed',
description: message,
time: 5000,
});
}
return response
}
export async function getConfig(fileName) {
const response = await apiGet(`${API_URI}/config?file_name=${fileName}`);
return (await response.json())['config'];
}
export async function getConfigText(fileName) {
const response = await apiGet(`${API_URI}/config?file_name=${fileName}`);
return (await response.json())['config_text'];
}
export async function getConfigBackups(fileName) {
const response = await apiGet(`${API_URI}/config/backups?file_name=${fileName}`);
if (response.ok) {
return await response.json();
}
// Undefined, not {dates: []}, so the modal can tell a failed fetch from having no backups.
}
export async function postConfigBackupPreview(fileName, backupDate, mode) {
const body = {file_name: fileName, backup_date: backupDate, mode};
const response = await apiPost(`${API_URI}/config/backup/preview`, body);
if (response.ok) {
return await response.json();
}
const message = await getErrorMessage(response, 'Could not preview backup.');
toast({type: 'error', title: 'Preview failed', description: message, time: 5000});
return null;
}
export async function postConfigBackupImport(fileName, backupDate, mode) {
const body = {file_name: fileName, backup_date: backupDate, mode};
const response = await apiPost(`${API_URI}/config/backup/import`, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Could not import backup.');
toast({type: 'error', title: 'Backup import failed', description: message, time: 5000});
}
return response;
}
export async function fetchVideoDownloaderConfig() {
return await getConfig('videos_downloader.yaml');
}
export async function fetchVideoDownloadDefaults() {
const response = await apiGet(`${VIDEOS_API}/download-defaults`);
return await response.json();
}
export async function updateConfig(fileName, config) {
const body = {config};
const response = await apiPost(`${API_URI}/config?file_name=${fileName}`, body);
if (!response.ok) {
throw Error('Failed to save config');
}
}
export async function updateVideoDownloaderConfig(config) {
return await updateConfig('videos_downloader.yaml', config);
}
export async function fetchArchiveDownloaderConfig() {
return await getConfig('archives_downloader.yaml');
}
export async function updateArchiveDownloaderConfig(config) {
return await updateConfig('archives_downloader.yaml', config);
}
export async function postArchiveFileFormat(archive_file_format) {
const body = {archive_file_format};
return await apiPost(`${ARCHIVES_API}/file_format`, body);
}
export async function fetchArchiveBrowsers() {
const response = await apiGet(`${ARCHIVES_API}/browsers`);
if (response.ok) {
return await response.json();
}
return {browsers: [], available: false};
}
export async function getArchiveStatistics() {
const response = await apiGet(`${ARCHIVES_API}/statistics`);
if (response.status === 200) {
return (await response.json())['statistics'];
} else {
const message = await getErrorMessage(response, 'Could not get archive statistics.');
toast({
type: 'error',
title: 'Getting Archive Statistics Failed',
description: message,
time: 5000,
});
}
return response
}
export async function updateVideoDownloaderSettings(settings) {
const response = await apiPut(`${API_URI}/videos/settings`, settings);
if (response.status === 200) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Failed to save settings.');
toast({
type: 'error',
title: 'Saving Settings Failed',
description: message,
time: 5000,
});
}
}
export async function validateRegex(regex) {
const response = await apiPost(`${API_URI}/valid_regex`, {regex});
try {
return (await response.json())['valid'];
} catch (e) {
return false;
}
}
export async function getVideosStatistics() {
const response = await apiGet(`${VIDEOS_API}/statistics`);
if (response.status === 200) {
return (await response.json())['statistics'];
} else {
const message = await getErrorMessage(response, 'Could not get statistics.');
toast({
type: 'error',
title: 'Getting Statistics Failed',
description: message,
time: 5000,
});
}
return response
}
export async function refreshChannel(channelId) {
let url = `${VIDEOS_API}/channels/refresh/${channelId}`;
const response = await apiPost(url);
if (!response.ok) {
const message = await getErrorMessage(response, "Failed to refresh this channel's directory");
toast({
type: 'error',
title: 'Failed to refresh',
description: message,
time: 5000,
})
}
return response;
}
// Inventory is config-only on the backend (no DB). The frontend loads every inventory once (full), derives
// list/items/summary/locations client-side, and persists whole inventories on change — so there are only four
// inventory calls.
export async function getInventories() {
// Returns every inventory in full (fields + items).
const response = await apiGet(`${API_URI}/inventory`);
if (response.ok) {
return (await response.json())['inventories'];
}
const message = await getErrorMessage(response, 'Could not get inventories');
toast({type: 'error', title: 'Unexpected server response', description: message, time: 5000});
return null;
}
export async function createInventory({name, type}) {
const response = await apiPost(`${API_URI}/inventory`, {name, type});
if (response.ok) {
return (await response.json())['inventory'];
}
const message = await getErrorMessage(response, 'Failed to create inventory');
toast({type: 'error', title: 'Unexpected server response', description: message, time: 5000});
return null;
}
export async function saveInventory(slug, data) {
// Whole-inventory save (data = {name?, fields?, items?, version?}). Returns the saved inventory, or null on
// error/conflict so the caller can reconcile.
const response = await apiPut(`${API_URI}/inventory/${slug}`, data);
if (response.ok) {
return (await response.json())['inventory'];
}
const message = await getErrorMessage(response, 'Failed to save inventory');
toast({type: 'error', title: 'Unexpected server response', description: message, time: 5000});
return null;
}
export async function deleteInventory(slug) {
const response = await apiDelete(`${API_URI}/inventory/${slug}`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to delete inventory');
toast({type: 'error', title: 'Unexpected server response', description: message, time: 5000});
}
return response;
}
export async function reimportInventories() {
// Re-read every inventory config file from disk (picks up hand-edits and copied-in files).
const response = await apiPost(`${API_URI}/inventory/reimport`);
if (response.ok) {
return (await response.json())['inventories'];
}
const message = await getErrorMessage(response, 'Failed to re-import inventories');
toast({type: 'error', title: 'Unexpected server response', description: message, time: 5000});
return null;
}
export async function getInventoryBackups(slug) {
const response = await apiGet(`${API_URI}/inventory/${slug}/backups`);
if (response.ok) {
return (await response.json())['dates'];
}
// Undefined, not [], so the modal can tell a failed fetch from having no backups.
}
export async function postInventoryRestorePreview(slug, backupDate, mode) {
const body = {backup_date: backupDate, mode};
const response = await apiPost(`${API_URI}/inventory/${slug}/restore/preview`, body);
if (response.ok) {
return (await response.json())['preview'];
}
const message = await getErrorMessage(response, 'Failed to preview the backup');
toast({type: 'error', title: 'Unexpected server response', description: message, time: 5000});
return null;
}
export async function postInventoryRestore(slug, backupDate, mode) {
const body = {backup_date: backupDate, mode};
const response = await apiPost(`${API_URI}/inventory/${slug}/restore`, body);
if (response.ok) {
return (await response.json())['inventory'];
}
const message = await getErrorMessage(response, 'Failed to restore the backup');
toast({type: 'error', title: 'Unexpected server response', description: message, time: 5000});
return null;
}
export async function getCatalog() {
// The shared food catalog (known items with total calories per package).
const response = await apiGet(`${API_URI}/inventory/catalog`);
if (response.ok) {
return (await response.json())['catalog'];
}
const message = await getErrorMessage(response, 'Could not get the food catalog');
toast({type: 'error', title: 'Unexpected server response', description: message, time: 5000});
return null;
}
export async function saveCatalog(items) {
const response = await apiPut(`${API_URI}/inventory/catalog`, {items});
if (response.ok) {
return (await response.json())['catalog'];
}
const message = await getErrorMessage(response, 'Failed to save the food catalog');
toast({type: 'error', title: 'Unexpected server response', description: message, time: 5000});
return null;
}
export async function searchArchives(offset, limit, domain, searchStr, order, tagNames, headline, deep) {
// Build a search query to retrieve a list of videos from the API
offset = parseInt(offset || 0);
limit = parseInt(limit || DEFAULT_LIMIT);
let body = {offset, limit};
if (domain) {
body['domain'] = domain;
}
if (searchStr) {
body['search_str'] = searchStr;
}
if (order) {
body['order_by'] = order;
}
if (tagNames) {
body['tag_names'] = tagNames;
}
if (headline) {
body['headline'] = true;
}
if (deep) {
body['deep'] = true;
}
console.debug('searching archives', body);
const response = await apiPost(`${ARCHIVES_API}/search`, body);
if (response.ok) {
let data = await response.json();
return [data['file_groups'], data['totals']['file_groups']];
} else {
const message = await getErrorMessage(response, 'Cannot search archives. See server logs.');
toast({
type: 'error',
title: 'Unable to search archives',
description: message,
time: 5000,
});
return [[], 0];
}
}
export async function fetchDomains() {
const response = await apiGet(`${COLLECTIONS_API}?kind=domain`);
if (response.ok) {
let data = await response.json();
return [data['collections'], data['totals']['collections']];
} else {
const message = await getErrorMessage(response, 'Unable to fetch Domains. See server logs.');
toast({
type: 'error',
title: 'Domains Error',
description: message,
time: 5000,
});
}
}
export async function fetchAuthors() {
const response = await apiGet(`${COLLECTIONS_API}?kind=author`);
if (response.ok) {
let data = await response.json();
return [data['collections'], data['totals']['collections']];
} else {
const message = await getErrorMessage(response, 'Unable to fetch Authors. See server logs.');
toast({
type: 'error',
title: 'Authors Error',
description: message,
time: 5000,
});
}
}
export async function fetchSubjects() {
const response = await apiGet(`${COLLECTIONS_API}?kind=subject`);
if (response.ok) {
let data = await response.json();
return [data['collections'], data['totals']['collections']];
} else {
const message = await getErrorMessage(response, 'Unable to fetch Subjects. See server logs.');
toast({
type: 'error',
title: 'Subjects Error',
description: message,
time: 5000,
});
}
}
export async function fetchChannels() {
const response = await apiGet(`${COLLECTIONS_API}?kind=channel`);
if (response.ok) {
let data = await response.json();
return [data['collections'], data['totals']['collections']];
} else {
const message = await getErrorMessage(response, 'Unable to fetch Channels. See server logs.');
toast({
type: 'error',
title: 'Channels Error',
description: message,
time: 5000,
});
}
}
export async function getDomain(domainId) {
const response = await apiGet(`${COLLECTIONS_API}/${domainId}`);
if (response.ok) {
const data = await response.json();
return data['collection'];
} else {
const message = await getErrorMessage(response, 'Unable to fetch Domain. See server logs.');
toast({
type: 'error',
title: 'Domain Error',
description: message,
time: 5000,
});
throw new Error(message);
}
}
export async function updateDomain(domainId, updates) {
updates = emptyToNull(updates);
const response = await apiPut(`${COLLECTIONS_API}/${domainId}`, updates);
if (response.ok) {
const data = await response.json();
return data['collection'];
} else {
const message = await getErrorMessage(response, 'Unable to update Domain. See server logs.');
toast({
type: 'error',
title: 'Domain Update Error',
description: message,
time: 5000,
});
throw new Error(message);
}
}
export async function getCollectionTagInfo(collectionId, tagName) {
const body = {
tag_name: tagName,
}
const response = await apiPost(`${COLLECTIONS_API}/${collectionId}/tag_info`, body);
if (response.ok) {
const data = await response.json();
return data;
} else {
const message = await getErrorMessage(response, 'Unable to get tag info. See server logs.');
toast({
type: 'error',
title: 'Tag Info Error',
description: message,
time: 5000,
});
throw new Error(message);
}
}
// Tag any Collection (and optionally move its directory).
export async function tagCollection(collectionId, tagName, directory) {
const body = {tag_name: tagName};
if (directory) {
body['directory'] = directory;
}
const response = await apiPost(`${COLLECTIONS_API}/${collectionId}/tag`, body);
if (response.ok) {
return await response.json();
}
const message = await getErrorMessage(response, 'Unable to tag. See server logs.');
toast({type: 'error', title: 'Tag Error', description: message, time: 5000});
throw new Error(message);
}
export async function tagDomain(domainId, tagName, directory) {
const body = {
tag_name: tagName,
}
if (directory) {
body['directory'] = directory;
}
const response = await apiPost(`${COLLECTIONS_API}/${domainId}/tag`, body);
if (response.ok) {
const data = await response.json();
return data;
} else {
const message = await getErrorMessage(response, 'Unable to tag Domain. See server logs.');
toast({
type: 'error',
title: 'Domain Tag Error',
description: message,
time: 5000,
});
throw new Error(message);
}
}
export async function deleteDomain(domainId) {
const response = await apiDelete(`${COLLECTIONS_API}/${domainId}`);
if (response.status !== 204) {
const message = await getErrorMessage(response, 'Failed to delete domain.');
toast({
type: 'error',
title: 'Delete Failed',
description: message,
time: 5000,
});
throw new Error(message);
}
return response;
}
export async function refreshDomain(domainId) {
let url = `${COLLECTIONS_API}/${domainId}/refresh`;
const response = await apiPost(url);
if (!response.ok) {
const message = await getErrorMessage(response, "Failed to refresh this domain's directory");
toast({
type: 'error',
title: 'Failed to refresh',
description: message,
time: 5000,
});
} else {
toast({
type: 'success',
title: 'Domain refresh started',
description: 'The domain directory is being refreshed',
time: 3000,
});
}
return response;
}
export async function previewCollectionReorganization(collectionId) {
const response = await apiGet(`${COLLECTIONS_API}/${collectionId}/reorganize/preview`);
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Unable to preview reorganization. See server logs.');
toast({
type: 'error',
title: 'Reorganization Preview Error',
description: message,
time: 5000,
});
throw new Error(message);
}
}
export async function executeCollectionReorganization(collectionId) {
const response = await apiPost(`${COLLECTIONS_API}/${collectionId}/reorganize`);
if (response.ok) {
const data = await response.json();
if (data.job_id) {
toast({
type: 'success',
title: 'Reorganization Started',
description: 'Files are being reorganized in the background',
time: 3000,
});
} else {
toast({
type: 'info',
title: 'No Changes Needed',
description: data.message || 'No files need reorganization',
time: 3000,
});
}
return data;
} else {
const message = await getErrorMessage(response, 'Unable to start reorganization. See server logs.');
toast({
type: 'error',
title: 'Reorganization Error',
description: message,
time: 5000,
});
throw new Error(message);
}
}
export async function getReorganizationStatus(collectionId, jobId) {
const response = await apiGet(`${COLLECTIONS_API}/${collectionId}/reorganize/status/${jobId}`);
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Unable to get reorganization status.');
throw new Error(message);
}
}
// Batch Reorganization API Functions
export async function previewBatchReorganization(kind) {
const endpoint = kind === 'channel' ? 'channels' : 'domains';
const response = await apiGet(`${COLLECTIONS_API}/reorganize/${endpoint}`);
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, `Unable to get ${kind}s needing reorganization.`);
toast({
type: 'error',
title: 'Batch Reorganization Preview Error',
description: message,
time: 5000,
});
throw new Error(message);
}
}
export async function executeBatchReorganization(kind) {
const endpoint = kind === 'channel' ? 'channels' : 'domains';
const response = await apiPost(`${COLLECTIONS_API}/reorganize/${endpoint}`);
if (response.ok) {
const data = await response.json();
if (data.batch_job_id) {
toast({
type: 'success',
title: 'Batch Reorganization Started',
description: `${data.collection_count} ${kind}s are being reorganized`,
time: 3000,
});
} else {
toast({
type: 'info',
title: 'No Changes Needed',
description: data.message || `No ${kind}s need reorganization`,
time: 3000,
});
}
return data;
} else {
const message = await getErrorMessage(response, `Unable to start batch reorganization. See server logs.`);
toast({
type: 'error',
title: 'Batch Reorganization Error',
description: message,
time: 5000,
});
throw new Error(message);
}
}
export async function getBatchReorganizationStatus(batchJobId) {
const response = await apiGet(`${COLLECTIONS_API}/reorganize/batch/status/${batchJobId}`);
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Unable to get batch reorganization status.');
throw new Error(message);
}
}
export async function fetchCollectionConflicts(collectionId) {
const response = await apiGet(`${COLLECTIONS_API}/${collectionId}/reorganize/conflicts`);
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Unable to fetch conflicts. See server logs.');
toast({
type: 'error',
title: 'Conflict Fetch Error',
description: message,
time: 5000,
});
throw new Error(message);
}
}
export async function getArchive(fileGroupId) {
const response = await apiGet(`${ARCHIVES_API}/${fileGroupId}`);
if (response.ok) {
const data = await response.json();
return {
file_group: data['file_group'],
history: data['history'],
}
} else {
const message = await getErrorMessage(response, 'Unable to get Archive. See server logs.');
toast({
type: 'error', title: 'Archive Error', description: message, time: 5000,
});
}
}
export async function generateArchiveScreenshot(fileGroupId) {
const response = await apiPost(`${ARCHIVES_API}/${fileGroupId}/generate_screenshot`);
if (response.ok) {
toast({
type: 'success',
title: 'Screenshot Generation Queued',
description: 'Screenshot generation has been queued. This may take a moment.',
time: 3000,
});
return true;
} else {
const message = await getErrorMessage(response, 'Failed to queue screenshot generation.');
toast({
type: 'error',
title: 'Screenshot Generation Error',
description: message,
time: 5000,
});
return false;
}
}
export async function postDownload(downloadData) {
if (!downloadData.downloader) {
toast({
type: 'error',
title: 'Failed to submit download',
description: 'downloader is required, but was not provided',
time: 5000,
})
throw new Error('downloader is required, but was not provided');
}
const body = {
urls: downloadData.urls,
destination: downloadData.destination,
downloader: downloadData.downloader,
sub_downloader: downloadData.sub_downloader || null,
frequency: downloadData.frequency || null,
tag_names: downloadData.tag_names || null,
settings: downloadData.settings,
};
const response = await apiPost(`${API_URI}/download`, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Unable to create Download. See server logs.');
toast({
type: 'error', title: 'Download Error', description: message, time: 5000,
});
throw new Error('Failed to create download');
}
return response;
}
export async function putDownload(download_id, downloadData) {
if (!downloadData.downloader) {
toast({
type: 'error',
title: 'Failed to submit download',
description: 'downloader is required, but was not provided',
time: 5000,
})
throw new Error('downloader is required, but was not provided');
}
const response = await apiPut(`${API_URI}/download/${download_id}`, downloadData);
if (!response.ok) {
const message = await getErrorMessage(response, 'Unable to update Download. See server logs.');
toast({
type: 'error', title: 'Download Error', description: message, time: 5000,
});
throw new Error('Failed to update download');
}
return response;
}
export async function killDownload(download_id) {
const response = await apiPost(`${API_URI}/download/${download_id}/kill`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Unable to stop Download. See server logs.');
toast({
type: 'error', title: 'Download Error', description: message, time: 5000,
});
}
return response;
}
export async function restartDownload(download_id) {
const response = await apiPost(`${API_URI}/download/${download_id}/restart`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Unable to restart Download. See server logs.');
toast({
type: 'error', title: 'Download Error', description: message, time: 5000,
});
}
return response
}
export async function killDownloads() {
const response = await apiPost(`${API_URI}/download/kill`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Unable to stop downloading. See server logs.');
toast({
type: 'error', title: 'Download Error', description: message, time: 5000,
});
}
return response;
}
export async function startDownloads() {
const response = await apiPost(`${API_URI}/download/enable`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Unable to start downloading. See server logs.');
toast({
type: 'error', title: 'Download Error', description: message, time: 5000,
});
}
return response;
}
export async function getDownloaders() {
// Not toasting because this will happen often. Undefined on any failure, not an empty list, so
// the form can say the downloaders could not be fetched.
try {
const response = await apiGet(`${API_URI}/downloaders`);
if (response.ok) {
return await response.json();
}
} catch (e) {
// The API is down; reported as undefined below.
}
}
export async function deleteDownload(downloadId) {
try {
const response = await apiDelete(`${API_URI}/download/${downloadId}`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Unable to delete the download. See server logs.');
toast({
type: 'error',
title: 'Download Error',
description: message,
time: 5000,
});
}
} catch (e) {
return null;
}
}
export async function filesSearch(offset, limit, searchStr, mimetypes, model, tagNames, headline, months, fromYear, toYear, anyTag, order, suffix, path, deep, inProgress) {
const body = {search_str: searchStr, offset: parseInt(offset), limit: parseInt(limit), any_tag: anyTag};
if (suffix) {
body['suffix'] = suffix;
}
if (path) {
body['path'] = path;
}
if (mimetypes) {
body['mimetypes'] = mimetypes;
}
if (model) {
body['model'] = model;
}
if (tagNames) {
body['tag_names'] = tagNames;
}
if (headline) {
body['headline'] = true;
}
if (!_.isEmpty(months)) {
body['months'] = months.map(i => parseInt(i));
}
if (fromYear) {
body['from_year'] = fromYear;
}
if (toYear) {
body['to_year'] = toYear;
}
if (order) {
body['order'] = order;
}
if (deep) {
body['deep'] = true;
}
if (inProgress) {
body['in_progress'] = true;
}
console.info('searching files', body);
const response = await apiPost(`${API_URI}/files/search`, body);
if (response.ok) {
let data = await response.json();
let [file_groups, total] = [data['file_groups'], data['totals']['file_groups']];
return [file_groups, total];
} else {
const message = await getErrorMessage(response, 'Cannot search files. See server logs.');
toast({
type: 'error',
title: 'Unable to search files',
description: message,
time: 5000,
});
return [null, null];
}
}
export async function flasherSearch(chip, path) {
// Search .bin firmware, filtered to ESP images for the given chip (from connecting to the device).
const body = {};
if (chip) body['chip'] = chip;
if (path) body['path'] = path;
const response = await apiPost(`${API_URI}/flasher/search`, body);
if (response.ok) {
const data = await response.json();
return [data['file_groups'], data['totals']['file_groups']];
}
const message = await getErrorMessage(response, 'Cannot search firmware. See server logs.');
toast({type: 'error', title: 'Unable to search firmware', description: message, time: 5000});
// Undefined, not [null, null], so the picker can tell a failed search from finding nothing.
}
export async function getFlasherConfigs() {
// Return the user's saved firmware flashing configurations.
const response = await apiGet(`${API_URI}/flasher/configs`);
if (response.ok) {
const data = await response.json();
return data['configurations'] || [];
}
// Undefined, not [], so the page can tell a failed fetch from an empty list.
const message = await getErrorMessage(response, 'Could not get saved firmware configurations');
toast({type: 'error', title: 'Flasher Error', description: message, time: 5000});
}
export async function saveFlasherConfig(name, files, eraseAll) {
const response = await apiPost(`${API_URI}/flasher/configs`, {name, files, erase_all: eraseAll});
if (!response.ok) {
const message = await getErrorMessage(response, 'Cannot save configuration. See server logs.');
toast({type: 'error', title: 'Unable to save configuration', description: message, time: 5000});
return false;
}
return true;
}
export async function deleteFlasherConfig(name) {
const response = await apiDelete(`${API_URI}/flasher/configs/${encodeURIComponent(name)}`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Cannot delete configuration. See server logs.');
toast({type: 'error', title: 'Unable to delete configuration', description: message, time: 5000});
return false;
}
return true;
}
export async function refreshFiles(paths) {
let response;
if (Array.isArray(paths) && paths.length > 0) {
// Refresh user-selected files/directories only.
const body = {paths};
console.info(`Refreshing: ${paths}`);
response = await apiPost(`${API_URI}/files/refresh`, body);
} else {
console.info(`Refreshing all files`);
response = await apiPost(`${API_URI}/files/refresh`);
}
if (!response.ok) {
const message = await getErrorMessage(response, 'Cannot refresh files. See server logs.');
toast({
type: 'error',
title: 'Files Error',
description: message,
time: 5000,
});
}
return response;
}
export async function makeDirectory(path) {
const body = {path: path};
const response = await apiPost(`${API_URI}/files/directory`, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to create directory. See server logs.');
toast({
type: 'error',
title: 'Unable to create directory',
description: message,
time: 5000,
});
}
}
export async function getFiles(directories) {
console.debug(`getFiles ${directories}`);
let body = {directories: directories || []};
const response = await apiPost(`${API_URI}/files`, body);
// Not toasting because this will happen often.
let {files} = await response.json();
return files;
}
export async function getFile(path, skipTracking = false) {
let body = {file: path, skip_tracking: skipTracking};
const response = await apiPost(`${API_URI}/files/file`, body);
if (response.ok) {
const {file} = await response.json();
return file;
} else {
const message = await getErrorMessage(response, 'Failed to get file data. See server logs.');
toast({
type: 'error',
title: 'Files Error',
description: message,
time: 5000,
});
}
}
export async function deleteFile(paths, force = false) {
let body = {paths: paths, force: force};
const response = await apiPost(`${API_URI}/files/delete`, body);
if (!response.ok) {
try {
const json = await response.clone().json();
if (json.code === 'FILE_GROUP_IS_TAGGED' && json.file_groups) {
return {tagged: true, file_groups: json.file_groups};
}
} catch (e) {
// Not JSON, fall through to generic error.
}
const message = await getErrorMessage(response, 'Failed to delete file. See server logs.');
toast({
type: 'error',
title: 'Files Error',
description: message,
time: 5000,
});
}
return response
}
export async function getArchiveContents(path) {
const response = await apiPost(`${API_URI}/files/zip/contents`, {path});
if (response.ok) {
const {contents} = await response.json();
return contents;
}
const message = await getErrorMessage(response, 'Could not read archive contents.');
toast({type: 'error', title: 'Archive Error', description: message, time: 5000});
}
export function downloadArchiveMember(path, member) {
const params = new URLSearchParams({path, member});
window.open(`${API_URI}/files/zip/download?${params}`, '_blank');
}
export function getArchiveMemberUrl(path, member) {
const params = new URLSearchParams({path, member});
return `${API_URI}/files/zip/download?${params}`;
}
export async function fetchFilesProgress() {
const response = await apiGet(`${API_URI}/files/worker_status`);
if (response.ok) {
const json = await response.json();
return json['status'];
}
}
export async function setHotspot(on) {
let response;
if (on) {
response = await apiPost(`${API_URI}/hotspot/on`);
} else {
response = await apiPost(`${API_URI}/hotspot/off`);
}
if (response.ok) {
return null;
} else {
const content = await response.json();
const code = content['code'];
if (code === 'NATIVE_ONLY') {
toast({
type: 'error',
title: 'Unsupported!',
description: 'Hotspot is only supported outside Docker!',
time: 5000,
});
} else {
const message = await getErrorMessage(response, 'Could not modify hotspot. See server logs.');
toast({
type: 'error',
title: 'Hotspot Error',
description: message,
time: 5000,
});
}
}
}
export async function setThrottle(on) {
let response;
if (on) {
response = await apiPost(`${API_URI}/throttle/on`);
} else {
response = await apiPost(`${API_URI}/throttle/off`);
}
if (response.ok) {
return null;
} else {
const content = await response.json();
const code = content['code'];
if (code === 'NATIVE_ONLY') {
toast({
type: 'error',
title: 'Unsupported!',
description: 'Throttle is only supported on a Raspberry Pi!',
time: 5000,
});
} else {
const message = await getErrorMessage(response, 'Could not modify throttle. See server logs.');
toast({type: 'error', title: 'Throttle Error', description: message, time: 5000});
}
}
}
export async function getMapFiles() {
const response = await apiGet(`${API_URI}/map/files`);
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Could not get map files');
toast({type: 'error', title: 'Map Error', description: message, time: 5000});
}
}
export async function deleteMapFile(filename) {
const response = await apiDelete(`${API_URI}/map/files/${encodeURIComponent(filename)}`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Could not delete map file');
toast({type: 'error', title: 'Map Error', description: message, time: 5000});
}
}
export async function fetchMapSubscriptions() {
const response = await apiGet(`${API_URI}/map/subscribe`);
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Could not get map subscriptions');
toast({type: 'error', title: 'Map Error', description: message, time: 5000});
}
}
export async function mapSubscribe(name, region) {
const body = {name, region};
const response = await apiPost(`${API_URI}/map/subscribe`, body);
if (response.ok) {
return true;
} else {
const message = await getErrorMessage(response, 'Could not subscribe to map region');
toast({type: 'error', title: 'Map Error', description: message, time: 5000});
return false;
}
}
export async function mapUnsubscribe(region) {
const response = await apiDelete(`${API_URI}/map/subscribe/${encodeURIComponent(region)}`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Could not unsubscribe from map region');
toast({type: 'error', title: 'Map Error', description: message, time: 5000});
}
}
export async function getMapPins() {
const response = await apiGet(`${API_URI}/map/pins`);
if (response.ok) {
return await response.json();
}
}
export async function addMapPin(lat, lon, label, color) {
const body = {lat, lon, label, color};
const response = await apiPost(`${API_URI}/map/pins`, body);
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Could not add map pin');
toast({type: 'error', title: 'Map Error', description: message, time: 5000});
}
}
export async function deleteMapPin(pinId) {
const response = await apiDelete(`${API_URI}/map/pins/${pinId}`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Could not delete map pin');
toast({type: 'error', title: 'Map Error', description: message, time: 5000});
}
}
export async function updateMapPin(pinId, label, color) {
const body = {label, color};
const response = await apiPut(`${API_URI}/map/pins/${pinId}`, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Could not update map pin');
toast({type: 'error', title: 'Map Error', description: message, time: 5000});
}
}
export async function setMapDefaultLocation(lat, lon, zoom) {
try {
await updateConfig('wrolpi.yaml', {map_default_location: {lat, lon, zoom}});
toast({type: 'success', title: 'Default Location', description: 'Default map location saved', time: 3000});
return true;
} catch (e) {
toast({type: 'error', title: 'Map Error', description: 'Could not set default location', time: 5000});
return false;
}
}
export async function searchMap(query, limit = 12, offset = 0, lat = null, lon = null) {
let url = `${API_URI}/map/search?q=${encodeURIComponent(query)}&limit=${limit}&offset=${offset}`;
if (lat !== null && lon !== null) {
url += `&lat=${lat}&lon=${lon}`;
}
const response = await apiGet(url);
if (!response.ok) {
// Undefined, not {results: []}, so a failed search is not shown as finding nothing.
return undefined;
}
return await response.json();
}
export async function getMapSearchStatus() {
const response = await apiGet(`${API_URI}/map/search/status`);
if (!response.ok) {
// Undefined, not {indexed: [], missing: []}, which read as "no maps are installed".
return undefined;
}
return await response.json();
}
export async function searchEstimateMap(search_str) {
if (!search_str) return {mapPlaces: 0};
const response = await apiGet(`${API_URI}/map/search/estimate?q=${encodeURIComponent(search_str)}`);
if (!response.ok) return {mapPlaces: 0};
const data = await response.json();
return {mapPlaces: data.map_places || 0};
}
export async function rebuildMapSearchIndex(filename) {
const response = await apiPost(`${API_URI}/map/search/rebuild/${encodeURIComponent(filename)}`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Could not rebuild search index');
toast({type: 'error', title: 'Map Error', description: message, time: 5000});
} else {
toast({type: 'success', title: 'Search Index', description: 'Search index build started', time: 3000});
}
}
export async function clearCompletedDownloads() {
const response = await apiPost(`${API_URI}/download/clear_completed`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Could not clear completed downloads! See server logs.');
toast({type: 'error', title: 'Downloads Error', description: message, time: 5000});
}
return response
}
export async function deleteOnceDownloads() {
const response = await apiPost(`${API_URI}/download/delete_once`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Could not delete once downloads! See server logs.');
toast({type: 'error', title: 'Downloads Error', description: message, time: 5000});
}
return response
}
export async function retryOnceDownloads() {
const response = await apiPost(`${API_URI}/download/retry_once`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Could not retry once downloads! See server logs.');
toast({type: 'error', title: 'Downloads Error', description: message, time: 5000});
}
return response
}
export async function batchDeleteDownloads(downloadIds) {
const response = await apiPost(`${API_URI}/download/batch/delete`, {download_ids: downloadIds});
if (!response.ok) {
const message = await getErrorMessage(response, 'Could not delete downloads! See server logs.');
toast({type: 'error', title: 'Downloads Error', description: message, time: 5000});
}
return response
}
export async function batchRetryDownloads(downloadIds) {
const response = await apiPost(`${API_URI}/download/batch/retry`, {download_ids: downloadIds});
if (!response.ok) {
const message = await getErrorMessage(response, 'Could not retry downloads! See server logs.');
toast({type: 'error', title: 'Downloads Error', description: message, time: 5000});
}
return response
}
export async function batchClearDownloads(downloadIds) {
const response = await apiPost(`${API_URI}/download/batch/clear`, {download_ids: downloadIds});
if (!response.ok) {
const message = await getErrorMessage(response, 'Could not clear downloads! See server logs.');
toast({type: 'error', title: 'Downloads Error', description: message, time: 5000});
}
return response
}
export async function getStatistics() {
const response = await apiGet(`${API_URI}/statistics`);
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Unable to get file statistics');
toast({type: 'error', title: 'Error', description: message, time: 5000});
}
}
export async function getEvents(after) {
let uri = `${API_URI}/events/feed`;
if (after) {
uri = `${uri}?after=${encodeURIComponent(after)}`
}
const response = await apiGet(uri);
if (response.ok) {
return await response.json();
}
// Not toasting because this happens often.
return response
}
export async function getTags(tagNames) {
let uri = `${API_URI}/tag`;
if (tagNames && tagNames.length > 0) {
const params = new URLSearchParams();
(typeof tagNames === 'string' ? [tagNames] : tagNames).forEach(name => params.append('tag_names', name));
uri = `${uri}?${params.toString()}`;
}
const response = await apiGet(uri);
if (response.ok) {
const body = await response.json();
return body;
}
// Not toasting because this happens often.
return response
}
export async function getRecentTags() {
const uri = `${API_URI}/tag/recent`;
const response = await apiGet(uri);
if (response.ok) {
const body = await response.json();
return body['tag_names'] || [];
}
return [];
}
export async function tagFileGroup(fileGroup, name) {
const body = {tag_name: name};
const {id, primary_path, path} = fileGroup;
if (id) {
body['file_group_id'] = id;
} else if (primary_path) {
body['file_group_primary_path'] = primary_path;
} else if (path) {
body['file_group_primary_path'] = path;
}
const uri = `${API_URI}/files/tag`;
const response = await apiPost(uri, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Unable to add tag');
toast({type: 'error', title: 'Tag Error', description: message, time: 5000});
}
}
export async function untagFileGroup(fileGroup, name) {
const body = {tag_name: name};
const {id, primary_path, path} = fileGroup;
if (id) {
body['file_group_id'] = id;
} else if (primary_path) {
body['file_group_primary_path'] = primary_path;
} else if (path) {
body['file_group_primary_path'] = path;
}
const uri = `${API_URI}/files/untag`;
const response = await apiPost(uri, body)
if (!response.ok) {
const message = await getErrorMessage(response, 'Unable to untag');
toast({type: 'error', title: 'Tag Error', description: message, time: 5000});
}
}
export async function saveTag(name, color, id) {
const body = {name: name, color: color};
let uri = `${API_URI}/tag`;
if (id) {
uri = `${uri}/${id}`;
}
const response = await apiPost(uri, body);
if (id && response.status === 200) {
toast({
type: 'info', title: 'Saved tag', description: `Saved tag: ${name}`, time: 2000,
});
} else if (response.status === 201) {
toast({
type: 'info', title: 'Created new tag', description: `Created new tag: ${name}`, time: 2000,
});
} else {
const message = await getErrorMessage(response, 'Unable to save tag');
toast({type: 'error', title: 'Tag Error', description: message, time: 5000});
}
}
export async function deleteTag(id, name) {
const uri = `${API_URI}/tag/${id}`;
const response = await apiDelete(uri);
if (response.status === 400) {
const content = await response.json();
const message = await getErrorMessage(response, 'Cannot delete, Tag is used');
if (content['code'] === 'USED_TAG') {
toast({
type: 'error', title: 'Error!', description: message, time: 5000,
})
}
} else if (!response.ok) {
console.error('Failed to delete tag');
const message = await getErrorMessage(response, `Unable to delete tag: ${name}`);
toast({type: 'error', title: 'Tag Error', description: message, time: 5000});
}
}
export async function sendNotification(message, url) {
const body = {message, url};
const response = await apiPost(`${API_URI}/notify`, body);
if (response.status === 201) {
toast({type: 'success', title: 'Shared', description: 'Your share was sent', time: 2000});
} else {
const message = await getErrorMessage(response, 'Your share failed to send!');
toast({type: 'error', title: 'Error', description: message, time: 5000});
}
}
export async function searchDirectories(path) {
const body = {path: path || ''};
const response = await apiPost(`${API_URI}/files/search_directories`, body);
if (response.status === 204) {
return [];
} else if (response.status === 200) {
const content = await response.json();
return {
is_dir: content.is_dir,
directories: content.directories,
channel_directories: content.channel_directories,
domain_directories: content.domain_directories,
}
} else {
const message = await getErrorMessage(response, 'Failed to search directories!');
toast({type: 'error', title: 'Error', description: message, time: 5000});
}
}
export async function renamePath(path, newName) {
const body = {path, new_name: newName};
const response = await apiPost(`${API_URI}/files/rename`, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to rename!');
toast({type: 'error', title: 'Error', description: message, time: 5000});
}
}
export async function movePaths(destination, paths) {
const body = {destination, paths};
const response = await apiPost(`${API_URI}/files/move`, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to move!');
toast({type: 'error', title: 'Error', description: message, time: 5000});
}
}
export async function ignoreDirectory(directory) {
const body = {path: directory};
const response = await apiPost(`${API_URI}/files/ignore_directory`, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to ignore directory!');
toast({type: 'error', title: 'Error', description: message, time: 5000});
}
}
export async function unignoreDirectory(directory) {
const body = {path: directory};
const response = await apiPost(`${API_URI}/files/unignore_directory`, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to unignore directory!');
toast({type: 'error', title: 'Error', description: message, time: 5000});
}
}
export async function fetchZims() {
const response = await apiGet(`${API_URI}/zim`);
if (response.ok) {
const content = await response.json();
return {
zims: content['zims'],
}
} else {
const message = await getErrorMessage(response, 'Cannot fetch Zims');
toast({type: 'error', title: 'Error', description: message, time: 5000});
}
}
export async function fetchZimSubscriptions() {
const response = await apiGet(`${API_URI}/zim/subscribe`);
if (response.ok) {
const content = await response.json();
return {
subscriptions: content['subscriptions'],
catalog: content['catalog'],
iso_639_codes: content['iso_639_codes'],
}
} else {
const message = await getErrorMessage(response, 'Cannot fetch Zim Subscriptions');
toast({type: 'error', title: 'Error', description: message, time: 5000});
}
}
export async function searchZim(offset, limit, searchStr, zimId, activeTags) {
offset = parseInt(offset || 0);
limit = parseInt(limit || DEFAULT_LIMIT);
let body = {offset, limit, search_str: searchStr, tag_names: activeTags || []};
console.debug(`Searching Zim ${zimId} for: ${searchStr} tags: ${activeTags}`);
const response = await apiPost(`${ZIM_API}/search/${zimId}`, body);
if (response.ok) {
const content = await response.json();
const zim = content['zim'];
const searchLength = zim['search'].length;
console.debug(`Got ${searchLength} results for Zim ${zimId}`);
return content['zim'];
}
}
export async function tagZimEntry(zim_id, zim_entry, name) {
const body = {tag_name: name, zim_id: zim_id, zim_entry: zim_entry};
const uri = `${API_URI}/zim/tag`;
const response = await apiPost(uri, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to add zim tag');
toast({type: 'error', title: 'Error', description: message, time: 5000});
}
}
export async function untagZimEntry(zim_id, zim_entry, name) {
const body = {tag_name: name, zim_id: zim_id, zim_entry: zim_entry};
const uri = `${API_URI}/zim/untag`;
const response = await apiPost(uri, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to untag zim');
toast({type: 'error', title: 'Error', description: message, time: 5000});
}
}
export async function zimSubscribe(name, language) {
const body = {name, language};
const response = await apiPost(`${API_URI}/zim/subscribe`, body);
return response.status === 201;
}
export async function zimUnsubscribe(id) {
const response = await apiDelete(`${API_URI}/zim/subscribe/${id}`);
return response.status === 204;
}
export async function setZimAutoSearch(id, auto_search) {
const body = {auto_search};
const response = await apiPost(`${API_URI}/zim/${id}/auto_search`, body);
if (response.status !== 204) {
throw new Error('Failed to set Zim auto search');
}
}
export async function fetchDecoded(vinNumber) {
const body = {vin_number: vinNumber};
const response = await apiPost(`${API_URI}/vin_number_decoder`, body);
if (response.status === 200) {
const content = await response.json();
return content['vin'];
} else if (response.status === 400) {
console.error('Invalid VIN Number');
return null;
}
}
export async function searchSuggestions(search_str) {
const body = {search_str};
const response = await apiPost(`${API_URI}/search_suggestions`, body);
if (response.ok) {
const content = await response.json();
return {
channels: content.channels,
domains: content.domains,
authors: content.authors || [],
subjects: content.subjects || [],
repos: content.repos || [],
}
} else {
console.error('Failed to get file search suggestions!');
}
}
export async function searchEstimateFiles(search_str, tagNames, mimetypes, months, dateRange, anyTag) {
months = months ? months.map(i => parseInt(i)) : [];
const body = {search_str, tag_names: tagNames, mimetypes, months, any_tag: anyTag};
if (dateRange) {
body['from_year'] = dateRange[0];
body['to_year'] = dateRange[1];
}
const response = await apiPost(`${API_URI}/search_file_estimates`, body);
if (response.ok) {
const content = await response.json();
return {
fileGroups: content.file_groups,
fileGroupsDeep: content.file_groups_deep,
}
} else {
console.error('Failed to get file search estimates!');
}
}
export async function searchEstimateZims(search_str, tagNames) {
const body = {search_str: search_str, tag_names: tagNames};
const response = await apiPost(`${API_URI}/zim/search_estimates`, body);
if (response.ok) {
const content = await response.json();
return {
zimsEstimates: content.zims_estimates,
}
}
}
export async function searchEstimateOthers(tagNames, searchStr) {
const body = {tag_names: tagNames, search_str: searchStr || null};
const response = await apiPost(`${API_URI}/search_other_estimates`, body);
if (response.ok) {
const content = await response.json();
return {
others: content.others,
}
}
}
export async function searchChannels(tagNames, searchStr) {
const body = {tag_names: tagNames, search_str: searchStr};
const response = await apiPost(`${API_URI}/videos/channels/search`, body);
if (response.ok) {
const content = await response.json();
return {
channels: content.channels,
}
}
}
// Collections of `kind` whose name matches `searchStr` and/or which have any of the tags.
export async function searchCollections(kind, tagNames, searchStr) {
const body = {kind, tag_names: tagNames, search_str: searchStr};
const response = await apiPost(`${COLLECTIONS_API}/search`, body);
if (response.ok) {
const content = await response.json();
return {collections: content.collections};
}
}
export async function getOutdatedZims() {
const response = await apiGet(`${API_URI}/zim/outdated`);
if (response.status === 200) {
const content = await response.json();
return {
outdated: content['outdated'],
current: content['current'],
}
} else {
const message = await getErrorMessage(response, 'Cannot fetch outdated Zims. See server logs.');
toast({
type: 'error',
title: 'Error',
description: message,
time: 5000,
});
}
}
export async function deleteOutdatedZims() {
const response = await apiDelete(`${API_URI}/zim/outdated`);
return response.status === 204;
}
export async function postShutdown() {
// Call Controller directly for system shutdown
const {shutdownSystem} = await import('./api/controller');
try {
const result = await shutdownSystem();
if (result.success) {
return null; // Success
}
return {code: 'SHUTDOWN_FAILED', error: result.error};
} catch (e) {
return {code: 'SHUTDOWN_FAILED', error: e.message};
}
}
export async function postRestart() {
// Call Controller directly for system reboot
const {rebootSystem} = await import('./api/controller');
try {
const result = await rebootSystem();
if (result.success) {
return null; // Success
}
return {code: 'SHUTDOWN_FAILED', error: result.error};
} catch (e) {
return {code: 'SHUTDOWN_FAILED', error: e.message};
}
}
export async function postVideoFileFormat(video_file_format) {
const body = {video_file_format};
return await apiPost(`${API_URI}/videos/file_format`, body);
}
export async function checkUpgrade(force = false) {
const url = force ? `${API_URI}/upgrade/check?force=true` : `${API_URI}/upgrade/check`;
const response = await apiGet(url);
if (response.ok) {
return await response.json();
}
return null;
}
export async function triggerUpgrade() {
const response = await apiPost(`${API_URI}/upgrade/start`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to start upgrade. See server logs.');
toast({
type: 'error',
title: 'Upgrade Error',
description: message,
time: 5000,
});
}
return response;
}
export async function getBulkTagPreview(paths) {
const body = {paths};
const response = await apiPost(`${API_URI}/files/bulk_tag/preview`, body);
if (response.ok) {
return await response.json();
} else {
const message = await getErrorMessage(response, 'Failed to get bulk tag preview.');
toast({type: 'error', title: 'Bulk Tag Error', description: message, time: 5000});
}
}
export async function applyBulkTags(paths, addTagNames, removeTagNames) {
const body = {
paths,
add_tag_names: addTagNames || [],
remove_tag_names: removeTagNames || [],
};
const response = await apiPost(`${API_URI}/files/bulk_tag/apply`, body);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to apply bulk tags.');
toast({type: 'error', title: 'Bulk Tag Error', description: message, time: 5000});
}
return response;
}
export async function getBulkTagProgress() {
const response = await apiGet(`${API_URI}/files/bulk_tag/progress`);
if (response.ok) {
return await response.json();
}
// Not toasting because this happens often during polling.
return null;
}
// Encrypted Cookies API
export async function getCookiesStatus() {
const response = await apiGet(`${VIDEOS_API}/cookies/status`);
if (response.ok) {
return await response.json();
}
const message = await getErrorMessage(response, 'Failed to get cookies status.');
toast({type: 'error', title: 'Cookies Error', description: message, time: 5000});
return null;
}
export async function uploadCookies(cookiesContent, password) {
const body = {cookies_content: cookiesContent, password};
const response = await apiPost(`${VIDEOS_API}/cookies`, body);
if (response.ok) {
toast({type: 'success', title: 'Cookies Saved', description: 'Cookies encrypted and saved successfully.', time: 3000});
return {success: true};
}
const message = await getErrorMessage(response, 'Failed to save cookies.');
toast({type: 'error', title: 'Cookies Error', description: message, time: 5000});
return {success: false, error: message};
}
export async function deleteCookies() {
const response = await apiDelete(`${VIDEOS_API}/cookies`);
if (response.status === 204) {
toast({type: 'success', title: 'Cookies Deleted', description: 'Encrypted cookies file deleted.', time: 3000});
return {success: true};
}
const message = await getErrorMessage(response, 'Failed to delete cookies.');
toast({type: 'error', title: 'Cookies Error', description: message, time: 5000});
return {success: false, error: message};
}
export async function unlockCookies(password) {
const body = {password};
const response = await apiPost(`${VIDEOS_API}/cookies/unlock`, body);
if (response.ok) {
toast({type: 'success', title: 'Cookies Unlocked', description: 'Cookies decrypted and ready for downloads.', time: 3000});
return {success: true};
}
const message = await getErrorMessage(response, 'Failed to unlock cookies.');
toast({type: 'error', title: 'Cookies Error', description: message, time: 5000});
return {success: false, error: message};
}
export async function lockCookies() {
const response = await apiPost(`${VIDEOS_API}/cookies/lock`);
if (response.ok) {
toast({type: 'success', title: 'Cookies Locked', description: 'Cookies cleared from memory.', time: 3000});
return {success: true};
}
const message = await getErrorMessage(response, 'Failed to lock cookies.');
toast({type: 'error', title: 'Cookies Error', description: message, time: 5000});
return {success: false, error: message};
}
export async function fetchSuggestedUserAgent() {
const response = await apiGet(`${VIDEOS_API}/suggested-user-agent`);
if (response.ok) {
const data = await response.json();
return data?.user_agent || '';
}
return '';
}
// Docs API
const DOCS_API = '/api/docs';
export async function getDocStatistics() {
return await apiGet(`${DOCS_API}/statistics`);
}
export async function getDoc(fileGroupId) {
return await apiGet(`${DOCS_API}/${fileGroupId}`);
}
export async function searchDocs(offset, limit, searchStr, order, tagNames, author, subject, mimetype, deep) {
offset = parseInt(offset || 0);
limit = parseInt(limit || DEFAULT_LIMIT);
let body = {offset, limit};
if (searchStr) {
body['search_str'] = searchStr;
}
if (order) {
body['order_by'] = order;
}
if (tagNames && tagNames.length > 0) {
body['tag_names'] = tagNames;
}
if (author) {
body['author'] = author;
}
if (subject) {
body['subject'] = subject;
}
if (mimetype) {
body['mimetype'] = mimetype;
}
if (deep) {
body['deep'] = true;
}
console.debug('searching docs', body);
const response = await apiPost(`${DOCS_API}/search`, body);
if (response.ok) {
let data = await response.json();
return [data['file_groups'], data['totals']['file_groups']];
} else {
const message = await getErrorMessage(response, 'Cannot search docs. See server logs.');
toast({
type: 'error',
title: 'Unable to search docs',
description: message,
time: 5000,
});
throw Error(message);
}
}
export async function fetchPlaylists() {
const response = await apiGet(`${COLLECTIONS_API}?kind=playlist`);
if (response.ok) {
return (await response.json())['collections'];
}
const message = await getErrorMessage(response, 'Failed to fetch playlists.');
toast({type: 'error', title: 'Error', description: message, time: 5000});
throw Error(message);
}
export async function getPlaylist(playlistId) {
const response = await apiGet(`${COLLECTIONS_API}/${playlistId}`);
if (response.ok) {
return (await response.json())['collection'];
}
const message = await getErrorMessage(response, 'Failed to fetch playlist.');
toast({type: 'error', title: 'Error', description: message, time: 5000});
throw Error(message);
}
export async function createPlaylist(name, description, tagName) {
// Trailing slash avoids Sanic's 302 redirect on the root POST route (which would drop the body).
const response = await apiPost(`${COLLECTIONS_API}/`,
{name, description, tag_name: tagName || null, kind: 'playlist'});
if (response.ok) {
return (await response.json())['collection'];
}
const message = await getErrorMessage(response, 'Failed to create playlist.');
toast({type: 'error', title: 'Error', description: message, time: 5000});
throw Error(message);
}
export async function deletePlaylist(playlistId) {
const response = await apiDelete(`${COLLECTIONS_API}/${playlistId}`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to delete playlist.');
toast({type: 'error', title: 'Error', description: message, time: 5000});
throw Error(message);
}
}
export async function addPlaylistItem(playlistId, item) {
// item: {item_kind, url?, title?, file_group_id?, zim_id?, zim_entry?, position?}
const response = await apiPost(`${COLLECTIONS_API}/${playlistId}/items`, item);
if (response.ok) {
return (await response.json())['item'];
}
const message = await getErrorMessage(response, 'Failed to add item.');
toast({type: 'error', title: 'Error', description: message, time: 5000});
throw Error(message);
}
export async function removePlaylistItem(playlistId, itemId) {
const response = await apiDelete(`${COLLECTIONS_API}/${playlistId}/items/${itemId}`);
if (!response.ok) {
const message = await getErrorMessage(response, 'Failed to remove item.');
toast({type: 'error', title: 'Error', description: message, time: 5000});
throw Error(message);
}
}
export async function reorderPlaylistItems(playlistId, itemIds) {
const response = await apiPut(`${COLLECTIONS_API}/${playlistId}/items/order`, {item_ids: itemIds});
if (response.ok) {
return (await response.json())['collection'];
}
const message = await getErrorMessage(response, 'Failed to reorder items.');
toast({type: 'error', title: 'Error', description: message, time: 5000});
throw Error(message);
}
// Update a playlist's name, description, and/or custom directory ('' clears the directory).
export async function updatePlaylist(playlistId, {name, description, directory}) {
const response = await apiPut(`${COLLECTIONS_API}/${playlistId}`, {name, description, directory});
if (response.ok) {
return (await response.json())['collection'];
}
const message = await getErrorMessage(response, 'Failed to update playlist.');
toast({type: 'error', title: 'Error', description: message, time: 5000});
throw Error(message);
}
// Set (tagName) or clear (tagName='') a playlist's single tag. When `directory` is given, the
// playlist also moves there (the managed tag location is normalized back to auto-managed).
export async function setPlaylistTag(playlistId, tagName, directory) {
const body = {tag_name: tagName || ''};
if (directory !== null && directory !== undefined) {
body.directory = directory;
}
const response = await apiPut(`${COLLECTIONS_API}/${playlistId}`, body);
if (response.ok) {
return (await response.json())['collection'];
}
const message = await getErrorMessage(response, 'Failed to update playlist tag.');
toast({type: 'error', title: 'Error', description: message, time: 5000});
throw Error(message);
}
// Bookmarks: a tree of nodes, a node with `children` being a directory.
export async function getBookmarks() {
const response = await apiGet(`${API_URI}/bookmarks`);
if (response.ok) {
return (await response.json())['bookmarks'];
}
throw new Error('Could not fetch bookmarks');
}
async function bookmarksModify(request, fallbackMessage) {
const response = await request;
if (response.ok) {
return response.status === 204 ? null : await response.json();
}
const message = await getErrorMessage(response, fallbackMessage);
toast({type: 'error', title: 'Bookmarks Error', description: message, time: 5000});
throw new Error(message);
}
export async function addBookmark({name, url, new_tab = false, parent_id = null, position = null}) {
const body = {name, url, new_tab, parent_id, position};
return await bookmarksModify(apiPost(`${API_URI}/bookmarks`, body), 'Could not add bookmark');
}
export async function addBookmarkDirectory({name, parent_id = null, position = null}) {
const body = {name, parent_id, position};
return await bookmarksModify(apiPost(`${API_URI}/bookmarks/directory`, body), 'Could not add directory');
}
export async function updateBookmark(nodeId, {name, url, new_tab}) {
const body = {name, url, new_tab};
return await bookmarksModify(apiPut(`${API_URI}/bookmarks/${nodeId}`, body), 'Could not update bookmark');
}
export async function moveBookmark(nodeId, parent_id, position = null) {
const body = {parent_id, position};
return await bookmarksModify(apiPost(`${API_URI}/bookmarks/${nodeId}/move`, body), 'Could not move bookmark');
}
export async function deleteBookmark(nodeId) {
return await bookmarksModify(apiDelete(`${API_URI}/bookmarks/${nodeId}`), 'Could not delete bookmark');
}
async function reposError(response, fallbackMessage) {
const message = await getErrorMessage(response, fallbackMessage);
toast({type: 'error', title: 'Repos Error', description: message, time: 5000});
throw Error(message);
}
export async function fetchRepos() {
const response = await apiGet(REPOS_API);
if (response.ok) {
return (await response.json())['repos'];
}
await reposError(response, 'Failed to fetch repos.');
}
export async function getRepo(repoId) {
const response = await apiGet(`${REPOS_API}/${repoId}`);
if (response.ok) {
return (await response.json())['repo'];
}
await reposError(response, 'Failed to fetch repo.');
}
// `repo`: {url, directory?, name?, tag_name?, frequency?, mode?, branch?, description?}
export async function createRepo(repo) {
const response = await apiPost(REPOS_API, repo);
if (response.ok) {
return (await response.json())['repo'];
}
await reposError(response, 'Failed to add repo.');
}
// `changes`: {description?, frequency?, mode?, branch?}. An empty branch follows the remote's default branch.
export async function updateRepo(repoId, changes) {
const response = await apiPut(`${REPOS_API}/${repoId}`, changes);
if (response.ok) {
return (await response.json())['repo'];
}
await reposError(response, 'Failed to update repo.');
}
export async function deleteRepo(repoId, deleteFiles = false) {
const response = await apiDelete(`${REPOS_API}/${repoId}?delete_files=${deleteFiles ? 'true' : 'false'}`);
if (!response.ok) {
await reposError(response, 'Failed to delete repo.');
}
}
// Download the repo's updates now.
export async function updateRepoNow(repoId) {
const response = await apiPost(`${REPOS_API}/${repoId}/update`);
if (!response.ok) {
await reposError(response, 'Failed to start the repo update.');
}
}
// One directory of a repo's files: {path, entries: [{name, path, is_dir, size}], readme_path}.
export async function getRepoTree(repoId, path) {
const query = path ? `?path=${encodeURIComponent(path)}` : '';
const response = await apiGet(`${REPOS_API}/${repoId}/tree${query}`);
if (response.ok) {
return await response.json();
}
await reposError(response, 'Failed to list the repo files.');
}
// Repos matching the search (name and README) and/or the tags. Each has an escaped `readme_headline`.
export async function searchRepos(searchStr, tagNames) {
const body = {search_str: searchStr || null, tag_names: tagNames || []};
const response = await apiPost(`${REPOS_API}/search`, body);
if (response.ok) {
const content = await response.json();
return {repos: content.repos, total: content.totals.repos};
}
await reposError(response, 'Failed to search repos.');
}
// The commits of a repo's checked out branch, newest first: {commits: [{sha, author, date, message}], total}.
export async function getRepoLog(repoId, offset = 0, limit = 50) {
const response = await apiGet(`${REPOS_API}/${repoId}/log?limit=${limit}&offset=${offset}`);
if (response.ok) {
return await response.json();
}
await reposError(response, 'Failed to fetch the repo history.');
}
// A ZIP of a repo's checked out files; the browser downloads it.
export function repoArchiveURL(repoId) {
return `${REPOS_API}/${repoId}/archive.zip`;
}
// What importing the git clone in `directory` (relative to the media directory) would do. Changes nothing.
export async function inspectRepoImport(directory, name, tagName, url) {
const body = {directory, name: name || null, tag_name: tagName || null, url: url || null};
const response = await apiPost(`${REPOS_API}/import/inspect`, body);
if (response.ok) {
return {inspection: (await response.json())['inspection']};
}
return {error: await getErrorMessage(response, 'Cannot import this directory.')};
}
// Import an existing git clone as a Repo; it stays where it is. `repo`: {directory, confirm, url?, name?, ...}
export async function importRepo(repo) {
const response = await apiPost(`${REPOS_API}/import`, repo);
if (response.ok) {
return (await response.json())['repo'];
}
await reposError(response, 'Failed to import the repo.');
}