mirror of
https://github.com/sysadminsmedia/homebox.git
synced 2025-12-24 14:31:55 +01:00
Compare commits
5 Commits
mk/daily-a
...
homebox-co
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2b5d4074d3 | ||
|
|
4749ce791d | ||
|
|
1d941b148c | ||
|
|
c980ce679c | ||
|
|
84dc54be07 |
@@ -29,6 +29,6 @@
|
||||
// Comment out to connect as root instead. More info: https://aka.ms/vscode-remote/containers/non-root.
|
||||
"remoteUser": "node",
|
||||
"features": {
|
||||
"ghcr.io/devcontainers/features/go:1": "1.24"
|
||||
"ghcr.io/devcontainers/features/go:1": "1.21"
|
||||
}
|
||||
}
|
||||
|
||||
40
.github/AGENTS.md
vendored
40
.github/AGENTS.md
vendored
@@ -1,40 +0,0 @@
|
||||
This is a Go based repository with a VueJS client for the frontend built with Vite and Nuxt, with ShadCN.
|
||||
|
||||
To make life easier, the use of a Taskfile is included for the majority of development commands.
|
||||
|
||||
Please follow these guidelines when contributing:
|
||||
|
||||
## Required Before Each Commit
|
||||
- Generate Swagger Files: `task swag --force`
|
||||
- Generate JS API Client: `task typescript-types --force`
|
||||
- Lint Golang: `task go:lint`
|
||||
- Lint frontend: `task ui:fix`
|
||||
|
||||
## Repository Structure
|
||||
### Backend
|
||||
- `backend/`: Contains the backend folders
|
||||
- `backend/app`: Contains main app code including API endpoints
|
||||
- `backend/internal/core`: Contains basic services such as currencies
|
||||
- `backend/data`: Contains all information related to data, including `ent` schemas, repos, migrations, etc.
|
||||
- `backend/data/migrations`: Contains migration data, the `sqlite3` sub-folder contains sqlite migrations, `postgres` sub-folder the postgres migrations, BOTH are REQUIRED.
|
||||
- `backend/data/ent/schema`: Contains the actual `ent` data models.
|
||||
- `backend/data/repo`: Contains the data repositories
|
||||
- `backend/pkgs`: Contains general helper functions and services
|
||||
|
||||
### Frontend
|
||||
- `frontend/`: Contains initial frontend files
|
||||
- `frontend/components`: Contains the ShadCN components
|
||||
- `frontend/locales`: Contains the i18n JSON for languages
|
||||
- `frontend/pages`: Contains VueJS pages
|
||||
- `frontend/test`: Contains Playwright setup
|
||||
- `frontend/test/e2e`: Contains actual Playwright test files
|
||||
|
||||
### Docs
|
||||
- `docs/`: Contains VitePress based documentation
|
||||
|
||||
## Key Guidelines
|
||||
1. Follow best practices for the various programming languages
|
||||
2. Maintain existing code structure and organization when possible
|
||||
3. Use dependency injection when reasonable
|
||||
4. Write tests for new functionality and after fixing bugs to validate they're fixed
|
||||
5. Document changes to the `docs/` folder when appropriate
|
||||
52
.github/workflows/copilot-setup-steps.yml
vendored
52
.github/workflows/copilot-setup-steps.yml
vendored
@@ -1,52 +0,0 @@
|
||||
name: "Copilot Setup Steps"
|
||||
|
||||
# Automatically run the setup steps when they are changed to allow for easy validation, and
|
||||
# allow manual testing through the repository's "Actions" tab
|
||||
on:
|
||||
workflow_dispatch:
|
||||
push:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/copilot-setup-steps.yml
|
||||
|
||||
jobs:
|
||||
# The job MUST be called `copilot-setup-steps` or it will not be picked up by Copilot.
|
||||
copilot-setup-steps:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
# Set the permissions to the lowest permissions possible needed for your steps.
|
||||
# Copilot will be given its own token for its operations.
|
||||
permissions:
|
||||
# If you want to clone the repository as part of your setup steps, for example to install dependencies, you'll need the `contents: read` permission. If you don't clone the repository in your setup steps, Copilot will do this for you automatically after the steps complete.
|
||||
contents: read
|
||||
|
||||
# You can define any steps you want, and they will run before the agent starts.
|
||||
# If you do not check out your code, Copilot will do this for you.
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- uses: pnpm/action-setup@v3.0.0
|
||||
with:
|
||||
version: 9.12.2
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.24"
|
||||
cache-dependency-path: backend/go.mod
|
||||
|
||||
- name: Install Task
|
||||
uses: arduino/setup-task@v1
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Perform setup
|
||||
run: task setup
|
||||
@@ -33,7 +33,7 @@ permissions:
|
||||
|
||||
env:
|
||||
DOCKERHUB_REPO: sysadminsmedia/homebox
|
||||
GHCR_REPO: ghcr.io/${{ github.repository }}
|
||||
GHCR_REPO: ghcr.io/sysadminsmedia/homebox
|
||||
|
||||
jobs:
|
||||
build:
|
||||
@@ -83,7 +83,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -159,7 +159,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -204,7 +204,7 @@ jobs:
|
||||
- name: Create manifest list and push Dockerhub
|
||||
id: push-dockerhub
|
||||
working-directory: /tmp/digests
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)
|
||||
|
||||
7
.github/workflows/docker-publish.yaml
vendored
7
.github/workflows/docker-publish.yaml
vendored
@@ -27,7 +27,7 @@ on:
|
||||
|
||||
env:
|
||||
DOCKERHUB_REPO: sysadminsmedia/homebox
|
||||
GHCR_REPO: ghcr.io/${{ github.repository }}
|
||||
GHCR_REPO: ghcr.io/sysadminsmedia/homebox
|
||||
|
||||
permissions:
|
||||
contents: read # Access to repository contents
|
||||
@@ -78,7 +78,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -152,7 +152,6 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
uses: docker/login-action@v3
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_PASSWORD }}
|
||||
@@ -195,7 +194,7 @@ jobs:
|
||||
- name: Create manifest list and push Dockerhub
|
||||
id: push-dockerhub
|
||||
working-directory: /tmp/digests
|
||||
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
|
||||
if: github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/')
|
||||
run: |
|
||||
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
|
||||
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)
|
||||
|
||||
8
.vscode/launch.json
vendored
8
.vscode/launch.json
vendored
@@ -16,12 +16,14 @@
|
||||
"type": "go",
|
||||
"request": "launch",
|
||||
"mode": "debug",
|
||||
"program": "${workspaceFolder}/backend/app/api/",
|
||||
"program": "${workspaceRoot}/backend/app/api/",
|
||||
"args": [],
|
||||
"env": {
|
||||
"HBOX_DEMO": "true",
|
||||
"HBOX_LOG_LEVEL": "debug",
|
||||
"HBOX_DEBUG_ENABLED": "true"
|
||||
"HBOX_DEBUG_ENABLED": "true",
|
||||
"HBOX_STORAGE_DATA": "${workspaceRoot}/backend/.data",
|
||||
"HBOX_STORAGE_SQLITE_URL": "${workspaceRoot}/backend/.data/homebox.db?_fk=1&_time_format=sqlite"
|
||||
},
|
||||
"console": "integratedTerminal",
|
||||
},
|
||||
@@ -44,4 +46,4 @@
|
||||
"console": "integratedTerminal",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,6 @@ builds:
|
||||
- linux
|
||||
- windows
|
||||
- darwin
|
||||
- freebsd
|
||||
goarch:
|
||||
- amd64
|
||||
- "386"
|
||||
@@ -26,16 +25,11 @@ builds:
|
||||
goarch: arm
|
||||
- goos: windows
|
||||
goarch: "386"
|
||||
- goos: freebsd
|
||||
goarch: arm
|
||||
- goos: freebsd
|
||||
goarch: "386"
|
||||
tags:
|
||||
- >-
|
||||
{{- if eq .Arch "riscv64" }}nodynamic
|
||||
{{- else if eq .Arch "arm" }}nodynamic
|
||||
{{- else if eq .Arch "386" }}nodynamic
|
||||
{{- else if eq .Os "freebsd" }}nodynamic
|
||||
{{ end }}
|
||||
|
||||
signs:
|
||||
|
||||
@@ -1,16 +1,22 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
"github.com/pressly/goose/v3"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/analytics"
|
||||
"github.com/google/uuid"
|
||||
"github.com/sysadminsmedia/homebox/backend/pkgs/utils"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/pressly/goose/v3"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/go-chi/chi/v5/middleware"
|
||||
|
||||
"github.com/hay-kot/httpkit/errchain"
|
||||
"github.com/hay-kot/httpkit/graceful"
|
||||
"github.com/rs/zerolog"
|
||||
@@ -22,6 +28,7 @@ import (
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/migrations"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/repo"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/analytics"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/web/mid"
|
||||
|
||||
@@ -30,6 +37,7 @@ import (
|
||||
_ "github.com/sysadminsmedia/homebox/backend/internal/data/migrations/sqlite3"
|
||||
_ "github.com/sysadminsmedia/homebox/backend/pkgs/cgofreesqlite"
|
||||
|
||||
"gocloud.dev/pubsub"
|
||||
_ "gocloud.dev/pubsub/awssnssqs"
|
||||
_ "gocloud.dev/pubsub/azuresb"
|
||||
_ "gocloud.dev/pubsub/gcppubsub"
|
||||
@@ -94,13 +102,41 @@ func main() {
|
||||
}
|
||||
}
|
||||
|
||||
//nolint:gocyclo
|
||||
func run(cfg *config.Config) error {
|
||||
app := new(cfg)
|
||||
app.setupLogger()
|
||||
|
||||
if cfg.Options.AllowAnalytics {
|
||||
analytics.Send(version, build())
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Initialize Database & Repos
|
||||
setupStorageDir(cfg)
|
||||
|
||||
if strings.HasPrefix(cfg.Storage.ConnString, "file:///./") {
|
||||
raw := strings.TrimPrefix(cfg.Storage.ConnString, "file:///./")
|
||||
clean := filepath.Clean(raw)
|
||||
absBase, err := filepath.Abs(clean)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to get absolute path for storage connection string")
|
||||
}
|
||||
// Construct and validate the full storage path
|
||||
storageDir := filepath.Join(absBase, cfg.Storage.PrefixPath)
|
||||
// Set windows paths to use forward slashes required by go-cloud
|
||||
storageDir = strings.ReplaceAll(storageDir, "\\", "/")
|
||||
if !strings.HasPrefix(storageDir, absBase+"/") && storageDir != absBase {
|
||||
log.Fatal().
|
||||
Str("path", storageDir).
|
||||
Msg("invalid storage path: you tried to use a prefix that is not a subdirectory of the base path")
|
||||
}
|
||||
// Create with more restrictive permissions
|
||||
if err := os.MkdirAll(storageDir, 0o750); err != nil {
|
||||
log.Fatal().
|
||||
Err(err).
|
||||
Msg("failed to create data directory")
|
||||
}
|
||||
}
|
||||
|
||||
if strings.ToLower(cfg.Database.Driver) == "postgres" {
|
||||
if !validatePostgresSSLMode(cfg.Database.SslMode) {
|
||||
@@ -108,7 +144,23 @@ func run(cfg *config.Config) error {
|
||||
}
|
||||
}
|
||||
|
||||
databaseURL := setupDatabaseURL(cfg)
|
||||
// Set up the database URL based on the driver because for some reason a common URL format is not used
|
||||
databaseURL := ""
|
||||
switch strings.ToLower(cfg.Database.Driver) {
|
||||
case "sqlite3":
|
||||
databaseURL = cfg.Database.SqlitePath
|
||||
|
||||
// Create directory for SQLite database if it doesn't exist
|
||||
dbFilePath := strings.Split(cfg.Database.SqlitePath, "?")[0] // Remove query parameters
|
||||
dbDir := filepath.Dir(dbFilePath)
|
||||
if err := os.MkdirAll(dbDir, 0o755); err != nil {
|
||||
log.Fatal().Err(err).Str("path", dbDir).Msg("failed to create SQLite database directory")
|
||||
}
|
||||
case "postgres":
|
||||
databaseURL = fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=%s", cfg.Database.Host, cfg.Database.Port, cfg.Database.Username, cfg.Database.Password, cfg.Database.Database, cfg.Database.SslMode)
|
||||
default:
|
||||
log.Fatal().Str("driver", cfg.Database.Driver).Msg("unsupported database driver")
|
||||
}
|
||||
|
||||
c, err := ent.Open(strings.ToLower(cfg.Database.Driver), databaseURL)
|
||||
if err != nil {
|
||||
@@ -134,9 +186,25 @@ func run(cfg *config.Config) error {
|
||||
return err
|
||||
}
|
||||
|
||||
collectFuncs, err := loadCurrencies(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
collectFuncs := []currencies.CollectorFunc{
|
||||
currencies.CollectDefaults(),
|
||||
}
|
||||
|
||||
if cfg.Options.CurrencyConfig != "" {
|
||||
log.Info().
|
||||
Str("path", cfg.Options.CurrencyConfig).
|
||||
Msg("loading currency config file")
|
||||
|
||||
content, err := os.ReadFile(cfg.Options.CurrencyConfig)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("path", cfg.Options.CurrencyConfig).
|
||||
Msg("failed to read currency config file")
|
||||
return err
|
||||
}
|
||||
|
||||
collectFuncs = append(collectFuncs, currencies.CollectJSON(bytes.NewReader(content)))
|
||||
}
|
||||
|
||||
currencies, err := currencies.CollectionCurrencies(collectFuncs...)
|
||||
@@ -194,31 +262,150 @@ func run(cfg *config.Config) error {
|
||||
return httpserver.ListenAndServe()
|
||||
})
|
||||
|
||||
// =========================================================================
|
||||
// Start Reoccurring Tasks
|
||||
registerRecurringTasks(app, cfg, runner)
|
||||
|
||||
// Send analytics if enabled at around midnight UTC
|
||||
if cfg.Options.AllowAnalytics {
|
||||
analyticsTime := time.Second
|
||||
runner.AddPlugin(NewTask("send-analytics", analyticsTime, func(ctx context.Context) {
|
||||
for {
|
||||
now := time.Now().UTC()
|
||||
nextMidnight := time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, time.UTC)
|
||||
dur := time.Until(nextMidnight)
|
||||
analyticsTime = dur
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-time.After(dur):
|
||||
log.Debug().Msg("running send analytics")
|
||||
err := analytics.Send(version, build())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to send analytics")
|
||||
}
|
||||
runner.AddFunc("eventbus", app.bus.Run)
|
||||
|
||||
runner.AddFunc("seed_database", func(ctx context.Context) error {
|
||||
// TODO: Remove through external API that does setup
|
||||
if cfg.Demo {
|
||||
log.Info().Msg("Running in demo mode, creating demo data")
|
||||
err := app.SetupDemo()
|
||||
if err != nil {
|
||||
log.Fatal().Msg(err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
runner.AddPlugin(NewTask("purge-tokens", time.Duration(24)*time.Hour, func(ctx context.Context) {
|
||||
_, err := app.repos.AuthTokens.PurgeExpiredTokens(ctx)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("failed to purge expired tokens")
|
||||
}
|
||||
}))
|
||||
|
||||
runner.AddPlugin(NewTask("purge-invitations", time.Duration(24)*time.Hour, func(ctx context.Context) {
|
||||
_, err := app.repos.Groups.InvitationPurge(ctx)
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("failed to purge expired invitations")
|
||||
}
|
||||
}))
|
||||
|
||||
runner.AddPlugin(NewTask("send-notifications", time.Duration(1)*time.Hour, func(ctx context.Context) {
|
||||
now := time.Now()
|
||||
|
||||
if now.Hour() == 8 {
|
||||
fmt.Println("run notifiers")
|
||||
err := app.services.BackgroundService.SendNotifiersToday(context.Background())
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("failed to send notifiers")
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
go runner.AddFunc("create-thumbnails-subscription", func(ctx context.Context) error {
|
||||
pubsubString, err := utils.GenerateSubPubConn(cfg.Database.PubSubConnString, "thumbnails")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to generate pubsub connection string")
|
||||
return err
|
||||
}
|
||||
topic, err := pubsub.OpenTopic(ctx, pubsubString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func(topic *pubsub.Topic, ctx context.Context) {
|
||||
err := topic.Shutdown(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("fail to shutdown pubsub topic")
|
||||
}
|
||||
}(topic, ctx)
|
||||
|
||||
subscription, err := pubsub.OpenSubscription(ctx, pubsubString)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to open pubsub topic")
|
||||
return err
|
||||
}
|
||||
defer func(topic *pubsub.Subscription, ctx context.Context) {
|
||||
err := topic.Shutdown(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("fail to shutdown pubsub topic")
|
||||
}
|
||||
}(subscription, ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
msg, err := subscription.Receive(ctx)
|
||||
log.Debug().Msg("received thumbnail generation request from pubsub topic")
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to receive message from pubsub topic")
|
||||
}
|
||||
groupId, err := uuid.Parse(msg.Metadata["group_id"])
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("group_id", msg.Metadata["group_id"]).
|
||||
Msg("failed to parse group ID from message metadata")
|
||||
}
|
||||
attachmentId, err := uuid.Parse(msg.Metadata["attachment_id"])
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Str("attachment_id", msg.Metadata["attachment_id"]).
|
||||
Msg("failed to parse attachment ID from message metadata")
|
||||
}
|
||||
err = app.repos.Attachments.CreateThumbnail(ctx, groupId, attachmentId, msg.Metadata["title"], msg.Metadata["path"])
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to create thumbnail")
|
||||
}
|
||||
msg.Ack()
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
if cfg.Options.GithubReleaseCheck {
|
||||
runner.AddPlugin(NewTask("get-latest-github-release", time.Hour, func(ctx context.Context) {
|
||||
log.Debug().Msg("running get latest github release")
|
||||
err := app.services.BackgroundService.GetLatestGithubRelease(context.Background())
|
||||
if err != nil {
|
||||
log.Error().
|
||||
Err(err).
|
||||
Msg("failed to get latest github release")
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
if cfg.Debug.Enabled {
|
||||
runner.AddFunc("debug", func(ctx context.Context) error {
|
||||
debugserver := http.Server{
|
||||
Addr: fmt.Sprintf("%s:%s", cfg.Web.Host, cfg.Debug.Port),
|
||||
Handler: app.debugRouter(),
|
||||
ReadTimeout: cfg.Web.ReadTimeout,
|
||||
WriteTimeout: cfg.Web.WriteTimeout,
|
||||
IdleTimeout: cfg.Web.IdleTimeout,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = debugserver.Shutdown(context.Background())
|
||||
}()
|
||||
|
||||
log.Info().Msgf("Debug server is running on %s:%s", cfg.Web.Host, cfg.Debug.Port)
|
||||
return debugserver.ListenAndServe()
|
||||
})
|
||||
// Print the configuration to the console
|
||||
cfg.Print()
|
||||
}
|
||||
|
||||
return runner.Start(context.Background())
|
||||
}
|
||||
|
||||
@@ -1,150 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"github.com/hay-kot/httpkit/graceful"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/sysadminsmedia/homebox/backend/pkgs/utils"
|
||||
"gocloud.dev/pubsub"
|
||||
)
|
||||
|
||||
func registerRecurringTasks(app *app, cfg *config.Config, runner *graceful.Runner) {
|
||||
runner.AddFunc("eventbus", app.bus.Run)
|
||||
|
||||
runner.AddFunc("seed_database", func(ctx context.Context) error {
|
||||
if cfg.Demo {
|
||||
log.Info().Msg("Running in demo mode, creating demo data")
|
||||
err := app.SetupDemo()
|
||||
if err != nil {
|
||||
log.Fatal().Msg(err.Error())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
runner.AddPlugin(NewTask("purge-tokens", 24*time.Hour, func(ctx context.Context) {
|
||||
_, err := app.repos.AuthTokens.PurgeExpiredTokens(ctx)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to purge expired tokens")
|
||||
}
|
||||
}))
|
||||
|
||||
runner.AddPlugin(NewTask("purge-invitations", 24*time.Hour, func(ctx context.Context) {
|
||||
_, err := app.repos.Groups.InvitationPurge(ctx)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to purge expired invitations")
|
||||
}
|
||||
}))
|
||||
|
||||
runner.AddPlugin(NewTask("send-notifications", time.Hour, func(ctx context.Context) {
|
||||
now := time.Now()
|
||||
if now.Hour() == 8 {
|
||||
fmt.Println("run notifiers")
|
||||
err := app.services.BackgroundService.SendNotifiersToday(context.Background())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to send notifiers")
|
||||
}
|
||||
}
|
||||
}))
|
||||
|
||||
if cfg.Thumbnail.Enabled {
|
||||
runner.AddFunc("create-thumbnails-subscription", func(ctx context.Context) error {
|
||||
pubsubString, err := utils.GenerateSubPubConn(cfg.Database.PubSubConnString, "thumbnails")
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to generate pubsub connection string")
|
||||
return err
|
||||
}
|
||||
topic, err := pubsub.OpenTopic(ctx, pubsubString)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer func(topic *pubsub.Topic, ctx context.Context) {
|
||||
err := topic.Shutdown(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("fail to shutdown pubsub topic")
|
||||
}
|
||||
}(topic, ctx)
|
||||
|
||||
subscription, err := pubsub.OpenSubscription(ctx, pubsubString)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to open pubsub topic")
|
||||
return err
|
||||
}
|
||||
defer func(topic *pubsub.Subscription, ctx context.Context) {
|
||||
err := topic.Shutdown(ctx)
|
||||
if err != nil {
|
||||
log.Err(err).Msg("fail to shutdown pubsub topic")
|
||||
}
|
||||
}(subscription, ctx)
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
default:
|
||||
msg, err := subscription.Receive(ctx)
|
||||
log.Debug().Msg("received thumbnail generation request from pubsub topic")
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to receive message from pubsub topic")
|
||||
continue
|
||||
}
|
||||
if msg == nil {
|
||||
log.Warn().Msg("received nil message from pubsub topic")
|
||||
continue
|
||||
}
|
||||
groupId, err := uuid.Parse(msg.Metadata["group_id"])
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("group_id", msg.Metadata["group_id"]).Msg("failed to parse group ID from message metadata")
|
||||
}
|
||||
attachmentId, err := uuid.Parse(msg.Metadata["attachment_id"])
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("attachment_id", msg.Metadata["attachment_id"]).Msg("failed to parse attachment ID from message metadata")
|
||||
}
|
||||
err = app.repos.Attachments.CreateThumbnail(ctx, groupId, attachmentId, msg.Metadata["title"], msg.Metadata["path"])
|
||||
if err != nil {
|
||||
log.Err(err).Msg("failed to create thumbnail")
|
||||
}
|
||||
msg.Ack()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
if cfg.Options.GithubReleaseCheck {
|
||||
runner.AddPlugin(NewTask("get-latest-github-release", time.Hour, func(ctx context.Context) {
|
||||
log.Debug().Msg("running get latest github release")
|
||||
err := app.services.BackgroundService.GetLatestGithubRelease(context.Background())
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to get latest github release")
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
if cfg.Debug.Enabled {
|
||||
runner.AddFunc("debug", func(ctx context.Context) error {
|
||||
debugserver := http.Server{
|
||||
Addr: fmt.Sprintf("%s:%s", cfg.Web.Host, cfg.Debug.Port),
|
||||
Handler: app.debugRouter(),
|
||||
ReadTimeout: cfg.Web.ReadTimeout,
|
||||
WriteTimeout: cfg.Web.WriteTimeout,
|
||||
IdleTimeout: cfg.Web.IdleTimeout,
|
||||
}
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
_ = debugserver.Shutdown(context.Background())
|
||||
}()
|
||||
|
||||
log.Info().Msgf("Debug server is running on %s:%s", cfg.Web.Host, cfg.Debug.Port)
|
||||
return debugserver.ListenAndServe()
|
||||
})
|
||||
// Print the configuration to the console
|
||||
cfg.Print()
|
||||
}
|
||||
}
|
||||
@@ -1,93 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/rs/zerolog/log"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/core/currencies"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/sys/config"
|
||||
)
|
||||
|
||||
// setupStorageDir handles the creation and validation of the storage directory.
|
||||
func setupStorageDir(cfg *config.Config) {
|
||||
if strings.HasPrefix(cfg.Storage.ConnString, "file:///./") {
|
||||
raw := strings.TrimPrefix(cfg.Storage.ConnString, "file:///./")
|
||||
clean := filepath.Clean(raw)
|
||||
absBase, err := filepath.Abs(clean)
|
||||
if err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to get absolute path for storage connection string")
|
||||
}
|
||||
storageDir := filepath.Join(absBase, cfg.Storage.PrefixPath)
|
||||
storageDir = strings.ReplaceAll(storageDir, "\\", "/")
|
||||
if !strings.HasPrefix(storageDir, absBase+"/") && storageDir != absBase {
|
||||
log.Fatal().Str("path", storageDir).Msg("invalid storage path: you tried to use a prefix that is not a subdirectory of the base path")
|
||||
}
|
||||
if err := os.MkdirAll(storageDir, 0o750); err != nil {
|
||||
log.Fatal().Err(err).Msg("failed to create data directory")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// setupDatabaseURL returns the database URL and ensures any required directories exist.
|
||||
func setupDatabaseURL(cfg *config.Config) string {
|
||||
databaseURL := ""
|
||||
switch strings.ToLower(cfg.Database.Driver) {
|
||||
case "sqlite3":
|
||||
databaseURL = cfg.Database.SqlitePath
|
||||
dbFilePath := strings.Split(cfg.Database.SqlitePath, "?")[0]
|
||||
dbDir := filepath.Dir(dbFilePath)
|
||||
if err := os.MkdirAll(dbDir, 0o755); err != nil {
|
||||
log.Fatal().Err(err).Str("path", dbDir).Msg("failed to create SQLite database directory")
|
||||
}
|
||||
case "postgres":
|
||||
databaseURL = fmt.Sprintf("host=%s port=%s dbname=%s sslmode=%s", cfg.Database.Host, cfg.Database.Port, cfg.Database.Database, cfg.Database.SslMode)
|
||||
if cfg.Database.Username != "" {
|
||||
databaseURL += fmt.Sprintf(" user=%s", cfg.Database.Username)
|
||||
}
|
||||
if cfg.Database.Password != "" {
|
||||
databaseURL += fmt.Sprintf(" password=%s", cfg.Database.Password)
|
||||
}
|
||||
if cfg.Database.SslRootCert != "" {
|
||||
if _, err := os.Stat(cfg.Database.SslRootCert); err != nil || !os.IsNotExist(err) {
|
||||
log.Fatal().Err(err).Str("path", cfg.Database.SslRootCert).Msg("SSL root certificate file does not accessible")
|
||||
}
|
||||
databaseURL += fmt.Sprintf(" sslrootcert=%s", cfg.Database.SslRootCert)
|
||||
}
|
||||
if cfg.Database.SslCert != "" {
|
||||
if _, err := os.Stat(cfg.Database.SslCert); err != nil || !os.IsNotExist(err) {
|
||||
log.Fatal().Err(err).Str("path", cfg.Database.SslCert).Msg("SSL certificate file does not accessible")
|
||||
}
|
||||
databaseURL += fmt.Sprintf(" sslcert=%s", cfg.Database.SslCert)
|
||||
}
|
||||
if cfg.Database.SslKey != "" {
|
||||
if _, err := os.Stat(cfg.Database.SslKey); err != nil || !os.IsNotExist(err) {
|
||||
log.Fatal().Err(err).Str("path", cfg.Database.SslKey).Msg("SSL key file does not accessible")
|
||||
}
|
||||
databaseURL += fmt.Sprintf(" sslkey=%s", cfg.Database.SslKey)
|
||||
}
|
||||
default:
|
||||
log.Fatal().Str("driver", cfg.Database.Driver).Msg("unsupported database driver")
|
||||
}
|
||||
return databaseURL
|
||||
}
|
||||
|
||||
// loadCurrencies loads currency data from config if provided.
|
||||
func loadCurrencies(cfg *config.Config) ([]currencies.CollectorFunc, error) {
|
||||
collectFuncs := []currencies.CollectorFunc{
|
||||
currencies.CollectDefaults(),
|
||||
}
|
||||
if cfg.Options.CurrencyConfig != "" {
|
||||
log.Info().Str("path", cfg.Options.CurrencyConfig).Msg("loading currency config file")
|
||||
content, err := os.ReadFile(cfg.Options.CurrencyConfig)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Str("path", cfg.Options.CurrencyConfig).Msg("failed to read currency config file")
|
||||
return nil, err
|
||||
}
|
||||
collectFuncs = append(collectFuncs, currencies.CollectJSON(bytes.NewReader(content)))
|
||||
}
|
||||
return collectFuncs, nil
|
||||
}
|
||||
@@ -40,7 +40,6 @@ require (
|
||||
gocloud.dev/pubsub/rabbitpubsub v0.41.0
|
||||
golang.org/x/crypto v0.39.0
|
||||
golang.org/x/image v0.28.0
|
||||
golang.org/x/text v0.26.0
|
||||
modernc.org/sqlite v1.37.1
|
||||
)
|
||||
|
||||
@@ -190,6 +189,7 @@ require (
|
||||
golang.org/x/oauth2 v0.28.0 // indirect
|
||||
golang.org/x/sync v0.15.0 // indirect
|
||||
golang.org/x/sys v0.33.0 // indirect
|
||||
golang.org/x/text v0.26.0 // indirect
|
||||
golang.org/x/time v0.11.0 // indirect
|
||||
golang.org/x/tools v0.33.0 // indirect
|
||||
golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect
|
||||
|
||||
@@ -352,8 +352,6 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/
|
||||
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-runewidth v0.0.9 h1:Lm995f3rfxdpd6TSmuVCHVb/QhupuXlYr8sCI/QdE+0=
|
||||
github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI=
|
||||
github.com/mattn/go-sqlite3 v1.14.28 h1:ThEiQrnbtumT+QMknw63Befp/ce/nUPgBPMlRFEum7A=
|
||||
github.com/mattn/go-sqlite3 v1.14.28/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||
github.com/mfridman/interpolate v0.0.2 h1:pnuTK7MQIxxFz1Gr+rjSIx9u7qVjf5VOoM/u6BbAxPY=
|
||||
@@ -377,8 +375,6 @@ github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJm
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/olahol/melody v1.2.1 h1:xdwRkzHxf+B0w4TKbGpUSSkV516ZucQZJIWLztOWICQ=
|
||||
github.com/olahol/melody v1.2.1/go.mod h1:GgkTl6Y7yWj/HtfD48Q5vLKPVoZOH+Qqgfa7CvJgJM4=
|
||||
github.com/olekukonko/tablewriter v0.0.5 h1:P2Ga83D34wi1o9J6Wh1mRuqd4mF/x/lgBS7N7AbDhec=
|
||||
github.com/olekukonko/tablewriter v0.0.5/go.mod h1:hPp6KlRPjbx+hW8ykQs1w3UBbZlj6HuIJcUGPhkA7kY=
|
||||
github.com/onsi/ginkgo/v2 v2.9.2 h1:BA2GMJOtfGAfagzYtrAlufIP0lq6QERkFmHLMLPwFSU=
|
||||
github.com/onsi/ginkgo/v2 v2.9.2/go.mod h1:WHcJJG2dIlcCqVfBAwUCrJxSPFb6v4azBwgxeMeDuts=
|
||||
github.com/onsi/gomega v1.27.6 h1:ENqfyGeS5AX/rlXDd/ETokDz93u0YufY1Pgxuy/PvWE=
|
||||
@@ -421,10 +417,6 @@ github.com/shirou/gopsutil/v4 v4.25.5 h1:rtd9piuSMGeU8g1RMXjZs9y9luK5BwtnG7dZaQU
|
||||
github.com/shirou/gopsutil/v4 v4.25.5/go.mod h1:PfybzyydfZcN+JMMjkF6Zb8Mq1A/VcogFFg7hj50W9c=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e h1:MRM5ITcdelLK2j1vwZ3Je0FKVCfqOLp5zO6trqMLYs0=
|
||||
github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e/go.mod h1:XV66xRDqSt+GTGFMVlhk3ULuV0y9ZmzeVGR4mloJI3M=
|
||||
github.com/spf13/cobra v1.7.0 h1:hyqWnYt1ZQShIddO5kBpj3vu05/++x6tJ6dg8EC572I=
|
||||
github.com/spf13/cobra v1.7.0/go.mod h1:uLxZILRyS/50WlhOIKD7W6V5bgeIt+4sICxh6uRMrb0=
|
||||
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
|
||||
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
package ent
|
||||
|
||||
import (
|
||||
"entgo.io/ent/dialect/sql"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/item"
|
||||
"github.com/sysadminsmedia/homebox/backend/internal/data/ent/predicate"
|
||||
"github.com/sysadminsmedia/homebox/backend/pkgs/textutils"
|
||||
)
|
||||
|
||||
// AccentInsensitiveContains creates a predicate that performs accent-insensitive text search.
|
||||
// It normalizes both the database field value and the search value for comparison.
|
||||
func AccentInsensitiveContains(field string, searchValue string) predicate.Item {
|
||||
if searchValue == "" {
|
||||
return predicate.Item(func(s *sql.Selector) {
|
||||
// Return a predicate that never matches if search is empty
|
||||
s.Where(sql.False())
|
||||
})
|
||||
}
|
||||
|
||||
// Normalize the search value
|
||||
normalizedSearch := textutils.NormalizeSearchQuery(searchValue)
|
||||
|
||||
return predicate.Item(func(s *sql.Selector) {
|
||||
dialect := s.Dialect()
|
||||
|
||||
switch dialect {
|
||||
case "sqlite3":
|
||||
// For SQLite, we'll create a custom normalization function using REPLACE
|
||||
// to handle common accented characters
|
||||
normalizeFunc := buildSQLiteNormalizeExpression(s.C(field))
|
||||
s.Where(sql.ExprP(
|
||||
"LOWER("+normalizeFunc+") LIKE ?",
|
||||
"%"+normalizedSearch+"%",
|
||||
))
|
||||
case "postgres":
|
||||
// For PostgreSQL, try to use unaccent extension if available
|
||||
// Fall back to REPLACE-based normalization if not available
|
||||
normalizeFunc := buildPostgreSQLNormalizeExpression(s.C(field))
|
||||
s.Where(sql.ExprP(
|
||||
"LOWER("+normalizeFunc+") LIKE ?",
|
||||
"%"+normalizedSearch+"%",
|
||||
))
|
||||
default:
|
||||
// Default fallback using REPLACE for common accented characters
|
||||
normalizeFunc := buildGenericNormalizeExpression(s.C(field))
|
||||
s.Where(sql.ExprP(
|
||||
"LOWER("+normalizeFunc+") LIKE ?",
|
||||
"%"+normalizedSearch+"%",
|
||||
))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// buildSQLiteNormalizeExpression creates a SQLite expression to normalize accented characters
|
||||
func buildSQLiteNormalizeExpression(fieldExpr string) string {
|
||||
return buildGenericNormalizeExpression(fieldExpr)
|
||||
}
|
||||
|
||||
// buildPostgreSQLNormalizeExpression creates a PostgreSQL expression to normalize accented characters
|
||||
func buildPostgreSQLNormalizeExpression(fieldExpr string) string {
|
||||
// Use a CASE statement to check if unaccent function exists before using it
|
||||
// This prevents errors when the unaccent extension is not installed
|
||||
return "CASE WHEN EXISTS (SELECT 1 FROM pg_proc WHERE proname = 'unaccent') " +
|
||||
"THEN unaccent(" + fieldExpr + ") " +
|
||||
"ELSE " + buildGenericNormalizeExpression(fieldExpr) + " END"
|
||||
}
|
||||
|
||||
// buildGenericNormalizeExpression creates a database-agnostic expression to normalize common accented characters
|
||||
func buildGenericNormalizeExpression(fieldExpr string) string {
|
||||
// Chain REPLACE functions to handle the most common accented characters
|
||||
// Focused on the most frequently used accents in Spanish, French, and Portuguese
|
||||
// Ordered by frequency of use for better performance
|
||||
normalized := fieldExpr
|
||||
|
||||
// Most common accented characters ordered by frequency
|
||||
commonAccents := []struct {
|
||||
from, to string
|
||||
}{
|
||||
// Spanish - most common
|
||||
{"á", "a"}, {"é", "e"}, {"í", "i"}, {"ó", "o"}, {"ú", "u"}, {"ñ", "n"},
|
||||
{"Á", "A"}, {"É", "E"}, {"Í", "I"}, {"Ó", "O"}, {"Ú", "U"}, {"Ñ", "N"},
|
||||
|
||||
// French - most common
|
||||
{"è", "e"}, {"ê", "e"}, {"à", "a"}, {"ç", "c"},
|
||||
{"È", "E"}, {"Ê", "E"}, {"À", "A"}, {"Ç", "C"},
|
||||
|
||||
// German umlauts and Portuguese - common
|
||||
{"ä", "a"}, {"ö", "o"}, {"ü", "u"}, {"ã", "a"}, {"õ", "o"},
|
||||
{"Ä", "A"}, {"Ö", "O"}, {"Ü", "U"}, {"Ã", "A"}, {"Õ", "O"},
|
||||
}
|
||||
|
||||
for _, accent := range commonAccents {
|
||||
normalized = "REPLACE(" + normalized + ", '" + accent.from + "', '" + accent.to + "')"
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
// ItemNameAccentInsensitiveContains creates an accent-insensitive search predicate for the item name field.
|
||||
func ItemNameAccentInsensitiveContains(value string) predicate.Item {
|
||||
return AccentInsensitiveContains(item.FieldName, value)
|
||||
}
|
||||
|
||||
// ItemDescriptionAccentInsensitiveContains creates an accent-insensitive search predicate for the item description field.
|
||||
func ItemDescriptionAccentInsensitiveContains(value string) predicate.Item {
|
||||
return AccentInsensitiveContains(item.FieldDescription, value)
|
||||
}
|
||||
|
||||
// ItemSerialNumberAccentInsensitiveContains creates an accent-insensitive search predicate for the item serial number field.
|
||||
func ItemSerialNumberAccentInsensitiveContains(value string) predicate.Item {
|
||||
return AccentInsensitiveContains(item.FieldSerialNumber, value)
|
||||
}
|
||||
|
||||
// ItemModelNumberAccentInsensitiveContains creates an accent-insensitive search predicate for the item model number field.
|
||||
func ItemModelNumberAccentInsensitiveContains(value string) predicate.Item {
|
||||
return AccentInsensitiveContains(item.FieldModelNumber, value)
|
||||
}
|
||||
|
||||
// ItemManufacturerAccentInsensitiveContains creates an accent-insensitive search predicate for the item manufacturer field.
|
||||
func ItemManufacturerAccentInsensitiveContains(value string) predicate.Item {
|
||||
return AccentInsensitiveContains(item.FieldManufacturer, value)
|
||||
}
|
||||
|
||||
// ItemNotesAccentInsensitiveContains creates an accent-insensitive search predicate for the item notes field.
|
||||
func ItemNotesAccentInsensitiveContains(value string) predicate.Item {
|
||||
return AccentInsensitiveContains(item.FieldNotes, value)
|
||||
}
|
||||
@@ -1,156 +0,0 @@
|
||||
package ent
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestBuildGenericNormalizeExpression(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
field string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Simple field name",
|
||||
field: "name",
|
||||
expected: "name", // Should be wrapped in many REPLACE functions
|
||||
},
|
||||
{
|
||||
name: "Complex field name",
|
||||
field: "description",
|
||||
expected: "description",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := buildGenericNormalizeExpression(tt.field)
|
||||
|
||||
// Should contain the original field
|
||||
assert.Contains(t, result, tt.field)
|
||||
|
||||
// Should contain REPLACE functions for accent normalization
|
||||
assert.Contains(t, result, "REPLACE(")
|
||||
|
||||
// Should handle common accented characters
|
||||
assert.Contains(t, result, "'á'", "Should handle Spanish á")
|
||||
assert.Contains(t, result, "'é'", "Should handle Spanish é")
|
||||
assert.Contains(t, result, "'ñ'", "Should handle Spanish ñ")
|
||||
assert.Contains(t, result, "'ü'", "Should handle German ü")
|
||||
|
||||
// Should handle uppercase accents too
|
||||
assert.Contains(t, result, "'Á'", "Should handle uppercase Spanish Á")
|
||||
assert.Contains(t, result, "'É'", "Should handle uppercase Spanish É")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSQLiteNormalizeExpression(t *testing.T) {
|
||||
result := buildSQLiteNormalizeExpression("test_field")
|
||||
|
||||
// Should contain the field name and REPLACE functions
|
||||
assert.Contains(t, result, "test_field")
|
||||
assert.Contains(t, result, "REPLACE(")
|
||||
// Check for some specific accent replacements (order doesn't matter)
|
||||
assert.Contains(t, result, "'á'", "Should handle Spanish á")
|
||||
assert.Contains(t, result, "'ó'", "Should handle Spanish ó")
|
||||
}
|
||||
|
||||
func TestPostgreSQLNormalizeExpression(t *testing.T) {
|
||||
result := buildPostgreSQLNormalizeExpression("test_field")
|
||||
|
||||
// Should contain unaccent function and CASE WHEN logic
|
||||
assert.Contains(t, result, "unaccent(")
|
||||
assert.Contains(t, result, "CASE WHEN EXISTS")
|
||||
assert.Contains(t, result, "test_field")
|
||||
}
|
||||
|
||||
func TestAccentInsensitivePredicateCreation(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
field string
|
||||
searchValue string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Normal search value",
|
||||
field: "name",
|
||||
searchValue: "electronica",
|
||||
description: "Should create predicate for normal search",
|
||||
},
|
||||
{
|
||||
name: "Accented search value",
|
||||
field: "description",
|
||||
searchValue: "electrónica",
|
||||
description: "Should create predicate for accented search",
|
||||
},
|
||||
{
|
||||
name: "Empty search value",
|
||||
field: "name",
|
||||
searchValue: "",
|
||||
description: "Should handle empty search gracefully",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
predicate := AccentInsensitiveContains(tt.field, tt.searchValue)
|
||||
assert.NotNil(t, predicate, tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSpecificItemPredicates(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
predicateFunc func(string) interface{}
|
||||
searchValue string
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "ItemNameAccentInsensitiveContains",
|
||||
predicateFunc: func(val string) interface{} { return ItemNameAccentInsensitiveContains(val) },
|
||||
searchValue: "electronica",
|
||||
description: "Should create accent-insensitive name search predicate",
|
||||
},
|
||||
{
|
||||
name: "ItemDescriptionAccentInsensitiveContains",
|
||||
predicateFunc: func(val string) interface{} { return ItemDescriptionAccentInsensitiveContains(val) },
|
||||
searchValue: "descripcion",
|
||||
description: "Should create accent-insensitive description search predicate",
|
||||
},
|
||||
{
|
||||
name: "ItemManufacturerAccentInsensitiveContains",
|
||||
predicateFunc: func(val string) interface{} { return ItemManufacturerAccentInsensitiveContains(val) },
|
||||
searchValue: "compañia",
|
||||
description: "Should create accent-insensitive manufacturer search predicate",
|
||||
},
|
||||
{
|
||||
name: "ItemSerialNumberAccentInsensitiveContains",
|
||||
predicateFunc: func(val string) interface{} { return ItemSerialNumberAccentInsensitiveContains(val) },
|
||||
searchValue: "sn123",
|
||||
description: "Should create accent-insensitive serial number search predicate",
|
||||
},
|
||||
{
|
||||
name: "ItemModelNumberAccentInsensitiveContains",
|
||||
predicateFunc: func(val string) interface{} { return ItemModelNumberAccentInsensitiveContains(val) },
|
||||
searchValue: "model456",
|
||||
description: "Should create accent-insensitive model number search predicate",
|
||||
},
|
||||
{
|
||||
name: "ItemNotesAccentInsensitiveContains",
|
||||
predicateFunc: func(val string) interface{} { return ItemNotesAccentInsensitiveContains(val) },
|
||||
searchValue: "notas importantes",
|
||||
description: "Should create accent-insensitive notes search predicate",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
predicate := tt.predicateFunc(tt.searchValue)
|
||||
assert.NotNil(t, predicate, tt.description)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -1,126 +0,0 @@
|
||||
-- +goose Up
|
||||
-- GENERATED with 20250706190000_generate_migration.py
|
||||
-- Migrating auth_tokens/created_at
|
||||
update auth_tokens set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
|
||||
update auth_tokens set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
|
||||
|
||||
-- Migrating auth_tokens/updated_at
|
||||
update auth_tokens set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
|
||||
update auth_tokens set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
|
||||
|
||||
-- Migrating auth_tokens/expires_at
|
||||
update auth_tokens set expires_at = substr(expires_at,1, instr(expires_at, ' +')-1) || substr(expires_at, instr(expires_at, ' +')+1,3) || ':' || substr(expires_at, instr(expires_at, ' +')+4,2) where expires_at like '% +%';
|
||||
update auth_tokens set expires_at = substr(expires_at,1, instr(expires_at, ' -')-1) || substr(expires_at, instr(expires_at, ' -')+1,3) || ':' || substr(expires_at, instr(expires_at, ' -')+4,2) where expires_at like '% -%';
|
||||
|
||||
-- Migrating groups/created_at
|
||||
update groups set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
|
||||
update groups set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
|
||||
|
||||
-- Migrating groups/updated_at
|
||||
update groups set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
|
||||
update groups set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
|
||||
|
||||
-- Migrating group_invitation_tokens/created_at
|
||||
update group_invitation_tokens set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
|
||||
update group_invitation_tokens set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
|
||||
|
||||
-- Migrating group_invitation_tokens/updated_at
|
||||
update group_invitation_tokens set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
|
||||
update group_invitation_tokens set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
|
||||
|
||||
-- Migrating group_invitation_tokens/expires_at
|
||||
update group_invitation_tokens set expires_at = substr(expires_at,1, instr(expires_at, ' +')-1) || substr(expires_at, instr(expires_at, ' +')+1,3) || ':' || substr(expires_at, instr(expires_at, ' +')+4,2) where expires_at like '% +%';
|
||||
update group_invitation_tokens set expires_at = substr(expires_at,1, instr(expires_at, ' -')-1) || substr(expires_at, instr(expires_at, ' -')+1,3) || ':' || substr(expires_at, instr(expires_at, ' -')+4,2) where expires_at like '% -%';
|
||||
|
||||
-- Migrating item_fields/created_at
|
||||
update item_fields set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
|
||||
update item_fields set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
|
||||
|
||||
-- Migrating item_fields/updated_at
|
||||
update item_fields set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
|
||||
update item_fields set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
|
||||
|
||||
-- Migrating item_fields/time_value
|
||||
update item_fields set time_value = substr(time_value,1, instr(time_value, ' +')-1) || substr(time_value, instr(time_value, ' +')+1,3) || ':' || substr(time_value, instr(time_value, ' +')+4,2) where time_value like '% +%';
|
||||
update item_fields set time_value = substr(time_value,1, instr(time_value, ' -')-1) || substr(time_value, instr(time_value, ' -')+1,3) || ':' || substr(time_value, instr(time_value, ' -')+4,2) where time_value like '% -%';
|
||||
|
||||
-- Migrating labels/created_at
|
||||
update labels set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
|
||||
update labels set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
|
||||
|
||||
-- Migrating labels/updated_at
|
||||
update labels set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
|
||||
update labels set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
|
||||
|
||||
-- Migrating locations/created_at
|
||||
update locations set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
|
||||
update locations set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
|
||||
|
||||
-- Migrating locations/updated_at
|
||||
update locations set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
|
||||
update locations set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
|
||||
|
||||
-- Migrating maintenance_entries/created_at
|
||||
update maintenance_entries set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
|
||||
update maintenance_entries set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
|
||||
|
||||
-- Migrating maintenance_entries/updated_at
|
||||
update maintenance_entries set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
|
||||
update maintenance_entries set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
|
||||
|
||||
-- Migrating maintenance_entries/date
|
||||
update maintenance_entries set date = substr(date,1, instr(date, ' +')-1) || substr(date, instr(date, ' +')+1,3) || ':' || substr(date, instr(date, ' +')+4,2) where date like '% +%';
|
||||
update maintenance_entries set date = substr(date,1, instr(date, ' -')-1) || substr(date, instr(date, ' -')+1,3) || ':' || substr(date, instr(date, ' -')+4,2) where date like '% -%';
|
||||
|
||||
-- Migrating maintenance_entries/scheduled_date
|
||||
update maintenance_entries set scheduled_date = substr(scheduled_date,1, instr(scheduled_date, ' +')-1) || substr(scheduled_date, instr(scheduled_date, ' +')+1,3) || ':' || substr(scheduled_date, instr(scheduled_date, ' +')+4,2) where scheduled_date like '% +%';
|
||||
update maintenance_entries set scheduled_date = substr(scheduled_date,1, instr(scheduled_date, ' -')-1) || substr(scheduled_date, instr(scheduled_date, ' -')+1,3) || ':' || substr(scheduled_date, instr(scheduled_date, ' -')+4,2) where scheduled_date like '% -%';
|
||||
|
||||
-- Migrating notifiers/created_at
|
||||
update notifiers set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
|
||||
update notifiers set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
|
||||
|
||||
-- Migrating notifiers/updated_at
|
||||
update notifiers set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
|
||||
update notifiers set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
|
||||
|
||||
-- Migrating users/created_at
|
||||
update users set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
|
||||
update users set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
|
||||
|
||||
-- Migrating users/updated_at
|
||||
update users set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
|
||||
update users set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
|
||||
|
||||
-- Migrating users/activated_on
|
||||
update users set activated_on = substr(activated_on,1, instr(activated_on, ' +')-1) || substr(activated_on, instr(activated_on, ' +')+1,3) || ':' || substr(activated_on, instr(activated_on, ' +')+4,2) where activated_on like '% +%';
|
||||
update users set activated_on = substr(activated_on,1, instr(activated_on, ' -')-1) || substr(activated_on, instr(activated_on, ' -')+1,3) || ':' || substr(activated_on, instr(activated_on, ' -')+4,2) where activated_on like '% -%';
|
||||
|
||||
-- Migrating items/created_at
|
||||
update items set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
|
||||
update items set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
|
||||
|
||||
-- Migrating items/updated_at
|
||||
update items set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
|
||||
update items set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
|
||||
|
||||
-- Migrating items/warranty_expires
|
||||
update items set warranty_expires = substr(warranty_expires,1, instr(warranty_expires, ' +')-1) || substr(warranty_expires, instr(warranty_expires, ' +')+1,3) || ':' || substr(warranty_expires, instr(warranty_expires, ' +')+4,2) where warranty_expires like '% +%';
|
||||
update items set warranty_expires = substr(warranty_expires,1, instr(warranty_expires, ' -')-1) || substr(warranty_expires, instr(warranty_expires, ' -')+1,3) || ':' || substr(warranty_expires, instr(warranty_expires, ' -')+4,2) where warranty_expires like '% -%';
|
||||
|
||||
-- Migrating items/purchase_time
|
||||
update items set purchase_time = substr(purchase_time,1, instr(purchase_time, ' +')-1) || substr(purchase_time, instr(purchase_time, ' +')+1,3) || ':' || substr(purchase_time, instr(purchase_time, ' +')+4,2) where purchase_time like '% +%';
|
||||
update items set purchase_time = substr(purchase_time,1, instr(purchase_time, ' -')-1) || substr(purchase_time, instr(purchase_time, ' -')+1,3) || ':' || substr(purchase_time, instr(purchase_time, ' -')+4,2) where purchase_time like '% -%';
|
||||
|
||||
-- Migrating items/sold_time
|
||||
update items set sold_time = substr(sold_time,1, instr(sold_time, ' +')-1) || substr(sold_time, instr(sold_time, ' +')+1,3) || ':' || substr(sold_time, instr(sold_time, ' +')+4,2) where sold_time like '% +%';
|
||||
update items set sold_time = substr(sold_time,1, instr(sold_time, ' -')-1) || substr(sold_time, instr(sold_time, ' -')+1,3) || ':' || substr(sold_time, instr(sold_time, ' -')+4,2) where sold_time like '% -%';
|
||||
|
||||
-- Migrating attachments/created_at
|
||||
update attachments set created_at = substr(created_at,1, instr(created_at, ' +')-1) || substr(created_at, instr(created_at, ' +')+1,3) || ':' || substr(created_at, instr(created_at, ' +')+4,2) where created_at like '% +%';
|
||||
update attachments set created_at = substr(created_at,1, instr(created_at, ' -')-1) || substr(created_at, instr(created_at, ' -')+1,3) || ':' || substr(created_at, instr(created_at, ' -')+4,2) where created_at like '% -%';
|
||||
|
||||
-- Migrating attachments/updated_at
|
||||
update attachments set updated_at = substr(updated_at,1, instr(updated_at, ' +')-1) || substr(updated_at, instr(updated_at, ' +')+1,3) || ':' || substr(updated_at, instr(updated_at, ' +')+4,2) where updated_at like '% +%';
|
||||
update attachments set updated_at = substr(updated_at,1, instr(updated_at, ' -')-1) || substr(updated_at, instr(updated_at, ' -')+1,3) || ':' || substr(updated_at, instr(updated_at, ' -')+4,2) where updated_at like '% -%';
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
#!/usr/bin/env python
|
||||
import os
|
||||
|
||||
# Extract fields with
|
||||
""" WITH tables AS (
|
||||
SELECT name AS table_name
|
||||
FROM sqlite_master
|
||||
WHERE type = 'table'
|
||||
AND name NOT LIKE 'sqlite_%'
|
||||
)
|
||||
|
||||
SELECT
|
||||
'["' || t.table_name || '", "' || c.name || '"],' AS table_column
|
||||
FROM tables t
|
||||
JOIN pragma_table_info(t.table_name) c
|
||||
WHERE c.name like'%date%'; """
|
||||
|
||||
fields = [["auth_tokens", "created_at"],
|
||||
["auth_tokens", "updated_at"],
|
||||
["auth_tokens", "expires_at"],
|
||||
["groups", "created_at"],
|
||||
["groups", "updated_at"],
|
||||
["group_invitation_tokens", "created_at"],
|
||||
["group_invitation_tokens", "updated_at"],
|
||||
["group_invitation_tokens", "expires_at"],
|
||||
["item_fields", "created_at"],
|
||||
["item_fields", "updated_at"],
|
||||
["item_fields", "time_value"],
|
||||
["labels", "created_at"],
|
||||
["labels", "updated_at"],
|
||||
["locations", "created_at"],
|
||||
["locations", "updated_at"],
|
||||
["maintenance_entries", "created_at"],
|
||||
["maintenance_entries", "updated_at"],
|
||||
["maintenance_entries", "date"],
|
||||
["maintenance_entries", "scheduled_date"],
|
||||
["notifiers", "created_at"],
|
||||
["notifiers", "updated_at"],
|
||||
["users", "created_at"],
|
||||
["users", "updated_at"],
|
||||
["users", "activated_on"],
|
||||
["items", "created_at"],
|
||||
["items", "updated_at"],
|
||||
["items", "warranty_expires"],
|
||||
["items", "purchase_time"],
|
||||
["items", "sold_time"],
|
||||
["attachments", "created_at"],
|
||||
["attachments", "updated_at"]]
|
||||
|
||||
|
||||
def generate_migration(table_name, field_name):
|
||||
return f"""update {table_name} set {field_name} = substr({field_name},1, instr({field_name}, ' +')-1) || substr({field_name}, instr({field_name}, ' +')+1,3) || ':' || substr({field_name}, instr({field_name}, ' +')+4,2) where {field_name} like '% +%';\n""" + \
|
||||
f"""update {table_name} set {field_name} = substr({field_name},1, instr({field_name}, ' -')-1) || substr({field_name}, instr({field_name}, ' -')+1,3) || ':' || substr({field_name}, instr({field_name}, ' -')+4,2) where {field_name} like '% -%';"""
|
||||
|
||||
|
||||
print("-- +goose Up")
|
||||
print(f"-- GENERATED with {os.path.basename(__file__)}")
|
||||
for table, column in fields:
|
||||
print(f"-- Migrating {table}/{column}")
|
||||
print(generate_migration(table, column))
|
||||
print()
|
||||
@@ -319,19 +319,16 @@ func (r *AttachmentRepo) Update(ctx context.Context, gid uuid.UUID, id uuid.UUID
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Only remove primary status from other photo attachments when setting a new photo as primary
|
||||
if typ == attachment.TypePhoto && data.Primary {
|
||||
err = r.db.Attachment.Update().
|
||||
Where(
|
||||
attachment.HasItemWith(item.ID(attachmentItem.ID)),
|
||||
attachment.IDNEQ(updatedAttachment.ID),
|
||||
attachment.TypeEQ(attachment.TypePhoto),
|
||||
).
|
||||
SetPrimary(false).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Ensure all other attachments are not primary
|
||||
err = r.db.Attachment.Update().
|
||||
Where(
|
||||
attachment.HasItemWith(item.ID(attachmentItem.ID)),
|
||||
attachment.IDNEQ(updatedAttachment.ID),
|
||||
).
|
||||
SetPrimary(false).
|
||||
Exec(ctx)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return r.Get(ctx, gid, updatedAttachment.ID)
|
||||
|
||||
@@ -152,132 +152,3 @@ func TestAttachmentRepo_EnsureSinglePrimaryAttachment(t *testing.T) {
|
||||
setAndVerifyPrimary(attachments[0].ID, attachments[1].ID)
|
||||
setAndVerifyPrimary(attachments[1].ID, attachments[0].ID)
|
||||
}
|
||||
|
||||
func TestAttachmentRepo_UpdateNonPhotoDoesNotAffectPrimaryPhoto(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
item := useItems(t, 1)[0]
|
||||
|
||||
// Create a photo attachment that will be primary
|
||||
photoAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Test Photo", Content: strings.NewReader("Photo content")}, attachment.TypePhoto, true)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Create a manual attachment (non-photo)
|
||||
manualAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Test Manual", Content: strings.NewReader("Manual content")}, attachment.TypeManual, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Cleanup
|
||||
t.Cleanup(func() {
|
||||
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, photoAttachment.ID)
|
||||
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, manualAttachment.ID)
|
||||
})
|
||||
|
||||
// Verify photo is primary initially
|
||||
photoAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, photoAttachment.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, photoAttachment.Primary)
|
||||
|
||||
// Update the manual attachment (this should NOT affect the photo's primary status)
|
||||
_, err = tRepos.Attachments.Update(ctx, tGroup.ID, manualAttachment.ID, &ItemAttachmentUpdate{
|
||||
Type: attachment.TypeManual.String(),
|
||||
Title: "Updated Manual",
|
||||
Primary: false, // This should have no effect since it's not a photo
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify photo is still primary after updating the manual
|
||||
photoAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, photoAttachment.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, photoAttachment.Primary, "Photo attachment should remain primary after updating non-photo attachment")
|
||||
|
||||
// Verify manual attachment is not primary
|
||||
manualAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, manualAttachment.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, manualAttachment.Primary)
|
||||
}
|
||||
|
||||
func TestAttachmentRepo_AddingPDFAfterPhotoKeepsPhotoAsPrimary(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
item := useItems(t, 1)[0]
|
||||
|
||||
// Step 1: Upload a photo first (this should become primary since it's the first photo)
|
||||
photoAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Item Photo", Content: strings.NewReader("Photo content")}, attachment.TypePhoto, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Cleanup
|
||||
t.Cleanup(func() {
|
||||
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, photoAttachment.ID)
|
||||
})
|
||||
|
||||
// Verify photo becomes primary automatically (since it's the first photo)
|
||||
photoAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, photoAttachment.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, photoAttachment.Primary, "First photo should automatically become primary")
|
||||
|
||||
// Step 2: Add a PDF receipt (this should NOT affect the photo's primary status)
|
||||
pdfAttachment, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Receipt PDF", Content: strings.NewReader("PDF content")}, attachment.TypeReceipt, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Add to cleanup
|
||||
t.Cleanup(func() {
|
||||
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, pdfAttachment.ID)
|
||||
})
|
||||
|
||||
// Step 3: Verify photo is still primary after adding PDF
|
||||
photoAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, photoAttachment.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, photoAttachment.Primary, "Photo should remain primary after adding PDF attachment")
|
||||
|
||||
// Verify PDF is not primary
|
||||
pdfAttachment, err = tRepos.Attachments.Get(ctx, tGroup.ID, pdfAttachment.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, pdfAttachment.Primary)
|
||||
|
||||
// Step 4: Test the actual item summary mapping (this is what determines the card display)
|
||||
updatedItem, err := tRepos.Items.GetOne(ctx, item.ID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// The item should have the photo's ID as the imageId
|
||||
assert.NotNil(t, updatedItem.ImageID, "Item should have an imageId")
|
||||
assert.Equal(t, photoAttachment.ID, *updatedItem.ImageID, "Item's imageId should match the photo attachment ID")
|
||||
}
|
||||
|
||||
func TestAttachmentRepo_SettingPhotoPrimaryStillWorks(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
item := useItems(t, 1)[0]
|
||||
|
||||
// Create two photo attachments
|
||||
photo1, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Photo 1", Content: strings.NewReader("Photo 1 content")}, attachment.TypePhoto, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
photo2, err := tRepos.Attachments.Create(ctx, item.ID, ItemCreateAttachment{Title: "Photo 2", Content: strings.NewReader("Photo 2 content")}, attachment.TypePhoto, false)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Cleanup
|
||||
t.Cleanup(func() {
|
||||
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, photo1.ID)
|
||||
_ = tRepos.Attachments.Delete(ctx, tGroup.ID, item.ID, photo2.ID)
|
||||
})
|
||||
|
||||
// First photo should be primary (since it was created first)
|
||||
photo1, err = tRepos.Attachments.Get(ctx, tGroup.ID, photo1.ID)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, photo1.Primary)
|
||||
|
||||
photo2, err = tRepos.Attachments.Get(ctx, tGroup.ID, photo2.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, photo2.Primary)
|
||||
|
||||
// Now set photo2 as primary (this should work and remove primary from photo1)
|
||||
photo2, err = tRepos.Attachments.Update(ctx, tGroup.ID, photo2.ID, &ItemAttachmentUpdate{
|
||||
Type: attachment.TypePhoto.String(),
|
||||
Title: "Photo 2",
|
||||
Primary: true,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.True(t, photo2.Primary)
|
||||
|
||||
// Verify photo1 is no longer primary
|
||||
photo1, err = tRepos.Attachments.Get(ctx, tGroup.ID, photo1.ID)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, photo1.Primary, "Photo 1 should no longer be primary after setting Photo 2 as primary")
|
||||
}
|
||||
|
||||
@@ -360,25 +360,14 @@ func (e *ItemsRepository) QueryByGroup(ctx context.Context, gid uuid.UUID, q Ite
|
||||
}
|
||||
|
||||
if q.Search != "" {
|
||||
// Use accent-insensitive search predicates that normalize both
|
||||
// the search query and database field values during comparison.
|
||||
// For queries without accents, the traditional search is more efficient.
|
||||
qb.Where(
|
||||
item.Or(
|
||||
// Regular case-insensitive search (fastest)
|
||||
item.NameContainsFold(q.Search),
|
||||
item.DescriptionContainsFold(q.Search),
|
||||
item.SerialNumberContainsFold(q.Search),
|
||||
item.ModelNumberContainsFold(q.Search),
|
||||
item.ManufacturerContainsFold(q.Search),
|
||||
item.NotesContainsFold(q.Search),
|
||||
// Accent-insensitive search using custom predicates
|
||||
ent.ItemNameAccentInsensitiveContains(q.Search),
|
||||
ent.ItemDescriptionAccentInsensitiveContains(q.Search),
|
||||
ent.ItemSerialNumberAccentInsensitiveContains(q.Search),
|
||||
ent.ItemModelNumberAccentInsensitiveContains(q.Search),
|
||||
ent.ItemManufacturerAccentInsensitiveContains(q.Search),
|
||||
ent.ItemNotesAccentInsensitiveContains(q.Search),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,213 +0,0 @@
|
||||
package repo
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/sysadminsmedia/homebox/backend/pkgs/textutils"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestItemsRepository_AccentInsensitiveSearch(t *testing.T) {
|
||||
// Test cases for accent-insensitive search
|
||||
testCases := []struct {
|
||||
name string
|
||||
itemName string
|
||||
searchQuery string
|
||||
shouldMatch bool
|
||||
description string
|
||||
}{
|
||||
{
|
||||
name: "Spanish accented item, search without accents",
|
||||
itemName: "electrónica",
|
||||
searchQuery: "electronica",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'electrónica' when searching for 'electronica'",
|
||||
},
|
||||
{
|
||||
name: "Spanish accented item, search with accents",
|
||||
itemName: "electrónica",
|
||||
searchQuery: "electrónica",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'electrónica' when searching for 'electrónica'",
|
||||
},
|
||||
{
|
||||
name: "Non-accented item, search with accents",
|
||||
itemName: "electronica",
|
||||
searchQuery: "electrónica",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'electronica' when searching for 'electrónica' (bidirectional search)",
|
||||
},
|
||||
{
|
||||
name: "Spanish item with tilde, search without accents",
|
||||
itemName: "café",
|
||||
searchQuery: "cafe",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'café' when searching for 'cafe'",
|
||||
},
|
||||
{
|
||||
name: "Spanish item without tilde, search with accents",
|
||||
itemName: "cafe",
|
||||
searchQuery: "café",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'cafe' when searching for 'café' (bidirectional)",
|
||||
},
|
||||
{
|
||||
name: "French accented item, search without accents",
|
||||
itemName: "pére",
|
||||
searchQuery: "pere",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'pére' when searching for 'pere'",
|
||||
},
|
||||
{
|
||||
name: "French: père without accent, search with accents",
|
||||
itemName: "pere",
|
||||
searchQuery: "père",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'pere' when searching for 'père' (bidirectional)",
|
||||
},
|
||||
{
|
||||
name: "Mixed case with accents",
|
||||
itemName: "Electrónica",
|
||||
searchQuery: "ELECTRONICA",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'Electrónica' when searching for 'ELECTRONICA' (case insensitive)",
|
||||
},
|
||||
{
|
||||
name: "Bidirectional: Non-accented item, search with different accents",
|
||||
itemName: "cafe",
|
||||
searchQuery: "café",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'cafe' when searching for 'café' (bidirectional)",
|
||||
},
|
||||
{
|
||||
name: "Bidirectional: Item with accent, search with different accent",
|
||||
itemName: "résumé",
|
||||
searchQuery: "resume",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'résumé' when searching for 'resume' (bidirectional)",
|
||||
},
|
||||
{
|
||||
name: "Bidirectional: Spanish ñ to n",
|
||||
itemName: "espanol",
|
||||
searchQuery: "español",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'espanol' when searching for 'español' (bidirectional ñ)",
|
||||
},
|
||||
{
|
||||
name: "French: français with accent, search without",
|
||||
itemName: "français",
|
||||
searchQuery: "francais",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'français' when searching for 'francais'",
|
||||
},
|
||||
{
|
||||
name: "French: français without accent, search with",
|
||||
itemName: "francais",
|
||||
searchQuery: "français",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'francais' when searching for 'français' (bidirectional)",
|
||||
},
|
||||
{
|
||||
name: "French: été with accent, search without",
|
||||
itemName: "été",
|
||||
searchQuery: "ete",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'été' when searching for 'ete'",
|
||||
},
|
||||
{
|
||||
name: "French: été without accent, search with",
|
||||
itemName: "ete",
|
||||
searchQuery: "été",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'ete' when searching for 'été' (bidirectional)",
|
||||
},
|
||||
{
|
||||
name: "French: hôtel with accent, search without",
|
||||
itemName: "hôtel",
|
||||
searchQuery: "hotel",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'hôtel' when searching for 'hotel'",
|
||||
},
|
||||
{
|
||||
name: "French: hôtel without accent, search with",
|
||||
itemName: "hotel",
|
||||
searchQuery: "hôtel",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'hotel' when searching for 'hôtel' (bidirectional)",
|
||||
},
|
||||
{
|
||||
name: "French: naïve with accent, search without",
|
||||
itemName: "naïve",
|
||||
searchQuery: "naive",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'naïve' when searching for 'naive'",
|
||||
},
|
||||
{
|
||||
name: "French: naïve without accent, search with",
|
||||
itemName: "naive",
|
||||
searchQuery: "naïve",
|
||||
shouldMatch: true,
|
||||
description: "Should find 'naive' when searching for 'naïve' (bidirectional)",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
// Test the normalization logic used in the repository
|
||||
normalizedSearch := textutils.NormalizeSearchQuery(tc.searchQuery)
|
||||
|
||||
// This simulates what happens in the repository
|
||||
// The original search would find exact matches (case-insensitive)
|
||||
// The normalized search would find accent-insensitive matches
|
||||
|
||||
// Test that our normalization works as expected
|
||||
if tc.shouldMatch {
|
||||
// If it should match, then either the original query should match
|
||||
// or the normalized query should match when applied to the stored data
|
||||
assert.NotEqual(t, "", normalizedSearch, "Normalized search should not be empty")
|
||||
|
||||
// The key insight is that we're searching with both the original and normalized queries
|
||||
// So "electrónica" will be found when searching for "electronica" because:
|
||||
// 1. Original search: "electronica" doesn't match "electrónica"
|
||||
// 2. Normalized search: "electronica" matches the normalized version
|
||||
t.Logf("✓ %s: Item '%s' should be found with search '%s' (normalized: '%s')",
|
||||
tc.description, tc.itemName, tc.searchQuery, normalizedSearch)
|
||||
} else {
|
||||
t.Logf("✗ %s: Item '%s' should NOT be found with search '%s' (normalized: '%s')",
|
||||
tc.description, tc.itemName, tc.searchQuery, normalizedSearch)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSearchQueryIntegration(t *testing.T) {
|
||||
// Test that the normalization function works correctly
|
||||
testCases := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"electrónica", "electronica"},
|
||||
{"café", "cafe"},
|
||||
{"ELECTRÓNICA", "electronica"},
|
||||
{"Café París", "cafe paris"},
|
||||
{"hello world", "hello world"},
|
||||
// French accented words
|
||||
{"père", "pere"},
|
||||
{"français", "francais"},
|
||||
{"été", "ete"},
|
||||
{"hôtel", "hotel"},
|
||||
{"naïve", "naive"},
|
||||
{"PÈRE", "pere"},
|
||||
{"FRANÇAIS", "francais"},
|
||||
{"ÉTÉ", "ete"},
|
||||
{"HÔTEL", "hotel"},
|
||||
{"NAÏVE", "naive"},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
result := textutils.NormalizeSearchQuery(tc.input)
|
||||
assert.Equal(t, tc.expected, result, "Normalization should work correctly")
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,6 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
var startTime = time.Now()
|
||||
|
||||
type Data struct {
|
||||
Domain string `json:"domain"`
|
||||
Name string `json:"name"`
|
||||
@@ -20,7 +18,7 @@ type Data struct {
|
||||
Props map[string]interface{} `json:"props"`
|
||||
}
|
||||
|
||||
func Send(version, buildInfo string) error {
|
||||
func Send(version, buildInfo string) {
|
||||
hostData, _ := host.Info()
|
||||
analytics := Data{
|
||||
Domain: "homebox.software",
|
||||
@@ -34,23 +32,22 @@ func Send(version, buildInfo string) error {
|
||||
"platform_version": hostData.PlatformVersion,
|
||||
"kernel_arch": hostData.KernelArch,
|
||||
"virt_type": hostData.VirtualizationSystem,
|
||||
"uptime_min": time.Since(startTime).Minutes(),
|
||||
},
|
||||
}
|
||||
jsonBody, err := json.Marshal(analytics)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to marshal analytics data")
|
||||
return err
|
||||
return
|
||||
}
|
||||
bodyReader := bytes.NewReader(jsonBody)
|
||||
req, err := http.NewRequest("POST", "https://a.sysadmins.zone/api/event", bodyReader)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to create analytics request")
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set("User-Agent", "Homebox/"+version+"/(https://homebox.software)")
|
||||
req.Header.Set("User-Agent", "Homebox/"+version+"/"+buildInfo+" (https://homebox.software)")
|
||||
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
@@ -59,7 +56,7 @@ func Send(version, buildInfo string) error {
|
||||
res, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Error().Err(err).Msg("failed to send analytics request")
|
||||
return err
|
||||
return
|
||||
}
|
||||
|
||||
defer func() {
|
||||
@@ -68,5 +65,4 @@ func Send(version, buildInfo string) error {
|
||||
log.Error().Err(err).Msg("failed to close response body")
|
||||
}
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -18,9 +18,6 @@ type Database struct {
|
||||
Port string `yaml:"port"`
|
||||
Database string `yaml:"database"`
|
||||
SslMode string `yaml:"ssl_mode"`
|
||||
SslRootCert string `yaml:"ssl_rootcert"`
|
||||
SslCert string `yaml:"ssl_cert"`
|
||||
SslKey string `yaml:"ssl_key"`
|
||||
SqlitePath string `yaml:"sqlite_path" conf:"default:./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1&_time_format=sqlite"`
|
||||
PubSubConnString string `yaml:"pubsub_conn_string" conf:"default:mem://{{ .Topic }}"`
|
||||
}
|
||||
|
||||
@@ -303,27 +303,8 @@ func PrintLabel(cfg *config.Config, params *GenerateParameters) error {
|
||||
|
||||
commandTemplate := template.Must(template.New("command").Parse(*cfg.LabelMaker.PrintCommand))
|
||||
builder := &strings.Builder{}
|
||||
additionalInformation := func() string {
|
||||
if params.AdditionalInformation != nil {
|
||||
return *params.AdditionalInformation
|
||||
}
|
||||
return ""
|
||||
}()
|
||||
if err := commandTemplate.Execute(builder, map[string]string{
|
||||
"FileName": f.Name(),
|
||||
"Width": fmt.Sprintf("%d", params.Width),
|
||||
"Height": fmt.Sprintf("%d", params.Height),
|
||||
"QrSize": fmt.Sprintf("%d", params.QrSize),
|
||||
"Margin": fmt.Sprintf("%d", params.Margin),
|
||||
"ComponentPadding": fmt.Sprintf("%d", params.ComponentPadding),
|
||||
"TitleText": params.TitleText,
|
||||
"TitleFontSize": fmt.Sprintf("%f", params.TitleFontSize),
|
||||
"DescriptionText": params.DescriptionText,
|
||||
"DescriptionFontSize": fmt.Sprintf("%f", params.DescriptionFontSize),
|
||||
"AdditionalInformation": additionalInformation,
|
||||
"Dpi": fmt.Sprintf("%f", params.Dpi),
|
||||
"URL": params.URL,
|
||||
"DynamicLength": fmt.Sprintf("%t", params.DynamicLength),
|
||||
"FileName": f.Name(),
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
package textutils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"golang.org/x/text/runes"
|
||||
"golang.org/x/text/transform"
|
||||
"golang.org/x/text/unicode/norm"
|
||||
)
|
||||
|
||||
// RemoveAccents removes accents from text by normalizing Unicode characters
|
||||
// and removing diacritical marks. This allows for accent-insensitive search.
|
||||
//
|
||||
// Example:
|
||||
// - "electrónica" becomes "electronica"
|
||||
// - "café" becomes "cafe"
|
||||
// - "père" becomes "pere"
|
||||
func RemoveAccents(text string) string {
|
||||
// Create a transformer that:
|
||||
// 1. Normalizes to NFD (canonical decomposition)
|
||||
// 2. Removes diacritical marks (combining characters)
|
||||
// 3. Normalizes back to NFC (canonical composition)
|
||||
t := transform.Chain(norm.NFD, runes.Remove(runes.In(unicode.Mn)), norm.NFC)
|
||||
|
||||
result, _, err := transform.String(t, text)
|
||||
if err != nil {
|
||||
// If transformation fails, return the original text
|
||||
return text
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// NormalizeSearchQuery normalizes a search query for accent-insensitive matching.
|
||||
// This function removes accents and converts to lowercase for consistent search behavior.
|
||||
func NormalizeSearchQuery(query string) string {
|
||||
normalized := RemoveAccents(query)
|
||||
return strings.ToLower(normalized)
|
||||
}
|
||||
@@ -1,152 +0,0 @@
|
||||
package textutils
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRemoveAccents(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Spanish accented characters",
|
||||
input: "electrónica",
|
||||
expected: "electronica",
|
||||
},
|
||||
{
|
||||
name: "Spanish accented characters with tilde",
|
||||
input: "café",
|
||||
expected: "cafe",
|
||||
},
|
||||
{
|
||||
name: "French accented characters",
|
||||
input: "père",
|
||||
expected: "pere",
|
||||
},
|
||||
{
|
||||
name: "German umlauts",
|
||||
input: "Björk",
|
||||
expected: "Bjork",
|
||||
},
|
||||
{
|
||||
name: "Mixed accented characters",
|
||||
input: "résumé",
|
||||
expected: "resume",
|
||||
},
|
||||
{
|
||||
name: "Portuguese accented characters",
|
||||
input: "João",
|
||||
expected: "Joao",
|
||||
},
|
||||
{
|
||||
name: "No accents",
|
||||
input: "hello world",
|
||||
expected: "hello world",
|
||||
},
|
||||
{
|
||||
name: "Empty string",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "Numbers and symbols",
|
||||
input: "123!@#",
|
||||
expected: "123!@#",
|
||||
},
|
||||
{
|
||||
name: "Multiple accents in one word",
|
||||
input: "été",
|
||||
expected: "ete",
|
||||
},
|
||||
{
|
||||
name: "Complex Unicode characters",
|
||||
input: "français",
|
||||
expected: "francais",
|
||||
},
|
||||
{
|
||||
name: "Unicode diacritics",
|
||||
input: "naïve",
|
||||
expected: "naive",
|
||||
},
|
||||
{
|
||||
name: "Unicode combining characters",
|
||||
input: "e\u0301", // e with combining acute accent
|
||||
expected: "e",
|
||||
},
|
||||
{
|
||||
name: "Very long string with accents",
|
||||
input: strings.Repeat("café", 1000),
|
||||
expected: strings.Repeat("cafe", 1000),
|
||||
},
|
||||
{
|
||||
name: "All French accents",
|
||||
input: "àâäéèêëïîôöùûüÿç",
|
||||
expected: "aaaeeeeiioouuuyc",
|
||||
},
|
||||
{
|
||||
name: "All Spanish accents",
|
||||
input: "áéíóúñüÁÉÍÓÚÑÜ",
|
||||
expected: "aeiounuAEIOUNU",
|
||||
},
|
||||
{
|
||||
name: "All German umlauts",
|
||||
input: "äöüÄÖÜß",
|
||||
expected: "aouAOUß",
|
||||
},
|
||||
{
|
||||
name: "Mixed languages",
|
||||
input: "Français café España niño",
|
||||
expected: "Francais cafe Espana nino",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := RemoveAccents(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("RemoveAccents(%q) = %q, expected %q", tc.input, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSearchQuery(t *testing.T) {
|
||||
testCases := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "Uppercase with accents",
|
||||
input: "ELECTRÓNICA",
|
||||
expected: "electronica",
|
||||
},
|
||||
{
|
||||
name: "Mixed case with accents",
|
||||
input: "Electrónica",
|
||||
expected: "electronica",
|
||||
},
|
||||
{
|
||||
name: "Multiple words with accents",
|
||||
input: "Café París",
|
||||
expected: "cafe paris",
|
||||
},
|
||||
{
|
||||
name: "No accents mixed case",
|
||||
input: "Hello World",
|
||||
expected: "hello world",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
result := NormalizeSearchQuery(tc.input)
|
||||
if result != tc.expected {
|
||||
t.Errorf("NormalizeSearchQuery(%q) = %q, expected %q", tc.input, result, tc.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
105
docs/.vitepress/components/BasicConfig.vue
Normal file
105
docs/.vitepress/components/BasicConfig.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Basic Configuration</h2>
|
||||
<p class="card-description">Configure the basic settings for your Homebox instance.</p>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="form-row">
|
||||
<label for="rootless">Use Rootless Image</label>
|
||||
<div class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="rootless"
|
||||
v-model="config.rootless"
|
||||
/>
|
||||
<label for="rootless"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="port">External Port</label>
|
||||
<input
|
||||
type="text"
|
||||
id="port"
|
||||
v-model="config.port"
|
||||
/>
|
||||
<p class="help-text">Only used if HTTPS is not enabled</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="maxFileUpload">Max File Upload (MB)</label>
|
||||
<input
|
||||
type="text"
|
||||
id="maxFileUpload"
|
||||
v-model="config.maxFileUpload"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="allowAnalytics">Allow Analytics</label>
|
||||
<div class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allowAnalytics"
|
||||
v-model="config.allowAnalytics"
|
||||
/>
|
||||
<label for="allowAnalytics"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="separator"></div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="allowRegistration">Allow Registration</label>
|
||||
<div class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="allowRegistration"
|
||||
v-model="config.allowRegistration"
|
||||
/>
|
||||
<label for="allowRegistration"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="autoIncrementAssetId">Auto Increment Asset ID</label>
|
||||
<div class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="autoIncrementAssetId"
|
||||
v-model="config.autoIncrementAssetId"
|
||||
/>
|
||||
<label for="autoIncrementAssetId"></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<label for="checkGithubRelease">Check GitHub Release</label>
|
||||
<div class="toggle-switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
id="checkGithubRelease"
|
||||
v-model="config.checkGithubRelease"
|
||||
/>
|
||||
<label for="checkGithubRelease"></label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import 'common.css';
|
||||
</style>
|
||||
288
docs/.vitepress/components/ConfigEditor.vue
Normal file
288
docs/.vitepress/components/ConfigEditor.vue
Normal file
@@ -0,0 +1,288 @@
|
||||
<template>
|
||||
<div class="config-generator">
|
||||
<div class="config-layout">
|
||||
<div class="config-form">
|
||||
<div class="tabs">
|
||||
<div class="tab-list">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.value"
|
||||
class="tab-button"
|
||||
:class="{ active: activeTab === tab.value }"
|
||||
@click="activeTab = tab.value"
|
||||
>
|
||||
{{ tab.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<BasicConfig
|
||||
v-show="activeTab === 'basic'"
|
||||
:config="config"
|
||||
/>
|
||||
|
||||
<DatabaseConfig
|
||||
v-show="activeTab === 'database'"
|
||||
:config="config"
|
||||
:show-password="showPassword"
|
||||
@toggle-password="showPassword = !showPassword"
|
||||
@regenerate-password="regeneratePassword"
|
||||
/>
|
||||
|
||||
<HttpsConfig
|
||||
v-show="activeTab === 'https'"
|
||||
:config="config"
|
||||
/>
|
||||
|
||||
<StorageConfig
|
||||
v-show="activeTab === 'storage'"
|
||||
:config="config"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfigPreview
|
||||
:config="generateDockerCompose(config)"
|
||||
@copy="copyToClipboard"
|
||||
@download="downloadConfig"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive } from 'vue'
|
||||
import BasicConfig from './BasicConfig.vue'
|
||||
import DatabaseConfig from './DatabaseConfig.vue'
|
||||
import HttpsConfig from './HttpsConfig.vue'
|
||||
import StorageConfig from './StorageConfig.vue'
|
||||
import ConfigPreview from './ConfigPreview.vue'
|
||||
import { generateDockerCompose } from './dockerComposeGenerator'
|
||||
|
||||
const showPassword = ref(false)
|
||||
const activeTab = ref('basic')
|
||||
|
||||
const tabs = [
|
||||
{ label: 'Basic', value: 'basic' },
|
||||
{ label: 'Database', value: 'database' },
|
||||
{ label: 'HTTPS', value: 'https' },
|
||||
{ label: 'Storage', value: 'storage' }
|
||||
]
|
||||
|
||||
function generateRandomPassword(length = 16) {
|
||||
const charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789!@#$%^&*()-_=+"
|
||||
let password = ""
|
||||
for (let i = 0; i < length; i++) {
|
||||
const randomIndex = Math.floor(Math.random() * charset.length)
|
||||
password += charset[randomIndex]
|
||||
}
|
||||
return password
|
||||
}
|
||||
|
||||
const config = reactive({
|
||||
image: "ghcr.io/sysadminsmedia/homebox:latest",
|
||||
rootless: false,
|
||||
port: "3100",
|
||||
logLevel: "info",
|
||||
logFormat: "text",
|
||||
maxFileUpload: "10",
|
||||
allowAnalytics: false,
|
||||
|
||||
// HTTPS options
|
||||
httpsOption: "none", // none, traefik, nginx, caddy, cloudflared
|
||||
|
||||
// Traefik config
|
||||
traefikConfig: {
|
||||
domain: "homebox.example.com",
|
||||
email: "",
|
||||
},
|
||||
|
||||
// Nginx config
|
||||
nginxConfig: {
|
||||
domain: "homebox.example.com",
|
||||
port: "443",
|
||||
sslCertPath: "/etc/nginx/ssl/cert.pem",
|
||||
sslKeyPath: "/etc/nginx/ssl/key.pem",
|
||||
},
|
||||
|
||||
// Caddy config
|
||||
caddyConfig: {
|
||||
domain: "homebox.example.com",
|
||||
email: "",
|
||||
},
|
||||
|
||||
// Cloudflared config
|
||||
cloudflaredConfig: {
|
||||
tunnel: "homebox-tunnel",
|
||||
domain: "homebox.example.com",
|
||||
token: "",
|
||||
},
|
||||
|
||||
databaseType: "sqlite",
|
||||
postgresConfig: {
|
||||
host: "postgres",
|
||||
port: "5432",
|
||||
username: "homebox",
|
||||
password: generateRandomPassword(),
|
||||
database: "homebox",
|
||||
},
|
||||
allowRegistration: true,
|
||||
autoIncrementAssetId: true,
|
||||
checkGithubRelease: true,
|
||||
|
||||
// Storage Configuration
|
||||
storageType: "local", // local, s3, gcs, azure
|
||||
storageConfig: {
|
||||
// Local storage settings
|
||||
local: {
|
||||
type: "volume", // "volume" or "directory"
|
||||
directory: "./homebox-data",
|
||||
volumeName: "homebox-data",
|
||||
path: "/data", // Custom path for local storage
|
||||
},
|
||||
|
||||
// S3 storage settings
|
||||
s3: {
|
||||
bucket: "",
|
||||
region: "",
|
||||
endpoint: "", // For S3-compatible storage
|
||||
awsAccessKeyId: "",
|
||||
awsSecretAccessKey: "",
|
||||
awsSessionToken: "", // Optional for temporary credentials
|
||||
prefixPath: "", // Storage prefix path
|
||||
awsSdk: "v2", // AWS SDK version
|
||||
disableSSL: false,
|
||||
s3ForcePathStyle: false,
|
||||
sseType: "", // Server-side encryption type
|
||||
kmsKeyId: "", // KMS key ID for encryption
|
||||
fips: false,
|
||||
dualstack: false,
|
||||
accelerate: false,
|
||||
isCompatible: false, // Whether using S3-compatible storage
|
||||
compatibleService: "", // minio, cloudflare-r2, backblaze-b2, custom
|
||||
},
|
||||
|
||||
// Google Cloud Storage settings
|
||||
gcs: {
|
||||
bucket: "",
|
||||
projectId: "",
|
||||
credentialsPath: "/app/gcs-credentials.json", // Path to service account key
|
||||
prefixPath: "", // Storage prefix path
|
||||
},
|
||||
|
||||
// Azure Blob Storage settings
|
||||
azure: {
|
||||
container: "",
|
||||
storageAccount: "",
|
||||
storageKey: "",
|
||||
sasToken: "", // Optional SAS token
|
||||
useEmulator: false,
|
||||
emulatorEndpoint: "localhost:10001", // For local emulator
|
||||
prefixPath: "", // Storage prefix path
|
||||
},
|
||||
|
||||
// Container storage volumes (for non-local storage types)
|
||||
containerStorage: {
|
||||
postgresStorage: {
|
||||
type: "volume",
|
||||
directory: "./postgres-data",
|
||||
volumeName: "postgres-data",
|
||||
},
|
||||
traefikStorage: {
|
||||
type: "volume",
|
||||
directory: "./traefik-data",
|
||||
volumeName: "traefik-data",
|
||||
},
|
||||
nginxStorage: {
|
||||
type: "volume",
|
||||
directory: "./nginx-data",
|
||||
volumeName: "nginx-data",
|
||||
},
|
||||
caddyStorage: {
|
||||
type: "volume",
|
||||
directory: "./caddy-data",
|
||||
volumeName: "caddy-data",
|
||||
},
|
||||
cloudflaredStorage: {
|
||||
type: "volume",
|
||||
directory: "./cloudflared-data",
|
||||
volumeName: "cloudflared-data",
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function regeneratePassword() {
|
||||
config.postgresConfig.password = generateRandomPassword()
|
||||
alert('A new random password has been generated for the database.')
|
||||
}
|
||||
|
||||
function copyToClipboard() {
|
||||
navigator.clipboard.writeText(generateDockerCompose(config))
|
||||
alert('Docker Compose configuration has been copied to your clipboard.')
|
||||
}
|
||||
|
||||
function downloadConfig() {
|
||||
const element = document.createElement("a")
|
||||
const file = new Blob([generateDockerCompose(config)], { type: "text/plain" })
|
||||
element.href = URL.createObjectURL(file)
|
||||
element.download = "docker-compose.yml"
|
||||
document.body.appendChild(element)
|
||||
element.click()
|
||||
document.body.removeChild(element)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
.config-generator {
|
||||
font-family: var(--vp-font-family-base);
|
||||
color: var(--vp-c-text-1);
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.title {
|
||||
font-size: 2rem;
|
||||
font-weight: 600;
|
||||
margin-bottom: 2rem;
|
||||
color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.config-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.tabs {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.tab-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, 1fr);
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.tab-button {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--vp-c-bg-mute);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.tab-button.active {
|
||||
background-color: var(--vp-c-brand);
|
||||
color: white;
|
||||
border-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.tab-button:hover:not(.active) {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
}
|
||||
</style>
|
||||
81
docs/.vitepress/components/ConfigPreview.vue
Normal file
81
docs/.vitepress/components/ConfigPreview.vue
Normal file
@@ -0,0 +1,81 @@
|
||||
<template>
|
||||
<div class="config-preview">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<div class="card-title-with-actions">
|
||||
<h2 class="card-title">Docker Compose Configuration</h2>
|
||||
<div class="card-actions">
|
||||
<button class="icon-button" @click="$emit('copy')" title="Copy to clipboard">
|
||||
Copy
|
||||
</button>
|
||||
<button class="icon-button" @click="$emit('download')" title="Download as file">
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p class="card-description">This configuration will be saved as docker-compose.yml</p>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<textarea
|
||||
class="code-preview"
|
||||
readonly
|
||||
:value="config"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
config: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
})
|
||||
|
||||
defineEmits(['copy', 'download'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import './common.css';
|
||||
|
||||
.config-preview {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.card-title-with-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.card-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.code-preview {
|
||||
width: 100%;
|
||||
height: 600px;
|
||||
font-family: monospace;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background-color: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
resize: none;
|
||||
white-space: pre;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
112
docs/.vitepress/components/DatabaseConfig.vue
Normal file
112
docs/.vitepress/components/DatabaseConfig.vue
Normal file
@@ -0,0 +1,112 @@
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">Database Configuration</h2>
|
||||
<p class="card-description">Configure the database for your Homebox instance.</p>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="form-group">
|
||||
<label for="databaseType">Database Type</label>
|
||||
<select id="databaseType" v-model="config.databaseType">
|
||||
<option value="sqlite">SQLite (Default)</option>
|
||||
<option value="postgres">PostgreSQL</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="config.databaseType === 'postgres'" class="nested-form">
|
||||
<div class="form-group">
|
||||
<label for="postgresHost">PostgreSQL Host</label>
|
||||
<input
|
||||
type="text"
|
||||
id="postgresHost"
|
||||
v-model="config.postgresConfig.host"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="postgresPort">PostgreSQL Port</label>
|
||||
<input
|
||||
type="text"
|
||||
id="postgresPort"
|
||||
v-model="config.postgresConfig.port"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="postgresUsername">PostgreSQL Username</label>
|
||||
<input
|
||||
type="text"
|
||||
id="postgresUsername"
|
||||
v-model="config.postgresConfig.username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="postgresPassword">PostgreSQL Password</label>
|
||||
<div class="password-input">
|
||||
<input
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
id="postgresPassword"
|
||||
v-model="config.postgresConfig.password"
|
||||
/>
|
||||
<button
|
||||
class="icon-button"
|
||||
@click="$emit('togglePassword')"
|
||||
type="button"
|
||||
>
|
||||
<span v-if="showPassword">Hide</span>
|
||||
<span v-else>Show</span>
|
||||
</button>
|
||||
<button
|
||||
class="icon-button"
|
||||
@click="$emit('regeneratePassword')"
|
||||
type="button"
|
||||
title="Generate new random password"
|
||||
>
|
||||
Regenerate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="postgresDatabase">PostgreSQL Database</label>
|
||||
<input
|
||||
type="text"
|
||||
id="postgresDatabase"
|
||||
v-model="config.postgresConfig.database"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
showPassword: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['togglePassword', 'regeneratePassword'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import './common.css';
|
||||
|
||||
.password-input {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.password-input input {
|
||||
flex: 1;
|
||||
}
|
||||
</style>
|
||||
179
docs/.vitepress/components/HttpsConfig.vue
Normal file
179
docs/.vitepress/components/HttpsConfig.vue
Normal file
@@ -0,0 +1,179 @@
|
||||
<template>
|
||||
<div class="tab-content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2 class="card-title">HTTPS Configuration</h2>
|
||||
<p class="card-description">Configure HTTPS for your Homebox instance.</p>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<div class="form-group">
|
||||
<label for="httpsOption">HTTPS Option</label>
|
||||
<select id="httpsOption" v-model="config.httpsOption">
|
||||
<option value="none">None (HTTP only)</option>
|
||||
<option value="traefik">Traefik (Automatic HTTPS with Let's Encrypt)</option>
|
||||
<option value="nginx">Nginx (Custom SSL certificates)</option>
|
||||
<option value="caddy">Caddy (Automatic HTTPS with Let's Encrypt)</option>
|
||||
<option value="cloudflared">Cloudflare Tunnel</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Traefik Configuration -->
|
||||
<div v-if="config.httpsOption === 'traefik'" class="nested-form">
|
||||
<h3>Traefik Configuration</h3>
|
||||
<p class="help-text">Traefik automatically handles HTTPS certificates via Let's Encrypt</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="traefikDomain">Domain Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="traefikDomain"
|
||||
v-model="config.traefikConfig.domain"
|
||||
placeholder="homebox.example.com"
|
||||
/>
|
||||
<p class="help-text">The domain name must be pointed to your server's IP address</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="traefikEmail">Email Address (for Let's Encrypt)</label>
|
||||
<input
|
||||
type="email"
|
||||
id="traefikEmail"
|
||||
v-model="config.traefikConfig.email"
|
||||
placeholder="your-email@example.com"
|
||||
/>
|
||||
<p class="help-text">Required for Let's Encrypt certificate notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Nginx Configuration -->
|
||||
<div v-if="config.httpsOption === 'nginx'" class="nested-form">
|
||||
<h3>Nginx Configuration</h3>
|
||||
<p class="help-text">Nginx requires you to provide SSL certificates</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nginxDomain">Domain Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="nginxDomain"
|
||||
v-model="config.nginxConfig.domain"
|
||||
placeholder="homebox.example.com"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nginxPort">HTTPS Port</label>
|
||||
<input
|
||||
type="text"
|
||||
id="nginxPort"
|
||||
v-model="config.nginxConfig.port"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nginxSslCert">SSL Certificate Path</label>
|
||||
<input
|
||||
type="text"
|
||||
id="nginxSslCert"
|
||||
v-model="config.nginxConfig.sslCertPath"
|
||||
/>
|
||||
<p class="help-text">Path to SSL certificate file inside the Nginx container</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="nginxSslKey">SSL Key Path</label>
|
||||
<input
|
||||
type="text"
|
||||
id="nginxSslKey"
|
||||
v-model="config.nginxConfig.sslKeyPath"
|
||||
/>
|
||||
<p class="help-text">Path to SSL key file inside the Nginx container</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Caddy Configuration -->
|
||||
<div v-if="config.httpsOption === 'caddy'" class="nested-form">
|
||||
<h3>Caddy Configuration</h3>
|
||||
<p class="help-text">Caddy automatically handles HTTPS certificates via Let's Encrypt</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caddyDomain">Domain Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="caddyDomain"
|
||||
v-model="config.caddyConfig.domain"
|
||||
placeholder="homebox.example.com"
|
||||
/>
|
||||
<p class="help-text">The domain name must be pointed to your server's IP address</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="caddyEmail">Email Address (for Let's Encrypt)</label>
|
||||
<input
|
||||
type="email"
|
||||
id="caddyEmail"
|
||||
v-model="config.caddyConfig.email"
|
||||
placeholder="your-email@example.com"
|
||||
/>
|
||||
<p class="help-text">Optional: Used for Let's Encrypt certificate notifications</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cloudflared Configuration -->
|
||||
<div v-if="config.httpsOption === 'cloudflared'" class="nested-form">
|
||||
<h3>Cloudflare Tunnel Configuration</h3>
|
||||
<p class="help-text">Cloudflare Tunnel provides secure access without exposing ports</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cloudflaredTunnel">Tunnel Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="cloudflaredTunnel"
|
||||
v-model="config.cloudflaredConfig.tunnel"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cloudflaredDomain">Domain Name</label>
|
||||
<input
|
||||
type="text"
|
||||
id="cloudflaredDomain"
|
||||
v-model="config.cloudflaredConfig.domain"
|
||||
placeholder="homebox.example.com"
|
||||
/>
|
||||
<p class="help-text">The domain must be managed by Cloudflare</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="cloudflaredToken">Tunnel Token</label>
|
||||
<input
|
||||
type="password"
|
||||
id="cloudflaredToken"
|
||||
v-model="config.cloudflaredConfig.token"
|
||||
placeholder="Your Cloudflare Tunnel token"
|
||||
/>
|
||||
<p class="help-text">Create a tunnel in the Cloudflare Zero Trust dashboard to get a token</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import './common.css';
|
||||
|
||||
h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
</style>
|
||||
552
docs/.vitepress/components/StorageConfig.vue
Normal file
552
docs/.vitepress/components/StorageConfig.vue
Normal file
@@ -0,0 +1,552 @@
|
||||
<template>
|
||||
<div class="storage-config">
|
||||
<h3>Storage Configuration</h3>
|
||||
|
||||
<!-- Storage Type Selector -->
|
||||
<div class="form-group">
|
||||
<label for="storageType">Storage Type</label>
|
||||
<select id="storageType" v-model="config.storageType" class="form-input">
|
||||
<option value="local">Local Storage</option>
|
||||
<option value="s3">Amazon S3 / S3-Compatible</option>
|
||||
<option value="gcs">Google Cloud Storage</option>
|
||||
<option value="azure">Azure Blob Storage</option>
|
||||
</select>
|
||||
<p class="form-help">Choose where Homebox will store your data</p>
|
||||
</div>
|
||||
|
||||
<!-- Local Storage Configuration -->
|
||||
<div v-if="config.storageType === 'local'" class="storage-section">
|
||||
<h4>Local Storage Settings</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="localType">Storage Type</label>
|
||||
<select id="localType" v-model="config.storageConfig.local.type" class="form-input">
|
||||
<option value="volume">Docker Volume</option>
|
||||
<option value="directory">Host Directory</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="config.storageConfig.local.type === 'directory'" class="form-group">
|
||||
<label for="localDirectory">Host Directory Path</label>
|
||||
<input
|
||||
id="localDirectory"
|
||||
v-model="config.storageConfig.local.directory"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="./homebox-data"
|
||||
/>
|
||||
<p class="form-help">Path on the host system where data will be stored</p>
|
||||
</div>
|
||||
|
||||
<div v-if="config.storageConfig.local.type === 'volume'" class="form-group">
|
||||
<label for="localVolume">Volume Name</label>
|
||||
<input
|
||||
id="localVolume"
|
||||
v-model="config.storageConfig.local.volumeName"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="homebox-data"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="localPath">Custom Storage Path (Optional)</label>
|
||||
<input
|
||||
id="localPath"
|
||||
v-model="config.storageConfig.local.path"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="/data"
|
||||
/>
|
||||
<p class="form-help">Custom path inside the container. Leave as /data for default.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- S3 Storage Configuration -->
|
||||
<div v-if="config.storageType === 's3'" class="storage-section">
|
||||
<h4>S3 Storage Settings</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="config.storageConfig.s3.isCompatible"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
Use S3-Compatible Storage (MinIO, Cloudflare R2, Backblaze B2, etc.)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="config.storageConfig.s3.isCompatible" class="form-group">
|
||||
<label for="s3Service">S3-Compatible Service</label>
|
||||
<select id="s3Service" v-model="config.storageConfig.s3.compatibleService" class="form-input">
|
||||
<option value="">Custom/Other</option>
|
||||
<option value="minio">MinIO</option>
|
||||
<option value="cloudflare-r2">Cloudflare R2</option>
|
||||
<option value="backblaze-b2">Backblaze B2</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="s3Bucket">Bucket Name</label>
|
||||
<input
|
||||
id="s3Bucket"
|
||||
v-model="config.storageConfig.s3.bucket"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="my-homebox-bucket"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!config.storageConfig.s3.isCompatible" class="form-group">
|
||||
<label for="s3Region">AWS Region</label>
|
||||
<input
|
||||
id="s3Region"
|
||||
v-model="config.storageConfig.s3.region"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="us-east-1"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="config.storageConfig.s3.isCompatible" class="form-group">
|
||||
<label for="s3Endpoint">Endpoint URL</label>
|
||||
<input
|
||||
id="s3Endpoint"
|
||||
v-model="config.storageConfig.s3.endpoint"
|
||||
type="text"
|
||||
class="form-input"
|
||||
:placeholder="getS3EndpointPlaceholder()"
|
||||
/>
|
||||
<p class="form-help">The endpoint URL for your S3-compatible service</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="s3AccessKey">AWS Access Key ID</label>
|
||||
<input
|
||||
id="s3AccessKey"
|
||||
v-model="config.storageConfig.s3.awsAccessKeyId"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="AKIAIOSFODNN7EXAMPLE"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="s3SecretKey">AWS Secret Access Key</label>
|
||||
<input
|
||||
id="s3SecretKey"
|
||||
v-model="config.storageConfig.s3.awsSecretAccessKey"
|
||||
type="password"
|
||||
class="form-input"
|
||||
placeholder="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="s3SessionToken">AWS Session Token (Optional)</label>
|
||||
<input
|
||||
id="s3SessionToken"
|
||||
v-model="config.storageConfig.s3.awsSessionToken"
|
||||
type="password"
|
||||
class="form-input"
|
||||
placeholder="For temporary credentials"
|
||||
/>
|
||||
<p class="form-help">Only needed for temporary AWS credentials</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="s3PrefixPath">Storage Prefix Path (Optional)</label>
|
||||
<input
|
||||
id="s3PrefixPath"
|
||||
v-model="config.storageConfig.s3.prefixPath"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="homebox/"
|
||||
/>
|
||||
<p class="form-help">Prefix for all stored objects in the bucket</p>
|
||||
</div>
|
||||
|
||||
<!-- Advanced S3 Settings -->
|
||||
<details class="advanced-settings">
|
||||
<summary>Advanced S3 Settings</summary>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="s3AwsSdk">AWS SDK Version</label>
|
||||
<select id="s3AwsSdk" v-model="config.storageConfig.s3.awsSdk" class="form-input">
|
||||
<option value="v2">v2 (Recommended)</option>
|
||||
<option value="v1">v1</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="config.storageConfig.s3.disableSSL"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
Disable SSL
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="config.storageConfig.s3.s3ForcePathStyle"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
Force Path Style Access
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="s3SseType">Server-Side Encryption</label>
|
||||
<select id="s3SseType" v-model="config.storageConfig.s3.sseType" class="form-input">
|
||||
<option value="">None</option>
|
||||
<option value="AES256">AES256</option>
|
||||
<option value="aws:kms">AWS KMS</option>
|
||||
<option value="aws:kms:dsse">AWS KMS DSSE</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div v-if="config.storageConfig.s3.sseType.includes('kms')" class="form-group">
|
||||
<label for="s3KmsKey">KMS Key ID</label>
|
||||
<input
|
||||
id="s3KmsKey"
|
||||
v-model="config.storageConfig.s3.kmsKeyId"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="config.storageConfig.s3.fips"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
Use FIPS Endpoints
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="config.storageConfig.s3.dualstack"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
Use Dual-Stack Endpoints
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="config.storageConfig.s3.accelerate"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
Use S3 Transfer Acceleration
|
||||
</label>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Google Cloud Storage Configuration -->
|
||||
<div v-if="config.storageType === 'gcs'" class="storage-section">
|
||||
<h4>Google Cloud Storage Settings</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="gcsBucket">Bucket Name</label>
|
||||
<input
|
||||
id="gcsBucket"
|
||||
v-model="config.storageConfig.gcs.bucket"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="my-homebox-bucket"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="gcsProject">Project ID</label>
|
||||
<input
|
||||
id="gcsProject"
|
||||
v-model="config.storageConfig.gcs.projectId"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="my-gcp-project"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="gcsCredentialsPath">Service Account Key Path</label>
|
||||
<input
|
||||
id="gcsCredentialsPath"
|
||||
v-model="config.storageConfig.gcs.credentialsPath"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="/app/gcs-credentials.json"
|
||||
/>
|
||||
<p class="form-help">Path to the service account JSON key file inside the container</p>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="gcsPrefixPath">Storage Prefix Path (Optional)</label>
|
||||
<input
|
||||
id="gcsPrefixPath"
|
||||
v-model="config.storageConfig.gcs.prefixPath"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="homebox/"
|
||||
/>
|
||||
<p class="form-help">Prefix for all stored objects in the bucket</p>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<h5>📋 Setup Instructions:</h5>
|
||||
<ol>
|
||||
<li>Create a service account in your GCP project</li>
|
||||
<li>Grant Storage Admin permissions to the service account</li>
|
||||
<li>Download the JSON key file</li>
|
||||
<li>Mount the key file as a read-only volume in your container</li>
|
||||
<li>Set GOOGLE_APPLICATION_CREDENTIALS environment variable</li>
|
||||
</ol>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Azure Blob Storage Configuration -->
|
||||
<div v-if="config.storageType === 'azure'" class="storage-section">
|
||||
<h4>Azure Blob Storage Settings</h4>
|
||||
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="config.storageConfig.azure.useEmulator"
|
||||
class="form-checkbox"
|
||||
/>
|
||||
Use Azure Storage Emulator (for development)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="azureContainer">Container Name</label>
|
||||
<input
|
||||
id="azureContainer"
|
||||
v-model="config.storageConfig.azure.container"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="homebox-container"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!config.storageConfig.azure.useEmulator" class="form-group">
|
||||
<label for="azureAccount">Storage Account Name</label>
|
||||
<input
|
||||
id="azureAccount"
|
||||
v-model="config.storageConfig.azure.storageAccount"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="mystorageaccount"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!config.storageConfig.azure.useEmulator" class="form-group">
|
||||
<label for="azureKey">Storage Account Key</label>
|
||||
<input
|
||||
id="azureKey"
|
||||
v-model="config.storageConfig.azure.storageKey"
|
||||
type="password"
|
||||
class="form-input"
|
||||
placeholder="Your Azure storage account key"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="!config.storageConfig.azure.useEmulator" class="form-group">
|
||||
<label for="azureSas">SAS Token (Optional)</label>
|
||||
<input
|
||||
id="azureSas"
|
||||
v-model="config.storageConfig.azure.sasToken"
|
||||
type="password"
|
||||
class="form-input"
|
||||
placeholder="?sv=2021-06-08&ss=b&srt=sco&sp=rwdlacupx&se=..."
|
||||
/>
|
||||
<p class="form-help">Use SAS token instead of storage account key</p>
|
||||
</div>
|
||||
|
||||
<div v-if="config.storageConfig.azure.useEmulator" class="form-group">
|
||||
<label for="azureEmulatorEndpoint">Emulator Endpoint</label>
|
||||
<input
|
||||
id="azureEmulatorEndpoint"
|
||||
v-model="config.storageConfig.azure.emulatorEndpoint"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="localhost:10001"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="azurePrefixPath">Storage Prefix Path (Optional)</label>
|
||||
<input
|
||||
id="azurePrefixPath"
|
||||
v-model="config.storageConfig.azure.prefixPath"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="homebox/"
|
||||
/>
|
||||
<p class="form-help">Prefix for all stored objects in the container</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
config: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
function getS3EndpointPlaceholder() {
|
||||
const service = props.config.storageConfig.s3.compatibleService
|
||||
switch (service) {
|
||||
case 'minio':
|
||||
return 'http://minio:9000'
|
||||
case 'cloudflare-r2':
|
||||
return 'https://<account-id>.r2.cloudflarestorage.com'
|
||||
case 'backblaze-b2':
|
||||
return 'https://s3.us-west-004.backblazeb2.com'
|
||||
default:
|
||||
return 'https://your-s3-compatible-endpoint.com'
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.storage-config {
|
||||
padding: 1.5rem;
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.storage-section {
|
||||
margin-top: 1.5rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--vp-c-bg);
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 0.25rem;
|
||||
font-weight: 500;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.form-input {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background-color: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 0 0 2px var(--vp-c-brand-light);
|
||||
}
|
||||
|
||||
.form-checkbox {
|
||||
width: auto;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.form-help {
|
||||
margin-top: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
.advanced-settings {
|
||||
margin-top: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.advanced-settings summary {
|
||||
padding: 0.75rem;
|
||||
background-color: var(--vp-c-bg-mute);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.advanced-settings[open] summary {
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.advanced-settings .form-group {
|
||||
margin: 1rem;
|
||||
}
|
||||
|
||||
.info-box {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
border-left: 4px solid var(--vp-c-brand);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.info-box h5 {
|
||||
margin: 0 0 0.5rem 0;
|
||||
color: var(--vp-c-text-1);
|
||||
}
|
||||
|
||||
.info-box ol {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
.info-box li {
|
||||
margin-bottom: 0.25rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin: 0 0 1.5rem 0;
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
h4 {
|
||||
margin: 0 0 1rem 0;
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
95
docs/.vitepress/components/StorageTypeSelector.vue
Normal file
95
docs/.vitepress/components/StorageTypeSelector.vue
Normal file
@@ -0,0 +1,95 @@
|
||||
<template>
|
||||
<div class="storage-selector">
|
||||
<h3>{{ label }}</h3>
|
||||
<p class="help-text">{{ description }}</p>
|
||||
|
||||
<div class="radio-group">
|
||||
<div class="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
:id="`${storageKey}-volume`"
|
||||
value="volume"
|
||||
v-model="config.storageConfig[storageKey].type"
|
||||
/>
|
||||
<label :for="`${storageKey}-volume`">Docker Volume</label>
|
||||
</div>
|
||||
<div class="radio-option">
|
||||
<input
|
||||
type="radio"
|
||||
:id="`${storageKey}-directory`"
|
||||
value="directory"
|
||||
v-model="config.storageConfig[storageKey].type"
|
||||
/>
|
||||
<label :for="`${storageKey}-directory`">Host Directory</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="config.storageConfig[storageKey].type === 'volume'" class="form-group">
|
||||
<label :for="`${storageKey}-volume-name`">Volume Name</label>
|
||||
<input
|
||||
type="text"
|
||||
:id="`${storageKey}-volume-name`"
|
||||
v-model="config.storageConfig[storageKey].volumeName"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="form-group">
|
||||
<label :for="`${storageKey}-directory-path`">Directory Path</label>
|
||||
<input
|
||||
type="text"
|
||||
:id="`${storageKey}-directory-path`"
|
||||
v-model="config.storageConfig[storageKey].directory"
|
||||
/>
|
||||
<p class="help-text">Absolute path recommended (e.g., /home/user/data)</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
storageKey: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
label: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
description: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
config: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@import './common.css';
|
||||
|
||||
.storage-selector {
|
||||
margin-bottom: 2rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.storage-selector h3 {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
}
|
||||
|
||||
.radio-group {
|
||||
display: flex;
|
||||
gap: 1.5rem;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.radio-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
150
docs/.vitepress/components/common.css
Normal file
150
docs/.vitepress/components/common.css
Normal file
@@ -0,0 +1,150 @@
|
||||
.card {
|
||||
background-color: var(--vp-c-bg-soft);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
padding: 1.5rem;
|
||||
border-bottom: 1px solid var(--vp-c-divider);
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem;
|
||||
|
||||
border-top: 0px;
|
||||
padding-top: 0px;
|
||||
}
|
||||
|
||||
.card-description {
|
||||
color: var(--vp-c-text-2);
|
||||
font-size: 0.875rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.card-content {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.tab-content {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.form-group input,
|
||||
.form-group select,
|
||||
.form-group textarea {
|
||||
width: 100%;
|
||||
padding: 0.5rem;
|
||||
border: 1px soli var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
background-color: var(--vp-c-bg);
|
||||
color: var(--vp-c-text-1);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-group input:focus,
|
||||
.form-group select:focus,
|
||||
.form-group textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--vp-c-brand);
|
||||
box-shadow: 0 0 0 2px rgba(var(--vp-c-brand-rgb), 0.1);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.toggle-switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.toggle-switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.toggle-switch label {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: var(--vp-c-divider);
|
||||
transition: .4s;
|
||||
border-radius: 20px;
|
||||
}
|
||||
|
||||
.toggle-switch label:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 16px;
|
||||
width: 16px;
|
||||
left: 2px;
|
||||
bottom: 2px;
|
||||
background-color: white;
|
||||
transition: .4s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + label {
|
||||
background-color: var(--vp-c-brand);
|
||||
}
|
||||
|
||||
.toggle-switch input:checked + label:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.75rem;
|
||||
color: var(--vp-c-text-2);
|
||||
margin-top: 0.25rem;
|
||||
}
|
||||
|
||||
.separator {
|
||||
height: 1px;
|
||||
background-color: var(--vp-c-divider);
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.nested-form {
|
||||
margin-top: 1rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.icon-button {
|
||||
padding: 0.5rem;
|
||||
background-color: var(--vp-c-bg-mute);
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.icon-button:hover {
|
||||
background-color: var(--vp-c-bg-alt);
|
||||
}
|
||||
440
docs/.vitepress/components/dockerComposeGenerator.ts
Normal file
440
docs/.vitepress/components/dockerComposeGenerator.ts
Normal file
@@ -0,0 +1,440 @@
|
||||
export function generateDockerCompose(config: any): string {
|
||||
const services: any = {}
|
||||
const volumes: any = {}
|
||||
const networks: any = {
|
||||
homebox: {
|
||||
driver: 'bridge'
|
||||
}
|
||||
}
|
||||
|
||||
// Generate Homebox service
|
||||
services.homebox = generateHomeboxService(config)
|
||||
|
||||
// Add database service if PostgreSQL is selected
|
||||
if (config.databaseType === 'postgres') {
|
||||
services.postgres = generatePostgresService(config)
|
||||
if (config.storageConfig.containerStorage.postgresStorage.type === 'volume') {
|
||||
volumes[config.storageConfig.containerStorage.postgresStorage.volumeName] = null
|
||||
}
|
||||
}
|
||||
|
||||
// Ensure homebox-data volume exists if SQLite is selected
|
||||
if (config.databaseType === 'sqlite') {
|
||||
volumes['homebox-data'] = null
|
||||
}
|
||||
|
||||
// Add reverse proxy services based on HTTPS option
|
||||
switch (config.httpsOption) {
|
||||
case 'traefik':
|
||||
services.traefik = generateTraefikService(config)
|
||||
if (config.storageConfig.containerStorage.traefikStorage.type === 'volume') {
|
||||
volumes[config.storageConfig.containerStorage.traefikStorage.volumeName] = null
|
||||
}
|
||||
break
|
||||
case 'nginx':
|
||||
services.nginx = generateNginxService(config)
|
||||
if (config.storageConfig.containerStorage.nginxStorage.type === 'volume') {
|
||||
volumes[config.storageConfig.containerStorage.nginxStorage.volumeName] = null
|
||||
}
|
||||
break
|
||||
case 'caddy':
|
||||
services.caddy = generateCaddyService(config)
|
||||
if (config.storageConfig.containerStorage.caddyStorage.type === 'volume') {
|
||||
volumes[config.storageConfig.containerStorage.caddyStorage.volumeName] = null
|
||||
}
|
||||
break
|
||||
case 'cloudflared':
|
||||
services.cloudflared = generateCloudflaredService(config)
|
||||
if (config.storageConfig.containerStorage.cloudflaredStorage.type === 'volume') {
|
||||
volumes[config.storageConfig.containerStorage.cloudflaredStorage.volumeName] = null
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
// Add Homebox storage volume only for local storage
|
||||
if (config.storageType === 'local' && config.storageConfig.local.type === 'volume') {
|
||||
volumes[config.storageConfig.local.volumeName] = null
|
||||
}
|
||||
|
||||
const compose = {
|
||||
version: '3.8',
|
||||
services,
|
||||
...(Object.keys(volumes).length > 0 && {volumes}),
|
||||
networks
|
||||
}
|
||||
|
||||
return `# Generated Homebox Docker Compose Config Generator 1.0 Beta
|
||||
# Storage Type: ${config.storageType.toUpperCase()}
|
||||
# Generated on: ${new Date().toISOString()}
|
||||
${yaml.stringify(compose)}`
|
||||
}
|
||||
|
||||
function generateHomeboxService(config: any): any {
|
||||
const service: any = {
|
||||
image: config.rootless ? config.image.replace(':latest', ':latest-rootless') : config.image,
|
||||
container_name: 'homebox',
|
||||
restart: 'unless-stopped',
|
||||
environment: generateEnvironmentVariables(config),
|
||||
networks: ['homebox']
|
||||
}
|
||||
|
||||
// Add ports for direct access (when no reverse proxy is used)
|
||||
if (config.httpsOption === 'none') {
|
||||
service.ports = [`${config.port}:7745`]
|
||||
}
|
||||
|
||||
// Configure storage based on storage type
|
||||
if (config.storageType === 'local') {
|
||||
service.volumes = generateLocalStorageVolumes(config)
|
||||
} else {
|
||||
// For cloud storage, we might still need some local volumes for certain files
|
||||
service.volumes = generateCloudStorageVolumes(config)
|
||||
}
|
||||
|
||||
// Always mount homebox-data at /data if SQLite is used
|
||||
if (config.databaseType === 'sqlite') {
|
||||
if (!service.volumes) service.volumes = []
|
||||
// Only add if not already present
|
||||
if (!service.volumes.some(v => v.startsWith('homebox-data:'))) {
|
||||
service.volumes.push('homebox-data:/data')
|
||||
}
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
function generateEnvironmentVariables(config: any): string[] {
|
||||
const env: string[] = [
|
||||
`HBOX_LOG_LEVEL=${config.logLevel}`,
|
||||
`HBOX_LOG_FORMAT=${config.logFormat}`,
|
||||
`HBOX_MAX_UPLOAD_SIZE=${config.maxFileUpload}`,
|
||||
`HBOX_AUTO_INCREMENT_ASSET_ID=${config.autoIncrementAssetId}`,
|
||||
`HBOX_WEB_PORT=7745`
|
||||
]
|
||||
|
||||
// Database configuration
|
||||
if (config.databaseType === 'postgres') {
|
||||
env.push(
|
||||
`HBOX_DATABASE_DRIVER=postgres`,
|
||||
`HBOX_DATABASE_HOST=${config.postgresConfig.host}`,
|
||||
`HBOX_DATABASE_PORT=${config.postgresConfig.port}`,
|
||||
`HBOX_DATABASE_NAME=${config.postgresConfig.database}`,
|
||||
`HBOX_DATABASE_USER=${config.postgresConfig.username}`,
|
||||
`HBOX_DATABASE_PASS=${config.postgresConfig.password}`
|
||||
)
|
||||
}
|
||||
|
||||
// Registration settings
|
||||
if (!config.allowRegistration) {
|
||||
env.push('HBOX_OPTIONS_ALLOW_REGISTRATION=false')
|
||||
}
|
||||
|
||||
// Analytics settings
|
||||
if (!config.allowAnalytics) {
|
||||
env.push('HBOX_OPTIONS_ALLOW_ANALYTICS=false')
|
||||
}
|
||||
|
||||
// GitHub release check
|
||||
if (!config.checkGithubRelease) {
|
||||
env.push('HBOX_OPTIONS_CHECK_GITHUB_RELEASE=false')
|
||||
}
|
||||
|
||||
// Storage configuration
|
||||
env.push(...generateStorageEnvironmentVariables(config))
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
function generateStorageEnvironmentVariables(config: any): string[] {
|
||||
const env: string[] = []
|
||||
|
||||
switch (config.storageType) {
|
||||
case 'local':
|
||||
const storagePath = config.storageConfig.local.path || '/data'
|
||||
env.push(`HBOX_STORAGE_CONN_STRING=file://${storagePath}`)
|
||||
if (config.storageConfig.local.prefixPath) {
|
||||
env.push(`HBOX_STORAGE_PREFIX_PATH=${config.storageConfig.local.prefixPath}`)
|
||||
}
|
||||
break
|
||||
|
||||
case 's3':
|
||||
const s3Config = config.storageConfig.s3
|
||||
let connectionString = `s3://${s3Config.bucket}?awssdk=${s3Config.awsSdk}`
|
||||
|
||||
if (s3Config.region && !s3Config.isCompatible) {
|
||||
connectionString += `®ion=${s3Config.region}`
|
||||
}
|
||||
|
||||
if (s3Config.endpoint) {
|
||||
connectionString += `&endpoint=${s3Config.endpoint}`
|
||||
}
|
||||
|
||||
if (s3Config.disableSSL) {
|
||||
connectionString += '&disableSSL=true'
|
||||
}
|
||||
|
||||
if (s3Config.s3ForcePathStyle) {
|
||||
connectionString += '&s3ForcePathStyle=true'
|
||||
}
|
||||
|
||||
if (s3Config.sseType) {
|
||||
connectionString += `&sseType=${s3Config.sseType}`
|
||||
}
|
||||
|
||||
if (s3Config.kmsKeyId) {
|
||||
connectionString += `&kmskeyid=${s3Config.kmsKeyId}`
|
||||
}
|
||||
|
||||
if (s3Config.fips) {
|
||||
connectionString += '&fips=true'
|
||||
}
|
||||
|
||||
if (s3Config.dualstack) {
|
||||
connectionString += '&dualstack=true'
|
||||
}
|
||||
|
||||
if (s3Config.accelerate) {
|
||||
connectionString += '&accelerate=true'
|
||||
}
|
||||
|
||||
env.push(`HBOX_STORAGE_CONN_STRING=${connectionString}`)
|
||||
|
||||
if (s3Config.prefixPath) {
|
||||
env.push(`HBOX_STORAGE_PREFIX_PATH=${s3Config.prefixPath}`)
|
||||
}
|
||||
|
||||
// AWS credentials
|
||||
env.push(`AWS_ACCESS_KEY_ID=${s3Config.awsAccessKeyId}`)
|
||||
env.push(`AWS_SECRET_ACCESS_KEY=${s3Config.awsSecretAccessKey}`)
|
||||
|
||||
if (s3Config.awsSessionToken) {
|
||||
env.push(`AWS_SESSION_TOKEN=${s3Config.awsSessionToken}`)
|
||||
}
|
||||
break
|
||||
|
||||
case 'gcs':
|
||||
const gcsConfig = config.storageConfig.gcs
|
||||
env.push(`HBOX_STORAGE_CONN_STRING=gcs://${gcsConfig.bucket}`)
|
||||
|
||||
if (gcsConfig.prefixPath) {
|
||||
env.push(`HBOX_STORAGE_PREFIX_PATH=${gcsConfig.prefixPath}`)
|
||||
}
|
||||
|
||||
env.push(`GOOGLE_APPLICATION_CREDENTIALS=${gcsConfig.credentialsPath}`)
|
||||
break
|
||||
|
||||
case 'azure':
|
||||
const azureConfig = config.storageConfig.azure
|
||||
let azureConnectionString = `azblob://${azureConfig.container}`
|
||||
|
||||
if (azureConfig.useEmulator) {
|
||||
azureConnectionString += `?protocol=http&domain=${azureConfig.emulatorEndpoint}`
|
||||
}
|
||||
|
||||
env.push(`HBOX_STORAGE_CONN_STRING=${azureConnectionString}`)
|
||||
|
||||
if (azureConfig.prefixPath) {
|
||||
env.push(`HBOX_STORAGE_PREFIX_PATH=${azureConfig.prefixPath}`)
|
||||
}
|
||||
|
||||
if (!azureConfig.useEmulator) {
|
||||
env.push(`AZURE_STORAGE_ACCOUNT=${azureConfig.storageAccount}`)
|
||||
|
||||
if (azureConfig.sasToken) {
|
||||
env.push(`AZURE_STORAGE_SAS_TOKEN=${azureConfig.sasToken}`)
|
||||
} else {
|
||||
env.push(`AZURE_STORAGE_KEY=${azureConfig.storageKey}`)
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
return env
|
||||
}
|
||||
|
||||
function generateLocalStorageVolumes(config: any): string[] {
|
||||
const volumes: string[] = []
|
||||
|
||||
if (config.storageConfig.local.type === 'volume') {
|
||||
const mountPath = config.storageConfig.local.path || '/data'
|
||||
volumes.push(`${config.storageConfig.local.volumeName}:${mountPath}`)
|
||||
} else {
|
||||
const mountPath = config.storageConfig.local.path || '/data'
|
||||
volumes.push(`${config.storageConfig.local.directory}:${mountPath}`)
|
||||
}
|
||||
|
||||
return volumes
|
||||
}
|
||||
|
||||
function generateCloudStorageVolumes(config: any): string[] {
|
||||
const volumes: string[] = []
|
||||
|
||||
// For cloud storage, we might still need local volumes for certain files like GCS credentials
|
||||
if (config.storageType === 'gcs') {
|
||||
volumes.push('/path/to/gcs-credentials.json:/app/gcs-credentials.json:ro')
|
||||
}
|
||||
|
||||
return volumes
|
||||
}
|
||||
|
||||
function generatePostgresService(config: any): any {
|
||||
const service: any = {
|
||||
image: 'postgres:17-alpine',
|
||||
container_name: 'homebox_postgres',
|
||||
restart: 'unless-stopped',
|
||||
environment: [
|
||||
`POSTGRES_USER=${config.postgresConfig.username}`,
|
||||
`POSTGRES_PASSWORD=${config.postgresConfig.password}`,
|
||||
`POSTGRES_DB=${config.postgresConfig.database}`
|
||||
],
|
||||
networks: ['homebox']
|
||||
}
|
||||
|
||||
if (config.storageConfig.containerStorage.postgresStorage.type === 'volume') {
|
||||
service.volumes = [`${config.storageConfig.containerStorage.postgresStorage.volumeName}:/var/lib/postgresql/data`]
|
||||
} else {
|
||||
service.volumes = [`${config.storageConfig.containerStorage.postgresStorage.directory}:/var/lib/postgresql/data`]
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
function generateTraefikService(config: any): any {
|
||||
const service: any = {
|
||||
image: 'traefik:v3.0',
|
||||
container_name: 'traefik',
|
||||
restart: 'unless-stopped',
|
||||
command: [
|
||||
'--api.dashboard=true',
|
||||
'--providers.docker=true',
|
||||
'--providers.docker.exposedbydefault=false',
|
||||
'--entrypoints.web.address=:80',
|
||||
'--entrypoints.websecure.address=:443',
|
||||
'--certificatesresolvers.letsencrypt.acme.tlschallenge=true',
|
||||
`--certificatesresolvers.letsencrypt.acme.email=${config.traefikConfig.email}`,
|
||||
'--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json'
|
||||
],
|
||||
ports: ['80:80', '443:443'],
|
||||
networks: ['homebox'],
|
||||
labels: [
|
||||
'traefik.enable=true',
|
||||
'traefik.http.routers.traefik.rule=Host(`traefik.${config.traefikConfig.domain}`)',
|
||||
'traefik.http.routers.traefik.entrypoints=websecure',
|
||||
'traefik.http.routers.traefik.tls.certresolver=letsencrypt',
|
||||
'traefik.http.routers.traefik.service=api@internal'
|
||||
]
|
||||
}
|
||||
|
||||
if (config.storageConfig.containerStorage.traefikStorage.type === 'volume') {
|
||||
service.volumes = [
|
||||
'/var/run/docker.sock:/var/run/docker.sock:ro',
|
||||
`${config.storageConfig.containerStorage.traefikStorage.volumeName}:/letsencrypt`
|
||||
]
|
||||
} else {
|
||||
service.volumes = [
|
||||
'/var/run/docker.sock:/var/run/docker.sock:ro',
|
||||
`${config.storageConfig.containerStorage.traefikStorage.directory}:/letsencrypt`
|
||||
]
|
||||
}
|
||||
|
||||
return service
|
||||
}
|
||||
|
||||
function generateNginxService(config: any): any {
|
||||
// This would generate an Nginx service with SSL configuration
|
||||
// Implementation would depend on specific Nginx configuration needs
|
||||
return {
|
||||
image: 'nginx:alpine',
|
||||
container_name: 'nginx',
|
||||
restart: 'unless-stopped',
|
||||
ports: [`${config.nginxConfig.port}:443`, '80:80'],
|
||||
networks: ['homebox']
|
||||
}
|
||||
}
|
||||
|
||||
function generateCaddyService(config: any): any {
|
||||
return {
|
||||
image: 'caddy:alpine',
|
||||
container_name: 'caddy',
|
||||
restart: 'unless-stopped',
|
||||
ports: ['80:80', '443:443'],
|
||||
networks: ['homebox']
|
||||
}
|
||||
}
|
||||
|
||||
function generateCloudflaredService(config: any): any {
|
||||
return {
|
||||
image: 'cloudflare/cloudflared:latest',
|
||||
container_name: 'cloudflared',
|
||||
restart: 'unless-stopped',
|
||||
command: `tunnel --no-autoupdate run --token ${config.cloudflaredConfig.token}`,
|
||||
networks: ['homebox']
|
||||
}
|
||||
}
|
||||
|
||||
// Simple YAML stringifier (basic implementation
|
||||
|
||||
const yaml = {
|
||||
stringify(obj: any, indent = 0, parentKey = "", isTopLevel = true): string {
|
||||
const spaces = ' '.repeat(indent)
|
||||
const nextSpaces = ' '.repeat(indent + 1)
|
||||
if (obj === null || obj === undefined) {
|
||||
return 'null'
|
||||
}
|
||||
if (typeof obj === 'string') {
|
||||
if (parentKey === 'environment') {
|
||||
// Should not be used, handled by stringifyEnv
|
||||
return obj
|
||||
}
|
||||
if (obj.includes(':') || obj.includes('#') || obj.includes('\n') || /^[0-9]/.test(obj) || obj.includes('${')) {
|
||||
return `"${obj.replace(/"/g, '\\"')}"`
|
||||
}
|
||||
return obj
|
||||
}
|
||||
if (typeof obj === 'number' || typeof obj === 'boolean') {
|
||||
return String(obj)
|
||||
}
|
||||
if (Array.isArray(obj)) {
|
||||
if (obj.length === 0) return '[]'
|
||||
if (parentKey === 'environment') {
|
||||
return yaml.stringifyEnv(obj, indent)
|
||||
}
|
||||
// For arrays under object keys, indent dashes at the same level as the parent key's value (spaces)
|
||||
return '\n' + obj.map(item => `${spaces}- ${this.stringify(item, indent + 1, '', false).replace(/^\s+/, '')}`).join('\n')
|
||||
}
|
||||
if (typeof obj === 'object') {
|
||||
const keys = Object.keys(obj)
|
||||
if (keys.length === 0) return '{}'
|
||||
return (isTopLevel ? '' : '\n') + keys.map(key => {
|
||||
const value = this.stringify(obj[key], indent + 1, key, false)
|
||||
// If value is an array, ensure correct indentation
|
||||
if (Array.isArray(obj[key])) {
|
||||
// Place key at current indent, then array items at next indent
|
||||
return `${isTopLevel ? '' : spaces}${key}:${value}`
|
||||
}
|
||||
if (value.startsWith('\n')) {
|
||||
return `${isTopLevel ? '' : spaces}${key}:${value}`
|
||||
}
|
||||
return `${isTopLevel ? '' : spaces}${key}: ${value}`
|
||||
}).join('\n')
|
||||
}
|
||||
return String(obj)
|
||||
},
|
||||
|
||||
stringifyEnv(envArr: string[], indent = 0): string {
|
||||
const spaces = ' '.repeat(indent)
|
||||
return '\n' + envArr.map(env => {
|
||||
const eqIdx = env.indexOf('=')
|
||||
if (eqIdx !== -1) {
|
||||
const key = env.slice(0, eqIdx + 1)
|
||||
let value = env.slice(eqIdx + 1)
|
||||
// Only quote the value if it contains special YAML characters
|
||||
if (value.match(/[:#\n]|^\d|\${/)) {
|
||||
value = `"${value.replace(/"/g, '\\"')}"`
|
||||
}
|
||||
return `${spaces}- ${key}${value}`
|
||||
}
|
||||
return `${spaces}- ${env}`
|
||||
}).join('\n')
|
||||
}
|
||||
}
|
||||
90
docs/.vitepress/components/types.ts
Normal file
90
docs/.vitepress/components/types.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
// types.ts
|
||||
|
||||
export type StorageType = "volume" | "directory"
|
||||
export type HttpsOption = "none" | "traefik" | "nginx" | "caddy" | "cloudflared"
|
||||
export type DatabaseType = "sqlite" | "postgres"
|
||||
|
||||
export interface StorageDetail {
|
||||
type: StorageType
|
||||
directory: string
|
||||
volumeName: string
|
||||
}
|
||||
|
||||
export interface StorageConfig {
|
||||
homeboxStorage: StorageDetail
|
||||
postgresStorage: StorageDetail
|
||||
traefikStorage: StorageDetail
|
||||
nginxStorage: StorageDetail
|
||||
caddyStorage: StorageDetail
|
||||
cloudflaredStorage: StorageDetail
|
||||
}
|
||||
|
||||
export interface PostgresConfig {
|
||||
host: string
|
||||
port: string
|
||||
username: string
|
||||
password: string
|
||||
database: string
|
||||
}
|
||||
|
||||
export interface TraefikConfig {
|
||||
domain: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface NginxConfig {
|
||||
domain: string
|
||||
port: string
|
||||
sslCertPath: string
|
||||
sslKeyPath: string
|
||||
}
|
||||
|
||||
export interface CaddyConfig {
|
||||
domain: string
|
||||
email: string
|
||||
}
|
||||
|
||||
export interface CloudflaredConfig {
|
||||
tunnel: string // Note: This wasn't used in the generator function, but kept for completeness
|
||||
domain: string
|
||||
token: string
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
image: string // Not directly used in generator, but part of the config
|
||||
rootless: boolean
|
||||
port: string
|
||||
logLevel: string
|
||||
logFormat: string
|
||||
maxFileUpload: string
|
||||
allowAnalytics: boolean
|
||||
httpsOption: HttpsOption
|
||||
traefikConfig: TraefikConfig
|
||||
nginxConfig: NginxConfig
|
||||
caddyConfig: CaddyConfig
|
||||
cloudflaredConfig: CloudflaredConfig
|
||||
databaseType: DatabaseType
|
||||
postgresConfig: PostgresConfig
|
||||
allowRegistration: boolean
|
||||
autoIncrementAssetId: boolean
|
||||
checkGithubRelease: boolean
|
||||
storageConfig: StorageConfig
|
||||
}
|
||||
|
||||
// Types for the generated Docker Compose structure
|
||||
export interface DockerService {
|
||||
image: string
|
||||
container_name: string
|
||||
restart: string
|
||||
environment?: string[]
|
||||
volumes: string[]
|
||||
ports?: string[]
|
||||
expose?: string[]
|
||||
labels?: string[]
|
||||
command?: string[]
|
||||
depends_on?: string[]
|
||||
}
|
||||
|
||||
export interface DockerServices {
|
||||
[key: string]: DockerService
|
||||
}
|
||||
@@ -35,13 +35,10 @@ aside: false
|
||||
| HBOX_DATABASE_SQLITE_PATH | ./.data/homebox.db?_pragma=busy_timeout=999&_pragma=journal_mode=WAL&_fk=1 | sets the directory path for Sqlite |
|
||||
| HBOX_DATABASE_HOST | | sets the hostname for a postgres database |
|
||||
| HBOX_DATABASE_PORT | | sets the port for a postgres database |
|
||||
| HBOX_DATABASE_USERNAME | | sets the username for a postgres connection (optional if using cert auth) |
|
||||
| HBOX_DATABASE_PASSWORD | | sets the password for a postgres connection (optional if using cert auth) |
|
||||
| HBOX_DATABASE_USERNAME | | sets the username for a postgres connection |
|
||||
| HBOX_DATABASE_PASSWORD | | sets the password for a postgres connection |
|
||||
| HBOX_DATABASE_DATABASE | | sets the database for a postgres connection |
|
||||
| HBOX_DATABASE_SSL_MODE | | sets the sslmode for a postgres connection |
|
||||
| HBOX_DATABASE_SSL_CERT | | sets the sslcert for a postgres connection (should be a path) |
|
||||
| HBOX_DATABASE_SSL_KEY | | sets the sslkey for a postgres connection (should be a path) |
|
||||
| HBOX_DATABASE_SSL_ROOTCERT | | sets the sslrootcert for a postgres connection (should be a path) |
|
||||
| HBOX_OPTIONS_CHECK_GITHUB_RELEASE | true | check for new github releases |
|
||||
| HBOX_LABEL_MAKER_WIDTH | 526 | width for generated labels in pixels |
|
||||
| HBOX_LABEL_MAKER_HEIGHT | 200 | height for generated labels in pixels |
|
||||
|
||||
@@ -42,28 +42,7 @@ $ docker run -d \
|
||||
|
||||
1. Create a `docker-compose.yml` file.
|
||||
|
||||
```yaml
|
||||
services:
|
||||
homebox:
|
||||
image: ghcr.io/sysadminsmedia/homebox:latest
|
||||
# image: ghcr.io/sysadminsmedia/homebox:latest-rootless
|
||||
container_name: homebox
|
||||
restart: always
|
||||
environment:
|
||||
- HBOX_LOG_LEVEL=info
|
||||
- HBOX_LOG_FORMAT=text
|
||||
- HBOX_WEB_MAX_FILE_UPLOAD=10
|
||||
# Please consider allowing analytics to help us improve Homebox (basic computer information, no personal data)
|
||||
- HBOX_OPTIONS_ALLOW_ANALYTICS=false
|
||||
volumes:
|
||||
- homebox-data:/data/
|
||||
ports:
|
||||
- 3100:7745
|
||||
|
||||
volumes:
|
||||
homebox-data:
|
||||
driver: local
|
||||
```
|
||||
<ConfigEditor />
|
||||
|
||||
::: info
|
||||
If you use the `rootless` image, and instead of using named volumes you would prefer using a hostMount directly (e.g., `volumes: [ /path/to/data/folder:/data ]`) you need to `chown` the chosen directory in advance to the `65532` user (as shown in the Docker example above).
|
||||
@@ -103,3 +82,7 @@ You can learn more about Docker by [reading the official Docker documentation.](
|
||||
2. Extract the archive.
|
||||
3. Run the `homebox` executable.
|
||||
4. The web interface will be accessible on port 7745 by default. Access the page by navigating to `http://local.ip.address:7745/` (replace with the right ip address)
|
||||
|
||||
<script setup>
|
||||
import ConfigEditor from '../.vitepress/components/ConfigEditor.vue'
|
||||
</script>
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
/*
|
||||
X-Frame-Options: DENY
|
||||
X-Content-Type-Options: nosniff
|
||||
Content-Security-Policy: default-src 'self'; script-src 'report-sample' 'unsafe-inline' 'self' https://a.sysadmins.zone/js/embed.host.js https://static.cloudflareinsights.com/beacon.min.js/vcd15cbe7772f49c399c6a5babf22c1241717689176015 https://unpkg.com/@stoplight/elements/web-components.min.js; style-src 'report-sample' 'unsafe-inline' 'self' https://unpkg.com; object-src 'none'; base-uri 'self'; connect-src 'self' https://raw.githubusercontent.com; font-src 'self'; frame-src 'self' https://a.sysadmins.zone; img-src 'self' data: http://translate.sysadminsmedia.com; manifest-src 'self'; media-src 'self'; worker-src 'none';
|
||||
@@ -1,7 +0,0 @@
|
||||
name = "homebox-docs"
|
||||
compatibility_date = "2025-07-12"
|
||||
preview_urls = true
|
||||
|
||||
[assets]
|
||||
directory = ".vitepress/dist"
|
||||
not_found_handling = "single-page-application"
|
||||
@@ -33,16 +33,16 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, computed } from "vue";
|
||||
import { BrowserMultiFormatReader, NotFoundException } from "@zxing/library";
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { Dialog, DialogHeader, DialogScrollContent, DialogTitle } from "@/components/ui/dialog";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
||||
import { Dialog, DialogHeader, DialogTitle, DialogScrollContent } from "@/components/ui/dialog";
|
||||
import { Select, SelectTrigger, SelectValue, SelectContent, SelectItem } from "@/components/ui/select";
|
||||
import MdiAlertCircleOutline from "~icons/mdi/alert-circle-outline";
|
||||
import { useDialog } from "@/components/ui/dialog-provider";
|
||||
|
||||
const { t } = useI18n();
|
||||
const { activeDialog, closeDialog } = useDialog();
|
||||
const { activeDialog } = useDialog();
|
||||
const open = computed(() => activeDialog.value === "scanner");
|
||||
|
||||
const sources = ref<MediaDeviceInfo[]>([]);
|
||||
@@ -129,7 +129,6 @@
|
||||
throw new Error(t("scanner.invalid_url"));
|
||||
}
|
||||
const sanitizedPath = url.pathname.replace(/[^a-zA-Z0-9-_/]/g, "");
|
||||
closeDialog("scanner");
|
||||
navigateTo(sanitizedPath);
|
||||
} catch (err) {
|
||||
loading.value = false;
|
||||
|
||||
@@ -130,14 +130,8 @@
|
||||
}))
|
||||
.filter(i => !modelValue.value.includes(i.value));
|
||||
|
||||
// Only show "Create" option if search term is not empty and no exact match exists
|
||||
if (searchTerm.value.trim() !== "") {
|
||||
const trimmedSearchTerm = searchTerm.value.trim();
|
||||
const hasExactMatch = props.labels.some(label => label.name.toLowerCase() === trimmedSearchTerm.toLowerCase());
|
||||
|
||||
if (!hasExactMatch) {
|
||||
filtered.push({ value: "create-item", label: `${t("global.create")} ${searchTerm.value}` });
|
||||
}
|
||||
filtered.push({ value: "create-item", label: `${t("global.create")} ${searchTerm.value}` });
|
||||
}
|
||||
|
||||
return filtered;
|
||||
|
||||
@@ -55,10 +55,18 @@
|
||||
() => activeDialog.value,
|
||||
active => {
|
||||
if (active === "create-location") {
|
||||
// useTimeoutFn(() => {
|
||||
// focused.value = true;
|
||||
// }, 50);
|
||||
|
||||
if (locationId.value) {
|
||||
const found = locations.value.find(l => l.id === locationId.value);
|
||||
form.parent = found || null;
|
||||
if (found) {
|
||||
form.parent = found;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// focused.value = false;
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -66,6 +74,7 @@
|
||||
function reset() {
|
||||
form.name = "";
|
||||
form.description = "";
|
||||
form.parent = null;
|
||||
focused.value = false;
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -109,7 +118,6 @@
|
||||
if (data) {
|
||||
toast.success(t("components.location.create_modal.toast.create_success"));
|
||||
}
|
||||
|
||||
reset();
|
||||
|
||||
if (close) {
|
||||
|
||||
@@ -1,20 +1,10 @@
|
||||
{
|
||||
"components": {
|
||||
"app": {
|
||||
"create_modal": {
|
||||
"createAndAddAnother": "Brug {shiftKey} + {enterKey} til at oprette og tilføje en ny.",
|
||||
"enter": "Indtast",
|
||||
"shift": "Flytte"
|
||||
},
|
||||
"import_dialog": {
|
||||
"change_warning": "Adfærd for imports med eksisterende import_refs har ændret sig. Hvis en import_ref er tilstede i CSV filen,\nvil genstanden blive opdateret med værdierne fra CSV filen.",
|
||||
"description": "Importer en CSV fil som indeholder dine genstande, etiketter, og lokationer. Se dokumentation for mere information vedrørende\nden korrekte format.",
|
||||
"title": "Importer CSV Fil",
|
||||
"toast": {
|
||||
"import_failed": "Importen mislykkedes. Prøv igen senere.",
|
||||
"import_success": "Importen er gennemført!",
|
||||
"please_select_file": "Vælg venligst en fil, der skal importeres."
|
||||
}
|
||||
"title": "Importer CSV Fil"
|
||||
},
|
||||
"outdated": {
|
||||
"current_version": "Nuværende version",
|
||||
@@ -24,18 +14,6 @@
|
||||
"new_version_available_link": "Klik her for at læse udgivelsesnoterne"
|
||||
}
|
||||
},
|
||||
"color_selector": {
|
||||
"clear": "Reset farve",
|
||||
"color": "Farve",
|
||||
"no_color": "Ingen farve",
|
||||
"no_color_selected": "Ingen farve valgt",
|
||||
"randomize": "Tilfældig farve"
|
||||
},
|
||||
"form": {
|
||||
"password": {
|
||||
"toggle_show": "Slå adgangskode til/fra Vis"
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
"copy_text": {
|
||||
"documentation": "Dokumentation",
|
||||
@@ -73,12 +51,7 @@
|
||||
"download": "Hent label",
|
||||
"print": "Print label",
|
||||
"server_print": "Print på Server",
|
||||
"titles": "Labels",
|
||||
"toast": {
|
||||
"load_status_failed": "Status kunne ikke indlæses",
|
||||
"print_failed": "Kunne ikke udskrive etiketten",
|
||||
"print_success": "Etiket udskrevet"
|
||||
}
|
||||
"titles": "Labels"
|
||||
},
|
||||
"page_qr_code": {
|
||||
"page_url": "Side URL",
|
||||
@@ -89,41 +62,12 @@
|
||||
}
|
||||
},
|
||||
"item": {
|
||||
"attachments_list": {
|
||||
"download": "Download",
|
||||
"open_new_tab": "Åbn i ny fane"
|
||||
},
|
||||
"create_modal": {
|
||||
"delete_photo": "Slet billede",
|
||||
"item_description": "Genstandsbeskrivelse",
|
||||
"item_name": "Genstandsnavn",
|
||||
"item_photo": "Vare Foto 📷",
|
||||
"item_quantity": "Vare Antal",
|
||||
"parent_item": "Overordnet element",
|
||||
"rotate_photo": "Roter foto",
|
||||
"set_as_primary_photo": "Sæt som { isPrimary, select, true {non-} false {} other {}}primært foto",
|
||||
"title": "Opret genstand",
|
||||
"toast": {
|
||||
"already_creating": "Opretter allerede et element",
|
||||
"create_failed": "Kunne ikke oprette elementet",
|
||||
"create_success": "Element oprettet",
|
||||
"failed_load_parent": "Kunne ikke indlæse overordnet element - vælg venligst manuelt",
|
||||
"no_canvas_support": "Din browser understøtter ikke canvas-handlinger",
|
||||
"please_select_location": "Vælg venligst en placering.",
|
||||
"rotate_failed": "Kunne ikke rotere billedet: { error }",
|
||||
"rotate_process_failed": "Kunne ikke behandle roteret billede",
|
||||
"some_photos_failed": "{count, plural, =0 {Ingen billeder at uploade.} =1 {1 billede kunne ikke uploades.} other {Nogle billeder kunne ikke uploades.}}",
|
||||
"upload_failed": "Kunne ikke uploade billede: { photoName }",
|
||||
"upload_success": "{count, plural, =0 {Ingen billeder uploadet.} =1 {Foto uploadet.} other {Alle billeder uploadet.}}",
|
||||
"uploading_photos": "{count, plural, =0 {Ingen billeder at uploade} =1 {Uploader 1 billede…} other {Uploader {count} billeder…}}"
|
||||
},
|
||||
"upload_photos": "Upload Billeder",
|
||||
"uploaded": "Uploadet billede"
|
||||
},
|
||||
"selector": {
|
||||
"no_results": "Ingen resultater fundet",
|
||||
"placeholder": "Vælg…",
|
||||
"search_placeholder": "Skriv for at søge…"
|
||||
"upload_photos": "Upload Billeder"
|
||||
},
|
||||
"view": {
|
||||
"selectable": {
|
||||
@@ -133,26 +77,17 @@
|
||||
"table": "Tabel"
|
||||
},
|
||||
"table": {
|
||||
"headers": "Overskrifter",
|
||||
"page": "Side",
|
||||
"rows_per_page": "Rækker per side",
|
||||
"table_settings": "Tabel Indstillinger",
|
||||
"view_item": "Se vare"
|
||||
"table_settings": "Tabel Indstillinger"
|
||||
}
|
||||
}
|
||||
},
|
||||
"label": {
|
||||
"create_modal": {
|
||||
"label_color": "Etiketfarve",
|
||||
"label_description": "Etiketbeskrivelse",
|
||||
"label_name": "Etiketnavn",
|
||||
"title": "Opret label",
|
||||
"toast": {
|
||||
"already_creating": "Allerede oprettet en etiket",
|
||||
"create_failed": "Kunne ikke oprette etiket",
|
||||
"create_success": "Etiket oprettet",
|
||||
"label_name_too_long": "Etiketnavnet må ikke være længere end 50 tegn"
|
||||
}
|
||||
"title": "Opret label"
|
||||
},
|
||||
"selector": {
|
||||
"select_labels": "Vælg Etiketter"
|
||||
@@ -162,71 +97,47 @@
|
||||
"create_modal": {
|
||||
"location_description": "Lokationsbeskrivelse",
|
||||
"location_name": "Lokationsnavn",
|
||||
"title": "Opret lokation",
|
||||
"toast": {
|
||||
"already_creating": "Allerede oprettet en lokation",
|
||||
"create_failed": "Kunne ikke oprette placering",
|
||||
"create_success": "Placering oprettet"
|
||||
}
|
||||
"title": "Opret lokation"
|
||||
},
|
||||
"selector": {
|
||||
"no_location_found": "Ingen placering fundet",
|
||||
"parent_location": "Forældrelokation",
|
||||
"search_location": "Søg efter placeringer",
|
||||
"select_location": "Vælg en placering"
|
||||
"parent_location": "Forældrelokation"
|
||||
},
|
||||
"tree": {
|
||||
"no_locations": "Ingen tilgængelige placeringer. Tilføj nye placeringer via knappen '<span class=\"link-primary\">'Opret'</span>' på navigationslinjen."
|
||||
"no_locations": "Ingen tilgængelige lokationer. Opret nye lokationer gennem\n`<`span class=\"link-primary\">`Opret`<`/span`>` knappen i navigationslinjen."
|
||||
}
|
||||
},
|
||||
"quick_menu": {
|
||||
"no_results": "Ingen resultater fundet.",
|
||||
"shortcut_hint": "Brug de numeriske taster til hurtigt at vælge en handling."
|
||||
}
|
||||
},
|
||||
"global": {
|
||||
"add": "Tilføj",
|
||||
"archived": "Arkiveret",
|
||||
"build": "Build: { build }",
|
||||
"cancel": "Ophæv",
|
||||
"confirm": "Bekræft",
|
||||
"create": "Opret",
|
||||
"create_and_add": "Opret og tilføj ny",
|
||||
"create_subitem": "Opret underelement",
|
||||
"created": "Oprettet",
|
||||
"delete": "Slet",
|
||||
"delete_confirm": "Er du sikker på, at du vil slette dette element? ",
|
||||
"demo_instance": "Dette er en demo-instans",
|
||||
"details": "Detaljer",
|
||||
"duplicate": "Dupliker",
|
||||
"edit": "Rediger",
|
||||
"email": "Email",
|
||||
"follow_dev": "Følg udvikleren",
|
||||
"footer": {
|
||||
"api_link": "'<a href=\"https://homebox.software/en/api/\" target=\"_blank\">'API'</a>'",
|
||||
"version_link": "'<'a href=\"https://github.com/sysadminsmedia/homebox/releases/tag/{ version }\" target=\"_blank\"'>' Version: { version } Build: { build } '</a>'"
|
||||
},
|
||||
"github": "GitHub projekt",
|
||||
"insured": "Forsikret",
|
||||
"items": "Genstande",
|
||||
"join_discord": "Deltag i vores Discord",
|
||||
"labels": "Etiketter",
|
||||
"loading": "Indlæser…",
|
||||
"locations": "Lokationer",
|
||||
"maintenance": "Opretholdelse",
|
||||
"name": "Navn",
|
||||
"navigate": "Naviger",
|
||||
"password": "Adgangskode",
|
||||
"quantity": "Mængde",
|
||||
"read_docs": "Læs Docs",
|
||||
"return_home": "Vend hjem",
|
||||
"save": "Gem",
|
||||
"search": "Søg",
|
||||
"sign_out": "Log ud",
|
||||
"submit": "Indsend",
|
||||
"unknown": "Ukendt",
|
||||
"update": "Opdater",
|
||||
"updating": "Opdaterer",
|
||||
"value": "Værdi",
|
||||
"version": "Version: { version }",
|
||||
"welcome": "Velkommen, { username }"
|
||||
@@ -251,49 +162,27 @@
|
||||
"set_email": "Hvad er din E-Mail?",
|
||||
"set_name": "Hvad hedder du?",
|
||||
"set_password": "Opret din adgangskode",
|
||||
"tagline": "Følg, Organiser, og Håndter dine Ting.",
|
||||
"title": "Organiser og Tag dine ting",
|
||||
"toast": {
|
||||
"invalid_email": "Ugyldig e-mailadresse",
|
||||
"invalid_email_password": "Ugyldig e-mail eller adgangskode",
|
||||
"login_success": "Logget ind",
|
||||
"problem_registering": "Problem med at registrere bruger",
|
||||
"user_registered": "Bruger registreret"
|
||||
}
|
||||
"tagline": "Følg, Organiser, og Håndter dine Ting."
|
||||
},
|
||||
"items": {
|
||||
"add": "Tilføj",
|
||||
"advanced": "Avanceret",
|
||||
"archived": "Arkiveret",
|
||||
"asset_id": "Aktiv-id",
|
||||
"associated_with_multiple": "Dette aktiv-id er knyttet til flere varer",
|
||||
"attachment": "Vedhæftning",
|
||||
"attachments": "Vedhæftninger",
|
||||
"changes_persisted_immediately": "Ændringer af vedhæftede filer gemmes med det samme",
|
||||
"created_at": "Oprettet den",
|
||||
"custom_fields": "Brugerdefinerede felter",
|
||||
"delete_attachment_confirm": "Er du sikker på, at du vil slette denne vedhæftede fil?",
|
||||
"delete_item_confirm": "Er du sikker på, at du vil slette dette element?",
|
||||
"description": "Beskrivelse",
|
||||
"details": "Detaljer",
|
||||
"drag_and_drop": "Træk og slip filer her, eller klik for at vælge filer",
|
||||
"edit": {
|
||||
"edit_attachment_dialog": {
|
||||
"attachment_title": "Titel på vedhæftet fil",
|
||||
"attachment_type": "Vedhæftningstype",
|
||||
"primary_photo": "Primært foto",
|
||||
"primary_photo_sub": "Denne mulighed er kun tilgængelig for fotos. Kun ét foto kan være primært. Hvis du vælger denne mulighed, vil det aktuelle primære foto, hvis der er et, blive fravalgt.",
|
||||
"select_type": "Vælg en type",
|
||||
"title": "Rediger vedhæftet fil"
|
||||
}
|
||||
},
|
||||
"edit_details": "Rediger detaljer",
|
||||
"field_selector": "Feltvælger",
|
||||
"field_value": "Feltværdi",
|
||||
"first": "Første",
|
||||
"include_archive": "Medtag arkiverede elementer",
|
||||
"insured": "Forsikret",
|
||||
"invalid_asset_id": "Ugyldigt aktiv-ID",
|
||||
"last": "Sidst",
|
||||
"lifetime_warranty": "livstidsgaranti",
|
||||
"location": "Lokalitet",
|
||||
@@ -304,7 +193,6 @@
|
||||
"name": "Navn",
|
||||
"negate_labels": "Ophæv valgte etiketter",
|
||||
"next_page": "Næste side",
|
||||
"no_attachments": "Ingen vedhæftede filer fundet",
|
||||
"no_results": "Ingen elementer fundet",
|
||||
"notes": "Noter",
|
||||
"only_with_photo": "Kun elementer med foto",
|
||||
@@ -326,77 +214,35 @@
|
||||
"receipts": "Kvitteringer",
|
||||
"reset_search": "Nulstil Søgning",
|
||||
"results": "{ total } Wyniki",
|
||||
"select_field": "Vælg et felt",
|
||||
"serial_number": "Serienummer",
|
||||
"show_advanced_view_options": "vis avancerede indstillinger",
|
||||
"sold_at": "Solgt D.",
|
||||
"sold_details": "Salgs detaljer",
|
||||
"sold_price": "Solgt pris",
|
||||
"sold_to": "Sold til",
|
||||
"sync_child_locations": "Synkroniser placeringer af underordnede elementer",
|
||||
"tip_1": "Placerings- og etiketfiltre bruger betjeningen 'ELLER'. Hvis mere end én er valgt, kræves der kun én\n til et match.",
|
||||
"tip_2": "Søgninger med præfikset '#'' vil forespørge efter et aktiv-id (eksempel '#000-001')",
|
||||
"tip_3": "Feltfiltre bruger handlingen 'ELLER'. Hvis mere end én er valgt, kræves der kun én til en\n kamp.",
|
||||
"tips": "Tips",
|
||||
"tips_sub": "Søgetips",
|
||||
"toast": {
|
||||
"asset_not_found": "Aktivet blev ikke fundet",
|
||||
"attachment_deleted": "Vedhæftet fil slettet",
|
||||
"attachment_updated": "Vedhæftet fil opdateret",
|
||||
"attachment_uploaded": "Vedhæftet fil uploadet",
|
||||
"child_items_location_no_longer_synced": "Placeringen af underelementer vil ikke længere blive synkroniseret med dette element.",
|
||||
"child_items_location_synced": "Placeringerne af underordnede elementer er blevet synkroniseret med dette element",
|
||||
"child_location_desync": "Ændring af placering vil afsynkronisere den fra forælderens placering",
|
||||
"error_loading_parent_data": "Noget gik galt under indlæsning af overordnede data",
|
||||
"failed_adjust_quantity": "Kunne ikke justere mængden",
|
||||
"failed_delete_attachment": "Kunne ikke slette vedhæftet fil",
|
||||
"failed_delete_item": "Kunne ikke slette elementet",
|
||||
"failed_duplicate_item": "Kunne ikke duplikere elementet",
|
||||
"failed_load_asset": "Kunne ikke indlæse aktiv",
|
||||
"failed_load_item": "Kunne ikke indlæse elementet",
|
||||
"failed_load_items": "Kunne ikke indlæse elementer",
|
||||
"failed_save": "Kunne ikke gemme elementet",
|
||||
"failed_save_no_location": "Kunne ikke gemme elementet: ingen placering valgt",
|
||||
"failed_search_items": "Kunne ikke søge efter elementer",
|
||||
"failed_update_attachment": "Kunne ikke opdatere vedhæftet fil",
|
||||
"failed_upload_attachment": "Kunne ikke uploade vedhæftet fil",
|
||||
"item_deleted": "Element slettet",
|
||||
"item_saved": "Element gemt",
|
||||
"quantity_cannot_negative": "Mængden må ikke være negativ",
|
||||
"sync_child_location": "Den valgte forælder synkroniserer sine børns placeringer med sine egne. Placeringen er blevet opdateret."
|
||||
},
|
||||
"updated_at": "Opdateret d.",
|
||||
"warranty": "Garanti",
|
||||
"warranty_details": "Oplysninger om garanti",
|
||||
"warranty_expires": "Garantien udløber"
|
||||
},
|
||||
"labels": {
|
||||
"label_delete_confirm": "Er du sikker på, at du vil slette denne etiket? Denne handling kan ikke fortrydes.",
|
||||
"no_results": "Ingen etiketter fundet",
|
||||
"toast": {
|
||||
"failed_delete_label": "Etiketten kunne ikke slettes",
|
||||
"failed_load_label": "Etiketten kunne ikke indlæses",
|
||||
"failed_update_label": "Etiketten kunne ikke opdateres",
|
||||
"label_deleted": "Etiket slettet",
|
||||
"label_updated": "Etiket opdateret"
|
||||
},
|
||||
"update_label": "Opdater etiket"
|
||||
},
|
||||
"languages": {
|
||||
"ca": "Catalansk",
|
||||
"cs-CZ": "Tjekkisk",
|
||||
"de": "Tysk",
|
||||
"en": "Engelsk",
|
||||
"es": "Spansk",
|
||||
"fi-FI": "Finsk",
|
||||
"fr": "Fransk",
|
||||
"hu": "Ungarsk",
|
||||
"id-ID": "Indonesisk",
|
||||
"it": "Italiensk",
|
||||
"ja-JP": "Japansk",
|
||||
"ko-KR": "Koreansk",
|
||||
"lb-LU": "Luxembourgsk (Luxembourg)",
|
||||
"lt-LT": "Litauisk (Litauen)",
|
||||
"nb-NO": "Norsk",
|
||||
"nl": "Hollandsk",
|
||||
"pl": "Polsk",
|
||||
@@ -404,7 +250,6 @@
|
||||
"pt-PT": "Portugisisk (Portugal)",
|
||||
"ru": "Russisk",
|
||||
"sl": "Slovensk",
|
||||
"sq-AL": "Albansk",
|
||||
"sv": "Svensk",
|
||||
"ta-IN": "Tamilsk",
|
||||
"th-TH": "Thailandsk",
|
||||
@@ -421,16 +266,7 @@
|
||||
"locations": {
|
||||
"child_locations": "Underordnede placeringer",
|
||||
"collapse_tree": "Kollaps træ",
|
||||
"expand_tree": "Udvid træ",
|
||||
"location_items_delete_confirm": "Er du sikker på, at du vil slette denne placering og alle dens elementer? Denne handling kan ikke fortrydes.",
|
||||
"no_results": "Ingen placeringer fundet",
|
||||
"toast": {
|
||||
"failed_delete_location": "Kunne ikke slette placeringen",
|
||||
"failed_load_location": "Placeringen kunne ikke indlæses",
|
||||
"failed_update_location": "Kunne ikke opdatere placeringen",
|
||||
"location_deleted": "Placering slettet",
|
||||
"location_updated": "Placering opdateret"
|
||||
},
|
||||
"update_location": "Opdatér sted"
|
||||
},
|
||||
"maintenance": {
|
||||
@@ -486,10 +322,7 @@
|
||||
"currency_format": "Valuta format",
|
||||
"current_password": "Aktuel adgangskode",
|
||||
"delete_account": "Slet Konto",
|
||||
"delete_account_confirm": "Er du sikker på, at du vil slette din konto? Hvis du er det sidste medlem i din gruppe, vil alle dine data blive slettet. Denne handling kan ikke fortrydes.",
|
||||
"delete_account_sub": "Slet din konto og alle dens tilknyttede data. Dette kan ikke laves om.",
|
||||
"delete_notifier_confirm": "Er du sikker på, at du vil slette denne underretter?",
|
||||
"display_legacy_header": "{ currentValue, select, true {Deaktiver Legacy Header} false {Aktiver Legacy Header} other {Ikke ramt}}",
|
||||
"enabled": "Aktiveret",
|
||||
"example": "Eksempel",
|
||||
"gen_invite": "Generer invitationslink",
|
||||
@@ -499,95 +332,39 @@
|
||||
"language": "Sprog",
|
||||
"new_password": "Ny Adgangskode",
|
||||
"no_notifiers": "Ingen notifikationer konfiguret",
|
||||
"no_override": "Ingen tilsidesættelse",
|
||||
"notifier_modal": "{ type, select, true {Rediger} false {Opret} other {Andet}} Meddeler",
|
||||
"notifiers": "Meddelere",
|
||||
"notifiers_sub": "Få notifikationer om kommende vedligeholdelsespåmindelser",
|
||||
"override_locale": "Tilsidesæt dato og valutasprog",
|
||||
"test": "Test",
|
||||
"theme_settings": "Temaindstillinger",
|
||||
"theme_settings_sub": "Temaindstillinger gemmes i din browsers lokale lager. Du kan til enhver tid ændre temaet. Hvis du har\n problemer med at indstille dit tema, kan du prøve at opdatere din browser.",
|
||||
"toast": {
|
||||
"account_deleted": "Din konto er blevet slettet.",
|
||||
"failed_change_password": "Kunne ikke ændre adgangskode.",
|
||||
"failed_create_notifier": "Kunne ikke oprette underretteren.",
|
||||
"failed_delete_account": "Kunne ikke slette din konto.",
|
||||
"failed_delete_notifier": "Kunne ikke slette underretteren.",
|
||||
"failed_get_currencies": "Kunne ikke hente valutaer",
|
||||
"failed_test_notifier": "Kunne ikke teste underretteren.",
|
||||
"failed_update_group": "Gruppen kunne ikke opdateres",
|
||||
"failed_update_notifier": "Kunne ikke opdatere underretteren.",
|
||||
"group_updated": "Gruppen er opdateret",
|
||||
"notifier_test_success": "Underretter-testen er gennemført.",
|
||||
"password_changed": "Adgangskoden er ændret."
|
||||
},
|
||||
"update_group": "Opdatér Gruppe",
|
||||
"update_language": "Opdater sprogfil",
|
||||
"url": "URL",
|
||||
"user_profile": "Brugerprofil",
|
||||
"user_profile_sub": "Inviter brugere, og administrer din konto."
|
||||
},
|
||||
"reports": {
|
||||
"label_generator": {
|
||||
"asset_end": "Aktivets slut",
|
||||
"asset_start": "Aktivets start",
|
||||
"base_url": "Base URL",
|
||||
"bordered_labels": "Etiketter med kant",
|
||||
"generate_page": "Generer side",
|
||||
"input_placeholder": "Skriv her",
|
||||
"instruction_1": "Homebox Label Generator er et værktøj, der hjælper dig med at udskrive etiketter til dit Homebox-lager. Disse er beregnet til\nat være etiketter, der kan udskrives på forhånd, så du kan udskrive mange etiketter og have dem klar til påsætning.",
|
||||
"instruction_2": "Disse etiketter fungerer derfor ved at udskrive en URL, QR-kode og AssetID-oplysninger på en etiket. Hvis du har deaktiveret\nAssetID'er i dine Homebox-indstillinger, kan du stadig bruge dette værktøj, men AssetID'erne vil ikke referere til nogen varer.",
|
||||
"instruction_3": "Denne funktion er i de tidlige udviklingsfaser og kan ændres i fremtidige udgivelser. Hvis du har feedback, bedes\ndu give den i '<a href=\"https://github.com/sysadminsmedia/homebox/discussions/53\">'GitHub-diskussionen'</a>'",
|
||||
"label_height": "Etikethøjde",
|
||||
"label_width": "Etiketbredde",
|
||||
"measure_type": "Målingstype",
|
||||
"page_bottom_padding": "Sidebundspolstring",
|
||||
"page_height": "Sidehøjde",
|
||||
"page_left_padding": "Venstre sidepolstring",
|
||||
"page_right_padding": "Højre sidepolstring",
|
||||
"page_top_padding": "Sidetoppolstring",
|
||||
"page_width": "Sidebredde",
|
||||
"qr_code_example": "Eksempel på QR-kode",
|
||||
"tip_1": "Standardindstillingerne her er konfigureret for\n'<a href=\"https://www.avery.com/templates/5260\">'Avery 5260 etiketark'</a>'. Hvis du bruger et andet ark,\nskal du justere indstillingerne, så de passer til dit ark.",
|
||||
"tip_2": "Hvis du tilpasser dit ark, er dimensionerne i tommer. Da jeg byggede 5260-arket, opdagede jeg, at de\ndimensioner, der blev brugt i deres skabelon, ikke matchede det, der var nødvendigt for at udskrive i felterne.\n'<b>'Vær forberedt på nogle forsøg og fejl.'</b>'",
|
||||
"tip_3": "Ved printning sørg for at:\n'<ol><li>'Indstil margenerne til 0 eller Ingen'</li><li>'Indstil skaleringen til 100%'</li><li>'Deaktiver dobbeltsidet udskrivning'</li><li>'Udskriv en testside, før du udskriver flere sider'</li></ol>'",
|
||||
"tips": "Tips",
|
||||
"title": "Etiketgenerator",
|
||||
"toast": {
|
||||
"page_too_small_card": "Sidestørrelsen er for lille til kortstørrelsen"
|
||||
}
|
||||
}
|
||||
},
|
||||
"scanner": {
|
||||
"error": "Der skete en fejl under skanningen",
|
||||
"invalid_url": "Ugyldig stregkode-URL",
|
||||
"no_sources": "Ingen videokilder er tilgængelig",
|
||||
"permission_denied": "Kameratilladelse nægtet, Tillad venligst adgang til kameraet i dine browserindstillinger",
|
||||
"select_video_source": "Vælg en videokilde",
|
||||
"title": "Skanner",
|
||||
"unsupported": "Media Stream API understøttes ikke uden HTTPS"
|
||||
},
|
||||
"tools": {
|
||||
"actions": "Handlinger på lagerbeholdning",
|
||||
"actions_set": {
|
||||
"create_missing_thumbnails": "Opret manglende miniaturebilleder",
|
||||
"create_missing_thumbnails_button": "Opret miniaturebilleder",
|
||||
"create_missing_thumbnails_confirm": "Er du sikker på, at du vil oprette manglende miniaturebilleder? Dette kan tage et stykke tid og kan ikke sættes på pause.",
|
||||
"create_missing_thumbnails_sub": "Opretter miniaturebilleder for alle vedhæftede filer, der understøttes af den aktuelle konfiguration. Dette er nyttigt for vedhæftede filer, der blev uploadet før v0.20.0-udgivelsen af Homebox. Dette overskriver ikke eksisterende miniaturebilleder, men opretter kun nye for vedhæftede filer, der ikke har et miniaturebillede. Bemærk, at miniaturebillederne oprettes i baggrunden og kan tage et stykke tid at fuldføre.",
|
||||
"ensure_ids": "Sørg for aktiv-id'er",
|
||||
"ensure_ids_button": "Sørg for aktiv-id'er",
|
||||
"ensure_ids_confirm": "Er du sikker på, at du vil sikre dig, at alle aktiver har et ID? Dette kan tage et stykke tid og kan ikke fortrydes.",
|
||||
"ensure_ids_sub": "Sikrer, at alle varer på lageret har et gyldigt asset_id felt. Dette gøres ved at finde det højeste aktuelle aktiv_id felt i databasen og anvende den næste værdi på hvert element, der har et ikke sat aktiv_id felt. Dette gøres i rækkefølge efter feltet opret_den.",
|
||||
"ensure_import_refs": "Sørg for importreferencer",
|
||||
"ensure_import_refs_button": "Sørg for importreferencer",
|
||||
"ensure_import_refs_sub": "Sikrer, at alle varer på lageret har et gyldigt import_ref felt. Dette gøres ved tilfældigt at generere en streng på 8 tegn for hvert element, der har et uindstillet import_ref felt.",
|
||||
"set_primary_photo": "Indstil primært foto",
|
||||
"set_primary_photo_button": "Indstil primært foto",
|
||||
"set_primary_photo_confirm": "Er du sikker på, at du vil indstille primære billeder? Dette kan tage et stykke tid og kan ikke fortrydes.",
|
||||
"set_primary_photo_sub": "I version v0.10.0 af Homebox blev det primære billedfelt tilføjet til vedhæftede filer af typen foto. Denne handling indstiller det primære billedfelt til det første billede i matrixen for vedhæftede filer i databasen, hvis det ikke allerede er angivet. '<a class=\"link\" href=\"https://github.com/hay-kot/homebox/pull/576\">'Se GitHub PR #576'</a>'",
|
||||
"zero_datetimes": "Nul Vare Dato Tider",
|
||||
"zero_datetimes_button": "Nul Varedato Tider",
|
||||
"zero_datetimes_confirm": "Er du sikker på, at du vil nulstille alle dato- og klokkeslætsværdier? Dette kan tage et stykke tid og kan ikke fortrydes.",
|
||||
"zero_datetimes_sub": "Nulstiller klokkeslætsværdien for alle dato- og klokkeslætsfelter i lageret til begyndelsen af datoen. Dette er for at rette en fejl, der blev introduceret tidligt i udviklingen af webstedet, der forårsagede, at tidsværdien blev gemt med tiden, hvilket forårsagede problemer med datofelter, der viste nøjagtige værdier. '<a class=\"link\" href=\"https://github.com/hay-kot/homebox/issues/236\" target=\"_blank\">'Se Github-udgave #236 for flere detaljer.'</a>'"
|
||||
},
|
||||
"actions_sub": "Anvend flere handlinger på din beholdning på én gang. Det er uigenkaldelige handlinger. '<b>'Vær forsigtig.'</b>'",
|
||||
@@ -598,7 +375,6 @@
|
||||
"export_sub": "Eksporterer standard CSV-formatet til Homebox. Dette vil eksportere alle varer i dit lager.",
|
||||
"import": "Importeret beholdning",
|
||||
"import_button": "Importer beholdning",
|
||||
"import_ref_confirm": "Er du sikker på, at du vil sikre dig, at alle aktiver har en import_ref? Dette kan tage et stykke tid og kan ikke fortrydes.",
|
||||
"import_sub": "Importerer standard CSV-formatet til Homebox. Uden en '<code>'HB.import_ref'</code>'-kolonne vil dette '<b>'ikke'</b>' overskrive eksisterende genstande i dit lager, kun tilføje nye genstande. Rækker med kolonnen \"<code>HB.import_ref\"</code> flettes ind i eksisterende elementer med samme import_ref, hvis der findes en."
|
||||
},
|
||||
"import_export_sub": "Importér og eksporter din lagerbeholdning til og fra en CSV-fil. Dette er nyttigt til at migrere dit lager til en ny forekomst af Homebox.",
|
||||
@@ -611,14 +387,6 @@
|
||||
"bill_of_materials_button": "Generer stykliste",
|
||||
"bill_of_materials_sub": "Genererer en CSV-fil (kommaseparerede værdier), der kan importeres til et regnearksprogram. Dette er en oversigt over din beholdning med grundlæggende vare- og prisoplysninger."
|
||||
},
|
||||
"reports_sub": "Generer forskellige rapporter for dit lager.",
|
||||
"toast": {
|
||||
"asset_success": "Aktiverne i { results } er blevet opdateret.",
|
||||
"failed_create_missing_thumbnails": "Kunne ikke oprette manglende miniaturebilleder.",
|
||||
"failed_ensure_ids": "Kunne ikke sikre aktiv-ID'er.",
|
||||
"failed_ensure_import_refs": "Kunne ikke sikre importreferencer.",
|
||||
"failed_set_primary_photos": "Kunne ikke indstille primære billeder.",
|
||||
"failed_zero_datetimes": "Dato- og klokkeslætsværdier kunne ikke nulstilles."
|
||||
}
|
||||
"reports_sub": "Generer forskellige rapporter for dit lager."
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,11 +24,6 @@
|
||||
"new_version_available_link": "Cliquez ici pour consulter les notes de version"
|
||||
}
|
||||
},
|
||||
"color_selector": {
|
||||
"color": "Couleur",
|
||||
"no_color_selected": "Aucune couleur sélectionnée",
|
||||
"randomize": "Couleur aléatoire"
|
||||
},
|
||||
"form": {
|
||||
"password": {
|
||||
"toggle_show": "Activer/désactiver l'affichage du mot de passe"
|
||||
@@ -134,8 +129,7 @@
|
||||
"headers": "En-têtes",
|
||||
"page": "Page",
|
||||
"rows_per_page": "Lignes par page",
|
||||
"table_settings": "Paramètres du Tableau",
|
||||
"view_item": "Afficher l'élément"
|
||||
"table_settings": "Paramètres du Tableau"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -567,10 +561,6 @@
|
||||
"tools": {
|
||||
"actions": "Actions d’inventaire",
|
||||
"actions_set": {
|
||||
"create_missing_thumbnails": "Crée les miniatures manquantes",
|
||||
"create_missing_thumbnails_button": "Crée les miniatures",
|
||||
"create_missing_thumbnails_confirm": "Êtes-vous sûr de vouloir créer les vignettes manquantes ? Cette opération peut prendre un certain temps et ne peut pas être interrompue.",
|
||||
"create_missing_thumbnails_sub": "Crée des miniatures pour toutes les pièces jointes prises en charge par la configuration actuelle. Ceci est utile pour les pièces jointes importées avant la version 0.20.0 de Homebox. Cette opération n'écrase pas les miniatures existantes, mais crée de nouvelles miniatures pour les pièces jointes sans miniature. Veuillez noter que la création des miniatures s'effectue en arrière-plan et peut prendre un certain temps.",
|
||||
"ensure_ids": "Vérifier les ID de ressources",
|
||||
"ensure_ids_button": "Vérifier les ID de ressources",
|
||||
"ensure_ids_confirm": "Êtes-vous certain de vous assurer que toutes les ressources ont une ID ? Cela peut prendre du temps et est irréversible.",
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
"item_photo": "Objektfoto",
|
||||
"item_quantity": "Objektantall",
|
||||
"parent_item": "Overordnet enhet",
|
||||
"rotate_photo": "Roter bilde",
|
||||
"rotate_photo": "Roter bile",
|
||||
"set_as_primary_photo": "Sett som { isPrimary, select, true {non-} false {} other {}}primary photo",
|
||||
"title": "Opprett objekt",
|
||||
"toast": {
|
||||
@@ -110,7 +110,7 @@
|
||||
"failed_load_parent": "Klarte ikke å laster overordnet enhet, vennligst velg en manuelt",
|
||||
"no_canvas_support": "Nettleseren din støtter ikke canvas-operasjoner",
|
||||
"please_select_location": "Vennligst velg en lokasjon.",
|
||||
"rotate_failed": "Fikk ikke til å rotere bildet: { error }",
|
||||
"rotate_failed": "Fikk ikke å rotere bildet: { error }",
|
||||
"rotate_process_failed": "Fikk ikke prosessert rotert bilde",
|
||||
"some_photos_failed": "{count, plural, =0 {Ingen bilder å laste opp.} =1 {1 fikk ikke lastet opp bilde.} other {Noen bilder ble ikke lastet opp.}}",
|
||||
"upload_failed": "Fikk ikke lastet opp bildet: { photoName }",
|
||||
@@ -121,9 +121,9 @@
|
||||
"uploaded": "Bilde lastet opp"
|
||||
},
|
||||
"selector": {
|
||||
"no_results": "Ingen resultat funnet",
|
||||
"placeholder": "Velg…",
|
||||
"search_placeholder": "Skriv for å søke…"
|
||||
"no_results": "Ikke funnet resultat",
|
||||
"placeholder": "Velg...",
|
||||
"search_placeholder": "Skriv for å søke..."
|
||||
},
|
||||
"view": {
|
||||
"selectable": {
|
||||
@@ -149,7 +149,7 @@
|
||||
"title": "Opprett merkelapp",
|
||||
"toast": {
|
||||
"already_creating": "Opprettelse av etikett pågår allerede",
|
||||
"create_failed": "Kunne ikke opprette etikett",
|
||||
"create_failed": "Fikk ikke opprettet etikett",
|
||||
"create_success": "Etikett opprettet",
|
||||
"label_name_too_long": "Etikettnavn kan ikke overstige 50 tegn"
|
||||
}
|
||||
@@ -195,7 +195,7 @@
|
||||
"create_subitem": "Opprett underenhet",
|
||||
"created": "Opprettet",
|
||||
"delete": "Slett",
|
||||
"delete_confirm": "Er du sikker på at du ønsker å slette denne enheten? ",
|
||||
"delete_confirm": "Vil du virkelig slette denne enheten? ",
|
||||
"demo_instance": "Dette er et demomiljø",
|
||||
"details": "Detaljer",
|
||||
"duplicate": "Dupliser",
|
||||
@@ -368,7 +368,7 @@
|
||||
"updated_at": "Oppdatert den",
|
||||
"warranty": "Garanti",
|
||||
"warranty_details": "Garantidetaljer",
|
||||
"warranty_expires": "Garanti utløper"
|
||||
"warranty_expires": "Garanti upløper"
|
||||
},
|
||||
"labels": {
|
||||
"label_delete_confirm": "Er du sikker på at du vil slette denne etiketten? Denne handlingen kan ikke angres.",
|
||||
|
||||
@@ -574,7 +574,7 @@
|
||||
"create_missing_thumbnails_button": "Vytvoriť náhľady",
|
||||
"create_missing_thumbnails_confirm": "Naozaj chcete vytvoriť chýbajúce náhľady? Môže to chvíľku trvať a akciu nie je možné pozastaviť.",
|
||||
"create_missing_thumbnails_sub": "Vytvorí miniatúry pre všetky prílohy, ktoré sú podporované aktuálnou konfiguráciou. Toto je užitočné pre prílohy, ktoré boli nahrané pred vydaním Homeboxu v0.20.0. Toto neprepíše existujúce miniatúry, vytvorí nové iba pre prílohy, ktoré nemajú miniatúru. Upozorňujeme, že miniatúry sa vytvárajú na pozadí a ich vytvorenie môže chvíľu trvať.",
|
||||
"ensure_ids": "Zabezpečenie identifikátorov položiek",
|
||||
"ensure_ids": "Zabezpečenie identifikátorov aktív",
|
||||
"ensure_ids_button": "Zabezpečenie identifikátorov aktív",
|
||||
"ensure_ids_confirm": "Naozaj chcete zabezpečiť, aby všetky prvky mali ID? Môže to chvíľu trvať a nedá sa to vrátiť späť.",
|
||||
"ensure_ids_sub": "Zabezpečí, aby všetky položky vo vašom inventári mali platné pole asset_id. To sa dosiahne tak, že sa v databáze zistí najvyššie aktuálne pole asset_id a na každú položku, ktorá nemá nastavené pole asset_id sa použije ďalšia hodnota. Toto sa vykonáva v poradí podľa poľa created_at.",
|
||||
@@ -604,10 +604,10 @@
|
||||
"import_export_sub": "Importujte a exportujte svoj inventár do a zo súboru CSV. To je užitočné pri migrácii inventára do novej inštancie Homeboxu.",
|
||||
"reports": "Správy",
|
||||
"reports_set": {
|
||||
"asset_labels": "Identifikačné štítky položiek",
|
||||
"asset_labels": "Štítky ID diela",
|
||||
"asset_labels_button": "Generátor štítkov",
|
||||
"asset_labels_sub": "Generuje tlačiteľné PDF štítkov pre rad Asset ID. Tieto nie sú špecifické pre váš inventár, takže štítky si môžete vytlačiť vopred a aplikovať ich na váš inventár, keď ich dostanete.",
|
||||
"bill_of_materials": "Súpis položiek",
|
||||
"bill_of_materials": "Faktúra za materiály",
|
||||
"bill_of_materials_button": "Generovať kusovník",
|
||||
"bill_of_materials_sub": "Vygeneruje súbor CSV (Comma Separated Values), ktorý možno importovať do tabuľkového procesora. Toto je súhrn vášho inventára so základnými informáciami o položkách a cenách."
|
||||
},
|
||||
|
||||
@@ -571,9 +571,6 @@
|
||||
"actions": "Dejanja inventarja",
|
||||
"actions_set": {
|
||||
"create_missing_thumbnails": "Ustvari manjkajoče predoglede",
|
||||
"create_missing_thumbnails_button": "Ustvari Predoglede",
|
||||
"create_missing_thumbnails_confirm": "Ali ste prepričani, da želite ustvariti manjkajoče predoglede? To lahko traja nekaj časa in ni možno ustaviti.",
|
||||
"create_missing_thumbnails_sub": "Ustvari predoglede za vse priloge, ki jih omogoča trenutna konfiguracija. To je uporabno za priloge, ki so bile naložene pred Homebox izdajo v0.20.0. To ne bo prepisalo obstoječih predogledov, ampak samo izdelalo nove za priloge ki še nimajo predogledov. Upoštevajte da so predogledi izdelani v ozadju in da to lahko traja nekaj časa.",
|
||||
"ensure_ids": "Zagotovi ID-je sredstev",
|
||||
"ensure_ids_button": "Zagotovi ID-je sredstev",
|
||||
"ensure_ids_confirm": "Ali ste prepričani, da želite zagotoviti, da imajo vsi predmeti ID? To lahko traja nekaj časa in ni možno razveljaviti.",
|
||||
@@ -614,7 +611,6 @@
|
||||
"reports_sub": "Ustvari različna poročila za svoj inventar.",
|
||||
"toast": {
|
||||
"asset_success": "{ results } predmetov je bilo posodobljenih.",
|
||||
"failed_create_missing_thumbnails": "Izdelava manjkajočih predogledov ni uspela.",
|
||||
"failed_ensure_ids": "Zagotavljanje IDjev predmetov ni uspelo.",
|
||||
"failed_ensure_import_refs": "Zagotavljanje import_ref ni uspelo.",
|
||||
"failed_set_primary_photos": "Nastavljanje primarnih fotografij ni uspelo.",
|
||||
|
||||
@@ -294,7 +294,7 @@
|
||||
if (preferences.value.showEmpty) {
|
||||
return true;
|
||||
}
|
||||
return item.value?.lifetimeWarranty || validDate(item.value?.warrantyExpires);
|
||||
return validDate(item.value?.warrantyExpires);
|
||||
});
|
||||
|
||||
const warrantyDetails = computed(() => {
|
||||
|
||||
715
pnpm-lock.yaml
generated
715
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user