diff --git a/docs/README.md b/docs/README.md index 577acc2..0b62304 100644 --- a/docs/README.md +++ b/docs/README.md @@ -1,192 +1,215 @@ -# Archivr +

+ + + Archivr — Preserve what matters. Forever. + +

-An open-source self-hosted archiving tool. Work in progress. +

+ MIT License + Rust 2024 + Self-hosted +

-- [x] Archiving - - [x] Archiving media files from social media platforms - - [x] YouTube Videos - - [x] YouTube Playlists - - [x] YouTube Channels - - [x] Twitter Videos - - [x] Instagram - - [x] Facebook - - [x] TikTok - - [x] Reddit - - [x] Snapchat - - [ ] YouTube Posts (postponed) - - [x] Archiving local files - - [x] Archiving Twitter Tweets, Threads, and Articles - - [ ] Archiving files from cloud storage services (Google Drive, Dropbox, OneDrive) and from URLs - - [x] URLs - - [ ] Google Drive - - [ ] Dropbox - - [ ] OneDrive - - (Some of these could be postponed for later.) - - [x] Archive web pages (HTML, CSS, JS, images) - - [ ] Archiving emails (???) - - [ ] Gmail - - [ ] Outlook - - [ ] Yahoo Mail -- [x] Management - - [x] Deduplication - - [x] Tagging system - - [x] Search functionality - - [ ] Categorization - - [x] Metadata extraction and storage -- [x] User Interface - - [x] Web-based UI - - [x] Authentication and login - - [x] Archive setup - - [x] Browse and view entries - - [x] Tag management and filtering - - [x] Search entries - - [x] View archive runs - - [x] Capture dialog - - [x] User settings and API tokens - - [x] Admin panel -- [ ] Backup and Sync - - [ ] Cloud backup (AWS S3, Google Cloud Storage) - - [ ] Local backup +--- -## Motivation +Archivr is a self-hosted tool for capturing and preserving digital content — YouTube videos and playlists, tweets and threads, Instagram, TikTok, web pages, and local files — into self-contained, locally-owned archives. Content is stored in SQLite with SHA3-256 blob deduplication, hierarchical tags, a browser-based UI, and role-based auth. -There are two driving factors behind this project: +## Table of Contents -- In the age of information, all data is ephemeral. Social media platforms frequently delete content, and cloud storage services can become inaccessible and unreliable. Being able to archive important data is _very important_ for preserving personal memories and digital history. -- I will be creating a small encyclopedia for my future family and kids. Therefore, I want to make sure that all the information I gather is preserved and accessible for future reference. +- [Features](#features) +- [Quick Start](#quick-start) + - [With Nix](#with-nix) + - [With Docker](#with-docker-1) +- [Architecture](#architecture) +- [Supported Inputs](#supported-inputs) + - [YouTube playlists and channels](#youtube-playlists-and-channels) + - [Video quality and audio-only](#video-quality-and-audio-only) +- [Configuration](#configuration) + - [TOML config file](#toml-config-file) + - [Environment variables](#environment-variables) +- [Deployment](#deployment) + - [Security](#security) + - [NixOS](#hosting-on-nixos) + - [Docker](#hosting-with-docker) +- [Development](#development) +- [License](#license) -This project aims to provide a reliable solution for archiving important data from various sources, ensuring that users can preserve their digital assets for the long term. +## Features -## Archive Inputs +- **Social media** — YouTube (videos, shorts, playlists, channels with sync mode), X/Twitter (tweet and thread JSON + media downloads), Instagram, TikTok, Facebook, Reddit, Snapchat via yt-dlp +- **Web pages** — full self-contained HTML snapshots via SingleFile + Chromium; optional Freedium mirror for paywalled articles; reader mode +- **Local files** — import any file from disk by `file://` path +- **Deduplication** — SHA3-256 content-addressed blob store shared across all captures; identical files are stored once +- **Tags and search** — hierarchical tag tree, full-text search, filterable entry list +- **Multiple archives** — the server mounts any number of separate archives from a single TOML config +- **Role-based auth** — Guest / User / Admin / Owner roles; session cookies and API tokens; Argon2 passwords +- **Quality selection** — choose video quality or audio-only per capture; a live metadata probe populates the selector before download -`archivr archive ` currently accepts three kinds of inputs: +## Quick Start -- Local files via `file://...` -- Direct platform URLs -- Platform shorthand inputs such as `tweet:...`, `yt:...`, or `instagram:...` - -## Running archivr - -archivr currently ships as two binaries: - -- `archivr` - - The CLI for creating and writing to one archive. - - Use this for `init` and `archive`. -- `archivr-server` - - The web server for reading one or more existing archives through the browser UI. - - Use this after archives already exist. - -With Nix, run the CLI with: +### With Nix ```sh -nix run .#archivr -- init ./my-archive --name "My Archive" -nix run .#archivr -- archive file:///absolute/path/to/file.pdf +# Create an archive +nix run github:thegeneralist/archivr#archivr -- init ./my-archive --name "My Archive" + +# Archive something +nix run github:thegeneralist/archivr#archivr -- archive https://www.youtube.com/watch?v=dQw4w9WgXcQ + +# Start the web UI (reads ./archivr-server.toml) +nix run github:thegeneralist/archivr#archivr-server ``` -Run the web server with: - -```sh -nix run .#archivr-server -- ./archivr-server.toml -``` - -The server expects a TOML registry file. If no path is passed, it reads `./archivr-server.toml`. - -Example: +Create `archivr-server.toml` next to where you run the command: ```toml +auth_db_path = "/absolute/path/to/archivr-auth.sqlite" + [[archives]] id = "personal" label = "Personal" archive_path = "/absolute/path/to/my-archive/.archivr" ``` -Then open: +Then open `http://127.0.0.1:8080`. On the first visit you will be prompted to create the owner account. -```text -http://127.0.0.1:8080 -``` - -When installed through Nix, `archivr-server` is wrapped so it can find the static web UI assets automatically. The wrapper sets `ARCHIVR_STATIC_DIR` to the installed static asset directory. Running from source with `cargo run -p archivr-server` falls back to `crates/archivr-server/static`. - -### Security and Deployment - -`archivr-server` is a **local-only tool by default**. It binds to `127.0.0.1:8080` and has no authentication or access control. Do not expose it to a public network or a shared LAN without understanding the risks. - -**Changing the bind address** - -You can set the bind address in your TOML config: - -```toml -# Optional. Default: 127.0.0.1:8080 -# Only change this if you know what you are doing — the server has no authentication. -bind = "127.0.0.1:9090" -``` - -Or override it with the `ARCHIVR_BIND` environment variable: +### With Docker ```sh -ARCHIVR_BIND=127.0.0.1:9090 nix run .#archivr-server -- ./archivr-server.toml +mkdir config +cp docker/config.example.toml config/archivr-server.toml +# Edit config/archivr-server.toml + +# Initialize the archive on the persistent volume (run once) +docker compose run --rm archivr archivr init \ + /data/archives/main /data/archives/main/.archivr/store \ + --name "Main Archive" + +docker compose up -d ``` -If the server is started with a non-loopback address (e.g. `0.0.0.0`), it prints a warning to stderr: +Open `http://localhost:8080`. See [Hosting with Docker](#hosting-with-docker) for volume layout and Twitter/X credential setup. -```text -warn: archivr-server is bound to 0.0.0.0:8080 — this server has no authentication. Only expose it on a trusted network. +## Architecture + +Two binaries: + +| Binary | Purpose | +|---|---| +| `archivr` | CLI — create archives (`init`) and add content (`archive`) | +| `archivr-server` | Web server — browse and search one or more archives via browser UI | + +Archive layout created by `archivr init`: + +``` +my-archive/ +├── .archivr/ # metadata: name, store_path, archivr.sqlite +└── store/ + ├── raw/ # deduplicated blobs: raw/A/B/.ext + ├── raw_tweets/ # tweet and thread JSON + ├── structured/ # structured metadata outputs + └── temp/ # staging area during capture ``` -**When will auth be added?** +A separate auth database (`archivr-auth.sqlite`, path set in TOML) holds users, sessions, API tokens, and role bits. It is independent of individual archives. -Auth and session handling will be designed when remote or public hosting becomes a real requirement. Until then, keep the server on loopback. See `crates/archivr-server/src/routes.rs` for the route classification that will guide where middleware is applied. +## Supported Inputs -### Supported Platforms +`archivr archive ` accepts URLs and platform shorthands: -- Local files: `file:///absolute/path/to/file.ext` -- YouTube media: individual videos/shorts, playlists, and channels; standard URLs or [shorthand video inputs](#supported-shorthand-inputs). Playlists and channels archive as a container entry with each video stored as a child entry beneath it. -- X/Twitter media from Tweets: normal Tweet URLs or the `tweet:media:ID` shorthand -- X/Twitter Tweet content scrape: [Tweet and Thread shorthands](#supported-shorthand-inputs). (These are saved as JSON files in `raw_tweets/`) -- Instagram, Facebook, TikTok, Reddit, Snapchat: direct URLs or platform-prefixed shorthand passed through to `yt-dlp` +| Platform | Input examples | +|---|---| +| Local file | `file:///absolute/path/to/file.pdf` | +| YouTube video / short | `https://youtube.com/watch?v=ID` · `yt:video/ID` · `yt:short/ID` | +| YouTube playlist | `https://youtube.com/playlist?list=ID` | +| YouTube channel | `https://youtube.com/@handle` | +| X/Twitter tweet (JSON) | `tweet:ID` · `x:tweet:ID` · `twitter:tweet:ID` | +| X/Twitter thread (JSON) | `x:thread:ID` · `twitter:thread:ID` | +| X/Twitter media download | `tweet:media:ID` | +| Instagram | Direct URL · `instagram:ID` | +| TikTok | Direct URL · `tiktok:ID` | +| Facebook | Direct URL · `facebook:ID` | +| Reddit | Direct URL · `reddit:ID` | +| Snapchat | Direct URL · `snapchat:ID` | +| Arbitrary URL / web page | Any `https://` URL | -#### Video quality and audio-only downloads +### YouTube playlists and channels -When capturing via the web UI, entering a URL for a yt-dlp-backed source (YouTube, Instagram, TikTok, Facebook, Reddit, Snapchat, X media) triggers a metadata probe via `GET /api/archives/:id/captures/probe`. The quality selector then shows only the heights actually available in that video plus **Best quality** (default). An **Audio only** option is appended whenever the probe confirms an audio track exists. UI behaviour by probe outcome: +Capturing a playlist or channel creates a **container entry** with each video archived as a child beneath it. Before downloading, the UI probes each video for available quality options — set quality per-video or apply one to the whole batch. Individual videos can be excluded with the remove button. + +**Sync mode:** when re-archiving a playlist or channel, enable sync mode in the capture dialog to skip videos that are already in the archive. Only new videos are downloaded; the existing container is reused. + +### Video quality and audio-only + +When capturing a yt-dlp-backed source through the web UI, a metadata probe runs first and populates the quality selector with heights actually available in that video: | `qualities` | `has_audio` | UI shows | |---|---|---| | `["1080p", "720p", …]` | `true` | Best / heights / Audio only | | `["1080p", …]` | `false` | Best / heights | -| `[]` | `true` | Audio only (pre-selected, no Best option) | +| `[]` | `true` | Audio only (pre-selected) | | `[]` | `false` | "No media detected" | -| probe fails (502) | — | picker hidden, capture still submittable | +| probe fails (502) | — | picker hidden; capture still submittable | -The `POST /api/archives/:id/captures` endpoint accepts an optional `quality` field: `"best"`, `"audio"`, or any `"NNNp"` height string: +The `POST /api/archives/:id/captures` endpoint accepts an optional `quality` field: ```json { "locator": "https://www.youtube.com/watch?v=...", "quality": "720p" } { "locator": "https://www.youtube.com/watch?v=...", "quality": "audio" } ``` -`"audio"` selects the most efficient native audio track without transcoding: Opus/WebM is preferred (smallest at equivalent quality), then AAC/M4A, then whatever yt-dlp considers best. The saved file's extension matches the native format (`.webm` for Opus, `.m4a` for AAC, etc.) — no ffmpeg re-encode, no size inflation. Any `"NNNp"` height is accepted; the server builds the yt-dlp format selector with an unconditional `/best` fallback so the download succeeds even if the exact height is unavailable. Omitting `quality` or passing `"best"` downloads at the highest available quality. Anything else is rejected with HTTP 400. +`"audio"` selects the most efficient native audio track without re-encoding (Opus/WebM preferred, then AAC/M4A). Omitting `quality` or passing `"best"` downloads at the highest available quality. -The probe endpoint (`GET /api/archives/:id/captures/probe?locator=…`) requires auth and returns 200 with: -```json -{ "has_video": true, "has_audio": true, "qualities": ["1080p", "720p", "480p"] } -{ "has_video": false, "has_audio": true, "qualities": [] } -{ "has_video": false, "has_audio": false, "qualities": [] } +## Configuration + +### TOML config file + +```toml +# Optional. Default: 127.0.0.1:8080 +bind = "127.0.0.1:8080" + +# Required. Persists across upgrades; must be on a writable path. +auth_db_path = "/var/lib/archivr/archivr-auth.sqlite" + +[[archives]] +id = "personal" +label = "Personal" +archive_path = "/srv/archivr/personal/.archivr" + +[[archives]] +id = "work" +label = "Work" +archive_path = "/srv/archivr/work/.archivr" ``` -`has_video: false, has_audio: false` means yt-dlp found no downloadable tracks (e.g. a tweet with no media). A 502 means yt-dlp itself failed (transient network error, rate-limit, unsupported extractor) — treat as inconclusive, not "no media." -#### YouTube playlists and channels +See `docker/config.example.toml` for a complete annotated example. -Capturing a YouTube playlist or channel URL creates a **container entry** for the playlist or channel, with each video archived as a child entry beneath it. Before downloading, the capture UI probes each video to fetch available quality options, letting you set quality per-video or apply a single quality to the whole playlist. +### Environment variables -**Incremental sync:** When re-archiving a playlist or channel, enable **sync mode** in the capture dialog to skip videos that are already in the archive. Only new videos are downloaded; the existing container entry is reused. +| Variable | Default | Description | +|---|---|---| +| `ARCHIVR_BIND` | `127.0.0.1:8080` | Bind address; overrides `bind` in TOML | +| `ARCHIVR_STATIC_DIR` | `crates/archivr-server/static` | Pre-built frontend asset directory | +| `ARCHIVR_YT_DLP` | `yt-dlp` | yt-dlp binary used for video and social downloads | +| `ARCHIVR_SINGLE_FILE` | `single-file` | single-file-cli binary for web page archiving | +| `ARCHIVR_CHROME` | `chromium` | Chromium executable passed to single-file | +| `ARCHIVR_CHROME_ARGS` | — | Extra space-separated Chromium flags (Docker sets `--no-sandbox`) | +| `ARCHIVR_TWITTER_CREDENTIALS_FILE` | — | Cookies file for tweet/thread scraping — required for `tweet:ID` and `x:thread:ID` inputs | +| `ARCHIVR_TWEET_SCRAPER` | `vendor/twitter/scrape_user_tweet_contents.py` | Tweet scraper script path | +| `ARCHIVR_TWEET_PYTHON` | `python3` | Python executable for the tweet scraper | -**Excluding individual videos:** In the expanded per-video list, each video has a remove button (×) to exclude it from the current capture. Removed videos are not downloaded; the rest proceed normally. +The Nix wrapper and Docker image set `ARCHIVR_STATIC_DIR`, `ARCHIVR_SINGLE_FILE`, and `ARCHIVR_CHROME` automatically. + +## Deployment + +### Security + +`archivr-server` binds to `127.0.0.1:8080` by default. Do not expose it to a public network without understanding the risks. When started on a non-loopback address the server logs a warning to stderr. ### Hosting on NixOS -The flake exposes a `nixosModules.default` output. Add it to your system flake and -enable the service: +The flake exposes `nixosModules.default`: ```nix # flake.nix (your system flake) @@ -200,7 +223,7 @@ enable the service: { services.archivr-server = { enable = true; - # listenAddress defaults to "127.0.0.1" (loopback only) + # listenAddress defaults to "127.0.0.1" # port defaults to 8080 archives = [ { id = "personal"; label = "Personal"; path = "/srv/archivr/personal/.archivr"; } @@ -214,161 +237,73 @@ enable the service: } ``` -The module: -- Creates an `archivr` system user and group. -- Generates the TOML config from your options and stores the auth database under - `/var/lib/archivr-server/` (persists across upgrades). -- Runs under a hardened systemd unit (`ProtectSystem = strict`, `NoNewPrivileges`, - `PrivateTmp`, etc.). Archive directories are whitelisted for read-write access. -- Restarts automatically on failure. +The module creates an `archivr` system user and group, generates the TOML config from your options, stores the auth database at `/var/lib/archivr-server/` (persists across upgrades), and runs under a hardened systemd unit (`ProtectSystem = strict`, `NoNewPrivileges`, `PrivateTmp`). Archive directories are whitelisted for read-write access. -**`openFirewall`** — set to `true` to open the TCP port derived from `bind`. -Only needed when binding to a non-loopback address: - -```nix -services.archivr-server = { - listenAddress = "0.0.0.0"; - port = 8080; # explicit, though 8080 is the default - openFirewall = true; -}; -``` - -**Archive directories** must be readable and writable by the `archivr` user. -Initialise them with `archivr init` first, then `chown -R archivr:archivr /srv/archivr`. +Set `openFirewall = true` with a non-loopback `listenAddress` only when LAN or remote access is required. +Archive directories must be owned by the `archivr` user. Initialise them with `archivr init` first, then `chown -R archivr:archivr /srv/archivr`. ### Hosting with Docker -A `Dockerfile` and `docker-compose.yml` are provided for self-hosting without Nix. +```sh +# 1. Configure +mkdir config +cp docker/config.example.toml config/archivr-server.toml +# Edit archivr-server.toml — set id, label, archive_path, and auth_db_path -**Quickstart** +# 2. Initialize each archive (run once per archive) +docker compose run --rm archivr archivr init \ + /data/archives/main /data/archives/main/.archivr/store \ + --name "Main Archive" -1. Copy the example config and edit it: - - ```sh - mkdir config - cp docker/config.example.toml config/archivr-server.toml - # edit config/archivr-server.toml — set archive id, label, and archive_path - ``` - -2. Initialize each archive on the persistent data volume before the first start. - The image includes the `archivr` CLI for this purpose: - - ```sh - docker compose run --rm archivr archivr init /data/archives/main /data/archives/main/.archivr/store --name "Main Archive" - ``` - - This creates `/data/archives/main/.archivr/` with the metadata the server requires. - A bare `mkdir` is not enough — the server reads `name` and `store_path` files that - only `archivr init` writes. - -3. Start the server: - - ```sh - docker compose up -d - ``` - - Then open `http://localhost:8080`. - -**Volumes** +# 3. Start +docker compose up -d +``` | Mount | Purpose | -|-------|---------| +|---|---| | `./config` (read-only) | Directory containing `archivr-server.toml` | | `archivr-data` named volume | Auth database (`/data/archivr-auth.sqlite`) and archive directories | -> **Important:** `auth_db_path` must be set explicitly in `archivr-server.toml` to a -> path on the writable data volume (e.g. `/data/archivr-auth.sqlite`). If left unset, -> the server defaults to writing the auth database next to the config file — which is -> on the read-only `/config` mount and will fail. The example config sets this correctly. +> **Important:** `auth_db_path` must point to a path on the writable data volume (e.g. `/data/archivr-auth.sqlite`). The example config sets this correctly. A bare `mkdir` is not enough to initialise an archive — `archivr init` writes metadata files the server requires. -**Twitter/X archiving** - -Supply a cookies file inside the config volume and set `ARCHIVR_TWITTER_CREDENTIALS_FILE` in `docker-compose.yml`: +**Twitter/X archiving:** supply a cookies file inside the config volume and reference it in `docker-compose.yml`: ```yaml environment: ARCHIVR_TWITTER_CREDENTIALS_FILE: /config/twitter-cookies.txt ``` -**Building the image locally** +**Building locally:** ```sh docker build -t archivr-server . ``` -The image compiles the Rust binary in a separate build stage so only the runtime -dependencies (Chromium, Node.js, Python) land in the final layer. +The image compiles the Rust binary in a separate build stage; only runtime dependencies (Chromium, Node.js, Python) land in the final layer. -### Supported Shorthand Inputs +## Development -- YouTube video/short media: - - `yt:video/ID` - - `youtube:video/ID` - - `yt:short/ID` - - `yt:shorts/ID` - - `youtube:shorts/ID` -- X/Twitter tweet JSON content: - - `tweet:ID` - - `x:tweet:ID` - - `x:x:ID` - - `twitter:x:ID` - - `twitter:tweet:ID` -- X/Twitter media/video download: - - `tweet:media:ID` -- X/Twitter thread JSON content: - - `x:thread:ID` - - `twitter:thread:ID` -- Other platform shorthands: - - `instagram:ID` - - `facebook:ID` - - `tiktok:ID` - - `reddit:ID` - - `snapchat:ID` +Runtime dependencies beyond Rust and Node: `yt-dlp`, Chromium, `single-file` (Node), Python 3 with `twitter-api-client`, `ffmpeg`. `nix develop` provides the dev subset. -### Environment Variables +```sh +# Rust (workspace root) +cargo build +cargo test +cargo test -p archivr-core +cargo run -p archivr-server -- ./archivr-server.toml -- `ARCHIVR_BIND` - - Optional. - - Overrides the bind address from the TOML config. Useful in Docker where you need - `0.0.0.0:8080` without editing the config file. Default: `127.0.0.1:8080`. -- `ARCHIVR_STATIC_DIR` - - Optional. - - Path to the directory of pre-built frontend assets served by the web UI. - Set automatically by the Nix wrapper and the Docker image. When running from - source with `cargo run`, falls back to `crates/archivr-server/static`. -- `ARCHIVR_YT_DLP` - - Optional. - - Overrides the `yt-dlp` binary used for YouTube, X media posts, Instagram, Facebook, TikTok, Reddit, and Snapchat downloads. -- `ARCHIVR_SINGLE_FILE` - - Optional. - - Overrides the `single-file` binary used for web page archiving. Set automatically by the Nix wrapper and the Docker image. -- `ARCHIVR_CHROME` - - Optional. - - Overrides the Chromium/Chrome executable passed to `single-file` via `--browser-executable-path`. Set automatically by the Nix wrapper and the Docker image. Default: `chromium`. -- `ARCHIVR_CHROME_ARGS` - - Optional. - - Space-separated extra flags appended to Chromium's `--browser-args`. The Docker - image sets this to `--no-sandbox` because Chromium refuses to run as root without - it. Leave unset when running natively (Nix, Linux desktop). - A `--window-size=1920,1080` is always passed to provide a realistic desktop - viewport (so responsive @media rules and styles are evaluated and preserved - correctly). Supply your own `--window-size=...` here to override. -- `ARCHIVR_TWITTER_CREDENTIALS_FILE` - - Required for tweet/thread scraping inputs such as `tweet:ID` and `x:thread:ID`. - - Must point to a cookies file for the vendored scraper. -- `ARCHIVR_TWEET_SCRAPER` - - Optional. - - Overrides the tweet scraper script path. Default: `vendor/twitter/scrape_user_tweet_contents.py`. -- `ARCHIVR_TWEET_PYTHON` - - Optional. - - Overrides the Python executable used to run the tweet scraper. Default: `python3`. +# Frontend (from frontend/) +bun install +bun run dev # Vite dev server +bun run build # → crates/archivr-server/static/ +bun run storybook # Component QA on :6006 -### Current Limitations - -- Arbitrary `http://` or `https://` URLs that return HTML are archived as self-contained single-file HTML snapshots via `single-file-cli` (requires Chromium). Plain file URLs (PDFs, images, zips, etc.) are downloaded directly. Requires `single-file` and a Chromium binary on PATH, or the `ARCHIVR_SINGLE_FILE` / `ARCHIVR_CHROME` env vars set. -- Local files currently need to be passed as `file://...` paths. +# Nix +nix develop # dev shell +nix build .#archivr-server +``` ## License -This project is licensed under the MIT License. See the [LICENSE](LICENSE.md) file for details. +MIT — see [LICENSE](../LICENSE.md). diff --git a/docs/branding/assets/banner-dark.svg b/docs/branding/assets/banner-dark.svg new file mode 100644 index 0000000..c90872f --- /dev/null +++ b/docs/branding/assets/banner-dark.svg @@ -0,0 +1,22 @@ + + + + + + + + + A + + + archivr + + + Preserve what matters. Forever. + diff --git a/docs/branding/assets/banner-light.svg b/docs/branding/assets/banner-light.svg new file mode 100644 index 0000000..c684e46 --- /dev/null +++ b/docs/branding/assets/banner-light.svg @@ -0,0 +1,22 @@ + + + + + + + + + A + + + archivr + + + Preserve what matters. Forever. +