Proof-of-concept for upload & import
This commit is contained in:
parent
3433199c08
commit
60e9346f08
10 changed files with 299 additions and 52 deletions
|
@ -175,6 +175,7 @@ windsor tie:
|
|||
|
||||
chainlink fence:
|
||||
label: fence
|
||||
priority: -1
|
||||
|
||||
mitten:
|
||||
label: unknown
|
||||
|
@ -2727,6 +2728,7 @@ photocopier:
|
|||
- office
|
||||
|
||||
picket fence:
|
||||
priority: -1
|
||||
categories:
|
||||
- fence
|
||||
|
||||
|
@ -3128,6 +3130,7 @@ wooden spoon:
|
|||
|
||||
worm fence:
|
||||
label: fence
|
||||
priority: -1
|
||||
|
||||
wreck:
|
||||
categories:
|
||||
|
@ -3513,3 +3516,15 @@ wool:
|
|||
|
||||
ear:
|
||||
priority: -1
|
||||
|
||||
sunglass:
|
||||
label: sunglasses
|
||||
priority: 2
|
||||
|
||||
packet:
|
||||
label: package
|
||||
priority: 1
|
||||
|
||||
swing:
|
||||
label: relax
|
||||
priority: 1
|
||||
|
|
|
@ -1,38 +1,136 @@
|
|||
<template>
|
||||
<div>
|
||||
<v-toolbar flat color="blue-grey lighten-4">
|
||||
<h1>Import</h1>
|
||||
</v-toolbar>
|
||||
<v-container fluid>
|
||||
<v-form>
|
||||
<p class="md-subheading">
|
||||
You have two possibilities to get your photos into photoprism.</p>
|
||||
<h2>Import & Index</h2>
|
||||
<p>Importing means the photos you upload are renamed (the naming schema you can define in settings), and moved to the originals folder sorted by year and month.
|
||||
Additionally duplicates are removed and images get tagged and metadata (like location, camera model etc.) will be extracted. In case you have not supported file types
|
||||
(e.g. videos) within the folder you import --> those are ignored. </p>
|
||||
<v-btn color="success" @click="$refs.inputUpload.click()" type="file" class="importbtn">Import & Index</v-btn>
|
||||
<input v-show="false" ref="inputUpload" type="file" >
|
||||
<div class="p-page p-page-import">
|
||||
<v-form ref="form" class="p-photo-import" lazy-validation @submit.prevent="submit" dense>
|
||||
<input type="file" ref="upload" multiple @change.stop="upload()" class="d-none">
|
||||
|
||||
<v-toolbar flat color="blue-grey lighten-4">
|
||||
<v-toolbar-title>Import</v-toolbar-title>
|
||||
|
||||
<v-spacer></v-spacer>
|
||||
|
||||
<v-btn icon @click.stop="uploadDialog()">
|
||||
<v-icon>cloud_upload</v-icon>
|
||||
</v-btn>
|
||||
</v-toolbar>
|
||||
|
||||
<v-container fluid>
|
||||
<v-progress-linear color="accent" v-model="completed"></v-progress-linear>
|
||||
|
||||
<v-container grid-list-xs fluid class="pa-0 p-photos p-photo-mosaic">
|
||||
<p class="subheading" v-if="uploads.length === 0">
|
||||
Select photos to start import...
|
||||
</p>
|
||||
|
||||
<v-layout row wrap>
|
||||
<v-flex
|
||||
v-for="(file, index) in uploads"
|
||||
:key="index"
|
||||
class="p-photo"
|
||||
xs4 sm3 md2 lg1 d-flex
|
||||
>
|
||||
<v-card tile class="elevation-2 ma-2">
|
||||
<v-img :src="file.data"
|
||||
aspect-ratio="1"
|
||||
:title="file.name"
|
||||
class="grey lighten-2"
|
||||
>
|
||||
<v-layout
|
||||
slot="placeholder"
|
||||
fill-height
|
||||
align-center
|
||||
justify-center
|
||||
ma-0
|
||||
>
|
||||
<v-progress-circular indeterminate
|
||||
color="grey lighten-5"></v-progress-circular>
|
||||
</v-layout>
|
||||
</v-img>
|
||||
</v-card>
|
||||
</v-flex>
|
||||
</v-layout>
|
||||
|
||||
<p class="subheading" v-if="completed === 100">
|
||||
Done.
|
||||
</p>
|
||||
</v-container>
|
||||
</v-container>
|
||||
</v-form>
|
||||
</v-container>
|
||||
<v-container fluid>
|
||||
<v-form>
|
||||
<h2>Index</h2>
|
||||
<p>In case you already have a nice folder structure you can only index the photos. Therefore in settings you need to set the base directory to the directory your photos
|
||||
are in. The index functionality will then just tag the images and extract the metadata.
|
||||
</p>
|
||||
<v-btn color="success" @click="$refs.inputUpload.click()" type="file" class="importbtn">Index</v-btn>
|
||||
</v-form>
|
||||
</v-container>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import axios from "axios";
|
||||
import Event from "pubsub-js";
|
||||
|
||||
export default {
|
||||
name: 'import',
|
||||
props: {},
|
||||
name: 'p-page-import',
|
||||
data() {
|
||||
return {}
|
||||
return {
|
||||
selected: [],
|
||||
uploads: [],
|
||||
busy: false,
|
||||
current: 0,
|
||||
total: 0,
|
||||
completed: 0,
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
submit() {
|
||||
console.log("SUBMIT");
|
||||
},
|
||||
uploadDialog() {
|
||||
this.$refs.upload.click();
|
||||
},
|
||||
upload() {
|
||||
this.$alert.info("Uploading photos...");
|
||||
|
||||
Event.publish("ajax.start");
|
||||
|
||||
this.selected = this.$refs.upload.files;
|
||||
this.busy = true;
|
||||
this.total = this.selected.length;
|
||||
this.current = 0;
|
||||
this.completed = 0;
|
||||
|
||||
async function performUpload(ctx) {
|
||||
for (let i = 0; i < ctx.selected.length; i++) {
|
||||
ctx.current = i + 1;
|
||||
ctx.completed = Math.round((ctx.current / ctx.total) * 100);
|
||||
let file = ctx.selected[i];
|
||||
let formData = new FormData();
|
||||
|
||||
formData.append('files', file);
|
||||
|
||||
if (file.type.match('image.*')) {
|
||||
const reader = new FileReader;
|
||||
|
||||
reader.onload = e => {
|
||||
ctx.uploads.push({name: file.name, data: e.target.result});
|
||||
};
|
||||
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
await axios.post('/api/v1/upload',
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data'
|
||||
}
|
||||
}
|
||||
).then(function () {
|
||||
}).catch(function () {
|
||||
Event.publish("alert.error", "Upload failed");
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
performUpload(this).then(() => {
|
||||
Event.publish("ajax.end");
|
||||
Event.publish("alert.success", "Photos uploaded and imported");
|
||||
this.busy = false;
|
||||
});
|
||||
},
|
||||
}
|
||||
};
|
||||
</script>
|
||||
|
|
|
@ -1,5 +1,5 @@
|
|||
<template>
|
||||
<div>
|
||||
<div class="p-page p-page-todo">
|
||||
<v-toolbar flat color="blue-grey lighten-4">
|
||||
<v-toolbar-title>Not implemented yet</v-toolbar-title>
|
||||
|
||||
|
@ -20,7 +20,7 @@
|
|||
|
||||
<script>
|
||||
export default {
|
||||
name: 'todo',
|
||||
name: 'p-page-todo',
|
||||
data() {
|
||||
return {};
|
||||
},
|
||||
|
|
|
@ -3,6 +3,7 @@ import Places from "pages/places.vue";
|
|||
import Labels from "pages/labels.vue";
|
||||
import Events from "pages/events.vue";
|
||||
import People from "pages/people.vue";
|
||||
import Import from "pages/import.vue";
|
||||
import Share from "pages/share.vue";
|
||||
import Settings from "pages/settings.vue";
|
||||
import Todo from "pages/todo.vue";
|
||||
|
@ -65,7 +66,7 @@ export default [
|
|||
{
|
||||
name: "Import",
|
||||
path: "/import",
|
||||
component: Todo,
|
||||
component: Import,
|
||||
meta: {area: "Import"},
|
||||
},
|
||||
{
|
||||
|
|
|
@ -2,6 +2,7 @@ package api
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/util"
|
||||
|
@ -25,7 +26,7 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
|
|||
|
||||
if !ok {
|
||||
log.Errorf("invalid type: %s", typeName)
|
||||
c.Data(400, "image/svg+xml", photoIconSvg)
|
||||
c.Data(http.StatusBadRequest, "image/svg+xml", photoIconSvg)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -33,7 +34,7 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
|
|||
file, err := search.FindFileByHash(fileHash)
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(404, gin.H{"error": err.Error()})
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -41,7 +42,7 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
|
|||
|
||||
if !util.Exists(fileName) {
|
||||
log.Errorf("could not find original for thumbnail: %s", fileName)
|
||||
c.Data(404, "image/svg+xml", photoIconSvg)
|
||||
c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg)
|
||||
|
||||
// Set missing flag so that the file doesn't show up in search results anymore
|
||||
file.FileMissing = true
|
||||
|
@ -59,7 +60,7 @@ func GetThumbnail(router *gin.RouterGroup, conf *config.Config) {
|
|||
c.File(thumbnail)
|
||||
} else {
|
||||
log.Errorf("could not create thumbnail: %s", err)
|
||||
c.Data(400, "image/svg+xml", photoIconSvg)
|
||||
c.Data(http.StatusBadRequest, "image/svg+xml", photoIconSvg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
@ -79,7 +80,7 @@ func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) {
|
|||
|
||||
if !ok {
|
||||
log.Errorf("invalid type: %s", typeName)
|
||||
c.Data(400, "image/svg+xml", photoIconSvg)
|
||||
c.Data(http.StatusBadRequest, "image/svg+xml", photoIconSvg)
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -92,7 +93,7 @@ func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) {
|
|||
// log.Infof("Label thumb file: %#v", file)
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(404, gin.H{"error": util.UcFirst(err.Error())})
|
||||
c.AbortWithStatusJSON(http.StatusNotFound, gin.H{"error": util.UcFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
|
@ -100,7 +101,7 @@ func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) {
|
|||
|
||||
if !util.Exists(fileName) {
|
||||
log.Errorf("could not find original for thumbnail: %s", fileName)
|
||||
c.Data(404, "image/svg+xml", photoIconSvg)
|
||||
c.Data(http.StatusNotFound, "image/svg+xml", photoIconSvg)
|
||||
|
||||
// Set missing flag so that the file doesn't show up in search results anymore
|
||||
file.FileMissing = true
|
||||
|
@ -118,7 +119,7 @@ func LabelThumbnail(router *gin.RouterGroup, conf *config.Config) {
|
|||
c.File(thumbnail)
|
||||
} else {
|
||||
log.Errorf("could not create thumbnail: %s", err)
|
||||
c.Data(400, "image/svg+xml", photoIconSvg)
|
||||
c.Data(http.StatusBadRequest, "image/svg+xml", photoIconSvg)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
80
internal/api/upload.go
Normal file
80
internal/api/upload.go
Normal file
|
@ -0,0 +1,80 @@
|
|||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/util"
|
||||
uuid "github.com/satori/go.uuid"
|
||||
log "github.com/sirupsen/logrus"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/photoprism/photoprism/internal/photoprism"
|
||||
)
|
||||
|
||||
var importer *photoprism.Importer
|
||||
|
||||
func initImporter(conf *config.Config) {
|
||||
if importer != nil {
|
||||
return
|
||||
}
|
||||
|
||||
tensorFlow := photoprism.NewTensorFlow(conf)
|
||||
|
||||
indexer := photoprism.NewIndexer(conf, tensorFlow)
|
||||
|
||||
converter := photoprism.NewConverter(conf)
|
||||
|
||||
importer = photoprism.NewImporter(conf, indexer, converter)
|
||||
}
|
||||
|
||||
// POST /api/v1/upload
|
||||
func Upload(router *gin.RouterGroup, conf *config.Config) {
|
||||
router.POST("/upload", func(c *gin.Context) {
|
||||
start := time.Now()
|
||||
|
||||
form, err := c.MultipartForm()
|
||||
|
||||
if err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": util.UcFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
log.Debugf("Value: %#v", form.Value)
|
||||
log.Debugf("File: %#v", form.File)
|
||||
|
||||
files := form.File["files"]
|
||||
|
||||
path := fmt.Sprintf("%s/uploads/%s", conf.ImportPath(), uuid.NewV4())
|
||||
|
||||
if err := os.MkdirAll(path, os.ModePerm); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": util.UcFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
|
||||
for _, file := range files {
|
||||
filename := fmt.Sprintf("%s/%s", path, filepath.Base(file.Filename))
|
||||
|
||||
if err := c.SaveUploadedFile(file, filename); err != nil {
|
||||
c.AbortWithStatusJSON(http.StatusBadRequest, gin.H{"error": util.UcFirst(err.Error())})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
log.Infof("importing photos from %s", conf.ImportPath())
|
||||
|
||||
initImporter(conf)
|
||||
|
||||
importer.ImportPhotosFromDirectory(conf.ImportPath())
|
||||
|
||||
elapsed := time.Since(start)
|
||||
|
||||
log.Infof("%d files imported in %s", len(files), elapsed)
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{"message": fmt.Sprintf("%d files imported in %s", len(files), elapsed)})
|
||||
})
|
||||
}
|
|
@ -11,6 +11,7 @@ import (
|
|||
"github.com/jinzhu/gorm"
|
||||
"github.com/photoprism/photoprism/internal/config"
|
||||
"github.com/photoprism/photoprism/internal/models"
|
||||
"github.com/photoprism/photoprism/internal/util"
|
||||
)
|
||||
|
||||
const (
|
||||
|
@ -176,22 +177,30 @@ func (i *Indexer) indexMediaFile(mediaFile *MediaFile) string {
|
|||
if len(labels) > 0 && labels[0].Priority >= -1 && labels[0].Uncertainty <= 60 && labels[0].Name != "" { // TODO: User defined title format
|
||||
log.Infof("label for title: %#v", labels[0])
|
||||
if location.LocCity == "" || len(location.LocCity) > 16 || strings.Contains(labels[0].Name, location.LocCity) {
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", strings.Title(labels[0].Name), location.LocCountry, photo.TakenAt.Format("2006"))
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", util.Title(labels[0].Name), location.LocCountry, photo.TakenAt.Format("2006"))
|
||||
} else {
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", strings.Title(labels[0].Name), location.LocCity, photo.TakenAt.Format("2006"))
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", util.Title(labels[0].Name), location.LocCity, photo.TakenAt.Format("2006"))
|
||||
}
|
||||
} else if location.LocName != "" && location.LocCity != "" {
|
||||
if len(location.LocName) > 45 {
|
||||
photo.PhotoTitle = strings.Title(location.LocName)
|
||||
photo.PhotoTitle = util.Title(location.LocName)
|
||||
} else if len(location.LocName) > 20 || len(location.LocCity) > 16 || strings.Contains(location.LocName, location.LocCity) {
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s", strings.Title(location.LocName), photo.TakenAt.Format("2006"))
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s", util.Title(location.LocName), photo.TakenAt.Format("2006"))
|
||||
} else {
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", strings.Title(location.LocName), location.LocCity, photo.TakenAt.Format("2006"))
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", util.Title(location.LocName), location.LocCity, photo.TakenAt.Format("2006"))
|
||||
}
|
||||
} else if location.LocCity != "" && location.LocCountry != "" {
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCity, location.LocCountry, photo.TakenAt.Format("2006"))
|
||||
if len(location.LocCity) > 20 {
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s", location.LocCity, photo.TakenAt.Format("2006"))
|
||||
} else {
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCity, location.LocCountry, photo.TakenAt.Format("2006"))
|
||||
}
|
||||
} else if location.LocCounty != "" && location.LocCountry != "" {
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCounty, location.LocCountry, photo.TakenAt.Format("2006"))
|
||||
if len(location.LocCounty) > 20 {
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s", location.LocCounty, photo.TakenAt.Format("2006"))
|
||||
} else {
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s / %s", location.LocCounty, location.LocCountry, photo.TakenAt.Format("2006"))
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
|
@ -200,7 +209,7 @@ func (i *Indexer) indexMediaFile(mediaFile *MediaFile) string {
|
|||
|
||||
if photo.PhotoTitleChanged == false && photo.PhotoTitle == "" {
|
||||
if len(labels) > 0 && labels[0].Priority >= -1 && labels[0].Uncertainty <= 85 && labels[0].Name != "" {
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s", strings.Title(labels[0].Name), mediaFile.DateCreated().Format("2006"))
|
||||
photo.PhotoTitle = fmt.Sprintf("%s / %s", util.Title(labels[0].Name), mediaFile.DateCreated().Format("2006"))
|
||||
} else {
|
||||
var daytimeString string
|
||||
hour := mediaFile.DateCreated().Hour()
|
||||
|
|
|
@ -8,6 +8,7 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/photoprism/photoprism/internal/models"
|
||||
"github.com/photoprism/photoprism/internal/util"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
|
@ -90,12 +91,11 @@ func (m *MediaFile) Location() (*models.Location, error) {
|
|||
location.LocLong = lon
|
||||
}
|
||||
|
||||
locationName := strings.ReplaceAll(openstreetmapLocation.Name, " - ", " / ")
|
||||
locationName = strings.Title(strings.TrimSpace(strings.ReplaceAll(locationName, "_", " ")))
|
||||
if len(openstreetmapLocation.Name) > 1 {
|
||||
location.LocName = strings.ReplaceAll(openstreetmapLocation.Name, " - ", " / ")
|
||||
location.LocName = util.Title(strings.TrimSpace(strings.ReplaceAll(location.LocName, "_", " ")))
|
||||
}
|
||||
|
||||
locationCategory := strings.TrimSpace(strings.ReplaceAll(openstreetmapLocation.Category, "_", " "))
|
||||
|
||||
location.LocName = locationName
|
||||
location.LocHouseNr = strings.TrimSpace(openstreetmapLocation.Address.HouseNumber)
|
||||
location.LocStreet = strings.TrimSpace(openstreetmapLocation.Address.Road)
|
||||
location.LocSuburb = strings.TrimSpace(openstreetmapLocation.Address.Suburb)
|
||||
|
@ -105,6 +105,8 @@ func (m *MediaFile) Location() (*models.Location, error) {
|
|||
location.LocCountry = strings.TrimSpace(openstreetmapLocation.Address.Country)
|
||||
location.LocCountryCode = strings.TrimSpace(openstreetmapLocation.Address.CountryCode)
|
||||
location.LocDisplayName = strings.TrimSpace(openstreetmapLocation.DisplayName)
|
||||
|
||||
locationCategory := strings.TrimSpace(strings.ReplaceAll(openstreetmapLocation.Category, "_", " "))
|
||||
location.LocCategory = locationCategory
|
||||
|
||||
if openstreetmapLocation.Type != "yes" && openstreetmapLocation.Type != "unclassified" {
|
||||
|
|
|
@ -29,6 +29,8 @@ func registerRoutes(router *gin.Engine, conf *config.Config) {
|
|||
api.LikeLabel(v1, conf)
|
||||
api.DislikeLabel(v1, conf)
|
||||
api.LabelThumbnail(v1, conf)
|
||||
|
||||
api.Upload(v1, conf)
|
||||
}
|
||||
|
||||
// Default HTML page (client-side routing implemented via Vue.js)
|
||||
|
|
|
@ -1,12 +1,51 @@
|
|||
package util
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
// isSeparator reports whether the rune could mark a word boundary.
|
||||
func isSeparator(r rune) bool {
|
||||
// ASCII alphanumerics and underscore are not separators
|
||||
if r <= 0x7F {
|
||||
switch {
|
||||
case '0' <= r && r <= '9':
|
||||
return false
|
||||
case 'a' <= r && r <= 'z':
|
||||
return false
|
||||
case 'A' <= r && r <= 'Z':
|
||||
return false
|
||||
case r == '_', r == '\'':
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
// Letters and digits are not separators
|
||||
if unicode.IsLetter(r) || unicode.IsDigit(r) {
|
||||
return false
|
||||
}
|
||||
// Otherwise, all we can do for now is treat spaces as separators.
|
||||
return unicode.IsSpace(r)
|
||||
}
|
||||
|
||||
func UcFirst(str string) string {
|
||||
for i, v := range str {
|
||||
return string(unicode.ToUpper(v)) + str[i+1:]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func Title(s string) string {
|
||||
prev := ' '
|
||||
return strings.Map(
|
||||
func(r rune) rune {
|
||||
if isSeparator(prev) {
|
||||
prev = r
|
||||
return unicode.ToTitle(r)
|
||||
}
|
||||
prev = r
|
||||
return r
|
||||
},
|
||||
s)
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue