Compare commits

..

2 Commits

Author SHA1 Message Date
copilot-swe-agent[bot]
f1fc4e75d7 Fix warranty section visibility when lifetime warranty is enabled
Co-authored-by: tankerkiller125 <3457368+tankerkiller125@users.noreply.github.com>
2025-07-07 13:07:14 +00:00
copilot-swe-agent[bot]
a9ba2bc4aa Initial plan 2025-07-07 12:57:37 +00:00
33 changed files with 279 additions and 1557 deletions

View File

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

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

View File

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

View File

@@ -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/')) && secrets.DOCKER_USERNAME != ''
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/')) && secrets.DOCKER_USERNAME != ''
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/')) && secrets.DOCKER_USERNAME != ''
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)

View File

@@ -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/')) && secrets.DOCKER_USERNAME != ''
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@@ -152,7 +152,7 @@ jobs:
- name: Login to Docker Hub
uses: docker/login-action@v3
if: (github.event_name == 'schedule' || startsWith(github.ref, 'refs/tags/'))
if: secrets.DOCKER_USERNAME != ''
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
@@ -195,7 +195,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/')) && secrets.DOCKER_USERNAME != ''
run: |
docker buildx imagetools create $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \
$(printf '${{ env.DOCKERHUB_REPO }}@sha256:%s ' *)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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."
}
}

View File

@@ -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 dinventaire",
"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.",

View File

@@ -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.",

View File

@@ -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."
},

View File

@@ -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.",